diff --git a/.changeset/pi-tanstack-tools.md b/.changeset/pi-tanstack-tools.md new file mode 100644 index 00000000..aadab7ef --- /dev/null +++ b/.changeset/pi-tanstack-tools.md @@ -0,0 +1,20 @@ +--- +"@cloudflare/computer": minor +--- + +Add the workspace tools for two more agent libraries, so the same +`read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec` +tools now work whichever of three libraries an agent is built on. +`@cloudflare/computer/tools/pi` serves pi, which keeps the tool list +apart from the code that runs the tools, so it returns both the +declarations and a dispatcher. `@cloudflare/computer/tools/tanstack` +serves TanStack AI, which runs the tools itself and takes them as a +list, so a list is what it returns; `format: "object"` keys them by +name when a single tool has to be reached. Each library is an optional +peer dependency, so installing one does not pull in the others. + +Each library declares its own tools, so it can use that library's own +features directly. What they share is the workspace logic underneath: +the executors, their schemas and descriptions, and the options that +decide which tools exist. `@cloudflare/computer/tools` is unchanged for +AI SDK callers. diff --git a/README.md b/README.md index dd124e41..a987004c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ public surface. Each is a Worker workspace with its own README. - [`examples/rlm`](examples/rlm) — shows how generated JavaScript can read long context from a Computer Workspace, call bounded model workers, and reduce their structured results with code. +- [`examples/pi`](examples/pi) — a one-shot [pi](https://github.com/earendil-works/pi) + agent: a minimal loop that asks a model, runs the workspace tools it asks + for, and repeats until it is done. +- [`examples/tanstack-ai`](examples/tanstack-ai) — the same one-shot agent on + [TanStack AI](https://tanstack.com/ai), where `chat()` owns the loop. - [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) chat agent that uses the workspace as its working directory, reachable from a terminal. diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 93bfb800..c066877e 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -1,6 +1,12 @@ # 09. Tool interface (agents) -`@cloudflare/computer/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`. +`@cloudflare/computer/tools` ships ready-made tools for agents that use a `Workspace`. Three agent SDKs are supported from one implementation: + +| SDK | Entrypoint | Factory | +| --- | --- | --- | +| [AI SDK](https://github.com/vercel/ai) (`ai`) | `@cloudflare/computer/tools` | `createAITools` | +| [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`) | `@cloudflare/computer/tools/pi` | `createPiTools` | +| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack` | `createTanStackTools` | The tools wrap three Workspace surfaces: @@ -8,11 +14,65 @@ The tools wrap three Workspace surfaces: - `workspace.runtime.exec` for command execution when the caller opts in; - `workspace.assets` for publishing generated files when an assets publisher is configured. +Every factory takes the same options and produces the same tools with the same names, descriptions, schemas, and caps. Only the returned shape differs, because each SDK wants a different one. Each SDK's package is an optional peer dependency: importing one adapter does not require the other two to be installed. + +## Shared logic, separate tools + +Each library gets its own directory, and declares its own tools in that +library's own terms: + +``` +tools/ + common/ ← executors, schemas, descriptions, options, helpers + ai-sdk/ ← createAITools (ai) + pi/ ← createPiTools (pi-ai, TypeBox) + tanstack-ai/ ← createTanStackTools (@tanstack/ai) +``` + +`common/` is deliberately not a tool abstraction. It holds the workspace +logic underneath the tools — reading a file by line and byte offset, +applying an edit and rendering its diff, buffering a command's output — +along with each tool's Zod schema and description text, the shared +options, and a few helpers with no library in them. A provider directory +imports those executors and writes out its own tool declarations. + +That means the same tool is declared three times, once per library. The +duplication is intentional: it is a few lines of declaration each, and +in exchange every provider can use its library's own features directly +instead of through a lowest-common-denominator layer. + +Where the libraries genuinely differ, each provider handles it in its +own file: + +| Concern | AI SDK | pi | TanStack AI | +| --- | --- | --- | --- | +| Schema | Zod, passed through | converted to JSON Schema for TypeBox | Zod, passed through (Standard Schema) | +| Execution | `execute` on the tool | caller's loop, via the returned `execute` dispatcher | `execute` on the tool | +| Streaming `exec` | progressive tool results | terminal snapshot | terminal snapshot, optional custom events | +| Images and PDFs | typed `file` output part | base64 `image` block (PDFs degrade to text) | base64 payload plus media type | +| Argument enforcement | schema validation | provider-side constrained sampling | schema validation | +| Result shape | `toModelOutput` | `toolResult` content blocks | `outputSchema` | +| Approval and discovery | — | — | `needsApproval`, `lazy` | + +Because each provider owns its declarations, it can act on what its +library supports without asking the others to agree. pi marks `edit` and +`write` for provider-side constrained sampling because their arguments +are easy to malform; TanStack marks the mutating tools `needsApproval` +and can emit progressive `exec` output as custom events; the AI SDK +hands an async iterable straight to `execute` so streaming works with no +draining at all. + +A fourth library is a new directory next to these three. It imports the +executors and schemas from `common/` and declares its tools however that +library wants them. + ## What ships | Export | Purpose | | --- | --- | | `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. | +| `createPiTools` | Create pi tool declarations plus their executor. | +| `createTanStackTools` | Create the TanStack AI tool list for a Workspace. | | `createReadTool` | Stream text by line and pass images or PDFs to capable models. | | `createWriteTool` | Write a whole file with a UTF-8 byte cap. | | `createEditTool` | Apply atomic targeted replacements and return a unified diff. | @@ -23,8 +83,9 @@ The tools wrap three Workspace surfaces: | `createExecTool` | Run a command through a configured Workspace backend. | | `createPublishTool` | Publish a workspace file through `workspace.assets`. | | `WorkspaceFileStore` | Adapt `workspace.fs` to the store used by file tools. | +| `resolveToolOptions` | Resolve the shared options and tool-inclusion rules. | -`createAITools()` always names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. +Every factory always names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. ## Wiring up @@ -55,6 +116,90 @@ export class Agent { Pass the returned AI SDK `ToolSet` to `generateText`, `streamText`, or an agent framework hook such as `getTools()`. +### pi + +pi splits a tool into data and execution: `Context.tools` carries declarations with TypeBox `parameters`, and the caller's own agent loop runs the calls. `createPiTools` returns both halves so they cannot drift apart. + +```ts +import { createPiTools } from "@cloudflare/computer/tools/pi"; +import { builtinModels } from "@earendil-works/pi-ai/providers/all"; + +const { tools, execute } = createPiTools({ workspace }); +const models = builtinModels(); +const model = models.getModel("anthropic", "claude-sonnet-4-5")!; + +const context = { + systemPrompt: "You are a coding agent working in /workspace.", + messages: [{ role: "user", content: "Summarize the README.", timestamp: Date.now() }], + tools, +}; + +// One turn of the caller's loop. +const message = await models.complete(model, context); +context.messages.push(message); + +for (const block of message.content) { + if (block.type !== "toolCall") continue; + const { content, isError } = await execute(block); + context.messages.push({ + role: "toolResult", + toolCallId: block.id, + toolName: block.name, + content, + isError, + timestamp: Date.now(), + }); +} +``` + +`execute` validates the call's arguments against the tool's schema and returns pi `toolResult` content, reporting a bad call or a failed tool as `isError: true` so the model can retry instead of the loop throwing. The Zod schemas are converted to plain JSON Schema for TypeBox, so a field with a default stays optional for the model and pi applies the default during validation. + +Tools whose arguments are structurally fussy — `read`, `write`, and `edit`, which carry byte offsets and long verbatim strings — are declared with pi's `constrainedSampling`, so a provider that supports it enforces the schema during sampling and a malformed `edit` never reaches the tool. Strict enforcement requires a closed schema in which every property is listed, so those declarations set `additionalProperties: false`, mark each optional field nullable, and the dispatcher drops the resulting top-level nulls before validation. + +The default is `strict: "prefer"`, which falls back to ordinary tool calling on a provider that cannot enforce a schema. Pass `constrainedSampling: "require"` to fail the request instead, when the caller pins a model known to support it, or `false` to opt out and get the open schemas: + +```ts +const { tools } = createPiTools({ workspace, constrainedSampling: "require" }); +``` + +### TanStack AI + +A TanStack tool is a plain object whose `inputSchema` is a Standard Schema, which Zod implements, so the schemas are passed through with no conversion. The tools come back as a list, which is what every TanStack entry point takes: `chat({ tools })`, `mergeAgentTools`, and `createToolRegistry` all want an array. Pass `format: "object"` to get them keyed by name instead, for reaching one tool directly, such as to adjust a single tool before the call. + +```ts +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + const abortController = new AbortController(); + + const tools = createTanStackTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "Worker shell." } } }, + approve: "mutating", + }); + + return toServerSentEventsResponse( + chat({ + adapter: anthropicText("claude-sonnet-4-5"), + messages, + tools, + abortController, + }), + ); +} +``` + +`approve` marks tools that pause for confirmation through TanStack's `needsApproval`. Pass a name list, or `"mutating"` to gate every tool that changes workspace state so the list does not have to be restated as the tool set grows. `lazy` takes the same shape and marks tools to withhold from the prompt until TanStack's lazy discovery asks for them, which keeps a workspace tool set out of the system prompt for an agent whose file work is occasional. + +The tools that have one fixed result shape — `write`, `edit`, `delete`, and `publish` — also carry an `outputSchema`, which TanStack validates client-side and threads into its typed hooks. Paged tools like `ls` omit it. The schema covers the error branch as well as success, because TanStack validates every return against it: a success-only schema would replace a real failure reason with a validation complaint. + +Aborting the chat run through its `abortController` kills a running `exec`, because TanStack hands each tool call the run's abort signal. A TanStack tool settles on one value, so `exec` returns the run's terminal snapshot; set `streamEventName` to also forward each running snapshot through `emitCustomEvent` as it arrives, for a live view of a command's output. + +### Shared options + Pass `shell` only when the Workspace has matching backend ids: ```ts diff --git a/examples/pi/README.md b/examples/pi/README.md new file mode 100644 index 00000000..71d41ea2 --- /dev/null +++ b/examples/pi/README.md @@ -0,0 +1,46 @@ +# pi agent + +A one-shot agent built on [pi](https://github.com/earendil-works/pi). Send it a +task, it works in a durable Workspace, and it replies when it is done. + +The whole agent loop is the `run` method in [`src/index.ts`](src/index.ts): ask +the model, run whatever tools it asked for, repeat until it stops asking. pi +keeps the list of tools separate from the code that runs them, so +`createPiTools` hands back both — `tools` to show the model, and `execute` to +run one of its requests. + +The workspace tools come from +[`@cloudflare/computer/tools/pi`](../../docs/09_tool_interface.md): `read`, +`ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`. + +[`src/workers-ai.ts`](src/workers-ai.ts) teaches pi to reach Workers AI through +the `AI` binding rather than the REST endpoint, so the example needs no API +key. It is lifted from the pi harness example in +[cloudflare/agents](https://github.com/cloudflare/agents). + +## Run it + +```sh +npm install +npm run dev --workspace @example/computer-pi +``` + +Then give it something to do: + +```sh +curl -X POST http://localhost:8787 \ + -H 'content-type: application/json' \ + -d '{"task":"Write a haiku about durable objects to /workspace/haiku.txt, then read it back."}' +``` + +The agent writes the file with the `write` tool and reads it back with `read`, +then says what it did. Ask it to `grep` or run a shell command and it will +reach for those tools instead. + +This uses the remote Workers AI binding and counts against your account's +Workers AI usage. If your Wrangler login has access to more than one account, +set `CLOUDFLARE_ACCOUNT_ID` before starting. + +Small models pick tools less reliably than large ones. If the agent replies +without touching a file, say the task more plainly or try a bigger model in +`MODEL`. diff --git a/examples/pi/package.json b/examples/pi/package.json new file mode 100644 index 00000000..5cbe0109 --- /dev/null +++ b/examples/pi/package.json @@ -0,0 +1,22 @@ +{ + "name": "@example/computer-pi", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object running a one-shot pi agent against a Workspace, with tools from @cloudflare/computer/tools/pi.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.85.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.130.0" + } +} diff --git a/examples/pi/run-local.mjs b/examples/pi/run-local.mjs new file mode 100644 index 00000000..72195436 --- /dev/null +++ b/examples/pi/run-local.mjs @@ -0,0 +1,131 @@ +// Drive the pi example's agent loop locally, with no Cloudflare +// account. The loop, tools, and Workspace are real; only the model and +// the storage are substituted, so the tool calls below are scripted +// rather than chosen. +// +// node run-local.mjs + +import { Workspace } from "@cloudflare/computer"; +import { createPiTools } from "@cloudflare/computer/tools/pi"; +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { + createModels, + fauxAssistantMessage, + fauxProvider, + fauxText, + fauxToolCall, +} from "@earendil-works/pi-ai"; + +const MAX_TURNS = 10; + +const workspace = new Workspace({ storage: new SQLiteTestStorage() }); +// Declared so `exec` exists. No shell backend runs under plain node, +// so a call fails at the backend — past the argument handling checked +// at the end of this script. +const { tools, execute } = createPiTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "test shell" } } }, +}); + +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +const model = faux.getModel(); + +// One scripted reply per turn: write a file, read it back, then answer. +faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall("write", { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }), + ], + { stopReason: "toolUse" }, + ), +]); + +const messages = [ + { + role: "user", + content: "Write a haiku to /workspace/haiku.txt then read it back.", + timestamp: Date.now(), + }, +]; + +let answer = "(no answer)"; +for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: "You are working in a directory at /workspace.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + answer = reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + break; + } + + for (const call of calls) { + const { content, isError } = await execute(call); + console.log( + `turn ${turn}: ${call.name}(${JSON.stringify(call.arguments).slice(0, 60)}) -> isError=${isError} ${JSON.stringify(content).slice(0, 110)}`, + ); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + + if (turn === 0) { + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("read", { path: "/workspace/haiku.txt" })], { + stopReason: "toolUse", + }), + ]); + } else if (turn === 1) { + faux.setResponses([fauxAssistantMessage([fauxText("Wrote the haiku and read it back.")])]); + } +} + +console.log("\nanswer:", answer); + +// Prove the tools really touched the workspace, not a mock of it. +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("file on disk:", JSON.stringify(onDisk)); + +// A failure must come back as a retryable error result, not a throw. +const missing = await execute({ + id: "x", + name: "read", + arguments: { path: "/workspace/nope.txt" }, +}); +console.log( + "missing file -> isError=%s %s", + missing.isError, + JSON.stringify(missing.content).slice(0, 80), +); + +// A deliberate null must reach the backend rather than being dropped +// as if the argument had been omitted. +const nulled = await execute({ + id: "y", + name: "exec", + arguments: { command: "echo hi", input: null }, +}); +console.log( + "exec input:null -> isError=%s %s", + nulled.isError, + JSON.stringify(nulled.content).slice(0, 110), +); + +await workspace.close?.(); diff --git a/examples/pi/src/index.ts b/examples/pi/src/index.ts new file mode 100644 index 00000000..1fd5cd22 --- /dev/null +++ b/examples/pi/src/index.ts @@ -0,0 +1,112 @@ +// A one-shot agent on pi, working in a durable Workspace. pi leaves +// the agent loop to the caller, so `run` below is that whole loop. +// +// client ──► Worker / ──► PiAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createPiTools } from "@cloudflare/computer/tools/pi"; +import { createModels, type Message } from "@earendil-works/pi-ai"; + +import { WORKERS_AI_PROVIDER, workersAI } from "./workers-ai.js"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +// Bound the spend if the model fails to converge. +const MAX_TURNS = 10; + +export class PiAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "PiAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + const { tools, execute } = createPiTools({ + workspace: this.workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "A shell with common text commands." } }, + }, + }); + + const models = createModels(); + models.setProvider(workersAI(this.env.AI, MODEL)); + const model = models.getModel(WORKERS_AI_PROVIDER, MODEL); + if (!model) throw new Error(`model ${MODEL} is not registered`); + + const messages: Message[] = [{ role: "user", content: task, timestamp: Date.now() }]; + + for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + return reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + } + + for (const call of calls) { + const { content, isError } = await execute(call); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + } + + return `Gave up after ${MAX_TURNS} turns.`; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + 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`); + }, +} satisfies ExportedHandler; diff --git a/examples/pi/src/workers-ai.ts b/examples/pi/src/workers-ai.ts new file mode 100644 index 00000000..b5deea53 --- /dev/null +++ b/examples/pi/src/workers-ai.ts @@ -0,0 +1,99 @@ +// pi's Workers AI provider, transported over the `AI` binding rather +// than the REST endpoint, so the example needs no API key. Only the +// transport differs from pi's own provider. +// +// Lifted from the pi harness example in cloudflare/agents. + +import { + type ApiStreamOptions, + type Context, + createProvider, + type Model, + type ProviderStreams, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; + +export const WORKERS_AI_PROVIDER = "cloudflare-workers-ai"; + +type RunBinding = { + run( + model: string, + input: Record, + options: { returnRawResponse: true; signal?: AbortSignal }, + ): Promise; +}; + +function bodyText(body: BodyInit | null | undefined): string { + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + throw new TypeError("Workers AI pi requests require a JSON request body"); +} + +function model(id: string): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: WORKERS_AI_PROVIDER, + // Never dialed: the fetch below answers every request through the + // binding instead. pi still wants a syntactically valid base URL. + baseUrl: "https://workers-ai.binding.invalid/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + }, + }; +} + +export function workersAI(binding: Ai, modelId: string) { + // SAFETY: Workers AI returns a Response when `returnRawResponse` is + // set. The public `Ai` overload cannot express that correlation. + const runBinding = binding as unknown as RunBinding; + + const fetch = async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const input = JSON.parse(bodyText(init?.body)) as Record; + const name = typeof input.model === "string" ? input.model : undefined; + if (!name) throw new TypeError("Workers AI pi request is missing its model"); + delete input.model; + return runBinding.run(name, input, { + returnRawResponse: true, + ...(init?.signal ? { signal: init.signal } : {}), + }); + }; + + const api = openAICompletionsApi(); + const streams: ProviderStreams = { + stream: (m, context, options) => + api.stream(m, context, { ...options, fetch } as ApiStreamOptions), + streamSimple: (m: Model, context: Context, options?: SimpleStreamOptions) => + api.streamSimple(m, context, { ...options, fetch }), + }; + + return createProvider({ + id: WORKERS_AI_PROVIDER, + name: "Cloudflare Workers AI", + // The binding carries its own authorization, so there is no key to + // resolve. pi still requires every provider to declare auth. + auth: { + apiKey: { + name: "Workers AI binding", + check: async () => ({ type: "api_key" as const, source: "Workers AI binding" }), + resolve: async () => ({ + auth: { apiKey: "workers-ai-binding" }, + source: "Workers AI binding", + }), + }, + }, + models: [model(modelId)], + api: streams, + }); +} diff --git a/examples/pi/tsconfig.json b/examples/pi/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/pi/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/pi/wrangler.jsonc b/examples/pi/wrangler.jsonc new file mode 100644 index 00000000..50ee2941 --- /dev/null +++ b/examples/pi/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + // Example: a one-shot pi agent in a Durable Object. + // + // The DO holds a Workspace whose shell is a Dynamic Worker, and + // talks to Workers AI through the AI binding, so the example needs + // no API key. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-pi-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI. Running this uses your account's Workers AI quota. + "ai": { + "binding": "AI" + }, + + // The workspace shell runs in a Dynamic Worker minted through this. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "PiAgent", + "class_name": "PiAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["PiAgent"] + } + ] +} diff --git a/examples/tanstack-ai/README.md b/examples/tanstack-ai/README.md new file mode 100644 index 00000000..1668b521 --- /dev/null +++ b/examples/tanstack-ai/README.md @@ -0,0 +1,48 @@ +# TanStack AI agent + +A one-shot agent built on [TanStack AI](https://tanstack.com/ai). Send it a +task, it works in a durable Workspace, and it replies when it is done. + +There is no loop to write here. `chat()` owns it: it calls the tools the model +asks for, feeds the results back, and keeps going until the model is finished. +`streamToText` waits for that and returns the final text. The agent is about +ten lines in [`src/index.ts`](src/index.ts). + +The workspace tools come from +[`@cloudflare/computer/tools/tanstack`](../../docs/09_tool_interface.md): +`read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`. They +arrive as a list, which is the shape `chat()` wants. + +The Cloudflare adapter talks to Workers AI through the `AI` binding, so the +example needs no API key. + +## Run it + +```sh +npm install +npm run dev --workspace @example/computer-tanstack-ai +``` + +Then give it something to do: + +```sh +curl -X POST http://localhost:8787 \ + -H 'content-type: application/json' \ + -d '{"task":"Write a haiku about durable objects to /workspace/haiku.txt, then read it back."}' +``` + +The agent writes the file with the `write` tool and reads it back with `read`, +then says what it did. Ask it to `grep` or run a shell command and it will +reach for those tools instead. + +To make it ask before changing anything, pass `approve: "mutating"` to +`createTanStackTools`. Tools marked that way pause for confirmation instead of +running straight away. + +This uses the remote Workers AI binding and counts against your account's +Workers AI usage. If your Wrangler login has access to more than one account, +set `CLOUDFLARE_ACCOUNT_ID` before starting. + +Small models pick tools less reliably than large ones. If the agent replies +without touching a file, say the task more plainly or try a bigger model in +`MODEL`. diff --git a/examples/tanstack-ai/package.json b/examples/tanstack-ai/package.json new file mode 100644 index 00000000..eb5b5d81 --- /dev/null +++ b/examples/tanstack-ai/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example/computer-tanstack-ai", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object running a one-shot TanStack AI agent against a Workspace, with tools from @cloudflare/computer/tools/tanstack.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.55.0", + "@tanstack/ai-cloudflare": "^0.1.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.130.0" + } +} diff --git a/examples/tanstack-ai/run-local.mjs b/examples/tanstack-ai/run-local.mjs new file mode 100644 index 00000000..1278390e --- /dev/null +++ b/examples/tanstack-ai/run-local.mjs @@ -0,0 +1,103 @@ +// Drive the TanStack AI example's agent loop locally, with no +// Cloudflare account. `chat()`, the tools, and the Workspace are real; +// only the provider is substituted, so the tool calls below are +// scripted rather than chosen. +// +// node run-local.mjs + +import { Workspace } from "@cloudflare/computer"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack"; +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { chat, maxIterations } from "@tanstack/ai"; + +const workspace = new Workspace({ storage: new SQLiteTestStorage() }); +const tools = createTanStackTools({ workspace }); + +// One scripted turn per agent-loop iteration. +const script = [ + { + toolCalls: [ + { + name: "write", + args: { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }, + }, + ], + }, + { toolCalls: [{ name: "read", args: { path: "/workspace/haiku.txt" } }] }, + { text: "Wrote the haiku and read it back." }, +]; + +let turn = 0; + +// The smallest adapter shape chat() will drive: AG-UI events for one +// assistant turn, then stop. +const scriptedAdapter = { + name: "scripted", + model: "scripted", + provider: "scripted", + capabilities: { streaming: true, tools: true }, + async *chatStream() { + const step = script[Math.min(turn, script.length - 1)]; + turn += 1; + const messageId = `m${turn}`; + yield { type: "RUN_STARTED", timestamp: Date.now() }; + + if (step.toolCalls) { + for (const [i, call] of step.toolCalls.entries()) { + const toolCallId = `call-${turn}-${i}`; + yield { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: call.name, + toolName: call.name, + index: i, + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_ARGS", + toolCallId, + delta: JSON.stringify(call.args), + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_END", + toolCallId, + toolCallName: call.name, + toolName: call.name, + input: call.args, + timestamp: Date.now(), + }; + } + yield { type: "RUN_FINISHED", finishReason: "tool_calls", timestamp: Date.now() }; + return; + } + + yield { type: "TEXT_MESSAGE_START", messageId, role: "assistant", timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_CONTENT", messageId, delta: step.text, timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_END", messageId, timestamp: Date.now() }; + yield { type: "RUN_FINISHED", finishReason: "stop", timestamp: Date.now() }; + }, +}; + +const stream = chat({ + adapter: scriptedAdapter, + systemPrompts: ["You are working in a directory at /workspace."], + messages: [{ role: "user", content: "Write a haiku to /workspace/haiku.txt then read it back." }], + tools, + agentLoopStrategy: maxIterations(10), +}); + +const chunks = []; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + chunks.push(JSON.stringify(chunk).slice(0, 320)); + } + if (chunk.type === "TEXT_MESSAGE_CONTENT") chunks.push(`text: ${chunk.delta}`); +} +for (const line of chunks) console.log(line); + +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("\nfile on disk:", JSON.stringify(onDisk)); diff --git a/examples/tanstack-ai/src/index.ts b/examples/tanstack-ai/src/index.ts new file mode 100644 index 00000000..13e471d0 --- /dev/null +++ b/examples/tanstack-ai/src/index.ts @@ -0,0 +1,85 @@ +// A one-shot agent on TanStack AI, working in a durable Workspace. +// +// client ──► Worker / ──► TanStackAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack"; +import { chat, maxIterations, streamToText } from "@tanstack/ai"; +import { cloudflareText } from "@tanstack/ai-cloudflare"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +export class TanStackAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "TanStackAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + const tools = createTanStackTools({ + workspace: this.workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "A shell with common text commands." } }, + }, + }); + + const stream = chat({ + // The cast is a version mismatch, not a real one: the adapter is + // on @cloudflare/workers-types v4 and this repo is on v5, so the + // two structurally identical `Ai` types will not unify. Drop it + // once the adapter moves to v5. + adapter: cloudflareText(MODEL, { binding: this.env.AI as unknown as never }), + systemPrompts: [ + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + ], + messages: [{ role: "user", content: task }], + tools, + // Bound the spend if the model fails to converge. + agentLoopStrategy: maxIterations(10), + }); + + return await streamToText(stream); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + 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`); + }, +} satisfies ExportedHandler; diff --git a/examples/tanstack-ai/tsconfig.json b/examples/tanstack-ai/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/tanstack-ai/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/tanstack-ai/wrangler.jsonc b/examples/tanstack-ai/wrangler.jsonc new file mode 100644 index 00000000..16d79fbc --- /dev/null +++ b/examples/tanstack-ai/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + // Example: a one-shot TanStack AI agent in a Durable Object. + // + // The DO holds a Workspace whose shell is a Dynamic Worker, and + // talks to Workers AI through the AI binding, so the example needs + // no API key. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-tanstack-ai-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI. Running this uses your account's Workers AI quota. + "ai": { + "binding": "AI" + }, + + // The workspace shell runs in a Dynamic Worker minted through this. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "TanStackAgent", + "class_name": "TanStackAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TanStackAgent"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 97586512..159a3c24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,19 @@ "wrangler": "^4.130.0" } }, + "examples/pi": { + "name": "@example/computer-pi", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.85.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.130.0" + } + }, "examples/rlm": { "name": "@cloudflare/example-rlm", "version": "0.0.0", @@ -135,6 +148,20 @@ "wrangler": "^4.130.0" } }, + "examples/tanstack-ai": { + "name": "@example/computer-tanstack-ai", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.55.0", + "@tanstack/ai-cloudflare": "^0.1.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.130.0" + } + }, "examples/think": { "name": "@cloudflare/example-think", "version": "0.0.0", @@ -274,6 +301,20 @@ "wrangler": "^4.130.0" } }, + "node_modules/@ag-ui/core": { + "version": "0.1.1-canary.beta.0", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.1.1-canary.beta.0.tgz", + "integrity": "sha512-zcP3RH5p3HJTZ0kyQxNOL1blnVFTDpaShitR7UMS/thwFdl48V6CAG+WQBcYEu/dfa8mKonRez88N0Ok+nEI+w==", + "license": "MIT", + "peerDependencies": { + "zod": "^3.25.18 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@ai-sdk/anthropic": { "version": "4.0.24", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.24.tgz", @@ -403,6 +444,27 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -454,6 +516,438 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.53.tgz", + "integrity": "sha512-bIrDaMENQmYRBHntOiOheqkiw5+fhKW4Lqb+mS1uqF0VwvdWI22fW2HFgWrng66CmYd+4k8ePlpj38sEfTuMLQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2072,6 +2566,39 @@ "node": ">=20.19.0" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", @@ -2543,6 +3070,14 @@ "resolved": "examples/mcp", "link": true }, + "node_modules/@example/computer-pi": { + "resolved": "examples/pi", + "link": true + }, + "node_modules/@example/computer-tanstack-ai": { + "resolved": "examples/tanstack-ai", + "link": true + }, "node_modules/@example/computer-tutorial": { "resolved": "examples/tutorial", "link": true @@ -2573,6 +3108,30 @@ } } }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", @@ -4019,6 +4578,63 @@ "dev": true, "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -4425,6 +5041,125 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", @@ -4432,6 +5167,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4690,36 +5431,205 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/ai": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai/-/ai-0.55.0.tgz", + "integrity": "sha512-VkevqxTDQWeVjWIAR2SQzR6Hi8t6dNdZHfh4jQZEumanwyzsUbBvuPpzf6RldnMY7Z4eX0MHfV9esxAiOukc+A==", + "license": "MIT", + "dependencies": { + "@ag-ui/core": "0.1.1-canary.beta.0", + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai-event-client": "^0.11.3", + "@tanstack/ai-utils": "^0.4.0", + "partial-json": "^0.1.7" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@tanstack/ai-cloudflare": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/ai-cloudflare/-/ai-cloudflare-0.1.2.tgz", + "integrity": "sha512-gSKL8/UapufQ5T75rySKOzrKrsIrPiSTs/PaXaTRM5JIbOwSt2EHqKG5Wmo08/OkE4joMSniE3zevuXHTl37yA==", + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai-utils": "^0.4.0", + "@tanstack/openai-base": "^0.10.12", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/ai": "^0.55.0" + } + }, + "node_modules/@tanstack/ai-cloudflare/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@tanstack/ai-cloudflare/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@tanstack/ai-event-client": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@tanstack/ai-event-client/-/ai-event-client-0.11.3.tgz", + "integrity": "sha512-K5Nw05TG2NPJNunV7LI/5m7S97XHHdxN7vwTYcbKbmA5txlToaiF4wCfYBT406m7BOeGlLHZJpr3m1M6Vaai4g==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/ai-utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai-utils/-/ai-utils-0.4.0.tgz", + "integrity": "sha512-xqvAgPJkIuGk1m+jZKyb7vBTQIaBmUD9EVzlPkDWWMp80n69uwL0TnBZCVk+OwL9goTQiFy648dnBfLsiJgl6A==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, "engines": { - "node": ">= 20" + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, + "node_modules/@tanstack/openai-base": { + "version": "0.10.12", + "resolved": "https://registry.npmjs.org/@tanstack/openai-base/-/openai-base-0.10.12.tgz", + "integrity": "sha512-FcaUXi2xP8r12HetLej4r04oIscVs4Xi4sN5EAE85jZDbfHOTUTRbRSywu6DthXiR8R4+ht8MzznMlenf/QLFg==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" + "@tanstack/ai-utils": "^0.4.0", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" + "@tanstack/ai": "^0.55.0" + } + }, + "node_modules/@tanstack/openai-base/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@testing-library/dom": { @@ -4899,7 +5809,6 @@ "version": "25.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -4924,6 +5833,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5137,6 +6052,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/agents": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/agents/-/agents-0.20.1.tgz", @@ -5469,6 +6393,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/birpc": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", @@ -5575,6 +6508,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -5658,6 +6597,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6069,6 +7014,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6301,6 +7255,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6724,6 +7687,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", @@ -6807,6 +7776,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -6888,6 +7880,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6996,6 +8000,52 @@ "integrity": "sha512-Dj4ssxo1/MKGvOsVWRblSRu+o5F5OJTrVPDkjSyGDU2yKvVnIzQSwy1deiWA0qCcS/Q8iJMlZaCpCcZWSwvoug==", "license": "MIT" }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -7138,6 +8188,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7333,6 +8409,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-id": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", @@ -7757,12 +8859,34 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -7848,6 +8972,27 @@ "node": ">=0.3.1" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -8151,6 +9296,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9392,6 +10543,26 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9553,6 +10724,24 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -9612,6 +10801,19 @@ "node": ">=6" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -9691,6 +10893,12 @@ "node": ">= 0.8" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/partyserver": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.5.9.tgz", @@ -9988,6 +11196,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10401,6 +11632,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11178,6 +12418,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11651,11 +12901,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -11715,6 +12970,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -11768,7 +13029,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -12429,6 +13689,15 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -12661,7 +13930,6 @@ "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12856,14 +14124,22 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@earendil-works/pi-ai": "^0.84.0 || ^0.85.0", "@platformatic/vfs": "*", + "@tanstack/ai": "^0.54.0 || ^0.55.0", "ai": "^6.0.196 || ^7.0.0", "zod": "^4.4.3" }, "peerDependenciesMeta": { + "@earendil-works/pi-ai": { + "optional": true + }, "@platformatic/vfs": { "optional": true }, + "@tanstack/ai": { + "optional": true + }, "ai": { "optional": true }, diff --git a/packages/computer/README.md b/packages/computer/README.md index 14833957..b3949686 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -50,8 +50,11 @@ worker-shell and worker-javascript backends additionally need the own binding requirements — see [Choosing a backend](#choosing-a-backend). Optional peer dependencies, installed only if you use the matching -feature: `ai` and `zod` (for `@cloudflare/computer/tools`), -`@platformatic/vfs` (for the Node-side VFS provider). +feature: `zod` plus one agent SDK for the tool entrypoints (`ai` for +`@cloudflare/computer/tools`, `@earendil-works/pi-ai` for +`@cloudflare/computer/tools/pi`, `@tanstack/ai` for +`@cloudflare/computer/tools/tanstack`), `@platformatic/vfs` (for the +Node-side VFS provider). ## Quick start @@ -272,6 +275,14 @@ framework's `getTools()`. The default set is `read`, `ls`, `find`, when you configure them. Read-only mode keeps `read`, `ls`, `find`, and `grep`. +The same tools are available for two other agent SDKs, built from one +shared implementation so the names, descriptions, schemas, and caps +match: `@cloudflare/computer/tools/pi` for +[pi](https://github.com/earendil-works/pi) and +`@cloudflare/computer/tools/tanstack` for +[TanStack AI](https://tanstack.com/ai). Only the returned shape differs. +See [docs/09_tool_interface.md](../../docs/09_tool_interface.md). + ```ts import { createAITools } from "@cloudflare/computer/tools"; @@ -419,6 +430,8 @@ on a computerd instance. | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | | `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. | +| `@cloudflare/computer/tools/pi` | The same tool set for pi (`@earendil-works/pi-ai`): TypeBox declarations plus a tool-call executor. | +| `@cloudflare/computer/tools/tanstack` | The same tool set for TanStack AI (`@tanstack/ai`), keyed by tool name for `chat({ tools })`. | | `@cloudflare/computer/git` | Opt-in `isomorphic-git` glue for checkouts inside the workspace. | | `@cloudflare/computer/assets` | `createAssets` — share a workspace file to R2 as a presigned URL. | | `@cloudflare/computer/artifacts` | `createArtifact` and its CLI, an optionally session-scoped wrapper over the Cloudflare Artifacts binding. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 8240061c..7bf93623 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -35,6 +35,14 @@ "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" }, + "./tools/pi": { + "types": "./dist/tools/pi.d.ts", + "import": "./dist/tools/pi.js" + }, + "./tools/tanstack": { + "types": "./dist/tools/tanstack.d.ts", + "import": "./dist/tools/tanstack.js" + }, "./backends/container": { "types": "./dist/backends/container/index.d.ts", "import": "./dist/backends/container/index.js" @@ -121,14 +129,22 @@ "just-bash": "^3.0.1" }, "peerDependencies": { + "@earendil-works/pi-ai": "^0.84.0 || ^0.85.0", "@platformatic/vfs": "*", + "@tanstack/ai": "^0.54.0 || ^0.55.0", "ai": "^6.0.196 || ^7.0.0", "zod": "^4.4.3" }, "peerDependenciesMeta": { + "@earendil-works/pi-ai": { + "optional": true + }, "@platformatic/vfs": { "optional": true }, + "@tanstack/ai": { + "optional": true + }, "ai": { "optional": true }, diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 7f21309e..533ad69a 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,6 +31,8 @@ export default defineConfig({ "artifacts/index": "src/artifacts/index.ts", "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", + "tools/pi": "src/tools/pi/index.ts", + "tools/tanstack": "src/tools/tanstack-ai/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", "backends/worker-shell/index": "src/backends/worker-shell/index.ts", diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai-sdk/index.test.ts similarity index 99% rename from packages/computer/src/tools/ai.test.ts rename to packages/computer/src/tools/ai-sdk/index.test.ts index 5d283781..37162c9c 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai-sdk/index.test.ts @@ -1,7 +1,9 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; -import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js"; -import { Workspace } from "../workspace.js"; +import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../../runtime/types.js"; +import { Workspace } from "../../workspace.js"; +import { WorkspaceFileStore } from "../common/fs/store.js"; +import type { FileStore } from "../common/fs/types.js"; import { createAITools, createDeleteTool, @@ -10,8 +12,6 @@ import { createGrepTool, createReadTool, createWriteTool, - type FileStore, - WorkspaceFileStore, } from "./index.js"; const toolOptions = { toolCallId: "test-call", messages: [] }; diff --git a/packages/computer/src/tools/ai-sdk/index.ts b/packages/computer/src/tools/ai-sdk/index.ts new file mode 100644 index 00000000..f80ee9d3 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/index.ts @@ -0,0 +1,59 @@ +import type { ToolSet } from "ai"; +import type { FindWorkspaceLike } from "../common/fs/find.js"; +import type { GrepWorkspaceLike } from "../common/fs/grep.js"; +import type { ListWorkspaceLike } from "../common/fs/list.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import type { PublishWorkspaceLike } from "../common/publish.js"; +import { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +export type CreateAIToolsOptions = CreateToolsOptions; + +export { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +export function createAITools(options: CreateAIToolsOptions): ToolSet { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + + const tools: ToolSet = { + read: createReadTool(resolved.read), + ls: createListTool({ workspace: workspace as ListWorkspaceLike }), + find: createFindTool({ workspace: workspace as FindWorkspaceLike }), + grep: createGrepTool({ workspace: workspace as GrepWorkspaceLike }), + }; + + if (resolved.readonly) return tools; + + tools.write = createWriteTool(resolved.write); + tools.edit = createEditTool(resolved.edit); + tools.delete = createDeleteTool(resolved.delete); + + if (resolved.exec !== undefined) { + tools.exec = createExecTool(resolved.exec) as ToolSet[string]; + } + + if (resolved.publish) { + tools.publish = createPublishTool({ workspace: workspace as PublishWorkspaceLike }); + } + + return tools; +} diff --git a/packages/computer/src/tools/ai-sdk/output.ts b/packages/computer/src/tools/ai-sdk/output.ts new file mode 100644 index 00000000..963a93c2 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/output.ts @@ -0,0 +1,35 @@ +import type { JSONValue } from "ai"; +import type { ModelOutput } from "../common/model-output.js"; + +export function toAISDKOutput(output: ModelOutput) { + switch (output.type) { + case "text": + return { type: "text" as const, value: output.value }; + case "error-text": + return { type: "error-text" as const, value: output.value }; + case "json": + return { type: "json" as const, value: toJSONValue(output.value) }; + case "media": + return { + type: "content" as const, + value: [ + { type: "text" as const, text: output.text }, + { + type: "file" as const, + data: { type: "data" as const, data: output.data }, + mediaType: output.mediaType, + filename: output.filename, + }, + ], + }; + } +} + +export function toJSONValue(value: unknown): JSONValue { + try { + const json = JSON.stringify(value); + return json === undefined ? null : (JSON.parse(json) as JSONValue); + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/ai-sdk/tools.ts b/packages/computer/src/tools/ai-sdk/tools.ts new file mode 100644 index 00000000..fbbbd15a --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/tools.ts @@ -0,0 +1,143 @@ +import { type Tool, tool } from "ai"; +import type { z } from "zod"; +import { + createExecExecutor, + type ExecInput, + type ExecToolOptions, + type ExecToolOutput, + execDescription, + execInputSchema, +} from "../common/exec.js"; +import { + type DeleteToolOptions, + deleteDescription, + deleteFromStore, + deleteInputSchema, +} from "../common/fs/delete.js"; +import { + type EditToolOptions, + editDescription, + editInputSchema, + editInStore, +} from "../common/fs/edit.js"; +import { + type FindToolOptions, + findDescription, + findInputSchema, + findInWorkspace, +} from "../common/fs/find.js"; +import { + type GrepToolOptions, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "../common/fs/grep.js"; +import { + type ListToolOptions, + listDescription, + listInputSchema, + listWorkspace, +} from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolOptions, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + type WriteToolOptions, + writeDescription, + writeInputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { + createPublishExecutor, + type PublishToolOptions, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { toAISDKOutput } from "./output.js"; + +export function createReadTool(options: ReadToolOptions): Tool> { + const toModelOutput = readModelOutput(options); + return tool({ + description: readDescription(options), + inputSchema: readInputSchema, + execute: createReadExecutor(options), + toModelOutput: ({ input, output }: { input: unknown; output: unknown }) => + toAISDKOutput(toModelOutput({ input: input as ReadInput, output: output as ReadToolResult })), + }); +} + +export function createWriteTool(options: WriteToolOptions): Tool> { + return tool({ + description: writeDescription, + inputSchema: writeInputSchema, + execute: (input) => writeToStore(options, input), + }); +} + +export function createEditTool(options: EditToolOptions): Tool> { + return tool({ + description: editDescription, + inputSchema: editInputSchema, + execute: (rawInput) => editInStore(options, rawInput), + }); +} + +export function createDeleteTool( + options: DeleteToolOptions, +): Tool> { + return tool({ + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(options, input), + }); +} + +export function createListTool(options: ListToolOptions): Tool> { + return tool({ + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(options.workspace, input), + }); +} + +export function createFindTool(options: FindToolOptions): Tool> { + return tool({ + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(options.workspace, input), + }); +} + +export function createGrepTool(options: GrepToolOptions): Tool> { + return tool({ + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(options.workspace, input), + }); +} + +export function createExecTool(options: ExecToolOptions): Tool { + const executor = createExecExecutor(options); + return tool({ + description: execDescription(options), + inputSchema: execInputSchema(options), + execute: (input, { abortSignal }) => executor(input, { abortSignal }), + }); +} + +export function createPublishTool( + options: PublishToolOptions, +): Tool> { + const execute = createPublishExecutor(options.workspace); + return tool({ + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => execute(input), + }); +} diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts deleted file mode 100644 index 78cc8358..00000000 --- a/packages/computer/src/tools/ai.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ToolSet } from "ai"; -import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; -import { createDeleteTool } from "./fs/delete.js"; -import { createEditTool, type EditToolOptions } from "./fs/edit.js"; -import { createFindTool } from "./fs/find.js"; -import { createGrepTool } from "./fs/grep.js"; -import { createListTool } from "./fs/list.js"; -import { createReadTool, type ReadToolOptions } from "./fs/read.js"; -import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; -import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; - -export interface CreateAIToolsOptions { - workspace: FileWorkspaceLike & Partial & Partial; - readonly?: boolean; - assets?: boolean; - read?: Omit; - write?: Omit; - edit?: Omit; - shell?: Omit; -} - -export function createAITools(options: CreateAIToolsOptions): ToolSet { - const store = new WorkspaceFileStore(options.workspace); - const tools: ToolSet = { - read: createReadTool({ store, ...options.read }), - ls: createListTool({ workspace: options.workspace }), - find: createFindTool({ workspace: options.workspace }), - grep: createGrepTool({ workspace: options.workspace }), - }; - - if (options.readonly === true) return tools; - - tools.write = createWriteTool({ store, ...options.write }); - tools.edit = createEditTool({ store, ...options.edit }); - tools.delete = createDeleteTool({ store }); - - if (options.shell !== undefined) { - tools.exec = createExecTool({ - workspace: options.workspace as ExecWorkspaceLike, - ...options.shell, - }); - } - - if (options.assets !== false && options.workspace.assets !== undefined) { - tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); - } - - return tools; -} diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/common/exec.ts similarity index 60% rename from packages/computer/src/tools/exec.ts rename to packages/computer/src/tools/common/exec.ts index 5a58fb36..e1c9346b 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/common/exec.ts @@ -1,8 +1,7 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; -import { notCallableMessage } from "../runtime/runtime.js"; -import type { WorkspaceRuntimeValue } from "../runtime/types.js"; +import { notCallableMessage } from "../../runtime/runtime.js"; +import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; // A finite JSON value: what a callable backend accepts as `input` and // returns as `result`. Declared as a concrete recursive schema rather @@ -47,6 +46,10 @@ export interface ExecRuntimeHandle extends Partial; } +export interface ExecCallContext { + abortSignal?: AbortSignal; +} + export interface ExecWorkspaceLike { runtime: { exec( @@ -110,19 +113,26 @@ export type ExecToolOutput = } | { command: string; cwd: string | null; backend: string; error: string }; -export function createExecTool(options: ExecToolOptions): Tool< - { - command: string; - cwd?: string; - backend?: string; - env?: Record; - input?: WorkspaceRuntimeValue; - }, - ExecToolOutput -> { - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - const streamMaxBytes = options.streamMaxBytes ?? DEFAULT_STREAM_MAX_BYTES; - const now = options.now ?? Date.now; +export interface ExecInput { + command: string; + cwd?: string; + backend?: string; + env?: Record; + input?: WorkspaceRuntimeValue; +} + +/** + * Resolve and validate the backend set once. + * + * Every entrypoint that builds an exec tool needs the same derived + * facts: the backend ids, which of them are callable, and the guard + * that `defaultBackend` is actually one of them. Doing it here means a + * misconfigured tool throws at construction for all SDKs alike. + */ +function resolveBackends(options: ExecToolOptions): { + backendIds: string[]; + callableBackendIds: Set; +} { const backendIds = Object.keys(options.backends); if (backendIds.length === 0) { throw new Error("createExecTool: pass at least one backend in `backends`"); @@ -132,9 +142,20 @@ export function createExecTool(options: ExecToolOptions): Tool< `createExecTool: defaultBackend ${JSON.stringify(options.defaultBackend)} is not one of ${backendIds.map((id) => JSON.stringify(id)).join(", ")}`, ); } - const isCallable = options.workspace.runtime.isCallable?.bind(options.workspace.runtime); const callableBackendIds = new Set(backendIds.filter((id) => isCallable?.(id) === true)); + return { backendIds, callableBackendIds }; +} + +/** + * The model-facing description, including each backend's own blurb. + * + * The model picks a backend from this text alone, so the caller's + * descriptions and the callable-backend note are folded in here rather + * than left to the prompt. + */ +export function execDescription(options: ExecToolOptions): string { + const { backendIds, callableBackendIds } = resolveBackends(options); const backendGuidance = backendIds .map((id) => { const suffix = callableBackendIds.has(id) ? " (callable)" : ""; @@ -148,7 +169,7 @@ export function createExecTool(options: ExecToolOptions): Tool< `Callable backends (${[...callableBackendIds].map((id) => JSON.stringify(id)).join(", ")}) run \`command\` as module source rather than a shell command. Pass \`input\` to hand the module a structured value, and read the module's returned value back from the \`result\` field. Other backends reject \`input\`.`, ].join("\n") : ""; - const description = [ + return [ "Run a shell command in the workspace. The workspace exposes multiple backends, each with different capabilities.", "Pick the cheapest backend that can run the command; fall back to a heavier one only when the lighter backend's command set doesn't cover what you need.", "", @@ -159,7 +180,14 @@ export function createExecTool(options: ExecToolOptions): Tool< "Use for builds, test runs, typechecks, formatters, and git plumbing. Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small.", callableGuidance, ].join("\n"); +} +/** + * The input schema, whose `backend` enum is built from the configured + * backend ids so the model cannot name a backend that does not exist. + */ +export function execInputSchema(options: ExecToolOptions) { + const { backendIds } = resolveBackends(options); const backendSchema = z .enum(backendIds as [string, ...string[]]) .optional() @@ -171,130 +199,145 @@ export function createExecTool(options: ExecToolOptions): Tool< ].join(" "), ); - return tool({ - description, - inputSchema: z.object({ - command: z - .string() - .describe( - "Shell command, e.g. 'npm test' or 'git diff HEAD'. For a callable backend this is the module source to run.", - ), - cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), - backend: backendSchema, - env: z - .record(z.string(), z.string()) - .optional() - .describe( - "Environment variables for this run only. Values override the backend's base environment without affecting later runs.", - ), - input: jsonValueSchema - .optional() - .describe( - "Structured value handed to a callable backend's module. Only callable backends accept it; other backends reject it.", - ), - }), - execute: async function* ({ command, cwd, backend, env, input }, { abortSignal }) { - const selectedBackend = backend ?? options.defaultBackend; - const base = { command, cwd: cwd ?? null, backend: selectedBackend }; - if (input !== undefined && !callableBackendIds.has(selectedBackend)) { - yield { ...base, error: notCallableMessage(selectedBackend) }; - return; - } - let handle: ExecRuntimeHandle; - try { - handle = await options.workspace.runtime.exec(command, { - cwd, - encoding: "utf8", - backend: selectedBackend, - env, - input, - }); - } catch (err) { - yield { ...base, error: errorMessage(err) }; - return; - } + return z.object({ + command: z + .string() + .describe( + "Shell command, e.g. 'npm test' or 'git diff HEAD'. For a callable backend this is the module source to run.", + ), + cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), + backend: backendSchema, + env: z + .record(z.string(), z.string()) + .optional() + .describe( + "Environment variables for this run only. Values override the backend's base environment without affecting later runs.", + ), + input: jsonValueSchema + .optional() + .describe( + "Structured value handed to a callable backend's module. Only callable backends accept it; other backends reject it.", + ), + }); +} - // Aborting the model turn kills the backend execution so it does - // not run on unobserved after the tool stops iterating. The run - // then emits its terminal event and the stream closes normally. - const onAbort = () => void handle.kill?.().catch(() => undefined); - if (abortSignal?.aborted) onAbort(); - else abortSignal?.addEventListener("abort", onAbort, { once: true }); - try { - yield* runExecution(); - } finally { - abortSignal?.removeEventListener("abort", onAbort); - } +/** + * Build the streaming exec executor. + * + * Yields progressive snapshots of the run and a terminal snapshot once + * the exit code lands. Each yielded value is a complete result, so an + * SDK that cannot forward intermediate tool output can simply keep the + * last one. + */ +export function createExecExecutor( + options: ExecToolOptions, +): (input: ExecInput, context: ExecCallContext) => AsyncGenerator { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const streamMaxBytes = options.streamMaxBytes ?? DEFAULT_STREAM_MAX_BYTES; + const now = options.now ?? Date.now; + const { callableBackendIds } = resolveBackends(options); + + return async function* ({ command, cwd, backend, env, input }, { abortSignal } = {}) { + const selectedBackend = backend ?? options.defaultBackend; + const base = { command, cwd: cwd ?? null, backend: selectedBackend }; + if (input !== undefined && !callableBackendIds.has(selectedBackend)) { + yield { ...base, error: notCallableMessage(selectedBackend) }; + return; + } + let handle: ExecRuntimeHandle; + try { + handle = await options.workspace.runtime.exec(command, { + cwd, + encoding: "utf8", + backend: selectedBackend, + env, + input, + }); + } catch (err) { + yield { ...base, error: errorMessage(err) }; + return; + } - // Produce the run's snapshots. Streams the raw events when the - // handle is iterable; otherwise drains the aggregate result. - async function* runExecution(): AsyncGenerator { - // Stream stdout / stderr chunks as they arrive when the handle - // is iterable. Each chunk yields a fresh snapshot with the - // running output so the model sees progress before the run - // ends; the exit event settles the terminal snapshot. - if (typeof handle[Symbol.asyncIterator] === "function") { - const stdout = new StreamBuffer(streamMaxBytes); - const stderr = new StreamBuffer(streamMaxBytes); - let exitCode: number | null = null; - let value: unknown; - let hasValue = false; - // Coalesce running snapshots to at most one per interval. A - // chatty command would otherwise yield a full-buffer snapshot - // per chunk; the terminal snapshot below always fires. - let lastSnapshot = 0; - try { - for await (const event of handle as AsyncIterable) { - if (event.name === "stdout") stdout.push(event.value); - else if (event.name === "stderr") stderr.push(event.value); - else { - exitCode = event.code; - if ("result" in event) { - value = event.result; - hasValue = true; - } - continue; + // Aborting the model turn kills the backend execution so it does + // not run on unobserved after the tool stops iterating. The run + // then emits its terminal event and the stream closes normally. + const onAbort = () => void handle.kill?.().catch(() => undefined); + if (abortSignal?.aborted) onAbort(); + else abortSignal?.addEventListener("abort", onAbort, { once: true }); + try { + yield* runExecution(); + } finally { + abortSignal?.removeEventListener("abort", onAbort); + } + + // Produce the run's snapshots. Streams the raw events when the + // handle is iterable; otherwise drains the aggregate result. + async function* runExecution(): AsyncGenerator { + // Stream stdout / stderr chunks as they arrive when the handle + // is iterable. Each chunk yields a fresh snapshot with the + // running output so the model sees progress before the run + // ends; the exit event settles the terminal snapshot. + if (typeof handle[Symbol.asyncIterator] === "function") { + const stdout = new StreamBuffer(streamMaxBytes); + const stderr = new StreamBuffer(streamMaxBytes); + let exitCode: number | null = null; + let value: unknown; + let hasValue = false; + // Coalesce running snapshots to at most one per interval. A + // chatty command would otherwise yield a full-buffer snapshot + // per chunk; the terminal snapshot below always fires. + let lastSnapshot = 0; + try { + for await (const event of handle as AsyncIterable) { + if (event.name === "stdout") stdout.push(event.value); + else if (event.name === "stderr") stderr.push(event.value); + else { + exitCode = event.code; + if ("result" in event) { + value = event.result; + hasValue = true; } - const at = now(); - if (at - lastSnapshot < STREAM_COALESCE_MS) continue; - lastSnapshot = at; - yield { - ...base, - exitCode: null, - stdout: stdout.render(maxBytes), - stderr: stderr.render(maxBytes), - }; + continue; } - } catch (err) { - yield { ...base, error: errorMessage(err) }; - return; + const at = now(); + if (at - lastSnapshot < STREAM_COALESCE_MS) continue; + lastSnapshot = at; + yield { + ...base, + exitCode: null, + stdout: stdout.render(maxBytes), + stderr: stderr.render(maxBytes), + }; } - yield { - ...base, - exitCode, - stdout: stdout.render(maxBytes), - stderr: stderr.render(maxBytes), - ...(hasValue ? { result: value } : {}), - }; - return; - } - - // Non-streaming handle: drain the aggregate result. - try { - const result = await handle.result(); - yield { - ...base, - exitCode: result.exitCode, - stdout: truncate(result.stdout, maxBytes), - stderr: truncate(result.stderr, maxBytes), - ...(result.value === undefined ? {} : { result: result.value }), - }; } catch (err) { yield { ...base, error: errorMessage(err) }; + return; } + yield { + ...base, + exitCode, + stdout: stdout.render(maxBytes), + stderr: stderr.render(maxBytes), + ...(hasValue ? { result: value } : {}), + }; + return; } - }, - }); + + // Non-streaming handle: drain the aggregate result. + try { + const result = await handle.result(); + yield { + ...base, + exitCode: result.exitCode, + stdout: truncate(result.stdout, maxBytes), + stderr: truncate(result.stderr, maxBytes), + ...(result.value === undefined ? {} : { result: result.value }), + }; + } catch (err) { + yield { ...base, error: errorMessage(err) }; + } + } + }; } function errorMessage(err: unknown): string { diff --git a/packages/computer/src/tools/fs/delete.test.ts b/packages/computer/src/tools/common/fs/delete.test.ts similarity index 100% rename from packages/computer/src/tools/fs/delete.test.ts rename to packages/computer/src/tools/common/fs/delete.test.ts diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/common/fs/delete.ts similarity index 63% rename from packages/computer/src/tools/fs/delete.ts rename to packages/computer/src/tools/common/fs/delete.ts index 41cf6315..365cfdc5 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/common/fs/delete.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { MutableFileStore } from "./types.js"; @@ -7,7 +6,7 @@ export interface DeleteToolOptions { store: MutableFileStore; } -const inputSchema = z.object({ +export const deleteInputSchema = z.object({ path: z.string().describe("Absolute path to the file or directory to delete."), recursive: z .boolean() @@ -15,6 +14,22 @@ const inputSchema = z.object({ .describe("Remove a directory and all of its contents. Defaults to false."), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const deleteOutputSchema = z.union([ + z.object({ deleted: z.string() }), + z.object({ error: z.string() }), +]); + +export const deleteDescription = + "Delete a file or directory. Set recursive to true to remove a non-empty directory."; + export interface DeleteInput { path: string; recursive?: boolean; @@ -38,12 +53,3 @@ export function deleteFromStore( { subtree: recursive === true }, ); } - -export function createDeleteTool(options: DeleteToolOptions): Tool> { - return tool({ - description: - "Delete a file or directory. Set recursive to true to remove a non-empty directory.", - inputSchema, - execute: (input) => deleteFromStore(options, input), - }); -} diff --git a/packages/computer/src/tools/fs/edit-diff.test.ts b/packages/computer/src/tools/common/fs/edit-diff.test.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.test.ts rename to packages/computer/src/tools/common/fs/edit-diff.test.ts diff --git a/packages/computer/src/tools/fs/edit-diff.ts b/packages/computer/src/tools/common/fs/edit-diff.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.ts rename to packages/computer/src/tools/common/fs/edit-diff.ts diff --git a/packages/computer/src/tools/common/fs/edit.ts b/packages/computer/src/tools/common/fs/edit.ts new file mode 100644 index 00000000..ffd00f6f --- /dev/null +++ b/packages/computer/src/tools/common/fs/edit.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { + applyEditsToNormalizedContent, + detectLineEnding, + type Edit, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.js"; +import { withFileLock } from "./locks.js"; +import type { FileStore } from "./types.js"; + +export interface EditToolOptions { + store: FileStore; + /** + * Reject edits to files larger than this byte cap. Fuzzy matching needs the + * whole buffer in memory, so we'd rather force the model to use `write`. + * Default 2 MiB. + */ + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +const replacementSchema = z + .object({ + oldText: z + .string() + .describe( + "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", + ), + newText: z.string().describe("Replacement text for this targeted edit."), + }) + .strict(); + +export const editInputSchema = z.object({ + path: z.string().describe("Path to the file to edit"), + edits: z + .array(replacementSchema) + .describe( + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", + ), +}); + +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const editOutputSchema = z.union([ + z.object({ + path: z.string(), + editsApplied: z.number().int(), + diff: z.string(), + patch: z.string(), + firstChangedLine: z.number().int().optional(), + }), + z.object({ error: z.string() }), +]); + +export const editDescription = + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit."; + +export interface EditInput { + path: string; + edits: Edit[]; +} + +export interface EditSuccess { + path: string; + editsApplied: number; + diff: string; + patch: string; + /** Undefined when the edit produced no line-level change. */ + firstChangedLine: number | undefined; +} + +export type EditResult = EditSuccess | { error: string }; + +/** Best-effort coercion for inputs from quirky models. */ +function prepareArguments(input: unknown): { path: string; edits: Edit[] } { + if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; + const args = input as Record; + + // Some models pack edits into a JSON string. + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) args.edits = parsed; + } catch { + /* fall through to validation error */ + } + } + + // Legacy single-edit shape: oldText/newText siblings on the root object. + if (typeof args.oldText === "string" && typeof args.newText === "string") { + const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; + edits.push({ oldText: args.oldText as string, newText: args.newText as string }); + args.edits = edits; + delete args.oldText; + delete args.newText; + } + + return args as { path: string; edits: Edit[] }; +} + +/** + * Apply a batch of targeted replacements to one file. + * + * Takes the raw tool input because the coercion in `prepareArguments` + * has to run before validation: models sometimes pack `edits` into a + * JSON string or send a single `oldText`/`newText` pair at the root. + */ +export async function editInStore( + options: EditToolOptions, + rawInput: unknown, +): Promise { + const { store } = options; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const { path, edits } = prepareArguments(rawInput); + + if (!Array.isArray(edits) || edits.length === 0) { + return { error: "edits must contain at least one replacement." }; + } + + return withFileLock(store, path, async () => { + try { + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + if (stat.size > maxBytes) { + return { + error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, + }; + } + + const bytes = await store.readAll(path); + if (!bytes) return { error: `File not found: ${path}` }; + + const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes); + const { bom, text } = stripBom(rawContent); + const ending = detectLineEnding(text); + const normalized = normalizeToLF(text); + + let baseContent: string; + let newContent: string; + try { + ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + + const finalContent = bom + restoreLineEndings(newContent, ending); + // Round-trip the file's mode so editing an executable script (or any + // file with a non-default mode) doesn't silently drop bits. `stat.mode` + // is undefined for stores that don't track modes; pass `undefined` in + // that case so the store applies its own default. + await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + + return { + path, + editsApplied: edits.length, + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/packages/computer/src/tools/common/fs/find.ts b/packages/computer/src/tools/common/fs/find.ts new file mode 100644 index 00000000..6cae445a --- /dev/null +++ b/packages/computer/src/tools/common/fs/find.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +export interface FindWorkspaceLike { + fs: { + find( + directory: string, + pattern?: string, + options?: { limit?: number; offset?: number; exclude?: string[] }, + ): Promise; + }; +} + +export interface FindToolOptions { + workspace: FindWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const findInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute directory to search."), + pattern: z + .string() + .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const findDescription = + "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character."; + +export interface FindInput { + path?: string; + pattern: string; + exclude?: string[]; + limit?: number; + offset?: number; +} + +export type FindResult = + | { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } + | { error: string }; + +/** + * Page glob matches under a directory. + * + * `path` carries a schema default, but an executor can also be called + * directly by an SDK that does not apply Zod defaults, so the root + * fallback is repeated here. + */ +export async function findInWorkspace( + workspace: FindWorkspaceLike, + { path, pattern, exclude, limit, offset }: FindInput, +): Promise { + const directory = path ?? "/workspace"; + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const matches = await workspace.fs.find(directory, pattern, { + limit: pageSize + 1, + offset: pageOffset, + exclude, + }); + const truncated = matches.length > pageSize; + const entries = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } = { path: directory, pattern, count: entries.length, entries }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/grep.ts b/packages/computer/src/tools/common/fs/grep.ts new file mode 100644 index 00000000..3aea68c1 --- /dev/null +++ b/packages/computer/src/tools/common/fs/grep.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; + +interface GrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + +interface GrepMatch { + path: string; + line: number; + text: string; + context?: GrepContextLine[]; +} + +interface GrepOptions { + regex?: boolean; + ignoreCase?: boolean; + context?: number; + limit?: number; + offset?: number; + include?: string; +} + +export interface GrepWorkspaceLike { + fs: { + grep(pattern: string, path: string, options?: GrepOptions): Promise; + }; +} + +export interface GrepToolOptions { + workspace: GrepWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const grepInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute file or directory to search."), + query: z.string().describe("Literal string or regular expression to search for."), + include: z + .string() + .optional() + .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), + regex: z.boolean().optional().describe("Interpret query as a regular expression."), + ignoreCase: z.boolean().optional().describe("Ignore letter case."), + context: z.number().int().min(0).max(10).optional(), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const grepDescription = + "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines."; + +export interface GrepInput { + path?: string; + query: string; + include?: string; + regex?: boolean; + ignoreCase?: boolean; + context?: number; + limit?: number; + offset?: number; +} + +export type GrepResult = + | { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } + | { error: string }; + +/** + * Page matches for one query. + * + * Matching is literal and case-sensitive unless the caller opts into + * `regex` or `ignoreCase`, which keeps a model's plain-string query from + * being reinterpreted as a pattern. + */ +export async function grepInWorkspace( + workspace: GrepWorkspaceLike, + { path, query, include, regex, ignoreCase, context, limit, offset }: GrepInput, +): Promise { + const target = path ?? "/workspace"; + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const searchOptions = { + regex: regex ?? false, + ignoreCase: ignoreCase ?? false, + context: context ?? 0, + }; + const matches = await workspace.fs.grep(query, target, { + ...searchOptions, + include, + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = matches.length > pageSize; + const page = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } = { path: target, query, count: page.length, matches: page }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/list.ts b/packages/computer/src/tools/common/fs/list.ts new file mode 100644 index 00000000..b028a321 --- /dev/null +++ b/packages/computer/src/tools/common/fs/list.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +export interface ListWorkspaceLike { + fs: { + readdir( + path: string, + options?: { limit?: number; offset?: number }, + ): Promise< + Array<{ + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; + }> + >; + }; +} + +export interface ListToolOptions { + workspace: ListWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const listInputSchema = z.object({ + path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), + offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), +}); + +export const listDescription = `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`; + +export interface ListInput { + path: string; + limit?: number; + offset?: number; +} + +interface ListEntry { + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +export type ListResult = + | { path: string; count: number; entries: ListEntry[]; nextOffset?: number } + | { error: string }; + +/** + * Page one directory. + * + * Reads one more entry than the page size to learn whether a further + * page exists without a second call, then reports `nextOffset` when it + * does. + */ +export async function listWorkspace( + workspace: ListWorkspaceLike, + { path, limit, offset }: ListInput, +): Promise { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const entries = await workspace.fs.readdir(path, { + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = entries.length > pageSize; + const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ + name: entry.name, + size: entry.size, + mtime: entry.mtime, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + isSymbolicLink: entry.isSymbolicLink, + })); + const result: { + path: string; + count: number; + entries: typeof page; + nextOffset?: number; + } = { + path, + count: page.length, + entries: page, + }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/computer/src/tools/fs/locks.test.ts b/packages/computer/src/tools/common/fs/locks.test.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.test.ts rename to packages/computer/src/tools/common/fs/locks.test.ts diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/common/fs/locks.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.ts rename to packages/computer/src/tools/common/fs/locks.ts diff --git a/packages/computer/src/tools/fs/media.test.ts b/packages/computer/src/tools/common/fs/media.test.ts similarity index 100% rename from packages/computer/src/tools/fs/media.test.ts rename to packages/computer/src/tools/common/fs/media.test.ts diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/common/fs/media.ts similarity index 100% rename from packages/computer/src/tools/fs/media.ts rename to packages/computer/src/tools/common/fs/media.ts diff --git a/packages/computer/src/tools/fs/read.test.ts b/packages/computer/src/tools/common/fs/read.test.ts similarity index 100% rename from packages/computer/src/tools/fs/read.test.ts rename to packages/computer/src/tools/common/fs/read.test.ts diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/common/fs/read.ts similarity index 82% rename from packages/computer/src/tools/fs/read.ts rename to packages/computer/src/tools/common/fs/read.ts index f24cff01..e5755ae2 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/common/fs/read.ts @@ -1,5 +1,5 @@ -import { type JSONValue, type Tool, tool } from "ai"; import { z } from "zod"; +import type { ModelOutput } from "../model-output.js"; import { detectMedia } from "./media.js"; import type { FileStore } from "./types.js"; @@ -27,7 +27,7 @@ const DEFAULT_MAX_MODEL_BYTES = 3.5 * 1024 * 1024; const DEFAULT_MEDIA_SNIFF_BYTES = 512; const TRUNCATION_MARKER = "... (truncated)"; -const inputSchema = z +export const readInputSchema = z .object({ path: z.string().describe("Path to the file to read"), offset: z @@ -84,7 +84,7 @@ interface MediaReadResult { unsupported?: true; } -type ReadToolResult = ReadResult | MediaReadResult | { error: string }; +export type ReadToolResult = ReadResult | MediaReadResult | { error: string }; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: false }); @@ -93,7 +93,7 @@ function utf8ByteLength(value: string): number { return encoder.encode(value).length; } -function createReadExecutor( +export function createReadExecutor( options: ReadToolOptions, ): (input: ReadInput) => Promise { const { store } = options; @@ -303,58 +303,73 @@ export function readFromStore(options: ReadToolOptions, input: ReadInput): Promi return createReadExecutor(options)(input); } -export function createReadTool(options: ReadToolOptions): Tool> { +/** + * The model-facing description, which quotes the configured caps so the + * model can plan continuations instead of discovering the limit by + * hitting it. + */ +export function readDescription(options: ReadToolOptions): string { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + return `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`; +} + +/** + * Build the SDK-neutral model representation for a read result. + * + * A complete, unpositioned text read is returned as bare text because + * that is what the model actually wants to see. Truncated, empty, and + * explicitly positioned reads keep their JSON envelope so the + * continuation offsets survive. Eligible images and PDFs become a + * `media` output carrying the bytes captured during execution, so + * regenerating prompt history cannot observe a later version of the + * file. + */ +export function readModelOutput( + options: ReadToolOptions, +): (args: { input: ReadInput; output: ReadToolResult }) => ModelOutput { const maxModelBytes = validateBoundedReadLimit( "maxModelBytes", options.maxModelBytes ?? DEFAULT_MAX_MODEL_BYTES, ); - return tool({ - description: `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`, - inputSchema, - execute: createReadExecutor(options), - toModelOutput: async ({ input, output }: { input: unknown; output: unknown }) => { - if (!isRecord(output)) return { type: "text", value: String(output) }; - if (typeof output.error === "string") { - return { type: "error-text", value: output.error }; - } - if (typeof output.content === "string") { - const positioned = - isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); - return output.truncated === true || output.content.length === 0 || positioned - ? { type: "json", value: toJSONValue(output) } - : { type: "text", value: output.content }; - } - if (output.kind === "binary") return { type: "json", value: toJSONValue(output) }; - if (!isMediaReadResult(output)) return { type: "json", value: toJSONValue(output) }; - if (output.sizeBytes > maxModelBytes) { - return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); - } - if (output.data === undefined) { - return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; - } - if (output.data.length === 0) { - return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; - } - return { - type: "content", - value: [ - { - type: "text", - text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, - }, - { - type: "file", - data: { type: "data", data: output.data }, - mediaType: output.mediaType, - filename: output.name, - }, - ], - }; - }, - }); + return ({ input, output: settled }) => { + // Inspect the result as an open record. The union's members are + // distinguished by which fields are present rather than by a tag, + // so narrowing field-by-field is clearer than reconstructing the + // discriminator, and every branch below re-establishes the shape it + // needs before using it. + const output: Record = settled as unknown as Record; + if (!isRecord(output)) return { type: "text", value: String(output) }; + if (typeof output.error === "string") { + return { type: "error-text", value: output.error }; + } + if (typeof output.content === "string") { + const positioned = + isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); + return output.truncated === true || output.content.length === 0 || positioned + ? { type: "json", value: output } + : { type: "text", value: output.content }; + } + if (output.kind === "binary") return { type: "json", value: output }; + if (!isMediaReadResult(output)) return { type: "json", value: output }; + if (output.sizeBytes > maxModelBytes) { + return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); + } + if (output.data === undefined) { + return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; + } + if (output.data.length === 0) { + return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; + } + return { + type: "media", + text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, + data: output.data, + mediaType: output.mediaType, + filename: output.name, + }; + }; } function validateBoundedReadLimit(name: string, value: number): number { @@ -460,15 +475,6 @@ function inlineMediaLimitError( }; } -function toJSONValue(value: unknown): JSONValue { - try { - const json = JSON.stringify(value); - return json === undefined ? null : (JSON.parse(json) as JSONValue); - } catch { - return String(value); - } -} - function isReadInput( value: unknown, ): value is { path: string; offset?: number; byteOffset?: number } { diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/common/fs/store.ts similarity index 100% rename from packages/computer/src/tools/fs/store.ts rename to packages/computer/src/tools/common/fs/store.ts diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/common/fs/types.ts similarity index 100% rename from packages/computer/src/tools/fs/types.ts rename to packages/computer/src/tools/common/fs/types.ts diff --git a/packages/computer/src/tools/fs/write.test.ts b/packages/computer/src/tools/common/fs/write.test.ts similarity index 100% rename from packages/computer/src/tools/fs/write.test.ts rename to packages/computer/src/tools/common/fs/write.test.ts diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/common/fs/write.ts similarity index 74% rename from packages/computer/src/tools/fs/write.ts rename to packages/computer/src/tools/common/fs/write.ts index 89d3d479..907fcfd5 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/common/fs/write.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; @@ -14,11 +13,27 @@ export interface WriteToolOptions { const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; -const inputSchema = z.object({ +export const writeInputSchema = z.object({ path: z.string().describe("Absolute path, e.g. /workspace/main.zig"), content: z.string().describe("File content"), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const writeOutputSchema = z.union([ + z.object({ path: z.string(), bytesWritten: z.number().int() }), + z.object({ error: z.string() }), +]); + +export const writeDescription = + "Write content to a file. Overwrites any existing file at the path."; + export interface WriteInput { path: string; content: string; @@ -48,11 +63,3 @@ export async function writeToStore( } }); } - -export function createWriteTool(options: WriteToolOptions): Tool> { - return tool({ - description: "Write content to a file. Overwrites any existing file at the path.", - inputSchema, - execute: (input) => writeToStore(options, input), - }); -} diff --git a/packages/computer/src/tools/common/model-output.ts b/packages/computer/src/tools/common/model-output.ts new file mode 100644 index 00000000..105428ee --- /dev/null +++ b/packages/computer/src/tools/common/model-output.ts @@ -0,0 +1,34 @@ +/** + * A model-facing representation of a tool result, in terms no agent + * library owns. Each provider lowers it onto its own library's shape, + * degrading to text where there is no equivalent. + */ +export type ModelOutput = + | { type: "text"; value: string } + | { type: "error-text"; value: string } + | { type: "json"; value: unknown } + /** `data` is base64: how the read tool captures bytes, and what pi and TanStack want on the wire. */ + | { type: "media"; text: string; data: string; mediaType: string; filename?: string }; + +export function defaultModelOutput(output: unknown): ModelOutput { + if ( + typeof output === "object" && + output !== null && + typeof (output as { error?: unknown }).error === "string" + ) { + return { type: "error-text", value: (output as { error: string }).error }; + } + return { type: "json", value: output }; +} + +export function modelOutputToText(output: ModelOutput): string { + switch (output.type) { + case "text": + case "error-text": + return output.value; + case "json": + return JSON.stringify(output.value); + case "media": + return output.text; + } +} diff --git a/packages/computer/src/tools/common/options.ts b/packages/computer/src/tools/common/options.ts new file mode 100644 index 00000000..48cb5e56 --- /dev/null +++ b/packages/computer/src/tools/common/options.ts @@ -0,0 +1,47 @@ +import type { ExecToolOptions, ExecWorkspaceLike } from "./exec.js"; +import type { EditToolOptions } from "./fs/edit.js"; +import type { ReadToolOptions } from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import type { WriteToolOptions } from "./fs/write.js"; +import type { PublishWorkspaceLike } from "./publish.js"; + +export interface CreateToolsOptions { + workspace: FileWorkspaceLike & Partial & Partial; + /** Omit `write`, `edit`, `delete`, `exec`, and `publish`. */ + readonly?: boolean; + /** Set `false` to omit `publish` even when assets are configured. */ + assets?: boolean; + read?: Omit; + write?: Omit; + edit?: Omit; + /** Pass to add `exec`. Omit to leave command execution out entirely. */ + shell?: Omit; +} + +export interface ResolvedToolOptions { + read: ReadToolOptions; + write: WriteToolOptions; + edit: EditToolOptions; + delete: { store: WorkspaceFileStore }; + exec?: ExecToolOptions; + publish: boolean; + readonly: boolean; + workspace: CreateToolsOptions["workspace"]; +} + +export function resolveToolOptions(options: CreateToolsOptions): ResolvedToolOptions { + const store = new WorkspaceFileStore(options.workspace); + return { + read: { store, ...options.read }, + write: { store, ...options.write }, + edit: { store, ...options.edit }, + delete: { store }, + exec: + options.shell === undefined + ? undefined + : { workspace: options.workspace as ExecWorkspaceLike, ...options.shell }, + publish: options.assets !== false && options.workspace.assets !== undefined, + readonly: options.readonly === true, + workspace: options.workspace, + }; +} diff --git a/packages/computer/src/tools/common/publish.ts b/packages/computer/src/tools/common/publish.ts new file mode 100644 index 00000000..bec94112 --- /dev/null +++ b/packages/computer/src/tools/common/publish.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { AssetsClient } from "../../assets/index.js"; + +export interface PublishWorkspaceLike { + readonly sessionId: string; + readonly assets?: AssetsClient; +} + +export interface PublishToolOptions { + workspace: PublishWorkspaceLike; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; + +export const publishInputSchema = z.object({ + path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), + expiresAfterMs: z + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), +}); + +/** Successful publish carries the link; a failure carries the reason. */ +export const publishOutputSchema = z.union([ + z.object({ ok: z.literal(true), url: z.string() }), + z.object({ ok: z.literal(false), error: z.string() }), +]); + +export const publishDescription = + "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report."; + +export interface PublishInput { + path: string; + expiresAfterMs?: number; +} + +export type PublishResult = { ok: true; url: string } | { ok: false; error: string }; + +/** + * Bind a publish executor to one workspace. + * + * The assets client is resolved once, at construction, so a workspace + * without a configured publisher fails loudly when the tool is built + * rather than on the model's first call. + */ +export function createPublishExecutor( + workspace: PublishWorkspaceLike, +): (input: PublishInput) => Promise { + const assets = workspace.assets; + if (!assets) { + throw new Error("createPublishTool: workspace.assets is not configured"); + } + + return async ({ path, expiresAfterMs }) => { + try { + const prefix = workspace.sessionId ? `agent-${workspace.sessionId}` : undefined; + const url = await assets.share(path, { + expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, + ...(prefix ? { prefix } : {}), + }); + return { ok: true, url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }; +} diff --git a/packages/computer/src/tools/common/stream.ts b/packages/computer/src/tools/common/stream.ts new file mode 100644 index 00000000..23a322cf --- /dev/null +++ b/packages/computer/src/tools/common/stream.ts @@ -0,0 +1,29 @@ +/** + * Drain an executor to its settled result. + * + * A streaming executor yields successive complete snapshots of one run + * rather than deltas, so the last one is the whole result. + */ +export async function settle( + returned: Promise | AsyncIterable, +): Promise { + if (isAsyncIterable(returned)) { + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; + } + return await returned; +} + +export function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in (value as Record) + ); +} diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts deleted file mode 100644 index ec338b9a..00000000 --- a/packages/computer/src/tools/fs/edit.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import { - applyEditsToNormalizedContent, - detectLineEnding, - type Edit, - generateDiffString, - generateUnifiedPatch, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "./edit-diff.js"; -import { withFileLock } from "./locks.js"; -import type { FileStore } from "./types.js"; - -export interface EditToolOptions { - store: FileStore; - /** - * Reject edits to files larger than this byte cap. Fuzzy matching needs the - * whole buffer in memory, so we'd rather force the model to use `write`. - * Default 2 MiB. - */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; - -const replacementSchema = z - .object({ - oldText: z - .string() - .describe( - "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", - ), - newText: z.string().describe("Replacement text for this targeted edit."), - }) - .strict(); - -const inputSchema = z.object({ - path: z.string().describe("Path to the file to edit"), - edits: z - .array(replacementSchema) - .describe( - "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", - ), -}); - -/** Best-effort coercion for inputs from quirky models. */ -function prepareArguments(input: unknown): { path: string; edits: Edit[] } { - if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; - const args = input as Record; - - // Some models pack edits into a JSON string. - if (typeof args.edits === "string") { - try { - const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; - } catch { - /* fall through to validation error */ - } - } - - // Legacy single-edit shape: oldText/newText siblings on the root object. - if (typeof args.oldText === "string" && typeof args.newText === "string") { - const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; - edits.push({ oldText: args.oldText as string, newText: args.newText as string }); - args.edits = edits; - delete args.oldText; - delete args.newText; - } - - return args as { path: string; edits: Edit[] }; -} - -export function createEditTool(options: EditToolOptions): Tool> { - const { store } = options; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: - "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit.", - inputSchema, - execute: async (rawInput: z.infer) => { - const { path, edits } = prepareArguments(rawInput); - - if (!Array.isArray(edits) || edits.length === 0) { - return { error: "edits must contain at least one replacement." }; - } - - return withFileLock(store, path, async () => { - try { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - if (stat.size > maxBytes) { - return { - error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, - }; - } - - const bytes = await store.readAll(path); - if (!bytes) return { error: `File not found: ${path}` }; - - const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode( - bytes, - ); - const { bom, text } = stripBom(rawContent); - const ending = detectLineEnding(text); - const normalized = normalizeToLF(text); - - let baseContent: string; - let newContent: string; - try { - ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - - const finalContent = bom + restoreLineEndings(newContent, ending); - // Round-trip the file's mode so editing an executable script (or any - // file with a non-default mode) doesn't silently drop bits. `stat.mode` - // is undefined for stores that don't track modes; pass `undefined` in - // that case so the store applies its own default. - await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - - return { - path, - editsApplied: edits.length, - diff: diffResult.diff, - patch, - firstChangedLine: diffResult.firstChangedLine, - }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }); - }, - }); -} diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts deleted file mode 100644 index b19b23f9..00000000 --- a/packages/computer/src/tools/fs/find.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface FoundEntry { - path: string; - type: "file" | "dir"; -} - -export interface FindWorkspaceLike { - fs: { - find( - directory: string, - pattern?: string, - options?: { limit?: number; offset?: number; exclude?: string[] }, - ): Promise; - }; -} - -export interface FindToolOptions { - workspace: FindWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute directory to search."), - pattern: z - .string() - .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), - exclude: z - .array(z.string()) - .optional() - .describe( - 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', - ), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createFindTool(options: FindToolOptions): Tool> { - return tool({ - description: - "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character.", - inputSchema, - execute: async ({ path, pattern, exclude, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const matches = await options.workspace.fs.find(path, pattern, { - limit: pageSize + 1, - offset: pageOffset, - exclude, - }); - const truncated = matches.length > pageSize; - const entries = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - pattern: string; - count: number; - entries: FoundEntry[]; - nextOffset?: number; - } = { path, pattern, count: entries.length, entries }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts deleted file mode 100644 index 43f35cf8..00000000 --- a/packages/computer/src/tools/fs/grep.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface GrepContextLine { - line: number; - text: string; - isMatch: boolean; -} - -interface GrepMatch { - path: string; - line: number; - text: string; - context?: GrepContextLine[]; -} - -interface GrepOptions { - regex?: boolean; - ignoreCase?: boolean; - context?: number; - limit?: number; - offset?: number; - include?: string; -} - -export interface GrepWorkspaceLike { - fs: { - grep(pattern: string, path: string, options?: GrepOptions): Promise; - }; -} - -export interface GrepToolOptions { - workspace: GrepWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute file or directory to search."), - query: z.string().describe("Literal string or regular expression to search for."), - include: z - .string() - .optional() - .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), - regex: z.boolean().optional().describe("Interpret query as a regular expression."), - ignoreCase: z.boolean().optional().describe("Ignore letter case."), - context: z.number().int().min(0).max(10).optional(), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createGrepTool(options: GrepToolOptions): Tool> { - return tool({ - description: - "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines.", - inputSchema, - execute: async ({ path, query, include, regex, ignoreCase, context, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const searchOptions = { - regex: regex ?? false, - ignoreCase: ignoreCase ?? false, - context: context ?? 0, - }; - const matches = await options.workspace.fs.grep(query, path, { - ...searchOptions, - include, - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = matches.length > pageSize; - const page = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - query: string; - count: number; - matches: GrepMatch[]; - nextOffset?: number; - } = { path, query, count: page.length, matches: page }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/list.ts b/packages/computer/src/tools/fs/list.ts deleted file mode 100644 index 744dd757..00000000 --- a/packages/computer/src/tools/fs/list.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -export interface ListWorkspaceLike { - fs: { - readdir( - path: string, - options?: { limit?: number; offset?: number }, - ): Promise< - Array<{ - name: string; - size: number; - mtime: number; - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - }> - >; - }; -} - -export interface ListToolOptions { - workspace: ListWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), - limit: z - .number() - .int() - .min(1) - .max(MAX_LIMIT) - .optional() - .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), - offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), -}); - -export function createListTool(options: ListToolOptions): Tool> { - return tool({ - description: `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`, - inputSchema, - execute: async ({ path, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const entries = await options.workspace.fs.readdir(path, { - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = entries.length > pageSize; - const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ - name: entry.name, - size: entry.size, - mtime: entry.mtime, - isFile: entry.isFile, - isDirectory: entry.isDirectory, - isSymbolicLink: entry.isSymbolicLink, - })); - const result: { - path: string; - count: number; - entries: typeof page; - nextOffset?: number; - } = { - path, - count: page.length, - entries: page, - }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 9bad4745..716a4187 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,19 +1,62 @@ -export { type CreateAIToolsOptions, createAITools } from "./ai.js"; +/** Also the AI SDK entrypoint, published as `@cloudflare/computer/tools`. */ + +export { type CreateAIToolsOptions, createAITools } from "./ai-sdk/index.js"; +export { toAISDKOutput } from "./ai-sdk/output.js"; export { + createDeleteTool, + createEditTool, createExecTool, - type ExecBackendDescription, - type ExecRuntimeHandle, - type ExecStreamEvent, - type ExecToolOptions, - type ExecToolOutput, -} from "./exec.js"; -export { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; -export { createEditTool, type EditToolOptions } from "./fs/edit.js"; -export { createFindTool, type FindToolOptions } from "./fs/find.js"; -export { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; -export { createListTool, type ListToolOptions } from "./fs/list.js"; -export { createReadTool, type LineTruncation, type ReadToolOptions } from "./fs/read.js"; -export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; -export type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; -export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -export { createPublishTool, type PublishToolOptions } from "./publish.js"; + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./ai-sdk/tools.js"; +export type { + ExecBackendDescription, + ExecInput, + ExecRuntimeHandle, + ExecStreamEvent, + ExecToolOptions, + ExecToolOutput, +} from "./common/exec.js"; +export type { DeleteToolOptions } from "./common/fs/delete.js"; +export type { EditToolOptions } from "./common/fs/edit.js"; +export type { FindToolOptions } from "./common/fs/find.js"; +export type { GrepToolOptions } from "./common/fs/grep.js"; +export type { ListToolOptions } from "./common/fs/list.js"; +export type { LineTruncation, ReadToolOptions } from "./common/fs/read.js"; +export { WorkspaceFileStore, type WorkspaceLike } from "./common/fs/store.js"; +export type { FileStat, FileStore, MutableFileStore } from "./common/fs/types.js"; +export type { WriteToolOptions } from "./common/fs/write.js"; +export { + defaultModelOutput, + type ModelOutput, + modelOutputToText, +} from "./common/model-output.js"; +export { type CreateToolsOptions, resolveToolOptions } from "./common/options.js"; +export type { PublishToolOptions } from "./common/publish.js"; +export { settle } from "./common/stream.js"; +export { + type CreatePiToolsOptions, + type CreatePiToolsResult, + createPiTools, + type PiDeclarationOptions, + type PiJSONSchema, + type PiTool, + type PiToolCall, + type PiToolResult, + type PiToolResultContent, + type ToolCallContext, +} from "./pi/index.js"; +export { + type CreateTanStackToolsOptions, + createTanStackTools, + type TanStackTool, + type TanStackToolExecutionContext, + type TanStackToolFormat, + type TanStackToolList, + type TanStackToolSet, + type TanStackToolsFor, +} from "./tanstack-ai/index.js"; diff --git a/packages/computer/src/tools/pi/index.test.ts b/packages/computer/src/tools/pi/index.test.ts new file mode 100644 index 00000000..8f87e039 --- /dev/null +++ b/packages/computer/src/tools/pi/index.test.ts @@ -0,0 +1,271 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createPiTools } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +function declaration(tools: ReturnType, name: string) { + const tool = tools.tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`no ${name} tool`); + return tool; +} + +describe("createPiTools declarations", () => { + it("declares the default tool set with object parameter schemas", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); + for (const tool of tools.tools) { + expect(tool.parameters.type).toBe("object"); + expect(tool.description.length).toBeGreaterThan(0); + } + }); + + it("omits mutating tools when readonly", () => { + const tools = createPiTools({ workspace: makeWorkspace(), readonly: true }); + + expect(tools.tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); + }); + + it("adds exec with a backend enum when shell options are supplied", () => { + const tools = createPiTools({ + workspace: makeWorkspace(), + shell: { + defaultBackend: "shell", + backends: { + shell: { description: "Fast worker shell." }, + container: { description: "Full Linux container." }, + }, + }, + }); + + const exec = declaration(tools, "exec"); + expect(exec.description).toContain("Fast worker shell."); + expect(exec.description).toContain("Full Linux container."); + const backend = exec.parameters.properties?.backend as { enum?: string[] }; + expect(backend.enum).toEqual(["shell", "container"]); + }); + + it("emits required fields without a $schema key and keeps defaults optional", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const write = declaration(tools, "write"); + expect(write.parameters.$schema).toBeUndefined(); + expect(write.parameters.required?.sort()).toEqual(["content", "path"]); + + // `find.path` carries a Zod default, so the model may omit it. + const find = declaration(tools, "find"); + expect(find.parameters.required).toEqual(["pattern"]); + const path = find.parameters.properties?.path as { default?: string } | undefined; + expect(path?.default).toBe("/workspace"); + }); +}); + +describe("createPiTools constrained sampling", () => { + it("requests provider-side strict schemas for the fussy tools only", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + // `edit` and `write` carry long verbatim strings a model can mangle. + expect(declaration(tools, "edit").constrainedSampling).toEqual({ + type: "json_schema", + strict: "prefer", + }); + expect(declaration(tools, "write").constrainedSampling).toEqual({ + type: "json_schema", + strict: "prefer", + }); + // A plain listing has nothing worth constraining. + expect(declaration(tools, "ls").constrainedSampling).toBeUndefined(); + }); + + it("closes a strict schema and makes optional fields nullable", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const read = declaration(tools, "read"); + expect(read.parameters.additionalProperties).toBe(false); + // Strict mode requires every property; optional ones accept null. + expect(read.parameters.required?.sort()).toEqual(["byteOffset", "limit", "offset", "path"]); + const offset = read.parameters.properties?.offset as { type?: unknown }; + expect(offset.type).toEqual(["integer", "null"]); + const path = read.parameters.properties?.path as { type?: unknown }; + expect(path.type).toBe("string"); + }); + + it("escalates to require or opts out when asked", () => { + const required = createPiTools({ + workspace: makeWorkspace(), + constrainedSampling: "require", + }); + expect(declaration(required, "edit").constrainedSampling).toEqual({ + type: "json_schema", + strict: "require", + }); + + const off = createPiTools({ workspace: makeWorkspace(), constrainedSampling: false }); + expect(declaration(off, "edit").constrainedSampling).toBeUndefined(); + // Opting out also restores the open, minimally-required schema. + expect(declaration(off, "edit").parameters.additionalProperties).toBeUndefined(); + expect(declaration(off, "read").parameters.required).toEqual(["path"]); + }); + + it("accepts a strict-mode call that fills optional fields with null", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/w/a.txt", content: "hi\n" }, + }); + // A provider enforcing the closed schema sends every property. + const result = await tools.execute({ + id: "2", + name: "read", + arguments: { path: "/w/a.txt", offset: null, byteOffset: null, limit: null }, + }); + + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); + }); +}); + +describe("createPiTools execution", () => { + it("runs a tool call and returns text content for a complete read", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/w/a.txt", content: "hi\n" }, + }); + const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); + + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("returns structured results as JSON text", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "x" } }); + const result = await tools.execute({ id: "2", name: "ls", arguments: { path: "/w" } }); + + expect(result.isError).toBe(false); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.count).toBe(1); + expect(parsed.entries[0].name).toBe("a.txt"); + }); + + it("marks a missing file as an error result", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ + id: "1", + name: "read", + arguments: { path: "/w/missing.txt" }, + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("missing.txt"); + }); + + it("rejects invalid arguments as a retryable error rather than throwing", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ id: "1", name: "read", arguments: { path: 42 } }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Invalid arguments for read"); + }); + + it("reports an unknown tool name with the available names", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ id: "1", name: "nope", arguments: {} }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain('Unknown tool "nope"'); + }); + + it("keeps a null the tool genuinely accepts", async () => { + // `exec`'s structured input is any JSON value, so null means null. + const seen: Array<{ input: unknown }> = []; + const workspace = makeWorkspace(); + (workspace.runtime as unknown as Record).exec = async ( + _command: string, + options: { input?: unknown }, + ) => { + seen.push({ input: options.input }); + return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; + }; + (workspace.runtime as unknown as Record).isCallable = () => true; + const tools = createPiTools({ + workspace, + shell: { defaultBackend: "js", backends: { js: { description: "callable" } } }, + }); + + await tools.execute({ id: "1", name: "exec", arguments: { command: "a", input: null } }); + await tools.execute({ id: "2", name: "exec", arguments: { command: "b" } }); + + expect(seen[0].input).toBeNull(); + expect(seen[1].input).toBeUndefined(); + }); + + it("applies a schema default when the model omits the field", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/workspace/found.ts", content: "export {};" }, + }); + const result = await tools.execute({ + id: "2", + name: "find", + arguments: { pattern: "**/*.ts" }, + }); + + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.path).toBe("/workspace"); + expect(parsed.entries.map((entry: { path: string }) => entry.path)).toContain( + "/workspace/found.ts", + ); + }); + + it("returns an image read as a base64 image block", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + // A one-pixel PNG, written through the filesystem so the read tool + // classifies it by extension and captures its bytes. + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, + ]); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/pixel.png", png); + + const result = await tools.execute({ + id: "1", + name: "read", + arguments: { path: "/workspace/pixel.png" }, + }); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect(result.content[1]).toMatchObject({ type: "image", mimeType: "image/png" }); + }); +}); diff --git a/packages/computer/src/tools/pi/index.ts b/packages/computer/src/tools/pi/index.ts new file mode 100644 index 00000000..cce94056 --- /dev/null +++ b/packages/computer/src/tools/pi/index.ts @@ -0,0 +1,392 @@ +/** + * pi keeps tool declarations and tool execution apart: declarations + * travel in `Context.tools` while the caller's own agent loop runs the + * tools. So this module returns both halves together. + */ + +import { z } from "zod"; +import { createExecExecutor, execDescription, execInputSchema } from "../common/exec.js"; +import { deleteDescription, deleteFromStore, deleteInputSchema } from "../common/fs/delete.js"; +import { editDescription, editInputSchema, editInStore } from "../common/fs/edit.js"; +import { + type FindWorkspaceLike, + findDescription, + findInputSchema, + findInWorkspace, +} from "../common/fs/find.js"; +import { + type GrepWorkspaceLike, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "../common/fs/grep.js"; +import { + type ListWorkspaceLike, + listDescription, + listInputSchema, + listWorkspace, +} from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { writeDescription, writeInputSchema, writeToStore } from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { settle } from "../common/stream.js"; + +export interface ToolCallContext { + abortSignal?: AbortSignal; +} + +interface PiToolEntry { + name: string; + description: string; + inputSchema: z.ZodType; + strictArguments?: boolean; + execute: (input: never, context: ToolCallContext) => Promise | AsyncIterable; + toModelOutput?: (args: { input: never; output: never }) => ModelOutput; +} + +/** Structurally compatible with `Tool` from `@earendil-works/pi-ai`, declared locally so pi is not a build-time dependency. */ +export interface PiTool { + name: string; + description: string; + parameters: PiJSONSchema; + constrainedSampling?: { type: "json_schema"; strict: "prefer" | "require" }; +} + +export interface PiJSONSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +/** One tool call as pi reports it on a `toolcall_end` event. */ +export interface PiToolCall { + id: string; + name: string; + arguments?: unknown; +} + +export type PiToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +/** A tool result minus the routing fields (`toolCallId`, `toolName`, `timestamp`), which the caller owns. */ +export interface PiToolResult { + content: PiToolResultContent[]; + isError: boolean; +} + +export interface CreatePiToolsResult { + tools: PiTool[]; + execute: (call: PiToolCall, context?: ToolCallContext) => Promise; +} + +export function createPiTools(options: CreatePiToolsOptions): CreatePiToolsResult { + const entries = piToolEntries(options); + const nullable = new Map>(); + return { + tools: declarations(entries, options, nullable), + execute: dispatcher(entries, nullable), + }; +} + +export interface CreatePiToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} + +function piToolEntries(options: CreateToolsOptions): PiToolEntry[] { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const entries: PiToolEntry[] = [ + { + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + // Byte offsets must be echoed back verbatim on the next call. + strictArguments: true, + execute: (input: ReadInput) => readExecutor(input), + toModelOutput: ({ input, output }: { input: ReadInput; output: ReadToolResult }) => + toReadOutput({ input, output }), + } as PiToolEntry, + { + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(workspace as ListWorkspaceLike, input), + } as PiToolEntry, + { + name: "find", + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(workspace as FindWorkspaceLike, input), + } as PiToolEntry, + { + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(workspace as GrepWorkspaceLike, input), + } as PiToolEntry, + ]; + + if (resolved.readonly) return entries; + + entries.push( + { + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + // The whole file body travels as one string argument. + strictArguments: true, + execute: (input) => writeToStore(resolved.write, input), + } as PiToolEntry, + { + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + // A nested array of exact-match strings is easy to malform. + strictArguments: true, + execute: (input) => editInStore(resolved.edit, input), + } as PiToolEntry, + { + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(resolved.delete, input), + } as PiToolEntry, + ); + + if (resolved.exec !== undefined) { + const executor = createExecExecutor(resolved.exec); + entries.push({ + name: "exec", + description: execDescription(resolved.exec), + inputSchema: execInputSchema(resolved.exec), + execute: (input, context) => executor(input, context), + } as PiToolEntry); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + entries.push({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => executor(input), + } as PiToolEntry); + } + + return entries; +} + +function declarations( + entries: readonly PiToolEntry[], + options: PiDeclarationOptions, + nullable: Map>, +): PiTool[] { + const strict = options.constrainedSampling ?? "prefer"; + return entries.map((entry) => { + // Strict schemas must close the object: a provider enforcing the + // schema has to know no other properties are allowed. + const wantsStrict = strict !== false && entry.strictArguments === true; + const converted = toPiParameters(entry.inputSchema, wantsStrict); + nullable.set(entry.name, converted.nullable); + const tool: PiTool = { + name: entry.name, + description: entry.description, + parameters: converted.parameters, + }; + if (wantsStrict) { + tool.constrainedSampling = { type: "json_schema", strict }; + } + return tool; + }); +} + +export interface PiDeclarationOptions { + /** + * `"prefer"` (default) falls back to ordinary tool calling where the + * provider cannot enforce a schema; `"require"` fails the request + * instead, so it suits only a pinned model known to support it. + */ + constrainedSampling?: "prefer" | "require" | false; +} + +/** + * Validation failures and thrown executors both come back as error + * results rather than exceptions, so a bad call costs the model a turn + * instead of breaking the caller's loop. + */ +function dispatcher( + entries: readonly PiToolEntry[], + nullable: ReadonlyMap>, +): (call: PiToolCall, context?: ToolCallContext) => Promise { + const byName = new Map(entries.map((entry) => [entry.name, entry])); + return async (call, context = {}) => { + const entry = byName.get(call.name); + if (!entry) { + return errorResult( + `Unknown tool ${JSON.stringify(call.name)}. Available tools: ${entries + .map((e) => JSON.stringify(e.name)) + .join(", ")}.`, + ); + } + + const args = dropPlaceholderNulls(call.arguments ?? {}, nullable.get(entry.name) ?? EMPTY); + const parsed = entry.inputSchema.safeParse(args); + if (!parsed.success) { + return errorResult(`Invalid arguments for ${call.name}: ${formatZodError(parsed.error)}`); + } + + const run = entry.execute as ( + i: unknown, + c: ToolCallContext, + ) => Promise | AsyncIterable; + const toOutput = entry.toModelOutput as + | ((args: { input: unknown; output: unknown }) => ModelOutput) + | undefined; + try { + const output = await settle(run(parsed.data, context)); + return toPiResult( + toOutput ? toOutput({ input: parsed.data, output }) : defaultModelOutput(output), + ); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err)); + } + }; +} + +function toPiResult(output: ModelOutput): PiToolResult { + switch (output.type) { + case "text": + return { content: [{ type: "text", text: output.value }], isError: false }; + case "error-text": + return { content: [{ type: "text", text: output.value }], isError: true }; + case "json": + return { + content: [{ type: "text", text: stringify(output.value) }], + isError: false, + }; + case "media": { + // pi's tool results carry images but nothing else, so a PDF + // degrades to text rather than being dropped. + if (!output.mediaType.startsWith("image/")) { + return { + content: [ + { + type: "text", + text: `${output.text} This file type cannot be attached to a tool result; read it with a dedicated tool if its contents are needed.`, + }, + ], + isError: false, + }; + } + return { + content: [ + { type: "text", text: output.text }, + { type: "image", data: output.data, mimeType: output.mediaType }, + ], + isError: false, + }; + } + } +} + +/** + * `io: "input"` keeps a field with a Zod `.default()` optional: the + * default is emitted as a JSON Schema `default` for TypeBox to apply. + * The output view would instead mark those fields required. + */ +function toPiParameters( + schema: z.ZodType, + strict = false, +): { parameters: PiJSONSchema; nullable: Set } { + const json = z.toJSONSchema(schema, { + target: "draft-7", + io: "input", + // Providers reject `$ref` pointers into a definitions section. + reused: "inline", + unrepresentable: "any", + }) as Record; + delete json.$schema; + if (json.type !== "object") { + throw new Error(`pi tool parameters must be an object schema, got ${String(json.type)}`); + } + // A provider enforcing the schema needs the object closed, and + // OpenAI further requires every property in `required`, so an + // optional field becomes required-but-nullable instead. + const nullable = new Set(); + if (strict) { + json.additionalProperties = false; + const properties = (json.properties ?? {}) as Record>; + const names = Object.keys(properties); + const required = new Set((json.required as string[] | undefined) ?? []); + for (const name of names) { + if (required.has(name)) continue; + const property = properties[name]; + const type = property.type; + // Recorded so the dispatcher can tell this null, which means + // "absent", from one the tool genuinely accepts. + if (typeof type === "string" && type !== "null") { + property.type = [type, "null"]; + nullable.add(name); + } + } + json.required = names; + } + return { parameters: json as PiJSONSchema, nullable }; +} + +/** + * Strips only the nulls that {@link toPiParameters} introduced, named + * by `nullable`. A null on any other field is a value the tool accepts + * — `exec`'s structured `input` is any JSON — and must survive. + */ +function dropPlaceholderNulls(args: unknown, nullable: ReadonlySet): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) return args; + if (nullable.size === 0) return args; + const out: Record = {}; + for (const [key, value] of Object.entries(args as Record)) { + if (value === null && nullable.has(key)) continue; + out[key] = value; + } + return out; +} + +const EMPTY: ReadonlySet = new Set(); + +function errorResult(message: string): PiToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join("; "); +} + +function stringify(value: unknown): string { + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/pi/model-output.test.ts b/packages/computer/src/tools/pi/model-output.test.ts new file mode 100644 index 00000000..c80a51ad --- /dev/null +++ b/packages/computer/src/tools/pi/model-output.test.ts @@ -0,0 +1,32 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it, vi } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createPiTools } from "./index.js"; + +// Only read shapes its own model output, so a failing formatter is +// simulated by replacing it. +vi.mock("../common/fs/read.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readModelOutput: () => () => { + throw new Error("formatter exploded"); + }, + }; +}); + +describe("createPiTools model output", () => { + it("returns a failing formatter as an error result rather than throwing", async () => { + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const tools = createPiTools({ workspace }); + await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "hi" } }); + + const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); + + expect(result).toEqual({ + content: [{ type: "text", text: "formatter exploded" }], + isError: true, + }); + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/publish.ts b/packages/computer/src/tools/publish.ts deleted file mode 100644 index 75b6ab46..00000000 --- a/packages/computer/src/tools/publish.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import type { AssetsClient } from "../assets/index.js"; - -export interface PublishWorkspaceLike { - readonly sessionId: string; - readonly assets?: AssetsClient; -} - -export interface PublishToolOptions { - workspace: PublishWorkspaceLike; -} - -const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; - -export function createPublishTool( - options: PublishToolOptions, -): Tool<{ path: string; expiresAfterMs?: number }> { - const assets = options.workspace.assets; - if (!assets) { - throw new Error("createPublishTool: workspace.assets is not configured"); - } - - return tool({ - description: - "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", - inputSchema: z.object({ - path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), - expiresAfterMs: z - .number() - .int() - .positive() - .optional() - .describe("Link lifetime in milliseconds. Defaults to one hour."), - }), - execute: async ({ path, expiresAfterMs }) => { - try { - const prefix = options.workspace.sessionId - ? `agent-${options.workspace.sessionId}` - : undefined; - const url = await assets.share(path, { - expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, - ...(prefix ? { prefix } : {}), - }); - return { ok: true, url }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts new file mode 100644 index 00000000..281fa9ec --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -0,0 +1,372 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createTanStackTools } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +// Streams a fixed event sequence through a real WorkspaceRuntime +// handle, rather than a hand-shaped fake. +function streamingCommandBackend(events: import("@cloudflare/computer-rpc").ExecEvent[]): { + id: string; + type: string; + connect(): Promise<{ + rpc: import("@cloudflare/computer-rpc").WorkspaceRPC; + sync: "none"; + close(): Promise; + }>; +} { + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec(input) { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue({ ...event, id }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + const noopSync = new Proxy( + {}, + { get: () => () => Promise.reject(new Error("sync: none")) }, + ) as import("@cloudflare/computer-rpc").SyncRPC; + return { + id: "shell", + type: "fake-command", + async connect() { + return { rpc: { sync: noopSync, shell }, sync: "none", close: async () => {} }; + }, + }; +} + +// A command that prints once and then stays quiet until killed or +// released, the shape that exposes buffering and cancellation bugs. +function quietCommandBackend(): { + backend: ReturnType; + killed: Promise; + release(): void; +} { + let finish: (() => void) | undefined; + let markKilled: () => void = () => {}; + const killed = new Promise((resolve) => { + markKilled = resolve; + }); + const base = streamingCommandBackend([]); + const backend = { + ...base, + async connect() { + const connection = await base.connect(); + connection.rpc.shell.exec = async (input) => { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ + id, + seq: 1, + name: "stdout", + value: new TextEncoder().encode("starting\n"), + }); + finish = () => { + controller.enqueue({ id, seq: 2, name: "exit", code: 0 }); + controller.close(); + }; + }, + }), + }; + }; + connection.rpc.shell.killExec = async () => { + markKilled(); + finish?.(); + }; + return connection; + }, + }; + return { backend, killed, release: () => finish?.() }; +} + +describe("createTanStackTools", () => { + it("returns a list, the shape every TanStack entry point takes", () => { + const tools = createTanStackTools({ workspace: makeWorkspace() }); + + // chat(), mergeAgentTools and createToolRegistry all call array + // methods on what they are given, so an array is the contract. + expect(Array.isArray(tools)).toBe(true); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); + for (const tool of tools) { + expect(typeof tool.execute).toBe("function"); + } + }); + + it("keys the tools by name when asked", () => { + const tools = createTanStackTools({ workspace: makeWorkspace() }); + const set = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + expect(Array.isArray(set)).toBe(false); + expect(Object.keys(set).sort()).toEqual(tools.map((tool) => tool.name).sort()); + for (const [name, tool] of Object.entries(set)) { + expect(tool.name).toBe(name); + } + }); + + it("omits mutating tools when readonly", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), readonly: true }); + + expect(tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); + }); + + it("passes the Zod schema through untouched for standard-schema validation", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const schema = tools.write.inputSchema as unknown as { + "~standard": { version: number }; + safeParse: (value: unknown) => { success: boolean }; + }; + expect(schema["~standard"].version).toBe(1); + expect(schema.safeParse({ path: "/w/a.txt", content: "x" }).success).toBe(true); + expect(schema.safeParse({ path: "/w/a.txt" }).success).toBe(false); + }); + + it("flags only the requested tools as needing approval", () => { + const tools = createTanStackTools({ + workspace: makeWorkspace(), + approve: ["delete"], + format: "object", + }); + + expect(tools.delete.needsApproval).toBe(true); + expect(tools.write.needsApproval).toBeUndefined(); + }); + + it("gates every mutating tool from one keyword", () => { + const tools = createTanStackTools({ + workspace: makeWorkspace(), + approve: "mutating", + format: "object", + }); + + for (const name of ["write", "edit", "delete"]) { + expect(tools[name].needsApproval).toBe(true); + } + // Reads and searches change nothing, so they run unattended. + for (const name of ["read", "ls", "find", "grep"]) { + expect(tools[name].needsApproval).toBeUndefined(); + } + }); + + it("describes output shapes including the error branch", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const schema = tools.write.outputSchema as unknown as { + safeParse: (v: unknown) => { success: boolean }; + }; + expect(schema.safeParse({ path: "/w/a.txt", bytesWritten: 3 }).success).toBe(true); + expect(schema.safeParse({ path: "/w/a.txt" }).success).toBe(false); + // TanStack validates every return against this, failures included. + expect(schema.safeParse({ error: "read-only filesystem" }).success).toBe(true); + // A paged listing has no fixed success shape worth asserting. + expect(tools.ls.outputSchema).toBeUndefined(); + }); + + it("returns the real reason when a mutating tool fails", async () => { + const workspace = makeWorkspace(); + workspace.fs.writeFile = async () => { + throw new Error("read-only filesystem"); + }; + const tools = createTanStackTools({ workspace, format: "object" }); + + const result = (await tools.write.execute({ + path: "/workspace/a.txt", + content: "hi", + } as never)) as { error: string }; + + expect(result.error).toContain("read-only filesystem"); + const schema = tools.write.outputSchema as unknown as { + parse: (v: unknown) => unknown; + }; + expect(schema.parse(result)).toEqual({ error: expect.stringContaining("read-only") }); + }); + + it("marks tools lazy so they stay out of the prompt until discovered", () => { + const all = createTanStackTools({ + workspace: makeWorkspace(), + lazy: "all", + format: "object", + }); + expect(all.read.lazy).toBe(true); + expect(all.write.lazy).toBe(true); + + const some = createTanStackTools({ + workspace: makeWorkspace(), + lazy: ["grep"], + format: "object", + }); + expect(some.grep.lazy).toBe(true); + expect(some.read.lazy).toBeUndefined(); + }); + + it("returns plain text for a complete read and objects for structured results", async () => { + const workspace = makeWorkspace(); + const tools = createTanStackTools({ workspace, format: "object" }); + + await tools.write.execute({ path: "/w/a.txt", content: "hi\n" } as never); + + await expect(tools.read.execute({ path: "/w/a.txt" } as never)).resolves.toBe("hi"); + await expect(tools.ls.execute({ path: "/w" } as never)).resolves.toMatchObject({ + path: "/w", + count: 1, + }); + }); + + it("returns an error object for a failed call", async () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const result = (await tools.read.execute({ path: "/w/missing.txt" } as never)) as { + error: string; + }; + + expect(result.error).toContain("missing.txt"); + }); + + it("settles a streaming exec tool on its terminal snapshot", async () => { + // TanStack tools return one value, so a streaming executor has to + // collapse to the run's terminal snapshot rather than a mid-run one. + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([ + { id: "cmd-1", seq: 1, name: "stdout", value: new TextEncoder().encode("hello\n") }, + { id: "cmd-1", seq: 2, name: "exit", code: 0 }, + ]) as never, + ], + }); + const tools = createTanStackTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "fast shell" } } }, + format: "object", + }); + + await expect(tools.exec.execute({ command: "echo hello" } as never)).resolves.toEqual({ + command: "echo hello", + cwd: null, + backend: "shell", + exitCode: 0, + stdout: "hello\n", + stderr: "", + }); + await workspace.close(); + }); + + it("forwards pre-terminal snapshots as custom events when asked", async () => { + const events: Array<{ name: string; value: Record }> = []; + // The last snapshot settles as the return value; earlier ones are + // emitted, so a UI can show output while the command runs. + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([ + { id: "cmd-1", seq: 1, name: "stdout", value: new TextEncoder().encode("partial\n") }, + { id: "cmd-1", seq: 2, name: "exit", code: 0 }, + ]) as never, + ], + }); + const tools = createTanStackTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "fast shell" } } }, + streamEventName: "exec-progress", + format: "object", + }); + + const result = await tools.exec.execute({ command: "echo partial" } as never, { + toolCallId: "call-1", + emitCustomEvent: (name, value) => events.push({ name, value }), + }); + + expect(result).toMatchObject({ exitCode: 0, stdout: "partial\n" }); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0].name).toBe("exec-progress"); + await workspace.close(); + }); + + it("emits a running snapshot before the command produces more output", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "fast shell" } } }, + streamEventName: "exec-progress", + format: "object", + }); + let firstEvent: () => void = () => {}; + const emitted = new Promise((resolve) => { + firstEvent = resolve; + }); + const events: Array> = []; + + const pending = tools.exec.execute({ command: "build" } as never, { + toolCallId: "call-1", + emitCustomEvent: (_name, value) => { + events.push(value); + firstEvent(); + }, + }); + await emitted; + + expect(events[0]).toMatchObject({ + toolCallId: "call-1", + snapshot: { exitCode: null, stdout: "starting\n" }, + }); + quiet.release(); + expect(await pending).toMatchObject({ exitCode: 0 }); + // The terminal snapshot is returned, not emitted. + expect( + events.every((event) => (event.snapshot as { exitCode: unknown }).exitCode === null), + ).toBe(true); + await workspace.close(); + }); + + it("kills exec when the chat run's abort signal fires", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackTools({ + workspace, + shell: { defaultBackend: "shell", backends: { shell: { description: "fast shell" } } }, + format: "object", + }); + const controller = new AbortController(); + + const pending = tools.exec.execute({ command: "npm test" } as never, { + toolCallId: "call-1", + abortSignal: controller.signal, + }); + controller.abort(); + + await quiet.killed; + await pending; + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/tanstack-ai/index.ts b/packages/computer/src/tools/tanstack-ai/index.ts new file mode 100644 index 00000000..15844ff0 --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.ts @@ -0,0 +1,314 @@ +/** + * `inputSchema` is a Standard Schema, which Zod v4 implements, so the + * schemas from `../common` are passed through untouched. + */ + +import type { z } from "zod"; +import { createExecExecutor, execDescription, execInputSchema } from "../common/exec.js"; +import { + deleteDescription, + deleteFromStore, + deleteInputSchema, + deleteOutputSchema, +} from "../common/fs/delete.js"; +import { + editDescription, + editInputSchema, + editInStore, + editOutputSchema, +} from "../common/fs/edit.js"; +import { + type FindWorkspaceLike, + findDescription, + findInputSchema, + findInWorkspace, +} from "../common/fs/find.js"; +import { + type GrepWorkspaceLike, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "../common/fs/grep.js"; +import { + type ListWorkspaceLike, + listDescription, + listInputSchema, + listWorkspace, +} from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + writeDescription, + writeInputSchema, + writeOutputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput, modelOutputToText } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, + publishOutputSchema, +} from "../common/publish.js"; +import { settle } from "../common/stream.js"; + +/** Structurally compatible with `toolDefinition().server()`, declared locally so `@tanstack/ai` is not a build-time dependency. */ +export interface TanStackTool { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + // biome-ignore lint/suspicious/noExplicitAny: matches the signature chat() calls + execute: (input: any, context?: TanStackToolExecutionContext) => Promise; + needsApproval?: boolean; + lazy?: boolean; + /** Phantom marker carrying no runtime value; declaring it satisfies the union `chat({ tools })` accepts. */ + readonly "~toolKind"?: undefined; +} + +export interface TanStackToolExecutionContext { + toolCallId?: string; + /** Fires when the chat run's `abortController` aborts; a running `exec` is killed. */ + abortSignal?: AbortSignal; + emitCustomEvent?: (eventName: string, value: Record) => void; +} + +export type TanStackToolList = TanStackTool[]; + +export type TanStackToolSet = Record>; + +/** `chat()`, `mergeAgentTools` and `createToolRegistry` all take an array, so `"array"` is the default. */ +export type TanStackToolFormat = "array" | "object"; + +export type TanStackToolsFor = Format extends "object" + ? TanStackToolSet + : TanStackToolList; + +export interface CreateTanStackToolsOptions + extends CreateToolsOptions { + format?: Format; + /** Tools that pause for approval. `"mutating"` selects every tool that changes workspace state. */ + approve?: string[] | "mutating"; + /** Forward pre-terminal `exec` snapshots through `emitCustomEvent` under this name; otherwise they are discarded. */ + streamEventName?: string; + /** Tools withheld from the prompt until TanStack lazy discovery asks for them. */ + lazy?: string[] | "all"; +} + +export function createTanStackTools( + options: CreateTanStackToolsOptions, +): TanStackToolsFor { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const tools: TanStackToolList = []; + const add = (entry: { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + mutates?: boolean; + streams?: boolean; + run: (input: never, context?: TanStackToolExecutionContext) => unknown; + }) => { + const needsApproval = wants(options.approve, entry.name, entry.mutates === true); + const lazy = wants(options.lazy, entry.name, options.lazy === "all"); + tools.push({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + // TanStack validates every return against this, errors included, + // so a success-only schema would mask the real failure reason. + outputSchema: entry.outputSchema, + needsApproval: needsApproval ? true : undefined, + lazy: lazy ? true : undefined, + execute: entry.run, + } as TanStackTool); + }; + + add({ + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + run: async (input: ReadInput) => { + const output = (await readExecutor(input)) as ReadToolResult; + return toTanStackOutput(toReadOutput({ input, output })); + }, + }); + + add({ + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + run: async (input: never) => plain(await listWorkspace(workspace as ListWorkspaceLike, input)), + }); + + add({ + name: "find", + description: findDescription, + inputSchema: findInputSchema, + run: async (input: never) => + plain(await findInWorkspace(workspace as FindWorkspaceLike, input)), + }); + + add({ + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + run: async (input: never) => + plain(await grepInWorkspace(workspace as GrepWorkspaceLike, input)), + }); + + if (!resolved.readonly) { + add({ + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + outputSchema: writeOutputSchema, + mutates: true, + run: async (input: never) => plain(await writeToStore(resolved.write, input)), + }); + + add({ + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + outputSchema: editOutputSchema, + mutates: true, + run: async (input: never) => plain(await editInStore(resolved.edit, input)), + }); + + add({ + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + outputSchema: deleteOutputSchema, + mutates: true, + run: async (input: never) => plain(await deleteFromStore(resolved.delete, input)), + }); + + if (resolved.exec !== undefined) { + const executor = createExecExecutor(resolved.exec); + add({ + name: "exec", + description: execDescription(resolved.exec), + inputSchema: execInputSchema(resolved.exec), + mutates: true, + streams: true, + run: async (input: never, context?: TanStackToolExecutionContext) => { + const returned = executor(input, { abortSignal: context?.abortSignal }); + const output = options.streamEventName + ? await settleWithEvents(returned, options.streamEventName, context) + : await settle(returned); + return plain(output); + }, + }); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + add({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + outputSchema: publishOutputSchema, + mutates: true, + run: async (input: never) => plain(await executor(input)), + }); + } + } + + if (options.format === "object") { + const set: TanStackToolSet = {}; + for (const tool of tools) set[tool.name] = tool; + // The generic resolves to one branch or the other at each call + // site, which a return inside the function cannot prove. + return set as TanStackToolsFor; + } + return tools as TanStackToolsFor; +} + +function plain(output: unknown): unknown { + return toTanStackOutput(defaultModelOutput(output)); +} + +/** + * Running snapshots are emitted as they arrive, so a command that + * prints once and goes quiet shows that output straight away. The + * terminal snapshot is returned rather than emitted, so a consumer + * ignoring custom events still sees the complete outcome. + */ +async function settleWithEvents( + returned: Promise | AsyncIterable, + eventName: string, + context: TanStackToolExecutionContext | undefined, +): Promise { + const emit = context?.emitCustomEvent; + if (!emit || !isAsyncIterable(returned)) return settle(returned); + + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + if (isRunning(chunk)) { + emit(eventName, { toolCallId: context?.toolCallId, snapshot: chunk as never }); + } + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; +} + +/** An exec snapshot is running until it carries an exit code or an error. */ +function isRunning(snapshot: unknown): boolean { + const s = snapshot as { exitCode?: unknown; error?: unknown }; + return s.exitCode === null && s.error === undefined; +} + +function wants(option: string[] | string | undefined, name: string, byTrait: boolean): boolean { + if (option === undefined) return false; + if (Array.isArray(option)) return option.includes(name); + return byTrait; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in (value as Record) + ); +} + +/** + * TanStack has no typed tool-result part for media, so an image or PDF + * returns its text alongside the payload for a caller to reattach. + */ +function toTanStackOutput(output: ModelOutput): unknown { + switch (output.type) { + case "text": + return output.value; + case "error-text": + return { error: output.value }; + case "json": + return output.value; + case "media": + return { + text: output.text, + mediaType: output.mediaType, + filename: output.filename, + data: output.data, + }; + } +} + +export { modelOutputToText };