From 290a2d09c5678fb2946556326639f5017d084815 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 11:01:54 -0700 Subject: [PATCH] Give a Bot its computer when nobody is watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The computer tools were registered in the browser with `useFrontendTool`, and every handler was a `fetch` back to `/api/computers/:botId/...` — a round trip into this same process. That made an open tab load-bearing. A Bot whose person had closed the window had no browser, no workspace and no shell, because the only thing that could carry out a tool call had gone away, and an unattended run was out of reach entirely. This repository has already made that move once, for MCP, and `plugins/tools.ts` still says why: it "made a browser a hard requirement for a Bot to do anything, which rules out an embedded widget, a run nobody is watching, and any surface that is not our own app." So twelve of the fourteen move to `computer/tools.ts` and execute here, offered through the same per-run capability seam that already hands a Bot `message_bot` and `ask_person`. Nothing about governance moves with them: every acting tool still goes through ComputerGateway, which resolves the ref against the snapshot this server took, evaluates the policy, writes the audit row, and only then acts. Two deliberately stay in the browser. `computer_request_secret` and `computer_request_help` both end with a person typing into a masked box or taking the wheel, so both need somebody present by definition; moving them would produce a tool a headless run can call and can never have answered. Such a run is not left mute — it still holds `ask_person`. Rendering stays in the browser too, through `useRenderTool`, which draws a call without claiming to execute it. The renderers are unchanged: they already parsed a JSON result, which is what these return. The activity pane had to change with it. It was written from the handlers, "which run exactly once per call", and `render` runs on every paint. So recordActivity now takes the tool call's id and is idempotent on it, and its listeners are notified in a microtask rather than synchronously inside another component's render. --- app/src/lib/computers/activity.ts | 46 ++- app/src/lib/copilot/computer-tools.tsx | 355 ++++---------------- app/tests/computer-activity.test.ts | 69 +++- server/src/computer/tools.ts | 431 +++++++++++++++++++++++++ server/src/index.ts | 43 ++- server/tests/computer-tools.test.ts | 187 +++++++++++ 6 files changed, 828 insertions(+), 303 deletions(-) create mode 100644 server/src/computer/tools.ts create mode 100644 server/tests/computer-tools.test.ts diff --git a/app/src/lib/computers/activity.ts b/app/src/lib/computers/activity.ts index c9535034..0a62d235 100644 --- a/app/src/lib/computers/activity.ts +++ b/app/src/lib/computers/activity.ts @@ -12,8 +12,12 @@ * and it is allowed to be gone when the tab is closed. Keeping it in the browser means no new * endpoint, no polling, and no second copy of command output in the database. * - * Written from the tool handlers, which run exactly once per call. A tool's `render` runs on every - * re-render, so recording from there would append the same command repeatedly. + * Written from the tools' `render`, which is now the only place in the browser that sees a call at + * all: the handlers moved to the server so that a Bot with no tab open still has a computer. + * + * `render` runs on every re-render, so recording from it needs an identity to be idempotent, and the + * tool call has one. Entries are keyed by `toolCallId`; a second record under a key already held is + * dropped. Without that the pane grew a fresh copy of the same command on every paint. */ /** One thing a Bot did on its computer. */ @@ -44,25 +48,44 @@ export type ComputerActivity = { const LIMIT = 200; const byComputer = new Map(); +/** Every tool call already recorded, so a repeated `render` adds nothing. */ +const seen = new Set(); const listeners = new Set<() => void>(); /** A stable empty array, so `useSyncExternalStore` does not see a new value on every render. */ const NONE: ComputerActivity[] = []; -let counter = 0; - +/** + * Record one thing a Bot did, at most once. + * + * `id` is the tool call's own id. It is required rather than generated because the caller is a + * `render` that runs repeatedly for one call, and an id minted here would make every paint a new + * entry. Recording under a key already present is a no-op, including after the entry has aged out + * of the window: `seen` is what remembers, and it is the thing that keeps a long-running Bot's pane + * from redrawing its oldest command as its newest. + */ export function recordActivity( computerId: string, + id: string, entry: Omit, ): void { - counter += 1; + const key = `${computerId}:${id}`; + if (seen.has(key)) return; + seen.add(key); const existing = byComputer.get(computerId) ?? []; - const next = [ - ...existing, - { ...entry, id: `activity-${counter}`, at: Date.now() }, - ]; + const next = [...existing, { ...entry, id, at: Date.now() }]; byComputer.set(computerId, next.slice(-LIMIT)); - for (const listener of listeners) listener(); + /* + * Deferred, because the caller is now a `render`. + * + * Notifying synchronously from inside one component's render sets state in the pane subscribed + * through `useSyncExternalStore`, which React reports as updating a component while rendering a + * different one. The store is already updated by the line above, so the only thing a microtask + * delays is the repaint. + */ + queueMicrotask(() => { + for (const listener of listeners) listener(); + }); } export function activityFor(computerId: string): ComputerActivity[] { @@ -105,5 +128,8 @@ export function subscribeToActivity(listener: () => void): () => void { export function clearActivity(computerId: string): void { byComputer.delete(computerId); browsed.delete(computerId); + for (const key of seen) { + if (key.startsWith(`${computerId}:`)) seen.delete(key); + } for (const listener of listeners) listener(); } diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index 20d9f75d..eb02101b 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,4 +1,4 @@ -import { useFrontendTool } from "@copilotkit/react-core/v2"; +import { useFrontendTool, useRenderTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; import { ToolLine } from "@/components/channels/tool-line"; import { CommandOutput } from "@/components/computer/command-output"; @@ -174,6 +174,12 @@ export function outputOf(result: ToolOutcome): string { /** * Parse the SDK-render result string so the transcript can distinguish success, refusal, and failure. */ +/** How many things a listing came back with, as the pane's one-line summary. */ +function entriesCountFor(outcome: ComputerOutcome): string { + const entries = Array.isArray(outcome.entries) ? outcome.entries : []; + return `${entries.length} item${entries.length === 1 ? "" : "s"}`; +} + function outcomeOf(result: string | undefined): ComputerOutcome { if (!result) return {}; try { @@ -240,60 +246,13 @@ function didNotWork(outcome: ComputerOutcome): boolean { export function ComputerTools() { const bot = useActiveBotHolder(); - useFrontendTool({ + useRenderTool({ name: "computer_navigate", - description: - "Open a web page on your own computer so the person can watch. Use this when asked to look " + - "at, visit, open or check a website. Returns the page title and its readable text, so answer " + - "from what comes back rather than telling the person to go and look.", parameters: z.object({ url: z.string().describe("Full web address to open, including https://"), }), - handler: async ( - { url }: { url: string }, - // Context is optional in the SDK. - { - signal, - toolCall, - }: { signal?: AbortSignal; toolCall?: { id?: string } } = {}, - ) => { - const computerId = bot.current; - const result = await callComputer( - computerId, - "/navigate", - { - method: "POST", - /* - * Which turn is asking, so the server can file the picture under it. - * - * The handler's context carries the tool call, which is worth saying because assuming it - * did not is how the frame ended up keyed on the page instead: two visits to one address - * then collided, and resolving that by letting the newer win made a past turn's picture - * change under the person reading it. - */ - body: { url, ...(toolCall?.id ? { toolCallId: toolCall.id } : {}) }, - }, - signal, - ); - /* - * This Bot has a page of its own now, so the pane may default to the screen. - * - * Until it does, the screen shows whatever the shared computer had open last, which may be - * another Bot's page from an hour ago. Captioning that as this Bot's screen is confidently - * wrong, and worse than showing nothing. - */ - if (result.ok) noteBrowsed(computerId); - return result.ok - ? { - ok: true, - title: result.title, - url: result.url, - text: result.text, - truncated: result.truncated, - } - : result; - }, render: ({ result, status, toolCallId }) => { + if (status === "complete") noteBrowsed(bot.current); /* * The page this turn left open, so reopening the conversation shows what it browsed rather * than what the Bot has open now. Only once the turn is finished: while it runs, the live @@ -339,27 +298,16 @@ export function ComputerTools() { }, }); - useFrontendTool({ + useRenderTool({ name: "computer_read", - description: - "Read the page currently open on your computer, without opening anything. Use this after you " + - "click something that changes the page, such as submitting a form, to find out what it now says.", parameters: z.object({}), - handler: async () => callComputer(bot.current, "/read"), - render: () => null, + // Nothing to draw: a fragment rather than null, which this hook's signature refuses. + render: () => <>, }); - useFrontendTool({ + useRenderTool({ name: "computer_snapshot", - description: - "List the things on the current page you can act on: fields, buttons, links and checkboxes, " + - "each with a ref, its label and its current value. Call this BEFORE clicking or typing, and " + - "use the refs it returns. Always send back the snapshotId it gives you. If an action reports " + - "that your refs are stale, the page changed: call this again and use the new refs.", parameters: z.object({}), - handler: async () => - callComputer(bot.current, "/snapshot", { method: "POST" }), - // Snapshot renders a count only; navigate owns the screen view. render: ({ result, status }) => { const outcome = outcomeOf(result); const elements = Array.isArray(outcome.elements) ? outcome.elements : []; @@ -377,12 +325,8 @@ export function ComputerTools() { }, }); - useFrontendTool({ + useRenderTool({ name: "computer_type", - description: - "Enter text into a field on the page. Give the ref of the field from your most recent " + - "snapshot and the snapshotId it came from. This replaces whatever the field already contains. " + - "Set submit to true to press Enter afterwards.", parameters: z.object({ ref: z .string() @@ -394,25 +338,7 @@ export function ComputerTools() { .optional() .describe("Press Enter after typing, to submit a single-field form"), }), - handler: async ( - input: { - ref: string; - snapshotId: number; - text: string; - submit?: boolean; - }, - { signal }: { signal?: AbortSignal } = {}, - ) => - callComputer( - bot.current, - "/type", - { - method: "POST", - body: input, - }, - signal, - ), - render: ({ args, result, status }) => ( + render: ({ parameters: args, result, status }) => ( - callComputer( - bot.current, - "/click", - { - method: "POST", - body: input, - }, - signal, - ), - render: ({ args, result, status }) => { + render: ({ parameters: args, result, status }) => { const outcome = outcomeOf(result); return ( - callComputer( - bot.current, - "/key", - { - method: "POST", - body: input, - }, - signal, - ), - render: ({ args, result, status }) => ( + render: ({ parameters: args, result, status }) => ( { - try { - const response = await tryClient( - `/api/agents/${encodeURIComponent(bot.current)}/declined`, - { method: "POST", body: input, signal }, - ); - return response.ok - ? "Recorded. Now tell the person what you decided and why." - : "That could not be recorded. Tell the person what you decided anyway."; - } catch { - // Audit bookkeeping must not prevent the Bot from answering. - return "That could not be recorded. Tell the person what you decided anyway."; - } - }, - render: () => null, + // Nothing to draw: a fragment rather than null, which this hook's signature refuses. + render: () => <>, }); useFrontendTool({ @@ -663,34 +531,24 @@ export function ComputerTools() { render: () => null, }); - useFrontendTool({ + useRenderTool({ name: "computer_list_files", - description: - "List what is in your workspace: every file and folder you have saved, with sizes. Call this " + - "FIRST when you are asked what files you have, or before reading a file whose exact name you " + - "are not sure of. Never guess a filename.", parameters: z.object({ path: z .string() .optional() .describe("Optional folder to list. Omit for the whole workspace."), }), - handler: async (input: { path?: string }) => { - const computerId = bot.current; - const result = await callComputer(computerId, "/files/list", { - method: "POST", - body: input ?? {}, - }); - recordActivity(computerId, { - kind: "list_files", - subject: input?.path ?? "the workspace", - output: outputOf(result), - ...(result.refused === true ? { refused: true } : {}), - }); - return result; - }, - render: ({ result, status }) => { + render: ({ result, status, toolCallId }) => { const outcome = outcomeOf(result); + if (status === "complete") { + recordActivity(bot.current, toolCallId, { + kind: "list_files", + subject: "the workspace", + output: `${entriesCountFor(outcome)}`, + ...(outcome.refused === true ? { refused: true } : {}), + }); + } const entries = Array.isArray(outcome.entries) ? outcome.entries : []; return ( { - const computerId = bot.current; - const result = await callComputer(computerId, "/files/read", { - method: "POST", - body: input, - }); - recordActivity(computerId, { - kind: "read_file", - subject: input.path, - output: outputOf(result), - ...(result.refused === true ? { refused: true } : {}), - }); - return result; - }, - render: ({ args, result, status }) => { + render: ({ parameters: args, result, status, toolCallId }) => { const outcome = outcomeOf(result); + if (status === "complete") { + recordActivity(bot.current, toolCallId, { + kind: "read_file", + subject: typeof args?.path === "string" ? args.path : "a file", + output: typeof outcome.text === "string" ? outcome.text : "", + ...(outcome.refused === true ? { refused: true } : {}), + ...(outcome.truncated === true ? { truncated: true } : {}), + }); + } return ( `. If sudo is refused, this " + - "computer does not grant it, so say so rather than retrying.", parameters: z.object({ command: z .string() .describe("The command to run, such as: sudo apt-get install -y jq"), }), - handler: async ( - input: { command: string }, - { signal }: { signal?: AbortSignal } = {}, - ) => { - const computerId = bot.current; - const result = await callComputer( - computerId, - "/exec", - { method: "POST", body: input }, - signal, - ); - /* - * Recorded here rather than in `render`, which runs again on every re-render and would append - * the same command each time. This is the only place that runs once per call and has both the - * command and what it printed. - */ - recordActivity(computerId, { - kind: "command", - subject: input.command, - output: outputOf(result), - ...(typeof result.exitCode === "number" - ? { exitCode: result.exitCode } - : {}), - ...(result.refused === true ? { refused: true } : {}), - ...(result.truncated === true ? { truncated: true } : {}), - ...(result.timedOut === true ? { timedOut: true } : {}), - }); - return result; - }, - render: ({ args, result, status }) => { + render: ({ parameters: args, result, status, toolCallId }) => { const outcome = outcomeOf(result); + if (status === "complete") { + recordActivity(bot.current, toolCallId, { + kind: "command", + subject: typeof args?.command === "string" ? args.command : "", + output: outputOf(outcome as ToolOutcome), + ...(typeof outcome.exitCode === "number" + ? { exitCode: outcome.exitCode } + : {}), + ...(outcome.refused === true ? { refused: true } : {}), + ...(outcome.truncated === true ? { truncated: true } : {}), + ...(outcome.timedOut === true ? { timedOut: true } : {}), + }); + } /* * The command on the line, its output behind the chevron. * @@ -840,12 +663,8 @@ export function ComputerTools() { }, }); - useFrontendTool({ + useRenderTool({ name: "computer_write_file", - description: - "Save a file in your own workspace so you still have it later. Paths are relative to your " + - "workspace and folders are created as needed. Set append to true to add to the end of an " + - "existing file rather than replacing it. Text only.", parameters: z.object({ path: z .string() @@ -858,36 +677,17 @@ export function ComputerTools() { .optional() .describe("Add to the end of the file instead of replacing it"), }), - handler: async (input: { - path: string; - contents: string; - append?: boolean; - }) => { - const computerId = bot.current; - const result = await callComputer(computerId, "/files/write", { - method: "POST", - body: input, - }); - /* - * The path and the size, never the contents. A Bot may well be saving something it was told in - * confidence, and the write route declines to echo it back for exactly that reason; putting it - * in a pane would undo that. - */ - recordActivity(computerId, { - kind: "write_file", - subject: input.path, - output: - result.refused === true - ? outputOf(result) - : typeof result.bytes === "number" - ? `${result.bytes} bytes${input.append === true ? ", appended" : ""}` - : "", - ...(result.refused === true ? { refused: true } : {}), - }); - return result; - }, - render: ({ args, result, status }) => { + render: ({ parameters: args, result, status, toolCallId }) => { const outcome = outcomeOf(result); + if (status === "complete") { + recordActivity(bot.current, toolCallId, { + kind: "write_file", + subject: typeof args?.path === "string" ? args.path : "a file", + // Never the contents: this pane is on screen beside the browser holding somebody's logins. + output: "", + ...(outcome.refused === true ? { refused: true } : {}), + }); + } return ( - callComputer( - bot.current, - "/scroll", - { - method: "POST", - body: input, - }, - signal, - ), render: ({ result, status }) => ( { }); test("keeps what a Bot ran, in the order it ran it", () => { - recordActivity("bot-1", { kind: "command", subject: "ls", output: "a\nb" }); - recordActivity("bot-1", { + recordActivity("bot-1", "call-1", { + kind: "command", + subject: "ls", + output: "a\nb", + }); + recordActivity("bot-1", "call-2", { kind: "command", subject: "pwd", output: "/workspace", @@ -96,7 +100,11 @@ describe("the activity a pane shows", () => { }); test("keeps one Bot's work out of another's", () => { - recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + recordActivity("bot-1", "call-3", { + kind: "command", + subject: "ls", + output: "", + }); expect(activityFor("bot-2")).toEqual([]); }); @@ -108,7 +116,11 @@ describe("the activity a pane shows", () => { test("wiping a computer forgets what ran on it", () => { // Reset deletes the machine those commands ran on, so leaving them on screen would describe // something that no longer exists. - recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + recordActivity("bot-1", "call-4", { + kind: "command", + subject: "ls", + output: "", + }); clearActivity("bot-1"); expect(activityFor("bot-1")).toEqual([]); @@ -116,7 +128,7 @@ describe("the activity a pane shows", () => { test("stops growing, because this is a pane and not an archive", () => { for (let index = 0; index < 250; index += 1) { - recordActivity("bot-1", { + recordActivity("bot-1", `call-${index}`, { kind: "command", subject: `command-${index}`, output: "", @@ -131,10 +143,53 @@ describe("the activity a pane shows", () => { }); test("every entry is distinguishable, so two identical commands both show", () => { - recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); - recordActivity("bot-1", { kind: "command", subject: "ls", output: "" }); + recordActivity("bot-1", "call-6", { + kind: "command", + subject: "ls", + output: "", + }); + recordActivity("bot-1", "call-7", { + kind: "command", + subject: "ls", + output: "", + }); const [first, second] = activityFor("bot-1"); expect(first?.id).not.toBe(second?.id); }); + + test("one call is one entry, however many times a render records it", () => { + /* + * The property the whole keyed signature exists for. The handlers moved to the server, so the + * only place the browser still sees a call is `render` — which runs on every paint. Before the + * key, a Bot that ran one command grew a fresh copy of it in the pane on each one. + */ + for (let paint = 0; paint < 5; paint += 1) { + recordActivity("bot-1", "one-call", { + kind: "command", + subject: "bun test", + output: "ok", + }); + } + + expect(activityFor("bot-1")).toHaveLength(1); + }); + + test("a wiped computer can record the same call id again", () => { + // Reset gives the Bot a new machine. The key must not outlive the thing it was about, or the + // first command on the new computer is silently dropped as a duplicate of one on the old. + recordActivity("bot-1", "call-8", { + kind: "command", + subject: "ls", + output: "", + }); + clearActivity("bot-1"); + recordActivity("bot-1", "call-8", { + kind: "command", + subject: "ls", + output: "", + }); + + expect(activityFor("bot-1")).toHaveLength(1); + }); }); diff --git a/server/src/computer/tools.ts b/server/src/computer/tools.ts new file mode 100644 index 00000000..15bfa385 --- /dev/null +++ b/server/src/computer/tools.ts @@ -0,0 +1,431 @@ +/** + * The computer tools, declared once and executed on the server. + * + * WHAT THIS CHANGES. These were registered in the browser with `useFrontendTool`, and every handler + * was a `fetch` back to `/api/computers/:botId/...` — a round trip into this same process. That made + * an open tab load-bearing. A Bot whose person had closed the window had no browser, no workspace + * and no shell, because the only thing that could carry out a tool call had gone away, and an + * unattended run was out of reach entirely. + * + * The repository has already made this move once, for a different tool family, and said why: + * + * > The loop used to run in the browser: every MCP tool was registered with `useFrontendTool` and + * > its handler posted back to `/api/plugins/call`. That made a browser a hard requirement for a Bot + * > to do anything, which rules out an embedded widget, a run nobody is watching, and any surface + * > that is not our own app. — `plugins/tools.ts` + * + * NOTHING ABOUT GOVERNANCE MOVES WITH IT, for the same reason it did not move for MCP. Every acting + * tool still goes through `ComputerGateway`, which resolves the ref against the snapshot this server + * took, evaluates the policy, writes the audit row, and only then acts. This module hands the model a + * description of what it may call; the gateway remains what decides whether a call happens. + * + * WHAT DELIBERATELY STAYS IN THE BROWSER IS RENDERING. The transcript still draws these calls, and + * still names the element the gateway resolved rather than the ref the model sent, because the + * result shape below is the one those renderers already parse. They register with + * `useRenderToolCall`, which draws a call without claiming to execute it. + * + * WHAT ALSO STAYS IN THE BROWSER, AND WHY. `computer_request_secret` and `computer_request_help` are + * not here. Both end with a person typing into a masked box or taking the wheel, so both need + * somebody present by definition; moving them would produce a tool that a headless run can call and + * can never have answered. A run with nobody watching is not left mute by their absence — it still + * holds `ask_person`, which is the honest exit for a Bot that needs a human and has no browser + * attached to reach one through. + * + * A `GrantedTool` rather than a new shape, because that is already this codebase's word for "a tool + * the model may call, executed here". `escalation.ts` and `handoff-tool.ts` build them without going + * anywhere near a plugin grant, so the type is the tool interface rather than the plugin interface. + */ + +import { z } from "zod"; +import type { GrantedTool } from "../plugins/tools"; +import { + ComputerUnavailableError, + ElementNotFoundError, + NavigationRefusedError, + StaleSnapshotError, + WorkspaceRefusedError, + WorkspaceRequestError, +} from "./client"; +import { + type ActionActor, + ActionRefusedError, + type ComputerGateway, +} from "./gateway"; + +export type ComputerToolsContext = { + gateway: ComputerGateway; + /** The Bot whose computer this is. The gateway addresses the computer by the same id. */ + botId: string; + /** Whose authorization the call carries. The audit row names them, not the Bot alone. */ + actor: ActionActor; + /** + * Records a Bot's self-reported refusal. + * + * Absent leaves `report_refusal` recording nothing, which is the correct behaviour for a + * deployment with no audit store rather than a reason to withhold the tool: a Bot that has + * declined something should still be able to say so in the transcript. + */ + recordRefusal?: (input: { + botId: string; + actor: ActionActor; + reason: string; + request?: string; + }) => Promise; +}; + +/** + * What a tool answers with. + * + * JSON rather than prose, because two readers consume it: the model, which does better with named + * fields than with a sentence it has to parse, and the transcript renderer in the browser, which + * already reads exactly this shape. `ok` is first because the one thing both readers must not have + * to infer is whether the thing happened. + */ +export type ToolOutcome = Record & { ok: boolean }; + +const answer = (outcome: ToolOutcome) => JSON.stringify(outcome); + +/** + * Every way a computer call can fail, as something a model can act on. + * + * WHY EACH IS SEPARATE. A model handed "an error occurred" retries the identical call, and a model + * handed "your refs are stale" takes a fresh snapshot. The distinction between a refusal it must + * not retry, a stale reference it should re-read, and a computer that is simply not there is the + * difference between a Bot that recovers and one that loops until its step cap. + * + * A refusal returns the rule's own words. "The agent declined" is exactly the sentence this + * product exists to replace: an operator must be able to see which rule said no. + */ +function outcomeForError(error: unknown): ToolOutcome { + if (error instanceof ActionRefusedError) { + return { + ok: false, + refused: true, + reason: error.message, + ...(error.rule ? { rule: error.rule } : {}), + }; + } + if (error instanceof StaleSnapshotError) { + return { + ok: false, + stale: true, + reason: + "The page changed since that snapshot. Call computer_snapshot again and use the new refs.", + }; + } + if (error instanceof ElementNotFoundError) { + return { + ok: false, + reason: + "Nothing on the page matches that ref. Take a fresh snapshot and use a ref from it.", + }; + } + if (error instanceof NavigationRefusedError) { + return { ok: false, refused: true, reason: error.message }; + } + if (error instanceof ComputerUnavailableError) { + return { + ok: false, + unavailable: true, + reason: + "Your computer is not available right now, so nothing was done. Say so rather than retrying.", + }; + } + if ( + error instanceof WorkspaceRefusedError || + error instanceof WorkspaceRequestError + ) { + return { ok: false, refused: true, reason: error.message }; + } + return { + ok: false, + reason: error instanceof Error ? error.message : "That did not work.", + }; +} + +/** Run one gateway call, turning every failure into an outcome the model can read. */ +async function attempt(work: () => Promise): Promise { + try { + const result = (await work()) as Record | undefined; + return answer({ ok: true, ...(result ?? {}) }); + } catch (error) { + return answer(outcomeForError(error)); + } +} + +/** + * One tool, with its arguments validated before the gateway is touched. + * + * Validated here rather than trusted, because these arguments are model output: a `ref` that is a + * number and a `snapshotId` that is a string are both things a model does, and the gateway would + * refuse them somewhere deeper with a message written for a developer rather than for the model + * that has to correct itself. + */ +function tool( + name: string, + description: string, + parameters: Schema, + run: (args: z.infer) => Promise, +): GrantedTool { + return { + name, + ref: `computer/${name}`, + description, + parameters, + execute: async (args: unknown) => { + const parsed = parameters.safeParse(args ?? {}); + if (!parsed.success) { + return answer({ + ok: false, + reason: `Those arguments are not right for ${name}: ${parsed.error.issues + .map( + (issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`, + ) + .join("; ")}`, + }); + } + return attempt(() => run(parsed.data)); + }, + }; +} + +const empty = z.object({}); + +/** + * The tools a Bot with a computer is offered. + * + * Built per run and per person rather than once at boot, because the actor is what the audit row + * names and the Bot is which computer gets driven. A module-level list would have to take both on + * every call, which is the same thing written less safely. + */ +export function computerTools(context: ComputerToolsContext): GrantedTool[] { + const { gateway, botId, actor } = context; + + return [ + tool( + "computer_navigate", + "Open a web page on your own computer so the person can watch. Use this when asked to look " + + "at, visit, open or check a website. Returns the page title and its readable text, so answer " + + "from what comes back rather than telling the person to go and look.", + z.object({ + url: z + .string() + .describe("Full web address to open, including https://"), + }), + (args) => gateway.navigate(botId, actor, args.url), + ), + + tool( + "computer_read", + "Read the page currently open on your computer, without opening anything. Use this after you " + + "click something that changes the page, such as submitting a form, to find out what it now says.", + empty, + () => gateway.read(botId), + ), + + tool( + "computer_snapshot", + "List the things on the current page you can act on: fields, buttons, links and checkboxes, " + + "each with a ref, its label and its current value. Call this BEFORE clicking or typing, and " + + "use the refs it returns. Always send back the snapshotId it gives you. If an action reports " + + "that your refs are stale, the page changed: call this again and use the new refs.", + empty, + () => gateway.snapshot(botId), + ), + + tool( + "computer_type", + "Enter text into a field on the page. Give the ref of the field from your most recent " + + "snapshot and the snapshotId it came from. This replaces whatever the field already contains. " + + "Set submit to true to press Enter afterwards.", + z.object({ + ref: z + .string() + .describe("Ref of the field, from your most recent snapshot"), + snapshotId: z.number().describe("The snapshotId that ref came from"), + text: z.string().describe("The text to enter"), + submit: z + .boolean() + .optional() + .describe("Press Enter after typing, to submit a single-field form"), + }), + (args) => + gateway.type(botId, actor, { + ref: args.ref, + snapshotId: args.snapshotId, + text: args.text, + ...(args.submit === undefined ? {} : { submit: args.submit }), + }), + ), + + tool( + "computer_click", + "Click something on the page: a button, a link, a checkbox or a radio option. Give the ref " + + "from your most recent snapshot and the snapshotId it came from.", + z.object({ + ref: z + .string() + .describe( + "Ref of the element to click, from your most recent snapshot", + ), + snapshotId: z.number().describe("The snapshotId that ref came from"), + }), + (args) => + gateway.click(botId, actor, { + ref: args.ref, + snapshotId: args.snapshotId, + }), + ), + + tool( + "computer_key", + "Press a key, such as Enter, Tab or Escape. Give a ref to press it while a particular field " + + "is focused, or omit the ref to press it on the page.", + z.object({ + key: z.string().describe("Key name, such as Enter, Tab or Escape"), + ref: z.string().optional().describe("Optional ref to press the key on"), + snapshotId: z + .number() + .optional() + .describe( + "The snapshotId the ref came from, required if ref is given", + ), + }), + (args) => + gateway.key(botId, actor, { + key: args.key, + ...(args.ref === undefined ? {} : { ref: args.ref }), + ...(args.snapshotId === undefined + ? {} + : { snapshotId: args.snapshotId }), + }), + ), + + tool( + "computer_scroll", + "Scroll the page down, or up with a negative amount, to bring more of a long page into view.", + z.object({ + deltaY: z + .number() + .optional() + .describe("Pixels to scroll; positive is down. Defaults to 600."), + }), + (args) => + gateway.scroll( + botId, + actor, + args.deltaY === undefined ? {} : { deltaY: args.deltaY }, + ), + ), + + tool( + "computer_list_files", + "List what is in your workspace: every file and folder you have saved, with sizes. Call this " + + "FIRST when you are asked what files you have, or before reading a file whose exact name you " + + "are not sure of. Never guess a filename.", + z.object({ + path: z + .string() + .optional() + .describe("Optional folder to list. Omit for the whole workspace."), + }), + (args) => + gateway.listFiles( + botId, + actor, + args.path === undefined ? {} : { path: args.path }, + ), + ), + + tool( + "computer_read_file", + "Read a file you saved earlier in your own workspace. Paths are relative to your workspace, " + + "such as notes.md or reports/august.csv. Your workspace survives between conversations, so use " + + "this to pick up notes you made before.", + z.object({ + path: z + .string() + .describe("Path relative to your workspace, such as notes.md"), + }), + (args) => gateway.readFile(botId, actor, { path: args.path }), + ), + + tool( + "computer_write_file", + "Save a file in your own workspace so you still have it later. Paths are relative to your " + + "workspace and folders are created as needed. Set append to true to add to the end of an " + + "existing file rather than replacing it. Text only.", + z.object({ + path: z + .string() + .describe( + "Path relative to your workspace, such as reports/august.csv", + ), + contents: z.string().describe("The text to save"), + append: z + .boolean() + .optional() + .describe("Add to the end of the file instead of replacing it"), + }), + (args) => + gateway.writeFile(botId, actor, { + path: args.path, + contents: args.contents, + ...(args.append === undefined ? {} : { append: args.append }), + }), + ), + + tool( + "computer_run_command", + "Run a shell command on your own computer. Use this for anything the browser cannot do: " + + "installing a tool you need, processing a file you saved, running a script. The working " + + "directory is your workspace, so paths are relative to it and files you write here are the " + + "same ones the file tools see. Commands run in bash, so pipes and && work. Long output is " + + "truncated from the start, and a command that runs too long is stopped. " + + "You are not the root user, so anything that writes outside your workspace needs sudo, " + + "which asks for no password: installing a package is " + + "`sudo apt-get update && sudo apt-get install -y `. If sudo is refused, this " + + "computer does not grant it, so say so rather than retrying.", + z.object({ + command: z + .string() + .describe("The command to run, such as: sudo apt-get install -y jq"), + }), + (args) => gateway.runCommand(botId, actor, { command: args.command }), + ), + + /* + * Not a computer call at all, and here because it belongs to the same run. + * + * A Bot that declines something has made a decision an administrator wants to see, and the only + * place that decision exists is the model's own sentence. Recording nothing when no recorder is + * configured, rather than withholding the tool: a Bot should always be able to say it said no. + */ + tool( + "report_refusal", + "Record that you DECLINED something you were asked to do, because it looked unsafe, was outside " + + "what you are for, or you judged you should not. Call this whenever you say no to a request, in " + + "addition to telling the person. It changes nothing about your answer; it exists so an " + + "administrator can see what this Bot is being asked to do. Do not call it when you simply could " + + "not do something, only when you chose not to.", + z.object({ + reason: z + .string() + .describe("Why you declined, in one sentence and in your own words"), + request: z + .string() + .optional() + .describe("What you were asked to do, in a few words"), + }), + async (args) => { + await context.recordRefusal?.({ + botId, + actor, + reason: args.reason, + ...(args.request === undefined ? {} : { request: args.request }), + }); + return { recorded: true }; + }, + ), + ]; +} diff --git a/server/src/index.ts b/server/src/index.ts index a3b18ef6..19af4b85 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -34,6 +34,7 @@ import { createThreadIdentity } from "./channels/thread-identity"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerGateway } from "./computer/gateway"; +import { computerTools } from "./computer/tools"; import { createPageFrameStore } from "./computer/page-frames"; import { startPolicyListener } from "./computer/policy-listener"; import { @@ -802,7 +803,47 @@ const copilotRuntime = mountCopilotRuntime( route: askTheirOwnPerson, auditStore: bootAuditStore, }); - return passing ? [passing, asking] : [asking]; + + /* + * The Bot's own computer, as tools this process runs. + * + * Here rather than in the browser because this is the only place that holds the gateway, the + * actor and the Bot at once — and because a run with nobody watching has to be able to reach a + * computer at all. See `computer/tools.ts` for what moved and what deliberately did not. + * + * Absent when the deployment configured no computer provider, which is the correct answer: + * offering a model a browser that does not exist buys a run of confident failures. + */ + /* + * Only a real `users` row may go in the audit table's foreign key column, so the local + * development actor carries an id and no `userId`. Writing it there fails the constraint and + * loses the row entirely, which is the one outcome worse than an unattributed one. The same rule + * the HTTP path applies in `computer/routes.ts`. + */ + const isRealUser = actorId !== DEV_ACTOR.id; + const driving = computerGateway + ? computerTools({ + gateway: computerGateway, + botId, + actor: { id: actorId, ...(isRealUser ? { userId: actorId } : {}) }, + recordRefusal: async ({ botId: declining, reason, request }) => { + await recordAuditEvent(bootAuditStore, { + eventType: "bot.declined", + targetType: "agent", + targetId: declining, + ...(isRealUser ? { actorUserId: actorId } : {}), + payload: { + bot: declining, + reason: reason.slice(0, 500), + ...(request ? { request: request.slice(0, 500) } : {}), + reportedBy: "the Bot itself", + }, + }); + }, + }) + : []; + + return [...(passing ? [passing] : []), asking, ...driving]; }, ); diff --git a/server/tests/computer-tools.test.ts b/server/tests/computer-tools.test.ts new file mode 100644 index 00000000..7edee764 --- /dev/null +++ b/server/tests/computer-tools.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; +import { + ComputerUnavailableError, + StaleSnapshotError, +} from "../src/computer/client"; +import { + ActionRefusedError, + type ComputerGateway, +} from "../src/computer/gateway"; +import { computerTools } from "../src/computer/tools"; + +/** + * What the server-side computer tools must guarantee. + * + * These moved out of the browser, and the properties worth testing are the ones a green typecheck + * cannot see: + * - the actor and the Bot reach the gateway, because the audit row is written from them + * - a refusal comes back as an ANSWER carrying the rule, not as a thrown error that ends the run + * - the failure modes stay distinguishable, because a model handed one sentence for all of them + * retries the identical call until its step cap + * - arguments a model got wrong are refused here, with a sentence the model can correct itself from + */ + +const ACTOR = { id: "user-1", userId: "user-1" }; +const BOT = "bot-1"; + +type Call = { method: string; botId: string; actor: unknown; input: unknown }; + +/** A gateway that records what reached it, and can be told to fail one way or another. */ +function fakeGateway(fail?: unknown) { + const calls: Call[] = []; + const record = + (method: string) => + async (botId: string, actor: unknown, input: unknown) => { + calls.push({ method, botId, actor, input }); + if (fail) throw fail; + return { did: method }; + }; + + const gateway = { + navigate: (botId: string, actor: unknown, url: string) => + record("navigate")(botId, actor, { url }), + click: record("click"), + type: record("type"), + key: record("key"), + scroll: record("scroll"), + readFile: record("readFile"), + listFiles: record("listFiles"), + writeFile: record("writeFile"), + runCommand: record("runCommand"), + read: async (botId: string) => { + calls.push({ method: "read", botId, actor: null, input: null }); + if (fail) throw fail; + return { title: "A page" }; + }, + snapshot: async (botId: string) => { + calls.push({ method: "snapshot", botId, actor: null, input: null }); + if (fail) throw fail; + return { snapshotId: 3 }; + }, + } as unknown as ComputerGateway; + + return { gateway, calls }; +} + +const toolsFor = (gateway: ComputerGateway, extra?: Record) => + new Map( + computerTools({ gateway, botId: BOT, actor: ACTOR, ...extra }).map( + (tool) => [tool.name, tool], + ), + ); + +const parse = (raw: string) => JSON.parse(raw) as Record; + +describe("the computer tools, executed here", () => { + test("every acting tool goes through the gateway, carrying the Bot and the actor", async () => { + const { gateway, calls } = fakeGateway(); + const tools = toolsFor(gateway); + + await tools.get("computer_run_command")?.execute({ command: "ls" }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe("runCommand"); + expect(calls[0]?.botId).toBe(BOT); + // The audit row is written from this. A tool that dropped it would leave an unattributed trail. + expect(calls[0]?.actor).toEqual(ACTOR); + expect(calls[0]?.input).toEqual({ command: "ls" }); + }); + + test("a refusal is an answer carrying the rule, not a thrown error", async () => { + const { gateway } = fakeGateway( + new ActionRefusedError("No shell on this deployment.", "no-shell"), + ); + const tools = toolsFor(gateway); + + const outcome = parse( + (await tools.get("computer_run_command")?.execute({ command: "ls" })) ?? + "{}", + ); + + expect(outcome.ok).toBe(false); + expect(outcome.refused).toBe(true); + // The rule's own words. "The agent declined" is the sentence this product exists to replace. + expect(outcome.reason).toBe("No shell on this deployment."); + expect(outcome.rule).toBe("no-shell"); + }); + + test("the failure modes stay apart, so a model can tell a retry from a dead end", async () => { + const stale = parse( + (await toolsFor(fakeGateway(new StaleSnapshotError("gone")).gateway) + .get("computer_click") + ?.execute({ ref: "e1", snapshotId: 1 })) ?? "{}", + ); + expect(stale.stale).toBe(true); + expect(String(stale.reason)).toContain("computer_snapshot"); + + const gone = parse( + (await toolsFor(fakeGateway(new ComputerUnavailableError("off")).gateway) + .get("computer_read") + ?.execute({})) ?? "{}", + ); + expect(gone.unavailable).toBe(true); + expect(gone.stale).toBeUndefined(); + }); + + test("arguments a model got wrong are refused before the gateway is touched", async () => { + const { gateway, calls } = fakeGateway(); + const tools = toolsFor(gateway); + + const outcome = parse( + // snapshotId as a string is a thing models do, and the gateway would refuse it much deeper. + (await tools + .get("computer_click") + ?.execute({ ref: "e1", snapshotId: "1" })) ?? "{}", + ); + + expect(outcome.ok).toBe(false); + expect(String(outcome.reason)).toContain("computer_click"); + expect(calls).toHaveLength(0); + }); + + test("optional arguments are omitted rather than sent as undefined", async () => { + const { gateway, calls } = fakeGateway(); + const tools = toolsFor(gateway); + + await tools.get("computer_key")?.execute({ key: "Enter" }); + + // `exactOptionalPropertyTypes` is on in this codebase, and a literal `undefined` on the wire is + // a different thing from an absent field to the computer reading it. + expect(calls[0]?.input).toEqual({ key: "Enter" }); + }); + + test("report_refusal records, and still answers when nothing is recording", async () => { + const recorded: unknown[] = []; + const { gateway } = fakeGateway(); + + const withStore = toolsFor(gateway, { + recordRefusal: async (input: unknown) => { + recorded.push(input); + }, + }); + const answered = parse( + (await withStore + .get("report_refusal") + ?.execute({ reason: "It looked unsafe.", request: "delete prod" })) ?? + "{}", + ); + expect(answered.ok).toBe(true); + expect(recorded).toHaveLength(1); + + // A deployment with no audit store must not lose the Bot's ability to say it declined. + const without = toolsFor(gateway); + const still = parse( + (await without.get("report_refusal")?.execute({ reason: "No." })) ?? "{}", + ); + expect(still.ok).toBe(true); + }); + + test("the two tools that need a person present are not offered here", async () => { + const names = new Set(toolsFor(fakeGateway().gateway).keys()); + // Both end with somebody typing into a masked box or taking the wheel. Offering them to a run + // with nobody watching would be offering a call that can never be answered. + expect(names.has("computer_request_secret")).toBe(false); + expect(names.has("computer_request_help")).toBe(false); + expect(names.has("computer_run_command")).toBe(true); + }); +});