Skip to content

computer: Update tool sets to work with new exec model - #186

Closed
mattzcarey wants to merge 17 commits into
feat/ws-container-modulefrom
feat/pi-ai-tanstack-ai-tools
Closed

mattzcarey wants to merge 17 commits into
feat/ws-container-modulefrom
feat/pi-ai-tanstack-ai-tools

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Stacked on #172. Replaces #149: same pi and TanStack AI tool sets from @aron-cf, rebuilt on the exec API that #181 and #182 gave createAITools.

Two new entry points, named after the library each one serves:

Library Entry point Factory
ai @cloudflare/computer/tools/ai-sdk createAITools (unchanged)
@earendil-works/pi-ai @cloudflare/computer/tools/pi-ai createPiTools
@tanstack/ai @cloudflare/computer/tools/tanstack-ai createTanStackTools

All three take the same options, exec included, and build the same tools from the same core:

import { createPiTools } from "@cloudflare/computer/tools/pi-ai";

// Every Workspace backend by default; `exec` picks them, `exec: {}` drops the tool.
const { tools, execute } = createPiTools({ workspace });

const message = await models.complete(model, { systemPrompt, messages, tools });
for (const block of message.content) {
  if (block.type !== "toolCall") continue;
  const { content, isError } = await execute(block); // a bad call comes back as isError, not a throw
  messages.push({ role: "toolResult", toolCallId: block.id, toolName: block.name, content, isError, timestamp: Date.now() });
}
import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai";

const tools = createTanStackTools({
  workspace,
  exec: { "worker-shell": { description: "Use for quick checks." } },
  approve: "mutating", // needsApproval on write, edit, delete, exec, publish
});
chat({ adapter, messages, tools, abortController });

Layout

flowchart LR
  subgraph common["tools/common (zod only)"]
    fs["fs/*: schema, description, executor"]
    exec["exec: defineExec() → description, inputSchema, execute"]
    opts["options: resolveToolOptions()"]
  end
  common --> aisdk["tools/ai-sdk<br/>tool() from ai"]
  common --> pi["tools/pi-ai<br/>JSON Schema + dispatcher"]
  common --> ts["tools/tanstack-ai<br/>Standard Schema tool list"]
  aisdk --> idx["tools (index)<br/>create*Tool, WorkspaceFileStore"]
Loading
  • On the stack, every tool called tool() from ai, so nothing could share them without loading ai. Each tool is now split: common/ holds the schema, description, and executor, and ai-sdk/tools.ts wraps them. @cloudflare/computer/tools exports exactly what it did before.
  • defineExec() is the stack's createExecTool minus tool(): one backend means no backend argument, several mean backend is required, and input appears only when a backend is callable. The pi and TanStack adapters get all of that for free.
  • resolveToolOptions() holds the exec / deprecated shell handling that lived in createAITools, so all three sets agree on which tools exist.

Tree-shaking

pi and TanStack are declared as local structural types, not imports, so they are not peer dependencies at all. I bundled each entry with esbuild, keeping packages external:

Entry External imports
tools/ai-sdk ai, zod
tools/pi-ai zod
tools/tanstack-ai zod

(node:zlib also shows up in all three, the same as on the base branch. It comes from a shared rolldown chunk, not the tools.)

Changes from #149

  • Entry points are named after their packages: tools/pi → tools/pi-ai, tools/tanstack → tools/tanstack-ai. The functions keep Add tools for Pi and Tanstack harnesses #149's names, createPiTools and createTanStackTools.
  • pi checks arguments with TypeBox, which also takes plain JSON Schema, so the pi tools need nothing beyond zod. The declarations now stay open: pi 0.99 closes a constrainedSampling schema itself when the provider runs it strict. Add tools for Pi and Tanstack harnesses #149 closed read, write, and edit up front, so a non-strict provider that left out offset failed pi's own validateToolCall. Tests now check the declarations against validateToolCall and makeStrictJsonSchema.
  • tools/index no longer re-exports the pi and TanStack sets, which would have tied them back to ai.
  • Uses exec instead of shell + defaultBackend. The tests cover several backends, exec narrowing to one, exec: {}, and callable input.
  • Keeps dofs: Add exclusions to recursive grep #150's grep exclude in the common grep.
  • TanStack read returns an image or PDF as [text part, image/document part]. TanStack only passes ContentPart[] results to the adapter as multimodal content; Add tools for Pi and Tanstack harnesses #149's plain object got JSON-stringified, so the model saw base64 as text.
  • Examples move to examples/pi-ai and examples/tanstack-ai, bump to pi-ai 0.99 / @tanstack/ai 0.63 / @tanstack/ai-cloudflare 0.2, and join the CI examples matrix. npm run local runs each agent loop in Node against a scripted model (a small shim covers cloudflare:workers).
  • Docs: docs/09_tool_interface.md has pi and TanStack sections. READMEs and a changeset are updated.

