From 9143353d342aaa5f442744becfa0acfe67a42c21 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:44:17 +0000 Subject: [PATCH 1/4] computer: Add pi-ai and TanStack AI tool sets Split each tool into a framework-neutral core under tools/common (schema, description, executor; zod only) and an adapter per agent library. tools/ai-sdk wraps the core with `tool()` from `ai`; tools/pi-ai and tools/tanstack-ai build their own shapes from the same core without importing their libraries, so each entry point pulls in only what it uses. createAITools stays exported from @cloudflare/computer/tools. The exec core keeps the current options: a `shell` with a backend map and a default backend. All three tool sets resolve options through the same resolveToolOptions, so they offer the same tools. The pi and TanStack adapters come from #149. Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com> --- packages/computer/package.json | 8 + packages/computer/rolldown.config.ts | 2 + .../{ai.test.ts => ai-sdk/index.test.ts} | 6 +- packages/computer/src/tools/ai-sdk/index.ts | 66 +++ packages/computer/src/tools/ai-sdk/output.ts | 35 ++ packages/computer/src/tools/ai-sdk/tools.ts | 141 ++++++ packages/computer/src/tools/ai.ts | 50 --- .../computer/src/tools/{ => common}/exec.ts | 51 ++- .../src/tools/{ => common}/fs/delete.test.ts | 0 .../src/tools/{ => common}/fs/delete.ts | 28 +- .../tools/{ => common}/fs/edit-diff.test.ts | 0 .../src/tools/{ => common}/fs/edit-diff.ts | 0 packages/computer/src/tools/common/fs/edit.ts | 178 ++++++++ packages/computer/src/tools/common/fs/find.ts | 95 +++++ packages/computer/src/tools/common/fs/grep.ts | 125 ++++++ packages/computer/src/tools/common/fs/list.ts | 103 +++++ .../src/tools/{ => common}/fs/locks.test.ts | 0 .../src/tools/{ => common}/fs/locks.ts | 0 .../src/tools/{ => common}/fs/media.test.ts | 0 .../src/tools/{ => common}/fs/media.ts | 0 .../src/tools/{ => common}/fs/read.test.ts | 0 .../src/tools/{ => common}/fs/read.ts | 122 +++--- .../src/tools/{ => common}/fs/store.ts | 0 .../src/tools/{ => common}/fs/types.ts | 0 .../src/tools/{ => common}/fs/write.test.ts | 0 .../src/tools/{ => common}/fs/write.ts | 27 +- .../computer/src/tools/common/model-output.ts | 22 + packages/computer/src/tools/common/options.ts | 54 +++ packages/computer/src/tools/common/publish.ts | 68 +++ packages/computer/src/tools/common/stream.ts | 29 ++ packages/computer/src/tools/fs/edit.ts | 141 ------ packages/computer/src/tools/fs/find.ts | 71 ---- packages/computer/src/tools/fs/grep.ts | 107 ----- packages/computer/src/tools/fs/list.ts | 79 ---- packages/computer/src/tools/index.ts | 49 ++- .../computer/src/tools/pi-ai/index.test.ts | 286 +++++++++++++ packages/computer/src/tools/pi-ai/index.ts | 377 ++++++++++++++++ .../src/tools/pi-ai/model-output.test.ts | 32 ++ packages/computer/src/tools/publish.ts | 51 --- .../src/tools/tanstack-ai/index.test.ts | 401 ++++++++++++++++++ .../computer/src/tools/tanstack-ai/index.ts | 287 +++++++++++++ 41 files changed, 2477 insertions(+), 614 deletions(-) rename packages/computer/src/tools/{ai.test.ts => ai-sdk/index.test.ts} (99%) create mode 100644 packages/computer/src/tools/ai-sdk/index.ts create mode 100644 packages/computer/src/tools/ai-sdk/output.ts create mode 100644 packages/computer/src/tools/ai-sdk/tools.ts delete mode 100644 packages/computer/src/tools/ai.ts rename packages/computer/src/tools/{ => common}/exec.ts (92%) rename packages/computer/src/tools/{ => common}/fs/delete.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/delete.ts (63%) rename packages/computer/src/tools/{ => common}/fs/edit-diff.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/edit-diff.ts (100%) create mode 100644 packages/computer/src/tools/common/fs/edit.ts create mode 100644 packages/computer/src/tools/common/fs/find.ts create mode 100644 packages/computer/src/tools/common/fs/grep.ts create mode 100644 packages/computer/src/tools/common/fs/list.ts rename packages/computer/src/tools/{ => common}/fs/locks.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/locks.ts (100%) rename packages/computer/src/tools/{ => common}/fs/media.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/media.ts (100%) rename packages/computer/src/tools/{ => common}/fs/read.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/read.ts (82%) rename packages/computer/src/tools/{ => common}/fs/store.ts (100%) rename packages/computer/src/tools/{ => common}/fs/types.ts (100%) rename packages/computer/src/tools/{ => common}/fs/write.test.ts (100%) rename packages/computer/src/tools/{ => common}/fs/write.ts (74%) create mode 100644 packages/computer/src/tools/common/model-output.ts create mode 100644 packages/computer/src/tools/common/options.ts create mode 100644 packages/computer/src/tools/common/publish.ts create mode 100644 packages/computer/src/tools/common/stream.ts delete mode 100644 packages/computer/src/tools/fs/edit.ts delete mode 100644 packages/computer/src/tools/fs/find.ts delete mode 100644 packages/computer/src/tools/fs/grep.ts delete mode 100644 packages/computer/src/tools/fs/list.ts create mode 100644 packages/computer/src/tools/pi-ai/index.test.ts create mode 100644 packages/computer/src/tools/pi-ai/index.ts create mode 100644 packages/computer/src/tools/pi-ai/model-output.test.ts delete mode 100644 packages/computer/src/tools/publish.ts create mode 100644 packages/computer/src/tools/tanstack-ai/index.test.ts create mode 100644 packages/computer/src/tools/tanstack-ai/index.ts diff --git a/packages/computer/package.json b/packages/computer/package.json index 516e38d4..f9162693 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-ai": { + "types": "./dist/tools/pi-ai.d.ts", + "import": "./dist/tools/pi-ai.js" + }, + "./tools/tanstack-ai": { + "types": "./dist/tools/tanstack-ai.d.ts", + "import": "./dist/tools/tanstack-ai.js" + }, "./backends/container-legacy": { "types": "./dist/backends/container-legacy/index.d.ts", "import": "./dist/backends/container-legacy/index.js" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 0a78e602..8bbbaabd 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-ai": "src/tools/pi-ai/index.ts", + "tools/tanstack-ai": "src/tools/tanstack-ai/index.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai-sdk/index.test.ts similarity index 99% rename from packages/computer/src/tools/ai.test.ts rename to packages/computer/src/tools/ai-sdk/index.test.ts index 5d283781..ece8b042 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai-sdk/index.test.ts @@ -1,7 +1,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; -import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js"; -import { Workspace } from "../workspace.js"; +import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../../runtime/types.js"; +import { Workspace } from "../../workspace.js"; import { createAITools, createDeleteTool, @@ -12,7 +12,7 @@ import { createWriteTool, type FileStore, WorkspaceFileStore, -} from "./index.js"; +} from "../index.js"; const toolOptions = { toolCallId: "test-call", messages: [] }; diff --git a/packages/computer/src/tools/ai-sdk/index.ts b/packages/computer/src/tools/ai-sdk/index.ts new file mode 100644 index 00000000..be84570a --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/index.ts @@ -0,0 +1,66 @@ +import type { ToolSet } from "ai"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import type { PublishWorkspaceLike } from "../common/publish.js"; +import { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +/** Options for {@link createAITools}. */ +export type CreateAIToolsOptions = CreateToolsOptions; + +export { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +/** + * Build the AI SDK tool set for a Workspace: `read`, `ls`, `find`, and + * `grep`, plus `write`, `edit`, `delete`, `exec`, and `publish` unless + * the set is read-only. `exec` offers every backend the Workspace has + * unless `exec` picks them. + * + * @param options - The Workspace and per-tool options. + * @returns An AI SDK `ToolSet` for `generateText`, `streamText`, or an agent's `getTools()`. + */ +export function createAITools(options: CreateAIToolsOptions): ToolSet { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + + const tools: ToolSet = { + read: createReadTool(resolved.read), + ls: createListTool({ workspace }), + find: createFindTool({ workspace }), + grep: createGrepTool({ workspace }), + }; + + if (resolved.readonly) return tools; + + tools.write = createWriteTool(resolved.write); + tools.edit = createEditTool(resolved.edit); + tools.delete = createDeleteTool(resolved.delete); + + if (resolved.exec !== undefined) { + tools.exec = createExecTool(resolved.exec); + } + + if (resolved.publish) { + tools.publish = createPublishTool({ workspace: workspace as PublishWorkspaceLike }); + } + + return tools; +} diff --git a/packages/computer/src/tools/ai-sdk/output.ts b/packages/computer/src/tools/ai-sdk/output.ts new file mode 100644 index 00000000..963a93c2 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/output.ts @@ -0,0 +1,35 @@ +import type { JSONValue } from "ai"; +import type { ModelOutput } from "../common/model-output.js"; + +export function toAISDKOutput(output: ModelOutput) { + switch (output.type) { + case "text": + return { type: "text" as const, value: output.value }; + case "error-text": + return { type: "error-text" as const, value: output.value }; + case "json": + return { type: "json" as const, value: toJSONValue(output.value) }; + case "media": + return { + type: "content" as const, + value: [ + { type: "text" as const, text: output.text }, + { + type: "file" as const, + data: { type: "data" as const, data: output.data }, + mediaType: output.mediaType, + filename: output.filename, + }, + ], + }; + } +} + +export function toJSONValue(value: unknown): JSONValue { + try { + const json = JSON.stringify(value); + return json === undefined ? null : (JSON.parse(json) as JSONValue); + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/ai-sdk/tools.ts b/packages/computer/src/tools/ai-sdk/tools.ts new file mode 100644 index 00000000..f4b40a42 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/tools.ts @@ -0,0 +1,141 @@ +import { type Tool, tool } from "ai"; +import type { z } from "zod"; +import { + defineExec, + type ExecInput, + type ExecToolOptions, + type ExecToolOutput, +} from "../common/exec.js"; +import { + type DeleteToolOptions, + deleteDescription, + deleteFromStore, + deleteInputSchema, +} from "../common/fs/delete.js"; +import { + type EditToolOptions, + editDescription, + editInputSchema, + editInStore, +} from "../common/fs/edit.js"; +import { + type FindToolOptions, + findDescription, + findInputSchema, + findInWorkspace, +} from "../common/fs/find.js"; +import { + type GrepToolOptions, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "../common/fs/grep.js"; +import { + type ListToolOptions, + listDescription, + listInputSchema, + listWorkspace, +} from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolOptions, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + type WriteToolOptions, + writeDescription, + writeInputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { + createPublishExecutor, + type PublishToolOptions, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { toAISDKOutput } from "./output.js"; + +export function createReadTool(options: ReadToolOptions): Tool> { + const toModelOutput = readModelOutput(options); + return tool({ + description: readDescription(options), + inputSchema: readInputSchema, + execute: createReadExecutor(options), + toModelOutput: ({ input, output }: { input: unknown; output: unknown }) => + toAISDKOutput(toModelOutput({ input: input as ReadInput, output: output as ReadToolResult })), + }); +} + +export function createWriteTool(options: WriteToolOptions): Tool> { + return tool({ + description: writeDescription, + inputSchema: writeInputSchema, + execute: (input) => writeToStore(options, input), + }); +} + +export function createEditTool(options: EditToolOptions): Tool> { + return tool({ + description: editDescription, + inputSchema: editInputSchema, + execute: (rawInput) => editInStore(options, rawInput), + }); +} + +export function createDeleteTool( + options: DeleteToolOptions, +): Tool> { + return tool({ + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(options, input), + }); +} + +export function createListTool(options: ListToolOptions): Tool> { + return tool({ + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(options.workspace, input), + }); +} + +export function createFindTool(options: FindToolOptions): Tool> { + return tool({ + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(options.workspace, input), + }); +} + +export function createGrepTool(options: GrepToolOptions): Tool> { + return tool({ + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(options.workspace, input), + }); +} + +export function createExecTool(options: ExecToolOptions): Tool { + const exec = defineExec(options); + return tool({ + description: exec.description, + inputSchema: exec.inputSchema, + execute: (input, { abortSignal }) => exec.execute(input, { abortSignal }), + }); +} + +export function createPublishTool( + options: PublishToolOptions, +): Tool> { + const execute = createPublishExecutor(options.workspace); + return tool({ + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => execute(input), + }); +} diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts deleted file mode 100644 index 78cc8358..00000000 --- a/packages/computer/src/tools/ai.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ToolSet } from "ai"; -import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; -import { createDeleteTool } from "./fs/delete.js"; -import { createEditTool, type EditToolOptions } from "./fs/edit.js"; -import { createFindTool } from "./fs/find.js"; -import { createGrepTool } from "./fs/grep.js"; -import { createListTool } from "./fs/list.js"; -import { createReadTool, type ReadToolOptions } from "./fs/read.js"; -import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; -import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; - -export interface CreateAIToolsOptions { - workspace: FileWorkspaceLike & Partial & Partial; - readonly?: boolean; - assets?: boolean; - read?: Omit; - write?: Omit; - edit?: Omit; - shell?: Omit; -} - -export function createAITools(options: CreateAIToolsOptions): ToolSet { - const store = new WorkspaceFileStore(options.workspace); - const tools: ToolSet = { - read: createReadTool({ store, ...options.read }), - ls: createListTool({ workspace: options.workspace }), - find: createFindTool({ workspace: options.workspace }), - grep: createGrepTool({ workspace: options.workspace }), - }; - - if (options.readonly === true) return tools; - - tools.write = createWriteTool({ store, ...options.write }); - tools.edit = createEditTool({ store, ...options.edit }); - tools.delete = createDeleteTool({ store }); - - if (options.shell !== undefined) { - tools.exec = createExecTool({ - workspace: options.workspace as ExecWorkspaceLike, - ...options.shell, - }); - } - - if (options.assets !== false && options.workspace.assets !== undefined) { - tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); - } - - return tools; -} diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/common/exec.ts similarity index 92% rename from packages/computer/src/tools/exec.ts rename to packages/computer/src/tools/common/exec.ts index 5a58fb36..c9d70416 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/common/exec.ts @@ -1,8 +1,7 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; -import { notCallableMessage } from "../runtime/runtime.js"; -import type { WorkspaceRuntimeValue } from "../runtime/types.js"; +import { notCallableMessage } from "../../runtime/runtime.js"; +import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; // A finite JSON value: what a callable backend accepts as `input` and // returns as `result`. Declared as a concrete recursive schema rather @@ -110,16 +109,36 @@ 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 -> { +export interface ExecInput { + command: string; + cwd?: string; + backend?: string; + env?: Record; + input?: WorkspaceRuntimeValue; +} + +export interface ExecCallContext { + abortSignal?: AbortSignal; +} + +/** The exec tool with no agent library attached. Each library wraps it in its own tool shape. */ +export interface ExecDefinition { + description: string; + inputSchema: z.ZodType; + /** + * Yields running snapshots while the command streams, then one + * terminal snapshot. Every snapshot is a complete result, so a + * library that cannot stream tool output keeps the last one. + */ + execute(input: ExecInput, context?: ExecCallContext): AsyncGenerator; +} + +/** + * Check the backends once and build the exec tool's description, input + * schema, and executor. Throws when no backend is given or the default + * is not among them, so a misconfigured tool fails when it is built. + */ +export function defineExec(options: ExecToolOptions): ExecDefinition { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const streamMaxBytes = options.streamMaxBytes ?? DEFAULT_STREAM_MAX_BYTES; const now = options.now ?? Date.now; @@ -171,7 +190,7 @@ export function createExecTool(options: ExecToolOptions): Tool< ].join(" "), ); - return tool({ + return { description, inputSchema: z.object({ command: z @@ -193,7 +212,7 @@ export function createExecTool(options: ExecToolOptions): Tool< "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 }) { + 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)) { @@ -294,7 +313,7 @@ export function createExecTool(options: ExecToolOptions): Tool< } } }, - }); + }; } function errorMessage(err: unknown): string { diff --git a/packages/computer/src/tools/fs/delete.test.ts b/packages/computer/src/tools/common/fs/delete.test.ts similarity index 100% rename from packages/computer/src/tools/fs/delete.test.ts rename to packages/computer/src/tools/common/fs/delete.test.ts diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/common/fs/delete.ts similarity index 63% rename from packages/computer/src/tools/fs/delete.ts rename to packages/computer/src/tools/common/fs/delete.ts index 41cf6315..365cfdc5 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/common/fs/delete.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { MutableFileStore } from "./types.js"; @@ -7,7 +6,7 @@ export interface DeleteToolOptions { store: MutableFileStore; } -const inputSchema = z.object({ +export const deleteInputSchema = z.object({ path: z.string().describe("Absolute path to the file or directory to delete."), recursive: z .boolean() @@ -15,6 +14,22 @@ const inputSchema = z.object({ .describe("Remove a directory and all of its contents. Defaults to false."), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const deleteOutputSchema = z.union([ + z.object({ deleted: z.string() }), + z.object({ error: z.string() }), +]); + +export const deleteDescription = + "Delete a file or directory. Set recursive to true to remove a non-empty directory."; + export interface DeleteInput { path: string; recursive?: boolean; @@ -38,12 +53,3 @@ export function deleteFromStore( { subtree: recursive === true }, ); } - -export function createDeleteTool(options: DeleteToolOptions): Tool> { - return tool({ - description: - "Delete a file or directory. Set recursive to true to remove a non-empty directory.", - inputSchema, - execute: (input) => deleteFromStore(options, input), - }); -} diff --git a/packages/computer/src/tools/fs/edit-diff.test.ts b/packages/computer/src/tools/common/fs/edit-diff.test.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.test.ts rename to packages/computer/src/tools/common/fs/edit-diff.test.ts diff --git a/packages/computer/src/tools/fs/edit-diff.ts b/packages/computer/src/tools/common/fs/edit-diff.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.ts rename to packages/computer/src/tools/common/fs/edit-diff.ts diff --git a/packages/computer/src/tools/common/fs/edit.ts b/packages/computer/src/tools/common/fs/edit.ts new file mode 100644 index 00000000..ffd00f6f --- /dev/null +++ b/packages/computer/src/tools/common/fs/edit.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { + applyEditsToNormalizedContent, + detectLineEnding, + type Edit, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.js"; +import { withFileLock } from "./locks.js"; +import type { FileStore } from "./types.js"; + +export interface EditToolOptions { + store: FileStore; + /** + * Reject edits to files larger than this byte cap. Fuzzy matching needs the + * whole buffer in memory, so we'd rather force the model to use `write`. + * Default 2 MiB. + */ + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +const replacementSchema = z + .object({ + oldText: z + .string() + .describe( + "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", + ), + newText: z.string().describe("Replacement text for this targeted edit."), + }) + .strict(); + +export const editInputSchema = z.object({ + path: z.string().describe("Path to the file to edit"), + edits: z + .array(replacementSchema) + .describe( + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", + ), +}); + +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const editOutputSchema = z.union([ + z.object({ + path: z.string(), + editsApplied: z.number().int(), + diff: z.string(), + patch: z.string(), + firstChangedLine: z.number().int().optional(), + }), + z.object({ error: z.string() }), +]); + +export const editDescription = + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit."; + +export interface EditInput { + path: string; + edits: Edit[]; +} + +export interface EditSuccess { + path: string; + editsApplied: number; + diff: string; + patch: string; + /** Undefined when the edit produced no line-level change. */ + firstChangedLine: number | undefined; +} + +export type EditResult = EditSuccess | { error: string }; + +/** Best-effort coercion for inputs from quirky models. */ +function prepareArguments(input: unknown): { path: string; edits: Edit[] } { + if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; + const args = input as Record; + + // Some models pack edits into a JSON string. + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) args.edits = parsed; + } catch { + /* fall through to validation error */ + } + } + + // Legacy single-edit shape: oldText/newText siblings on the root object. + if (typeof args.oldText === "string" && typeof args.newText === "string") { + const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; + edits.push({ oldText: args.oldText as string, newText: args.newText as string }); + args.edits = edits; + delete args.oldText; + delete args.newText; + } + + return args as { path: string; edits: Edit[] }; +} + +/** + * Apply a batch of targeted replacements to one file. + * + * Takes the raw tool input because the coercion in `prepareArguments` + * has to run before validation: models sometimes pack `edits` into a + * JSON string or send a single `oldText`/`newText` pair at the root. + */ +export async function editInStore( + options: EditToolOptions, + rawInput: unknown, +): Promise { + const { store } = options; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const { path, edits } = prepareArguments(rawInput); + + if (!Array.isArray(edits) || edits.length === 0) { + return { error: "edits must contain at least one replacement." }; + } + + return withFileLock(store, path, async () => { + try { + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + if (stat.size > maxBytes) { + return { + error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, + }; + } + + const bytes = await store.readAll(path); + if (!bytes) return { error: `File not found: ${path}` }; + + const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes); + const { bom, text } = stripBom(rawContent); + const ending = detectLineEnding(text); + const normalized = normalizeToLF(text); + + let baseContent: string; + let newContent: string; + try { + ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + + const finalContent = bom + restoreLineEndings(newContent, ending); + // Round-trip the file's mode so editing an executable script (or any + // file with a non-default mode) doesn't silently drop bits. `stat.mode` + // is undefined for stores that don't track modes; pass `undefined` in + // that case so the store applies its own default. + await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + + return { + path, + editsApplied: edits.length, + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/packages/computer/src/tools/common/fs/find.ts b/packages/computer/src/tools/common/fs/find.ts new file mode 100644 index 00000000..6cae445a --- /dev/null +++ b/packages/computer/src/tools/common/fs/find.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +export interface FindWorkspaceLike { + fs: { + find( + directory: string, + pattern?: string, + options?: { limit?: number; offset?: number; exclude?: string[] }, + ): Promise; + }; +} + +export interface FindToolOptions { + workspace: FindWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const findInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute directory to search."), + pattern: z + .string() + .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const findDescription = + "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character."; + +export interface FindInput { + path?: string; + pattern: string; + exclude?: string[]; + limit?: number; + offset?: number; +} + +export type FindResult = + | { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } + | { error: string }; + +/** + * Page glob matches under a directory. + * + * `path` carries a schema default, but an executor can also be called + * directly by an SDK that does not apply Zod defaults, so the root + * fallback is repeated here. + */ +export async function findInWorkspace( + workspace: FindWorkspaceLike, + { path, pattern, exclude, limit, offset }: FindInput, +): Promise { + const directory = path ?? "/workspace"; + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const matches = await workspace.fs.find(directory, pattern, { + limit: pageSize + 1, + offset: pageOffset, + exclude, + }); + const truncated = matches.length > pageSize; + const entries = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } = { path: directory, pattern, count: entries.length, entries }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/grep.ts b/packages/computer/src/tools/common/fs/grep.ts new file mode 100644 index 00000000..6f3f6cd9 --- /dev/null +++ b/packages/computer/src/tools/common/fs/grep.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +interface GrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + +interface GrepMatch { + path: string; + line: number; + text: string; + context?: GrepContextLine[]; +} + +interface GrepOptions { + regex?: boolean; + ignoreCase?: boolean; + context?: number; + limit?: number; + offset?: number; + include?: string; + exclude?: string[]; +} + +export interface GrepWorkspaceLike { + fs: { + grep(pattern: string, path: string, options?: GrepOptions): Promise; + }; +} + +export interface GrepToolOptions { + workspace: GrepWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const grepInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute file or directory to search."), + query: z.string().describe("Literal string or regular expression to search for."), + include: z + .string() + .optional() + .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), + regex: z.boolean().optional().describe("Interpret query as a regular expression."), + ignoreCase: z.boolean().optional().describe("Ignore letter case."), + context: z.number().int().min(0).max(10).optional(), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const grepDescription = + "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines."; + +export interface GrepInput { + path?: string; + query: string; + include?: string; + exclude?: 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, exclude, 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, + exclude, + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = matches.length > pageSize; + const page = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } = { path: target, query, count: page.length, matches: page }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/list.ts b/packages/computer/src/tools/common/fs/list.ts new file mode 100644 index 00000000..b028a321 --- /dev/null +++ b/packages/computer/src/tools/common/fs/list.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +export interface ListWorkspaceLike { + fs: { + readdir( + path: string, + options?: { limit?: number; offset?: number }, + ): Promise< + Array<{ + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; + }> + >; + }; +} + +export interface ListToolOptions { + workspace: ListWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const listInputSchema = z.object({ + path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), + offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), +}); + +export const listDescription = `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`; + +export interface ListInput { + path: string; + limit?: number; + offset?: number; +} + +interface ListEntry { + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +export type ListResult = + | { path: string; count: number; entries: ListEntry[]; nextOffset?: number } + | { error: string }; + +/** + * Page one directory. + * + * Reads one more entry than the page size to learn whether a further + * page exists without a second call, then reports `nextOffset` when it + * does. + */ +export async function listWorkspace( + workspace: ListWorkspaceLike, + { path, limit, offset }: ListInput, +): Promise { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const entries = await workspace.fs.readdir(path, { + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = entries.length > pageSize; + const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ + name: entry.name, + size: entry.size, + mtime: entry.mtime, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + isSymbolicLink: entry.isSymbolicLink, + })); + const result: { + path: string; + count: number; + entries: typeof page; + nextOffset?: number; + } = { + path, + count: page.length, + entries: page, + }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/computer/src/tools/fs/locks.test.ts b/packages/computer/src/tools/common/fs/locks.test.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.test.ts rename to packages/computer/src/tools/common/fs/locks.test.ts diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/common/fs/locks.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.ts rename to packages/computer/src/tools/common/fs/locks.ts diff --git a/packages/computer/src/tools/fs/media.test.ts b/packages/computer/src/tools/common/fs/media.test.ts similarity index 100% rename from packages/computer/src/tools/fs/media.test.ts rename to packages/computer/src/tools/common/fs/media.test.ts diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/common/fs/media.ts similarity index 100% rename from packages/computer/src/tools/fs/media.ts rename to packages/computer/src/tools/common/fs/media.ts diff --git a/packages/computer/src/tools/fs/read.test.ts b/packages/computer/src/tools/common/fs/read.test.ts similarity index 100% rename from packages/computer/src/tools/fs/read.test.ts rename to packages/computer/src/tools/common/fs/read.test.ts diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/common/fs/read.ts similarity index 82% rename from packages/computer/src/tools/fs/read.ts rename to packages/computer/src/tools/common/fs/read.ts index f24cff01..e5755ae2 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/common/fs/read.ts @@ -1,5 +1,5 @@ -import { type JSONValue, type Tool, tool } from "ai"; import { z } from "zod"; +import type { ModelOutput } from "../model-output.js"; import { detectMedia } from "./media.js"; import type { FileStore } from "./types.js"; @@ -27,7 +27,7 @@ const DEFAULT_MAX_MODEL_BYTES = 3.5 * 1024 * 1024; const DEFAULT_MEDIA_SNIFF_BYTES = 512; const TRUNCATION_MARKER = "... (truncated)"; -const inputSchema = z +export const readInputSchema = z .object({ path: z.string().describe("Path to the file to read"), offset: z @@ -84,7 +84,7 @@ interface MediaReadResult { unsupported?: true; } -type ReadToolResult = ReadResult | MediaReadResult | { error: string }; +export type ReadToolResult = ReadResult | MediaReadResult | { error: string }; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: false }); @@ -93,7 +93,7 @@ function utf8ByteLength(value: string): number { return encoder.encode(value).length; } -function createReadExecutor( +export function createReadExecutor( options: ReadToolOptions, ): (input: ReadInput) => Promise { const { store } = options; @@ -303,58 +303,73 @@ export function readFromStore(options: ReadToolOptions, input: ReadInput): Promi return createReadExecutor(options)(input); } -export function createReadTool(options: ReadToolOptions): Tool> { +/** + * The model-facing description, which quotes the configured caps so the + * model can plan continuations instead of discovering the limit by + * hitting it. + */ +export function readDescription(options: ReadToolOptions): string { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + return `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`; +} + +/** + * Build the SDK-neutral model representation for a read result. + * + * A complete, unpositioned text read is returned as bare text because + * that is what the model actually wants to see. Truncated, empty, and + * explicitly positioned reads keep their JSON envelope so the + * continuation offsets survive. Eligible images and PDFs become a + * `media` output carrying the bytes captured during execution, so + * regenerating prompt history cannot observe a later version of the + * file. + */ +export function readModelOutput( + options: ReadToolOptions, +): (args: { input: ReadInput; output: ReadToolResult }) => ModelOutput { const maxModelBytes = validateBoundedReadLimit( "maxModelBytes", options.maxModelBytes ?? DEFAULT_MAX_MODEL_BYTES, ); - return tool({ - description: `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`, - inputSchema, - execute: createReadExecutor(options), - toModelOutput: async ({ input, output }: { input: unknown; output: unknown }) => { - if (!isRecord(output)) return { type: "text", value: String(output) }; - if (typeof output.error === "string") { - return { type: "error-text", value: output.error }; - } - if (typeof output.content === "string") { - const positioned = - isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); - return output.truncated === true || output.content.length === 0 || positioned - ? { type: "json", value: toJSONValue(output) } - : { type: "text", value: output.content }; - } - if (output.kind === "binary") return { type: "json", value: toJSONValue(output) }; - if (!isMediaReadResult(output)) return { type: "json", value: toJSONValue(output) }; - if (output.sizeBytes > maxModelBytes) { - return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); - } - if (output.data === undefined) { - return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; - } - if (output.data.length === 0) { - return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; - } - return { - type: "content", - value: [ - { - type: "text", - text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, - }, - { - type: "file", - data: { type: "data", data: output.data }, - mediaType: output.mediaType, - filename: output.name, - }, - ], - }; - }, - }); + return ({ input, output: settled }) => { + // Inspect the result as an open record. The union's members are + // distinguished by which fields are present rather than by a tag, + // so narrowing field-by-field is clearer than reconstructing the + // discriminator, and every branch below re-establishes the shape it + // needs before using it. + const output: Record = settled as unknown as Record; + if (!isRecord(output)) return { type: "text", value: String(output) }; + if (typeof output.error === "string") { + return { type: "error-text", value: output.error }; + } + if (typeof output.content === "string") { + const positioned = + isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); + return output.truncated === true || output.content.length === 0 || positioned + ? { type: "json", value: output } + : { type: "text", value: output.content }; + } + if (output.kind === "binary") return { type: "json", value: output }; + if (!isMediaReadResult(output)) return { type: "json", value: output }; + if (output.sizeBytes > maxModelBytes) { + return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); + } + if (output.data === undefined) { + return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; + } + if (output.data.length === 0) { + return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; + } + return { + type: "media", + text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, + data: output.data, + mediaType: output.mediaType, + filename: output.name, + }; + }; } function validateBoundedReadLimit(name: string, value: number): number { @@ -460,15 +475,6 @@ function inlineMediaLimitError( }; } -function toJSONValue(value: unknown): JSONValue { - try { - const json = JSON.stringify(value); - return json === undefined ? null : (JSON.parse(json) as JSONValue); - } catch { - return String(value); - } -} - function isReadInput( value: unknown, ): value is { path: string; offset?: number; byteOffset?: number } { diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/common/fs/store.ts similarity index 100% rename from packages/computer/src/tools/fs/store.ts rename to packages/computer/src/tools/common/fs/store.ts diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/common/fs/types.ts similarity index 100% rename from packages/computer/src/tools/fs/types.ts rename to packages/computer/src/tools/common/fs/types.ts diff --git a/packages/computer/src/tools/fs/write.test.ts b/packages/computer/src/tools/common/fs/write.test.ts similarity index 100% rename from packages/computer/src/tools/fs/write.test.ts rename to packages/computer/src/tools/common/fs/write.test.ts diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/common/fs/write.ts similarity index 74% rename from packages/computer/src/tools/fs/write.ts rename to packages/computer/src/tools/common/fs/write.ts index 89d3d479..907fcfd5 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/common/fs/write.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; @@ -14,11 +13,27 @@ export interface WriteToolOptions { const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; -const inputSchema = z.object({ +export const writeInputSchema = z.object({ path: z.string().describe("Absolute path, e.g. /workspace/main.zig"), content: z.string().describe("File content"), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const writeOutputSchema = z.union([ + z.object({ path: z.string(), bytesWritten: z.number().int() }), + z.object({ error: z.string() }), +]); + +export const writeDescription = + "Write content to a file. Overwrites any existing file at the path."; + export interface WriteInput { path: string; content: string; @@ -48,11 +63,3 @@ export async function writeToStore( } }); } - -export function createWriteTool(options: WriteToolOptions): Tool> { - return tool({ - description: "Write content to a file. Overwrites any existing file at the path.", - inputSchema, - execute: (input) => writeToStore(options, input), - }); -} diff --git a/packages/computer/src/tools/common/model-output.ts b/packages/computer/src/tools/common/model-output.ts new file mode 100644 index 00000000..cbf20c06 --- /dev/null +++ b/packages/computer/src/tools/common/model-output.ts @@ -0,0 +1,22 @@ +/** + * A model-facing representation of a tool result, in terms no agent + * library owns. Each provider lowers it onto its own library's shape, + * degrading to text where there is no equivalent. + */ +export type ModelOutput = + | { type: "text"; value: string } + | { type: "error-text"; value: string } + | { type: "json"; value: unknown } + /** `data` is base64: how the read tool captures bytes, and what pi and TanStack want on the wire. */ + | { type: "media"; text: string; data: string; mediaType: string; filename?: string }; + +export function defaultModelOutput(output: unknown): ModelOutput { + if ( + typeof output === "object" && + output !== null && + typeof (output as { error?: unknown }).error === "string" + ) { + return { type: "error-text", value: (output as { error: string }).error }; + } + return { type: "json", value: output }; +} diff --git a/packages/computer/src/tools/common/options.ts b/packages/computer/src/tools/common/options.ts new file mode 100644 index 00000000..7d78886e --- /dev/null +++ b/packages/computer/src/tools/common/options.ts @@ -0,0 +1,54 @@ +import type { ExecToolOptions, ExecWorkspaceLike } from "./exec.js"; +import type { EditToolOptions } from "./fs/edit.js"; +import type { ReadToolOptions } from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import type { WriteToolOptions } from "./fs/write.js"; +import type { PublishWorkspaceLike } from "./publish.js"; + +/** Options every tool set takes: `createAITools`, `createPiAITools`, and `createTanStackAITools`. */ +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; + /** The backends `exec` may run on and which one it uses by default. Omit for no exec tool. */ + shell?: Omit; +} + +export interface ResolvedToolOptions { + read: ReadToolOptions; + write: WriteToolOptions; + edit: EditToolOptions; + delete: { store: WorkspaceFileStore }; + /** Absent when the set is read-only or `shell` is not given. */ + exec?: ExecToolOptions; + publish: boolean; + readonly: boolean; + workspace: CreateToolsOptions["workspace"]; +} + +/** Resolve the options into what each tool needs, so every tool set offers the same tools. */ +export function resolveToolOptions(options: CreateToolsOptions): ResolvedToolOptions { + const store = new WorkspaceFileStore(options.workspace); + const readonly = options.readonly === true; + return { + read: { store, ...options.read }, + write: { store, ...options.write }, + edit: { store, ...options.edit }, + delete: { store }, + exec: readonly ? undefined : execOptions(options), + publish: !readonly && options.assets !== false && options.workspace.assets !== undefined, + readonly, + workspace: options.workspace, + }; +} + +// Pair `shell` with the Workspace's runtime. +function execOptions(options: CreateToolsOptions): ExecToolOptions | undefined { + if (options.shell === undefined) return undefined; + return { workspace: options.workspace as ExecWorkspaceLike, ...options.shell }; +} diff --git a/packages/computer/src/tools/common/publish.ts b/packages/computer/src/tools/common/publish.ts new file mode 100644 index 00000000..bec94112 --- /dev/null +++ b/packages/computer/src/tools/common/publish.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { AssetsClient } from "../../assets/index.js"; + +export interface PublishWorkspaceLike { + readonly sessionId: string; + readonly assets?: AssetsClient; +} + +export interface PublishToolOptions { + workspace: PublishWorkspaceLike; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; + +export const publishInputSchema = z.object({ + path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), + expiresAfterMs: z + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), +}); + +/** Successful publish carries the link; a failure carries the reason. */ +export const publishOutputSchema = z.union([ + z.object({ ok: z.literal(true), url: z.string() }), + z.object({ ok: z.literal(false), error: z.string() }), +]); + +export const publishDescription = + "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report."; + +export interface PublishInput { + path: string; + expiresAfterMs?: number; +} + +export type PublishResult = { ok: true; url: string } | { ok: false; error: string }; + +/** + * Bind a publish executor to one workspace. + * + * The assets client is resolved once, at construction, so a workspace + * without a configured publisher fails loudly when the tool is built + * rather than on the model's first call. + */ +export function createPublishExecutor( + workspace: PublishWorkspaceLike, +): (input: PublishInput) => Promise { + const assets = workspace.assets; + if (!assets) { + throw new Error("createPublishTool: workspace.assets is not configured"); + } + + return async ({ path, expiresAfterMs }) => { + try { + const prefix = workspace.sessionId ? `agent-${workspace.sessionId}` : undefined; + const url = await assets.share(path, { + expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, + ...(prefix ? { prefix } : {}), + }); + return { ok: true, url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }; +} diff --git a/packages/computer/src/tools/common/stream.ts b/packages/computer/src/tools/common/stream.ts new file mode 100644 index 00000000..23a322cf --- /dev/null +++ b/packages/computer/src/tools/common/stream.ts @@ -0,0 +1,29 @@ +/** + * Drain an executor to its settled result. + * + * A streaming executor yields successive complete snapshots of one run + * rather than deltas, so the last one is the whole result. + */ +export async function settle( + returned: Promise | AsyncIterable, +): Promise { + if (isAsyncIterable(returned)) { + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; + } + return await returned; +} + +export function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in (value as Record) + ); +} diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts deleted file mode 100644 index ec338b9a..00000000 --- a/packages/computer/src/tools/fs/edit.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import { - applyEditsToNormalizedContent, - detectLineEnding, - type Edit, - generateDiffString, - generateUnifiedPatch, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "./edit-diff.js"; -import { withFileLock } from "./locks.js"; -import type { FileStore } from "./types.js"; - -export interface EditToolOptions { - store: FileStore; - /** - * Reject edits to files larger than this byte cap. Fuzzy matching needs the - * whole buffer in memory, so we'd rather force the model to use `write`. - * Default 2 MiB. - */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; - -const replacementSchema = z - .object({ - oldText: z - .string() - .describe( - "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", - ), - newText: z.string().describe("Replacement text for this targeted edit."), - }) - .strict(); - -const inputSchema = z.object({ - path: z.string().describe("Path to the file to edit"), - edits: z - .array(replacementSchema) - .describe( - "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", - ), -}); - -/** Best-effort coercion for inputs from quirky models. */ -function prepareArguments(input: unknown): { path: string; edits: Edit[] } { - if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; - const args = input as Record; - - // Some models pack edits into a JSON string. - if (typeof args.edits === "string") { - try { - const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; - } catch { - /* fall through to validation error */ - } - } - - // Legacy single-edit shape: oldText/newText siblings on the root object. - if (typeof args.oldText === "string" && typeof args.newText === "string") { - const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; - edits.push({ oldText: args.oldText as string, newText: args.newText as string }); - args.edits = edits; - delete args.oldText; - delete args.newText; - } - - return args as { path: string; edits: Edit[] }; -} - -export function createEditTool(options: EditToolOptions): Tool> { - const { store } = options; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: - "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit.", - inputSchema, - execute: async (rawInput: z.infer) => { - const { path, edits } = prepareArguments(rawInput); - - if (!Array.isArray(edits) || edits.length === 0) { - return { error: "edits must contain at least one replacement." }; - } - - return withFileLock(store, path, async () => { - try { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - if (stat.size > maxBytes) { - return { - error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, - }; - } - - const bytes = await store.readAll(path); - if (!bytes) return { error: `File not found: ${path}` }; - - const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode( - bytes, - ); - const { bom, text } = stripBom(rawContent); - const ending = detectLineEnding(text); - const normalized = normalizeToLF(text); - - let baseContent: string; - let newContent: string; - try { - ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - - const finalContent = bom + restoreLineEndings(newContent, ending); - // Round-trip the file's mode so editing an executable script (or any - // file with a non-default mode) doesn't silently drop bits. `stat.mode` - // is undefined for stores that don't track modes; pass `undefined` in - // that case so the store applies its own default. - await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - - return { - path, - editsApplied: edits.length, - diff: diffResult.diff, - patch, - firstChangedLine: diffResult.firstChangedLine, - }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }); - }, - }); -} diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts deleted file mode 100644 index b19b23f9..00000000 --- a/packages/computer/src/tools/fs/find.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface FoundEntry { - path: string; - type: "file" | "dir"; -} - -export interface FindWorkspaceLike { - fs: { - find( - directory: string, - pattern?: string, - options?: { limit?: number; offset?: number; exclude?: string[] }, - ): Promise; - }; -} - -export interface FindToolOptions { - workspace: FindWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute directory to search."), - pattern: z - .string() - .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), - exclude: z - .array(z.string()) - .optional() - .describe( - 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', - ), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createFindTool(options: FindToolOptions): Tool> { - return tool({ - description: - "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character.", - inputSchema, - execute: async ({ path, pattern, exclude, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const matches = await options.workspace.fs.find(path, pattern, { - limit: pageSize + 1, - offset: pageOffset, - exclude, - }); - const truncated = matches.length > pageSize; - const entries = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - pattern: string; - count: number; - entries: FoundEntry[]; - nextOffset?: number; - } = { path, pattern, count: entries.length, entries }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts deleted file mode 100644 index 430df6f2..00000000 --- a/packages/computer/src/tools/fs/grep.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface GrepContextLine { - line: number; - text: string; - isMatch: boolean; -} - -interface GrepMatch { - path: string; - line: number; - text: string; - context?: GrepContextLine[]; -} - -interface GrepOptions { - regex?: boolean; - ignoreCase?: boolean; - context?: number; - limit?: number; - offset?: number; - include?: string; - exclude?: string[]; -} - -export interface GrepWorkspaceLike { - fs: { - grep(pattern: string, path: string, options?: GrepOptions): Promise; - }; -} - -export interface GrepToolOptions { - workspace: GrepWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute file or directory to search."), - query: z.string().describe("Literal string or regular expression to search for."), - include: z - .string() - .optional() - .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), - exclude: z - .array(z.string()) - .optional() - .describe( - 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', - ), - regex: z.boolean().optional().describe("Interpret query as a regular expression."), - ignoreCase: z.boolean().optional().describe("Ignore letter case."), - context: z.number().int().min(0).max(10).optional(), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createGrepTool(options: GrepToolOptions): Tool> { - return tool({ - description: - "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines.", - inputSchema, - execute: async ({ - path, - query, - include, - exclude, - 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, - exclude, - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = matches.length > pageSize; - const page = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - query: string; - count: number; - matches: GrepMatch[]; - nextOffset?: number; - } = { path, query, count: page.length, matches: page }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/list.ts b/packages/computer/src/tools/fs/list.ts deleted file mode 100644 index 744dd757..00000000 --- a/packages/computer/src/tools/fs/list.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -export interface ListWorkspaceLike { - fs: { - readdir( - path: string, - options?: { limit?: number; offset?: number }, - ): Promise< - Array<{ - name: string; - size: number; - mtime: number; - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - }> - >; - }; -} - -export interface ListToolOptions { - workspace: ListWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), - limit: z - .number() - .int() - .min(1) - .max(MAX_LIMIT) - .optional() - .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), - offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), -}); - -export function createListTool(options: ListToolOptions): Tool> { - return tool({ - description: `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`, - inputSchema, - execute: async ({ path, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const entries = await options.workspace.fs.readdir(path, { - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = entries.length > pageSize; - const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ - name: entry.name, - size: entry.size, - mtime: entry.mtime, - isFile: entry.isFile, - isDirectory: entry.isDirectory, - isSymbolicLink: entry.isSymbolicLink, - })); - const result: { - path: string; - count: number; - entries: typeof page; - nextOffset?: number; - } = { - path, - count: page.length, - entries: page, - }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 9bad4745..8482527a 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,19 +1,34 @@ -export { type CreateAIToolsOptions, createAITools } from "./ai.js"; +// The AI SDK tool set, its individual tools, and the file store under +// them. Tool sets for other agent libraries have their own entry points, +// so importing one never pulls in the AI SDK: +// @cloudflare/computer/tools/pi-ai createPiAITools +// @cloudflare/computer/tools/tanstack-ai createTanStackAITools +export { type CreateAIToolsOptions, createAITools } from "./ai-sdk/index.js"; export { + createDeleteTool, + createEditTool, createExecTool, - type ExecBackendDescription, - type ExecRuntimeHandle, - type ExecStreamEvent, - type ExecToolOptions, - type ExecToolOutput, -} from "./exec.js"; -export { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; -export { createEditTool, type EditToolOptions } from "./fs/edit.js"; -export { createFindTool, type FindToolOptions } from "./fs/find.js"; -export { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; -export { createListTool, type ListToolOptions } from "./fs/list.js"; -export { createReadTool, type LineTruncation, type ReadToolOptions } from "./fs/read.js"; -export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; -export type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; -export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -export { createPublishTool, type PublishToolOptions } from "./publish.js"; + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./ai-sdk/tools.js"; +export type { + ExecBackendDescription, + ExecRuntimeHandle, + ExecStreamEvent, + ExecToolOptions, + ExecToolOutput, +} from "./common/exec.js"; +export type { DeleteToolOptions } from "./common/fs/delete.js"; +export type { EditToolOptions } from "./common/fs/edit.js"; +export type { FindToolOptions } from "./common/fs/find.js"; +export type { GrepToolOptions } from "./common/fs/grep.js"; +export type { ListToolOptions } from "./common/fs/list.js"; +export type { LineTruncation, ReadToolOptions } from "./common/fs/read.js"; +export { WorkspaceFileStore, type WorkspaceLike } from "./common/fs/store.js"; +export type { FileStat, FileStore, MutableFileStore } from "./common/fs/types.js"; +export type { WriteToolOptions } from "./common/fs/write.js"; +export type { PublishToolOptions } from "./common/publish.js"; diff --git a/packages/computer/src/tools/pi-ai/index.test.ts b/packages/computer/src/tools/pi-ai/index.test.ts new file mode 100644 index 00000000..66a9c521 --- /dev/null +++ b/packages/computer/src/tools/pi-ai/index.test.ts @@ -0,0 +1,286 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createPiAITools } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +function declaration(tools: ReturnType, name: string) { + const tool = tools.tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`no ${name} tool`); + return tool; +} + +describe("createPiAITools declarations", () => { + it("declares the default tool set with object parameter schemas", () => { + const tools = createPiAITools({ 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 = createPiAITools({ workspace: makeWorkspace(), readonly: true }); + + expect(tools.tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); + }); + + it("offers the backends `shell` lists, with the default named", () => { + const tools = createPiAITools({ + workspace: makeWorkspace(), + shell: { + backends: { + "worker-shell": { description: "Fast worker shell." }, + "container-shell": { description: "Full Linux container." }, + }, + defaultBackend: "worker-shell", + }, + }); + + const exec = declaration(tools, "exec"); + expect(exec.description).toContain("Fast worker shell."); + expect(exec.description).toContain("Full Linux container."); + expect(exec.description).toContain('Default backend: "worker-shell"'); + const backend = exec.parameters.properties?.backend as { enum?: string[] }; + expect(backend.enum).toEqual(["worker-shell", "container-shell"]); + expect(exec.parameters.required).not.toContain("backend"); + }); + + it("leaves exec out without `shell` and for a read-only set", () => { + const workspace = makeWorkspace(); + const shell = { + backends: { "worker-shell": { description: "Fast worker shell." } }, + defaultBackend: "worker-shell", + }; + + expect(createPiAITools({ workspace }).tools.map((t) => t.name)).not.toContain("exec"); + expect( + createPiAITools({ workspace, shell, readonly: true }).tools.map((t) => t.name), + ).not.toContain("exec"); + }); + + it("emits required fields without a $schema key and keeps defaults optional", () => { + const tools = createPiAITools({ 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("createPiAITools constrained sampling", () => { + it("requests provider-side strict schemas for the fussy tools only", () => { + const tools = createPiAITools({ 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 = createPiAITools({ 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 = createPiAITools({ + workspace: makeWorkspace(), + constrainedSampling: "require", + }); + expect(declaration(required, "edit").constrainedSampling).toEqual({ + type: "json_schema", + strict: "require", + }); + + const off = createPiAITools({ 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 = createPiAITools({ 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("createPiAITools execution", () => { + it("runs a tool call and returns text content for a complete read", async () => { + const workspace = makeWorkspace(); + const tools = createPiAITools({ 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 = createPiAITools({ 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 = createPiAITools({ 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 = createPiAITools({ 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 = createPiAITools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ id: "1", name: "nope", arguments: {} }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain('Unknown tool "nope"'); + }); + + it("keeps a null the tool genuinely accepts", async () => { + // `exec`'s structured input is any JSON value, so null means null. + const seen: Array<{ input: unknown }> = []; + const workspace = makeWorkspace(); + (workspace.runtime as unknown as Record).exec = async ( + _command: string, + options: { input?: unknown }, + ) => { + seen.push({ input: options.input }); + return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; + }; + (workspace.runtime as unknown as Record).isCallable = () => true; + const tools = createPiAITools({ + workspace, + shell: { backends: { js: { description: "callable" } }, defaultBackend: "js" }, + }); + + await tools.execute({ id: "1", name: "exec", arguments: { command: "a", input: null } }); + await tools.execute({ id: "2", name: "exec", arguments: { command: "b" } }); + + expect(seen[0].input).toBeNull(); + expect(seen[1].input).toBeUndefined(); + }); + + it("applies a schema default when the model omits the field", async () => { + const workspace = makeWorkspace(); + const tools = createPiAITools({ 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 = createPiAITools({ 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-ai/index.ts b/packages/computer/src/tools/pi-ai/index.ts new file mode 100644 index 00000000..349a665c --- /dev/null +++ b/packages/computer/src/tools/pi-ai/index.ts @@ -0,0 +1,377 @@ +/** + * pi keeps tool declarations and tool execution apart: declarations + * travel in `Context.tools` while the caller's own agent loop runs the + * tools. So this module returns both halves together. + */ + +import { z } from "zod"; +import { defineExec } from "../common/exec.js"; +import { deleteDescription, deleteFromStore, deleteInputSchema } from "../common/fs/delete.js"; +import { editDescription, editInputSchema, editInStore } from "../common/fs/edit.js"; +import { findDescription, findInputSchema, findInWorkspace } from "../common/fs/find.js"; +import { grepDescription, grepInputSchema, grepInWorkspace } from "../common/fs/grep.js"; +import { listDescription, listInputSchema, listWorkspace } from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { writeDescription, writeInputSchema, writeToStore } from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { settle } from "../common/stream.js"; + +export interface ToolCallContext { + abortSignal?: AbortSignal; +} + +interface PiToolEntry { + name: string; + description: string; + inputSchema: z.ZodType; + strictArguments?: boolean; + execute: (input: never, context: ToolCallContext) => Promise | AsyncIterable; + toModelOutput?: (args: { input: never; output: never }) => ModelOutput; +} + +/** Structurally compatible with `Tool` from `@earendil-works/pi-ai`, declared locally so pi is not a build-time dependency. */ +export interface PiTool { + name: string; + description: string; + parameters: PiJSONSchema; + constrainedSampling?: { type: "json_schema"; strict: "prefer" | "require" }; +} + +export interface PiJSONSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +/** One tool call as pi reports it on a `toolcall_end` event. */ +export interface PiToolCall { + id: string; + name: string; + arguments?: unknown; +} + +export type PiToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +/** A tool result minus the routing fields (`toolCallId`, `toolName`, `timestamp`), which the caller owns. */ +export interface PiToolResult { + content: PiToolResultContent[]; + isError: boolean; +} + +export interface CreatePiAIToolsResult { + tools: PiTool[]; + execute: (call: PiToolCall, context?: ToolCallContext) => Promise; +} + +export function createPiAITools(options: CreatePiAIToolsOptions): CreatePiAIToolsResult { + const entries = piToolEntries(options); + const nullable = new Map>(); + return { + tools: declarations(entries, options, nullable), + execute: dispatcher(entries, nullable), + }; +} + +export interface CreatePiAIToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} + +function piToolEntries(options: CreateToolsOptions): PiToolEntry[] { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const entries: PiToolEntry[] = [ + { + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + // Byte offsets must be echoed back verbatim on the next call. + strictArguments: true, + execute: (input: ReadInput) => readExecutor(input), + toModelOutput: ({ input, output }: { input: ReadInput; output: ReadToolResult }) => + toReadOutput({ input, output }), + } as PiToolEntry, + { + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(workspace, input), + } as PiToolEntry, + { + name: "find", + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(workspace, input), + } as PiToolEntry, + { + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(workspace, input), + } as PiToolEntry, + ]; + + if (resolved.readonly) return entries; + + entries.push( + { + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + // The whole file body travels as one string argument. + strictArguments: true, + execute: (input) => writeToStore(resolved.write, input), + } as PiToolEntry, + { + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + // A nested array of exact-match strings is easy to malform. + strictArguments: true, + execute: (input) => editInStore(resolved.edit, input), + } as PiToolEntry, + { + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(resolved.delete, input), + } as PiToolEntry, + ); + + if (resolved.exec !== undefined) { + const exec = defineExec(resolved.exec); + entries.push({ + name: "exec", + description: exec.description, + inputSchema: exec.inputSchema, + execute: (input, context) => exec.execute(input, context), + } as PiToolEntry); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + entries.push({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => executor(input), + } as PiToolEntry); + } + + return entries; +} + +function declarations( + entries: readonly PiToolEntry[], + options: PiDeclarationOptions, + nullable: Map>, +): PiTool[] { + const strict = options.constrainedSampling ?? "prefer"; + return entries.map((entry) => { + // Strict schemas must close the object: a provider enforcing the + // schema has to know no other properties are allowed. + const wantsStrict = strict !== false && entry.strictArguments === true; + const converted = toPiParameters(entry.inputSchema, wantsStrict); + nullable.set(entry.name, converted.nullable); + const tool: PiTool = { + name: entry.name, + description: entry.description, + parameters: converted.parameters, + }; + if (wantsStrict) { + tool.constrainedSampling = { type: "json_schema", strict }; + } + return tool; + }); +} + +export interface PiDeclarationOptions { + /** + * `"prefer"` (default) falls back to ordinary tool calling where the + * provider cannot enforce a schema; `"require"` fails the request + * instead, so it suits only a pinned model known to support it. + */ + constrainedSampling?: "prefer" | "require" | false; +} + +/** + * Validation failures and thrown executors both come back as error + * results rather than exceptions, so a bad call costs the model a turn + * instead of breaking the caller's loop. + */ +function dispatcher( + entries: readonly PiToolEntry[], + nullable: ReadonlyMap>, +): (call: PiToolCall, context?: ToolCallContext) => Promise { + const byName = new Map(entries.map((entry) => [entry.name, entry])); + return async (call, context = {}) => { + const entry = byName.get(call.name); + if (!entry) { + return errorResult( + `Unknown tool ${JSON.stringify(call.name)}. Available tools: ${entries + .map((e) => JSON.stringify(e.name)) + .join(", ")}.`, + ); + } + + const args = dropPlaceholderNulls(call.arguments ?? {}, nullable.get(entry.name) ?? EMPTY); + const parsed = entry.inputSchema.safeParse(args); + if (!parsed.success) { + return errorResult(`Invalid arguments for ${call.name}: ${formatZodError(parsed.error)}`); + } + + const run = entry.execute as ( + i: unknown, + c: ToolCallContext, + ) => Promise | AsyncIterable; + const toOutput = entry.toModelOutput as + | ((args: { input: unknown; output: unknown }) => ModelOutput) + | undefined; + try { + const output = await settle(run(parsed.data, context)); + return toPiResult( + toOutput ? toOutput({ input: parsed.data, output }) : defaultModelOutput(output), + ); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err)); + } + }; +} + +function toPiResult(output: ModelOutput): PiToolResult { + switch (output.type) { + case "text": + return { content: [{ type: "text", text: output.value }], isError: false }; + case "error-text": + return { content: [{ type: "text", text: output.value }], isError: true }; + case "json": + return { + content: [{ type: "text", text: stringify(output.value) }], + isError: false, + }; + case "media": { + // pi's tool results carry images but nothing else, so a PDF + // degrades to text rather than being dropped. + if (!output.mediaType.startsWith("image/")) { + return { + content: [ + { + type: "text", + text: `${output.text} This file type cannot be attached to a tool result; read it with a dedicated tool if its contents are needed.`, + }, + ], + isError: false, + }; + } + return { + content: [ + { type: "text", text: output.text }, + { type: "image", data: output.data, mimeType: output.mediaType }, + ], + isError: false, + }; + } + } +} + +/** + * `io: "input"` keeps a field with a Zod `.default()` optional: the + * default is emitted as a JSON Schema `default` for TypeBox to apply. + * The output view would instead mark those fields required. + */ +function toPiParameters( + schema: z.ZodType, + strict = false, +): { parameters: PiJSONSchema; nullable: Set } { + const json = z.toJSONSchema(schema, { + target: "draft-7", + io: "input", + // Providers reject `$ref` pointers into a definitions section. + reused: "inline", + unrepresentable: "any", + }) as Record; + delete json.$schema; + if (json.type !== "object") { + throw new Error(`pi tool parameters must be an object schema, got ${String(json.type)}`); + } + // A provider enforcing the schema needs the object closed, and + // OpenAI further requires every property in `required`, so an + // optional field becomes required-but-nullable instead. + const nullable = new Set(); + if (strict) { + json.additionalProperties = false; + const properties = (json.properties ?? {}) as Record>; + const names = Object.keys(properties); + const required = new Set((json.required as string[] | undefined) ?? []); + for (const name of names) { + if (required.has(name)) continue; + const property = properties[name]; + const type = property.type; + // Recorded so the dispatcher can tell this null, which means + // "absent", from one the tool genuinely accepts. + if (typeof type === "string" && type !== "null") { + property.type = [type, "null"]; + nullable.add(name); + } + } + json.required = names; + } + return { parameters: json as PiJSONSchema, nullable }; +} + +/** + * Strips only the nulls that {@link toPiParameters} introduced, named + * by `nullable`. A null on any other field is a value the tool accepts + * — `exec`'s structured `input` is any JSON — and must survive. + */ +function dropPlaceholderNulls(args: unknown, nullable: ReadonlySet): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) return args; + if (nullable.size === 0) return args; + const out: Record = {}; + for (const [key, value] of Object.entries(args as Record)) { + if (value === null && nullable.has(key)) continue; + out[key] = value; + } + return out; +} + +const EMPTY: ReadonlySet = new Set(); + +function errorResult(message: string): PiToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join("; "); +} + +function stringify(value: unknown): string { + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/pi-ai/model-output.test.ts b/packages/computer/src/tools/pi-ai/model-output.test.ts new file mode 100644 index 00000000..b5b171ed --- /dev/null +++ b/packages/computer/src/tools/pi-ai/model-output.test.ts @@ -0,0 +1,32 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it, vi } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createPiAITools } from "./index.js"; + +// Only read shapes its own model output, so a failing formatter is +// simulated by replacing it. +vi.mock("../common/fs/read.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readModelOutput: () => () => { + throw new Error("formatter exploded"); + }, + }; +}); + +describe("createPiAITools model output", () => { + it("returns a failing formatter as an error result rather than throwing", async () => { + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const tools = createPiAITools({ workspace }); + await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "hi" } }); + + const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); + + expect(result).toEqual({ + content: [{ type: "text", text: "formatter exploded" }], + isError: true, + }); + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/publish.ts b/packages/computer/src/tools/publish.ts deleted file mode 100644 index 75b6ab46..00000000 --- a/packages/computer/src/tools/publish.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import type { AssetsClient } from "../assets/index.js"; - -export interface PublishWorkspaceLike { - readonly sessionId: string; - readonly assets?: AssetsClient; -} - -export interface PublishToolOptions { - workspace: PublishWorkspaceLike; -} - -const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; - -export function createPublishTool( - options: PublishToolOptions, -): Tool<{ path: string; expiresAfterMs?: number }> { - const assets = options.workspace.assets; - if (!assets) { - throw new Error("createPublishTool: workspace.assets is not configured"); - } - - return tool({ - description: - "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", - inputSchema: z.object({ - path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), - expiresAfterMs: z - .number() - .int() - .positive() - .optional() - .describe("Link lifetime in milliseconds. Defaults to one hour."), - }), - execute: async ({ path, expiresAfterMs }) => { - try { - const prefix = options.workspace.sessionId - ? `agent-${options.workspace.sessionId}` - : undefined; - const url = await assets.share(path, { - expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, - ...(prefix ? { prefix } : {}), - }); - return { ok: true, url }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts new file mode 100644 index 00000000..4a10b96e --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -0,0 +1,401 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { WorkerJavaScriptBackend } from "../../backends/worker-javascript/worker-javascript.js"; +import { Workspace } from "../../workspace.js"; +import { createTanStackAITools } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +// Streams a fixed event sequence through a real WorkspaceRuntime +// handle, rather than a hand-shaped fake. +function streamingCommandBackend(events: import("@cloudflare/computer-rpc").ExecEvent[]): { + id: string; + type: string; + connect(): Promise<{ + rpc: import("@cloudflare/computer-rpc").WorkspaceRPC; + sync: "none"; + close(): Promise; + }>; +} { + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec(input) { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue({ ...event, id }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + const noopSync = new Proxy( + {}, + { get: () => () => Promise.reject(new Error("sync: none")) }, + ) as import("@cloudflare/computer-rpc").SyncRPC; + return { + id: "shell", + type: "fake-command", + async connect() { + return { rpc: { sync: noopSync, shell }, sync: "none", close: async () => {} }; + }, + }; +} + +// A command that prints once and then stays quiet until killed or +// released, the shape that exposes buffering and cancellation bugs. +function quietCommandBackend(): { + backend: ReturnType; + killed: Promise; + release(): void; +} { + let finish: (() => void) | undefined; + let markKilled: () => void = () => {}; + const killed = new Promise((resolve) => { + markKilled = resolve; + }); + const base = streamingCommandBackend([]); + const backend = { + ...base, + async connect() { + const connection = await base.connect(); + connection.rpc.shell.exec = async (input) => { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ + id, + seq: 1, + name: "stdout", + value: new TextEncoder().encode("starting\n"), + }); + finish = () => { + controller.enqueue({ id, seq: 2, name: "exit", code: 0 }); + controller.close(); + }; + }, + }), + }; + }; + connection.rpc.shell.killExec = async () => { + markKilled(); + finish?.(); + }; + return connection; + }, + }; + return { backend, killed, release: () => finish?.() }; +} + +describe("createTanStackAITools", () => { + it("returns a list, the shape every TanStack entry point takes", () => { + const tools = createTanStackAITools({ 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 = createTanStackAITools({ workspace: makeWorkspace() }); + const set = createTanStackAITools({ 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 = createTanStackAITools({ 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 = createTanStackAITools({ 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 = createTanStackAITools({ + 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 = createTanStackAITools({ + 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 = createTanStackAITools({ workspace: makeWorkspace(), format: "object" }); + + const schema = tools.write.outputSchema as unknown as { + safeParse: (v: unknown) => { success: boolean }; + }; + expect(schema.safeParse({ path: "/w/a.txt", bytesWritten: 3 }).success).toBe(true); + expect(schema.safeParse({ path: "/w/a.txt" }).success).toBe(false); + // TanStack validates every return against this, failures included. + expect(schema.safeParse({ error: "read-only filesystem" }).success).toBe(true); + // A paged listing has no fixed success shape worth asserting. + expect(tools.ls.outputSchema).toBeUndefined(); + }); + + it("returns the real reason when a mutating tool fails", async () => { + const workspace = makeWorkspace(); + workspace.fs.writeFile = async () => { + throw new Error("read-only filesystem"); + }; + const tools = createTanStackAITools({ workspace, format: "object" }); + + const result = (await tools.write.execute({ + path: "/workspace/a.txt", + content: "hi", + } as never)) as { error: string }; + + expect(result.error).toContain("read-only filesystem"); + const schema = tools.write.outputSchema as unknown as { + parse: (v: unknown) => unknown; + }; + expect(schema.parse(result)).toEqual({ error: expect.stringContaining("read-only") }); + }); + + it("marks tools lazy so they stay out of the prompt until discovered", () => { + const all = createTanStackAITools({ + workspace: makeWorkspace(), + lazy: "all", + format: "object", + }); + expect(all.read.lazy).toBe(true); + expect(all.write.lazy).toBe(true); + + const some = createTanStackAITools({ + 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 = createTanStackAITools({ 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 = createTanStackAITools({ 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("offers the backends `shell` lists and defaults to one", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const shell = { + backends: { + shell: { description: "fast shell" }, + "worker-javascript": { description: "isolate JavaScript" }, + }, + defaultBackend: "shell", + }; + const tools = createTanStackAITools({ workspace, shell, format: "object" }); + const schema = z.toJSONSchema(tools.exec.inputSchema) as { + properties: Record; + required?: string[]; + }; + + expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); + expect(schema.required ?? []).not.toContain("backend"); + expect(schema.properties).toHaveProperty("input"); + expect(createTanStackAITools({ workspace }).map((t) => t.name)).not.toContain("exec"); + }); + + 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 = createTanStackAITools({ + workspace, + shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + format: "object", + }); + + await expect(tools.exec.execute({ command: "echo hello" } as never)).resolves.toEqual({ + command: "echo hello", + cwd: null, + backend: "shell", + exitCode: 0, + stdout: "hello\n", + stderr: "", + }); + await workspace.close(); + }); + + it("forwards pre-terminal snapshots as custom events when asked", async () => { + const events: Array<{ name: string; value: Record }> = []; + // The last snapshot settles as the return value; earlier ones are + // emitted, so a UI can show output while the command runs. + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([ + { id: "cmd-1", seq: 1, name: "stdout", value: new TextEncoder().encode("partial\n") }, + { id: "cmd-1", seq: 2, name: "exit", code: 0 }, + ]) as never, + ], + }); + const tools = createTanStackAITools({ + workspace, + shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + streamEventName: "exec-progress", + format: "object", + }); + + const result = await tools.exec.execute({ command: "echo partial" } as never, { + toolCallId: "call-1", + emitCustomEvent: (name, value) => events.push({ name, value }), + }); + + expect(result).toMatchObject({ exitCode: 0, stdout: "partial\n" }); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0].name).toBe("exec-progress"); + await workspace.close(); + }); + + it("emits a running snapshot before the command produces more output", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackAITools({ + workspace, + shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + streamEventName: "exec-progress", + format: "object", + }); + let firstEvent: () => void = () => {}; + const emitted = new Promise((resolve) => { + firstEvent = resolve; + }); + const events: Array> = []; + + const pending = tools.exec.execute({ command: "build" } as never, { + toolCallId: "call-1", + emitCustomEvent: (_name, value) => { + events.push(value); + firstEvent(); + }, + }); + await emitted; + + expect(events[0]).toMatchObject({ + toolCallId: "call-1", + snapshot: { exitCode: null, stdout: "starting\n" }, + }); + quiet.release(); + expect(await pending).toMatchObject({ exitCode: 0 }); + // The terminal snapshot is returned, not emitted. + expect( + events.every((event) => (event.snapshot as { exitCode: unknown }).exitCode === null), + ).toBe(true); + await workspace.close(); + }); + + it("kills exec when the chat run's abort signal fires", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackAITools({ + workspace, + shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, + format: "object", + }); + const controller = new AbortController(); + + const pending = tools.exec.execute({ command: "npm test" } as never, { + toolCallId: "call-1", + abortSignal: controller.signal, + }); + controller.abort(); + + await quiet.killed; + await pending; + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/tanstack-ai/index.ts b/packages/computer/src/tools/tanstack-ai/index.ts new file mode 100644 index 00000000..a04c8e0d --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.ts @@ -0,0 +1,287 @@ +/** + * `inputSchema` is a Standard Schema, which Zod v4 implements, so the + * schemas from `../common` are passed through untouched. + */ + +import type { z } from "zod"; +import { defineExec } from "../common/exec.js"; +import { + deleteDescription, + deleteFromStore, + deleteInputSchema, + deleteOutputSchema, +} from "../common/fs/delete.js"; +import { + editDescription, + editInputSchema, + editInStore, + editOutputSchema, +} from "../common/fs/edit.js"; +import { findDescription, findInputSchema, findInWorkspace } from "../common/fs/find.js"; +import { grepDescription, grepInputSchema, grepInWorkspace } from "../common/fs/grep.js"; +import { listDescription, listInputSchema, listWorkspace } from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + writeDescription, + writeInputSchema, + writeOutputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, + publishOutputSchema, +} from "../common/publish.js"; +import { isAsyncIterable, settle } from "../common/stream.js"; + +/** Structurally compatible with `toolDefinition().server()`, declared locally so `@tanstack/ai` is not a build-time dependency. */ +export interface TanStackTool { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + // biome-ignore lint/suspicious/noExplicitAny: matches the signature chat() calls + execute: (input: any, context?: TanStackToolExecutionContext) => Promise; + needsApproval?: boolean; + lazy?: boolean; + /** Phantom marker carrying no runtime value; declaring it satisfies the union `chat({ tools })` accepts. */ + readonly "~toolKind"?: undefined; +} + +export interface TanStackToolExecutionContext { + toolCallId?: string; + /** Fires when the chat run's `abortController` aborts; a running `exec` is killed. */ + abortSignal?: AbortSignal; + emitCustomEvent?: (eventName: string, value: Record) => void; +} + +export type TanStackToolList = TanStackTool[]; + +export type TanStackToolSet = Record>; + +/** `chat()`, `mergeAgentTools` and `createToolRegistry` all take an array, so `"array"` is the default. */ +export type TanStackToolFormat = "array" | "object"; + +export type TanStackToolsFor = Format extends "object" + ? TanStackToolSet + : TanStackToolList; + +export interface CreateTanStackAIToolsOptions + extends CreateToolsOptions { + format?: Format; + /** Tools that pause for approval. `"mutating"` selects every tool that changes workspace state. */ + approve?: string[] | "mutating"; + /** Forward pre-terminal `exec` snapshots through `emitCustomEvent` under this name; otherwise they are discarded. */ + streamEventName?: string; + /** Tools withheld from the prompt until TanStack lazy discovery asks for them. */ + lazy?: string[] | "all"; +} + +export function createTanStackAITools( + options: CreateTanStackAIToolsOptions, +): TanStackToolsFor { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const tools: TanStackToolList = []; + const add = (entry: { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + mutates?: boolean; + streams?: boolean; + run: (input: never, context?: TanStackToolExecutionContext) => unknown; + }) => { + const needsApproval = wants(options.approve, entry.name, entry.mutates === true); + const lazy = wants(options.lazy, entry.name, options.lazy === "all"); + tools.push({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + // TanStack validates every return against this, errors included, + // so a success-only schema would mask the real failure reason. + outputSchema: entry.outputSchema, + needsApproval: needsApproval ? true : undefined, + lazy: lazy ? true : undefined, + execute: entry.run, + } as TanStackTool); + }; + + add({ + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + run: async (input: ReadInput) => { + const output = (await readExecutor(input)) as ReadToolResult; + return toTanStackOutput(toReadOutput({ input, output })); + }, + }); + + add({ + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + run: async (input: never) => plain(await listWorkspace(workspace, input)), + }); + + add({ + name: "find", + description: findDescription, + inputSchema: findInputSchema, + run: async (input: never) => plain(await findInWorkspace(workspace, input)), + }); + + add({ + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + run: async (input: never) => plain(await grepInWorkspace(workspace, input)), + }); + + if (!resolved.readonly) { + add({ + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + outputSchema: writeOutputSchema, + mutates: true, + run: async (input: never) => plain(await writeToStore(resolved.write, input)), + }); + + add({ + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + outputSchema: editOutputSchema, + mutates: true, + run: async (input: never) => plain(await editInStore(resolved.edit, input)), + }); + + add({ + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + outputSchema: deleteOutputSchema, + mutates: true, + run: async (input: never) => plain(await deleteFromStore(resolved.delete, input)), + }); + + if (resolved.exec !== undefined) { + const exec = defineExec(resolved.exec); + add({ + name: "exec", + description: exec.description, + inputSchema: exec.inputSchema, + mutates: true, + streams: true, + run: async (input: never, context?: TanStackToolExecutionContext) => { + const returned = exec.execute(input, { abortSignal: context?.abortSignal }); + const output = options.streamEventName + ? await settleWithEvents(returned, options.streamEventName, context) + : await settle(returned); + return plain(output); + }, + }); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + add({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + outputSchema: publishOutputSchema, + mutates: true, + run: async (input: never) => plain(await executor(input)), + }); + } + } + + if (options.format === "object") { + const set: TanStackToolSet = {}; + for (const tool of tools) set[tool.name] = tool; + // The generic resolves to one branch or the other at each call + // site, which a return inside the function cannot prove. + return set as TanStackToolsFor; + } + return tools as TanStackToolsFor; +} + +function plain(output: unknown): unknown { + return toTanStackOutput(defaultModelOutput(output)); +} + +/** + * Running snapshots are emitted as they arrive, so a command that + * prints once and goes quiet shows that output straight away. The + * terminal snapshot is returned rather than emitted, so a consumer + * ignoring custom events still sees the complete outcome. + */ +async function settleWithEvents( + returned: Promise | AsyncIterable, + eventName: string, + context: TanStackToolExecutionContext | undefined, +): Promise { + const emit = context?.emitCustomEvent; + if (!emit || !isAsyncIterable(returned)) return settle(returned); + + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + if (isRunning(chunk)) { + emit(eventName, { toolCallId: context?.toolCallId, snapshot: chunk as never }); + } + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; +} + +/** An exec snapshot is running until it carries an exit code or an error. */ +function isRunning(snapshot: unknown): boolean { + const s = snapshot as { exitCode?: unknown; error?: unknown }; + return s.exitCode === null && s.error === undefined; +} + +function wants(option: string[] | string | undefined, name: string, byTrait: boolean): boolean { + if (option === undefined) return false; + if (Array.isArray(option)) return option.includes(name); + return byTrait; +} + +/** + * TanStack has no typed tool-result part for media, so an image or PDF + * returns its text alongside the payload for a caller to reattach. + */ +function toTanStackOutput(output: ModelOutput): unknown { + switch (output.type) { + case "text": + return output.value; + case "error-text": + return { error: output.value }; + case "json": + return output.value; + case "media": + return { + text: output.text, + mediaType: output.mediaType, + filename: output.filename, + data: output.data, + }; + } +} From d87233b4269cbfd262371ba447ffd8355da4e79b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 12:22:03 +0100 Subject: [PATCH 2/4] examples: Add pi-ai and TanStack AI agents Two one-shot agents on a Worker-shell Workspace: pi-ai, where `run` is the whole loop, and tanstack-ai, where chat() owns it. Both pass the one worker shell to `exec` through `shell`. `npm run local` drives each loop in Node with a scripted model, through a small shim for `cloudflare:workers`. Bumps the libraries to current releases (pi-ai 0.99, @tanstack/ai 0.63, @tanstack/ai-cloudflare 0.2) and adds both to the CI examples matrix. Docs, READMEs, and a changeset cover the two entry points. The examples come from #149. Co-authored-by: aron <263346377+aron-cf@users.noreply.github.com> --- .changeset/pi-ai-tanstack-ai-tools.md | 9 + .github/workflows/ci.yml | 6 + README.md | 5 + docs/09_tool_interface.md | 83 +- docs/10_project_layout.md | 15 +- docs/README.md | 4 +- examples/pi-ai/.gitignore | 2 + examples/pi-ai/README.md | 50 + examples/pi-ai/local-shim.mjs | 16 + examples/pi-ai/package.json | 24 + examples/pi-ai/run-local.mjs | 114 +++ examples/pi-ai/src/index.ts | 112 +++ examples/pi-ai/src/workers-ai.ts | 96 ++ examples/pi-ai/tsconfig.json | 17 + examples/pi-ai/wrangler.jsonc | 40 + examples/tanstack-ai/.gitignore | 2 + examples/tanstack-ai/README.md | 51 + examples/tanstack-ai/local-shim.mjs | 16 + examples/tanstack-ai/package.json | 25 + examples/tanstack-ai/run-local.mjs | 103 ++ examples/tanstack-ai/src/index.ts | 85 ++ examples/tanstack-ai/tsconfig.json | 17 + examples/tanstack-ai/wrangler.jsonc | 40 + package-lock.json | 1283 ++++++++++++++++++++++++- packages/computer/README.md | 24 +- 25 files changed, 2224 insertions(+), 15 deletions(-) create mode 100644 .changeset/pi-ai-tanstack-ai-tools.md create mode 100644 examples/pi-ai/.gitignore create mode 100644 examples/pi-ai/README.md create mode 100644 examples/pi-ai/local-shim.mjs create mode 100644 examples/pi-ai/package.json create mode 100644 examples/pi-ai/run-local.mjs create mode 100644 examples/pi-ai/src/index.ts create mode 100644 examples/pi-ai/src/workers-ai.ts create mode 100644 examples/pi-ai/tsconfig.json create mode 100644 examples/pi-ai/wrangler.jsonc create mode 100644 examples/tanstack-ai/.gitignore create mode 100644 examples/tanstack-ai/README.md create mode 100644 examples/tanstack-ai/local-shim.mjs create mode 100644 examples/tanstack-ai/package.json create mode 100644 examples/tanstack-ai/run-local.mjs create mode 100644 examples/tanstack-ai/src/index.ts create mode 100644 examples/tanstack-ai/tsconfig.json create mode 100644 examples/tanstack-ai/wrangler.jsonc diff --git a/.changeset/pi-ai-tanstack-ai-tools.md b/.changeset/pi-ai-tanstack-ai-tools.md new file mode 100644 index 00000000..1813f14b --- /dev/null +++ b/.changeset/pi-ai-tanstack-ai-tools.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/computer": minor +--- + +Add the Workspace tool set for two more agent libraries. `createPiAITools` from `@cloudflare/computer/tools/pi-ai` serves [pi](https://github.com/earendil-works/pi), and `createTanStackAITools` from `@cloudflare/computer/tools/tanstack-ai` serves [TanStack AI](https://tanstack.com/ai). Both take the same options as `createAITools`, including `shell`, and build the same tools with the same names, descriptions, and limits. + +pi runs tools in the caller's own loop, so `createPiAITools` returns `tools` to send to the model and `execute` to run one tool call. A bad call or a failed tool comes back as an error result instead of a throw. TanStack AI runs tools itself, so `createTanStackAITools` returns the list `chat({ tools })` takes, with `approve`, `lazy`, `format`, and `streamEventName` for TanStack's approval, lazy discovery, and progress events. + +Neither entry point imports its library, only `zod`, so adding one does not pull `ai`, pi, or TanStack into a bundle that does not use them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25b57935..1fa88480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,12 @@ jobs: - name: mcp workspace: "@example/computer-mcp" path: examples/mcp + - name: pi-ai + workspace: "@example/computer-pi-ai" + path: examples/pi-ai + - name: tanstack-ai + workspace: "@example/computer-tanstack-ai" + path: examples/tanstack-ai - name: think workspace: "@cloudflare/example-think" path: examples/think diff --git a/README.md b/README.md index 8e1ea38e..c423a97a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,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-ai`](examples/pi-ai) — a one-shot [pi](https://github.com/earendil-works/pi) + agent. Its loop asks the model, runs the workspace tools it asked for, + and repeats until the model stops asking. +- [`examples/tanstack-ai`](examples/tanstack-ai) — the same one-shot agent on + [TanStack AI](https://tanstack.com/ai), where `chat()` runs 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 a8629a1c..e4654319 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -1,6 +1,14 @@ # 09. Tool interface (agents) -`@cloudflare/computer/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`. +Computer ships a ready-made tool set for agents that use a `Workspace`, once for each of three agent libraries: + +| Library | Entry point | 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-ai` | `createPiAITools` | +| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools` | + +All three take the same options and build the same tools, with the same names, descriptions, schemas, and limits. Only the shape they return differs. Each entry point imports only `zod` and its own library's types, so a pi agent never loads `ai` and an AI SDK agent never loads pi. The individual AI SDK `create*Tool` functions and `WorkspaceFileStore` also come from `@cloudflare/computer/tools`. The tools wrap three Workspace surfaces: @@ -13,6 +21,8 @@ The tools wrap three Workspace surfaces: | Export | Purpose | | --- | --- | | `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. | +| `createPiAITools` | Create pi tool declarations and the function that runs a pi tool call. | +| `createTanStackAITools` | Create the TanStack AI tool list for a Workspace. | | `createReadTool` | Stream text by line and pass images or PDFs to capable models. | | `createWriteTool` | Write a whole file with a UTF-8 byte cap. | | `createEditTool` | Apply atomic targeted replacements and return a unified diff. | @@ -24,7 +34,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 tool set 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 @@ -70,7 +80,72 @@ const tools = createAITools({ }); ``` -## `createAITools` +`createPiAITools` and `createTanStackAITools` take `shell` the same way. + +## pi + +pi keeps tool declarations apart from the code that runs them. `Context.tools` carries declarations with JSON Schema `parameters`, and the caller's own loop runs each call. `createPiAITools` returns both, so they cannot drift apart. + +```ts +import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; + +const { tools, execute } = createPiAITools({ workspace }); + +const message = await models.complete(model, { systemPrompt, messages, tools }); +messages.push(message); + +for (const block of message.content) { + if (block.type !== "toolCall") continue; + const { content, isError } = await execute(block); + messages.push({ + role: "toolResult", + toolCallId: block.id, + toolName: block.name, + content, + isError, + timestamp: Date.now(), + }); +} +``` + +`execute` checks the call's arguments against the tool's schema and returns pi `toolResult` content. A bad call or a failed tool comes back as `isError: true`, so the model can retry and the loop does not throw. The Zod schemas become plain JSON Schema, and a field with a default stays optional for the model. + +`read`, `write`, and `edit` carry byte offsets and long verbatim strings, so they ask for pi's `constrainedSampling`. A provider that supports it enforces the schema while sampling, and a malformed `edit` never reaches the tool. Those schemas are closed (`additionalProperties: false`) with every property required, and each optional field becomes nullable. `execute` drops those placeholder nulls before validation and keeps any null the tool accepts, such as `exec`'s `input`. + +The default is `"prefer"`, which falls back to ordinary tool calling on a provider that cannot enforce a schema. `"require"` fails the request instead, for a pinned model known to support it. `false` turns it off and keeps the schemas open: + +```ts +createPiAITools({ workspace, constrainedSampling: "require" }); +``` + +pi tool results carry text and images. An image from `read` comes back as an `image` block; a PDF comes back as text saying it cannot be attached. `exec` returns its final snapshot. + +## TanStack AI + +A TanStack tool's `inputSchema` is a Standard Schema, which Zod implements, so the schemas pass through unchanged. The tools come back as a list, the shape `chat({ tools })`, `mergeAgentTools`, and `createToolRegistry` take. `format: "object"` keys them by name instead, for reaching one tool directly. + +```ts +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; + +const abortController = new AbortController(); +const tools = createTanStackAITools({ workspace, approve: "mutating" }); + +return toServerSentEventsResponse(chat({ adapter, messages, tools, abortController })); +``` + +| Option | Default | Notes | +| --- | --- | --- | +| `format` | `"array"` | `"object"` keys the tools by name. | +| `approve` | none | Tool names that pause for TanStack's `needsApproval`, or `"mutating"` for every tool that changes the Workspace. | +| `lazy` | none | Tool names, or `"all"`, to withhold from the prompt until TanStack's lazy discovery asks for them. | +| `streamEventName` | none | Forward each running `exec` snapshot through `emitCustomEvent` under this name. | + +`write`, `edit`, `delete`, and `publish` have one fixed result shape, so they also carry an `outputSchema`. It covers failures too, because TanStack validates every return against it, and a success-only schema would replace the real error with a validation complaint. Paged tools such as `ls` have none. + +Aborting the chat run through its `abortController` kills a running `exec`. A TanStack tool settles on one value, so `exec` returns its final snapshot. + +## Options ```ts createAITools({ @@ -94,6 +169,8 @@ createAITools({ | `edit` | default caps | Options passed to `createEditTool`. | | `shell` | omitted | Options passed to `createExecTool`. | +`createPiAITools` and `createTanStackAITools` take the same options, plus their own listed above. + ## `read` ```ts diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 8718132f..3f28c2c4 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -175,10 +175,17 @@ produces the Node SEA single-file binary at ## Tools -AI SDK tools (`read`, `write`, `edit`, `ls`, optional `exec`, and -optional `publish`) ship from the `@cloudflare/computer/tools` subpath -rather than a separate package, under -[`packages/computer/src/tools/`](../packages/computer/src/tools/). See +Agent tools (`read`, `write`, `edit`, `ls`, optional `exec`, and optional +`publish`) ship from the package rather than a separate one, with one +entry point per agent library: `createAITools()` from +`@cloudflare/computer/tools`, `createPiAITools()` from +`@cloudflare/computer/tools/pi-ai`, and `createTanStackAITools()` from +`@cloudflare/computer/tools/tanstack-ai`. The individual AI SDK +`create*Tool` functions come from `@cloudflare/computer/tools` too. They live under +[`packages/computer/src/tools/`](../packages/computer/src/tools/): +`common/` holds each tool's schema, description, and executor with no +agent library in it, and `ai-sdk/`, `pi-ai/`, and `tanstack-ai/` wrap +those in each library's tool shape. See [09. Tool Interface (Agents)](./09_tool_interface.md). ## Git diff --git a/docs/README.md b/docs/README.md index 06ca0d7a..35f461c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ It provides: - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell, a just-bash Dynamic Worker, or an isolated ECMAScript-module Dynamic Worker. - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. + - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools`), pi (`createPiAITools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackAITools()` in `@cloudflare/computer/tools/tanstack-ai`). It comes with the following limitations: @@ -50,6 +50,8 @@ The package ships several entrypoints: | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, an optionally session-scoped wrapper over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | +| `@cloudflare/computer/tools/pi-ai` | `createPiAITools()`: the same tool set for pi, as declarations plus a function that runs a tool call. | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools()`: the same tool set for TanStack AI, as the list `chat({ tools })` takes. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. diff --git a/examples/pi-ai/.gitignore b/examples/pi-ai/.gitignore new file mode 100644 index 00000000..0dcc8a41 --- /dev/null +++ b/examples/pi-ai/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.wrangler/ diff --git a/examples/pi-ai/README.md b/examples/pi-ai/README.md new file mode 100644 index 00000000..e5d88ada --- /dev/null +++ b/examples/pi-ai/README.md @@ -0,0 +1,50 @@ +# pi-ai 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 +`createPiAITools` 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-ai`](../../docs/09_tool_interface.md): `read`, +`ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`. `exec` runs on +the Workspace's one backend, a Worker shell, so the model never has to name it. + +[`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-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 check the agent loop without a Cloudflare account, `npm run local --workspace @example/computer-pi-ai` +drives it in Node with a scripted model in place of Workers AI. + +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-ai/local-shim.mjs b/examples/pi-ai/local-shim.mjs new file mode 100644 index 00000000..2fd0eee0 --- /dev/null +++ b/examples/pi-ai/local-shim.mjs @@ -0,0 +1,16 @@ +// `npm run local` loads this before run-local.mjs. @cloudflare/computer +// imports `cloudflare:workers`, which only workerd provides; the local +// run never reaches those classes, so empty stand-ins are enough. +import { register } from "node:module"; + +const stub = + "export class RpcTarget {} export class WorkerEntrypoint {} export class DurableObject {} export const env = {};"; + +register( + `data:text/javascript,${encodeURIComponent(`export async function resolve(specifier, context, next) { + if (specifier === "cloudflare:workers") { + return { url: ${JSON.stringify(`data:text/javascript,${stub}`)}, shortCircuit: true }; + } + return next(specifier, context); + }`)}`, +); diff --git a/examples/pi-ai/package.json b/examples/pi-ai/package.json new file mode 100644 index 00000000..106bd8a7 --- /dev/null +++ b/examples/pi-ai/package.json @@ -0,0 +1,24 @@ +{ + "name": "@example/computer-pi-ai", + "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-ai.", + "scripts": { + "dev": "wrangler dev", + "local": "node --import ./local-shim.mjs run-local.mjs", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.99.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/pi-ai/run-local.mjs b/examples/pi-ai/run-local.mjs new file mode 100644 index 00000000..4c30a3aa --- /dev/null +++ b/examples/pi-ai/run-local.mjs @@ -0,0 +1,114 @@ +// Drive the pi example's agent loop locally, with no Cloudflare +// account. The loop, tools, and Workspace are real; only the model and +// the storage are substituted, so the tool calls below are scripted +// rather than chosen. +// +// npm run local + +import { Workspace } from "@cloudflare/computer"; +import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +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() }); +// No backend runs under plain node, so there is no `exec` tool here. +// The unit tests cover exec's argument handling. +const { tools, execute } = createPiAITools({ workspace }); + +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +const model = faux.getModel(); + +// One scripted reply per turn: write a file, read it back, then answer. +faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall("write", { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }), + ], + { stopReason: "toolUse" }, + ), +]); + +const messages = [ + { + role: "user", + content: "Write a haiku to /workspace/haiku.txt then read it back.", + timestamp: Date.now(), + }, +]; + +let answer = "(no answer)"; +for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: "You are working in a directory at /workspace.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + answer = reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + break; + } + + for (const call of calls) { + const { content, isError } = await execute(call); + console.log( + `turn ${turn}: ${call.name}(${JSON.stringify(call.arguments).slice(0, 60)}) -> isError=${isError} ${JSON.stringify(content).slice(0, 110)}`, + ); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + + if (turn === 0) { + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("read", { path: "/workspace/haiku.txt" })], { + stopReason: "toolUse", + }), + ]); + } else if (turn === 1) { + faux.setResponses([fauxAssistantMessage([fauxText("Wrote the haiku and read it back.")])]); + } +} + +console.log("\nanswer:", answer); + +// Prove the tools really touched the workspace, not a mock of it. +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("file on disk:", JSON.stringify(onDisk)); + +// A failure must come back as a retryable error result, not a throw. +const missing = await execute({ + id: "x", + name: "read", + arguments: { path: "/workspace/nope.txt" }, +}); +console.log( + "missing file -> isError=%s %s", + missing.isError, + JSON.stringify(missing.content).slice(0, 80), +); + +await workspace.close?.(); diff --git a/examples/pi-ai/src/index.ts b/examples/pi-ai/src/index.ts new file mode 100644 index 00000000..d204c5d8 --- /dev/null +++ b/examples/pi-ai/src/index.ts @@ -0,0 +1,112 @@ +// A one-shot agent on pi, working in a durable Workspace. pi leaves +// the agent loop to the caller, so `run` below is that whole loop. +// +// client ──► Worker / ──► PiAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +import { createModels, type Message } from "@earendil-works/pi-ai"; + +import { WORKERS_AI_PROVIDER, workersAI } from "./workers-ai.js"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +// The one worker shell `exec` runs on. +const SHELL = { + backends: { shell: { description: "A just-bash shell over the workspace files." } }, + defaultBackend: "shell", +}; + +// Bound the spend if the model fails to converge. +const MAX_TURNS = 10; + +export class PiAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "PiAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + const { tools, execute } = createPiAITools({ workspace: this.workspace, shell: SHELL }); + + const models = createModels(); + models.setProvider(workersAI(this.env.AI, MODEL)); + const model = models.getModel(WORKERS_AI_PROVIDER, MODEL); + if (!model) throw new Error(`model ${MODEL} is not registered`); + + const messages: Message[] = [{ role: "user", content: task, timestamp: Date.now() }]; + + for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + return reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + } + + for (const call of calls) { + const { content, isError } = await execute(call); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + } + + return `Gave up after ${MAX_TURNS} turns.`; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + const { task } = (await request.json()) as { task?: string }; + if (!task) return new Response("body needs a task\n", { status: 400 }); + + const agent = env.PiAgent.get(env.PiAgent.idFromName("demo")); + return new Response(`${await agent.run(task)}\n`); + }, +} satisfies ExportedHandler; diff --git a/examples/pi-ai/src/workers-ai.ts b/examples/pi-ai/src/workers-ai.ts new file mode 100644 index 00000000..793b11b0 --- /dev/null +++ b/examples/pi-ai/src/workers-ai.ts @@ -0,0 +1,96 @@ +// pi's Workers AI provider, transported over the `AI` binding rather +// than the REST endpoint, so the example needs no API key. Only the +// transport differs from pi's own provider. +// +// Lifted from the pi harness example in cloudflare/agents. + +import { + type ApiStreamOptions, + createProvider, + type Model, + type ProviderStreams, +} from "@earendil-works/pi-ai"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; + +export const WORKERS_AI_PROVIDER = "cloudflare-workers-ai"; + +type RunBinding = { + run( + model: string, + input: Record, + options: { returnRawResponse: true; signal?: AbortSignal }, + ): Promise; +}; + +function bodyText(body: BodyInit | null | undefined): string { + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + throw new TypeError("Workers AI pi requests require a JSON request body"); +} + +function model(id: string): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: WORKERS_AI_PROVIDER, + // Never dialed: the fetch below answers every request through the + // binding instead. pi still wants a syntactically valid base URL. + baseUrl: "https://workers-ai.binding.invalid/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + }, + }; +} + +export function workersAI(binding: Ai, modelId: string) { + // SAFETY: Workers AI returns a Response when `returnRawResponse` is + // set. The public `Ai` overload cannot express that correlation. + const runBinding = binding as unknown as RunBinding; + + const fetch = async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const input = JSON.parse(bodyText(init?.body)) as Record; + const name = typeof input.model === "string" ? input.model : undefined; + if (!name) throw new TypeError("Workers AI pi request is missing its model"); + delete input.model; + return runBinding.run(name, input, { + returnRawResponse: true, + ...(init?.signal ? { signal: init.signal } : {}), + }); + }; + + const api = openAICompletionsApi(); + const streams: ProviderStreams = { + stream: (m, context, options) => + api.stream(m, context, { ...options, fetch } as ApiStreamOptions), + streamSimple: (m, context, options) => 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-ai/tsconfig.json b/examples/pi-ai/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/pi-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/pi-ai/wrangler.jsonc b/examples/pi-ai/wrangler.jsonc new file mode 100644 index 00000000..8391f590 --- /dev/null +++ b/examples/pi-ai/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-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": "PiAgent", + "class_name": "PiAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["PiAgent"] + } + ] +} diff --git a/examples/tanstack-ai/.gitignore b/examples/tanstack-ai/.gitignore new file mode 100644 index 00000000..0dcc8a41 --- /dev/null +++ b/examples/tanstack-ai/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.wrangler/ diff --git a/examples/tanstack-ai/README.md b/examples/tanstack-ai/README.md new file mode 100644 index 00000000..aa1382b3 --- /dev/null +++ b/examples/tanstack-ai/README.md @@ -0,0 +1,51 @@ +# 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-ai`](../../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 +`createTanStackAITools`. Tools marked that way pause for confirmation instead of +running straight away. + +To check the agent loop without a Cloudflare account, `npm run local --workspace @example/computer-tanstack-ai` +drives it in Node with a scripted model in place of Workers AI. + +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/local-shim.mjs b/examples/tanstack-ai/local-shim.mjs new file mode 100644 index 00000000..2fd0eee0 --- /dev/null +++ b/examples/tanstack-ai/local-shim.mjs @@ -0,0 +1,16 @@ +// `npm run local` loads this before run-local.mjs. @cloudflare/computer +// imports `cloudflare:workers`, which only workerd provides; the local +// run never reaches those classes, so empty stand-ins are enough. +import { register } from "node:module"; + +const stub = + "export class RpcTarget {} export class WorkerEntrypoint {} export class DurableObject {} export const env = {};"; + +register( + `data:text/javascript,${encodeURIComponent(`export async function resolve(specifier, context, next) { + if (specifier === "cloudflare:workers") { + return { url: ${JSON.stringify(`data:text/javascript,${stub}`)}, shortCircuit: true }; + } + return next(specifier, context); + }`)}`, +); diff --git a/examples/tanstack-ai/package.json b/examples/tanstack-ai/package.json new file mode 100644 index 00000000..c5de1cc9 --- /dev/null +++ b/examples/tanstack-ai/package.json @@ -0,0 +1,25 @@ +{ + "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-ai.", + "scripts": { + "dev": "wrangler dev", + "local": "node --import ./local-shim.mjs run-local.mjs", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.63.0", + "@tanstack/ai-cloudflare": "^0.2.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/tanstack-ai/run-local.mjs b/examples/tanstack-ai/run-local.mjs new file mode 100644 index 00000000..fc7f2701 --- /dev/null +++ b/examples/tanstack-ai/run-local.mjs @@ -0,0 +1,103 @@ +// Drive the TanStack AI example's agent loop locally, with no +// Cloudflare account. `chat()`, the tools, and the Workspace are real; +// only the provider is substituted, so the tool calls below are +// scripted rather than chosen. +// +// npm run local + +import { Workspace } from "@cloudflare/computer"; +import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { chat, maxIterations } from "@tanstack/ai"; + +const workspace = new Workspace({ storage: new SQLiteTestStorage() }); +const tools = createTanStackAITools({ workspace }); + +// One scripted turn per agent-loop iteration. +const script = [ + { + toolCalls: [ + { + name: "write", + args: { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }, + }, + ], + }, + { toolCalls: [{ name: "read", args: { path: "/workspace/haiku.txt" } }] }, + { text: "Wrote the haiku and read it back." }, +]; + +let turn = 0; + +// The smallest adapter shape chat() will drive: AG-UI events for one +// assistant turn, then stop. +const scriptedAdapter = { + name: "scripted", + model: "scripted", + provider: "scripted", + capabilities: { streaming: true, tools: true }, + async *chatStream() { + const step = script[Math.min(turn, script.length - 1)]; + turn += 1; + const messageId = `m${turn}`; + yield { type: "RUN_STARTED", timestamp: Date.now() }; + + if (step.toolCalls) { + for (const [i, call] of step.toolCalls.entries()) { + const toolCallId = `call-${turn}-${i}`; + yield { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: call.name, + toolName: call.name, + index: i, + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_ARGS", + toolCallId, + delta: JSON.stringify(call.args), + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_END", + toolCallId, + toolCallName: call.name, + toolName: call.name, + input: call.args, + timestamp: Date.now(), + }; + } + yield { type: "RUN_FINISHED", finishReason: "tool_calls", timestamp: Date.now() }; + return; + } + + yield { type: "TEXT_MESSAGE_START", messageId, role: "assistant", timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_CONTENT", messageId, delta: step.text, timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_END", messageId, timestamp: Date.now() }; + yield { type: "RUN_FINISHED", finishReason: "stop", timestamp: Date.now() }; + }, +}; + +const stream = chat({ + adapter: scriptedAdapter, + systemPrompts: ["You are working in a directory at /workspace."], + messages: [{ role: "user", content: "Write a haiku to /workspace/haiku.txt then read it back." }], + tools, + agentLoopStrategy: maxIterations(10), +}); + +const chunks = []; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + chunks.push(JSON.stringify(chunk).slice(0, 320)); + } + if (chunk.type === "TEXT_MESSAGE_CONTENT") chunks.push(`text: ${chunk.delta}`); +} +for (const line of chunks) console.log(line); + +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("\nfile on disk:", JSON.stringify(onDisk)); diff --git a/examples/tanstack-ai/src/index.ts b/examples/tanstack-ai/src/index.ts new file mode 100644 index 00000000..86266891 --- /dev/null +++ b/examples/tanstack-ai/src/index.ts @@ -0,0 +1,85 @@ +// A one-shot agent on TanStack AI, working in a durable Workspace. +// +// client ──► Worker / ──► TanStackAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { chat, maxIterations, streamToText } from "@tanstack/ai"; +import { cloudflareText } from "@tanstack/ai-cloudflare"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +export class TanStackAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "TanStackAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + const tools = createTanStackAITools({ + workspace: this.workspace, + shell: { + backends: { shell: { description: "A just-bash shell over the workspace files." } }, + defaultBackend: "shell", + }, + }); + + const stream = chat({ + // The cast is a version mismatch, not a real one: the adapter is + // on @cloudflare/workers-types v4 and this repo is on v5, so the + // two structurally identical `Ai` types will not unify. Drop it + // once the adapter moves to v5. + adapter: cloudflareText(MODEL, { binding: this.env.AI as unknown as never }), + systemPrompts: [ + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + ], + messages: [{ role: "user", content: task }], + tools, + // Bound the spend if the model fails to converge. + agentLoopStrategy: maxIterations(10), + }); + + return await streamToText(stream); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + const { task } = (await request.json()) as { task?: string }; + if (!task) return new Response("body needs a task\n", { status: 400 }); + + const agent = env.TanStackAgent.get(env.TanStackAgent.idFromName("demo")); + return new Response(`${await agent.run(task)}\n`); + }, +} satisfies ExportedHandler; diff --git a/examples/tanstack-ai/tsconfig.json b/examples/tanstack-ai/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/tanstack-ai/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/tanstack-ai/wrangler.jsonc b/examples/tanstack-ai/wrangler.jsonc new file mode 100644 index 00000000..16d79fbc --- /dev/null +++ b/examples/tanstack-ai/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + // Example: a one-shot TanStack AI agent in a Durable Object. + // + // The DO holds a Workspace whose shell is a Dynamic Worker, and + // talks to Workers AI through the AI binding, so the example needs + // no API key. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-tanstack-ai-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI. Running this uses your account's Workers AI quota. + "ai": { + "binding": "AI" + }, + + // The workspace shell runs in a Dynamic Worker minted through this. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "TanStackAgent", + "class_name": "TanStackAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TanStackAgent"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index f371cda0..97a72a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5938,6 +5938,56 @@ } } }, + "examples/pi-ai": { + "name": "@example/computer-pi-ai", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.99.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/pi-ai/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "examples/rlm": { "name": "@cloudflare/example-rlm", "version": "0.0.0", @@ -6794,6 +6844,57 @@ } } }, + "examples/tanstack-ai": { + "name": "@example/computer-tanstack-ai", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.63.0", + "@tanstack/ai-cloudflare": "^0.2.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/tanstack-ai/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "examples/think": { "name": "@cloudflare/example-think", "version": "0.0.0", @@ -11088,6 +11189,20 @@ } } }, + "node_modules/@ag-ui/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-1.0.0.tgz", + "integrity": "sha512-yCRhsQvb4+lmGMrDsi4TOJxqKpm/kb3H64wwL+Z90jNVrkhy17RErfzsNgybfbjNKLM0nenEDYDVJsZHJJSigQ==", + "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", @@ -11217,6 +11332,27 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "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", @@ -11268,6 +11404,347 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.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/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@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/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "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", @@ -12885,6 +13362,39 @@ "node": ">=20.19.0" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.99.2.tgz", + "integrity": "sha512-9RFOEdY+ZTJ1AI+UuAFs4RM0tF2Tje/R/CEv9gWTJHt0iTu8XHZs64U/EIZxNSllFmec/4HfwsOxYj1qQek9bg==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.124.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^0.99.2", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.99.2.tgz", + "integrity": "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA==", + "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", @@ -13360,6 +13870,14 @@ "resolved": "examples/mcp", "link": true }, + "node_modules/@example/computer-pi-ai": { + "resolved": "examples/pi-ai", + "link": true + }, + "node_modules/@example/computer-tanstack-ai": { + "resolved": "examples/tanstack-ai", + "link": true + }, "node_modules/@example/computer-tutorial": { "resolved": "examples/tutorial", "link": true @@ -13390,6 +13908,30 @@ } } }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "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", @@ -14836,6 +15378,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", @@ -15242,6 +15841,87 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.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/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/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", @@ -15249,6 +15929,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", @@ -15536,7 +16222,176 @@ "tailwindcss": "4.3.3" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/ai": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai/-/ai-0.63.0.tgz", + "integrity": "sha512-4S4hOOc/2LvxNkMzwuBaECtchPQsxrlLNqnmi/WjcXmX8gyboy8UNPwwS/9XzFFJ4VwSEW2Md+h+OtTGmf6Y/g==", + "license": "MIT", + "dependencies": { + "@ag-ui/core": "1.0.0", + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai-event-client": "^0.13.0", + "@tanstack/ai-utils": "^0.4.1", + "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.2.1", + "resolved": "https://registry.npmjs.org/@tanstack/ai-cloudflare/-/ai-cloudflare-0.2.1.tgz", + "integrity": "sha512-Qo/UlQ/1Db4/k7rSXa1UV8x29IYM+Jd4Rsbzr3ygzF6oMzqNOT7INZS2I28XbpiNzwAhnHMUhKGPM1u9swC7aQ==", + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai-utils": "^0.4.1", + "@tanstack/openai-base": "^0.12.1", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/ai": "^0.63.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.13.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai-event-client/-/ai-event-client-0.13.0.tgz", + "integrity": "sha512-qGN7saScQHqDd06DE5CNG+wl1O9v6anwsGCCeP4N7zFbZd5k9/2C1u4cdJ2+Qxl2aA8drusLFZMhY/ljhBJqLQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@tanstack/ai-utils/-/ai-utils-0.4.1.tgz", + "integrity": "sha512-B3PGn2WYiRtivZCt7MUN2iXeoK9fwZhgdbrPIz5hPqVR8r8sNDZMmMqtiOkp7sxzPvVlCBItFVHw7QpZ9g6e9w==", + "license": "MIT", + "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": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/openai-base": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@tanstack/openai-base/-/openai-base-0.12.1.tgz", + "integrity": "sha512-mkt86u9kIW4oScA738ntrpTMXTqkTxZl5XU54lcUkdl5xT3Kx4fM4HT9x9x49twiUNHJEP7y44m7gK7FkZbiMA==", + "license": "MIT", + "dependencies": { + "@tanstack/ai-utils": "^0.4.1", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/ai": "^0.63.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": { @@ -15716,7 +16571,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" @@ -15741,6 +16595,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", @@ -15954,6 +16814,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/agents": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/agents/-/agents-0.20.1.tgz", @@ -16286,6 +17155,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", @@ -16392,6 +17270,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.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -16475,6 +17359,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", @@ -16886,6 +17776,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", @@ -17118,6 +18017,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", @@ -17541,6 +18449,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.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -17624,6 +18538,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", @@ -17705,6 +18642,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", @@ -17813,6 +18762,74 @@ "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/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/gaxios/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/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", @@ -17955,6 +18972,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", @@ -18150,6 +19193,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/human-id": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", @@ -18574,12 +19645,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", @@ -18665,6 +19758,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", @@ -18968,6 +20082,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", @@ -20209,6 +21329,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", @@ -20370,6 +21510,43 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.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", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -20429,6 +21606,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", @@ -20508,6 +21698,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", @@ -20805,6 +22001,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", @@ -20818,6 +22037,23 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -21218,6 +22454,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", @@ -21995,6 +23240,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", @@ -22468,11 +23723,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": { @@ -22532,6 +23792,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "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", @@ -22585,7 +23851,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": { @@ -23246,6 +24511,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", @@ -23478,7 +24752,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" diff --git a/packages/computer/README.md b/packages/computer/README.md index dc9b81b4..efc39f5b 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` for every tools entry point, `ai` for +`@cloudflare/computer/tools`, and +`@platformatic/vfs` for the Node-side VFS provider. The pi and TanStack +AI entry points need nothing beyond `zod`; your agent brings its own +library. ## Quick start @@ -303,6 +306,21 @@ mutations share locks across tool sets for the same workspace, and recursive deletion excludes mutations throughout its subtree. See [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). +The same tools, with the same options, come for two more agent +libraries. Each entry point loads only `zod` and its own code, so +importing one never pulls in another library. + +```ts +import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; + +// pi: declarations for the model, and a function your loop calls per tool call. +const { tools, execute } = createPiAITools({ workspace }); + +// TanStack AI: a list for chat({ tools }). This one asks before changing files. +const tanstackTools = createTanStackAITools({ workspace, approve: "mutating" }); +``` + ## Git `workspace.git` is an opt-in typed git client backed by @@ -420,6 +438,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-ai` | `createPiAITools()`: the same tool set for pi (`@earendil-works/pi-ai`). | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools()`: the same tool set for TanStack AI (`@tanstack/ai`). | | `@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. | From d5b24615825f69033ec43896ecb7b2ebaeacd0a0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 14:27:22 +0100 Subject: [PATCH 3/4] computer: Return TanStack media reads as content parts chat() passes a tool result to the adapter as multimodal content only when it is a ContentPart array. The read tool returned an image or PDF as a plain object, which TanStack JSON-stringified, so the model got the base64 as text. It now returns a text part plus an image or document part, and the test checks the shape with TanStack's own isContentPartArray. Also covers a failed pi publish, which already comes back as an error result. --- docs/09_tool_interface.md | 2 ++ package-lock.json | 1 + packages/computer/package.json | 1 + .../computer/src/tools/pi-ai/index.test.ts | 23 +++++++++++++++++ .../src/tools/tanstack-ai/index.test.ts | 25 +++++++++++++++++++ .../computer/src/tools/tanstack-ai/index.ts | 20 +++++++++------ 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index e4654319..d4e0ca8c 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -143,6 +143,8 @@ return toServerSentEventsResponse(chat({ adapter, messages, tools, abortControll `write`, `edit`, `delete`, and `publish` have one fixed result shape, so they also carry an `outputSchema`. It covers failures too, because TanStack validates every return against it, and a success-only schema would replace the real error with a validation complaint. Paged tools such as `ls` have none. +An image or PDF from `read` comes back as a text part plus an `image` or `document` content part, the array shape `chat()` passes to the adapter as multimodal content instead of stringifying it. + Aborting the chat run through its `abortController` kills a running `exec`. A TanStack tool settles on one value, so `exec` returns its final snapshot. ## Options diff --git a/package-lock.json b/package-lock.json index 97a72a8e..2a60587d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24933,6 +24933,7 @@ "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", "@platformatic/vfs": "^0.4.0", + "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", "diff": "^9.0.0", "esbuild": "^0.28.1", diff --git a/packages/computer/package.json b/packages/computer/package.json index f9162693..fe6164a7 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -158,6 +158,7 @@ "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", "@platformatic/vfs": "^0.4.0", + "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", "diff": "^9.0.0", "esbuild": "^0.28.1", diff --git a/packages/computer/src/tools/pi-ai/index.test.ts b/packages/computer/src/tools/pi-ai/index.test.ts index 66a9c521..081a7212 100644 --- a/packages/computer/src/tools/pi-ai/index.test.ts +++ b/packages/computer/src/tools/pi-ai/index.test.ts @@ -261,6 +261,29 @@ describe("createPiAITools execution", () => { ); }); + it("reports a failed publish as an error result", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + assets: { + share: async () => { + throw new Error("bucket unavailable"); + }, + } as never, + }); + const tools = createPiAITools({ workspace }); + + const result = await tools.execute({ + id: "1", + name: "publish", + arguments: { path: "/workspace/out.png" }, + }); + + expect(result).toEqual({ + content: [{ type: "text", text: "bucket unavailable" }], + isError: true, + }); + }); + it("returns an image read as a base64 image block", async () => { const workspace = makeWorkspace(); const tools = createPiAITools({ workspace }); diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts index 4a10b96e..9f67b70c 100644 --- a/packages/computer/src/tools/tanstack-ai/index.test.ts +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -1,4 +1,5 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { isContentPartArray } from "@tanstack/ai"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { WorkerJavaScriptBackend } from "../../backends/worker-javascript/worker-javascript.js"; @@ -248,6 +249,30 @@ describe("createTanStackAITools", () => { expect(result.error).toContain("missing.txt"); }); + it("returns an image read as content parts TanStack attaches", async () => { + // chat() passes a tool result through as multimodal content only when + // it is a ContentPart array; anything else becomes JSON text. + const workspace = makeWorkspace(); + const tools = createTanStackAITools({ workspace, format: "object" }); + 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.read.execute({ path: "/workspace/pixel.png" } as never); + + expect(result).toEqual([ + { type: "text", content: expect.stringContaining("/workspace/pixel.png") }, + { + type: "image", + source: { type: "data", value: expect.any(String), mimeType: "image/png" }, + }, + ]); + expect(isContentPartArray(result)).toBe(true); + }); + it("offers the backends `shell` lists and defaults to one", () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), diff --git a/packages/computer/src/tools/tanstack-ai/index.ts b/packages/computer/src/tools/tanstack-ai/index.ts index a04c8e0d..532bd06d 100644 --- a/packages/computer/src/tools/tanstack-ai/index.ts +++ b/packages/computer/src/tools/tanstack-ai/index.ts @@ -265,8 +265,11 @@ function wants(option: string[] | string | undefined, name: string, byTrait: boo } /** - * TanStack has no typed tool-result part for media, so an image or PDF - * returns its text alongside the payload for a caller to reattach. + * TanStack passes a tool result through as multimodal content only when + * it is an array of content parts; anything else is JSON-stringified. So + * an image or PDF comes back as a text part plus an `image` or + * `document` part, which the adapter attaches rather than sending the + * base64 as text. */ function toTanStackOutput(output: ModelOutput): unknown { switch (output.type) { @@ -277,11 +280,12 @@ function toTanStackOutput(output: ModelOutput): unknown { case "json": return output.value; case "media": - return { - text: output.text, - mediaType: output.mediaType, - filename: output.filename, - data: output.data, - }; + return [ + { type: "text", content: output.text }, + { + type: output.mediaType.startsWith("image/") ? "image" : "document", + source: { type: "data", value: output.data, mimeType: output.mediaType }, + }, + ]; } } From d810bea7b7371c093256a54d5c4d78635eed312e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 14:45:23 +0100 Subject: [PATCH 4/4] computer: Name the tool sets createPiTools and createTanStackTools Renames createPiAITools to createPiTools and createTanStackAITools to createTanStackTools, with their option and result types. The entry points stay tools/pi-ai and tools/tanstack-ai. pi checks tool arguments with TypeBox, which also compiles plain JSON Schema, so the pi tools need only zod for their own schemas. pi 0.99 also closes a constrainedSampling schema itself when the provider runs it strict. The adapter used to close read, write, and edit up front, which made every optional field required in the schema pi validates against. A provider that fell back to ordinary tool calling and left `offset` out of a read failed pi's own validateToolCall. The declarations now stay open, and execute drops a null only on an optional field that cannot take one. Tests check the declarations against pi's validateToolCall and makeStrictJsonSchema. --- .changeset/pi-ai-tanstack-ai-tools.md | 6 +- docs/09_tool_interface.md | 28 +++---- docs/10_project_layout.md | 4 +- docs/README.md | 6 +- examples/pi-ai/README.md | 2 +- examples/pi-ai/run-local.mjs | 4 +- examples/pi-ai/src/index.ts | 4 +- examples/tanstack-ai/README.md | 2 +- examples/tanstack-ai/run-local.mjs | 4 +- examples/tanstack-ai/src/index.ts | 4 +- package-lock.json | 1 + packages/computer/README.md | 12 +-- packages/computer/package.json | 1 + packages/computer/src/tools/common/options.ts | 2 +- packages/computer/src/tools/index.ts | 4 +- .../computer/src/tools/pi-ai/index.test.ts | 82 +++++++++++-------- packages/computer/src/tools/pi-ai/index.ts | 81 +++++++----------- .../src/tools/pi-ai/model-output.test.ts | 6 +- .../src/tools/tanstack-ai/index.test.ts | 44 +++++----- .../computer/src/tools/tanstack-ai/index.ts | 6 +- 20 files changed, 146 insertions(+), 157 deletions(-) diff --git a/.changeset/pi-ai-tanstack-ai-tools.md b/.changeset/pi-ai-tanstack-ai-tools.md index 1813f14b..b3388bfc 100644 --- a/.changeset/pi-ai-tanstack-ai-tools.md +++ b/.changeset/pi-ai-tanstack-ai-tools.md @@ -2,8 +2,4 @@ "@cloudflare/computer": minor --- -Add the Workspace tool set for two more agent libraries. `createPiAITools` from `@cloudflare/computer/tools/pi-ai` serves [pi](https://github.com/earendil-works/pi), and `createTanStackAITools` from `@cloudflare/computer/tools/tanstack-ai` serves [TanStack AI](https://tanstack.com/ai). Both take the same options as `createAITools`, including `shell`, and build the same tools with the same names, descriptions, and limits. - -pi runs tools in the caller's own loop, so `createPiAITools` returns `tools` to send to the model and `execute` to run one tool call. A bad call or a failed tool comes back as an error result instead of a throw. TanStack AI runs tools itself, so `createTanStackAITools` returns the list `chat({ tools })` takes, with `approve`, `lazy`, `format`, and `streamEventName` for TanStack's approval, lazy discovery, and progress events. - -Neither entry point imports its library, only `zod`, so adding one does not pull `ai`, pi, or TanStack into a bundle that does not use them. +Add Workspace tool sets for pi (`createPiTools` from `@cloudflare/computer/tools/pi-ai`) and TanStack AI (`createTanStackTools` from `@cloudflare/computer/tools/tanstack-ai`); see [the tool interface docs](https://github.com/cloudflare/computer/blob/main/docs/09_tool_interface.md). diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index d4e0ca8c..1524aa92 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -5,8 +5,8 @@ Computer ships a ready-made tool set for agents that use a `Workspace`, once for | Library | Entry point | 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-ai` | `createPiAITools` | -| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools` | +| [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`) | `@cloudflare/computer/tools/pi-ai` | `createPiTools` | +| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools` | All three take the same options and build the same tools, with the same names, descriptions, schemas, and limits. Only the shape they return differs. Each entry point imports only `zod` and its own library's types, so a pi agent never loads `ai` and an AI SDK agent never loads pi. The individual AI SDK `create*Tool` functions and `WorkspaceFileStore` also come from `@cloudflare/computer/tools`. @@ -21,8 +21,8 @@ The tools wrap three Workspace surfaces: | Export | Purpose | | --- | --- | | `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. | -| `createPiAITools` | Create pi tool declarations and the function that runs a pi tool call. | -| `createTanStackAITools` | Create the TanStack AI tool list for a Workspace. | +| `createPiTools` | Create pi tool declarations and the function that runs a pi tool call. | +| `createTanStackTools` | Create the TanStack AI tool list for a Workspace. | | `createReadTool` | Stream text by line and pass images or PDFs to capable models. | | `createWriteTool` | Write a whole file with a UTF-8 byte cap. | | `createEditTool` | Apply atomic targeted replacements and return a unified diff. | @@ -80,16 +80,16 @@ const tools = createAITools({ }); ``` -`createPiAITools` and `createTanStackAITools` take `shell` the same way. +`createPiTools` and `createTanStackTools` take `shell` the same way. ## pi -pi keeps tool declarations apart from the code that runs them. `Context.tools` carries declarations with JSON Schema `parameters`, and the caller's own loop runs each call. `createPiAITools` returns both, so they cannot drift apart. +pi keeps tool declarations apart from the code that runs them. `Context.tools` carries declarations with JSON Schema `parameters`, and the caller's own loop runs each call. `createPiTools` returns both, so they cannot drift apart. ```ts -import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; -const { tools, execute } = createPiAITools({ workspace }); +const { tools, execute } = createPiTools({ workspace }); const message = await models.complete(model, { systemPrompt, messages, tools }); messages.push(message); @@ -108,14 +108,14 @@ for (const block of message.content) { } ``` -`execute` checks the call's arguments against the tool's schema and returns pi `toolResult` content. A bad call or a failed tool comes back as `isError: true`, so the model can retry and the loop does not throw. The Zod schemas become plain JSON Schema, and a field with a default stays optional for the model. +`execute` checks the call's arguments against the tool's schema and returns pi `toolResult` content. A bad call or a failed tool comes back as `isError: true`, so the model can retry and the loop does not throw. pi describes tool parameters with TypeBox, which also accepts plain JSON Schema, so the Zod schemas are converted to JSON Schema and pi needs nothing else. A field with a default stays optional for the model. -`read`, `write`, and `edit` carry byte offsets and long verbatim strings, so they ask for pi's `constrainedSampling`. A provider that supports it enforces the schema while sampling, and a malformed `edit` never reaches the tool. Those schemas are closed (`additionalProperties: false`) with every property required, and each optional field becomes nullable. `execute` drops those placeholder nulls before validation and keeps any null the tool accepts, such as `exec`'s `input`. +`read`, `write`, and `edit` carry byte offsets and long verbatim strings, so they ask for pi's `constrainedSampling`. A provider that supports it enforces the schema while sampling, and a malformed `edit` never reaches the tool. The declarations stay open. pi closes a schema itself when the provider supports strict mode, making every field required and the optional ones nullable. `execute` drops a null on an optional field that does not accept one, and keeps a null the tool accepts, such as `exec`'s `input`. The default is `"prefer"`, which falls back to ordinary tool calling on a provider that cannot enforce a schema. `"require"` fails the request instead, for a pinned model known to support it. `false` turns it off and keeps the schemas open: ```ts -createPiAITools({ workspace, constrainedSampling: "require" }); +createPiTools({ workspace, constrainedSampling: "require" }); ``` pi tool results carry text and images. An image from `read` comes back as an `image` block; a PDF comes back as text saying it cannot be attached. `exec` returns its final snapshot. @@ -126,10 +126,10 @@ A TanStack tool's `inputSchema` is a Standard Schema, which Zod implements, so t ```ts import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; const abortController = new AbortController(); -const tools = createTanStackAITools({ workspace, approve: "mutating" }); +const tools = createTanStackTools({ workspace, approve: "mutating" }); return toServerSentEventsResponse(chat({ adapter, messages, tools, abortController })); ``` @@ -171,7 +171,7 @@ createAITools({ | `edit` | default caps | Options passed to `createEditTool`. | | `shell` | omitted | Options passed to `createExecTool`. | -`createPiAITools` and `createTanStackAITools` take the same options, plus their own listed above. +`createPiTools` and `createTanStackTools` take the same options, plus their own listed above. ## `read` diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 3f28c2c4..d4b870ed 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -178,8 +178,8 @@ produces the Node SEA single-file binary at Agent tools (`read`, `write`, `edit`, `ls`, optional `exec`, and optional `publish`) ship from the package rather than a separate one, with one entry point per agent library: `createAITools()` from -`@cloudflare/computer/tools`, `createPiAITools()` from -`@cloudflare/computer/tools/pi-ai`, and `createTanStackAITools()` from +`@cloudflare/computer/tools`, `createPiTools()` from +`@cloudflare/computer/tools/pi-ai`, and `createTanStackTools()` from `@cloudflare/computer/tools/tanstack-ai`. The individual AI SDK `create*Tool` functions come from `@cloudflare/computer/tools` too. They live under [`packages/computer/src/tools/`](../packages/computer/src/tools/): diff --git a/docs/README.md b/docs/README.md index 35f461c8..1ecb5fa4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,7 @@ It provides: - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell, a just-bash Dynamic Worker, or an isolated ECMAScript-module Dynamic Worker. - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools`), pi (`createPiAITools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackAITools()` in `@cloudflare/computer/tools/tanstack-ai`). + - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools`), pi (`createPiTools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackTools()` in `@cloudflare/computer/tools/tanstack-ai`). It comes with the following limitations: @@ -50,8 +50,8 @@ The package ships several entrypoints: | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, an optionally session-scoped wrapper over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | -| `@cloudflare/computer/tools/pi-ai` | `createPiAITools()`: the same tool set for pi, as declarations plus a function that runs a tool call. | -| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools()`: the same tool set for TanStack AI, as the list `chat({ tools })` takes. | +| `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi, as declarations plus a function that runs a tool call. | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI, as the list `chat({ tools })` takes. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. diff --git a/examples/pi-ai/README.md b/examples/pi-ai/README.md index e5d88ada..b4b001bb 100644 --- a/examples/pi-ai/README.md +++ b/examples/pi-ai/README.md @@ -6,7 +6,7 @@ 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 -`createPiAITools` hands back both — `tools` to show the model, and `execute` to +`createPiTools` hands back both — `tools` to show the model, and `execute` to run one of its requests. The workspace tools come from diff --git a/examples/pi-ai/run-local.mjs b/examples/pi-ai/run-local.mjs index 4c30a3aa..5d173ad4 100644 --- a/examples/pi-ai/run-local.mjs +++ b/examples/pi-ai/run-local.mjs @@ -6,7 +6,7 @@ // npm run local import { Workspace } from "@cloudflare/computer"; -import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { createModels, @@ -21,7 +21,7 @@ const MAX_TURNS = 10; const workspace = new Workspace({ storage: new SQLiteTestStorage() }); // No backend runs under plain node, so there is no `exec` tool here. // The unit tests cover exec's argument handling. -const { tools, execute } = createPiAITools({ workspace }); +const { tools, execute } = createPiTools({ workspace }); const faux = fauxProvider(); const models = createModels(); diff --git a/examples/pi-ai/src/index.ts b/examples/pi-ai/src/index.ts index d204c5d8..3dbc7245 100644 --- a/examples/pi-ai/src/index.ts +++ b/examples/pi-ai/src/index.ts @@ -14,7 +14,7 @@ import { type WorkspaceStub, } from "@cloudflare/computer"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; -import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; import { createModels, type Message } from "@earendil-works/pi-ai"; import { WORKERS_AI_PROVIDER, workersAI } from "./workers-ai.js"; @@ -54,7 +54,7 @@ export class PiAgent extends DurableObject { } async run(task: string): Promise { - const { tools, execute } = createPiAITools({ workspace: this.workspace, shell: SHELL }); + const { tools, execute } = createPiTools({ workspace: this.workspace, shell: SHELL }); const models = createModels(); models.setProvider(workersAI(this.env.AI, MODEL)); diff --git a/examples/tanstack-ai/README.md b/examples/tanstack-ai/README.md index aa1382b3..36b5fc0d 100644 --- a/examples/tanstack-ai/README.md +++ b/examples/tanstack-ai/README.md @@ -36,7 +36,7 @@ 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 -`createTanStackAITools`. Tools marked that way pause for confirmation instead of +`createTanStackTools`. Tools marked that way pause for confirmation instead of running straight away. To check the agent loop without a Cloudflare account, `npm run local --workspace @example/computer-tanstack-ai` diff --git a/examples/tanstack-ai/run-local.mjs b/examples/tanstack-ai/run-local.mjs index fc7f2701..bb8ff7b8 100644 --- a/examples/tanstack-ai/run-local.mjs +++ b/examples/tanstack-ai/run-local.mjs @@ -6,12 +6,12 @@ // npm run local import { Workspace } from "@cloudflare/computer"; -import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { chat, maxIterations } from "@tanstack/ai"; const workspace = new Workspace({ storage: new SQLiteTestStorage() }); -const tools = createTanStackAITools({ workspace }); +const tools = createTanStackTools({ workspace }); // One scripted turn per agent-loop iteration. const script = [ diff --git a/examples/tanstack-ai/src/index.ts b/examples/tanstack-ai/src/index.ts index 86266891..8b167390 100644 --- a/examples/tanstack-ai/src/index.ts +++ b/examples/tanstack-ai/src/index.ts @@ -13,7 +13,7 @@ import { type WorkspaceStub, } from "@cloudflare/computer"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; -import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; import { chat, maxIterations, streamToText } from "@tanstack/ai"; import { cloudflareText } from "@tanstack/ai-cloudflare"; @@ -43,7 +43,7 @@ export class TanStackAgent extends DurableObject { } async run(task: string): Promise { - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace: this.workspace, shell: { backends: { shell: { description: "A just-bash shell over the workspace files." } }, diff --git a/package-lock.json b/package-lock.json index 2a60587d..1614cea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24932,6 +24932,7 @@ "@cloudflare/dofs": "*", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", + "@earendil-works/pi-ai": "^0.99.2", "@platformatic/vfs": "^0.4.0", "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", diff --git a/packages/computer/README.md b/packages/computer/README.md index efc39f5b..c915ef75 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -311,14 +311,14 @@ libraries. Each entry point loads only `zod` and its own code, so importing one never pulls in another library. ```ts -import { createPiAITools } from "@cloudflare/computer/tools/pi-ai"; -import { createTanStackAITools } from "@cloudflare/computer/tools/tanstack-ai"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; // pi: declarations for the model, and a function your loop calls per tool call. -const { tools, execute } = createPiAITools({ workspace }); +const { tools, execute } = createPiTools({ workspace }); // TanStack AI: a list for chat({ tools }). This one asks before changing files. -const tanstackTools = createTanStackAITools({ workspace, approve: "mutating" }); +const tanstackTools = createTanStackTools({ workspace, approve: "mutating" }); ``` ## Git @@ -438,8 +438,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-ai` | `createPiAITools()`: the same tool set for pi (`@earendil-works/pi-ai`). | -| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackAITools()`: the same tool set for TanStack AI (`@tanstack/ai`). | +| `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi (`@earendil-works/pi-ai`). | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI (`@tanstack/ai`). | | `@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 fe6164a7..e8ea906a 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -157,6 +157,7 @@ "@cloudflare/dofs": "*", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", + "@earendil-works/pi-ai": "^0.99.2", "@platformatic/vfs": "^0.4.0", "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", diff --git a/packages/computer/src/tools/common/options.ts b/packages/computer/src/tools/common/options.ts index 7d78886e..518ef15c 100644 --- a/packages/computer/src/tools/common/options.ts +++ b/packages/computer/src/tools/common/options.ts @@ -5,7 +5,7 @@ import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./f import type { WriteToolOptions } from "./fs/write.js"; import type { PublishWorkspaceLike } from "./publish.js"; -/** Options every tool set takes: `createAITools`, `createPiAITools`, and `createTanStackAITools`. */ +/** Options every tool set takes: `createAITools`, `createPiTools`, and `createTanStackTools`. */ export interface CreateToolsOptions { workspace: FileWorkspaceLike & Partial & Partial; /** Omit `write`, `edit`, `delete`, `exec`, and `publish`. */ diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 8482527a..19f5fd51 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,8 +1,8 @@ // The AI SDK tool set, its individual tools, and the file store under // them. Tool sets for other agent libraries have their own entry points, // so importing one never pulls in the AI SDK: -// @cloudflare/computer/tools/pi-ai createPiAITools -// @cloudflare/computer/tools/tanstack-ai createTanStackAITools +// @cloudflare/computer/tools/pi-ai createPiTools +// @cloudflare/computer/tools/tanstack-ai createTanStackTools export { type CreateAIToolsOptions, createAITools } from "./ai-sdk/index.js"; export { createDeleteTool, diff --git a/packages/computer/src/tools/pi-ai/index.test.ts b/packages/computer/src/tools/pi-ai/index.test.ts index 081a7212..d91cad05 100644 --- a/packages/computer/src/tools/pi-ai/index.test.ts +++ b/packages/computer/src/tools/pi-ai/index.test.ts @@ -1,21 +1,23 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { validateToolCall } from "@earendil-works/pi-ai"; +import { makeStrictJsonSchema } from "@earendil-works/pi-ai/api/constrained-sampling"; import { describe, expect, it } from "vitest"; import { Workspace } from "../../workspace.js"; -import { createPiAITools } from "./index.js"; +import { createPiTools, type PiJSONSchema } from "./index.js"; function makeWorkspace(): Workspace { return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); } -function declaration(tools: ReturnType, name: string) { +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("createPiAITools declarations", () => { +describe("createPiTools declarations", () => { it("declares the default tool set with object parameter schemas", () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ "delete", @@ -33,13 +35,13 @@ describe("createPiAITools declarations", () => { }); it("omits mutating tools when readonly", () => { - const tools = createPiAITools({ workspace: makeWorkspace(), readonly: true }); + const tools = createPiTools({ workspace: makeWorkspace(), readonly: true }); expect(tools.tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); }); it("offers the backends `shell` lists, with the default named", () => { - const tools = createPiAITools({ + const tools = createPiTools({ workspace: makeWorkspace(), shell: { backends: { @@ -66,14 +68,14 @@ describe("createPiAITools declarations", () => { defaultBackend: "worker-shell", }; - expect(createPiAITools({ workspace }).tools.map((t) => t.name)).not.toContain("exec"); + expect(createPiTools({ workspace }).tools.map((t) => t.name)).not.toContain("exec"); expect( - createPiAITools({ workspace, shell, readonly: true }).tools.map((t) => t.name), + createPiTools({ workspace, shell, readonly: true }).tools.map((t) => t.name), ).not.toContain("exec"); }); it("emits required fields without a $schema key and keeps defaults optional", () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); const write = declaration(tools, "write"); expect(write.parameters.$schema).toBeUndefined(); @@ -87,9 +89,9 @@ describe("createPiAITools declarations", () => { }); }); -describe("createPiAITools constrained sampling", () => { +describe("createPiTools constrained sampling", () => { it("requests provider-side strict schemas for the fussy tools only", () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); // `edit` and `write` carry long verbatim strings a model can mangle. expect(declaration(tools, "edit").constrainedSampling).toEqual({ @@ -104,21 +106,31 @@ describe("createPiAITools constrained sampling", () => { expect(declaration(tools, "ls").constrainedSampling).toBeUndefined(); }); - it("closes a strict schema and makes optional fields nullable", () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); - + it("sends open schemas that pi validates and makes strict itself", () => { + 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"); + const call = (args: Record) => ({ + type: "toolCall" as const, + id: "1", + name: "read", + arguments: args, + }); + + // A provider that falls back to ordinary tool calling may leave the + // optional fields out; pi's own validator must accept that. + expect(read.parameters.required).toEqual(["path"]); + expect(validateToolCall(tools.tools as never, call({ path: "/w/a.txt" }))).toEqual({ + path: "/w/a.txt", + }); + // Under strict sampling pi closes the schema and lets the optional + // fields be null. + const strict = makeStrictJsonSchema(read.parameters as never) as PiJSONSchema; + expect(strict.additionalProperties).toBe(false); + expect(strict.required?.sort()).toEqual(["byteOffset", "limit", "offset", "path"]); }); it("escalates to require or opts out when asked", () => { - const required = createPiAITools({ + const required = createPiTools({ workspace: makeWorkspace(), constrainedSampling: "require", }); @@ -127,16 +139,14 @@ describe("createPiAITools constrained sampling", () => { strict: "require", }); - const off = createPiAITools({ workspace: makeWorkspace(), constrainedSampling: false }); + 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 = createPiAITools({ workspace }); + const tools = createPiTools({ workspace }); await tools.execute({ id: "1", @@ -155,10 +165,10 @@ describe("createPiAITools constrained sampling", () => { }); }); -describe("createPiAITools execution", () => { +describe("createPiTools execution", () => { it("runs a tool call and returns text content for a complete read", async () => { const workspace = makeWorkspace(); - const tools = createPiAITools({ workspace }); + const tools = createPiTools({ workspace }); await tools.execute({ id: "1", @@ -173,7 +183,7 @@ describe("createPiAITools execution", () => { it("returns structured results as JSON text", async () => { const workspace = makeWorkspace(); - const tools = createPiAITools({ workspace }); + 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" } }); @@ -185,7 +195,7 @@ describe("createPiAITools execution", () => { }); it("marks a missing file as an error result", async () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); const result = await tools.execute({ id: "1", @@ -198,7 +208,7 @@ describe("createPiAITools execution", () => { }); it("rejects invalid arguments as a retryable error rather than throwing", async () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); const result = await tools.execute({ id: "1", name: "read", arguments: { path: 42 } }); @@ -207,7 +217,7 @@ describe("createPiAITools execution", () => { }); it("reports an unknown tool name with the available names", async () => { - const tools = createPiAITools({ workspace: makeWorkspace() }); + const tools = createPiTools({ workspace: makeWorkspace() }); const result = await tools.execute({ id: "1", name: "nope", arguments: {} }); @@ -227,7 +237,7 @@ describe("createPiAITools execution", () => { return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; }; (workspace.runtime as unknown as Record).isCallable = () => true; - const tools = createPiAITools({ + const tools = createPiTools({ workspace, shell: { backends: { js: { description: "callable" } }, defaultBackend: "js" }, }); @@ -241,7 +251,7 @@ describe("createPiAITools execution", () => { it("applies a schema default when the model omits the field", async () => { const workspace = makeWorkspace(); - const tools = createPiAITools({ workspace }); + const tools = createPiTools({ workspace }); await tools.execute({ id: "1", @@ -270,7 +280,7 @@ describe("createPiAITools execution", () => { }, } as never, }); - const tools = createPiAITools({ workspace }); + const tools = createPiTools({ workspace }); const result = await tools.execute({ id: "1", @@ -286,7 +296,7 @@ describe("createPiAITools execution", () => { it("returns an image read as a base64 image block", async () => { const workspace = makeWorkspace(); - const tools = createPiAITools({ workspace }); + 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([ diff --git a/packages/computer/src/tools/pi-ai/index.ts b/packages/computer/src/tools/pi-ai/index.ts index 349a665c..8006a6d3 100644 --- a/packages/computer/src/tools/pi-ai/index.ts +++ b/packages/computer/src/tools/pi-ai/index.ts @@ -75,21 +75,20 @@ export interface PiToolResult { isError: boolean; } -export interface CreatePiAIToolsResult { +export interface CreatePiToolsResult { tools: PiTool[]; execute: (call: PiToolCall, context?: ToolCallContext) => Promise; } -export function createPiAITools(options: CreatePiAIToolsOptions): CreatePiAIToolsResult { +export function createPiTools(options: CreatePiToolsOptions): CreatePiToolsResult { const entries = piToolEntries(options); - const nullable = new Map>(); return { - tools: declarations(entries, options, nullable), - execute: dispatcher(entries, nullable), + tools: declarations(entries, options), + execute: dispatcher(entries), }; } -export interface CreatePiAIToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} +export interface CreatePiToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} function piToolEntries(options: CreateToolsOptions): PiToolEntry[] { const resolved = resolveToolOptions(options); @@ -178,24 +177,18 @@ function piToolEntries(options: CreateToolsOptions): PiToolEntry[] { return entries; } -function declarations( - entries: readonly PiToolEntry[], - options: PiDeclarationOptions, - nullable: Map>, -): PiTool[] { +// The schemas stay open. pi closes a schema itself when it sends a +// `constrainedSampling` tool in strict mode, and keeps the open one for +// a provider that falls back to ordinary tool calling. +function declarations(entries: readonly PiToolEntry[], options: PiDeclarationOptions): PiTool[] { const strict = options.constrainedSampling ?? "prefer"; return entries.map((entry) => { - // Strict schemas must close the object: a provider enforcing the - // schema has to know no other properties are allowed. - const wantsStrict = strict !== false && entry.strictArguments === true; - const converted = toPiParameters(entry.inputSchema, wantsStrict); - nullable.set(entry.name, converted.nullable); const tool: PiTool = { name: entry.name, description: entry.description, - parameters: converted.parameters, + parameters: toPiParameters(entry.inputSchema), }; - if (wantsStrict) { + if (strict !== false && entry.strictArguments === true) { tool.constrainedSampling = { type: "json_schema", strict }; } return tool; @@ -218,9 +211,9 @@ export interface PiDeclarationOptions { */ function dispatcher( entries: readonly PiToolEntry[], - nullable: ReadonlyMap>, ): (call: PiToolCall, context?: ToolCallContext) => Promise { const byName = new Map(entries.map((entry) => [entry.name, entry])); + const nullable = new Map(entries.map((entry) => [entry.name, absentWhenNull(entry.inputSchema)])); return async (call, context = {}) => { const entry = byName.get(call.name); if (!entry) { @@ -293,13 +286,9 @@ function toPiResult(output: ModelOutput): PiToolResult { /** * `io: "input"` keeps a field with a Zod `.default()` optional: the - * default is emitted as a JSON Schema `default` for TypeBox to apply. - * The output view would instead mark those fields required. + * default is emitted as a JSON Schema `default`. */ -function toPiParameters( - schema: z.ZodType, - strict = false, -): { parameters: PiJSONSchema; nullable: Set } { +function toPiParameters(schema: z.ZodType): PiJSONSchema { const json = z.toJSONSchema(schema, { target: "draft-7", io: "input", @@ -311,35 +300,27 @@ function toPiParameters( if (json.type !== "object") { throw new Error(`pi tool parameters must be an object schema, got ${String(json.type)}`); } - // A provider enforcing the schema needs the object closed, and - // OpenAI further requires every property in `required`, so an - // optional field becomes required-but-nullable instead. - const nullable = new Set(); - if (strict) { - json.additionalProperties = false; - const properties = (json.properties ?? {}) as Record>; - const names = Object.keys(properties); - const required = new Set((json.required as string[] | undefined) ?? []); - for (const name of names) { - if (required.has(name)) continue; - const property = properties[name]; - const type = property.type; - // Recorded so the dispatcher can tell this null, which means - // "absent", from one the tool genuinely accepts. - if (typeof type === "string" && type !== "null") { - property.type = [type, "null"]; - nullable.add(name); - } - } - json.required = names; + return json as PiJSONSchema; +} + +/** + * The optional fields that do not accept null. Under strict sampling pi + * makes every field required and lets the optional ones be null, so a + * null there means the model left the field out. + */ +function absentWhenNull(schema: z.ZodType): ReadonlySet { + if (!(schema instanceof z.ZodObject)) return EMPTY; + const names = new Set(); + for (const [name, field] of Object.entries(schema.shape as Record)) { + if (field.safeParse(undefined).success && !field.safeParse(null).success) names.add(name); } - return { parameters: json as PiJSONSchema, nullable }; + return names; } /** - * Strips only the nulls that {@link toPiParameters} introduced, named - * by `nullable`. A null on any other field is a value the tool accepts - * — `exec`'s structured `input` is any JSON — and must survive. + * Drops the nulls that stand for an absent field. A null on any other + * field is a value the tool accepts (`exec`'s structured `input` is any + * JSON) and survives. */ function dropPlaceholderNulls(args: unknown, nullable: ReadonlySet): unknown { if (typeof args !== "object" || args === null || Array.isArray(args)) return args; diff --git a/packages/computer/src/tools/pi-ai/model-output.test.ts b/packages/computer/src/tools/pi-ai/model-output.test.ts index b5b171ed..c80a51ad 100644 --- a/packages/computer/src/tools/pi-ai/model-output.test.ts +++ b/packages/computer/src/tools/pi-ai/model-output.test.ts @@ -1,7 +1,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it, vi } from "vitest"; import { Workspace } from "../../workspace.js"; -import { createPiAITools } from "./index.js"; +import { createPiTools } from "./index.js"; // Only read shapes its own model output, so a failing formatter is // simulated by replacing it. @@ -15,10 +15,10 @@ vi.mock("../common/fs/read.js", async (importOriginal) => { }; }); -describe("createPiAITools model output", () => { +describe("createPiTools model output", () => { it("returns a failing formatter as an error result rather than throwing", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage() }); - const tools = createPiAITools({ workspace }); + const tools = createPiTools({ workspace }); await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "hi" } }); const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts index 9f67b70c..7729da94 100644 --- a/packages/computer/src/tools/tanstack-ai/index.test.ts +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { WorkerJavaScriptBackend } from "../../backends/worker-javascript/worker-javascript.js"; import { Workspace } from "../../workspace.js"; -import { createTanStackAITools } from "./index.js"; +import { createTanStackTools } from "./index.js"; function makeWorkspace(): Workspace { return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); @@ -98,9 +98,9 @@ function quietCommandBackend(): { return { backend, killed, release: () => finish?.() }; } -describe("createTanStackAITools", () => { +describe("createTanStackTools", () => { it("returns a list, the shape every TanStack entry point takes", () => { - const tools = createTanStackAITools({ workspace: makeWorkspace() }); + const tools = createTanStackTools({ workspace: makeWorkspace() }); // chat(), mergeAgentTools and createToolRegistry all call array // methods on what they are given, so an array is the contract. @@ -120,8 +120,8 @@ describe("createTanStackAITools", () => { }); it("keys the tools by name when asked", () => { - const tools = createTanStackAITools({ workspace: makeWorkspace() }); - const set = createTanStackAITools({ workspace: makeWorkspace(), format: "object" }); + 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()); @@ -131,13 +131,13 @@ describe("createTanStackAITools", () => { }); it("omits mutating tools when readonly", () => { - const tools = createTanStackAITools({ workspace: makeWorkspace(), readonly: true }); + 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 = createTanStackAITools({ workspace: makeWorkspace(), format: "object" }); + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); const schema = tools.write.inputSchema as unknown as { "~standard": { version: number }; @@ -149,7 +149,7 @@ describe("createTanStackAITools", () => { }); it("flags only the requested tools as needing approval", () => { - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace: makeWorkspace(), approve: ["delete"], format: "object", @@ -160,7 +160,7 @@ describe("createTanStackAITools", () => { }); it("gates every mutating tool from one keyword", () => { - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace: makeWorkspace(), approve: "mutating", format: "object", @@ -176,7 +176,7 @@ describe("createTanStackAITools", () => { }); it("describes output shapes including the error branch", () => { - const tools = createTanStackAITools({ workspace: makeWorkspace(), format: "object" }); + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); const schema = tools.write.outputSchema as unknown as { safeParse: (v: unknown) => { success: boolean }; @@ -194,7 +194,7 @@ describe("createTanStackAITools", () => { workspace.fs.writeFile = async () => { throw new Error("read-only filesystem"); }; - const tools = createTanStackAITools({ workspace, format: "object" }); + const tools = createTanStackTools({ workspace, format: "object" }); const result = (await tools.write.execute({ path: "/workspace/a.txt", @@ -209,7 +209,7 @@ describe("createTanStackAITools", () => { }); it("marks tools lazy so they stay out of the prompt until discovered", () => { - const all = createTanStackAITools({ + const all = createTanStackTools({ workspace: makeWorkspace(), lazy: "all", format: "object", @@ -217,7 +217,7 @@ describe("createTanStackAITools", () => { expect(all.read.lazy).toBe(true); expect(all.write.lazy).toBe(true); - const some = createTanStackAITools({ + const some = createTanStackTools({ workspace: makeWorkspace(), lazy: ["grep"], format: "object", @@ -228,7 +228,7 @@ describe("createTanStackAITools", () => { it("returns plain text for a complete read and objects for structured results", async () => { const workspace = makeWorkspace(); - const tools = createTanStackAITools({ workspace, format: "object" }); + const tools = createTanStackTools({ workspace, format: "object" }); await tools.write.execute({ path: "/w/a.txt", content: "hi\n" } as never); @@ -240,7 +240,7 @@ describe("createTanStackAITools", () => { }); it("returns an error object for a failed call", async () => { - const tools = createTanStackAITools({ workspace: makeWorkspace(), format: "object" }); + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); const result = (await tools.read.execute({ path: "/w/missing.txt" } as never)) as { error: string; @@ -253,7 +253,7 @@ describe("createTanStackAITools", () => { // chat() passes a tool result through as multimodal content only when // it is a ContentPart array; anything else becomes JSON text. const workspace = makeWorkspace(); - const tools = createTanStackAITools({ workspace, format: "object" }); + const tools = createTanStackTools({ workspace, format: "object" }); const png = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, @@ -288,7 +288,7 @@ describe("createTanStackAITools", () => { }, defaultBackend: "shell", }; - const tools = createTanStackAITools({ workspace, shell, format: "object" }); + const tools = createTanStackTools({ workspace, shell, format: "object" }); const schema = z.toJSONSchema(tools.exec.inputSchema) as { properties: Record; required?: string[]; @@ -297,7 +297,7 @@ describe("createTanStackAITools", () => { expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); expect(schema.required ?? []).not.toContain("backend"); expect(schema.properties).toHaveProperty("input"); - expect(createTanStackAITools({ workspace }).map((t) => t.name)).not.toContain("exec"); + expect(createTanStackTools({ workspace }).map((t) => t.name)).not.toContain("exec"); }); it("settles a streaming exec tool on its terminal snapshot", async () => { @@ -312,7 +312,7 @@ describe("createTanStackAITools", () => { ]) as never, ], }); - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace, shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, format: "object", @@ -342,7 +342,7 @@ describe("createTanStackAITools", () => { ]) as never, ], }); - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace, shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, streamEventName: "exec-progress", @@ -366,7 +366,7 @@ describe("createTanStackAITools", () => { storage: new SQLiteTestStorage(), backends: [quiet.backend as never], }); - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace, shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, streamEventName: "exec-progress", @@ -406,7 +406,7 @@ describe("createTanStackAITools", () => { storage: new SQLiteTestStorage(), backends: [quiet.backend as never], }); - const tools = createTanStackAITools({ + const tools = createTanStackTools({ workspace, shell: { backends: { shell: { description: "fast shell" } }, defaultBackend: "shell" }, format: "object", diff --git a/packages/computer/src/tools/tanstack-ai/index.ts b/packages/computer/src/tools/tanstack-ai/index.ts index 532bd06d..186e8ed4 100644 --- a/packages/computer/src/tools/tanstack-ai/index.ts +++ b/packages/computer/src/tools/tanstack-ai/index.ts @@ -77,7 +77,7 @@ export type TanStackToolsFor = Format extends ? TanStackToolSet : TanStackToolList; -export interface CreateTanStackAIToolsOptions +export interface CreateTanStackToolsOptions extends CreateToolsOptions { format?: Format; /** Tools that pause for approval. `"mutating"` selects every tool that changes workspace state. */ @@ -88,8 +88,8 @@ export interface CreateTanStackAIToolsOptions( - options: CreateTanStackAIToolsOptions, +export function createTanStackTools( + options: CreateTanStackToolsOptions, ): TanStackToolsFor { const resolved = resolveToolOptions(options); const workspace = resolved.workspace;