diff --git a/packages/core/src/conversations/threads/message-text.ts b/packages/core/src/conversations/threads/message-text.ts index 0c89f18b..174bb4a6 100644 --- a/packages/core/src/conversations/threads/message-text.ts +++ b/packages/core/src/conversations/threads/message-text.ts @@ -8,6 +8,7 @@ import { } from "@sugabots/contracts"; import { Option, Schema } from "effect"; import { READ_THREAD_FILE_TOOL } from "../tools/read-thread-file/tool.ts"; +import { COPY_FILE_TO_SANDBOX_TOOL } from "../tools/sandbox/tools.ts"; import { TOOL_SEARCH } from "../tools/tool-search/tool.ts"; /** The tool an agent searches its thread's older history with. */ @@ -32,7 +33,7 @@ export function messageTextWithPlacedParts(message: Message): string { .map((part) => { if (part.type === "text") return part.text; if (part.type === "collaboration") return describeCollaboration(part); - if (part.type === "file") return describeAttachment(part); + if (part.type === "file") return describeAttachment(part, { usesSandbox: false }); return describeToolCall(part); }) .join("\n\n") @@ -74,9 +75,18 @@ export function describeToolCall(call: ToolCallPart, resultFile?: string): strin /** * A file attached to a message as one bracketed line: the agent is given what * it is and how to read it, not its content, which may be large or not text. + * An agent that `usesSandbox` is also told it can copy the file into the + * sandbox. That depends on the agent, not on whether its sandbox is up, so + * the line stays the same in the history its later requests repeat. */ -export function describeAttachment(file: FilePart): string { - return `[Attached ${file.name} (${file.contentType}, ${sizeOf(file.size)}) as file ${file.id}, which ${READ_THREAD_FILE_TOOL} reads]`; +export function describeAttachment( + file: FilePart, + { usesSandbox }: { usesSandbox: boolean }, +): string { + const reads = usesSandbox + ? `which ${READ_THREAD_FILE_TOOL} reads, or ${COPY_FILE_TO_SANDBOX_TOOL} copies` + : `which ${READ_THREAD_FILE_TOOL} reads`; + return `[Attached ${file.name} (${file.contentType}, ${sizeOf(file.size)}) as file ${file.id}, ${reads}]`; } /** A file's size as people write it, such as 2.1 MB. */ diff --git a/packages/core/src/conversations/threads/threads.test.ts b/packages/core/src/conversations/threads/threads.test.ts index 0b26c14e..07112cb5 100644 --- a/packages/core/src/conversations/threads/threads.test.ts +++ b/packages/core/src/conversations/threads/threads.test.ts @@ -1011,6 +1011,7 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () const search = searchHistoryTool({ threadId: details.thread.id, before: prepared.keptFrom, + usesSandbox: false, run: runOnPostgres, }); const searchFor = (input: { query?: string; after?: string; before?: string }) => @@ -1168,6 +1169,7 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () const search = searchHistoryTool({ threadId: details.thread.id, before: new Date(Date.now() + 60_000), + usesSandbox: false, run: runOnPostgres, }); const found = async (query: string) => { @@ -1237,6 +1239,7 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () const search = searchHistoryTool({ threadId: details.thread.id, before: new Date(Date.now() + 60_000), + usesSandbox: true, run: runOnPostgres, }); const found = async (input: { query?: string; after?: string }) => { @@ -1245,14 +1248,14 @@ describe.skipIf(!process.env.DATABASE_URL)("threads, against Postgres", async () }; expect(await found({ query: "signed contract" })).toEqual([ - `Here is the signed contract\n[Attached contract.pdf (application/pdf, 2.0 KB) as file ${fileIdOf("contract.pdf")}, which read_thread_file reads]`, + `Here is the signed contract\n[Attached contract.pdf (application/pdf, 2.0 KB) as file ${fileIdOf("contract.pdf")}, which read_thread_file reads, or copy_file_to_sandbox copies]`, ]); // By its name, for a message that says nothing else. expect(await found({ query: "chart.png" })).toEqual([ - `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads]`, + `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads, or copy_file_to_sandbox copies]`, ]); expect(await found({ after: started.toISOString() })).toContain( - `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads]`, + `[Attached chart.png (image/png, 2.0 KB) as file ${fileIdOf("chart.png")}, which read_thread_file reads, or copy_file_to_sandbox copies]`, ); }); diff --git a/packages/core/src/conversations/tools/read-thread-file/tool.ts b/packages/core/src/conversations/tools/read-thread-file/tool.ts index 286a1056..e3d03aed 100644 --- a/packages/core/src/conversations/tools/read-thread-file/tool.ts +++ b/packages/core/src/conversations/tools/read-thread-file/tool.ts @@ -1,6 +1,8 @@ import { mediaTypeEssence } from "@sugabots/contracts"; +import { type UserText, userText } from "@sugabots/errors"; import { tool } from "ai"; import { Effect, Option, Schema, Stream } from "effect"; +import type { BlobStore } from "../../../blob-store/blob-store.ts"; import type { RunEffect } from "../../../database/database.ts"; import type { ThreadFiles } from "../../thread-files/thread-files.ts"; import { @@ -28,17 +30,25 @@ export const MAX_READ_CHARACTERS = 40_000; const MAX_PATTERN_CHARACTERS = 500; -const NOT_FOUND = { - refused: "There is no file with that id in this thread.", -} satisfies ReadThreadFileResult; - -const UNAVAILABLE = { - refused: "The file can't be read right now. Try again shortly.", -} satisfies ReadThreadFileResult; - -const REJECTED = { - refused: "Files can't be read in this installation.", -} satisfies ReadThreadFileResult; +/** + * What an agent is told when a file in its thread can't be read. A store that + * refuses is set up wrong, which trying again won't fix, so that is logged. + */ +export function unreadableFileRefusal( + failure: ThreadFiles.FileNotFound | BlobStore.Failure, +): Effect.Effect { + switch (failure._tag) { + case "ThreadFileNotFound": + return Effect.succeed(userText`There is no file with that id in this thread.`); + case "BlobStoreUnavailable": + return Effect.succeed(userText`The file can't be read right now. Try again shortly.`); + case "BlobStoreRejected": + return Effect.as( + Effect.logError("The blob store rejected reading a file", failure), + userText`Files can't be read in this installation.`, + ); + } +} export type ReadThreadFileResult = | { @@ -134,15 +144,9 @@ export function readThreadFileTool({ if (text === undefined) return notText; return readLines(text, { offset: offset ?? 1, limit: limit ?? DEFAULT_LINES, pattern }); }).pipe( - Effect.catchTags({ - ThreadFileNotFound: () => Effect.succeed(NOT_FOUND), - BlobStoreUnavailable: () => Effect.succeed(UNAVAILABLE), - BlobStoreRejected: (rejected) => - Effect.as( - Effect.logError("The blob store rejected reading a file", rejected), - REJECTED, - ), - }), + Effect.catch((failure) => + Effect.map(unreadableFileRefusal(failure), (refused) => ({ refused })), + ), ), ), toModelOutput: ({ output }) => withImages(output, acceptsImages), diff --git a/packages/core/src/conversations/tools/sandbox.test.ts b/packages/core/src/conversations/tools/sandbox.test.ts index f4faf1b7..0e85ac88 100644 --- a/packages/core/src/conversations/tools/sandbox.test.ts +++ b/packages/core/src/conversations/tools/sandbox.test.ts @@ -1,9 +1,13 @@ +import { createHash } from "node:crypto"; import type { ToolSet } from "ai"; -import { Effect, Exit, Layer, Scope } from "effect"; +import { Effect, Exit, Layer, Scope, Stream } from "effect"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { + agent, + message, pod, sandboxAllowedHost, + thread, user, workspace, workspaceMember, @@ -12,6 +16,7 @@ import { closeDatabase, onDatabase, runOnPostgres } from "../../database/testing import { PodSandboxes } from "../../sandboxes/pod-sandboxes.ts"; import { SandboxProviderRepository } from "../../sandboxes/sandbox-provider-repository.ts"; import { Sandboxes } from "../../sandboxes/sandboxes.ts"; +import { ThreadFiles } from "../thread-files/thread-files.ts"; import { SandboxTools } from "./sandbox.ts"; /** @@ -28,6 +33,7 @@ describe.skipIf(!configured)("sandbox tools, against Postgres and OpenSandbox", let sandboxTools: SandboxTools.Interface; let podSandboxes: PodSandboxes.Interface; let thePod: PodSandboxes.Pod; + let ownerId: string; const scope = Effect.runSync(Scope.make()); beforeAll(async () => { @@ -50,6 +56,7 @@ describe.skipIf(!configured)("sandbox tools, against Postgres and OpenSandbox", .returning(), ); if (!space || !owner) throw new Error("fixture"); + ownerId = owner.id; await onDatabase((db) => db.insert(workspaceMember).values({ workspaceId: space.id, userId: owner.id }), ); @@ -153,6 +160,103 @@ describe.skipIf(!configured)("sandbox tools, against Postgres and OpenSandbox", SLOW, ); + it( + "copies a file in the thread into its folder when asked, under a safe name", + async () => { + const [host] = await onDatabase((db) => + db + .insert(agent) + .values({ + workspaceId: thePod.workspaceId, + podId: thePod.podId, + name: "Editor", + handle: `editor-${Date.now()}`, + color: "green", + face: "pill", + }) + .returning(), + ); + if (!host) throw new Error("fixture"); + const newThread = async (title: string) => { + const [made] = await onDatabase((db) => + db + .insert(thread) + .values({ + workspaceId: thePod.workspaceId, + podId: thePod.podId, + hostAgentId: host.id, + type: "collaboration", + title, + }) + .returning(), + ); + if (!made) throw new Error("fixture"); + return made; + }; + const where = await newThread("Pictures"); + const [sent] = await onDatabase((db) => + db + .insert(message) + .values({ + threadId: where.id, + authorUserId: ownerId, + kind: "text", + status: "complete", + parts: [], + content: "", + }) + .returning(), + ); + if (!sent) throw new Error("fixture"); + // Not text: a NUL and bytes that aren't UTF-8 must come through as they are. + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x0a]); + const files = await runOnPostgres(Effect.provide(ThreadFiles.Service, ThreadFiles.layer)); + const uploaded = await runOnPostgres( + files.upload({ + threadId: where.id, + uploaderId: ownerId, + name: "../dot.png", + maxSize: 1_000, + maxUnsent: 20, + content: { stream: Stream.succeed(bytes), contentType: "image/png" }, + }), + ); + await runOnPostgres( + files.attach({ + threadId: where.id, + messageId: sent.id, + uploaderId: ownerId, + fileIds: [uploaded.id], + }), + ); + const tools = await toolsFor("turn-9", where.id, host.id); + const hash = createHash("sha256").update(bytes).digest("hex"); + + const path = `attachments/${uploaded.id}/_dot.png`; + const copy = () => call(tools, "copy_file_to_sandbox", { file: uploaded.id }); + + expect(await copy()).toMatchObject({ path, alreadyCopied: false }); + expect(await run(tools, `sha256sum ${path} | cut -d' ' -f1`)).toBe(hash); + // Nothing half copied is left beside it. + expect(await run(tools, `ls -A attachments/${uploaded.id}`)).toBe("_dot.png"); + + // Copying again keeps the changes made to the copy. + await run(tools, `echo inverted > ${path}`); + expect(await copy()).toMatchObject({ path, alreadyCopied: true }); + expect(await run(tools, `cat ${path}`)).toBe("inverted"); + + await run(tools, `rm ${path}`); + expect(await copy()).toMatchObject({ path, alreadyCopied: false }); + expect(await run(tools, `sha256sum ${path} | cut -d' ' -f1`)).toBe(hash); + + const elsewhere = await toolsFor("turn-10", (await newThread("Other")).id, host.id); + expect(await call(elsewhere, "copy_file_to_sandbox", { file: uploaded.id })).toMatchObject({ + status: "failed", + }); + }, + SLOW, + ); + it( "keeps an agent's home across its threads", async () => { diff --git a/packages/core/src/conversations/tools/sandbox.ts b/packages/core/src/conversations/tools/sandbox.ts index 43dcb182..019f3195 100644 --- a/packages/core/src/conversations/tools/sandbox.ts +++ b/packages/core/src/conversations/tools/sandbox.ts @@ -4,14 +4,18 @@ import type { UserText } from "@sugabots/errors"; import type { Tool, ToolSet } from "ai"; import { Cause, Context, Effect, Exit, Layer, type Scope } from "effect"; import { type RunEffect, serviceOperations } from "../../database/database.ts"; +import { Ids } from "../../ids/ids.ts"; import { allowedHostsOf, blockedHostsOf } from "../../sandboxes/allowed-hosts.ts"; import { PodSandboxes } from "../../sandboxes/pod-sandboxes.ts"; import { SandboxNetwork } from "../../sandboxes/sandbox-network.ts"; import { SandboxProviderRepository } from "../../sandboxes/sandbox-provider-repository.ts"; +import { ThreadFiles } from "../thread-files/thread-files.ts"; import { type BrowserSession, browserSession, browserTools } from "./browser/browser.ts"; import { REQUEST_NETWORK_ACCESS_TOOL, requestNetworkAccess } from "./network-access/tool.ts"; import { allowedHostsNote, + COPY_FILE_TO_SANDBOX_TOOL, + copyFileToSandboxTool, type OpenSandbox, openOncePerTurn, type Place, @@ -25,14 +29,14 @@ import { } from "./sandbox/tools.ts"; /** - * The tools that work in a pod's sandbox: `run_command`, `read_file` and - * `write_file`, and the `browser_` tools of a browser the agent drives there - * (see `browser/browser.ts`), and `request_network_access`, whose calls wait - * for a person, to reach a host the sandbox may not. Offered to every turn, - * and usable while the agent uses the sandbox and the workspace has an - * enabled sandbox provider, looked up on every call, so enabling one applies - * from the next turn. The sandbox is opened by a turn's first call to one of - * the tools, not when the turn starts. + * The tools that work in a pod's sandbox: `run_command`, `read_file`, + * `write_file` and `copy_file_to_sandbox`, and the `browser_` tools of a + * browser the agent drives there (see `browser/browser.ts`), and + * `request_network_access`, whose calls wait for a person, to reach a host + * the sandbox may not. Offered to every turn, and usable while the agent uses + * the sandbox and the workspace has an enabled sandbox provider, looked up on + * every call, so enabling one applies from the next turn. The sandbox is + * opened by a turn's first call to one of the tools, not when the turn starts. */ export interface Interface { /** @@ -70,9 +74,12 @@ export const make = Effect.gen(function* () { const providers = yield* SandboxProviderRepository.Service; const podSandboxes = yield* PodSandboxes.Service; const network = yield* SandboxNetwork.Service; + const files = yield* ThreadFiles.Service; + const ids = yield* Ids.Service; const operation = yield* serviceOperations("SandboxTools"); const offeredIn = ({ turnId, + threadId, place, acceptsImages, openSandbox, @@ -84,6 +91,14 @@ export const make = Effect.gen(function* () { [RUN_COMMAND_TOOL]: runCommandTool(openSandbox, place), [READ_FILE_TOOL]: readFileTool(openSandbox, place, acceptsImages), [WRITE_FILE_TOOL]: writeFileTool(openSandbox, place), + [COPY_FILE_TO_SANDBOX_TOOL]: copyFileToSandboxTool({ + openSandbox, + place, + threadId, + files, + ids, + run, + }), ...browserTools(browser, acceptsImages), }, requests: { @@ -99,6 +114,7 @@ export const make = Effect.gen(function* () { return { ...offeredIn({ turnId, + threadId, place, acceptsImages, openSandbox: NEVER_OPENED, @@ -146,6 +162,7 @@ export const make = Effect.gen(function* () { return { ...offeredIn({ turnId, + threadId, place, acceptsImages, openSandbox, @@ -163,12 +180,18 @@ export const make = Effect.gen(function* () { export const layerNoDeps = Layer.effect(Service, make); export const layer = layerNoDeps.pipe( - Layer.provide([PodSandboxes.layer, SandboxNetwork.layer, SandboxProviderRepository.layer]), + Layer.provide([ + PodSandboxes.layer, + SandboxNetwork.layer, + SandboxProviderRepository.layer, + ThreadFiles.layer, + ]), ); /** What a turn's tools work through. */ interface Behind { readonly turnId: string; + readonly threadId: string; readonly place: Place; readonly acceptsImages: boolean; readonly openSandbox: OpenSandbox; diff --git a/packages/core/src/conversations/tools/sandbox/attachments.test.ts b/packages/core/src/conversations/tools/sandbox/attachments.test.ts new file mode 100644 index 00000000..e3a8d118 --- /dev/null +++ b/packages/core/src/conversations/tools/sandbox/attachments.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { sandboxPathOf } from "./attachments.ts"; + +const ID = "0199a3a0-0000-7000-8000-0000000000f1"; +const pathOf = (name: string) => sandboxPathOf({ id: ID, name }); + +describe("where an attachment is copied in the sandbox", () => { + it("keeps an ordinary name as it is, in a folder of the file's own", () => { + expect(pathOf("Q3 report (final).pdf")).toBe(`attachments/${ID}/Q3 report (final).pdf`); + expect(pathOf("café menu.csv")).toBe(`attachments/${ID}/café menu.csv`); + }); + + it("keeps the name one file in its folder, however it was written", () => { + expect(pathOf("../../etc/passwd")).toBe(`attachments/${ID}/_.._etc_passwd`); + expect(pathOf("C:\\Users\\sam\\notes.txt")).toBe(`attachments/${ID}/C:_Users_sam_notes.txt`); + expect(pathOf("..")).toBe(`attachments/${ID}/file`); + }); + + it("never makes a hidden file, or one a command reads as an option", () => { + expect(pathOf(".env")).toBe(`attachments/${ID}/env`); + expect(pathOf("-rf")).toBe(`attachments/${ID}/rf`); + }); + + it("leaves out characters that break a line or reorder how the name shows", () => { + // A right-to-left override shows "invoice‮fdp.exe" as "invoiceexe.pdf". + expect(pathOf("invoice\u202Efdp.exe")).toBe(`attachments/${ID}/invoicefdp.exe`); + expect(pathOf("a\u007Fb.txt")).toBe(`attachments/${ID}/a_b.txt`); + }); + + it("cuts a long name to what Linux allows, in bytes, keeping its extension", () => { + const name = `${"é".repeat(300)}.pdf`; + const kept = pathOf(name).split("/").at(-1) ?? ""; + + expect(Buffer.byteLength(kept)).toBeLessThanOrEqual(255); + expect(kept.endsWith("é.pdf")).toBe(true); + }); +}); diff --git a/packages/core/src/conversations/tools/sandbox/attachments.ts b/packages/core/src/conversations/tools/sandbox/attachments.ts new file mode 100644 index 00000000..00385ce2 --- /dev/null +++ b/packages/core/src/conversations/tools/sandbox/attachments.ts @@ -0,0 +1,137 @@ +import { userText } from "@sugabots/errors"; +import { Effect } from "effect"; +import type { Ids } from "../../../ids/ids.ts"; +import { Sandboxes } from "../../../sandboxes/sandboxes.ts"; + +/* + * A thread's file copied into the thread's sandbox folder, when the agent asks + * for it, so commands can work on it like any other file. Each goes in a + * folder of its own id, so two files with the same name never meet and a + * file's path follows from the file alone. The copy is the agents' to change, + * so copying a file again leaves it as it is: the file in the thread never + * changes, so a second copy could only undo their work. + * + * The pod's agents share its sandbox, so an agent in one of the pod's threads + * can read a copy made in another. Everyone who can see one of a pod's chats + * can see them all, so this lets no person see more than they could. + */ + +/** Where the copies are kept, in the thread's folder. */ +const ATTACHMENTS_FOLDER = "attachments"; + +/** Linux limits a file's name to 255 bytes, not characters. */ +const MAX_NAME_BYTES = 255; + +/** An extension longer than this is more likely part of the name, and isn't kept whole. */ +const MAX_EXTENSION_BYTES = 32; + +const COMMAND_OPTIONS = { timeout: "30 seconds", maxOutputCharacters: 2_000 } as const; + +/** A file in the thread, which has no name when it is a tool's result. */ +interface ThreadFile { + id: string; + name: string | undefined; +} + +/** Where `file` is copied, relative to the thread's folder, where commands start. */ +export function sandboxPathOf(file: ThreadFile): string { + return `${ATTACHMENTS_FOLDER}/${file.id}/${safeFileName(file.name)}`; +} + +/** + * `name`, as a person's computer gave it, made safe as one file name: no + * folders, nothing hidden or read as a command's option, no characters that + * break a line or reorder what's shown, and short enough for Linux, keeping + * its extension. + */ +function safeFileName(name: string | undefined): string { + const cleaned = (name ?? "") + .replace(/[/\\\p{Cc}]/gu, "_") + .replace(/\p{Cf}/gu, "") + .replace(/^[\s.-]+/u, "") + .trimEnd(); + return fitted(cleaned || "file"); +} + +/** `name` cut to {@link MAX_NAME_BYTES} bytes, keeping a short extension whole. */ +function fitted(name: string): string { + if (byteLength(name) <= MAX_NAME_BYTES) return name; + const dot = name.lastIndexOf("."); + const extension = dot > 0 ? name.slice(dot) : ""; + const kept = byteLength(extension) <= MAX_EXTENSION_BYTES ? extension : ""; + return ( + cutToBytes(name.slice(0, name.length - kept.length), MAX_NAME_BYTES - byteLength(kept)) + kept + ); +} + +/** The longest start of `text` that fits in `bytes`, never splitting a character. */ +function cutToBytes(text: string, bytes: number): string { + let cut = ""; + for (const character of text) { + if (byteLength(cut + character) > bytes) break; + cut += character; + } + return cut; +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +/** Where a copy is, relative to the thread's folder, and whether it was there before the call. */ +export interface Copy { + path: string; + alreadyCopied: boolean; +} + +/** + * Copies `file` into the thread's `folder`, reading its bytes from `content` + * only when no copy is there yet, and says where the copy is. It is written + * beside where it goes and moved into place, under a name of its own, so no + * command finds it half written, even with another agent copying the same + * file at once. + */ +export function copyIntoSandbox( + sandbox: Sandboxes.Sandbox, + folder: string, + file: ThreadFile, + content: Effect.Effect, + ids: Ids.Interface, +): Effect.Effect { + const path = sandboxPathOf(file); + const target = `${folder}/${path}`; + return Effect.gen(function* () { + const present = yield* sandbox.exec( + `test -e ${Sandboxes.shellQuoted(target)}`, + COMMAND_OPTIONS, + ); + if (present.exitCode === 0) return { path, alreadyCopied: true }; + const partial = `${folder}/${ATTACHMENTS_FOLDER}/${file.id}/.copying-${yield* ids.random}`; + yield* content.pipe( + Effect.flatMap((bytes) => sandbox.writeFile(partial, bytes)), + Effect.andThen( + sandbox.exec( + `mv -f -- ${Sandboxes.shellQuoted(partial)} ${Sandboxes.shellQuoted(target)}`, + COMMAND_OPTIONS, + ), + ), + Effect.flatMap((moved) => + moved.exitCode === 0 + ? Effect.void + : Effect.fail( + new Sandboxes.FileFailed({ + path: target, + reason: userText`The copy couldn't be put in place`, + }), + ), + ), + // A copy that didn't make it into place is removed, so no command finds it. + Effect.onError(() => + sandbox + .exec(`rm -f -- ${Sandboxes.shellQuoted(partial)}`, COMMAND_OPTIONS) + .pipe(Effect.ignore), + ), + ); + return { path, alreadyCopied: false }; + }); +} diff --git a/packages/core/src/conversations/tools/sandbox/tools.ts b/packages/core/src/conversations/tools/sandbox/tools.ts index bec951c0..05806c74 100644 --- a/packages/core/src/conversations/tools/sandbox/tools.ts +++ b/packages/core/src/conversations/tools/sandbox/tools.ts @@ -1,15 +1,22 @@ import { posix } from "node:path"; import { UserText, userText } from "@sugabots/errors"; import { tool } from "ai"; -import { Effect, Schema } from "effect"; +import { Effect, Schema, Stream } from "effect"; +import type { BlobStore } from "../../../blob-store/blob-store.ts"; +import type { RunEffect } from "../../../database/database.ts"; +import type { Ids } from "../../../ids/ids.ts"; import type { PodSandboxes } from "../../../sandboxes/pod-sandboxes.ts"; import { Sandboxes } from "../../../sandboxes/sandboxes.ts"; +import type { ThreadFiles } from "../../thread-files/thread-files.ts"; import { MAX_IMAGE_BYTES, SHOWN_IMAGE_FORMATS, shownImageType, withImages } from "../images.ts"; import { REQUEST_NETWORK_ACCESS_TOOL } from "../network-access/tool.ts"; +import { unreadableFileRefusal } from "../read-thread-file/tool.ts"; +import { type Copy, copyIntoSandbox } from "./attachments.ts"; export const RUN_COMMAND_TOOL = "run_command"; export const READ_FILE_TOOL = "read_file"; export const WRITE_FILE_TOOL = "write_file"; +export const COPY_FILE_TO_SANDBOX_TOOL = "copy_file_to_sandbox"; const DEFAULT_TIMEOUT_SECONDS = 120; const MAX_TIMEOUT_SECONDS = 900; @@ -223,6 +230,72 @@ export function readFileTool(openSandbox: OpenSandbox, place: Place, acceptsImag }); } +/** + * Copies a file in the thread into the thread's folder in the sandbox, when + * the agent wants to work on it there, and says where the copy is. The file + * is looked up first, so an id that names none never starts the sandbox. + */ +export function copyFileToSandboxTool({ + openSandbox, + place, + threadId, + files, + ids, + run, +}: { + openSandbox: OpenSandbox; + place: Place; + threadId: string; + files: Pick; + ids: Ids.Interface; + run: RunEffect; +}) { + return tool({ + description: `Copy a file in this thread, such as one a person attached, into the thread's folder in the pod's sandbox, to work on it with commands. Give the file's id, from the line that names it. Returns the copy's path, relative to the thread's folder. A file already copied is left as it is, with any changes made to it, and \`alreadyCopied\` says so; to start again from the file in the thread, remove the copy and copy the file again.`, + inputSchema: Schema.Struct({ + file: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)).annotate({ + description: "The file's id, from the line that names it", + }), + }).pipe(Schema.toStandardSchemaV1, Schema.toStandardJSONSchemaV1), + execute: ({ file }) => + run( + files.read({ threadId, fileId: file }).pipe( + Effect.flatMap((found) => + Effect.promise(() => + inSandbox(openSandbox, (sandbox) => copyThreadFile(sandbox, place, found, ids)), + ), + ), + Effect.catch(refusedRead), + ), + ), + }); +} + +/** Copies `found` into the sandbox, reading its bytes only when there is no copy yet. */ +function copyThreadFile( + sandbox: Sandboxes.Sandbox, + place: Place, + found: ThreadFiles.StoredFile, + ids: Ids.Interface, +): Effect.Effect { + const content = Effect.map(Stream.runCollect(found.stream), (chunks) => Buffer.concat(chunks)); + return copyIntoSandbox(sandbox, place.folder, found, content, ids).pipe( + // The file can still go while it is read, such as with its thread. + Effect.catchTags({ + ThreadFileNotFound: refusedRead, + BlobStoreUnavailable: refusedRead, + BlobStoreRejected: refusedRead, + }), + ); +} + +function refusedRead(failure: ThreadFiles.FileNotFound | BlobStore.Failure) { + return Effect.map( + unreadableFileRefusal(failure), + (error): Failed => ({ status: "failed", error }), + ); +} + export function writeFileTool(openSandbox: OpenSandbox, place: Place) { return tool({ description: `Write a text file in the pod's sandbox, replacing it if it exists and making its directories.`, diff --git a/packages/core/src/conversations/tools/search-history/tool.ts b/packages/core/src/conversations/tools/search-history/tool.ts index 5a766aad..1037e235 100644 --- a/packages/core/src/conversations/tools/search-history/tool.ts +++ b/packages/core/src/conversations/tools/search-history/tool.ts @@ -40,11 +40,14 @@ interface HistorySearch { export function searchHistoryTool({ threadId, before: keptFrom, + usesSandbox, run, }: { threadId: string; /** Where the history the bot reads word for word starts, once compacted; nothing from then on is searched. */ before: Date | undefined; + /** Whether the agent works in the pod's sandbox, so is told it can copy attachments into it. */ + usesSandbox: boolean; run: RunEffect; }) { return tool({ @@ -82,7 +85,7 @@ export function searchHistoryTool({ ...(after ? { after: DateTime.toDate(DateTime.makeUnsafe(after)) } : {}), ...(before ? { before: DateTime.toDate(DateTime.makeUnsafe(before)) } : {}), }; - return run(query((db) => searchHistory(db, threadId, keptFrom, search))); + return run(query((db) => searchHistory(db, threadId, keptFrom, search, usesSandbox))); }, }); } @@ -92,6 +95,7 @@ const searchHistory = Effect.fn("SearchHistory.searchHistory")(function* ( threadId: string, keptFrom: Date, { words, after, before }: HistorySearch, + usesSandbox: boolean, ) { const until = before && before < keptFrom ? before : keptFrom; const matching = words ? wordMatch(words) : undefined; @@ -132,7 +136,10 @@ const searchHistory = Effect.fn("SearchHistory.searchHistory")(function* ( author: found.author.kind === "routine_trigger" ? found.author.routineName : found.author.name, at: formatHistoryTime(row.createdAt), - text: [said, ...(files.get(row.id) ?? []).map(describeAttachment)] + text: [ + said, + ...(files.get(row.id) ?? []).map((file) => describeAttachment(file, { usesSandbox })), + ] .filter((line) => line !== "") .join("\n"), }; diff --git a/packages/core/src/conversations/turns/context.test.ts b/packages/core/src/conversations/turns/context.test.ts index 60db0622..e25eea0f 100644 --- a/packages/core/src/conversations/turns/context.test.ts +++ b/packages/core/src/conversations/turns/context.test.ts @@ -377,6 +377,28 @@ ${line}`, }); }); + it("tells an agent that works in the sandbox it can copy an attached file there", () => { + const input = context(); + const asked = input.messages[0]; + if (!asked) { + throw new Error("Context fixture has no person's message"); + } + const chart = { + type: "file", + id: "0199a3a0-0000-7000-8000-0000000000f3", + name: "chart.png", + contentType: "image/png", + size: 4_096, + } as const; + input.agent = { ...input.agent, usesSandbox: true }; + input.messages[0] = { ...asked, content: "", parts: [chart] }; + + expect(modelPrompt(input, environment()).messages[0]).toEqual({ + role: "user", + content: `Sam (@sam): [Attached chart.png (image/png, 4.0 KB) as file ${chart.id}, which read_thread_file reads, or copy_file_to_sandbox copies]`, + }); + }); + it("excludes incomplete assistant output", () => { const input = context(); const assistantMessage = input.messages[1]; diff --git a/packages/core/src/conversations/turns/context.ts b/packages/core/src/conversations/turns/context.ts index 0ee95b42..ba0448b8 100644 --- a/packages/core/src/conversations/turns/context.ts +++ b/packages/core/src/conversations/turns/context.ts @@ -75,7 +75,7 @@ export function modelPrompt( ...(context.compaction ? [compactionSummary(context.compaction)] : []), ...withReactions( context.messages.filter((message) => message.status === "complete"), - context.agent.id, + { agentId: context.agent.id, usesSandbox: context.agent.usesSandbox }, resultFiles, ), ]; @@ -115,7 +115,7 @@ function compactionSummary(compaction: TurnCompaction): Models.PromptMessage { */ function withReactions( messages: readonly Message[], - currentAgentId: string, + reader: Reader, resultFiles: ReadonlyMap, ): Models.PromptMessage[] { const reactions = messages @@ -123,7 +123,7 @@ function withReactions( (message.reactions ?? []).flatMap(({ emoji, people }) => people.map((person) => ({ at: person.at, - line: `${person.name} reacted ${emoji} to ${whose(message, currentAgentId)} message: "${quotedPreview(message)}"`, + line: `${person.name} reacted ${emoji} to ${whose(message, reader.agentId)} message: "${quotedPreview(message)}"`, })), ), ) @@ -152,12 +152,19 @@ function withReactions( return [ ...messages.flatMap((message) => [ ...reactionsBefore(message.createdAt), - ...promptMessagesFor(message, currentAgentId, resultFiles), + ...promptMessagesFor(message, reader, resultFiles), ]), ...reactionsBefore(), ]; } +/** The agent whose prompt the history is written for. */ +interface Reader { + agentId: string; + /** Whether it works in the pod's sandbox, which it can copy the thread's files into. */ + usesSandbox: boolean; +} + function whose(message: Message, currentAgentId: string): string { const ownMessage = message.author.kind === "agent" && message.author.id === currentAgentId; return ownMessage ? "your" : `${messageAuthorName(message.author)}'s`; @@ -165,13 +172,13 @@ function whose(message: Message, currentAgentId: string): string { function promptMessagesFor( message: Message, - currentAgentId: string, + reader: Reader, resultFiles: ReadonlyMap, ): Models.PromptMessage[] { const attachments = message.parts.flatMap((part) => - part.type === "file" ? [describeAttachment(part)] : [], + part.type === "file" ? [describeAttachment(part, reader)] : [], ); - const authored = authoredMessage(message, currentAgentId, attachments); + const authored = authoredMessage(message, reader.agentId, attachments); const records = message.parts.flatMap((part): string[] => { if (part.type === "text" || part.type === "file") return []; if (part.type === "collaboration") return [describeCollaboration(part)]; diff --git a/packages/core/src/conversations/turns/tools.ts b/packages/core/src/conversations/turns/tools.ts index 3e020bb6..cd01de5a 100644 --- a/packages/core/src/conversations/turns/tools.ts +++ b/packages/core/src/conversations/turns/tools.ts @@ -218,6 +218,7 @@ export function toolsForTurn(prepared: PreparedTurn, deps: ToolDependencies): Tu searchHistoryTool({ threadId: prepared.context.thread.id, before: prepared.context.compaction?.keptFrom, + usesSandbox: prepared.context.agent.usesSandbox, run: deps.run, }), recording,