Skip to content

Add tools for Pi and Tanstack harnesses - #149

Open
aron-cf wants to merge 7 commits into
mainfrom
tools-pi-tanstack
Open

aron-cf wants to merge 7 commits into
mainfrom
tools-pi-tanstack

Conversation

@aron-cf

@aron-cf aron-cf commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Add support for Pi and Tanstack AI tools alongside AI SDK.

This makes it easier to get started wiring up an agent and a computer workspace.

All three offer the same tools with the same names, descriptions and limits.

Usage for a Pi harness.

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

const { tools, execute } = createPiTools({ workspace });

// `tools` goes in the context you send to the model.
const message = await models.complete(model, { messages, tools });

// Then run whatever the model asked for.
for (const block of message.content) {
  if (block.type !== "toolCall") continue;
  const { content, isError } = await execute(block);
  messages.push({ role: "toolResult", toolCallId: block.id, toolName: block.name, content, isError, timestamp: Date.now() });
}

Usage for Tanstack AI:

import { createTanStackTools } from "@cloudflare/computer/tools/tanstack";

const tools = createTanStackTools({
  workspace,
  approve: "mutating", // ask the user first before anything that changes files
});

const stream = chat({ adapter, messages, tools });

Devin Review

The tools that let an agent read and change workspace files were each
written against one specific agent library. The description the model
reads, the rules about what a valid request looks like, and the code
that does the work were all tangled together with that library's way
of declaring a tool.

That was fine while there was only one library to support. It meant
that supporting a second one would have required copying every tool
and keeping the copies in step by hand.

Describe each tool once instead, in a form that mentions no library at
all, and keep the list of which tools exist in a single place. The
existing support is now a thin translation layer on top of that
description, and it behaves exactly as it did before.

A tool can also say how it wants its result shown to the model:
ordinary text, a failure, structured data, or a picture. Each library
then renders that in whatever way it supports.
Agents built on pi or TanStack AI could not use the workspace tools
without writing their own wrappers around the file and command
surfaces first. Add support for both, so the same tools are available
whichever of the three libraries an agent is built on, with the same
names, the same descriptions, and the same limits.

The two libraries expect to be handed tools in different shapes, and
each is served in the shape it wants.

pi keeps the list of available tools separate from the code that runs
them, and expects the surrounding program to run them itself. So it
gets both pieces together: the list to show the model, and something
that takes the model's request, checks it, and runs it. A bad request
or a tool that fails comes back as an ordinary failed result, which
the model can learn from and try again, rather than as a crash that
would stop the program.

TanStack AI wants each tool to hand back a single answer, so a
long-running command reports the state of the run once it has
finished, with the option of also reporting progress along the way.

Neither addition pulls in the original library, so installing one of
the three does not drag in the other two.
Support for the two new libraries was added by reducing all three to
the small set of things the original one needed. That shared the code,
but it also meant throwing away features the new libraries have and
the original does not. Anyone using them through this package got less
than they would have by wiring the tools up themselves.

Let each tool state a few plain facts about itself instead: whether it
changes files, whether its request is the sort a model is likely to
get subtly wrong, and whether it reports progress while it runs. Every
tool states these once. Each library then makes what use of them it
can and quietly ignores the rest, so sharing the code no longer means
settling for the least capable option.

This buys something real in both. Some requests are easy to get wrong,
because they carry an exact copy of a piece of a file or a position
part way through one, and a wrong guess wastes a turn. pi can ask the
model provider to hold the model to the expected shape as it writes
the request, so those mistakes are prevented rather than reported.
Where a provider cannot do this, the request is simply sent the
ordinary way, because refusing to run at all would be worse.

TanStack AI is told the shape of a successful answer for the tools
that always answer the same way, which saves the caller describing it
again. It can also be asked to confirm with the user before any tool
that changes files, without having to list them, and to keep tools out
of sight until the model goes looking for them.
@changeset-bot

changeset-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 8fa5a18

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

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 5 potential issues.

Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃攳 SDK contracts remain untested

Tests call locally declared shapes instead of either supported SDK. Contract drift can pass without exercising real Pi or TanStack integration.

(Refers to this code)

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment thread packages/computer/src/tools/tanstack.ts Outdated
Comment thread packages/computer/src/tools/tanstack.ts Outdated
Comment thread packages/computer/src/tools/pi.ts Outdated
Comment thread packages/computer/package.json
@pkg-pr-new

pkg-pr-new Bot commented Sep 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 8fa5a18

Add one example per new library, each a one-shot agent: post a task,
it works in a durable workspace, it replies when done. Both use the
Workers AI binding, so neither needs an API key.

The two examples exist to show the difference in shape. The pi one
writes the loop out in full, because pi keeps the tool list apart from
the code that runs the tools and expects the surrounding program to do
the running. The TanStack AI one has no loop at all, because chat()
owns it, so the agent is about ten lines. Seeing them side by side is
the clearest statement of what each library takes on.

The Workers AI provider for pi is lifted from the pi harness example
in cloudflare/agents, cut down to the one model this example uses.