The lockfile only adds the examples' dependencies. No existing version changes.

Co-authored-by: aron 263346377+aron-cf@users.noreply.github.com

mattzcarey and others added 11 commits October 1, 2026 12:04
WorkerJavaScriptBackend had two ways to add imports: modules for
bundled source, and trustedModules for a single call(method, args)
host handler reached through one generic call export. On top of those,
node:fs, ws:git, and ws:artifacts were always installed, with git and
artifacts special-cased in the bridge and gated by allowGitNetwork and
allowArtifactNetwork.

There is now one modules option, and the value's type says what it
is. A string is bundled source, as before. An object of functions is a
host module under a ws:* specifier, and each function becomes a named
export, so { "ws:weather": { forecast } } lets code write
import { forecast } from "ws:weather". A function is a factory the
backend calls when it connects, with the Workspace's Git client,
Artifacts client, and runtime. Each call gets its access level and a
path resolver confined to the backend root alongside the signal and
deadline, and may return any JSON-compatible value; the bridge checks
it at runtime.

Git and Artifacts become prebuilt factories under
@cloudflare/computer/modules/*, and nothing under ws: is installed
unless configured. Network access moves to allowNetwork on each
module. node:fs and node:fs/promises stay built in. Specifiers, source
module names, and object export names are checked at construction.

The backend also describes its source language and every importable
module in a description property, which workspace.runtime.describe(id)
returns, built from the same modules option it runs with. The rlm
example moves ws:model to a named batch function.
The Git CLI now takes a leading -C <path>, because agents reach for
git -C instead of changing directory. ws:git rejected every -C as a
path override, so those commands failed inside an isolate.

A leading -C now becomes the command's working directory and goes
through the same root confinement as cwd. A -C anywhere else, and
--git-dir and --work-tree, are still rejected.
The exec tool always offered a backend argument, even with one backend
configured, and always offered input, even when no backend accepted
it. With one backend the model saw an enum of one value and a
description written for choosing between backends. Callers also had
to describe each backend by hand, so the modules a JavaScript backend
installs had to be listed twice and kept in step.

With one backend the tool now has no backend argument and always runs
there, defaultBackend becomes optional, and the description talks
about what that backend does. input appears only when some backend
accepts it. A backend value sent anyway is removed by the schema.

Each backend's entry adds what the backend says about itself, read
through workspace.runtime.describe(id), after the caller's own
description, which becomes optional for a backend that describes
itself. The tool builds one input schema instead of a single-backend
and a multi-backend variant, and its output truncation moves to a
shared UTF-8 helper.
An agent that wants both isolated JavaScript and a full Linux
container has so far needed two exec backends, and the model had to
pick one per command. This lets JavaScript be the only backend the
model sees, with the container as a library it can call.

createContainerModule() in @cloudflare/computer/modules/container is a
host module factory. Installed as ws:container, its exec(command,
options) runs through workspace.runtime.exec on the container backend,
so the container shares the Workspace's files through the usual sync
bracket. It returns the exit code and bounded output once the command
finishes, kills the command when the execution is cancelled, caps the
command's timeout at the host call deadline, and refuses to run on a
read-only backend, since a container command can write to the
Workspace whatever the isolate's access is. Its description tells the
model how to call it, and reaches the exec tool through the
JavaScript backend's own description.
* computer: Carry backend information through Workspace clients

A WorkspaceClient from getWorkspace() wrapped the runtime with exec,
getExec, killExec, and disposeExec only. The exec tool also asks the
runtime for backendIds, isCallable, and describe, so a client lost all
three: with exec omitted it offered no exec tool, and a callable
backend lost its input argument and its module list. Over RPC those
calls would be asynchronous, while the tool builds its schema
synchronously.

Backends are fixed when the Workspace is constructed, so the runtime
and its RPC stub now expose one backends() call, and a client takes
that snapshot when it is created and answers the three questions from
it, locally and remotely alike.

CloudflareContainerBackend now describes network access from its
egress setting instead of always claiming it, exec takes precedence
over the deprecated shell option so exec: {} always means no tool, and
the mcp README and think prompt stop implying a default backend.

* computer: Answer backend questions with one backends() call

The exec tool learned about backends through three runtime methods,
backendIds, isCallable, and describe, and every way of reaching a
Workspace had to forward all three. It now reads one list from
runtime.backends(), each entry carrying the id, whether the backend is
callable, and its description. backendIds and describe are gone, and
a Workspace client exposes the same backends() from its snapshot.
isCallable stays on the runtime for its own check before running.

* computer: Freeze the client backend snapshot and test input through it

A client returned its backend snapshot array itself, so a caller that
edited it changed what later tool sets saw. The snapshot is now frozen
once when the client is created.

The client tests only checked that the exec schema offered input. They
now send structured input through the exec tool on a local and a
remote client to a callable backend that echoes it, and check the
value comes back as the result. The mcp README no longer calls
worker-shell the default.
The exec tool's self-description for the container landed on the
platform-scheduled backend, which is now LegacyContainerBackend. It
moves to ContainerBackend, the backend containers should use, with its
network line still following the egress mode. The legacy backend goes
back to describing nothing and gets the exec tool's one-line default.
ws:container found a missing or wrong container backend only on its
first exec. Its factory runs when the JavaScript backend connects and
can read the Workspace's backends, so it now fails there: with no such
backend, or with one that runs modules instead of shell commands.

Its description also stopped promising network access. The model only
sees the module's text in this setup, and whether the container can
reach the network depends on the container backend's egress setting,
which the module cannot know when it is built.
The ws:container docs, the exec tool docs, and the changesets named
the old CloudflareContainerBackend. They now show ContainerBackend,
the durable-object-scheduled backend containers should use.
Both examples ran their container through LegacyContainerBackend, the
platform-scheduled backend, which no longer describes itself to the
model. They now use ContainerBackend: the durable object schedules the
container, the containers block names an image under images.app with
scheduling_policy "durable_object", and the backend asks for
standard-2 at launch, the size the old block requested.
ws:container also rejected a backend marked callable, taking that as a
sign it runs modules. callable means a backend takes structured input,
and a shell backend may do both, so the check refused valid backends
and let through a module backend that was not callable. It now checks
only that the backend exists.
@changeset-bot

changeset-bot Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 67b065c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 potential issues.

Devin Review

Comment thread packages/computer/src/tools/pi-ai/index.ts
Comment thread packages/computer/src/tools/tanstack-ai/index.ts Outdated
Comment on lines +102 to +106
const { task } = (await request.json()) as { task?: string };
if (!task) return new Response("body needs a task\n", { status: 400 });

const agent = env.PiAgent.get(env.PiAgent.idFromName("demo"));
return new Response(`${await agent.run(task)}\n`);

@devin-ai-integration devin-ai-integration Bot Oct 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Anonymous callers control the pi agent

Any caller can POST a task to PiAgent.run without authentication. The agent exposes workspace writes and shell execution, allowing anonymous callers to consume Workers AI quota and alter shared files.

Devin Review


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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intentional. These are local wrangler dev demos, unauthenticated like the other examples in this repo (worker-shell, worker-javascript, tutorial). The README covers running it locally.

Comment on lines +75 to +79
const { task } = (await request.json()) as { task?: string };
if (!task) return new Response("body needs a task\n", { status: 400 });

const agent = env.TanStackAgent.get(env.TanStackAgent.idFromName("demo"));
return new Response(`${await agent.run(task)}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Anonymous callers control the TanStack agent

Any caller can POST a task to TanStackAgent.run without authentication. The agent exposes workspace writes and shell execution, allowing anonymous callers to consume Workers AI quota and alter shared files.

Devin Review


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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same as the pi example: a local wrangler dev demo, unauthenticated like the repo's other examples.

@pkg-pr-new

pkg-pr-new Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@186

commit: 67b065c

ws:container needs a backend that runs shell commands. It first judged
that by callable, which describes structured input instead: a shell
backend may be callable, and a module backend need not be. Without any
check, pointing it at the JavaScript backend would run a shell command
as JavaScript, or start a nested run.

runtime.backends() now reports each backend's protocol, "command" or
"module", and ws:container refuses a module backend when it connects.
A callable shell backend is accepted.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +314 to +316
for (const [name, field] of Object.entries(schema.shape as Record<string, z.ZodType>)) {
if (field.safeParse(undefined).success && !field.safeParse(null).success) names.add(name);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Invalid optional arguments execute tools

When a caller passes path: null to find, absentWhenNull drops it even though the declared schema rejects null. The tool searches /workspace instead of returning an invalid-arguments error.

Learn more

The pi dispatcher removes null placeholders before validating arguments. Strict sampling can produce those placeholders for optional fields in read, write, or edit; ordinary calls to other tools do not require this conversion. Here, absentWhenNull also includes optional fields on tools such as findInputSchema. Its path schema accepts omission but not null. Removing an explicit null lets its default apply, so an invalid call runs against the workspace root.

Example: A pi caller invokes execute({ id: "1", name: "find", arguments: { pattern: "**/*.ts", path: null } }). Instead of returning isError: true for invalid arguments, the dispatcher omits path and searches /workspace.

