Add configurable Daytona sandbox lifecycle behavior, deterministic sandbox names, and optional snapshot-first creation with image fallback for agent knowledge sandboxes.

This commit is contained in:
Robin Braumann
2026-06-18 18:31:17 +02:00
parent 47fd1115eb
commit 8d378dc829
4 changed files with 105 additions and 12 deletions
@@ -57,6 +57,10 @@ export class AgentsConfig {
@Env('N8N_AGENTS_AI_SANDBOX_IMAGE')
sandboxImage: string = 'daytonaio/sandbox:0.5.0';
/** Daytona snapshot name for agent knowledge sandboxes. Falls back to image when unavailable. */
@Env('N8N_AGENTS_AI_SANDBOX_SNAPSHOT')
sandboxSnapshot: string = '';
/** Default command timeout in the sandbox (milliseconds). */
@Env('N8N_AGENTS_AI_SANDBOX_TIMEOUT')
sandboxTimeout: number = 5 * Time.minutes.toMilliseconds;
+10
View File
@@ -591,6 +591,7 @@ describe('GlobalConfig', () => {
sandboxEnabled: false,
sandboxProvider: '',
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: '',
sandboxTimeout: 300000,
sandboxEphemeral: false,
daytonaVolumeId: '',
@@ -618,6 +619,15 @@ describe('GlobalConfig', () => {
expect(config.agents.sandboxEphemeral).toBe(true);
});
it('should parse N8N_AGENTS_AI_SANDBOX_SNAPSHOT from env variables', () => {
process.env = {
N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3',
};
const config = Container.get(GlobalConfig);
expect(config.agents.sandboxSnapshot).toBe('n8n/agent-knowledge:1.2.3');
});
it('should use values from env variables when defined', () => {
process.env = {
DB_POSTGRESDB_HOST: 'some-host',
@@ -121,6 +121,7 @@ function makeService(
sandboxEnabled: true,
sandboxProvider: 'daytona',
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: '',
sandboxTimeout: 300_000,
sandboxEphemeral: false,
daytonaApiUrl: 'https://daytona.example',
@@ -209,6 +210,8 @@ describe('AgentKnowledgeSandboxService', () => {
});
expect(params.volumes).toEqual([expectedVolumeMount]);
expect(params.ephemeral).toBe(false);
expect(params.image).toBe('daytonaio/sandbox:0.5.0');
expect(params.snapshot).toBeUndefined();
expect(options).toEqual({ timeout: 300 });
});
@@ -271,4 +274,56 @@ describe('AgentKnowledgeSandboxService', () => {
expect(listMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
});
it('creates a sandbox from configured snapshot', async () => {
const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' });
const expectedName = buildExpectedSandboxName();
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
expect(createMock).toHaveBeenCalledTimes(1);
const [params] = createMock.mock.calls[0];
expect(params.snapshot).toBe('n8n/agent-knowledge:1.2.3');
expect(params.image).toBeUndefined();
expect(params.name).toBe(expectedName);
expect(params.ephemeral).toBe(false);
expect(params.autoStopInterval).toBe(5);
expect(params.volumes).toEqual([expectedVolumeMount]);
});
it('falls back to image when configured snapshot create fails', async () => {
const logger = mock<Logger>();
createMock
.mockRejectedValueOnce(new Error('snapshot missing'))
.mockResolvedValueOnce(makeSandbox('started'));
const service = makeService({ sandboxSnapshot: 'n8n/agent-knowledge:missing' }, logger);
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
expect(createMock).toHaveBeenCalledTimes(2);
const [snapshotParams] = createMock.mock.calls[0];
const [imageParams] = createMock.mock.calls[1];
expect(snapshotParams.snapshot).toBe('n8n/agent-knowledge:missing');
expect(snapshotParams.image).toBeUndefined();
expect(imageParams.image).toBe('daytonaio/sandbox:0.5.0');
expect(imageParams.snapshot).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Agent knowledge sandbox create from snapshot failed; falling back to image',
expect.objectContaining({
projectId,
agentId,
snapshotName: 'n8n/agent-knowledge:missing',
}),
);
});
it('ignores whitespace-only sandboxSnapshot', async () => {
const service = makeService({ sandboxSnapshot: ' ' });
await service.withKnowledgeFilesystem(projectId, agentId, userId, async () => {});
const [params] = createMock.mock.calls[0];
expect(params.image).toBe('daytonaio/sandbox:0.5.0');
expect(params.snapshot).toBeUndefined();
});
});
@@ -49,6 +49,7 @@ interface AgentKnowledgeDaytonaConnection {
apiUrl?: string;
apiKey?: string;
image: string;
snapshot?: string;
mode: 'direct' | 'proxy';
}
@@ -224,21 +225,41 @@ export class AgentKnowledgeSandboxService {
}
const image = connection.image;
const baseCreateParams = {
name,
labels,
language: 'typescript' as const,
ephemeral: this.agentsConfig.sandboxEphemeral,
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
volumes: [volumeMount],
};
let sandbox: Sandbox;
try {
sandbox = await daytona.create(
{
name,
labels,
language: 'typescript',
image,
ephemeral: this.agentsConfig.sandboxEphemeral,
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
volumes: [volumeMount],
},
{ timeout: timeoutSeconds },
);
if (connection.snapshot) {
try {
sandbox = await daytona.create(
{ ...baseCreateParams, snapshot: connection.snapshot },
{ timeout: timeoutSeconds },
);
} catch (error) {
this.logger.warn(
'Agent knowledge sandbox create from snapshot failed; falling back to image',
{
projectId,
agentId,
snapshotName: connection.snapshot,
error: error instanceof Error ? error.message : String(error),
},
);
sandbox = await daytona.create(
{ ...baseCreateParams, image },
{ timeout: timeoutSeconds },
);
}
} else {
sandbox = await daytona.create({ ...baseCreateParams, image }, { timeout: timeoutSeconds });
}
} catch (error) {
if (connection.mode === 'proxy' && isVolumeMountFailure(error)) {
const message = error instanceof Error ? error.message : String(error);
@@ -256,6 +277,7 @@ export class AgentKnowledgeSandboxService {
private async resolveDaytonaConnection(userId: string): Promise<AgentKnowledgeDaytonaConnection> {
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
if (!this.aiService.isProxyEnabled()) {
return {
@@ -263,6 +285,7 @@ export class AgentKnowledgeSandboxService {
apiUrl: this.agentsConfig.daytonaApiUrl || undefined,
apiKey: this.agentsConfig.daytonaApiKey || undefined,
image: directImage,
snapshot,
};
}
@@ -275,6 +298,7 @@ export class AgentKnowledgeSandboxService {
apiUrl: client.getSandboxProxyBaseUrl(),
apiKey: token.accessToken,
image: proxyConfig.image || directImage,
snapshot,
};
}