Skip to content
Open
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/pi-browser-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Add `browserTool` to `agents/browser/pi`, the pi-durable version of the persistent browser tool. Screenshots come back as images the model can see. See [Persistent browser](https://github.com/cloudflare/agents/blob/main/docs/agents/browse-the-web.md#persistent-browser).
22 changes: 22 additions & 0 deletions docs/agents/browse-the-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,28 @@ const stream = chat({

The TanStack AI tool doesn't support screenshots yet. TanStack AI gives the host and the model the same output, so if the model returns a screenshot, the tool replaces it with a note saying it was left out, and the tool's instructions tell the model to read the page with `Runtime.evaluate` instead.

For [pi-durable](./harnesses/pi.md), import `browserTool` from `agents/browser/pi` and install it in an extension. It takes the same options plus an optional `name` (default `"browser"`). Outside an `Agent`, pass `ctx`:

```ts
import { browserTool } from "agents/browser/pi";

this.registry.install({
name: "browser",
tools: [
browserTool({
ctx: this.ctx,
browser: this.browser,
loader: this.env.LOADER
})
]
});
```

- A returned screenshot comes back as an image in the tool result, so the model can see the page and a UI can show it from the transcript. pi-ai only sends the image to models that accept images; other models get the text alone.
- The tool's calls run one at a time, since they share the active tab.
- pi doesn't rerun a browser call cut off by an eviction, because the code may have clicked or submitted something. The model gets an interrupted result and can try again; the browser itself is still there.
- Stopping a conversation doesn't stop a browser call already running. It finishes or times out (`timeoutMs`, default 60 seconds).
Comment thread
ben-reitz marked this conversation as resolved.

## Quick Actions (stateless browsing)

`browser_execute` drives a full, stateful CDP session — the right tool for interactive, multi-step automation. But a lot of agent browsing is really one-shot: _read this page as Markdown_, _extract these fields_, _list the links_. For those, [Quick Actions](https://developers.cloudflare.com/browser-run/quick-actions/) are simpler, faster, and cheaper. They need only the `browser` binding — no Durable Object, Worker Loader, or sandbox — so they work from any Worker.
Expand Down
25 changes: 25 additions & 0 deletions docs/agents/harnesses/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ harness: async ({ storage, context }) => {

The existing `@cloudflare/think` skills work out-of-the-box.

### Browser

`browserTool` from `agents/browser/pi` gives the model a persistent browser it drives with the Chrome DevTools Protocol. Put a `Browser` on the object's Lifecycle and install the tool in an extension:

```ts
import { Browser, browserRun } from "agents/browser";
import { browserTool } from "agents/browser/pi";

readonly browser = new Browser({ provider: browserRun(this.env.BROWSER) });

harness: async ({ storage, context }) => {
this.registry.install({
name: "browser",
tools: [
browserTool({ ctx: this.ctx, browser: this.browser, loader: this.env.LOADER })
]
});
return Harness.open(storage, { models, registry: this.registry }, context);
};

readonly lifecycle = Lifecycle.install(this).use(this.browser).use(this.harness);
```

It needs a `browser` binding, a `LOADER` Worker Loader binding, and `export { CodemodeRuntime } from "@cloudflare/codemode"` from the Worker entry. Screenshots come back as images. Refer to [Persistent browser](../browse-the-web.md#persistent-browser) for how it behaves.

## Work with sessions

A session is a Pi conversation. Each has its own transcript, inbox, model and run, and they can run at the same time.
Expand Down
4 changes: 3 additions & 1 deletion packages/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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/browser/pi` | `src/browser/pi.ts` | pi-durable `browserTool` for a persistent `Browser` |
| `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 @@ -178,9 +179,10 @@ src/
quick-actions.ts # Stateless Quick Action primitives (browserMarkdown, …)
ai.ts # createBrowserTools + createQuickActionTools (AI SDK)
ai-sdk.ts # browserTool for a persistent Browser (AI SDK)
browser-tool.ts # harness-neutral browserTool core shared by ai-sdk.ts and tanstack-ai.ts
browser-tool.ts # harness-neutral browserTool core shared by ai-sdk.ts, tanstack-ai.ts and pi.ts
tool-helpers.ts # model-output + ctx helpers shared by ai.ts and ai-sdk.ts
tanstack-ai.ts # browserTool and createBrowserTools for TanStack AI
pi.ts # browserTool for pi-durable

voice/ # Voice server, client, React, provider, SFU, and text entries
channels/ # Messaging core and Channels: slack/, telegram/, email/, web/
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@
"import": "./dist/browser/tanstack-ai.js",
"require": "./dist/browser/tanstack-ai.js"
},
"./browser/pi": {
"types": "./dist/browser/pi.d.ts",
"import": "./dist/browser/pi.js",
"require": "./dist/browser/pi.js"
},
"./voice": {
"types": "./dist/voice/index.d.ts",
"import": "./dist/voice/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const entries = [
"src/browser/ai.ts",
"src/browser/ai-sdk.ts",
"src/browser/tanstack-ai.ts",
"src/browser/pi.ts",
"src/experimental/webmcp.ts",
"src/voice/index.ts",
"src/voice/types.ts",
Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/browser/browser-tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* The harness-neutral core of `browserTool`, shared by the AI SDK
* (`agents/browser/ai-sdk`) and TanStack AI (`agents/browser/tanstack-ai`)
* adapters. Internal — not an entry point.
* (`agents/browser/ai-sdk`), TanStack AI (`agents/browser/tanstack-ai`), and
* pi-durable (`agents/browser/pi`) adapters. Internal — not an entry point.
*/
import {
createCodemodeRuntime,
Expand Down
184 changes: 184 additions & 0 deletions packages/agents/src/browser/pi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {
Type,
type ImageContent,
type TextContent
} from "@earendil-works/pi-ai";
import type {
ToolExecutionResult,
ToolRegistration
} from "@earendil-works/pi-durable";
import {
createBrowserToolCore,
type BrowserToolOptions,
type BrowserToolOutput
} from "./browser-tool";
import type { BrowserNewTab } from "./session-connector";
import {
browserExecuteModelOutput,
browserScreenshotOutput
} from "./tool-helpers";

export type {
BrowserToolInput,
BrowserToolOptions,
BrowserToolOutput
} from "./browser-tool";
export type { BrowserNewTab, BrowserSource } from "./session-connector";

export interface PiBrowserToolOptions<
TName extends string = "browser"
> extends BrowserToolOptions {
/** The tool's name. Default `"browser"`. */
name?: TName;
}

const browserToolParameters = Type.Object({
code: Type.String({
description: "An async arrow function that drives the browser with `cdp`"
})
});

/**
* What a UI can show about a run without reading the content: the run's
* outcome and what happened to the browser.
*/
export type PiBrowserToolDetails = {
executionId: string;
status: BrowserToolOutput["status"];
restarted?: true;
newTabs?: { targetId: string; url?: string; title?: string }[];
};

export type PiBrowserTool<TName extends string = "browser"> = ToolRegistration<
typeof browserToolParameters,
PiBrowserToolDetails
> & {
readonly name: TName;
};

function details(output: BrowserToolOutput): PiBrowserToolDetails {
return {
executionId: output.executionId,
status: output.status,
...(output.restarted ? { restarted: true as const } : {}),
...(output.newTabs
? {
newTabs: output.newTabs.map((tab: BrowserNewTab) => ({
targetId: tab.targetId,
...(tab.url === undefined ? {} : { url: tab.url }),
...(tab.title === undefined ? {} : { title: tab.title })
}))
}
: {})
};
}

function text(value: unknown): TextContent {
return {
type: "text",
text: typeof value === "string" ? value : JSON.stringify(value)
};
}

/**
* The tool result pi stores and sends: the model projection of the run as
* JSON (no `calls` log, bounded `logs`), and a screenshot as an image part
* after it. pi-ai only sends the image to models that accept images.
*/
function browserToolResult(
output: BrowserToolOutput
): ToolExecutionResult<PiBrowserToolDetails> {
const isError = output.status === "error";
const screenshot = browserScreenshotOutput(output);
if (!screenshot) {
return {
content: [text(browserExecuteModelOutput(output).value)],
details: details(output),
...(isError ? { isError } : {})
};
}

// No size check: a screenshot comes from one cdp.send result, which
// codemode caps at 1 MB, well under what model providers accept.
const bytes = Math.floor((screenshot.data.length * 3) / 4).toLocaleString();
// Keep the rest of the result (status, restarted, notice, newTabs) around
// the sentence that replaces the screenshot.
const summary = browserExecuteModelOutput({
...output,
result: `Screenshot attached as an image (${screenshot.mediaType}, approximately ${bytes} bytes). If you can't see it, read the page with Runtime.evaluate instead.`
}).value;
const image: ImageContent = {
type: "image",
data: screenshot.data,
mimeType: screenshot.mediaType
};
return {
content: [text(summary), image],
details: details(output)
};
}

/**
* Create a pi-durable tool that lets the model drive a persistent browser
* with JavaScript and the Chrome DevTools Protocol.
*
* Works like `browserTool` in `agents/browser/ai-sdk`: tabs, cookies, and
* logins carry over between runs, `sessionId: "active"` addresses the tab the
* model last worked in, and a replaced browser is reported as
* `restarted: true`. A returned screenshot comes back as an image part that
* the model sees, when its model accepts images.
*
* The tool runs its calls one at a time, since they share the active tab,
* and doesn't rerun after an eviction (its code may have clicked or
* submitted something): pi gives the model an interrupted result instead.
* Pass `ctx` unless the tool is built inside an Agent.
*
* @example
* ```ts
* import { Browser, browserRun } from "agents/browser";
* import { browserTool } from "agents/browser/pi";
*
* export class MyAgent extends DurableObject<Env> {
* readonly browser = new Browser({ provider: browserRun(this.env.BROWSER) });
* readonly registry = createRegistry();
* readonly harness = new PiHarness({
* harness: ({ storage, context }) => {
* this.registry.install({
* name: "browser",
* tools: [
* browserTool({
* ctx: this.ctx,
* browser: this.browser,
* loader: this.env.LOADER
* })
* ]
* });
* return Harness.open(storage, { models, registry: this.registry }, context);
* }
* });
* readonly lifecycle = Lifecycle.install(this)
* .use(this.browser)
* .use(this.harness);
* }
* ```
*/
export function browserTool<TName extends string = "browser">(
options: PiBrowserToolOptions<TName>
): PiBrowserTool<TName> {
const core = createBrowserToolCore(options, {
screenshotHint:
"To see a screenshot, return { type: 'browser_screenshot', mediaType, data } with data from Page.captureScreenshot and mediaType 'image/png', or 'image/jpeg' if you captured with format: 'jpeg'. The image is attached to the result."
});
return {
name: options.name ?? ("browser" as TName),
description: core.description,
parameters: browserToolParameters,
// Calls share the active tab, so one round's calls must not interleave.
executionMode: "sequential",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Concurrent conversations lose their active tab

When separate conversations call browserTool concurrently, executionMode does not serialize them across conversations. Both save to the same browser's activeTargetId, so a later call can act on another conversation's tab.

Learn more

Pi runs different conversations concurrently; executionMode: "sequential" orders calls within a conversation's tool round, not calls from other conversations. BrowserSessionConnector saves the active target to a single browser record after each execution. The next execution resolves "active" from that shared record via activeTarget. Two conversations using one Browser can therefore switch one another's active tab even though each tool reports sequential execution.

Example: Conversation A creates tab A, and conversation B creates tab B before A's next call. B's pass saves tab B as active. A's Runtime.evaluate with sessionId: "active" now runs in tab B, not tab A.

Recommended fix: Decide whether the browser is intended to be shared between conversations. If not, assign a distinct named Browser to each conversation. If it is shared, track the active target per conversation and pass the conversation identity into the connector; a global execution lock alone cannot retain separate active tabs between turns.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

// The code may have clicked or submitted something; don't run it twice.
replay: "unsafe",
async execute(args) {
return browserToolResult(await core.execute({ code: args.code }));
}
};
}
2 changes: 1 addition & 1 deletion packages/agents/src/browser/tool-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Helpers shared by the browser tools: `createBrowserTools` in `ai.ts`, and
* `browserTool` in `ai-sdk.ts` and `tanstack-ai.ts`. Internal — not an entry
* `browserTool` in `ai-sdk.ts`, `tanstack-ai.ts`, and `pi.ts`. Internal — not an entry
* point.
*/
import type { JSONValue } from "ai";
Expand Down
Loading
Loading