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/attachment-error-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

A custom channel's `fetchFile` can throw `attachmentError(message)` from `eve/channels` to tell the model why a file didn't arrive, such as a size limit, while the original error stays in operator logs. `fetchFile` now types the `session` it already receives, and `eve/channels` exports the `FetchFileFunction`, `FetchFileContext`, and `FetchFileResult` types for resolvers written in their own module.
5 changes: 5 additions & 0 deletions .changeset/eve-channel-fetch-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

`eveChannel()` accepts `fetchFile`, so a web client can upload a large file to its own storage and send only the URL. eve loads the file inside the workflow step, past the platform's request body limit. `defineChannel` and `eveChannel` take an `uploadPolicy` that eve now applies to every staged file, including URLs it downloads itself, on the final bytes and their verified media type. Slack, Telegram, and Teams apply their own `uploadPolicy` the same way. A file over the cap or of a disallowed type reaches the model as a note. An `eveChannel` with `uploadPolicy` or `fetchFile` reports its instrumentation kind as `channel:eve` instead of `http`, as one with `events` already does.
22 changes: 20 additions & 2 deletions docs/channels/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -550,13 +550,31 @@ 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.
If `fetchFile` throws, eve replaces that attachment with a model-visible note and continues the turn. To tell the model why, throw `attachmentError` from `eve/channels`:

```ts
import { attachmentError } from "eve/channels";

async function fetchUpload(url: string) {
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (response.status === 413) {
throw attachmentError("The file is over 100 MB. Ask for a smaller file.");
}
return Buffer.from(await response.arrayBuffer());
}
```

The model then reads `Attachment report.pdf could not be retrieved: The file is over 100 MB. Ask for a smaller file.` The message reaches the model as written, so keep it to details the person can act on, such as a size limit or a missing permission, and never include URLs, tokens, or upstream response text. Pass the original error as `attachmentError(message, { cause })` to keep it in operator logs. Any other error that `fetchFile` throws becomes a generic note, and eve keeps the original error in operator logs.

`fetchFile` receives the channel's `state` and the `session` the attachment arrived in as its second argument, so a resolver can check a file against the session's caller in `session.auth`.

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.
Set `uploadPolicy` on `defineChannel` to cap what eve stages for the channel, for example `uploadPolicy: { maxBytes: 10 * 1024 * 1024, allowedMediaTypes: ["image/*", "application/pdf"] }`. eve checks every staged file against it, on the final bytes and the media type they prove, including URLs it downloads itself. A file that breaks the policy reaches the model as a note. Without `uploadPolicy`, eve checks only what your `fetchFile` enforces, and its own downloads stop at 25 MB.

The framework handles staging bytes to the sandbox, 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.

## What to read next

Expand Down
34 changes: 34 additions & 0 deletions docs/channels/eve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,40 @@ An existing workflow whose inbox is not yet available returns `409 session_not_r
A session that another eve version built returns `409 session_stranded` with the `eveVersion`
that built it.

### Send files

A message can mix text and file parts. A file part's `data` is base64, a `data:` URL, or a URL:

```bash
curl -X POST https://<deployment>/eve/v1/session/wrun_A \
-H "Content-Type: application/json" \
-d '{"message":[{"type":"text","text":"Summarize this report."},{"type":"file","mediaType":"application/pdf","filename":"report.pdf","data":"data:application/pdf;base64,JVBERi0xLjQK..."}]}'
```

`uploadPolicy` sets the size cap and allowed media types, 25 MB and every type by default. A request whose inline file breaks the policy returns `413` for size or `415` for type. Inline bytes also count toward the request body limit of the platform that serves the channel. On Vercel Functions that limit is 4.5 MB, so a base64 file stops at about 3.3 MB.

For larger files, upload them from the browser to your own storage and send the URL in the file part. Add `fetchFile` to load the URLs you own inside the workflow step, so the bytes never pass through the request or the workflow start payload:

```ts title="agent/channels/eve.ts"
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc } from "eve/channels/auth";

