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
5 changes: 5 additions & 0 deletions .changeset/ai-sdk-harness-tool-model-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

`AiSdkHarness` passes its tools to `convertToModelMessages`, so a tool's `toModelOutput` also shapes its results on later turns instead of the model getting the raw output as JSON.
5 changes: 5 additions & 0 deletions .changeset/websearch-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Add `agents/websearch`, a `web_search` tool over Cloudflare's [Web Search API](https://developers.cloudflare.com/web-search/) for the pi harness, the AI SDK, and TanStack AI. See [Search the Web](https://github.com/cloudflare/agents/blob/main/docs/agents/search-the-web.md).
1 change: 1 addition & 0 deletions docs/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ The differentiator is not "we have durable state" — it is what happens when a
## Compute Environments

- [Browse the Web (Experimental)](./browse-the-web.md) - Full CDP access for web inspection, scraping, and debugging
- [Search the Web (Beta)](./search-the-web.md) - A `websearch` tool over Cloudflare's Web Search API for the pi harness, the AI SDK, and TanStack AI
- TODO: [Cloudflare Sandboxes](./sandboxes.md) - Isolated environments for coding agents, ffmpeg, and heavy compute

## Advanced Topics
Expand Down
133 changes: 133 additions & 0 deletions docs/agents/search-the-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Search the Web (Beta)

`agents/websearch` gives a model a `web_search` tool over Cloudflare's [Web Search API](https://developers.cloudflare.com/web-search/), called through the `AI` binding and billed by the account's AI Gateway. The same tool is available for the pi harness, the AI SDK, and TanStack AI.
Comment thread
ben-reitz marked this conversation as resolved.

