diff --git a/.changeset/pi-tanstack-tools.md b/.changeset/pi-tanstack-tools.md new file mode 100644 index 00000000..1f70348b --- /dev/null +++ b/.changeset/pi-tanstack-tools.md @@ -0,0 +1,17 @@ +--- +"@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. Both are built from one +shared description of each tool, so names, descriptions and limits +match the existing AI SDK entrypoint, which is unchanged. Each library +is an optional peer dependency, so installing one does not pull in the +others. 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..93d50325 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,43 @@ 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. + +## One implementation, three shapes + +A tool is described once as a `ToolSpec` — a name, a description, a Zod input schema, an executor, and an optional model-output hook — and `createToolSpecs()` assembles the set for a Workspace. The three factories are thin adapters over that set, so a change to a tool's behavior, schema, or description reaches all three SDKs at once. + +``` + createToolSpecs() ← tool set, gating, schemas, caps + / | \ + createAITools createPiTools createTanStackTools + (ai) (pi-ai, TypeBox) (@tanstack/ai) +``` + +Where the SDKs genuinely differ, the adapter absorbs it: + +| 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` | + +Sharing the implementation does not mean levelling every SDK down to the smallest common feature set. A spec carries SDK-agnostic *traits* — whether a tool mutates state, whether its arguments are fussy enough to be worth constraining, whether it streams — and each adapter lowers those onto whatever its SDK offers, ignoring the ones it cannot use. So `edit` asks to be constrained once, and pi turns that into provider-side strict sampling while the other two simply validate. + +`createToolSpecs` and the `ToolSpec` types are exported, so a fourth SDK is an adapter rather than a rewrite. + ## 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. | +| `createToolSpecs` | Build the SDK-neutral spec set the adapters share. | | `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. | @@ -24,7 +62,7 @@ The tools wrap three Workspace surfaces: | `createPublishTool` | Publish a workspace file through `workspace.assets`. | | `WorkspaceFileStore` | Adapt `workspace.fs` to the store used by file tools. | -`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 +93,91 @@ 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", + signal: abortController.signal, + }); + + 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. + +Because the tool execution context carries no abort signal, pass `signal` to cancel a running `exec` when the request aborts. A TanStack tool settles on one value, so `exec` returns the run's terminal snapshot; set `streamEventName` to also forward each pre-terminal snapshot through `emitCustomEvent` 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..d6605aee --- /dev/null +++ b/examples/pi/run-local.mjs @@ -0,0 +1,135 @@ +// Drive the pi example's agent loop locally, with no Cloudflare account. +// +// The loop, the tool declarations, and the dispatcher are the real ones +// from @cloudflare/computer/tools/pi against a real Workspace. Only two +// things are substituted: pi's own fauxProvider stands in for Workers +// AI, so the tool calls are scripted rather than chosen by a model, and +// an in-memory SQLite storage stands in for Durable Object storage. +// +// 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() }); +// `exec` needs a backend to be declared. There is no shell backend in +// plain node, so calling it fails at the backend — which is after the +// argument handling this script is checking. +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(); + +// What the model "decides" to do, one 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(), + }); + } + + // Script the next reply, now that this turn's tools have run. + 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)); + +// And that a failure comes back as a retryable error result. +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), +); + +// The review's item 3: a deliberate null input to a callable backend. +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..300e5634 --- /dev/null +++ b/examples/pi/src/index.ts @@ -0,0 +1,120 @@ +// A one-shot agent built on pi, working in a durable Workspace. +// +// POST a task, and the agent uses the workspace tools to carry it out. +// The whole loop is the `run` method below: ask the model, run whatever +// tools it asked for, repeat until it stops asking. +// +// 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 in-isolate shell shares one filesystem +// with the agent. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +// Stop after this many model turns, so a confused model cannot loop +// forever on someone else's bill. +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 { + // `tools` is the list the model sees. `execute` runs one of its + // requests and hands back a result to put in the transcript. + 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"); + // Nothing left to run, so this reply is the answer. + 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..534e8105 --- /dev/null +++ b/examples/pi/src/workers-ai.ts @@ -0,0 +1,104 @@ +// pi's Workers AI provider, transported over the `AI` binding. +// +// The catalog, request shaping, and streaming parser are pi's own. Only +// the transport changes: instead of posting to the REST endpoint with an +// API token, each request goes through +// `binding.run(model, body, { returnRawResponse: true })`, so the +// example needs no API key and AI Gateway attaches by id. +// +// 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"); +} + +/** One Workers AI model, described the way pi wants it. */ +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..b6d3ac04 --- /dev/null +++ b/examples/tanstack-ai/run-local.mjs @@ -0,0 +1,108 @@ +// Drive the TanStack AI example's agent loop locally, with no +// Cloudflare account. +// +// `chat()`, the agent loop, the tools, and the Workspace are the real +// ones. Only the provider is substituted: a hand-written adapter +// replays scripted assistant turns instead of calling Workers AI, so +// the tool calls are fixed rather than chosen by a model. +// +// 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 }); + +// Scripted turns: write a file, read it back, then answer. Each entry is +// what the "model" emits for that iteration of the agent loop. +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. It yields AG-UI events +// for one assistant turn, then stops. +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), +}); + +// Watch the tool results go by, then take the final text. +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..002a47de --- /dev/null +++ b/examples/tanstack-ai/src/index.ts @@ -0,0 +1,97 @@ +// A one-shot agent built on TanStack AI, working in a durable Workspace. +// +// POST a task, and the agent uses the workspace tools to carry it out. +// TanStack AI owns the loop: `chat()` calls the tools the model asks +// for, feeds the results back, and keeps going until the model is done. +// `streamToText` waits for that to finish and returns the final text. +// +// 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 in-isolate shell shares one filesystem +// with the agent. +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 Cloudflare adapter talks to Workers AI through the binding, + // so this example needs no API key. + // + // The cast is a version mismatch, not a real one: the adapter + // depends on version 4 of @cloudflare/workers-types while this + // repository is on version 5, so TypeScript sees two structurally + // identical `Ai` types from different packages and declines to + // unify them. Drop the cast once the adapter moves to version 5. + 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 }], + // The tools arrive as a list, which is what chat() takes. + tools, + // Stop after ten model turns, so a confused model cannot loop + // forever on someone else's bill. + 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..67674936 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.ts", + "tools/tanstack": "src/tools/tanstack.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-output.ts b/packages/computer/src/tools/ai-output.ts new file mode 100644 index 00000000..01ecb335 --- /dev/null +++ b/packages/computer/src/tools/ai-output.ts @@ -0,0 +1,45 @@ +/** + * Lowering from the neutral `ModelOutput` onto the AI SDK's tool output + * parts. + * + * Kept in its own module because both the `./ai.js` adapter and the + * standalone `createReadTool` in `./fs/read.js` need it, and `ai.js` + * imports the registry that `read.js` feeds — importing it from there + * would close a cycle. + */ + +import type { JSONValue } from "ai"; +import type { ModelOutput } from "./spec.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.ts b/packages/computer/src/tools/ai.ts index 78cc8358..54d27f47 100644 --- a/packages/computer/src/tools/ai.ts +++ b/packages/computer/src/tools/ai.ts @@ -1,50 +1,56 @@ -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"; +/** + * Tools for the [AI SDK](https://github.com/vercel/ai) (`ai`). + * + * The AI SDK is the closest match to the shared spec shape: it takes a + * Zod `inputSchema`, an `execute` that may return an async iterable of + * progressive results, and an optional `toModelOutput`. So this adapter + * forwards the specs from `./registry.js` almost unchanged, lowering + * only the neutral `ModelOutput` onto the SDK's output parts. + */ -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 }), - }; +import { type Tool, type ToolSet, tool } from "ai"; +import { toAISDKOutput } from "./ai-output.js"; +import { type CreateToolsOptions, createToolSpecs } from "./registry.js"; +import { type AnyToolSpec, applyModelOutput, runSpec, type ToolSpecSet } from "./spec.js"; - if (options.readonly === true) return tools; +export type CreateAIToolsOptions = CreateToolsOptions; - 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, - }); - } +/** + * Create the AI SDK `ToolSet` for a Workspace. + * + * Always includes `read`, `ls`, `find`, and `grep`. Adds `write`, + * `edit`, and `delete` unless `readonly` is set, `exec` when `shell` + * options are supplied, and `publish` when assets are configured. + */ +export function createAITools(options: CreateAIToolsOptions): ToolSet { + return toAITools(createToolSpecs(options)); +} - if (options.assets !== false && options.workspace.assets !== undefined) { - tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); +/** Adapt an existing spec set to AI SDK tools. */ +export function toAITools(specs: ToolSpecSet): ToolSet { + const tools: ToolSet = {}; + for (const spec of Object.values(specs)) { + tools[spec.name] = toAITool(spec); } - return tools; } + +function toAITool(spec: AnyToolSpec): Tool { + const hasModelOutput = spec.toModelOutput !== undefined; + return tool({ + description: spec.description, + inputSchema: spec.inputSchema, + // The AI SDK accepts either a promise or an async iterable from + // `execute`, which is the same contract a spec executor follows, so + // the return value passes straight through and a streaming tool + // keeps its progressive snapshots. + execute: (input: unknown, { abortSignal }: { abortSignal?: AbortSignal }) => + runSpec(spec, input, { abortSignal }), + ...(hasModelOutput + ? { + toModelOutput: async ({ input, output }: { input: unknown; output: unknown }) => + toAISDKOutput(await applyModelOutput(spec, input, output)), + } + : {}), + }) as Tool; +} diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/exec.ts index 5a58fb36..21d74e55 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/exec.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { notCallableMessage } from "../runtime/runtime.js"; import type { WorkspaceRuntimeValue } from "../runtime/types.js"; +import type { ToolCallContext } from "./spec.js"; // A finite JSON value: what a callable backend accepts as `input` and // returns as `result`. Declared as a concrete recursive schema rather @@ -110,19 +111,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 +140,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 +167,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 +178,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,129 +197,153 @@ 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: ToolCallContext) => 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); - // 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; + 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; + } + + // 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) }; } - }, + } + }; +} + +export function createExecTool(options: ExecToolOptions): Tool { + const executor = createExecExecutor(options); + return tool({ + description: execDescription(options), + inputSchema: execInputSchema(options), + execute: (input, { abortSignal }) => executor(input, { abortSignal }), }); } diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/fs/delete.ts index 41cf6315..54c57ad9 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/fs/delete.ts @@ -7,7 +7,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 +15,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; @@ -39,11 +55,12 @@ export function deleteFromStore( ); } -export function createDeleteTool(options: DeleteToolOptions): Tool> { +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, + description: deleteDescription, + inputSchema: deleteInputSchema, execute: (input) => deleteFromStore(options, input), }); } diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts index ec338b9a..6469850e 100644 --- a/packages/computer/src/tools/fs/edit.ts +++ b/packages/computer/src/tools/fs/edit.ts @@ -36,7 +36,7 @@ const replacementSchema = z }) .strict(); -const inputSchema = z.object({ +export const editInputSchema = z.object({ path: z.string().describe("Path to the file to edit"), edits: z .array(replacementSchema) @@ -45,6 +45,44 @@ const inputSchema = z.object({ ), }); +/** + * 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[] }; @@ -72,70 +110,78 @@ function prepareArguments(input: unknown): { path: string; edits: Edit[] } { return args as { path: string; edits: Edit[] }; } -export function createEditTool(options: EditToolOptions): Tool> { +/** + * 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); - 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." }; + 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.`, + }; } - 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) }; - } - }); - }, + 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) }; + } + }); +} + +export function createEditTool(options: EditToolOptions): Tool> { + return tool({ + description: editDescription, + inputSchema: editInputSchema, + execute: (rawInput) => editInStore(options, rawInput), }); } diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts index b19b23f9..ba2c931a 100644 --- a/packages/computer/src/tools/fs/find.ts +++ b/packages/computer/src/tools/fs/find.ts @@ -23,7 +23,7 @@ export interface FindToolOptions { const DEFAULT_LIMIT = 200; const MAX_LIMIT = 1000; -const inputSchema = z.object({ +export const findInputSchema = z.object({ path: z.string().default("/workspace").describe("Absolute directory to search."), pattern: z .string() @@ -38,34 +38,67 @@ const inputSchema = z.object({ offset: z.number().int().min(0).optional(), }); -export function createFindTool(options: FindToolOptions): Tool> { +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) }; + } +} + +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) }; - } - }, + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(options.workspace, input), }); } diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts index 43f35cf8..b282f685 100644 --- a/packages/computer/src/tools/fs/grep.ts +++ b/packages/computer/src/tools/fs/grep.ts @@ -36,7 +36,7 @@ export interface GrepToolOptions { const DEFAULT_LIMIT = 200; const MAX_LIMIT = 1000; -const inputSchema = z.object({ +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 @@ -50,40 +50,76 @@ const inputSchema = z.object({ offset: z.number().int().min(0).optional(), }); -export function createGrepTool(options: GrepToolOptions): Tool> { +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) }; + } +} + +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) }; - } - }, + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(options.workspace, input), }); } diff --git a/packages/computer/src/tools/fs/list.ts b/packages/computer/src/tools/fs/list.ts index 744dd757..e86801fc 100644 --- a/packages/computer/src/tools/fs/list.ts +++ b/packages/computer/src/tools/fs/list.ts @@ -26,7 +26,7 @@ export interface ListToolOptions { const DEFAULT_LIMIT = 200; const MAX_LIMIT = 1000; -const inputSchema = z.object({ +export const listInputSchema = z.object({ path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), limit: z .number() @@ -38,42 +38,75 @@ const inputSchema = z.object({ offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), }); -export function createListTool(options: ListToolOptions): Tool> { +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) }; + } +} + +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) }; - } - }, + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(options.workspace, input), }); } diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/fs/read.ts index f24cff01..daef559e 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/fs/read.ts @@ -1,5 +1,7 @@ -import { type JSONValue, type Tool, tool } from "ai"; +import { type Tool, tool } from "ai"; import { z } from "zod"; +import { toAISDKOutput } from "../ai-output.js"; +import type { ModelOutput } from "../spec.js"; import { detectMedia } from "./media.js"; import type { FileStore } from "./types.js"; @@ -27,7 +29,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 +86,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 +95,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,57 +305,84 @@ 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 ({ 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, + }; + }; +} + +export function createReadTool(options: ReadToolOptions): Tool> { + const toModelOutput = readModelOutput(options); + 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, + description: readDescription(options), + inputSchema: readInputSchema, 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, - }, - ], - }; - }, + toModelOutput: ({ input, output }: { input: unknown; output: unknown }) => + toAISDKOutput(toModelOutput({ input: input as ReadInput, output: output as ReadToolResult })), }); } @@ -460,15 +489,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/write.ts b/packages/computer/src/tools/fs/write.ts index 89d3d479..c5dceb5b 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/fs/write.ts @@ -14,11 +14,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; @@ -49,10 +65,10 @@ export async function writeToStore( }); } -export function createWriteTool(options: WriteToolOptions): Tool> { +export function createWriteTool(options: WriteToolOptions): Tool> { return tool({ - description: "Write content to a file. Overwrites any existing file at the path.", - inputSchema, + description: writeDescription, + inputSchema: writeInputSchema, execute: (input) => writeToStore(options, input), }); } diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 9bad4745..40968051 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,7 +1,9 @@ -export { type CreateAIToolsOptions, createAITools } from "./ai.js"; +export { type CreateAIToolsOptions, createAITools, toAITools } from "./ai.js"; +export { toAISDKOutput } from "./ai-output.js"; export { createExecTool, type ExecBackendDescription, + type ExecInput, type ExecRuntimeHandle, type ExecStreamEvent, type ExecToolOptions, @@ -16,4 +18,40 @@ export { createReadTool, type LineTruncation, type ReadToolOptions } from "./fs/ 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 { + type CreatePiToolsOptions, + type CreatePiToolsResult, + createPiTools, + createSpecExecutor, + type PiDeclarationOptions, + type PiJSONSchema, + type PiTool, + type PiToolCall, + type PiToolResult, + type PiToolResultContent, + piToolDeclarations, +} from "./pi.js"; export { createPublishTool, type PublishToolOptions } from "./publish.js"; +export { type CreateToolsOptions, createToolSpecs } from "./registry.js"; +export { + defaultModelOutput, + defineTool, + type ModelOutput, + modelOutputToText, + settle, + type ToolCallContext, + type ToolSpec, + type ToolSpecSet, + type ToolTraits, +} from "./spec.js"; +export { + type CreateTanStackToolsOptions, + createTanStackTools, + type TanStackTool, + type TanStackToolExecutionContext, + type TanStackToolFormat, + type TanStackToolList, + type TanStackToolSet, + type TanStackToolsFor, + toTanStackTools, +} from "./tanstack.js"; diff --git a/packages/computer/src/tools/pi.test.ts b/packages/computer/src/tools/pi.test.ts new file mode 100644 index 00000000..ce92cb2c --- /dev/null +++ b/packages/computer/src/tools/pi.test.ts @@ -0,0 +1,275 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "../workspace.js"; +import { createPiTools } from "./pi.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. + // Only the placeholder nulls that strict mode introduces for + // omitted optional fields may be stripped. + 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" } }); + + // An explicit null survives; an omitted field stays absent. A model + // that means null must be able to say so. + 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.ts b/packages/computer/src/tools/pi.ts new file mode 100644 index 00000000..b3b14cb4 --- /dev/null +++ b/packages/computer/src/tools/pi.ts @@ -0,0 +1,347 @@ +/** + * Tools for [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`). + * + * pi splits a tool in two. `Tool` is pure data — a name, a description, + * and a TypeBox `parameters` schema — that travels in `Context.tools`, + * while execution stays with the caller's own agent loop. So this module + * exposes both halves and keeps them consistent: + * + * - `createPiTools` returns the declarations to put in the context. + * - `createPiToolExecutor` returns a dispatcher that validates a tool + * call and runs the matching workspace tool, handing back pi's + * `toolResult` content blocks. + * + * TypeBox schemas are plain JSON Schema, and pi validates against them + * with TypeBox's validator, so the Zod schemas in `./spec.js` are + * converted rather than rewritten. That keeps one schema per tool across + * all three SDK entrypoints. + */ + +import { z } from "zod"; +import { type CreateToolsOptions, createToolSpecs } from "./registry.js"; +import { + applyModelOutput, + type ModelOutput, + runSpec, + settle, + type ToolCallContext, + type ToolSpecSet, +} from "./spec.js"; + +/** + * A pi tool declaration. + * + * Structurally compatible with `Tool` from `@earendil-works/pi-ai`, but + * declared locally so this module does not need pi at build time. pi + * only reads `name`, `description`, and `parameters`. + */ +export interface PiTool { + name: string; + description: string; + parameters: PiJSONSchema; + /** + * Provider-side constrained sampling, when the tool asks for it. + * + * `strict: "prefer"` rather than `"require"` so a provider or model + * that cannot enforce a schema falls back to ordinary tool calling + * instead of failing the request. + */ + constrainedSampling?: { type: "json_schema"; strict: "prefer" | "require" }; +} + +/** The JSON Schema subset TypeBox and pi exchange for tool parameters. */ +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; +} + +/** Content blocks pi accepts on a `toolResult` message. */ +export type PiToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +/** + * A settled tool result in pi's shape, minus the routing fields. + * + * The caller owns `toolCallId`, `toolName`, and `timestamp` because it + * owns the transcript; this module supplies only what running the tool + * determined. + */ +export interface PiToolResult { + content: PiToolResultContent[]; + isError: boolean; +} + +export interface CreatePiToolsResult { + /** Declarations for `Context.tools`. */ + tools: PiTool[]; + /** Run one tool call and get back pi `toolResult` content. */ + execute: (call: PiToolCall, context?: ToolCallContext) => Promise; +} + +/** + * Build pi tool declarations and their executor for a Workspace. + * + * Returns both halves together so the declarations and the dispatcher + * cannot drift apart. Callers that only need the declarations can + * destructure `tools` and ignore `execute`. + */ +export function createPiTools(options: CreatePiToolsOptions): CreatePiToolsResult { + const specs = createToolSpecs(options); + const nullable = new Map>(); + return { + tools: piToolDeclarations(specs, options, nullable), + execute: createSpecExecutor(specs, nullable), + }; +} + +export interface CreatePiToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} + +/** + * Declarations only, for a caller that already has a spec set. + * + * Useful when the same specs back both a declaration list sent to the + * model and a separately-held dispatcher. + */ +export function piToolDeclarations( + specs: ToolSpecSet, + options: PiDeclarationOptions = {}, + /** + * Filled in with the fields widened to nullable per tool. Pass the + * same map to {@link createSpecExecutor} so it can tell a placeholder + * null apart from one the tool accepts. + */ + nullable?: Map>, +): PiTool[] { + const strict = options.constrainedSampling ?? "prefer"; + return Object.values(specs).map((spec) => { + // Strict schemas must close the object: a provider enforcing the + // schema has to know no other properties are allowed. + const wantsStrict = strict !== false && spec.traits?.strictArguments === true; + const converted = toPiParameters(spec.inputSchema, wantsStrict); + nullable?.set(spec.name, converted.nullable); + const tool: PiTool = { + name: spec.name, + description: spec.description, + parameters: converted.parameters, + }; + if (wantsStrict) { + tool.constrainedSampling = { type: "json_schema", strict }; + } + return tool; + }); +} + +export interface PiDeclarationOptions { + /** + * Whether to request provider-side constrained sampling for the tools + * that ask for it, and how strictly. + * + * `"prefer"` (the default) falls back to ordinary tool calling on a + * provider that cannot enforce the schema. `"require"` fails the + * request instead, which is only appropriate when the caller pins a + * model known to support it. `false` opts out entirely. + */ + constrainedSampling?: "prefer" | "require" | false; +} + +/** + * Build a dispatcher over a spec set. + * + * Arguments are validated against the tool's own Zod schema before the + * executor runs. pi's loop may also validate with `validateToolCall`; + * validating here as well means a caller that skips that step still + * cannot reach an executor with malformed input, and a validation + * failure comes back as an error result the model can retry against + * rather than a thrown exception that breaks the loop. + */ +export function createSpecExecutor( + specs: ToolSpecSet, + /** + * Fields widened to nullable by the strict-schema transformation, per + * tool. Without it no null is treated as a placeholder, which is the + * right default for a caller that never asked for strict schemas. + */ + nullable: ReadonlyMap> = new Map(), +): (call: PiToolCall, context?: ToolCallContext) => Promise { + return async (call, context = {}) => { + const spec = specs[call.name]; + if (!spec) { + return errorResult( + `Unknown tool ${JSON.stringify(call.name)}. Available tools: ${Object.keys(specs) + .map((name) => JSON.stringify(name)) + .join(", ")}.`, + ); + } + + // Strict schemas require every property, expressing "absent" as + // null. Drop those placeholders, but only for the fields that were + // widened, so a null the tool genuinely accepts survives. + const args = dropPlaceholderNulls(call.arguments ?? {}, nullable.get(spec.name) ?? EMPTY); + const parsed = spec.inputSchema.safeParse(args); + if (!parsed.success) { + return errorResult(`Invalid arguments for ${call.name}: ${formatZodError(parsed.error)}`); + } + + let output: unknown; + try { + output = await settle(runSpec(spec, parsed.data, context)); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err)); + } + + return toPiResult(await applyModelOutput(spec, parsed.data, output)); + }; +} + +/** Lower a neutral `ModelOutput` onto pi's tool-result content blocks. */ +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 carries images as base64 blocks on a tool result. Anything + // else that reached a media output (a PDF) has no tool-result + // representation, so it degrades to its descriptive text rather + // than being dropped silently. + 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, + }; + } + } +} + +/** + * Convert a Zod schema to the JSON Schema pi hands to TypeBox. + * + * `io: "input"` is what makes a field carrying a Zod `.default()` + * optional in the emitted schema: the default is recorded as a JSON + * Schema `default` that TypeBox applies during conversion, so the model + * may omit it. Emitting the output view instead would mark those fields + * required and force the model to restate values it should not have to. + */ +function toPiParameters( + schema: z.ZodType, + strict = false, +): { parameters: PiJSONSchema; nullable: Set } { + const json = z.toJSONSchema(schema, { + target: "draft-7", + io: "input", + // Tool parameters are consumed by providers that reject `$ref` + // pointers into a definitions section, so inline every subschema. + // The one recursive schema here (`exec`'s structured `input`) would + // otherwise need a ref, and is reported rather than silently + // emitted as something the provider will reject. + 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 a JSON schema needs the object closed, and + // OpenAI additionally requires every property to be listed in + // `required` — optional fields are expressed as nullable instead. Zod + // emits the open, minimally-required form, so close it here rather + // than restating each schema for the strict case. + 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; + // Widen an optional property to accept null, so the model can + // fill the now-required slot without inventing a value. Record it, + // so the dispatcher knows this null means "absent" rather than a + // value the tool asked for. + if (typeof type === "string" && type !== "null") { + property.type = [type, "null"]; + nullable.add(name); + } + } + json.required = names; + } + return { parameters: json as PiJSONSchema, nullable }; +} + +/** + * Drop the null placeholders strict mode introduced, and only those. + * + * A closed schema has to list every property as required, so an omitted + * optional field is sent as null instead. Those nulls mean "absent" and + * have to go before Zod sees them. A null the tool genuinely accepts + * must survive: `exec`'s structured `input` is any JSON value, so a + * model passing null there means it. + * + * `nullable` names the fields this adapter widened for one tool, so + * only those are stripped. + */ +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/publish.ts b/packages/computer/src/tools/publish.ts index 75b6ab46..239e5c71 100644 --- a/packages/computer/src/tools/publish.ts +++ b/packages/computer/src/tools/publish.ts @@ -13,39 +13,68 @@ export interface PublishToolOptions { const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; -export function createPublishTool( - options: PublishToolOptions, -): Tool<{ path: string; expiresAfterMs?: number }> { - const assets = options.workspace.assets; +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) }; + } + }; +} + +export function createPublishTool( + options: PublishToolOptions, +): Tool> { + const execute = createPublishExecutor(options.workspace); 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) }; - } - }, + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => execute(input), }); } diff --git a/packages/computer/src/tools/registry.ts b/packages/computer/src/tools/registry.ts new file mode 100644 index 00000000..6a34b6c3 --- /dev/null +++ b/packages/computer/src/tools/registry.ts @@ -0,0 +1,237 @@ +/** + * The one place the workspace tool set is assembled. + * + * `createToolSpecs` decides which tools exist for a given Workspace and + * set of options, and wires each to the executor that already backs the + * AI SDK tools. Every SDK entrypoint calls this, so `readonly`, `shell`, + * and `assets` gating, tool names, descriptions, schemas, and caps are + * defined once rather than once per SDK. + */ + +import type { AssetsClient } from "../assets/index.js"; +import { + createExecExecutor, + type ExecInput, + type ExecToolOptions, + type ExecWorkspaceLike, + execDescription, + execInputSchema, +} from "./exec.js"; +import { + type DeleteInput, + deleteDescription, + deleteFromStore, + deleteInputSchema, + deleteOutputSchema, +} from "./fs/delete.js"; +import { + type EditInput, + type EditToolOptions, + editDescription, + editInputSchema, + editInStore, + editOutputSchema, +} from "./fs/edit.js"; +import { + type FindInput, + type FindWorkspaceLike, + findDescription, + findInputSchema, + findInWorkspace, +} from "./fs/find.js"; +import { + type GrepInput, + type GrepWorkspaceLike, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "./fs/grep.js"; +import { + type ListInput, + type ListWorkspaceLike, + listDescription, + listInputSchema, + listWorkspace, +} from "./fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolOptions, + readDescription, + readInputSchema, + readModelOutput, +} from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import { + type WriteInput, + type WriteToolOptions, + writeDescription, + writeInputSchema, + writeOutputSchema, + writeToStore, +} from "./fs/write.js"; +import { + createPublishExecutor, + type PublishInput, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, + publishOutputSchema, +} from "./publish.js"; +import { type AnyToolSpec, defineTool, type ToolSpec, type ToolSpecSet } from "./spec.js"; + +/** + * Options for the workspace tool set. + * + * Shared by `createAITools`, `createPiTools`, and `createTanStackTools` + * so the three SDKs expose the same knobs and defaults. + */ +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; +} + +/** + * Build the SDK-neutral spec set for a Workspace. + * + * Read-only sets keep the four search and inspection tools. The mutating + * tools, `exec`, and `publish` are added under the same conditions the + * AI SDK entrypoint has always used. + */ +export function createToolSpecs(options: CreateToolsOptions): ToolSpecSet { + const store = new WorkspaceFileStore(options.workspace); + const readOptions: ReadToolOptions = { store, ...options.read }; + + const specs: Record = {}; + const add = (spec: ToolSpec) => { + specs[spec.name] = spec as unknown as AnyToolSpec; + }; + + const readExecutor = createReadExecutor(readOptions); + add( + defineTool({ + name: "read", + description: readDescription(readOptions), + inputSchema: readInputSchema, + execute: (input: ReadInput) => readExecutor(input), + toModelOutput: readModelOutput(readOptions), + // Positioned reads hand back byte offsets the model must echo + // back verbatim on the next call, so constrain them. + traits: { strictArguments: true }, + }), + ); + + const listWorkspaceLike = options.workspace as ListWorkspaceLike; + add( + defineTool({ + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + execute: (input: ListInput) => listWorkspace(listWorkspaceLike, input), + }), + ); + + const findWorkspaceLike = options.workspace as FindWorkspaceLike; + add( + defineTool({ + name: "find", + description: findDescription, + inputSchema: findInputSchema, + execute: (input: FindInput) => findInWorkspace(findWorkspaceLike, input), + }), + ); + + const grepWorkspaceLike = options.workspace as GrepWorkspaceLike; + add( + defineTool({ + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input: GrepInput) => grepInWorkspace(grepWorkspaceLike, input), + }), + ); + + if (options.readonly === true) return specs; + + const writeOptions: WriteToolOptions = { store, ...options.write }; + add( + defineTool({ + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + execute: (input: WriteInput) => writeToStore(writeOptions, input), + outputSchema: writeOutputSchema, + // The whole file body travels as one string argument. + traits: { mutates: true, strictArguments: true }, + }), + ); + + const editOptions: EditToolOptions = { store, ...options.edit }; + add( + defineTool({ + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + execute: (input: EditInput) => editInStore(editOptions, input), + outputSchema: editOutputSchema, + // A nested array of exact-match strings is the easiest shape for + // a model to malform, and a malformed edit costs a whole turn. + traits: { mutates: true, strictArguments: true }, + }), + ); + + add( + defineTool({ + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input: DeleteInput) => deleteFromStore({ store }, input), + outputSchema: deleteOutputSchema, + traits: { mutates: true }, + }), + ); + + if (options.shell !== undefined) { + const execOptions: ExecToolOptions = { + workspace: options.workspace as ExecWorkspaceLike, + ...options.shell, + }; + const executor = createExecExecutor(execOptions); + add( + defineTool({ + name: "exec", + description: execDescription(execOptions), + inputSchema: execInputSchema(execOptions), + execute: (input: ExecInput, context) => executor(input, context), + traits: { mutates: true, streams: true }, + }), + ); + } + + if (options.assets !== false && options.workspace.assets !== undefined) { + const publishWorkspace = options.workspace as PublishWorkspaceLike; + const executor = createPublishExecutor(publishWorkspace); + add( + defineTool({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input: PublishInput) => executor(input), + outputSchema: publishOutputSchema, + traits: { mutates: true }, + }), + ); + } + + return specs; +} + +export type { AssetsClient }; diff --git a/packages/computer/src/tools/spec.ts b/packages/computer/src/tools/spec.ts new file mode 100644 index 00000000..557a5d2c --- /dev/null +++ b/packages/computer/src/tools/spec.ts @@ -0,0 +1,244 @@ +/** + * SDK-neutral tool specifications. + * + * A `ToolSpec` is the single description of one workspace tool: its + * model-facing name and description, its Zod input schema, its executor, + * and an optional hook that shapes the value the model finally sees. + * Nothing in this module imports an agent SDK. + * + * Each SDK entrypoint (`./ai.js`, `./pi.js`, `./tanstack.js`) is a thin + * adapter over the same specs, so a behavior change to a tool lands in + * one place and reaches every SDK. `./registry.js` builds the spec set; + * the adapters only translate the shape. + */ + +import type { z } from "zod"; + +/** + * How a result should be presented to the model. + * + * The file tools return rich structured results, but the best prompt + * representation is not always JSON: a complete text read is cheaper as + * plain text, an error reads better as an error string, and an image or + * PDF has to travel as a typed media part. `ModelOutput` names those + * cases in SDK-neutral terms and each adapter lowers them onto whatever + * its SDK supports, degrading to text when the SDK has no equivalent. + */ +export type ModelOutput = + | { type: "text"; value: string } + | { type: "error-text"; value: string } + | { type: "json"; value: unknown } + /** + * An image or PDF to hand the model. + * + * `data` is base64 because that is how the read tool captures the + * bytes and how pi and TanStack want them on the wire. The AI SDK + * accepts a base64 string for a `file` part too, so no adapter has to + * decode it. + */ + | { type: "media"; text: string; data: string; mediaType: string; filename?: string }; + +/** + * One tool, described once, independent of any SDK. + * + * `execute` may return a value or an async iterable of progressive + * snapshots. Streaming tools (`exec`) yield successive snapshots of the + * same run; adapters for SDKs without streaming tool results drain the + * iterable and keep the final snapshot, which is why every yielded value + * is a complete, self-contained result rather than a delta. + */ +export interface ToolSpec { + name: string; + description: string; + inputSchema: z.ZodType; + /** + * Shape of a successful result. + * + * Optional and never used to gate execution: an executor returns + * either this shape or `{ error }`, and the error branch is a normal + * outcome rather than a validation failure. Adapters forward it to + * SDKs that can describe a tool's output to the model or type a + * client-side handler. + */ + outputSchema?: z.ZodType; + execute: (input: Input, context: ToolCallContext) => Promise | AsyncIterable; + /** + * Map a settled result onto its model-facing representation. Omit to + * let the adapter apply its SDK's default encoding of the raw value. + */ + toModelOutput?: (args: { input: Input; output: Output }) => ModelOutput | Promise; + /** SDK-agnostic traits adapters lower onto native features. */ + traits?: ToolTraits; +} + +/** + * Properties of a tool that some SDKs can act on natively. + * + * These describe the tool itself rather than any one SDK's encoding of + * it, so the shared registry can state them once and each adapter can + * use them where its SDK has a matching feature and ignore them where + * it does not. + */ +export interface ToolTraits { + /** + * The tool changes workspace state. + * + * Drives approval gating in SDKs that support it, so a caller asking + * to confirm destructive work does not have to restate which tools + * those are. + */ + mutates?: boolean; + /** + * The tool's arguments are worth constraining during sampling. + * + * Set for tools whose arguments are structurally fussy enough that a + * malformed call costs a wasted turn — long verbatim strings, nested + * arrays. pi maps it to provider-side strict schema enforcement. + */ + strictArguments?: boolean; + /** + * The tool emits progressive snapshots while it runs. + * + * Lets an adapter decide whether to wire streaming machinery at all, + * rather than inspecting the executor's return value at call time. + */ + streams?: boolean; +} + +/** + * Per-call information an executor may use. + * + * Only cancellation is portable across the three SDKs today, so that is + * all this carries. Keeping it an object rather than a bare signal lets + * later additions stay backward compatible. + */ +export interface ToolCallContext { + abortSignal?: AbortSignal; +} + +/** + * A tool spec whose input type has been erased. + * + * A spec set holds tools with different input types, so the element + * type has to forget them. Erasing to `ToolSpec` would be + * unsound in `execute`'s contravariant parameter and erasing to + * `ToolSpec` makes the set unusable, so the schema and the + * executor are restated as an internally-consistent pair: whatever the + * schema parses is exactly what the executor accepts, even though a + * caller can no longer name that type. + */ +export interface AnyToolSpec { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + execute: (input: never, context: ToolCallContext) => Promise | AsyncIterable; + toModelOutput?: (args: { input: never; output: never }) => ModelOutput | Promise; + traits?: ToolTraits; +} + +/** A spec set keyed by model-facing tool name. */ +export type ToolSpecSet = Record; + +/** + * Run an erased spec. + * + * The cast is the one place the erasure is reintroduced. It is sound + * because `AnyToolSpec` guarantees the schema and executor came from the + * same `ToolSpec`, so a value the schema produced is a value the + * executor accepts. + */ +export function runSpec( + spec: AnyToolSpec, + input: unknown, + context: ToolCallContext, +): Promise | AsyncIterable { + return ( + spec.execute as (i: unknown, c: ToolCallContext) => Promise | AsyncIterable + )(input, context); +} + +/** Apply an erased spec's model-output hook, if it has one. */ +export async function applyModelOutput( + spec: AnyToolSpec, + input: unknown, + output: unknown, +): Promise { + if (!spec.toModelOutput) return defaultModelOutput(output); + const hook = spec.toModelOutput as (args: { + input: unknown; + output: unknown; + }) => ModelOutput | Promise; + return await hook({ input, output }); +} + +/** + * Declare a spec while keeping `Input` and `Output` inferred. + * + * Without this helper every spec would need its generics written out to + * keep `execute` and `toModelOutput` agreeing on one input type. + */ +export function defineTool(spec: ToolSpec): ToolSpec { + return spec; +} + +/** + * Drain an executor to its settled result. + * + * Shared by the adapters whose SDKs cannot forward progressive tool + * output. The last yielded snapshot is the terminal one; an iterable + * that yields nothing is a contract violation by the executor. + */ +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) + ); +} + +/** + * Default model representation for a spec without a `toModelOutput`. + * + * An `{ error }` result becomes error text so SDKs that distinguish tool + * failures can mark the call as failed; everything else stays JSON. + */ +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 }; +} + +/** Render a `ModelOutput` as plain text, for SDKs with no richer channel. */ +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/tanstack.test.ts b/packages/computer/src/tools/tanstack.test.ts new file mode 100644 index 00000000..24f87589 --- /dev/null +++ b/packages/computer/src/tools/tanstack.test.ts @@ -0,0 +1,268 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { Workspace } from "../workspace.js"; +import { defineTool } from "./spec.js"; +import { createTanStackTools, toTanStackTools } from "./tanstack.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +// An in-process command backend that streams a fixed event sequence, +// so the exec tool runs against 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 () => {} }; + }, + }; +} + +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 schema, so a + // failure has to pass it too. A success-only schema would replace + // the real reason with a validation complaint. + 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 }; + + // Validating this against the tool's own outputSchema must keep the + // message intact, which is what TanStack does with every return. + 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 }> = []; + // Exercise the event forwarding against the adapter's contract: + // the last snapshot settles, earlier ones are emitted. + const tools = toTanStackTools( + { + fake: defineTool({ + name: "fake", + description: "d", + inputSchema: z.object({}), + traits: { streams: true }, + execute: async function* () { + yield { exitCode: null, stdout: "partial" }; + yield { exitCode: 0, stdout: "complete" }; + }, + }) as never, + }, + { streamEventName: "exec-progress", format: "object" }, + ); + + const result = await tools.fake.execute({} as never, { + toolCallId: "call-1", + emitCustomEvent: (name, value) => events.push({ name, value }), + }); + + expect(result).toMatchObject({ exitCode: 0, stdout: "complete" }); + expect(events).toHaveLength(1); + expect(events[0].name).toBe("exec-progress"); + expect(events[0].value.snapshot).toMatchObject({ stdout: "partial" }); + }); +}); diff --git a/packages/computer/src/tools/tanstack.ts b/packages/computer/src/tools/tanstack.ts new file mode 100644 index 00000000..d369eb22 --- /dev/null +++ b/packages/computer/src/tools/tanstack.ts @@ -0,0 +1,283 @@ +/** + * Tools for [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`). + * + * A TanStack tool is a plain object with a `name`, a `description`, an + * `inputSchema`, and an `execute`. `inputSchema` is a Standard Schema, + * which Zod v4 implements, so the schemas in `./spec.js` are passed + * through untouched — no conversion and no second copy of any schema. + * + * `createTanStackTools` returns a list, which is what every TanStack + * entry point takes: `chat({ tools })`, `mergeAgentTools`, and + * `createToolRegistry` all want an array. Pass `format: "object"` to + * get the same tools keyed by name, for reaching one directly. + */ + +import type { z } from "zod"; +import { type CreateToolsOptions, createToolSpecs } from "./registry.js"; +import { + applyModelOutput, + type ModelOutput, + modelOutputToText, + runSpec, + settle, + type ToolSpecSet, +} from "./spec.js"; + +/** + * A TanStack AI server tool. + * + * Structurally compatible with the objects `toolDefinition().server()` + * produces, declared locally so this module does not need `@tanstack/ai` + * at build time. `chat()` reads exactly these fields for a server tool. + */ +export interface TanStackTool { + name: string; + description: string; + inputSchema: z.ZodType; + /** + * Shape of a successful result. TanStack validates a tool return + * against it client-side and threads it into the typed hooks, so a UI + * gets the result shape without restating it. + */ + outputSchema?: z.ZodType; + // TanStack hands the validated arguments back as `any`, so the + // parameter is declared the same way here. A narrower parameter would + // be unsound in the position TanStack calls it from, and each spec + // validates its own input before using it. + // biome-ignore lint/suspicious/noExplicitAny: matches the signature chat() calls + execute: (input: any, context?: TanStackToolExecutionContext) => Promise; + needsApproval?: boolean; + /** + * Withheld from the prompt until discovered through TanStack lazy + * tool discovery, which keeps a large tool set out of the system + * prompt until it is wanted. + */ + lazy?: boolean; + /** + * Phantom marker TanStack uses to tell an ordinary tool apart from a + * provider-supplied one. It carries no value at runtime; declaring it + * `undefined` is what lets a tool built here satisfy the union + * `chat({ tools })` accepts. + */ + readonly "~toolKind"?: undefined; +} + +/** + * The execution context TanStack passes to a server tool. + * + * Only the fields this adapter uses are declared. `abortSignal` is + * absent from the context, so cancellation is wired through the + * `signal` option on `createTanStackTools` instead. + */ +export interface TanStackToolExecutionContext { + toolCallId?: string; + emitCustomEvent?: (eventName: string, value: Record) => void; +} + +/** + * A list of tools, ready to pass to `chat({ tools })`. + * + * The element type erases its input to `never` because `chat()` accepts + * a tool whose `execute` takes `any`, and a spec validates its own + * input before use, so the looser parameter is accurate here. + */ +export type TanStackToolList = TanStackTool[]; + +/** + * The same tools keyed by name. + * + * No TanStack entry point takes this shape — it is for reaching one + * tool directly, such as to adjust a single tool before the call. + */ +export type TanStackToolSet = Record>; + +/** + * Which shape the builders return. + * + * Defaults to `"array"`, because that is what every TanStack entry + * point takes: `chat({ tools })`, `mergeAgentTools`, and + * `createToolRegistry` all call array methods on what they are given. + * `"object"` keys the same tools by name, for a caller that reaches + * one tool directly rather than passing the set along. + */ +export type TanStackToolFormat = "array" | "object"; + +/** Return shape for a given {@link TanStackToolFormat}. */ +export type TanStackToolsFor = Format extends "object" + ? TanStackToolSet + : TanStackToolList; + +export interface CreateTanStackToolsOptions + extends CreateToolsOptions { + /** + * Shape to return the tools in. Defaults to `"array"`, which is what + * every TanStack entry point takes. Pass `"object"` to get them + * keyed by name instead, for reaching one tool directly. + */ + format?: Format; + /** + * Which tools pause for user approval before running. + * + * A name list gates exactly those tools. `"mutating"` gates every + * tool that changes workspace state, which is the common case for a + * UI that wants a confirmation step and avoids restating the list + * when the tool set grows. + */ + approve?: string[] | "mutating"; + /** + * Signal that cancels in-flight tool executions. + * + * TanStack's tool execution context carries no abort signal, so a + * caller that wants `exec` to stop when the request is aborted passes + * the same signal it gave `chat({ abortController })` here. + */ + signal?: AbortSignal; + /** + * Emit progressive `exec` snapshots as custom stream events. + * + * TanStack tools settle on one return value, so intermediate output + * is otherwise discarded. When true, each pre-terminal snapshot is + * forwarded through `emitCustomEvent` under this event name so a UI + * can show a command's output while it runs. Defaults to false. + */ + streamEventName?: string; + /** + * Tools to withhold from the prompt until TanStack lazy discovery + * asks for them. `"all"` marks the whole set lazy, which suits an + * agent whose workspace work is occasional rather than central. + */ + lazy?: string[] | "all"; +} + +/** + * Build the TanStack AI tools for a Workspace. + * + * Returns a list, ready to pass straight to `chat({ tools })`. It holds + * the same tools, caps, and gating as the AI SDK and pi entrypoints. + * Pass `format: "object"` to get them keyed by name instead. + */ +export function createTanStackTools( + options: CreateTanStackToolsOptions, +): TanStackToolsFor { + const specs = createToolSpecs(options); + return toTanStackTools(specs, options); +} + +/** Adapt an existing spec set to TanStack tools. */ +export function toTanStackTools( + specs: ToolSpecSet, + options: Omit, keyof CreateToolsOptions> = {}, +): TanStackToolsFor { + const tools: TanStackToolList = []; + + for (const spec of Object.values(specs)) { + const needsApproval = wants(options.approve, spec.name, spec.traits?.mutates === true); + const lazy = wants(options.lazy, spec.name, options.lazy === "all"); + tools.push({ + name: spec.name, + description: spec.description, + inputSchema: spec.inputSchema, + // TanStack validates every return against this, including the + // error branch, so a spec's schema has to describe both outcomes. + // A success-only schema would replace a real failure reason with + // a schema complaint. + outputSchema: spec.outputSchema, + needsApproval: needsApproval ? true : undefined, + lazy: lazy ? true : undefined, + execute: async (input, context) => { + const returned = runSpec(spec, input, { abortSignal: options.signal }); + const output = + options.streamEventName && spec.traits?.streams === true + ? await settleWithEvents(returned, options.streamEventName, context) + : await settle(returned); + return toTanStackOutput(await applyModelOutput(spec, input, output)); + }, + } as TanStackTool); + } + + 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; +} + +/** + * Drain an executor, forwarding pre-terminal snapshots as custom events. + * + * The last snapshot is the settled result and is returned rather than + * emitted, so a consumer that ignores custom events still sees the + * complete outcome as the tool's return value. + */ +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 (seen) { + emit(eventName, { toolCallId: context?.toolCallId, snapshot: last as never }); + } + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; +} + +/** + * Resolve a name-list-or-keyword option for one tool. + * + * A list names tools explicitly; a keyword defers to the trait the + * caller asked to select on. + */ +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) + ); +} + +/** + * Lower a neutral `ModelOutput` onto a TanStack tool return value. + * + * TanStack serializes whatever a tool returns into the tool-result + * message, so structured results stay objects. Media has no typed + * tool-result part, so an image or PDF returns its descriptive text + * alongside the base64 payload and its media type, letting a caller + * that cares reattach it as a message part. + */ +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 };