Skip to content

Commit 88375a2

Browse files
authored
feat(tasks): upload agent-created artifacts (#3754)
1 parent e237b19 commit 88375a2

5 files changed

Lines changed: 260 additions & 1 deletion

File tree

docs/cloud-task-artifacts.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Cloud task artifacts
2+
3+
Cloud agents run with `/tmp/workspace` as the sandbox workspace. A repository task uses `/tmp/workspace/repos/<owner>/<repo>` as its working directory; a repository-free task uses `/tmp/workspace` directly. Claude and Codex receive the same working directory, so artifact paths do not vary by model or adapter.
4+
5+
Files left in the sandbox filesystem are temporary and cannot be downloaded after the sandbox is released. Agents should call the `upload_artifact` tool for every non-code deliverable they create. The tool accepts files inside the session working directory, uploads them directly to task object storage, and registers them as `output` artifacts on the task run. Registered artifacts are available through the existing task artifact download endpoint.
6+
7+
Repository changes should continue to be delivered through git rather than duplicated as task artifacts. A single uploaded artifact is limited to 30 MB.

packages/agent/src/adapters/local-tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { signedCommitTool } from "./tools/signed-commit";
66
import { signedMergeTool } from "./tools/signed-merge";
77
import { signedRewriteTool } from "./tools/signed-rewrite";
88
import { speakTool } from "./tools/speak";
9+
import { uploadArtifactTool } from "./tools/upload-artifact";
910

1011
export {
1112
LOCAL_TOOLS_MCP_NAME,
@@ -24,6 +25,7 @@ export const LOCAL_TOOLS: LocalTool[] = [
2425
listReposTool,
2526
cloneRepoTool,
2627
speakTool,
28+
uploadArtifactTool,
2729
finishTool,
2830
];
2931

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const prepareTaskArtifactUploads = vi.fn();
7+
const finalizeTaskArtifactUploads = vi.fn();
8+
9+
vi.mock("../../../signed-commit-artefacts", () => ({
10+
createSandboxPosthogClient: () => ({
11+
prepareTaskArtifactUploads,
12+
finalizeTaskArtifactUploads,
13+
}),
14+
}));
15+
16+
import { uploadArtifactTool } from "./upload-artifact";
17+
18+
describe("uploadArtifactTool", () => {
19+
let cwd: string;
20+
21+
beforeEach(async () => {
22+
vi.clearAllMocks();
23+
cwd = await mkdtemp(path.join(os.tmpdir(), "upload-artifact-"));
24+
vi.stubGlobal(
25+
"fetch",
26+
vi.fn().mockResolvedValue({ ok: true, status: 204 }),
27+
);
28+
prepareTaskArtifactUploads.mockResolvedValue([
29+
{
30+
id: "artifact-1",
31+
name: "report.csv",
32+
type: "output",
33+
size: 7,
34+
storage_path: "tasks/artifacts/report.csv",
35+
expires_in: 300,
36+
presigned_post: {
37+
url: "https://storage.example/upload",
38+
fields: { key: "value" },
39+
},
40+
},
41+
]);
42+
finalizeTaskArtifactUploads.mockResolvedValue([
43+
{
44+
id: "artifact-1",
45+
name: "report.csv",
46+
type: "output",
47+
size: 7,
48+
storage_path: "tasks/artifacts/report.csv",
49+
uploaded_at: "2026-01-01T00:00:00Z",
50+
},
51+
]);
52+
});
53+
54+
afterEach(async () => {
55+
vi.restoreAllMocks();
56+
await rm(cwd, { recursive: true, force: true });
57+
});
58+
59+
it("uploads and finalizes a workspace file as an output artifact", async () => {
60+
await writeFile(path.join(cwd, "report.csv"), "a,b\n1,2");
61+
62+
const result = await uploadArtifactTool.handler(
63+
{ cwd, taskId: "task-1", taskRunId: "run-1" },
64+
{ path: "report.csv", contentType: "text/csv" },
65+
);
66+
67+
expect(result.isError).toBeUndefined();
68+
expect(prepareTaskArtifactUploads).toHaveBeenCalledWith("task-1", "run-1", [
69+
{ name: "report.csv", type: "output", size: 7, content_type: "text/csv" },
70+
]);
71+
expect(fetch).toHaveBeenCalledWith(
72+
"https://storage.example/upload",
73+
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
74+
);
75+
expect(finalizeTaskArtifactUploads).toHaveBeenCalledWith(
76+
"task-1",
77+
"run-1",
78+
[
79+
expect.objectContaining({
80+
id: "artifact-1",
81+
type: "output",
82+
storage_path: "tasks/artifacts/report.csv",
83+
}),
84+
],
85+
);
86+
});
87+
88+
it("rejects files outside the session workspace", async () => {
89+
const outside = await mkdtemp(path.join(os.tmpdir(), "outside-artifact-"));
90+
const outsideFile = path.join(outside, "secret.txt");
91+
await writeFile(outsideFile, "secret");
92+
93+
try {
94+
const result = await uploadArtifactTool.handler(
95+
{ cwd, taskId: "task-1", taskRunId: "run-1" },
96+
{ path: outsideFile },
97+
);
98+
99+
expect(result.isError).toBe(true);
100+
expect(result.content[0]?.text).toContain("inside the session workspace");
101+
expect(prepareTaskArtifactUploads).not.toHaveBeenCalled();
102+
} finally {
103+
await rm(outside, { recursive: true, force: true });
104+
}
105+
});
106+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { readFile, realpath, stat } from "node:fs/promises";
2+
import path from "node:path";
3+
import { z } from "zod";
4+
import { createSandboxPosthogClient } from "../../../signed-commit-artefacts";
5+
import { defineLocalTool, type LocalToolResult } from "../registry";
6+
7+
const MAX_ARTIFACT_UPLOAD_BYTES = 30 * 1024 * 1024;
8+
9+
export const uploadArtifactTool = defineLocalTool({
10+
name: "upload_artifact",
11+
description:
12+
"Deliver a file you created to the user as a downloadable task artifact. " +
13+
"Call this for every non-code deliverable (reports, images, archives, data files, and similar output) " +
14+
"before your final response. The file must be inside the session workspace. Repository changes belong in git and should not be uploaded.",
15+
schema: {
16+
path: z
17+
.string()
18+
.min(1)
19+
.describe(
20+
"Absolute path, or a path relative to the session working directory.",
21+
),
22+
name: z
23+
.string()
24+
.min(1)
25+
.optional()
26+
.describe("Download filename. Defaults to the source filename."),
27+
contentType: z
28+
.string()
29+
.min(1)
30+
.optional()
31+
.describe("MIME type. Defaults to application/octet-stream."),
32+
},
33+
alwaysLoad: true,
34+
isEnabled: (ctx, meta) =>
35+
meta?.environment === "cloud" && !!ctx.taskId && !!ctx.taskRunId,
36+
handler: async (ctx, args): Promise<LocalToolResult> => {
37+
if (!ctx.taskId || !ctx.taskRunId) {
38+
return errorResult("Artifact upload is not available in this session.");
39+
}
40+
41+
try {
42+
const workspace = await realpath(ctx.cwd);
43+
const requestedPath = path.resolve(ctx.cwd, args.path);
44+
const artifactPath = await realpath(requestedPath);
45+
if (
46+
artifactPath !== workspace &&
47+
!artifactPath.startsWith(`${workspace}${path.sep}`)
48+
) {
49+
return errorResult("Artifact must be inside the session workspace.");
50+
}
51+
52+
const fileStat = await stat(artifactPath);
53+
if (!fileStat.isFile()) {
54+
return errorResult("Artifact path must point to a file.");
55+
}
56+
if (fileStat.size > MAX_ARTIFACT_UPLOAD_BYTES) {
57+
return errorResult("Artifact exceeds the 30 MB upload limit.");
58+
}
59+
60+
const client = createSandboxPosthogClient();
61+
if (!client) {
62+
return errorResult(
63+
"PostHog artifact storage is not configured in this sandbox.",
64+
);
65+
}
66+
67+
const name = args.name ?? path.basename(artifactPath);
68+
const contentType = args.contentType ?? "application/octet-stream";
69+
const prepared = await client.prepareTaskArtifactUploads(
70+
ctx.taskId,
71+
ctx.taskRunId,
72+
[
73+
{
74+
name,
75+
type: "output",
76+
size: fileStat.size,
77+
content_type: contentType,
78+
},
79+
],
80+
);
81+
const upload = prepared[0];
82+
if (!upload) {
83+
return errorResult("PostHog did not prepare the artifact upload.");
84+
}
85+
86+
const form = new FormData();
87+
for (const [key, value] of Object.entries(upload.presigned_post.fields)) {
88+
form.append(key, value);
89+
}
90+
form.append(
91+
"file",
92+
new Blob([await readFile(artifactPath)], { type: contentType }),
93+
name,
94+
);
95+
const response = await fetch(upload.presigned_post.url, {
96+
method: "POST",
97+
body: form,
98+
});
99+
if (!response.ok) {
100+
return errorResult(
101+
`Artifact storage upload failed (${response.status}).`,
102+
);
103+
}
104+
105+
const finalized = await client.finalizeTaskArtifactUploads(
106+
ctx.taskId,
107+
ctx.taskRunId,
108+
[
109+
{
110+
id: upload.id,
111+
name,
112+
type: "output",
113+
storage_path: upload.storage_path,
114+
content_type: contentType,
115+
},
116+
],
117+
);
118+
if (
119+
!finalized.some(
120+
(artifact) => artifact.storage_path === upload.storage_path,
121+
)
122+
) {
123+
return errorResult("PostHog did not confirm the artifact upload.");
124+
}
125+
126+
return {
127+
content: [
128+
{
129+
type: "text",
130+
text: `Uploaded ${name} as a downloadable task artifact. Mention it in your final response.`,
131+
},
132+
],
133+
};
134+
} catch (error) {
135+
return errorResult(
136+
`Artifact upload failed: ${error instanceof Error ? error.message : String(error)}`,
137+
);
138+
}
139+
},
140+
});
141+
142+
function errorResult(message: string): LocalToolResult {
143+
return { content: [{ type: "text", text: message }], isError: true };
144+
}

packages/agent/src/signed-commit-artefacts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function resolveSandboxPosthogApi(
5858
return { apiUrl, apiKey, projectId };
5959
}
6060

61-
function createSandboxPosthogClient(
61+
export function createSandboxPosthogClient(
6262
env?: Record<string, string | undefined>,
6363
envFilePath?: string,
6464
): PostHogAPIClient | undefined {

0 commit comments

Comments
 (0)