Recommended fix: Restrict placeholder-null removal to tools using constrained sampling, or validate ordinary calls before removing nulls. Keep the intentional strict-mode behavior for read's optional fields and preserve valid null values such as callable exec.input.

Devin Review


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

aron-cf and others added 5 commits October 2, 2026 11:03
Split each tool into a framework-neutral core under tools/common
(schema, description, executor; zod only) and an adapter per agent
library. tools/ai-sdk wraps the core with `tool()` from `ai`;
tools/pi-ai and tools/tanstack-ai build their own shapes from the
same core without importing their libraries, so each entry point
pulls in only what it uses. createAITools stays exported from
@cloudflare/computer/tools.

The exec core keeps the current options: a `shell` with a backend
map and a default backend. All three tool sets resolve options
through the same resolveToolOptions, so they offer the same tools.

The pi and TanStack adapters come from #149.

Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com>
Two one-shot agents on a Worker-shell Workspace: pi-ai, where `run` is
the whole loop, and tanstack-ai, where chat() owns it. Both pass the
one worker shell to `exec` through `shell`. `npm run local` drives
each loop in Node with a scripted model, through a small shim for
`cloudflare:workers`.

Bumps the libraries to current releases (pi-ai 0.99, @tanstack/ai
0.63, @tanstack/ai-cloudflare 0.2) and adds both to the CI examples
matrix. Docs, READMEs, and a changeset cover the two entry points.

