agents
Version:
A home for your AI agents
410 lines (409 loc) • 16.6 kB
JavaScript
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