Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions packages/core/src/conversations/threads/message-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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")
Expand Down Expand Up @@ -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. */
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/conversations/threads/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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]`,
);
});

Expand Down
44 changes: 24 additions & 20 deletions packages/core/src/conversations/tools/read-thread-file/tool.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<UserText> {
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 =
| {
Expand Down Expand Up @@ -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),
Expand Down
106 changes: 105 additions & 1 deletion packages/core/src/conversations/tools/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

/**
Expand All @@ -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 () => {
Expand All @@ -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 }),
);
Expand Down Expand Up @@ -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 () => {
Expand Down
41 changes: 32 additions & 9 deletions packages/core/src/conversations/tools/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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<Interface>("SandboxTools");
const offeredIn = ({
turnId,
threadId,
place,
acceptsImages,
openSandbox,
Expand All @@ -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: {
Expand All @@ -99,6 +114,7 @@ export const make = Effect.gen(function* () {
return {
...offeredIn({
turnId,
threadId,
place,
acceptsImages,
openSandbox: NEVER_OPENED,
Expand Down Expand Up @@ -146,6 +162,7 @@ export const make = Effect.gen(function* () {
return {
...offeredIn({
turnId,
threadId,
place,
acceptsImages,
openSandbox,
Expand All @@ -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;
Expand Down
Loading
Loading