export default eveChannel({
auth: [vercelOidc(), localDev()],
uploadPolicy: { maxBytes: 100 * 1024 * 1024 },
async fetchFile(url) {
if (!url.startsWith("https://uploads.example.com/")) return null;
const response = await fetch(url, {
headers: { authorization: `Bearer ${process.env.UPLOADS_TOKEN}` },
});
if (!response.ok) throw new Error(`Upload fetch returned HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
},
});
```

`fetchFile` follows the [custom channel contract](./custom#file-uploads): return bytes to stage the file, or `null` for a URL that isn't yours. eve then downloads a public `https:` URL itself, and any other URL reaches the model as a note. `uploadPolicy` covers every file eve stages for the channel, including URLs that `fetchFile` loads and URLs eve downloads itself. eve checks the final bytes and the media type they prove, so a file over the cap, or one whose bytes are a disallowed type, reaches the model as a note instead of failing the request. See [Inbound attachments](../sandbox#inbound-attachments) for how eve stores files and shows them to the model.

### Stream events

Stream a session as newline-delimited JSON from `GET /eve/v1/session/:sessionId/stream`. The [session protocol](../concepts/sessions-runs-and-streaming#stream-a-session) defines the event set, envelopes, cursors, and reconnection behavior.
Expand Down
12 changes: 12 additions & 0 deletions packages/eve/extension-contracts/compatibility/channel/v58.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineChannel, POST } from "#public/channels/index.js";

// Epoch 58 `fetchFile` resolvers that read only `state` from their context remain supported.
export default defineChannel({
async fetchFile(url, context) {
const token = context?.state.token;
if (typeof token !== "string" || !url.startsWith("https://files.example.com/")) return null;
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
return Buffer.from(await response.arrayBuffer());
},
routes: [POST("/files", async () => new Response("ok"))],
});
1 change: 1 addition & 0 deletions packages/eve/extension-contracts/entrypoints/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
PUT,
SessionStrandedError,
WS,
attachmentError,
createWebSocketUpgradeServer,
defineChannel,
disableRoute,
Expand Down
23 changes: 23 additions & 0 deletions packages/eve/extension-contracts/reports/channel/v59.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"kind": "eve-extension-capability-contract",
"capability": "channel",
"epoch": 59,
"sha256": "f2dda58588e3a1e550f34166c4cf8eddedb3d51c32f8c1ca994171628de543c3",
"exports": [
"DELETE",
"GET",
"HEAD",
"OPTIONS",
"PATCH",
"POST",
"PUT",
"SessionStrandedError",
"WS",
"attachmentError",
"createWebSocketUpgradeServer",
"defineChannel",
"disableRoute",
"isChannel",
"isDisabledRouteSentinel"
]
}
4 changes: 2 additions & 2 deletions packages/eve/src/compiler/extension-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ const EXTENSION_CAPABILITY_CONTRACTS = {
},
},
channel: {
current: 58,
current: 59,
supported: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 29, 31, 36, 37, 38, 39, 40, 41, 42,
43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58,
43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58, 59,
],
dropped: {
12: "Message and reasoning append events now expose deltas instead of cumulative snapshots.",
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/eve-channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,5 +744,7 @@ export function eveChannel(input: EveChannelInput): EveChannel {
}),
],
events: input.events,
fetchFile: input.fetchFile,
uploadPolicy: input.uploadPolicy,
});
}
15 changes: 14 additions & 1 deletion packages/eve/src/eve-channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SessionAuthContext, TurnPolicy } from "#channel/types.js";
import type { TrustedForwarders } from "#channel/forwarded-principal.js";
import type { AuthFn } from "#public/channels/auth.js";
import type { UploadPolicyInput } from "#public/channels/upload-policy.js";
import type { FetchFileFunction } from "#shared/channel-definition.js";
import type {
AudienceContext,
Channel,
Expand Down Expand Up @@ -144,9 +145,21 @@ export interface EveChannelInput {
readonly trustedForwarders?: TrustedForwarders;
/**
* Attachment policy for inbound file parts. Omit for the framework default (25 MB cap, all media
* types); `"disabled"` rejects every attachment; a partial config is merged onto the default. Violations reject with 413 (too large) or 415 (bad type).
* types); `"disabled"` rejects every attachment; a partial config is merged onto the default. Violations in the
* request reject with 413 (too large) or 415 (bad type). eve also checks every file it stages, including URLs, on
* its final bytes and verified media type; a file that breaks the policy there reaches the model as a note.
*/
readonly uploadPolicy?: UploadPolicyInput;
/**
* Loads a file part whose `data` is a URL, such as a browser upload that went
* to your own storage first. The bytes then skip the request body limit and
* the workflow start payload, and load inside the workflow step.
*
* Return bytes or `{ bytes, mediaType?, filename? }` to stage the file, or
* `null` when the URL is not yours: eve then downloads a public `https:` URL
* itself. Fetched bytes must pass {@link uploadPolicy}.
*/
readonly fetchFile?: FetchFileFunction;
/**
* Browser CORS policy for the eve HTTP routes. Omit or pass `false` to leave
* CORS untouched, pass `true` for fully permissive CORS, or pass an options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";

import type { ChannelAdapter, FetchFileResult } from "#channel/adapter.js";
import { EveAttachmentError } from "#internal/attachments/errors.js";
import { attachmentError } from "#public/channels/attachment-error.js";
import { decodeSandboxRef, isSandboxRefUrl } from "#internal/attachments/sandbox-refs.js";
import { createTestRuntime } from "#internal/testing/app-harness.js";
import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js";
Expand Down Expand Up @@ -161,6 +162,7 @@ describe("stageAttachmentsToSandbox (integration)", () => {
// fetchFile receives the URL string from the FilePart.
expect(url).toBe("https://example.com/file");
expect(context?.state).toEqual({ installationTeamId: "T_INSTALLATION" });
expect(context?.session.id).toEqual(expect.any(String));
return resolvedBytes;
},
kind: "custom-channel",
Expand Down Expand Up @@ -522,12 +524,10 @@ describe("stageAttachmentsToSandbox (integration)", () => {
);
});

it("exposes a channel-authored safe resolver error to the model", async () => {
it("exposes the reason a channel gives with attachmentError to the model", async () => {
const logs = captureLogRecords();
const resolverError = new EveAttachmentError({
adapterKind: "custom-channel",
kind: "resolver-threw",
message: "Slack file fetch returned HTTP 403.",
const resolverError = attachmentError("The file is over 100 MB; send a smaller one.", {
cause: new Error("upstream body exceeded 104857600 bytes"),
});
const adapter: ChannelAdapter<any> = {
async fetchFile() {
Expand All @@ -553,13 +553,15 @@ describe("stageAttachmentsToSandbox (integration)", () => {

expect(staged).toEqual([
{
text: "Attachment a.bin could not be retrieved: Slack file fetch returned HTTP 403.",
text: "Attachment a.bin could not be retrieved: The file is over 100 MB; send a smaller one.",
type: "text",
},
]);
expect(sandbox.writes).toHaveLength(0);
// eve names the channel that ran the resolver in operator logs.
expect(logs.records).toContainEqual(
expect.objectContaining({
fields: expect.objectContaining({ adapterKind: "custom-channel" }),
level: "warn",
message: "attachment resolver failed — degrading to text part",
}),
Expand Down
8 changes: 7 additions & 1 deletion packages/eve/src/harness/attachment-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,13 @@ async function resolveLink(
result = await adapter.fetchFile(url.href, adapterCtx);
} catch (cause) {
if (cause instanceof EveAttachmentError) {
throw cause;
// Operator logs name the channel that ran the resolver, not one it claims.
throw new EveAttachmentError({
adapterKind,
cause: cause.cause,
kind: cause.kind,
message: cause.message,
});
}
throw new EveAttachmentError({
adapterKind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ pnpm --filter eve exec vitest run --config vitest.unit.config.ts channel-conform

| Rule | `tui` | `web chat` | `chat-sdk` | `chat-sdk-dm` | `chat-sdk-text` | `discord` | `discord-dm` | `linq` | `linq-dm` | `slack` | `slack-dm` | `teams` | `teams-dm` | `telegram` | `telegram-dm` | `github` | `linear` | `photon` | `twilio` |
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| an image a person sends reaches the agent with its bytes and type | —<sup>[21](#note-21)</sup> | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a PDF a person sends reaches the agent with its bytes and type | —<sup>[21](#note-21)</sup> | —<sup>[21](#note-21)</sup> | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a file that can't be downloaded reaches the agent as a note, not a link, and the next message still works | —<sup>[21](#note-21)</sup> | —<sup>[21](#note-21)</sup> | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a file sent earlier in the conversation is still there on a later message | —<sup>[21](#note-21)</sup> | —<sup>[21](#note-21)</sup> | ✅ | | ✅ | —<sup>[22](#note-22)</sup> | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| an image a person sends reaches the agent with its bytes and type | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a PDF a person sends reaches the agent with its bytes and type | —<sup>[21](#note-21)</sup> | ✅ | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a file that can't be downloaded reaches the agent as a note, not a link, and the next message still works | —<sup>[21](#note-21)</sup> | —<sup>[22](#note-22)</sup> | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |
| a file sent earlier in the conversation is still there on a later message | —<sup>[21](#note-21)</sup> | ✅ | ✅ | | ✅ | —<sup>[23](#note-23)</sup> | | ✅ | | ✅ | | ✅ | | ✅ | | —<sup>[21](#note-21)</sup> | ✅ | ✅ | ✅ |

## Notes

Expand All @@ -130,4 +130,5 @@ pnpm --filter eve exec vitest run --config vitest.unit.config.ts channel-conform
19. <a id="note-19"></a>the sign-in prompt, link included, is posted to the whole thread
20. <a id="note-20"></a>the code is in the elicitation body the whole issue sees; who sees the auth signal's link is unverified
21. <a id="note-21"></a>the platform has no files a person can send
22. <a id="note-22"></a>each slash command starts its own session, so no later message shares one with the file
22. <a id="note-22"></a>the browser sends a file's bytes with the message, so there is no download to fail
23. <a id="note-23"></a>each slash command starts its own session, so no later message shares one with the file
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ const channelConformance = {
{
driver: webChatDriver,
unsupported: {
"a file that can't be downloaded reaches the agent as a note, not a link, and the next message still works":
"the browser sends a file's bytes with the message, so there is no download to fail",
"pressing an option of an answered question sends it to the agent as new input":
"an answered question disables its options, so nothing is left to press",
"pressing Approve names who approved on the approval": WEB_CHAT_SINGLE_PERSON,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ let bundled: Promise<string> | undefined;
export function webChatDriver(): ClientDriver {
return {
name: "web chat",
capabilities: ["buttons", "text-replies"],
capabilities: ["attachments", "buttons", "text-replies"],
// A browser tab shows only the person using it.
surface: "private",
async open(host, wait) {
Expand Down Expand Up @@ -99,7 +99,17 @@ export function webChatDriver(): ClientDriver {
}

return {
async say(text) {
async say(text, _person, files) {
if (files !== undefined && files.length > 0) {
// The composer's "Add photos or files" button opens this input.
await page.locator('input[type="file"]').setInputFiles(
files.map((file) => ({
buffer: Buffer.from(file.bytes),
mimeType: file.mediaType,
name: file.name,
})),
);
}
await composer.fill(text);
await composer.press("Enter");
},
Expand Down
16 changes: 16 additions & 0 deletions packages/eve/src/public/channels/attachment-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { EveAttachmentError } from "#internal/attachments/errors.js";

/**
* Builds the error a channel's `fetchFile` throws to tell the model why one
* attachment didn't arrive. eve replaces the attachment with
* `Attachment <name> could not be retrieved: <message>`, continues the turn,
* and keeps `cause` in operator logs.
*
* The model reads `message` as written. Name what the person can act on, such
* as a size limit or a missing permission, and never include URLs, tokens, or
* upstream response text. Any other error from `fetchFile` reaches the model as
* a generic note.
*/
export function attachmentError(message: string, options?: { readonly cause?: unknown }): Error {
return new EveAttachmentError({ cause: options?.cause, kind: "resolver-threw", message });
}
Loading
Loading