Writing these turned up a mistake in the tool documentation, now
fixed: chat() takes tools as a list, not keyed by name. The set is
still returned keyed by name, which is what a server-side registry
wants, so the examples pass Object.values(tools). A tool also needs a
phantom marker to satisfy the union chat() accepts, and its execute
takes the validated arguments loosely, matching the signature chat()
calls it with.
Running both example agents against a scripted model, rather than only
typechecking them, turned up two real faults. A script per example now
drives its loop locally with no Cloudflare account, so the next person
can reproduce either in one command.

A tool that failed reported the wrong reason under TanStack AI. It
validates every value a tool returns against the tool's output shape,
and those shapes described only success, so an ordinary failure such
as a missing file was replaced by a complaint about the shape. The
model was told the output was malformed instead of what went wrong.
The shapes now describe failure too, because for a filesystem tool a
failure is an ordinary outcome rather than a fault.

A deliberate null was lost under pi. Strict argument checking makes
every field required and sends an omitted one as null, so those nulls
were stripped before use. They were stripped from every field of every
tool, including one that accepts any value at all, so a caller passing
null to a command could not be told apart from one passing nothing.
Only the fields the strict transformation widened are stripped now.

Both faults have a test that fails without the fix, and a changeset
covers the new entrypoints.
Every TanStack entry point takes an array: chat(), mergeAgentTools and
createToolRegistry all call array methods on what they are given. The
tools came back keyed by name instead, so each caller had to convert,
and the docs and both examples carried that conversion.

The record was also a trap rather than merely inconvenient.
createToolRegistry rejected it outright, but mergeAgentTools accepted
it without complaint and returned something still keyed by name, so
the failure surfaced later inside chat() as a missing array method and
pointed away from its cause.

A list is what the library consumes, so a list is what these builders
return. tanStackToolsByName covers the rarer case of reaching one tool
directly, such as adjusting a single tool before the call.

An earlier note claimed a server-side registry wanted the record. It
does not; mergeAgentTools is typed ReadonlyArray on that argument, and
nothing in the library asks for the keyed shape.
Replaces the separate tanStackToolsByName helper with a `format`
option on the builders, so one call site covers both shapes.

It defaults to "array", which every TanStack entry point takes, so the
common path is unchanged and needs no argument. "object" keys the same
tools by name for a caller that reaches one directly.

The option is generic rather than a plain union, so the return type
follows from the literal: passing "object" types the result as a
record and "array" or nothing types it as a list. A caller that passes
a computed format gets the union and has to narrow, which is honest
about what it asked for.

@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 3 new potential issues.

Devin Review

Comment on lines +187 to +188
execute: async (input, context) => {
const returned = runSpec(spec, input, { abortSignal: options.signal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃敶 Chat cancellation leaves commands running

When TanStack aborts a chat, execute ignores context.abortSignal and keeps exec running. Only the factory-wide signal reaches the executor.

Learn more

TanStack passes each chat run's cancellation signal through ToolExecutionContext.abortSignal. The adapter's local context type omits that field, then supplies only the factory-wide signal to the shared executor. A caller using TanStack's normal abortController therefore cancels model generation but not the workspace command.

Example: A chat starts npm test, then the client disconnects and aborts the chat controller. TanStack aborts the run, but handle.kill() is never called, so the command continues in the backend.

Recommended fix: Add abortSignal?: AbortSignal to TanStackToolExecutionContext and pass the per-call signal into runSpec. If both per-call and factory signals can differ, combine them so either one cancels execution; otherwise prefer context?.abortSignal with options.signal as the fallback.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +223 to +230
let last: Output | undefined;
let seen = false;
for await (const chunk of returned) {
if (seen) {
emit(eventName, { toolCallId: context?.toolCallId, snapshot: last as never });
}
last = chunk;
seen = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 First progress event arrives late

When exec yields one running snapshot, settleWithEvents stores it without calling emitCustomEvent. Quiet commands expose no progress until another snapshot or completion.

Learn more

The adapter buffers one snapshot to distinguish intermediate output from the final return. That creates one-snapshot latency, which becomes unbounded when the command emits output and then remains quiet. The optional streamEventName feature therefore cannot provide its promised live view for this common stream shape.

Example: A command prints starting and then performs ten minutes of work without more output. The running snapshot containing starting is retained for ten minutes and appears only when the terminal snapshot arrives.

Recommended fix: Emit progressive snapshots as they arrive rather than waiting for the next item. Either permit the terminal snapshot to be both emitted and returned, or add an explicit terminal discriminator so only known nonterminal snapshots are emitted immediately.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

Comment on lines +195 to +202
let output: unknown;
try {
output = await settle(runSpec(spec, parsed.data, context));
} catch (err) {
return errorResult(err instanceof Error ? err.message : String(err));
}

return toPiResult(await applyModelOutput(spec, parsed.data, output));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃煛 Output hooks escape pi error handling

When a custom toModelOutput rejects, createSpecExecutor rejects instead of returning isError: true. The catch ends before applyModelOutput.

Suggested change
let output: unknown;
try {
output = await settle(runSpec(spec, parsed.data, context));
} catch (err) {
return errorResult(err instanceof Error ? err.message : String(err));
}
return toPiResult(await applyModelOutput(spec, parsed.data, output));
try {
const output = await settle(runSpec(spec, parsed.data, context));
return toPiResult(await applyModelOutput(spec, parsed.data, output));
} catch (err) {
return errorResult(err instanceof Error ? err.message : String(err));
}

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

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.

1 participant