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); + }); +});