The examples come from #149.

Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com>
chat() passes a tool result to the adapter as multimodal content only
when it is a ContentPart array. The read tool returned an image or PDF
as a plain object, which TanStack JSON-stringified, so the model got
the base64 as text. It now returns a text part plus an image or
document part, and the test checks the shape with TanStack's own
isContentPartArray.

Also covers a failed pi publish, which already comes back as an error
result.
Renames createPiAITools to createPiTools and createTanStackAITools to
createTanStackTools, with their option and result types. The entry
points stay tools/pi-ai and tools/tanstack-ai.

pi checks tool arguments with TypeBox, which also compiles plain JSON
Schema, so the pi tools need only zod for their own schemas. pi 0.99
also closes a constrainedSampling schema itself when the provider runs
it strict. The adapter used to close read, write, and edit up front,
which made every optional field required in the schema pi validates
against. A provider that fell back to ordinary tool calling and left
`offset` out of a read failed pi's own validateToolCall. The
declarations now stay open, and execute drops a null only on an
optional field that cannot take one. Tests check the declarations
against pi's validateToolCall and makeStrictJsonSchema.
The tool sets landed on main with the exec options main had: a
`shell` with a backend map and a default backend, and createAITools
exported from @cloudflare/computer/tools. This branch has since moved
exec to one `exec` option that offers every Workspace backend by
default and takes its backend list from runtime.backends(), and moved
createAITools to @cloudflare/computer/tools/ai-sdk.

