UNPKG

agents

Version:

A home for your AI agents

410 lines (409 loc) • 16.6 kB
import "./settings-Dk2il2Hf.js"; import { t as abortable } from "./abortable-C1SpfVoi.js"; //#region src/web/search/contract.ts /** * The `web_search` tool contract: its name, what it tells the model, what the * model passes in, what comes back, and how results are rendered for the * model. Shared by every executor of the tool — the harness-executed * adapters in `agents/websearch/*` today, and a gateway-executed variant in * `agents/models/*` once AI Gateway server tools ship — so a `web_search` call * looks the same in a transcript whoever ran it. */ /** * The tool's name, as the model calls it and as it appears in transcripts. * `web_search` is the name models know from Anthropic's and OpenAI's built-in * search tools. */ const WEB_SEARCH_TOOL_NAME = "web_search"; /** The default number of results when the model doesn't ask for a count. */ const DEFAULT_WEB_SEARCH_LIMIT = 5; /** The most results one search can return; the API rejects more. */ const MAX_WEB_SEARCH_LIMIT = 10; /** The longest query the API accepts, in characters. */ const MAX_WEB_SEARCH_QUERY_LENGTH = 1024; /** * Per-result description length in the model's view, in characters. Provider * descriptions run to ~8,000 characters (Ceramic returns page text), which at * ten results is 80 KB of context per search. The full text stays in the * host-side output. */ const DEFAULT_WEB_SEARCH_DESCRIPTION_CHARS = 600; const WEB_SEARCH_TOOL_DESCRIPTION = [ "Search the public web. Returns up to `limit` results, each with a URL, a title, and a short description of the page.", "Use it for current events, facts you're unsure of, documentation, and anything that may have changed since your training.", "There is no next page: for more or different results, search again with a rephrased query.", "Results are discovery only: to read a page, fetch its URL with `web_fetch` if it is available." ].join(" "); /** * Render search results as the text the model reads: one numbered block per * result, descriptions trimmed and whitespace collapsed. */ function renderWebSearchResults(response, options = {}) { const maxChars = options.maxDescriptionChars ?? 600; if (response.items.length === 0) return `No results for "${response.metadata.query}". Try a broader or rephrased query.`; const blocks = response.items.map((item, index) => { const lines = [`${index + 1}. ${item.title.trim() || item.url}`, item.url]; const description = compactDescription(item.description, maxChars); if (description) lines.push(description); if (item.lastModifiedDate) lines.push(`Modified: ${item.lastModifiedDate}`); return lines.join("\n"); }); return [ `${response.items.length} result${response.items.length === 1 ? "" : "s"} for "${response.metadata.query}":`, "", blocks.join("\n\n") ].join("\n"); } function compactDescription(description, maxChars) { if (!description) return void 0; const compact = description.replace(/\s+/g, " ").trim(); if (!compact) return void 0; if (compact.length <= maxChars) return compact; const cut = compact.slice(0, Math.max(0, maxChars - 1)); const space = cut.lastIndexOf(" "); const end = space >= cut.length - WORD_BOUNDARY_BACKOFF ? space : cut.length; return `${cut.slice(0, end).trimEnd()}…`; } /** How far truncation backs off to end on a whole word. */ const WORD_BOUNDARY_BACKOFF = 20; //#endregion //#region src/web/search/source.ts /** * Where searches run. A {@link WebSearchSource} turns a query into a * {@link WebSearchResponse}; the tool core is written against it, so the * same tool works over the Workers AI binding, the HTTP API, or a fake in * tests. */ /** * Search through the Workers AI binding. The Worker's own account and the * named gateway are billed. */ function createAIWebSearch(options) { validateGatewayOptions(options); const binding = options.binding; const search = async (input, call = {}) => { const request = validateRequest(input); if (typeof binding.websearch !== "function") throw new WebSearchError("This Workers runtime has no env.AI.websearch(). Web search needs workerd 1.20260924.1 or later; see https://github.com/cloudflare/agents/blob/main/docs/agents/search-the-web.md.", { status: 501, code: "web_search_unsupported_runtime", retryable: false }); call.signal?.throwIfAborted(); return readResponse(await abortable(binding.websearch({ gatewayId: options.gateway ?? "default", query: request.query, limit: request.limit, provider: options.provider, byokAlias: options.byokAlias }), call.signal), request.query); }; return { search, provider: options.provider }; } /** * Search through `POST /accounts/{account_id}/ai/websearch`. Use this * outside Workers, or to search through a gateway in another account. */ function createHTTPWebSearch(options) { validateGatewayOptions(options); const doFetch = options.fetch ?? fetch; const url = `${(options.baseUrl ?? "https://api.cloudflare.com").replace(/\/+$/, "")}/client/v4/accounts/${encodeURIComponent(options.accountId)}/ai/websearch/`; const search = async (input, call = {}) => { const request = validateRequest(input); return readResponse(await doFetch(url, { method: "POST", signal: call.signal, headers: { Authorization: `Bearer ${options.apiToken}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: request.query, limit: request.limit, provider: options.provider, byokAlias: options.byokAlias, options: { gateway: { id: options.gateway ?? "default" } } }) }), request.query); }; return { search, provider: options.provider }; } /** The API's pattern for `byokAlias`. */ const BYOK_ALIAS_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** Host configuration errors are programming errors: throw when building. */ function validateGatewayOptions(options) { if (options.byokAlias !== void 0 && !BYOK_ALIAS_PATTERN.test(options.byokAlias)) throw new RangeError(`byokAlias must be 1–64 letters, digits, "_" or "-"; got ${JSON.stringify(options.byokAlias)}.`); } /** Why a search failed, with what the API said. */ var WebSearchError = class extends Error { constructor(message, details) { super(message, { cause: details.cause }); this.name = "WebSearchError"; this.status = details.status; this.code = details.code; this.retryable = details.retryable ?? isRetryableStatus(details.status); this.requestId = details.requestId; this.apiCode = details.apiCode; } }; /** Check a request against the API's limits and fill in the default limit. */ function validateRequest(request) { const query = request.query.trim(); const limit = request.limit ?? 5; if (query.length === 0) throw new WebSearchError("Query must not be empty.", { status: 400, code: "invalid_web_search_input" }); if (query.length > 1024) throw new WebSearchError(`Query must be at most ${MAX_WEB_SEARCH_QUERY_LENGTH} characters.`, { status: 400, code: "invalid_web_search_input" }); if (!Number.isInteger(limit) || limit < 1 || limit > 10) throw new WebSearchError(`Limit must be an integer from 1 to 10.`, { status: 400, code: "invalid_web_search_input" }); return { query, limit }; } async function readResponse(response, query) { const text = await response.text(); let body; try { body = JSON.parse(text); } catch { body = void 0; } if (!response.ok) throw toWebSearchError(response.status, body, text); const result = toWebSearchResponse(body, query); if (!result) throw new WebSearchError(`Web search returned HTTP ${response.status} without search results${text ? `: ${text.slice(0, 200)}` : ""}.`, { status: response.status, code: "web_search_unavailable", retryable: true }); return result; } /** * Read a 200 body into a {@link WebSearchResponse}, tolerating what a beta * API and three providers might send: items without a URL are dropped, a * missing title falls back to the URL, unknown or mistyped fields are left * out, and missing metadata gets defaults. Only a body with no `items` * array is rejected. */ function toWebSearchResponse(body, query) { if (!isRecord(body) || !Array.isArray(body.items)) return void 0; const items = []; for (const item of body.items) { if (!isRecord(item)) continue; const url = asString(item.url)?.trim(); if (!url) continue; const result = { url, title: asString(item.title)?.trim() || url }; for (const key of OPTIONAL_RESULT_FIELDS) { const value = asString(item[key]); if (value !== void 0) result[key] = value; } items.push(result); } const metadata = isRecord(body.metadata) ? body.metadata : {}; return { items, metadata: { query: asString(metadata.query) ?? query, requestId: asString(metadata.requestId) ?? "", latencyMs: typeof metadata.latencyMs === "number" ? metadata.latencyMs : 0 } }; } const OPTIONAL_RESULT_FIELDS = [ "description", "lastModifiedDate", "imageUrl", "faviconUrl" ]; /** * The API fails in three shapes. Gateway-native: * `{ ok: false, error: { category, code, status, retryable, gatewayRequestId } }`. * Request validation, the Cloudflare envelope with issue details: * `{ success: false, errors: [{ code: 7000, message }], messages: [{ message, path }] }`. * Gateway configuration (`AiGatewayError`): * `{ success: false, error: [{ code: 2001, message }], message, description }`. */ function toWebSearchError(status, body, text) { if (isRecord(body)) { const error = body.error; if (isRecord(error) && typeof error.code === "string") { const errorStatus = typeof error.status === "number" ? error.status : status; return new WebSearchError(describeCode(error.code, errorStatus), { status: errorStatus, code: error.code, retryable: typeof error.retryable === "boolean" ? error.retryable : void 0, requestId: asString(error.gatewayRequestId) }); } const entries = (Array.isArray(body.errors) ? body.errors : Array.isArray(error) ? error : []).filter(isRecord); const issues = Array.isArray(body.messages) ? body.messages : []; const message = [...entries.map((entry) => asString(entry.message)), ...issues.map(describeIssue)].filter((m) => Boolean(m)).join("; ") || asString(body.message); if (message) { const apiCode = entries.map((entry) => entry.code).find((code) => typeof code === "number"); return new WebSearchError(message, { status, code: codeForApiError(apiCode, status), apiCode }); } } return new WebSearchError(`Web search failed with HTTP ${status}${text ? `: ${text.slice(0, 200)}` : ""}.`, { status, code: codeForStatus(status) }); } function codeForApiError(apiCode, status) { switch (apiCode) { case 7e3: return "invalid_web_search_input"; case 2001: return "web_search_gateway_not_configured"; default: return codeForStatus(status); } } function codeForStatus(status) { if (status === 400) return "invalid_web_search_input"; if (status === 401 || status === 403) return "web_search_unauthorized"; if (status === 402) return "web_search_payment_required"; if (status === 429) return "web_search_rate_limited"; return "web_search_unavailable"; } function isRetryableStatus(status) { return status === 429 || status >= 500; } function describeCode(code, status) { switch (code) { case "web_search_payment_required": return "Web search is unavailable: the AI Gateway has no credits and no provider key for this search. Top up AI Gateway credits or store a BYOK key for the provider."; case "web_search_byok_not_configured": return "Web search is unavailable: the requested BYOK key alias is not configured on the gateway."; case "invalid_web_search_input": return "The API rejected the request: check the query (1–1024 characters), limit (1–10), and provider (ceramic, exa, or linkup)."; default: return `Web search failed (${code}, HTTP ${status}).`; } } function describeIssue(issue) { if (!isRecord(issue)) return void 0; const message = asString(issue.message); if (!message) return void 0; const path = Array.isArray(issue.path) ? issue.path.join(".") : ""; return path ? `${path}: ${message}` : message; } function isRecord(value) { return typeof value === "object" && value !== null; } function asString(value) { return typeof value === "string" ? value : void 0; } //#endregion //#region src/web/search/tool.ts /** * The harness-neutral core of the `web_search` tool, shared by the pi * (`agents/websearch/pi`), AI SDK (`agents/websearch/ai-sdk`), and TanStack * AI (`agents/websearch/tanstack-ai`) adapters. Internal — not an entry point. */ /** The default for {@link WebSearchToolOptions.timeoutMs}. */ const DEFAULT_WEB_SEARCH_TIMEOUT_MS = 3e4; function createWebSearchToolCore(options) { const limit = options.limit ?? 5; const timeoutMs = options.timeoutMs ?? 3e4; const { maxDescriptionChars } = options; if (!Number.isInteger(limit) || limit < 1 || limit > 10) throw new RangeError(`limit must be an integer from 1 to 10; got ${limit}.`); if (maxDescriptionChars !== void 0 && maxDescriptionChars !== Infinity && !(Number.isInteger(maxDescriptionChars) && maxDescriptionChars > 0)) throw new RangeError(`maxDescriptionChars must be a positive integer or Infinity; got ${maxDescriptionChars}.`); if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new RangeError(`timeoutMs must be a positive integer; got ${timeoutMs}.`); const source = options.source === void 0 ? createAIWebSearch(options) : options.source; const provider = source.provider; const render = (output) => renderWebSearchResults(output, { maxDescriptionChars }); return { name: WEB_SEARCH_TOOL_NAME, description: options.description ?? WEB_SEARCH_TOOL_DESCRIPTION, limit, render, async run(input, { signal } = {}) { signal?.throwIfAborted(); const request = { query: input.query, limit: Math.min(clampLimit(input.limit ?? limit), limit) }; const timeout = AbortSignal.timeout(timeoutMs); const searchSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; try { const response = await abortable(source.search(request, { signal: searchSignal }), searchSignal); return { ok: true, output: provider ? { ...response, provider } : response, text: render(response) }; } catch (cause) { if (signal?.aborted) throw signal.reason; const error = toToolError(cause, timeout.aborted, timeoutMs); return { ok: false, error, text: describeFailureForModel(error) }; } } }; } /** * The error a throwing adapter (AI SDK, TanStack AI) raises for a failed * run. Those frameworks show the model the error's `message`, so it carries * the model's text; the source's error, with the API's detail, is `cause`. */ function toolFailure(run) { const { error } = run; return new WebSearchError(run.text, { status: error.status, code: error.code, retryable: error.retryable, requestId: error.requestId, apiCode: error.apiCode, cause: error }); } function toToolError(cause, timedOut, timeoutMs) { if (timedOut) return new WebSearchError(`Web search timed out after ${timeoutMs} ms.`, { status: 504, code: "web_search_timeout", retryable: true, cause }); if (cause instanceof WebSearchError) return cause; return new WebSearchError(cause instanceof Error ? cause.message : String(cause), { status: 500, code: "web_search_unavailable", retryable: true, cause }); } /** * What the model reads when a search fails: whether to retry, change the * query, or carry on without search. The operator's detail (credits, keys, * gateway setup) stays on the error for the host. */ function describeFailureForModel(error) { if (error.code === "invalid_web_search_input") return `Web search rejected the query (${error.message.replace(/\.$/, "")}). Fix the query and try again.`; if (error.retryable) return `Web search failed temporarily (${error.code}). You may retry once.`; return `Web search is unavailable here (${error.code}). Do not retry; answer without it and say that web search was unavailable.`; } /** The model's `limit`, made an integer from 1 to 10. */ function clampLimit(value) { if (!Number.isFinite(value)) return 5; return Math.min(10, Math.max(1, Math.trunc(value))); } //#endregion export { createAIWebSearch as a, DEFAULT_WEB_SEARCH_LIMIT as c, WEB_SEARCH_TOOL_DESCRIPTION as d, WEB_SEARCH_TOOL_NAME as f, WebSearchError as i, MAX_WEB_SEARCH_LIMIT as l, createWebSearchToolCore as n, createHTTPWebSearch as o, renderWebSearchResults as p, toolFailure as r, DEFAULT_WEB_SEARCH_DESCRIPTION_CHARS as s, DEFAULT_WEB_SEARCH_TIMEOUT_MS as t, MAX_WEB_SEARCH_QUERY_LENGTH as u }; //# sourceMappingURL=tool-DkT_DJBm.js.map