Skip to content

Commit a423c45

Browse files
committed
feat: docker sandbox function
1 parent 6d532cd commit a423c45

File tree

6 files changed

+271
-83
lines changed

6 files changed

+271
-83
lines changed

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,14 @@ const res = await program.forward({
415415
console.log(res);
416416
```
417417

418+
## Built-in Functions
419+
420+
| Function | Name | Description |
421+
| ------------------ | ------------------ | -------------------------------------------- |
422+
| JS Interpreter | AxJSInterpreter | Execute JS code in a sandboxed env |
423+
| Docker Sandbox | AxDockerSession | Execute commands within a docker environment |
424+
| Embeddings Adapter | AxEmbeddingAdapter | Fetch and pass embedding to your function |
425+
418426
## Checkout all the examples
419427

420428
Use the `tsx` command to run the examples it makes node run typescript code. It also support using a `.env` file to pass the AI API Keys as opposed to putting them in the commandline.
@@ -443,14 +451,7 @@ OPENAI_APIKEY=openai_key npm run tsx ./src/examples/marketing.ts
443451
| smart-hone.ts | Agent looks for dog in smart home |
444452
| multi-modal.ts | Use an image input along with other text inputs |
445453
| balancer.ts | Balance between various llm's based on cost, etc |
446-
447-
## Built-in Functions
448-
449-
| Function | Description |
450-
| ------------------ | ------------------------------------------------------ |
451-
| JS Interpreter | Used by the LLM to execute JS code in a sandboxed env. |
452-
| Docker Sandbox | LLM can Execute commands within a docker environment |
453-
| Embeddings Adapter | Wrapper to fetch and pass embedding to your function |
454+
| docker.ts | Use the docker sandbox to find files by description |
454455

455456
## Our Goal
456457

src/ax/dsp/generate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class AxGenerate<
7777
private pt: AxPromptTemplate;
7878
private asserts: AxAssertion[];
7979
private streamingAsserts: AxStreamingAssertion[];
80-
private options?: AxGenerateOptions;
80+
private options?: Omit<AxGenerateOptions, 'functions'>;
8181

8282
private functions?: AxFunction[];
8383
private funcProc?: AxFunctionProcessor;
@@ -90,7 +90,7 @@ export class AxGenerate<
9090
) {
9191
super(signature);
9292

93-
this.functions = this.options?.functions?.map((f) => {
93+
this.functions = options?.functions?.map((f) => {
9494
if ('toFunction' in f) {
9595
return f.toFunction();
9696
}

src/ax/funcs/code.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ export class AxJSInterpreter {
7070
required: ['code']
7171
},
7272

73-
func: this.codeInterpreterJavascript
73+
func: ({ code }: Readonly<{ code: string }>) =>
74+
this.codeInterpreterJavascript(code)
7475
};
7576
}
7677
}

src/ax/funcs/docker.ts

Lines changed: 223 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,58 @@
11
import type { AxFunction } from '../ai/types.js';
22

3+
export interface AxDockerContainer {
4+
Id: string;
5+
Names: string[];
6+
Image: string;
7+
ImageID: string;
8+
Command: string;
9+
Created: number;
10+
State: {
11+
Status: string;
12+
Running: boolean;
13+
Paused: boolean;
14+
Restarting: boolean;
15+
OOMKilled: boolean;
16+
Dead: boolean;
17+
Pid: number;
18+
ExitCode: number;
19+
Error: string;
20+
StartedAt: Date;
21+
FinishedAt: Date;
22+
};
23+
Status: string;
24+
Ports: Array<{
25+
IP: string;
26+
PrivatePort: number;
27+
PublicPort: number;
28+
Type: string;
29+
}>;
30+
Labels: { [key: string]: string };
31+
SizeRw: number;
32+
SizeRootFs: number;
33+
HostConfig: {
34+
NetworkMode: string;
35+
};
36+
NetworkSettings: {
37+
Networks: {
38+
[key: string]: {
39+
IPAddress: string;
40+
IPPrefixLen: number;
41+
Gateway: string;
42+
MacAddress: string;
43+
};
44+
};
45+
};
46+
Mounts: Array<{
47+
Type: string;
48+
Source: string;
49+
Destination: string;
50+
Mode: string;
51+
RW: boolean;
52+
Propagation: string;
53+
}>;
54+
}
55+
356
export class AxDockerSession {
457
private readonly apiUrl: string;
558
private containerId: string | null = null;
@@ -26,31 +79,40 @@ export class AxDockerSession {
2679

2780
async createContainer({
2881
imageName,
29-
volumes = [], // Example format: [{ hostPath: '/host/path', containerPath: '/container/path' }]
30-
doNotPullImage
82+
volumes = [],
83+
doNotPullImage,
84+
tag
3185
}: Readonly<{
3286
imageName: string;
3387
volumes?: Array<{ hostPath: string; containerPath: string }>;
3488
doNotPullImage?: boolean;
89+
tag?: string;
3590
}>) {
3691
const binds = volumes.map((v) => `${v.hostPath}:${v.containerPath}`);
3792

3893
if (!doNotPullImage) {
3994
await this.pullImage(imageName);
4095
}
4196

97+
const containerConfig = {
98+
Image: imageName,
99+
Tty: true,
100+
OpenStdin: false,
101+
AttachStdin: false,
102+
AttachStdout: false,
103+
AttachStderr: false,
104+
HostConfig: { Binds: binds },
105+
Labels: {} as Record<string, string>
106+
};
107+
108+
if (tag) {
109+
containerConfig.Labels['com.example.tag'] = tag;
110+
}
111+
42112
const response = await this.fetchDockerAPI(`/containers/create`, {
43113
method: 'POST',
44114
headers: { 'Content-Type': 'application/json' },
45-
body: JSON.stringify({
46-
Image: imageName,
47-
Tty: true,
48-
OpenStdin: false,
49-
AttachStdin: false,
50-
AttachStdout: false,
51-
AttachStderr: false,
52-
HostConfig: { Binds: binds }
53-
})
115+
body: JSON.stringify(containerConfig)
54116
});
55117

56118
if (!response.ok) {
@@ -63,6 +125,47 @@ export class AxDockerSession {
63125
return data;
64126
}
65127

128+
async findOrCreateContainer({
129+
imageName,
130+
volumes = [],
131+
doNotPullImage,
132+
tag
133+
}: Readonly<{
134+
imageName: string;
135+
volumes?: Array<{ hostPath: string; containerPath: string }>;
136+
doNotPullImage?: boolean;
137+
tag: string;
138+
}>): Promise<{ Id: string; isNew: boolean }> {
139+
// First, try to find existing containers with the given tag
140+
const existingContainers = await this.listContainers(true);
141+
const matchingContainers = existingContainers.filter(
142+
(container) =>
143+
container.Labels && container.Labels['com.example.tag'] === tag
144+
);
145+
146+
if (matchingContainers && matchingContainers.length > 0) {
147+
// Randomly select a container from the matching ones
148+
const randomIndex = Math.floor(Math.random() * matchingContainers.length);
149+
const selectedContainer = matchingContainers[randomIndex];
150+
151+
if (selectedContainer) {
152+
// Connect to the selected container
153+
await this.connectToContainer(selectedContainer.Id);
154+
return { Id: selectedContainer.Id, isNew: false };
155+
}
156+
}
157+
158+
// If no container with the tag exists, create a new one
159+
const newContainer = await this.createContainer({
160+
imageName,
161+
volumes,
162+
doNotPullImage,
163+
tag
164+
});
165+
166+
return { Id: newContainer.Id, isNew: true };
167+
}
168+
66169
async startContainer(): Promise<void> {
67170
if (!this.containerId) {
68171
throw new Error('No container created or connected');
@@ -92,11 +195,99 @@ export class AxDockerSession {
92195
this.containerId = containerId;
93196
}
94197

198+
async stopContainers({
199+
tag,
200+
remove,
201+
timeout = 10
202+
}: Readonly<{ tag?: string; remove?: boolean; timeout?: number }>): Promise<
203+
Array<{ Id: string; Action: 'stopped' | 'removed' }>
204+
> {
205+
const results: Array<{ Id: string; Action: 'stopped' | 'removed' }> = [];
206+
207+
// List all containers
208+
const containers = await this.listContainers(true);
209+
210+
// Filter containers by tag if provided
211+
const targetContainers = tag
212+
? containers.filter(
213+
(container) => container.Labels['com.example.tag'] === tag
214+
)
215+
: containers;
216+
217+
for (const container of targetContainers) {
218+
// Stop the container if it's running
219+
if (container.State.Status === 'running') {
220+
const stopResponse = await this.fetchDockerAPI(
221+
`/containers/${container.Id}/stop?t=${timeout}`,
222+
{ method: 'POST' }
223+
);
224+
225+
if (!stopResponse.ok) {
226+
console.warn(
227+
`Failed to stop container ${container.Id}: ${stopResponse.statusText}`
228+
);
229+
continue;
230+
}
231+
232+
results.push({ Id: container.Id, Action: 'stopped' });
233+
}
234+
235+
// Remove the container if the remove flag is set
236+
if (remove) {
237+
const removeResponse = await this.fetchDockerAPI(
238+
`/containers/${container.Id}`,
239+
{ method: 'DELETE' }
240+
);
241+
242+
if (!removeResponse.ok) {
243+
console.warn(
244+
`Failed to remove container ${container.Id}: ${removeResponse.statusText}`
245+
);
246+
continue;
247+
}
248+
249+
results.push({ Id: container.Id, Action: 'removed' });
250+
}
251+
}
252+
253+
return results;
254+
}
255+
256+
async listContainers(all: boolean = false): Promise<AxDockerContainer[]> {
257+
const response = await this.fetchDockerAPI(`/containers/json?all=${all}`, {
258+
method: 'GET'
259+
});
260+
return response.json() as Promise<AxDockerContainer[]>;
261+
}
262+
263+
async getContainerLogs(): Promise<string> {
264+
if (!this.containerId) {
265+
throw new Error('No container created or connected');
266+
}
267+
const response = await this.fetchDockerAPI(
268+
`/containers/${this.containerId}/logs?stdout=true&stderr=true`,
269+
{ method: 'GET' }
270+
);
271+
return response.text();
272+
}
273+
95274
async executeCommand(command: string) {
275+
console.log('Executing command:', command);
276+
96277
if (!this.containerId) {
97278
throw new Error('No container created or connected');
98279
}
99280

281+
// Check container state
282+
const containerInfo = await this.getContainerInfo(this.containerId);
283+
284+
if (containerInfo.State.Status !== 'running') {
285+
await this.startContainer();
286+
287+
// Wait for the container to be in the "running" state
288+
await this.waitForContainerToBeRunning(this.containerId);
289+
}
290+
100291
// Create exec instance
101292
const createResponse = await this.fetchDockerAPI(
102293
`/containers/${this.containerId}/exec`,
@@ -142,39 +333,33 @@ export class AxDockerSession {
142333
return await startResponse.text();
143334
}
144335

145-
async stopContainer() {
146-
if (!this.containerId) {
147-
throw new Error('No container created or connected');
148-
}
336+
// Add these new methods to the class:
149337

338+
private async getContainerInfo(
339+
containerId: string
340+
): Promise<AxDockerContainer> {
150341
const response = await this.fetchDockerAPI(
151-
`/containers/${this.containerId}/stop`,
152-
{
153-
method: 'POST'
154-
}
342+
`/containers/${containerId}/json`
155343
);
156-
157344
if (!response.ok) {
158-
throw new Error(`Failed to stop container: ${response.statusText}`);
345+
throw new Error(`Failed to get container info: ${response.statusText}`);
159346
}
347+
return response.json() as Promise<AxDockerContainer>;
160348
}
161349

162-
async listContainers(all: boolean = false) {
163-
const response = await this.fetchDockerAPI(`/containers/json?all=${all}`, {
164-
method: 'GET'
165-
});
166-
return response.json();
167-
}
168-
169-
async getContainerLogs(): Promise<string> {
170-
if (!this.containerId) {
171-
throw new Error('No container created or connected');
350+
private async waitForContainerToBeRunning(
351+
containerId: string,
352+
timeout: number = 30000
353+
): Promise<void> {
354+
const startTime = Date.now();
355+
while (Date.now() - startTime < timeout) {
356+
const containerInfo = await this.getContainerInfo(containerId);
357+
if (containerInfo.State.Status === 'running') {
358+
return;
359+
}
360+
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second before checking again
172361
}
173-
const response = await this.fetchDockerAPI(
174-
`/containers/${this.containerId}/logs?stdout=true&stderr=true`,
175-
{ method: 'GET' }
176-
);
177-
return response.text();
362+
throw new Error('Timeout waiting for container to start');
178363
}
179364

180365
private async fetchDockerAPI(
@@ -202,7 +387,8 @@ export class AxDockerSession {
202387
required: ['command']
203388
},
204389

205-
func: this.executeCommand
390+
func: async ({ command }: Readonly<{ command: string }>) =>
391+
await this.executeCommand(command)
206392
};
207393
}
208394
}

src/ax/funcs/embed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ export class AxEmbeddingAdapter {
7070
},
7171
required: ['text']
7272
},
73-
func: this.embedAdapter
73+
func: ({ text }: Readonly<{ text: string }>, options) =>
74+
this.embedAdapter(text, options)
7475
};
7576
}
7677
}

0 commit comments

Comments
 (0)