Skip to content
Open
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/honor-missing-attachment-on-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Resuming a durable session that referenced an inbound file attachment no longer fails the turn when the staging sandbox has been torn down. The missing attachment now degrades to a text reference noting the file is unavailable, so the run continues instead of ending in `session.failed`.
59 changes: 54 additions & 5 deletions packages/eve/src/harness/attachment-staging.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,19 +611,24 @@ describe("hydrateSandboxAttachments (integration)", () => {
expect(hydrated).toBe(messages);
});

it("throws a descriptive error when an inlinable sandbox ref points at a missing file", async () => {
it("degrades to a text reference when an inlinable sandbox ref points at a missing file", async () => {
// Resuming a durable session whose staging sandbox was torn down
// leaves historical attachment refs pointing at bytes that are gone.
// Hydration must not fail the whole turn over it — it degrades to a
// text part so the run survives (#276).
const sandbox = mockSandbox({ id: "sbx_missing" });
const runtime = createTestRuntime();

// Ref pointing at a path that was never written. Use an
// inlinable media type so the error path (byte read) fires —
// inlinable media type so the byte-read path fires —
// non-inlinable refs never touch the sandbox.
const danglingRef = new URL(
"eve-sandbox:?path=%2Fworkspace%2Fattachments%2Fdeadbeefdeadbeef%2Fghost.png&size=5&type=image%2Fpng",
);
const messages = [
{
content: [
{ type: "text", text: "what's in the image?" },
{
data: danglingRef,
filename: "/workspace/attachments/deadbeefdeadbeef/ghost.png",
Expand All @@ -635,9 +640,53 @@ describe("hydrateSandboxAttachments (integration)", () => {
},
];

await expect(
runtime.runAsSession({ sandbox }, async () => hydrateSandboxAttachments(messages)),
).rejects.toThrow(/references missing file/);
const hydrated = await runtime.runAsSession({ sandbox }, async () =>
hydrateSandboxAttachments(messages),
);

const hydratedContent = hydrated[0]?.content as Exclude<UserContent, string>;
// The leading text survives and the dangling file ref is replaced by
// an unavailability notice — no file part reaches the model.
expect(hydratedContent[0]).toEqual({ type: "text", text: "what's in the image?" });
expect(hydratedContent[1]).toEqual({
text: "Attached file /workspace/attachments/deadbeefdeadbeef/ghost.png (image/png) is no longer available in this session.",
type: "text",
});
const fileParts = hydratedContent.filter((p) => (p as FilePart).type === "file");
expect(fileParts).toHaveLength(0);
});

it("survives a resume after the staging sandbox was torn down (#276)", async () => {
// Stage an image into one sandbox, then hydrate the resulting
// ref-only message against a fresh sandbox — the same shape as
// resuming a durable session whose ephemeral sandbox is gone. The
// bytes are unreachable, but the turn must not fail.
const stagingSandbox = mockSandbox({ id: "sbx_resume_stage" });
const runtime = createTestRuntime();

const stagedContent = (await runtime.runAsSession({ sandbox: stagingSandbox }, async () =>
stageAttachmentsToSandbox([
{ type: "text", text: "describe the image" },
{ data: smallImageBytes, filename: "logo.png", mediaType: "image/png", type: "file" },
] as UserContent),
)) as UserContent;

const refPart = (stagedContent as FilePart[]).find((p) => p.type === "file") as FilePart;
const stagedPath = refPart.filename as string;

const messages = [{ content: stagedContent, role: "user" as const }];
const freshSandbox = mockSandbox({ id: "sbx_resume_fresh" });
const hydrated = await runtime.runAsSession({ sandbox: freshSandbox }, async () =>
hydrateSandboxAttachments(messages),
);

const hydratedContent = hydrated[0]?.content as Exclude<UserContent, string>;
expect(hydratedContent[0]).toEqual({ type: "text", text: "describe the image" });
expect(hydratedContent[1]).toEqual({
text: `Attached file ${stagedPath} (image/png) is no longer available in this session.`,
type: "text",
});
expect(hydratedContent.filter((p) => (p as FilePart).type === "file")).toHaveLength(0);
});

it("does not touch the sandbox when every ref is non-inlinable — text references carry all the info", async () => {
Expand Down
35 changes: 32 additions & 3 deletions packages/eve/src/harness/attachment-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SandboxKey } from "#context/keys.js";
import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js";
import { fileDataToBytes } from "#internal/attachments/data.js";
import { EveAttachmentError } from "#internal/attachments/errors.js";
import { createLogger } from "#internal/logging.js";
import { deserializeUrlFilePart, isSerializedUrlFilePart } from "#internal/attachments/url-refs.js";
import {
decodeSandboxRef,
Expand All @@ -28,6 +29,8 @@ import { toErrorMessage } from "#shared/errors.js";
*/
export const ATTACHMENTS_ROOT = "/workspace/attachments";

const log = createLogger("harness.attachment-staging");

const UNSAFE_FILENAME_CHARS = /[^\w.-]+/g;
const SHA_PREFIX_LENGTH = 16;

Expand Down Expand Up @@ -229,10 +232,22 @@ async function hydrateMessageContent(content: unknown, sandbox: SandboxSession):
}
const bytes = await sandbox.readBinaryFile({ path: ref.path });
if (bytes === null) {
throw new Error(
`Sandbox-ref FilePart references missing file: "${ref.path}". ` +
"The staging pipeline invariant (every eve-sandbox: ref has bytes on disk) was violated.",
// Resuming a durable session can outlive the sandbox that staged
// an earlier turn's attachment: an ephemeral backend tears the
// sandbox down between turns, so the bytes behind a historical
// ref are gone. Degrade to a text reference instead of failing
// the whole turn — the run continues and the model is told the
// attachment is no longer reachable rather than chasing a path
// that has nothing behind it.
log.warn(
"sandbox-ref attachment bytes missing on hydration — degrading to text reference",
{
mediaType: ref.mediaType,
path: ref.path,
size: ref.size,
},
);
return renderMissingSandboxRefAsTextPart(ref);
}
return { ...filePart, data: bytes, mediaType: ref.mediaType };
}),
Expand Down Expand Up @@ -273,6 +288,20 @@ function renderSandboxRefAsTextPart(ref: SandboxRef): TextPart {
return { text: `Attached file ${ref.path} (${ref.mediaType})`, type: "text" };
}

/**
* Renders a sandbox-resident attachment whose staged bytes are gone (the
* staging sandbox was torn down before the session resumed) as a
* {@link TextPart}. Unlike {@link renderSandboxRefAsTextPart}, this states
* the file is unreachable so the model does not try to read a path that no
* longer resolves.
*/
function renderMissingSandboxRefAsTextPart(ref: SandboxRef): TextPart {
return {
text: `Attached file ${ref.path} (${ref.mediaType}) is no longer available in this session.`,
type: "text",
};
}

async function stageFilePart(
part: FilePart,
sandbox: SandboxSession,
Expand Down