Skip to content

Commit 51932ae

Browse files
committed
Add anthropic container provider
1 parent 19dd7b2 commit 51932ae

File tree

8 files changed

+343
-2
lines changed

8 files changed

+343
-2
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Example demonstrating the Code Execution Middleware with Anthropic.
3+
*
4+
* This middleware enables Claude to execute code in a sandboxed container,
5+
* analyze uploaded files, and generate output files. The example demonstrates
6+
* multi-turn conversation with container and file state persistence.
7+
*/
8+
9+
import { ChatAnthropic } from "@langchain/anthropic";
10+
import { AnthropicContainerProvider } from "@langchain/anthropic/middleware";
11+
import { HumanMessage } from "@langchain/core/messages";
12+
import { MemorySaver } from "@langchain/langgraph";
13+
import {
14+
codeExecutionMiddleware,
15+
createAgent,
16+
MemoryFileProvider,
17+
} from "langchain";
18+
import fs from "node:fs/promises";
19+
import { join } from "node:path";
20+
21+
// Initial setup
22+
const model = new ChatAnthropic({
23+
model: "claude-sonnet-4-20250514",
24+
});
25+
26+
const middleware = codeExecutionMiddleware(
27+
new AnthropicContainerProvider(),
28+
new MemoryFileProvider()
29+
);
30+
31+
const agent = createAgent({
32+
model,
33+
middleware: [middleware],
34+
checkpointer: new MemorySaver(),
35+
});
36+
37+
const thread = {
38+
configurable: {
39+
thread_id: "test-123",
40+
},
41+
};
42+
43+
// Read and add the test data file
44+
const testDataPath = "test_data.csv";
45+
const fileContent = await fs.readFile(testDataPath);
46+
47+
// First invocation - should create container and analyze uploaded data
48+
const response1 = await agent.invoke(
49+
{
50+
messages: new HumanMessage("Filter to just widget A"),
51+
files: [await middleware.addFile(testDataPath, fileContent)],
52+
},
53+
thread
54+
);
55+
console.log("Response 1:", response1.messages);
56+
57+
// Second invocation - should reuse container and previous analysis
58+
const response2 = await agent.invoke(
59+
{
60+
messages: new HumanMessage(
61+
"Turn that into a graph of sales and units over time."
62+
),
63+
},
64+
thread
65+
);
66+
console.log("Response 2:", response2.messages);
67+
68+
// Extract and download generated files
69+
const generatedFiles = middleware
70+
.files(response2)
71+
.filter(({ type, path }) => type === "tool" && path.endsWith(".png"));
72+
73+
for (const file of generatedFiles) {
74+
const content = await file.getContent();
75+
const outputPath = join(".", file.path);
76+
await fs.writeFile(outputPath, content);
77+
console.log(`Downloaded generated file: ${outputPath}`);
78+
}