The resolution carries that exec core into tools/common/exec.ts and
common/options.ts, so createAITools, createPiTools, and
createTanStackTools all take `exec` (with `shell` kept as a
deprecated alias). The tools/ai-sdk entry point, the examples, and
the docs follow the same options.
@aron-cf
aron-cf force-pushed the feat/pi-ai-tanstack-ai-tools branch from e670853 to 67b065c Compare October 2, 2026 11:30
@aron-cf aron-cf changed the title computer: Add pi-ai and TanStack AI tool sets computer: Update tool sets to work with new exec model Oct 2, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 4 new potential issues.

Devin Review

Comment on lines +146 to +148
const declaredRelative = declared.map((path) => stripMount(path, resolved.mountPoint));
const actualRelative = resolved.paths.map((path) => stripMount(path, resolved.mountPoint));
const difference = diffIgnore(declaredRelative, actualRelative);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Redundant ignored paths block connection

When ignore includes a parent and its child, assertIgnoreMatches rejects the healthy container. resolveMountIgnore drops the child as redundant, so its reported set differs from the declaration.

Learn more

The daemon resolves MOUNT_IGNORE to a minimal set. A declared parent covers every child, so resolveMountIgnore removes nested entries and reports only the parent. The host comparison does not minimize the declaration. It therefore treats an equivalent, correctly applied configuration as a mismatch and refuses every connection.

Example: With ignore: ['/node_modules', '/node_modules/.cache'], the daemon reports ['node_modules']. The host reports .cache missing, although every .cache file stays local.

Recommended fix: Normalize and collapse covered paths in the declaration before comparing it to the daemon's resolved set. Preserve the original declaration in error details.

Devin Review


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

Comment on lines +681 to +686
const info: ComputerdInfo = {
backend,
mountPoint,
port,
store,
ignore: describeMountIgnore(ignoreConfig),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Local-only paths reported without a mount

With FUSE_MOUNT=none, describeMountIgnore reports active local-only paths although no passthrough mounts. A client can accept the report while those paths still enter the VFS.

Learn more

The daemon reports its configured ignore set in /__computerd/info. It creates the passthrough layer only inside the mount branch, which is skipped when the backend is none. The host accepts the reported set as proof that paths are kept out of sync, despite there being no local-only layer.

Example: Start computerd with FUSE_MOUNT=none MOUNT_IGNORE=/node_modules. The info endpoint reports enabled: true and paths: ['node_modules'], but no FUSE operations route /workspace/node_modules to local disk.

Recommended fix: Reject a nonempty MOUNT_IGNORE when the backend is none, or explicitly report it as inactive and ensure the host check refuses an inactive declaration.

Devin Review


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

Comment on lines +227 to +228
const wrapped: FuseOps = {
...ops,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Extended attributes fail on local-only files

For a file under MOUNT_IGNORE, getxattr and listxattr still reach the VFS. The VFS lacks local-only files, so applications receive ENOENT for existing files.

Learn more

The wrapper copies every VFS operation before overriding selected methods. The VFS xattr handlers check whether a path exists in the VFS. Local-only files are excluded from the VFS, so these handlers return ENOENT for paths the mount itself can read.

Example: With MOUNT_IGNORE=/node_modules, create /workspace/node_modules/pkg/index.js through the mount and request its extended attributes. Its FUSE getattr succeeds, but getxattr returns ENOENT.

Recommended fix: Override setxattr, getxattr, listxattr, and removexattr for ignored paths, using local filesystem behavior or matching the VFS's supported xattr semantics while checking existence on local disk.

Devin Review


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

Comment on lines +296 to +301
const target = localPath(path);
// O_CREAT is not implied by open(2) here; the kernel sends
// create() for that. But a flag set including O_TRUNC still has
// to reach the real file, so the flags are passed through as-is.
const fd = fs.openSync(target, flags);
cb(0, allocateHandle(fd, path));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Local-only symlinks reach container files

When an ignored path is a symlink outside its disk root, openSync follows it. Reads and writes through the mount then reach unrelated container files.

Devin Review


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

@mattzcarey
mattzcarey force-pushed the feat/ws-container-module branch from 0e2e978 to 1a90515 Compare October 6, 2026 17:54
@mattzcarey

Copy link
Copy Markdown
Member Author

Closing as superseded. The pi and TanStack AI tool sets landed on main in #191, and the rebased #172 now carries the exec changes for all three tool sets (createAITools, createPiTools, createTanStackTools), including the examples and tests from this branch.

@mattzcarey mattzcarey closed this Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants