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
5 changes: 5 additions & 0 deletions .changeset/attachments-eve-fetches-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

eve now downloads attachment links itself instead of handing them to the model provider. When a channel's `fetchFile` returns `null`, or the channel has none, eve fetches a public `https:` link with the 25 MB upload cap and a 30-second timeout, at most 10 links per message and one at a time, and any other link or failed download reaches the model as a note. A private or expired link, such as a Google Drive file shared in Slack, no longer fails every later model call, and sessions that already hold such a link recover.
5 changes: 5 additions & 0 deletions .changeset/attachments-labels-verified-media.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Every attachment now reaches the model with a label that names its sandbox path, including images and PDFs that also arrive as bytes, so the agent can pass a visible file to `bash` or open it again. eve checks image and PDF media types against the file's bytes. Only PNG, JPEG, GIF, and WebP images up to 3 MiB and 8000 pixels per side, and PDFs up to 20 MiB, go to the model as bytes, and tool results follow the same rule. A HEIC photo, an oversized screenshot, or a mislabeled file renders as its label instead of failing the model call. Live sessions pay one prompt-cache rewrite after upgrading.
5 changes: 5 additions & 0 deletions .changeset/attachments-never-unresolved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

A turn cancelled before its first model call now stages its attachments, so a photo sent just before a follow-up message no longer breaks every later turn. File parts eve can't stage become a note instead of entering session history. Byte-valued file parts cross the queue as `data:` URLs, a string with any URL scheme reaches the channel's `fetchFile` instead of being decoded as base64, and `message.received` reports an inline file's size instead of echoing its `data:` URL.
5 changes: 5 additions & 0 deletions .changeset/attachments-read-file-pdf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

`read_file` now shows PDFs up to 20 MiB to the model as files, so the agent can reopen an attached PDF after compaction, or read one that a tool or `bash` wrote to the sandbox. Its output's `image` field is now `file` and carries `pages` for PDFs. `read_file` also refuses images over 8000 pixels per side, which providers reject.
2 changes: 1 addition & 1 deletion docs/channels/chat-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ bot.onNewMention(async (thread, message) => {
});
```

When the adapter can download an attachment (it sets `fetchData`, as the Slack, Google Chat, WhatsApp, and Twilio adapters do), eve downloads it with the adapter's credentials when the turn runs, not in the webhook, and passes the bytes. Platform file URLs usually need those credentials, so the model provider couldn't open them. eve rebuilds the download with the adapter's `rehydrateAttachment`; an adapter without one can't download after the webhook returns, so its files reach the agent as a short note. A failed download, or a file over 25 MB, also becomes a note. An attachment with only a URL is passed as that URL.
When the adapter can download an attachment (it sets `fetchData`, as the Slack, Google Chat, WhatsApp, and Twilio adapters do), eve downloads it with the adapter's credentials when the turn runs, not in the webhook, and passes the bytes. Platform file URLs usually need those credentials, so the model provider couldn't open them. eve rebuilds the download with the adapter's `rehydrateAttachment`; an adapter without one can't download after the webhook returns, so its files reach the agent as a short note. A failed download, or a file over 25 MB, also becomes a note. An attachment with only a URL is downloaded by eve when the URL is public `https:`; otherwise it becomes a note.

See [File uploads](./custom#file-uploads) for how eve stages remote file URLs before the model call.

Expand Down
4 changes: 3 additions & 1 deletion docs/channels/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,9 @@ defineChannel({

If `fetchFile` throws, eve replaces that attachment with a model-visible error note and continues the turn. Built-in channels include safe details such as the upstream HTTP status. Custom channel errors use a generic note, while eve keeps the original error in operator logs.

The `URL` object survives the queue boundary as a string and is reconstituted inside the workflow step. The staging pipeline calls `fetchFile` with the URL serialized as a string (the URL's `href`), which is why the example matches on `url.startsWith(...)`. Return bytes to stage the file to the sandbox, or `null` to let the URL pass through to the model provider.
The `URL` object survives the queue boundary as a string and is reconstituted inside the workflow step. A string with a URL scheme, such as `https://...` or `myapp-file:abc`, is handled the same way. The staging pipeline calls `fetchFile` with the URL serialized as a string (the URL's `href`), which is why the example matches on `url.startsWith(...)`.

Return bytes to stage the file to the sandbox. Return `null` when the URL is not one your channel resolves: eve then downloads a public `https:` URL itself, with the 25 MB default size cap and a 30-second timeout, and refuses private or reserved addresses. It downloads at most 10 URLs per message, one at a time. Any other URL, or a download that fails, reaches the model as a note. The model provider never fetches an attachment, so a link that expires or needs credentials can't fail later model calls.

The framework handles staging bytes to the sandbox, enforcing upload policy, hydrating files for the model call, and reconstituting `URL` objects after queue serialization. See [Inbound attachments](../sandbox#inbound-attachments) for the shared storage, size, and provider-input behavior.

Expand Down
2 changes: 1 addition & 1 deletion docs/channels/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ slackChannel({
});
```

The bases say where a call may go. `apiBaseUrl` is the base Slack Web API method names resolve against, and defaults to `https://slack.com/api/`. `fileBaseUrl` adds a base for attachment downloads and falls back to `apiBaseUrl`: a download URL under it is fetched with the bot token, in addition to `files.slack.com` and Enterprise Grid file URLs. Every other URL passes through to the model provider unchanged. Both bases must be absolute `http:` or `https:` URLs carrying no query string or fragment; `slackChannel()` throws on one that is not, naming the option. A missing trailing slash is added, so `https://sim.example/api` sends `chat.postMessage` to `https://sim.example/api/chat.postMessage`.
The bases say where a call may go. `apiBaseUrl` is the base Slack Web API method names resolve against, and defaults to `https://slack.com/api/`. `fileBaseUrl` adds a base for attachment downloads and falls back to `apiBaseUrl`: a download URL under it is fetched with the bot token, in addition to `files.slack.com` and Enterprise Grid file URLs. eve downloads every other public `https:` URL without the bot token. Both bases must be absolute `http:` or `https:` URLs carrying no query string or fragment; `slackChannel()` throws on one that is not, naming the option. A missing trailing slash is added, so `https://sim.example/api` sends `chat.postMessage` to `https://sim.example/api/chat.postMessage`.

`api.fetch` says how the traffic travels. It replaces the global `fetch` for calls to the configured bases — use it to attach the per-call header a host behind an authenticating proxy requires, as the example does for a simulator behind Vercel Deployment Protection. eve confines it to those bases' origins, so that header reaches the simulator alone: `files.slack.com`, and any other host Slack names in a response or an inbound payload, keep the global `fetch`. A wrapper therefore attaches its credential unconditionally. With no base configured every call already goes to Slack's own hosts, and `api.fetch` carries all of them, which is what observes or stubs the traffic in a test.

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/built-in-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default disableTool();

### `read_file`

`read_file` reads text files from the sandbox with line-numbered output, and shows PNG, JPEG, GIF, and WebP images up to 3 MiB to the model as images. Images are detected from their bytes, so a missing or mismatched filename extension does not matter, and text files are always read as text. It accepts absolute paths and paths beginning with `$HOME/`.
`read_file` reads text files from the sandbox with line-numbered output, and shows PNG, JPEG, GIF, and WebP images up to 3 MiB and 8000 pixels per side, and PDFs up to 20 MiB, to the model as files. Images and PDFs are detected from their bytes, so a missing or mismatched filename extension does not matter, and other text files are always read as text. It accepts absolute paths and paths beginning with `$HOME/`.

```sh
eve add tool/read_file
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/client/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ A call to a tool that runs as a [task](/docs/tools/workflows#run-calls-as-tasks-
When a submitted message includes attachments, `message.received.data.message` stays the
flattened compatibility summary, while `message.received.data.parts` carries renderable text and
file metadata. File parts never include raw bytes or internal sandbox paths; `url` appears only for
client-resolvable `http(s)` and `data:` URLs.
`http(s)` URLs, and an inline file reports its `size` instead.

## Authorization pauses

Expand Down
12 changes: 8 additions & 4 deletions docs/sandbox/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,18 @@ Docker and microsandbox mount the compiled trees read-only under `/eve/resources

eve writes byte-backed files that reach the agent into the sandbox under `/workspace/.eve/attachments/` and keeps only a reference in session history. This covers files attached to user messages and files that tools return to the model with `toolOutputPart.file` or as MCP image, audio, and blob content.

Before every model call, eve reads the referenced files back from the sandbox:
Before every model call, eve reads the referenced files back from the sandbox. Every file, whether attached to a message or returned by a tool, reaches the model with a label that names its sandbox path, such as `Attached file /workspace/.eve/attachments/<hash>/report.csv (text/csv)`. The agent can open that path with `read_file` or `bash`. The file's bytes follow its label only when every major provider reads the format natively:

- An image up to 3 MiB or a PDF up to 20 MiB attached to a message reaches the model as a file. Larger files and other types reach the model as a text reference, such as `Attached file /workspace/.eve/attachments/<hash>/report.csv (text/csv)`, that the agent can open with `read_file` or `bash`.
- A file returned by a tool always reaches the model as a file.
- A PNG, JPEG, GIF, or WebP image up to 3 MiB and at most 8000 pixels per side.
- A PDF up to 20 MiB.

Other files, such as CSV, HEIC, audio, and video, reach the model as the label alone. eve checks image and PDF media types against the file's bytes when it stages the file. A file whose bytes don't match its declared image or PDF type is staged as `application/octet-stream`, so a provider never receives bytes it would reject.

If a referenced file is missing, or its bytes no longer match what eve staged because something in the sandbox overwrote it, the model receives a `FileNotFound` note in its place.

Each call sends the same bytes for the same file, so the model still sees attachments from earlier turns and the provider's prompt cache stays valid. Compaction replaces files in older tool results with their text reference. The `read_file` tool shows PNG, JPEG, GIF, and WebP files up to 3 MiB to the model as images, so the agent can look at an image again after compaction.
A file that eve can't stage never enters session history. When a channel's `fetchFile` fails, or a file part's `data` is not bytes, base64, a `data:` URL, or a URL, the model receives a note that names the file instead.

Each call sends the same bytes for the same file, so the model still sees attachments from earlier turns and the provider's prompt cache stays valid. Compaction replaces files in older tool results with their text reference. The `read_file` tool shows the same images and PDFs to the model as files, so the agent can look at an attachment again after compaction, or open a PDF that a tool or `bash` wrote to the sandbox.

## Custom providers

Expand Down
2 changes: 1 addition & 1 deletion docs/tools/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ File payloads must be base64 strings — raw bytes (`Uint8Array`, `Buffer`) are

eve writes each returned file to the session sandbox and keeps only a reference in session history, so the file's bytes are not stored again on every workflow step. Every later model call sends the same file, which keeps it visible on later turns and keeps the provider's prompt cache valid. See [Inbound attachments](/docs/sandbox#inbound-attachments). Compaction counts an image at roughly what providers bill for it rather than its base64 length.

When compaction drops older tool results, each file becomes a text reference such as `Attached file /workspace/.eve/attachments/<hash>/chart.png (image/png)`. The built-in `read_file` tool shows PNG, JPEG, GIF, and WebP files up to 3 MiB to the model as images, so the agent can open a compacted image again.
When compaction drops older tool results, each file becomes a text reference such as `Attached file /workspace/.eve/attachments/<hash>/chart.png (image/png)`. The built-in `read_file` tool shows PNG, JPEG, GIF, and WebP images up to 3 MiB and PDFs up to 20 MiB to the model as files, so the agent can open a compacted image or PDF again.

Do not return secrets, credentials, unnecessary personal data, or unbounded sensitive content from tools. Filter, minimize, and redact tool outputs before returning them.

Expand Down
2 changes: 1 addition & 1 deletion e2e/fixtures/agent-compaction-regressions/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const taskModel = mockModel({
toolText.includes(CONTENT_OUTPUT_LEAD_MARKER) ? "LEAD_PRESERVED" : "LEAD_LOST",
// The stub names the file's sandbox path, which ends in its filename.
toolText.includes("Attached file ") &&
toolText.includes(`${CONTENT_OUTPUT_FILENAME} (application/octet-stream)`)
toolText.includes(`${CONTENT_OUTPUT_FILENAME} (image/png)`)
? "FILE_STUB_RENDERED"
: "FILE_STUB_LOST",
request.userMessages.some((text) => text.includes("[case: content-output-file-stub]"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ import {
CONTENT_OUTPUT_TAIL_MARKER,
} from "../../constants";

// The canary sits within the first 2000 serialized characters, where the old
// prefix-clipping rendering would expose it to the compaction model. The rest
// of the payload pushes the trailing text far past that clip budget.
const INLINE_FILE_BASE64 = `${"A".repeat(400)}${CONTENT_OUTPUT_PAYLOAD_CANARY}${"A".repeat(11_600)}`;
// A PNG signature and 2000x2000 IHDR chunk (33 bytes, so 44 base64 characters
// with no padding) make eve verify the file as an image it inlines, so the
// payload reaches the model and weighs on history like any screenshot. The
// canary sits within the first 2000 serialized characters, where the old
// prefix-clipping rendering would expose it to the compaction model, and on a
// base64 group boundary so it survives eve's decode and re-encode unchanged.
// The rest of the payload pushes the trailing text far past that clip budget.
const PNG_HEADER = Buffer.from(
"89504e470d0a1a0a" + "0000000d49484452" + "000007d0000007d00806000000" + "00000000",
"hex",
);
const INLINE_FILE_BASE64 =
`${PNG_HEADER.toString("base64")}${"A".repeat(400)}` +
`${CONTENT_OUTPUT_PAYLOAD_CANARY}${"A".repeat(11_599)}`;

export default defineTool({
description:
Expand All @@ -28,7 +38,7 @@ export default defineTool({
toolOutputPart.text(`Alice's completed review note: ${CONTENT_OUTPUT_LEAD_MARKER}`),
toolOutputPart.file(INLINE_FILE_BASE64, {
filename: CONTENT_OUTPUT_FILENAME,
mediaType: "application/octet-stream",
mediaType: "image/png",
}),
// "The attachment's filename" is deliberately not spelled out: the
// checkpoint can only carry it by reading the rendered stub.
Expand Down
18 changes: 14 additions & 4 deletions packages/eve/src/channel/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLogger } from "#internal/logging.js";
import type { UnstampedMessageStreamEvent } from "#protocol/message.js";
import type { SessionHandle } from "#channel/session.js";
import type { DeliverPayload } from "#channel/types.js";
import type { UploadPolicy } from "#public/channels/upload-policy.js";
import type {
FetchFileContext,
FetchFileResult,
Expand Down Expand Up @@ -159,16 +160,25 @@ export type ChannelAdapter<TCtx extends ChannelAdapterContext<any> = ChannelAdap
/**
* Fetches bytes for a URL encountered in `FilePart.data`.
*
* Called by the staging pipeline when it encounters a `URL` object
* on a `FilePart`. Return `null` to let the URL pass through to the
* model provider (e.g. public images). Return bytes or
* {@link FetchFileResult} to stage the file to the sandbox.
* Called by the staging pipeline when it encounters a link on a
* `FilePart`. Return bytes or {@link FetchFileResult} to stage the file
* to the sandbox. Return `null` when the link is not this channel's: eve
* then downloads a public `https:` link itself, and any other link
* reaches the model as a note.
*
* Credentials should be captured in the closure at channel
* construction time.
*/
readonly fetchFile?: FetchFileFunction;

/**
* Upload policy staging applies to every inbound attachment, on its final
* bytes and verified media type, including links eve downloads itself.
* Without one, staging checks nothing and eve's own downloads stop at the
* framework default size.
*/
readonly uploadPolicy?: UploadPolicy;

/**
* Framework-owned observability projection for the active channel.
*
Expand Down
6 changes: 3 additions & 3 deletions packages/eve/src/channel/channel-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type ChannelDeliverySource,
} from "#channel/delivery-metadata.js";
import type { SendPayload } from "#channel/routes.js";
import { normalizeSendInput, serializeUrlFilePartsInMessage } from "#channel/send-input.js";
import { normalizeSendInput, serializeFilePartsInMessage } from "#channel/send-input.js";
import { createSession, sessionCallbackToTurnCaller, type Session } from "#channel/session.js";
import type {
CancelTurnResult,
Expand Down Expand Up @@ -93,7 +93,7 @@ export function createChannelAddress<TState = undefined>(input: {
kind: "send" as const,
payload: {
...payload,
message: serializeUrlFilePartsInMessage(payload.message),
message: serializeFilePartsInMessage(payload.message),
},
requestId: metadata.requestId,
turnPolicy:
Expand Down Expand Up @@ -123,7 +123,7 @@ export function createChannelAddress<TState = undefined>(input: {
initiatorAuth: options.initiatorAuth,
input: {
context: payload.context,
message: serializeUrlFilePartsInMessage(payload.message) ?? "",
message: serializeFilePartsInMessage(payload.message) ?? "",
outputSchema: payload.outputSchema,
state: payload.state,
},
Expand Down
25 changes: 25 additions & 0 deletions packages/eve/src/channel/send-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { FilePart, UserContent } from "ai";
import { describe, expect, it } from "vitest";

import { serializeFilePartsInMessage } from "#channel/send-input.js";
import { readFileData } from "#internal/attachments/data.js";

describe("serializeFilePartsInMessage", () => {
it("carries bytes and URLs across a JSON boundary intact", () => {
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const message: UserContent = [
{ type: "text", text: "two files" },
{ data: bytes, filename: "a.png", mediaType: "image/png", type: "file" },
{ data: new URL("https://example.com/b.pdf"), mediaType: "application/pdf", type: "file" },
];

const crossed = JSON.parse(JSON.stringify(serializeFilePartsInMessage(message))) as Exclude<
UserContent,
string
>;

expect(crossed[0]).toEqual({ type: "text", text: "two files" });
expect(readFileData((crossed[1] as FilePart).data)).toEqual({ bytes, kind: "bytes" });
expect((crossed[2] as FilePart).data).toBe("eve-url:https://example.com/b.pdf");
});
});
Loading
Loading