This page covers what the SDK adds on top of the API. For the binding, the providers, pricing, payment, and the error codes, see the [Web Search API docs](https://developers.cloudflare.com/web-search/).

> **Beta** — this feature may have breaking changes in future releases.
## Quick Start

You need:

- An `AI` binding: `"ai": { "binding": "AI" }` in `wrangler.jsonc`.
- workerd 1.20260924.1 or later, which is where `env.AI.websearch()` arrived. It ships with wrangler 4.141.0 and `@cloudflare/vite-plugin` 1.60.2.
- AI Gateway credits or a provider key on the gateway, in the account the Worker runs in. That account pays for every search. The `AI` binding always calls Cloudflare, so under `wrangler dev` searches run against, and bill, the account you are logged in to.

Then add the tool to your harness. Every adapter takes the same options; the TanStack AI adapter also takes `name`, because TanStack AI tools carry their name in the definition.

Pi harness:

```ts
import { webSearchTool } from "agents/websearch/pi";

this.registry.install({
name: "tools",
tools: [webSearchTool({ binding: this.env.AI, provider: "exa" })]
});
```

AI SDK:

```ts
import { webSearchTool } from "agents/websearch/ai-sdk";

const result = streamText({
model,
tools: { web_search: webSearchTool({ binding: this.env.AI }) },
messages
});
```

If you convert UI messages yourself, pass the same tools to `convertToModelMessages(messages, { tools })`. Without them, the AI SDK sends earlier search results back to the model as the full JSON response instead of the trimmed text. `AiSdkHarness` does this for you.

TanStack AI:

```ts
import { webSearchTool } from "agents/websearch/tanstack-ai";

const tools = [webSearchTool({ binding: this.env.AI })];
```

## Options

The model's input is `{ query, limit? }` and nothing else. The host fixes everything that affects cost or data handling. An invalid `limit`, `maxDescriptionChars`, `timeoutMs`, or `byokAlias` throws a `RangeError` when the tool is created:

| Option | Default | Notes |
| --------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `binding` | — | The `AI` binding. Or pass `source` instead (see [Other sources](#other-sources)). |
| `gateway` | `"default"` | AI Gateway id. |
| `provider` | platform default | `"ceramic"`, `"exa"`, or `"linkup"`. The model cannot choose or change it. |
| `byokAlias` | — | Bill a provider key stored on the gateway. Passed through as the API defines it. |
| `limit` | `5` | Results per search when the model does not ask for a count, and the most it gets when it asks for more. The tool description tells the model this number. |
| `maxDescriptionChars` | `600` | Per-result description length in the model's view. `Infinity` passes descriptions through whole. |
| `description` | built-in description | Replaces the tool description the model sees. |
| `timeoutMs` | `30000` | Give up on a search after this long, as a retryable `web_search_timeout` failure. |

## Model Interface

The model gets text: a numbered list of title, URL, and description, with descriptions trimmed to `maxDescriptionChars`. Some providers return descriptions of several thousand characters per result, so the default keeps a five-result search to a few kilobytes of context.

```
3 results for "cloudflare web search api":
1. Introducing the Web Search API
https://blog.cloudflare.com/introducing-web-search-api/
Today we are launching the Web Search API in open beta…
2. …
```

The host gets the full API response, without trimming, as `WebSearchToolOutput`: `items` with their whole descriptions and every documented field, `metadata` with `requestId` and `latencyMs`, plus `provider`. Items without a URL are dropped, a missing title falls back to the URL, and fields the API does not document are left out:

- **Pi**: in the tool result's `details`, as `{ ok: true, output }`. The tool is `replay: "safe"`: if a search is interrupted mid-call, for example by an eviction, pi runs it again when the session recovers, and that is a second billed search. Completed results are stored and not searched again.
- **AI SDK**: as the return value of `execute`, so `onFinish`, UI message parts, and logs see the full response. `toModelOutput` renders the text for the model.
- **TanStack AI**: the server tool returns the rendered text, so the host gets the same text the model does.

`renderWebSearchResults(output, { maxDescriptionChars })` from `agents/websearch` is the renderer, if you want the same text elsewhere.

The API has no pagination. The tool description tells the model to search again with a rephrased query when it wants more or different results.

## Failures

A failed search becomes a `WebSearchError` with `status`, `code`, `retryable`, and `requestId` (AI Gateway's id for the request, for the gateway log). Every error has a `code`: the API's own when it sends one, for example `web_search_payment_required`, otherwise one derived from the HTTP status, such as `web_search_rate_limited` or `web_search_unavailable`. On a runtime older than the one above, the binding has no `websearch()` and the search fails with code `web_search_unsupported_runtime`.

The error's `message` is written for you, and can say to top up credits or configure a key. The model gets different text that tells it what to do next: fix the query, retry once, or carry on without search.

- **Pi**: the tool returns an error result with the model's text, and `details` is `{ ok: false, message, status, code, retryable, requestId }`.
- **AI SDK** and **TanStack AI**: the tool throws a `WebSearchError`, which is how those frameworks report tool errors. Its `message` is the model's text, and its `cause` is the original error with the API's detail.

Every adapter entry point re-exports `WebSearchError`, so `instanceof` checks do not need a second import.

If the harness cancels the call, the search is aborted and the abort propagates instead of becoming a failed result.

## Other sources

`createAIWebSearch` and `createHTTPWebSearch` from `agents/websearch` return a `WebSearchSource`: an object whose `search({ query, limit? }, { signal? })` returns the API response (`limit` defaults to 5), with no model involved. Use them from scheduled jobs, or outside Workers:

```ts
import { createHTTPWebSearch } from "agents/websearch";

const search = createHTTPWebSearch({
accountId: env.CF_ACCOUNT_ID,
apiToken: env.CF_API_TOKEN,
provider: "linkup"
});
const { items } = await search.search({
query: "cloudflare agents sdk",
limit: 3
});
```

Any `WebSearchSource` can be passed to a tool as `source` instead of `binding`. That is how tests substitute a fake, and how you wrap a source with caching or logging. Spread the source you wrap so its `provider` is kept:

```ts
webSearchTool({
source: {
search: async ({ query }) => ({
items: [{ url: "https://example.com", title: query }],
metadata: { query, requestId: "test", latencyMs: 0 }
})
}
});
```
11 changes: 11 additions & 0 deletions packages/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ Each export maps to a public entry point that users `import` from. These are the
| `agents/browser/ai` | `src/browser/ai.ts` | AI SDK browser tools — `createBrowserTools` (CDP) + `createQuickActionTools` |
| `agents/browser/ai-sdk` | `src/browser/ai-sdk.ts` | AI SDK `browserTool` for a persistent `Browser` (+ `createQuickActionTools` re-export) |
| `agents/browser/tanstack-ai` | `src/browser/tanstack-ai.ts` | TanStack AI `browserTool` for a persistent `Browser` (+ legacy `createBrowserTools`) |
| `agents/websearch` | `src/websearch/index.ts` | Web Search API sources (`createAIWebSearch`, `createHTTPWebSearch`), `WebSearchError`, renderer |
| `agents/websearch/pi` | `src/websearch/tools/pi.ts` | pi harness `webSearchTool` |
| `agents/websearch/ai-sdk` | `src/websearch/tools/ai-sdk.ts` | AI SDK `webSearchTool` |
| `agents/websearch/tanstack-ai` | `src/websearch/tools/tanstack-ai.ts` | TanStack AI `webSearchTool` |
| `agents/voice` | `src/voice/index.ts` | Voice server mixins, contracts, Workers AI providers, text, and SFU helpers |
| `agents/voice/types` | `src/voice/types.ts` | Dependency-light Voice protocol and provider contracts |
| `agents/voice/client` | `src/voice/client.ts` | Framework-neutral browser Voice client |
Expand Down Expand Up @@ -187,6 +191,13 @@ src/
tool-helpers.ts # model-output + ctx helpers shared by ai.ts and ai-sdk.ts
tanstack-ai.ts # browserTool and createBrowserTools for TanStack AI

websearch/ # web_search tool over Cloudflare's Web Search API (beta)
index.ts # Barrel for agents/websearch
contract.ts # Tool name, description, input/output types, renderer
source.ts # WebSearchSource: AI binding + HTTP sources, WebSearchError
tool.ts # harness-neutral core shared by the adapters
tools/ # pi.ts, ai-sdk.ts, tanstack-ai.ts adapters; input-schema.ts (zod, shared)

voice/ # Voice server, client, React, provider, SFU, and text entries
channels/ # Messaging core and Channels: slack/, telegram/, email/, web/

Expand Down
20 changes: 20 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,26 @@
"import": "./dist/browser/tanstack-ai.js",
"require": "./dist/browser/tanstack-ai.js"
},
"./websearch": {
"types": "./dist/websearch/index.d.ts",
"import": "./dist/websearch/index.js",
"require": "./dist/websearch/index.js"
},
"./websearch/pi": {
"types": "./dist/websearch/tools/pi.d.ts",
"import": "./dist/websearch/tools/pi.js",
"require": "./dist/websearch/tools/pi.js"
},
"./websearch/ai-sdk": {
"types": "./dist/websearch/tools/ai-sdk.d.ts",
"import": "./dist/websearch/tools/ai-sdk.js",
"require": "./dist/websearch/tools/ai-sdk.js"
},
"./websearch/tanstack-ai": {
"types": "./dist/websearch/tools/tanstack-ai.d.ts",
"import": "./dist/websearch/tools/tanstack-ai.js",
"require": "./dist/websearch/tools/tanstack-ai.js"
},
"./voice": {
"types": "./dist/voice/index.d.ts",
"import": "./dist/voice/index.js",
Expand Down
4 changes: 4 additions & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const entries = [
"src/browser/ai.ts",
"src/browser/ai-sdk.ts",
"src/browser/tanstack-ai.ts",
"src/websearch/index.ts",
"src/websearch/tools/pi.ts",
"src/websearch/tools/ai-sdk.ts",
"src/websearch/tools/tanstack-ai.ts",
"src/experimental/webmcp.ts",
"src/voice/index.ts",
"src/voice/types.ts",
Expand Down
4 changes: 3 additions & 1 deletion packages/agents/src/harness/ai-sdk/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,9 @@ export class AiSdkHarness<TOOLS extends ToolSet = ToolSet>
...(system !== undefined && { system }),
...(tools && { tools }),
stopWhen: this.#options.stopWhen ?? stepCountIs(5),
messages: await convertToModelMessages(messages),
// `tools` lets `toModelOutput` shape earlier results; without it
// they go back to the model as raw JSON.
messages: await convertToModelMessages(messages, tools && { tools }),
abortSignal: run.abort.signal
});
const stream = toUIMessageStream<TOOLS, Message>({
Expand Down
43 changes: 43 additions & 0 deletions packages/agents/src/tests-d/websearch-tool.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Tool, ToolSet } from "ai";
import type { ServerTool } from "@tanstack/ai";
import { expectTypeOf } from "vitest";
import type { WebSearchSource, WebSearchToolOutput } from "../websearch";
import { webSearchTool } from "../websearch/tools/ai-sdk";
import { webSearchTool as piWebSearchTool } from "../websearch/tools/pi";
import { webSearchTool as tanStackWebSearchTool } from "../websearch/tools/tanstack-ai";

declare const env: { AI: Ai };
declare const source: WebSearchSource;

// The binding carries the gateway, provider, and billing options.
webSearchTool({
binding: env.AI,
gateway: "prod",
provider: "exa",
byokAlias: "team"
});

// A source carries its own; the tool rejects them instead of ignoring them.
webSearchTool({ source, limit: 3 });
// @ts-expect-error provider belongs to the source
webSearchTool({ source, provider: "exa" });
// @ts-expect-error gateway belongs to the source
piWebSearchTool({ source, gateway: "prod" });
// @ts-expect-error byokAlias belongs to the source
tanStackWebSearchTool({ source, byokAlias: "team" });
// @ts-expect-error binding or source, not both
webSearchTool({ source, binding: env.AI });

// @ts-expect-error one of binding or source is required
webSearchTool({ limit: 3 });

// A plain AI SDK tool the host can put under any key, with a typed execute.
const aiSdkTool = webSearchTool({ source });
expectTypeOf(aiSdkTool).toExtend<
Tool<{ query: string; limit?: number }, WebSearchToolOutput>
>();
const tools: ToolSet = { web_search: aiSdkTool };
void tools;

// The TanStack AI adapter is a ServerTool.
expectTypeOf(tanStackWebSearchTool({ source })).toExtend<ServerTool>();
11 changes: 11 additions & 0 deletions packages/agents/src/tests/capabilities/ai-sdk-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ export class AiSdkHarnessObject extends DurableObject<Cloudflare.Env> {
inputSchema: z.object({}),
needsApproval: true,
execute: async () => "Heads"
}),
lookUp: tool({
description: "Look something up",
inputSchema: z.object({}),
execute: async () => ({ raw: "full host-side output" }),
toModelOutput: () => ({ type: "text", value: "shaped for the model" })
})
}
});
Expand All @@ -114,6 +120,11 @@ export class AiSdkHarnessObject extends DurableObject<Cloudflare.Env> {
return this.prompts.length;
}