β€Žlibs/providers/langchain-anthropic/package.jsonβ€Ž

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,26 @@
3333
"@anthropic-ai/sdk": "^0.65.0"
3434
},
3535
"peerDependencies": {
36-
"@langchain/core": "^1.0.0-alpha.6"
36+
"@langchain/core": "^1.0.0-alpha.6",
37+
"langchain": "workspace:*"
38+
},
39+
"peerDependenciesMeta": {
40+
"langchain": {
41+
"optional": true
42+
}
3743
},
3844
"devDependencies": {
3945
"@anthropic-ai/vertex-sdk": "^0.11.5",
4046
"@langchain/core": "workspace:*",
4147
"@langchain/eslint": "workspace:*",
48+
"@langchain/langgraph-checkpoint": "^0.1.1",
4249
"@langchain/standard-tests": "workspace:*",
4350
"@tsconfig/recommended": "^1.0.10",
4451
"@vitest/coverage-v8": "^3.2.4",
4552
"dotenv": "^17.2.1",
4653
"dpdm": "^3.14.0",
4754
"eslint": "^9.34.0",
55+
"langchain": "workspace:*",
4856
"prettier": "^2.8.3",
4957
"rimraf": "^5.0.1",
5058
"typescript": "~5.8.3",
@@ -82,9 +90,20 @@
8290
},
8391
"input": "./src/index.ts"
8492
},
93+
"./middleware": {
94+
"import": {
95+
"types": "./dist/middleware/index.d.ts",
96+
"default": "./dist/middleware/index.js"
97+
},
98+
"require": {
99+
"types": "./dist/middleware/index.d.cts",
100+
"default": "./dist/middleware/index.cjs"
101+
},
102+
"input": "./src/middleware/index.ts"
103+
},
85104
"./package.json": "./package.json"
86105
},
87106
"files": [
88107
"dist/"
89108
]
90-
}
109+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import Anthropic, { toFile } from "@anthropic-ai/sdk";
2+
import { HumanMessage } from "@langchain/core/messages";
3+
import {
4+
ContainerInfo,
5+
ContainerProvider,
6+
FileFromProvider,
7+
ModelRequest,
8+
UploadFileToContainerOpts,
9+
UploadFileToContainerRet,
10+
} from "langchain";
11+
import { extractGeneratedFiles } from "../utils/extractGeneratedFiles.js";
12+
13+
export class AnthropicContainerProvider implements ContainerProvider {
14+
tools = [{ type: "code_execution_20250825", name: "code_execution" }];
15+
16+
async startContainer(): Promise<ContainerInfo> {
17+
// Anthropic automatically creates a container when you first use the code
18+
// execution tool, so we don't need to do anything here.
19+
return {};
20+
}
21+
22+
async uploadFileToContainer({
23+
path,
24+
providerId,
25+
getContent,
26+
}: UploadFileToContainerOpts): Promise<UploadFileToContainerRet> {
27+
// This function doesn't actually upload the file to the container. Instead,
28+
// we upload the file to Anthropic using the files API. We will pass the
29+
// Anthropic-provided file ID to the container using a special message in
30+
// modifyModelRequest.
31+
if (providerId != null) {
32+
// File already uploaded
33+
return { providerId, isPendingModelResponse: true };
34+
}
35+
36+
const response = await new Anthropic().beta.files.upload({
37+
file: await toFile(await getContent(), path),
38+
betas: ["files-api-2025-04-14"],
39+
});
40+
41+
// We use isPendingModelResponse to indicate that the file won't be in the
42+
// container until after the model responds.
43+
return { providerId: response.id, isPendingModelResponse: true };
44+
}
45+
46+
async modifyModelRequest<TState extends Record<string, unknown>, TContext>(
47+
containerId: string | undefined,
48+
newFiles: string[],
49+
request: ModelRequest<TState, TContext>
50+
): Promise<ModelRequest<TState, TContext> | void> {
51+
return {
52+
...request,
53+
messages: [
54+
...newFiles.map(
55+
(fileId) =>
56+
new HumanMessage({
57+
content: [{ type: "container_upload", file_id: fileId }],
58+
})
59+
),
60+
...request.messages,
61+
],
62+
modelSettings: {
63+
// Pass container ID to reuse files across turns
64+
container: containerId,
65+
66+
// Automatically inject required beta headers for Anthropic code execution
67+
headers: {
68+
"anthropic-beta": "code-execution-2025-08-25,files-api-2025-04-14",
69+
},
70+
},
71+
};
72+
}
73+
74+
extractFilesFromModelResponse(
75+
messages: ModelRequest["messages"]
76+
): Promise<FileFromProvider[]> {
77+
// TODO: Is it correct to just look at the last message?
78+
return Promise.all(
79+
extractGeneratedFiles(
80+
messages[
81+
messages.length - 1
82+
] as unknown as Anthropic.Beta.Messages.BetaMessage
83+
).map(async (fileId) => {
84+
const content = Buffer.from(
85+
await (await new Anthropic().beta.files.download(fileId)).bytes()
86+
);
87+
88+
const metadata = await new Anthropic().beta.files.retrieveMetadata(
89+
fileId
90+
);
91+
92+
return {
93+
providerId: fileId,
94+
content,
95+
path: metadata.filename,
96+
type: "tool",
97+
};
98+
})
99+
);
100+
}
101+
102+
extractContainerFromModelResponse(
103+
messages: ModelRequest["messages"]
104+
): ContainerInfo | undefined {
105+
const newContainer = messages.find(
106+
(message) => message.type === "ai" && message.additional_kwargs?.container
107+
)?.additional_kwargs?.container as Container | undefined;
108+
109+
return newContainer == null
110+
? undefined
111+
: { id: newContainer.id, expiresAt: new Date(newContainer.expires_at) };
112+
}
113+
}
114+
115+
interface Container {
116+
id: string;
117+
expires_at: string;
118+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AnthropicContainerProvider } from "./containerProvider.js";
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { HumanMessage } from "@langchain/core/messages";
2+
import { MemorySaver } from "@langchain/langgraph-checkpoint";
3+
import {
4+
codeExecutionMiddleware,
5+
createAgent,
6+
MemoryFileProvider,
7+
} from "langchain";
8+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
9+
import fs from "node:fs/promises";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
13+
import { ChatAnthropic } from "../../chat_models.js";
14+
import { AnthropicContainerProvider } from "../containerProvider.js";
15+
16+
const thread = {
17+
configurable: {
18+
thread_id: "test-123",
19+
},
20+
};
21+
22+
describe("dataAnalysisMiddleware integration tests", () => {
23+
let model: ChatAnthropic;
24+
let outputDir: string;
25+
const testDataPath = "test_data.csv";
26+
const fullTestDataPath = join(__dirname, "fixtures", testDataPath);
27+
28+
beforeAll(() => {
29+
model = new ChatAnthropic({
30+
model: "claude-sonnet-4-20250514", // Haiku is a bit too dumb
31+
});
32+
33+
// Create temporary directory for test outputs
34+
outputDir = mkdtempSync(join(tmpdir(), "langchain-test-"));
35+
});
36+
37+
afterAll(() => {
38+
// Clean up temporary directory
39+
if (existsSync(outputDir)) {
40+
rmSync(outputDir, { recursive: true, force: true });
41+
}
42+
});
43+
44+
it(
45+
"should upload file, analyze data, and download generated files",
46+
{
47+
timeout: 120000, // 2 minute timeout for API calls
48+
},
49+
async () => {
50+
const middleware = codeExecutionMiddleware(
51+
new AnthropicContainerProvider(),
52+
new MemoryFileProvider()
53+
);
54+
55+
// Create agent with data analysis middleware
56+
const agent = createAgent({
57+
model,
58+
middleware: [middleware],
59+
checkpointer: new MemorySaver(),
60+
});
61+
62+
// Invoke agent with analysis task
63+
const result1 = await agent.invoke(
64+
{
65+
messages: new HumanMessage("Filter to just widget A"),
66+
files: [
67+
await middleware.addFile(
68+
testDataPath,
69+
await fs.readFile(fullTestDataPath)
70+
),
71+
],
72+
},
73+
thread
74+
);
75+
76+
// Verify first response
77+
expect(result1.messages).toBeTruthy();
78+
expect(result1.messages.length).toBeGreaterThan(0);
79+
80+
const result2 = await agent.invoke(
81+
{
82+
messages: new HumanMessage(
83+
"Turn that into a graph of sales and units over time."
84+
),
85+
},
86+
thread
87+
);
88+
89+
// Verify second response and extract generated files
90+
expect(result2.messages).toBeTruthy();
91+
expect(result2.messages.length).toBeGreaterThan(0);
92+
93+
const generatedImages = middleware
94+
.files(result2)
95+
.filter(({ type, path }) => type === "tool" && path.endsWith(".png"));
96+
97+
expect(generatedImages.length).toBeGreaterThan(0);
98+
99+
for (const img of generatedImages) {
100+
const content = await img.getContent();
101+
expect(content.length).toBeGreaterThan(0);
102+
// Basic check that it's a PNG file
103+
expect(content.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a");
104+
}
105+
}
106+
);
107+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
date,region,product,sales,units
2+
2024-01-15,North,Widget A,1250.50,25
3+
2024-01-16,South,Widget B,890.25,18
4+
2024-01-17,East,Widget A,1456.75,29
5+
2024-01-18,West,Widget C,2100.00,42
6+
2024-01-19,North,Widget B,765.50,15
7+
2024-01-20,South,Widget A,1890.00,38
8+
2024-01-21,East,Widget C,2340.25,47
9+
2024-01-22,West,Widget B,1123.75,22
10+
2024-01-23,North,Widget C,1567.50,31
11+
2024-01-24,South,Widget B,934.25,19

β€Žlibs/providers/langchain-anthropic/src/types.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type AnthropicWebSearchToolResultBlockParam =
4848
export type AnthropicWebSearchResultBlockParam =
4949
Anthropic.Messages.WebSearchResultBlockParam;
5050
export type AnthropicSearchResultBlockParam = Anthropic.SearchResultBlockParam;
51+
// TODO(hntrl): beta blocks should be separated
5152
export type AnthropicContainerUploadBlockParam =
5253
Anthropic.Beta.BetaContainerUploadBlockParam;
5354

β€Žpnpm-lock.yamlβ€Ž

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
Β (0)