/** The prompt the model saw on call `index`, as JSON. */
getPrompt(index: number): string {
return JSON.stringify(this.prompts[index]);
}

/** Submit straight to the harness, bypassing Channels. */
async submit(session: string, text: string, operationId: string) {
return this.harness
Expand Down
18 changes: 18 additions & 0 deletions packages/agents/src/tests/channels/ai-sdk-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ describe("An AI SDK harness served through Channels", () => {
]);
});

it("shapes earlier tool results with toModelOutput on later turns", async () => {
const stub = agent();
await stub.setScript([
{ call: "lookUp", toolCallId: "c1" },
{ text: "Found it" },
{ text: "Still here" }
]);
await stub.submit("main", "look it up", "o1");
expect(await stub.wait("main", "o1")).toMatchObject({ status: "done" });
await stub.submit("main", "and again", "o2");
expect(await stub.wait("main", "o2")).toMatchObject({ status: "done" });

// The third call rebuilds the history from the saved transcript.
const prompt = await stub.getPrompt(2);
expect(prompt).toContain("shaped for the model");
expect(prompt).not.toContain("full host-side output");
});

it("queues a message sent while a turn runs", async () => {
const stub = agent();
await stub.setScript([{ text: "first", delayMs: 20 }, { text: "second" }]);
Expand Down
Loading
Loading