mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Isolate Agent sandboxes by principal (#36203)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { InMemoryFilesystem } from './test-utils';
|
||||
import {
|
||||
getToolResultRunDirectory,
|
||||
isToolResultPath,
|
||||
reconcileToolResultRuns,
|
||||
storeToolResult,
|
||||
} from '../../workspace/tool-result-storage';
|
||||
|
||||
const TOOL_RESULT_RUNS_DIRECTORY = 'tool-results/runs';
|
||||
|
||||
async function writeRunFile(filesystem: InMemoryFilesystem, runId: string): Promise<void> {
|
||||
await filesystem.writeFile(`${getToolResultRunDirectory(runId)}/payload.json`, '{}', {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('tool result storage', () => {
|
||||
it('stores results only under the hashed run and rejects thread-scoped paths', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
|
||||
const path = await storeToolResult(
|
||||
filesystem,
|
||||
{ runId: 'run-1', toolCallId: 'tool-call-1' },
|
||||
'result',
|
||||
'{"ok":true}',
|
||||
);
|
||||
|
||||
expect(path).toMatch(
|
||||
/^tool-results\/runs\/[A-Za-z0-9_-]{43}\/[A-Za-z0-9_-]{43}\.result\.json$/,
|
||||
);
|
||||
expect(isToolResultPath(path)).toBe(true);
|
||||
expect(
|
||||
isToolResultPath(
|
||||
`tool-results/threads/${'a'.repeat(43)}/${'b'.repeat(43)}/${'c'.repeat(43)}.result.json`,
|
||||
),
|
||||
).toBe(false);
|
||||
await expect(filesystem.readFile(path, { encoding: 'utf8' })).resolves.toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
it('reconciles only old unprotected run directories', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const originalStat = filesystem.stat.bind(filesystem);
|
||||
for (const runId of ['active', 'suspended', 'recent', 'old']) {
|
||||
await writeRunFile(filesystem, runId);
|
||||
}
|
||||
await filesystem.writeFile(`${TOOL_RESULT_RUNS_DIRECTORY}/ordinary.txt`, 'keep');
|
||||
vi.spyOn(filesystem, 'stat').mockImplementation(async (path, options) => {
|
||||
const stat = await originalStat(path, options);
|
||||
return path === getToolResultRunDirectory('recent')
|
||||
? { ...stat, modifiedAt: new Date() }
|
||||
: stat;
|
||||
});
|
||||
|
||||
await reconcileToolResultRuns(filesystem, ['active', 'suspended'], 60_000);
|
||||
|
||||
await expect(filesystem.exists(getToolResultRunDirectory('active'))).resolves.toBe(true);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory('suspended'))).resolves.toBe(true);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory('recent'))).resolves.toBe(true);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory('old'))).resolves.toBe(false);
|
||||
await expect(filesystem.exists(`${TOOL_RESULT_RUNS_DIRECTORY}/ordinary.txt`)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('examines at most 100 unprotected orphan candidates per pass', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const runIds = Array.from({ length: 101 }, (_, index) => `old-run-${index}`);
|
||||
await writeRunFile(filesystem, 'protected');
|
||||
for (const runId of runIds) await writeRunFile(filesystem, runId);
|
||||
|
||||
await reconcileToolResultRuns(filesystem, ['protected'], 60_000);
|
||||
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runIds[99]))).resolves.toBe(false);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runIds[100]))).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,7 @@ describe('createWorkspaceTools', () => {
|
||||
|
||||
it('read_tool_result describes and pages a nested result through escaped pointers', async () => {
|
||||
const hash = 'a'.repeat(43);
|
||||
const path = `tool-results/threads/${hash}/${hash}/${hash}.result.json`;
|
||||
const path = `tool-results/runs/${hash}/${hash}.result.json`;
|
||||
const escapedKey = 'folder/name~version';
|
||||
const largeString = '"\\\n'.repeat(12_000);
|
||||
const storedResult = {
|
||||
|
||||
@@ -424,7 +424,7 @@ export {
|
||||
CORE_WORKSPACE_TOOL_NAMES,
|
||||
createScopedWorkspace,
|
||||
createWorkspaceTools,
|
||||
getToolResultThreadDirectory,
|
||||
reconcileToolResultRuns,
|
||||
} from './workspace';
|
||||
export { SandboxProcessManager, ProcessHandle } from './workspace';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { StreamChunk } from '../../types/sdk/agent';
|
||||
import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message';
|
||||
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool';
|
||||
import type { BuiltTelemetry } from '../../types/telemetry';
|
||||
import { Workspace, createWorkspaceTools } from '../../workspace';
|
||||
import { Workspace, getToolResultRunDirectory } from '../../workspace';
|
||||
import { AgentRuntime } from '../loop/agent-runtime';
|
||||
import { InMemoryMemory } from '../memory/memory-store';
|
||||
import { AgentEventBus } from '../state/event-bus';
|
||||
@@ -7810,20 +7810,28 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function modelToolResults(): unknown[] {
|
||||
const call = generateText.mock.calls[1][0] as {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: Array<{ type: string; output?: { type: string; value: unknown } }>;
|
||||
}>;
|
||||
};
|
||||
return call.messages
|
||||
type ModelMessages = Array<{
|
||||
role: string;
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
output?: { type: string; value: unknown };
|
||||
}>;
|
||||
}>;
|
||||
|
||||
function toolResultsFromModelMessages(messages: ModelMessages): unknown[] {
|
||||
return messages
|
||||
.filter((message) => message.role === 'tool')
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === 'tool-result')
|
||||
.map((part) => part.output?.value);
|
||||
}
|
||||
|
||||
function modelToolResults(): unknown[] {
|
||||
const call = generateText.mock.calls[1][0] as { messages: ModelMessages };
|
||||
return toolResultsFromModelMessages(call.messages);
|
||||
}
|
||||
|
||||
function createWorkspaceAgent(filesystem: InMemoryFilesystem, tools: BuiltTool[]) {
|
||||
return new Agent('workspace-result-test')
|
||||
.model('openai/gpt-4o-mini')
|
||||
@@ -7833,6 +7841,52 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
.workspace(new Workspace({ filesystem }));
|
||||
}
|
||||
|
||||
function createRunScopedRuntime(
|
||||
filesystem: InMemoryFilesystem,
|
||||
tools: BuiltTool[],
|
||||
runId: string,
|
||||
eventBus?: AgentEventBus,
|
||||
checkpointStorage: 'memory' | CheckpointStore = 'memory',
|
||||
): AgentRuntime {
|
||||
return new AgentRuntime({
|
||||
name: 'workspace-result-test',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
instructions: 'Test',
|
||||
tools,
|
||||
runId,
|
||||
checkpointStorage,
|
||||
workspaceFilesystem: filesystem,
|
||||
toolCallConcurrency: Infinity,
|
||||
...(eventBus ? { eventBus } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function seedUnaffectedWorkspaceFiles(filesystem: InMemoryFilesystem): Promise<void> {
|
||||
await filesystem.writeFile(`${getToolResultRunDirectory('concurrent-run')}/keep.json`, '{}', {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectOnlyCurrentRunRemoved(
|
||||
filesystem: InMemoryFilesystem,
|
||||
runId: string,
|
||||
): Promise<void> {
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory('concurrent-run'))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const largeResultOutput = {
|
||||
value: 'x '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000),
|
||||
};
|
||||
const largeResultTool: BuiltTool = {
|
||||
name: 'large_result',
|
||||
description: 'large result',
|
||||
inputSchema: z.object({}),
|
||||
handler: async () => await Promise.resolve(largeResultOutput),
|
||||
};
|
||||
|
||||
it('stores complete concurrent transformed results without changing raw outputs', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const transformedOutputs = {
|
||||
@@ -7849,6 +7903,7 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
}),
|
||||
);
|
||||
const agent = createWorkspaceAgent(filesystem, tools);
|
||||
const storedResultsSeenByNextModel: string[] = [];
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([
|
||||
@@ -7856,7 +7911,14 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
{ toolCallId: 'tc/second', toolName: 'second', args: {} },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
.mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => {
|
||||
for (const envelope of toolResultsFromModelMessages(messages) as OffloadedEnvelope[]) {
|
||||
storedResultsSeenByNextModel.push(
|
||||
String(await filesystem.readFile(envelope.path, { encoding: 'utf8' })),
|
||||
);
|
||||
}
|
||||
return await Promise.resolve(makeGenerateSuccess());
|
||||
});
|
||||
|
||||
const result = await agent.generate('run', {
|
||||
persistence: { threadId: 'slack:C123:123.456', resourceId: 'user-1' },
|
||||
@@ -7872,29 +7934,143 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
input: { path, view: 'describe' },
|
||||
})),
|
||||
);
|
||||
await expect(filesystem.readFile(envelopes[0].path, { encoding: 'utf8' })).resolves.toBe(
|
||||
expect(storedResultsSeenByNextModel).toEqual([
|
||||
JSON.stringify(transformedOutputs.first),
|
||||
);
|
||||
await expect(filesystem.readFile(envelopes[1].path, { encoding: 'utf8' })).resolves.toBe(
|
||||
JSON.stringify(transformedOutputs.second),
|
||||
);
|
||||
]);
|
||||
expect(result.toolCalls?.map(({ output }) => output)).toEqual([
|
||||
{ raw: 'first' },
|
||||
{ raw: 'second' },
|
||||
]);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(result.runId))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
const reader = createWorkspaceTools({ filesystem }).find(
|
||||
(tool) => tool.name === 'workspace_read_tool_result',
|
||||
it('retains current run results when checkpoint deletion fails', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const checkpointStorage: CheckpointStore = {
|
||||
save: vi.fn(async () => await Promise.resolve()),
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockRejectedValue(new Error('checkpoint delete failed')),
|
||||
};
|
||||
const runId = 'checkpoint-delete-failed-run';
|
||||
const runtime = createRunScopedRuntime(
|
||||
filesystem,
|
||||
[largeResultTool],
|
||||
runId,
|
||||
undefined,
|
||||
checkpointStorage,
|
||||
);
|
||||
if (!reader?.handler) throw new Error('Expected workspace_read_tool_result');
|
||||
await expect(reader.handler(envelopes[0].requiredAction.input, {} as never)).resolves.toEqual(
|
||||
{
|
||||
view: 'describe',
|
||||
pointer: '',
|
||||
type: 'object',
|
||||
childCount: 1,
|
||||
},
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
|
||||
await runtime.generate('run');
|
||||
|
||||
expect(checkpointStorage.delete).toHaveBeenCalledWith(runId);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('removes only the current run results after a terminal model error', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const runId = 'failed-run';
|
||||
await seedUnaffectedWorkspaceFiles(filesystem);
|
||||
const runtime = createRunScopedRuntime(filesystem, [largeResultTool], runId);
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} },
|
||||
]),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('model unavailable'));
|
||||
|
||||
await runtime.generate('run');
|
||||
|
||||
await expectOnlyCurrentRunRemoved(filesystem, runId);
|
||||
});
|
||||
|
||||
it('removes only the current run results after cancellation', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const eventBus = new AgentEventBus();
|
||||
const runId = 'cancelled-run';
|
||||
eventBus.on(AgentEvent.ToolExecutionEnd, () => eventBus.abort());
|
||||
const runtime = createRunScopedRuntime(filesystem, [largeResultTool], runId, eventBus);
|
||||
generateText.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} },
|
||||
]),
|
||||
);
|
||||
|
||||
await runtime.generate('run');
|
||||
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('completes terminal runs when result cleanup hangs', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const runId = 'hung-cleanup-run';
|
||||
await filesystem.writeFile(`${getToolResultRunDirectory(runId)}/result.json`, '{}', {
|
||||
recursive: true,
|
||||
});
|
||||
vi.spyOn(filesystem, 'rmdir').mockReturnValue(new Promise(() => undefined));
|
||||
const runtime = createRunScopedRuntime(filesystem, [], runId);
|
||||
generateText.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
|
||||
await expect(runtime.generate('run')).resolves.toMatchObject({ finishReason: 'stop' });
|
||||
});
|
||||
|
||||
it('retains offloaded results across suspension and re-suspension, then removes them', async () => {
|
||||
const filesystem = new InMemoryFilesystem();
|
||||
const checkpointStorage = makeClaimingCheckpointStore();
|
||||
const approvalTool = makeSuspendingTool('approval', async (_input, ctx) => {
|
||||
if (ctx.resumeData) return { approved: true };
|
||||
return await ctx.suspend({ reason: 'approve' });
|
||||
});
|
||||
const runId = 'suspended-run';
|
||||
const runtime = createRunScopedRuntime(
|
||||
filesystem,
|
||||
[largeResultTool, approvalTool],
|
||||
runId,
|
||||
undefined,
|
||||
checkpointStorage,
|
||||
);
|
||||
generateText.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([
|
||||
{ toolCallId: 'tc-large', toolName: largeResultTool.name, args: {} },
|
||||
{ toolCallId: 'tc-approval-1', toolName: approvalTool.name, args: {} },
|
||||
{ toolCallId: 'tc-approval-2', toolName: approvalTool.name, args: {} },
|
||||
]),
|
||||
);
|
||||
|
||||
await runtime.generate('run', {
|
||||
persistence: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
});
|
||||
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true);
|
||||
|
||||
const second = await runtime.resume(
|
||||
'generate',
|
||||
{ approved: true },
|
||||
{ runId, toolCallId: 'tc-approval-1' },
|
||||
);
|
||||
expect(second.pendingSuspend?.map(({ toolCallId }) => toolCallId)).toEqual(['tc-approval-2']);
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(true);
|
||||
|
||||
let resultReadByResumedModel: string | undefined;
|
||||
generateText.mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => {
|
||||
const [resumedEnvelope] = toolResultsFromModelMessages(messages) as OffloadedEnvelope[];
|
||||
resultReadByResumedModel = String(
|
||||
await filesystem.readFile(resumedEnvelope.path, { encoding: 'utf8' }),
|
||||
);
|
||||
return await Promise.resolve(makeGenerateSuccess('done'));
|
||||
});
|
||||
await runtime.resume('generate', { approved: true }, { runId, toolCallId: 'tc-approval-2' });
|
||||
|
||||
expect(resultReadByResumedModel).toBe(JSON.stringify(largeResultOutput));
|
||||
await expect(filesystem.exists(getToolResultRunDirectory(runId))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('stores oversized errors without changing the rejected tool-call state', async () => {
|
||||
@@ -7908,11 +8084,19 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
},
|
||||
};
|
||||
const agent = createWorkspaceAgent(filesystem, [tool]);
|
||||
let storedError: string | undefined;
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([{ toolCallId: 'tc-error', toolName: tool.name, args: {} }]),
|
||||
)
|
||||
.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
.mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => {
|
||||
const [errorEnvelope] = toolResultsFromModelMessages(messages);
|
||||
const envelope = parseOffloadedEnvelope(
|
||||
typeof errorEnvelope === 'string' ? errorEnvelope : undefined,
|
||||
);
|
||||
storedError = String(await filesystem.readFile(envelope.path, { encoding: 'utf8' }));
|
||||
return await Promise.resolve(makeGenerateSuccess());
|
||||
});
|
||||
|
||||
const result = await agent.generate('run', {
|
||||
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
|
||||
@@ -7923,19 +8107,9 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
(content): content is ContentToolCall =>
|
||||
content.type === 'tool-call' && content.toolCallId === 'tc-error',
|
||||
);
|
||||
const envelope = parseOffloadedEnvelope(
|
||||
toolCall?.state === 'rejected' ? toolCall.error : undefined,
|
||||
);
|
||||
|
||||
expect(toolCall?.state).toBe('rejected');
|
||||
expect(envelope._offloaded).toBe(true);
|
||||
expect(envelope.requiredAction).toEqual({
|
||||
toolName: 'workspace_read_tool_result',
|
||||
input: { path: envelope.path, view: 'describe' },
|
||||
});
|
||||
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toContain(
|
||||
'ERROR_HEAD',
|
||||
);
|
||||
expect(storedError).toContain('ERROR_HEAD');
|
||||
});
|
||||
|
||||
it('stores oversized custom-message text while preserving file content', async () => {
|
||||
@@ -7957,11 +8131,20 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
}),
|
||||
};
|
||||
const agent = createWorkspaceAgent(filesystem, [tool]);
|
||||
let storedMessage: string | undefined;
|
||||
generateText
|
||||
.mockResolvedValueOnce(
|
||||
makeGenerateWithToolCalls([{ toolCallId: 'tc-message', toolName: tool.name, args: {} }]),
|
||||
)
|
||||
.mockResolvedValueOnce(makeGenerateSuccess());
|
||||
.mockImplementationOnce(async ({ messages }: { messages: ModelMessages }) => {
|
||||
const envelopeText = messages
|
||||
.filter((message) => message.role === 'assistant')
|
||||
.flatMap((message) => message.content)
|
||||
.find((part) => part.type === 'text')?.text;
|
||||
const envelope = parseOffloadedEnvelope(envelopeText);
|
||||
storedMessage = String(await filesystem.readFile(envelope.path, { encoding: 'utf8' }));
|
||||
return await Promise.resolve(makeGenerateSuccess());
|
||||
});
|
||||
|
||||
const result = await agent.generate('run', {
|
||||
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
|
||||
@@ -7971,25 +8154,13 @@ describe('AgentRuntime — oversized tool results', () => {
|
||||
'content' in candidate &&
|
||||
candidate.content.some((content) => content.type === 'file' && content.data === fileData),
|
||||
);
|
||||
const text =
|
||||
message && 'content' in message
|
||||
? message.content.find((content) => content.type === 'text')?.text
|
||||
: undefined;
|
||||
const envelope = parseOffloadedEnvelope(text);
|
||||
|
||||
expect(envelope._offloaded).toBe(true);
|
||||
expect(envelope.requiredAction).toEqual({
|
||||
toolName: 'workspace_read_tool_result',
|
||||
input: { path: envelope.path, view: 'describe' },
|
||||
});
|
||||
expect(message && 'content' in message ? message.content : []).toContainEqual({
|
||||
type: 'file',
|
||||
mediaType: 'text/plain',
|
||||
data: fileData,
|
||||
});
|
||||
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toBe(
|
||||
JSON.stringify([{ type: 'text', text: messageText }]),
|
||||
);
|
||||
expect(storedMessage).toBe(JSON.stringify([{ type: 'text', text: messageText }]));
|
||||
});
|
||||
|
||||
it('falls back to bounded truncation when storing the result fails', async () => {
|
||||
|
||||
@@ -699,7 +699,7 @@ describe('createDelegateSubAgentTool', () => {
|
||||
expect(executionCounter.incrementTokenCount).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('forwards the parent persistence thread id and resource id', async () => {
|
||||
it('forwards the parent persistence scope', async () => {
|
||||
const runSubAgent = vi
|
||||
.fn<DelegateSubAgentRunner>()
|
||||
.mockResolvedValue({ status: 'completed', taskPath: '/root/research_api', answer: 'done' });
|
||||
@@ -707,13 +707,18 @@ describe('createDelegateSubAgentTool', () => {
|
||||
|
||||
await tool.handler?.(input, {
|
||||
runId: 'parent-run-1',
|
||||
persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' },
|
||||
persistence: {
|
||||
threadId: 'parent-thread-1',
|
||||
resourceId: 'resource-1',
|
||||
hostMetadata: { tenant: 'tenant-1', scope: { id: 'scope-1' } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(runSubAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentThreadId: 'parent-thread-1',
|
||||
parentResourceId: 'resource-1',
|
||||
parentHostMetadata: { tenant: 'tenant-1', scope: { id: 'scope-1' } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runInlineSubAgent: expect.any(Function),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AgentMessageList } from '../model/message-list';
|
||||
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
||||
import { AgentEventBus } from '../state/event-bus';
|
||||
import { RuntimeTelemetry } from '../telemetry/runtime-telemetry';
|
||||
import { EXPIRED_OFFLOADED_TOOL_RESULT } from '../tools/tool-result-guard';
|
||||
|
||||
const THREAD_ID = 'thread-1';
|
||||
const RESOURCE_ID = 'user-1';
|
||||
@@ -247,6 +248,71 @@ describe('MemoryOrchestrator.persistTurnDelta', () => {
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({ type: AgentEvent.Error, source: 'turn-delta-persistence' });
|
||||
});
|
||||
|
||||
it.each(['persistTurnDelta', 'saveToMemory'] as const)(
|
||||
'sanitizes offloaded result locators for %s',
|
||||
async (method) => {
|
||||
const store = new InMemoryMemory();
|
||||
await store.saveThread({ id: THREAD_ID, resourceId: RESOURCE_ID });
|
||||
const runHash = 'a'.repeat(43);
|
||||
const envelope = (kind: 'result' | 'error' | 'message') => {
|
||||
const path = `tool-results/runs/${runHash}/${kind[0].repeat(43)}.${kind}.json`;
|
||||
return {
|
||||
_offloaded: true as const,
|
||||
path,
|
||||
originalCharCount: 100_000,
|
||||
estimatedTokenCount: 60_000,
|
||||
requiredAction: {
|
||||
toolName: 'workspace_read_tool_result' as const,
|
||||
input: { path, view: 'describe' as const },
|
||||
},
|
||||
message: 'Stored in workspace',
|
||||
};
|
||||
};
|
||||
const list = new AgentMessageList();
|
||||
list.addResponse([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'result-call',
|
||||
toolName: 'result-tool',
|
||||
input: {},
|
||||
state: 'resolved',
|
||||
output: envelope('result'),
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'error-call',
|
||||
toolName: 'error-tool',
|
||||
input: {},
|
||||
state: 'rejected',
|
||||
error: JSON.stringify(envelope('error')),
|
||||
},
|
||||
{ type: 'text', text: JSON.stringify(envelope('message')) },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const orchestrator = buildOrchestrator(store);
|
||||
await orchestrator[method](list, PERSIST);
|
||||
|
||||
const [persisted] = await store.getMessages(THREAD_ID, { resourceId: RESOURCE_ID });
|
||||
if (!persisted || !('content' in persisted)) throw new Error('Expected persisted message');
|
||||
expect(persisted.content).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'resolved',
|
||||
output: EXPIRED_OFFLOADED_TOOL_RESULT,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
state: 'rejected',
|
||||
error: JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT),
|
||||
}),
|
||||
{ type: 'text', text: JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT) },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('MemoryOrchestrator save-path telemetry', () => {
|
||||
|
||||
@@ -54,7 +54,8 @@ import type { AgentMessage, ContentToolCall } from '../../types/sdk/message';
|
||||
import type { JSONValue } from '../../types/utils/json';
|
||||
import { getModelIdString } from '../../utils/model';
|
||||
import { parseWithSchema } from '../../utils/parse';
|
||||
import type { WorkspaceFilesystem } from '../../workspace';
|
||||
import { removeToolResultRun, type WorkspaceFilesystem } from '../../workspace';
|
||||
import { createFilteredLogger } from '../logger';
|
||||
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
|
||||
import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner';
|
||||
import { generateThreadTitle } from '../memory/title-generation';
|
||||
@@ -143,6 +144,7 @@ const MAX_LOOP_ITERATIONS = 30;
|
||||
|
||||
/** Retries for a `stop` turn that produced no output at all (see isEmptyModelTurn). */
|
||||
const MAX_EMPTY_TURN_RETRIES = 2;
|
||||
const logger = createFilteredLogger();
|
||||
|
||||
const EMPTY_MESSAGE_LIST: SerializedMessageList = {
|
||||
messages: [],
|
||||
@@ -169,7 +171,7 @@ interface LoopContext {
|
||||
*
|
||||
* Memory strategy:
|
||||
* - `filterLlmMessages` strips custom messages before sending to the LLM.
|
||||
* - Memory stores ALL AgentMessages (including custom) unchanged.
|
||||
* - Memory stores all messages, but expires run-scoped offload locators.
|
||||
* - New messages for each turn are tracked via AgentMessageList.turnDelta(),
|
||||
* which uses Set-based source tracking to identify turn-only messages.
|
||||
* The list serializes with id-based sets so it can survive process restarts.
|
||||
@@ -292,7 +294,6 @@ export class AgentRuntime {
|
||||
list = builtList;
|
||||
return this.finalizeGenerate(rawResult, list);
|
||||
} catch (error) {
|
||||
await this.telemetry.flush(options);
|
||||
const isAbort = abortScope.isAborted;
|
||||
this.updateState({ status: isAbort ? 'cancelled' : 'failed' });
|
||||
if (isAbort) {
|
||||
@@ -302,6 +303,8 @@ export class AgentRuntime {
|
||||
} else {
|
||||
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
||||
}
|
||||
await this.cleanupRun();
|
||||
await this.telemetry.flush(options);
|
||||
return {
|
||||
runId: this.runId,
|
||||
messages: list?.responseDelta() ?? [],
|
||||
@@ -381,6 +384,7 @@ export class AgentRuntime {
|
||||
|
||||
let resumeData: unknown = data;
|
||||
let abortScope: AgentAbortScope | undefined;
|
||||
let resumeClaimed = false;
|
||||
|
||||
const resumeSchema = toolCall.suspended ? toolCall.resumeSchema : tool.resumeSchema;
|
||||
if (!isCancellation(resumeData) && resumeSchema) {
|
||||
@@ -428,6 +432,7 @@ export class AgentRuntime {
|
||||
if (!claimed) {
|
||||
throw new StaleResumeError(`Run ${this.runId} is not suspended. Cannot resume.`);
|
||||
}
|
||||
resumeClaimed = true;
|
||||
await options.onResumeClaimed?.();
|
||||
|
||||
abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal);
|
||||
@@ -486,6 +491,7 @@ export class AgentRuntime {
|
||||
if (!isAbort) {
|
||||
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
||||
}
|
||||
if (resumeClaimed) await this.cleanupRun();
|
||||
if (method === 'generate') {
|
||||
return {
|
||||
runId: this.runId,
|
||||
@@ -1109,7 +1115,20 @@ export class AgentRuntime {
|
||||
|
||||
/** Clean up stored state for a run when it finishes without re-suspending. */
|
||||
private async cleanupRun(): Promise<void> {
|
||||
await this.runState.complete(this.runId);
|
||||
try {
|
||||
await this.runState.complete(this.runId);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clean up agent run checkpoint', { runId: this.runId, error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.workspaceFilesystem) {
|
||||
try {
|
||||
await removeToolResultRun(this.config.workspaceFilesystem, this.runId);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clean up agent run tool results', { runId: this.runId, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit a TurnEnd event when an assistant message is present in `newMessages`. */
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type MemorySpanAttributes,
|
||||
type RuntimeTelemetry,
|
||||
} from '../telemetry/runtime-telemetry';
|
||||
import { sanitizeOffloadedToolResultsForMemory } from '../tools/tool-result-guard';
|
||||
|
||||
const DEFAULT_MEMORY_TASK_LOCK_TTL_MS = 30_000;
|
||||
const logger = createFilteredLogger();
|
||||
@@ -270,7 +271,7 @@ export class MemoryOrchestrator {
|
||||
): Promise<void> {
|
||||
const memory = this.config.memory;
|
||||
if (!memory || !options?.persistence) return;
|
||||
const delta = list.turnDelta();
|
||||
const delta = sanitizeOffloadedToolResultsForMemory(list.turnDelta());
|
||||
if (delta.length === 0) return;
|
||||
try {
|
||||
const telemetry = this.runtimeTelemetry.resolve(options);
|
||||
@@ -305,7 +306,7 @@ export class MemoryOrchestrator {
|
||||
): Promise<void> {
|
||||
const memory = this.config.memory;
|
||||
if (!memory || !options?.persistence) return;
|
||||
const delta = list.turnDelta();
|
||||
const delta = sanitizeOffloadedToolResultsForMemory(list.turnDelta());
|
||||
if (delta.length === 0) return;
|
||||
const telemetry = this.runtimeTelemetry.resolve(options);
|
||||
await this.saveMessagesWithSpan(
|
||||
|
||||
@@ -110,11 +110,7 @@ export class RunStateManager {
|
||||
|
||||
/** Delete a finished run from storage. Called when a resumed run completes without re-suspending. */
|
||||
async complete(runId: string): Promise<void> {
|
||||
try {
|
||||
await this.store.delete(runId);
|
||||
} catch (deleteError: unknown) {
|
||||
console.error(`[RunStateManager] Failed to delete checkpoint ${runId}:`, deleteError);
|
||||
}
|
||||
await this.store.delete(runId);
|
||||
}
|
||||
|
||||
/** Delete a cancelled run and surface failures so its parent can remain retryable. */
|
||||
|
||||
@@ -32,7 +32,7 @@ import type {
|
||||
ToolContext,
|
||||
} from '../../types/sdk/tool';
|
||||
import type { BuiltTelemetry } from '../../types/telemetry';
|
||||
import type { JSONValue } from '../../types/utils/json';
|
||||
import type { JSONObject, JSONValue } from '../../types/utils/json';
|
||||
import { withoutMessageCount } from '../loop/execution-counter';
|
||||
|
||||
export const DELEGATE_SUB_AGENT_TOOL_NAME = 'delegate_subagent';
|
||||
@@ -169,6 +169,8 @@ export interface DelegateSubAgentRequest extends DelegateSubAgentInput {
|
||||
parentThreadId?: string;
|
||||
/** Parent's episodic-memory resource id (`ctx.persistence.resourceId`). */
|
||||
parentResourceId?: string;
|
||||
/** Opaque host metadata from the parent's persistence scope. */
|
||||
parentHostMetadata?: JSONObject;
|
||||
/** Parent's tool-call id that triggered this delegation. */
|
||||
parentToolCallId?: string;
|
||||
/**
|
||||
@@ -718,6 +720,9 @@ function createDelegateSubAgentRequest(
|
||||
...(ctx.persistence?.resourceId !== undefined
|
||||
? { parentResourceId: ctx.persistence.resourceId }
|
||||
: {}),
|
||||
...(ctx.persistence?.hostMetadata !== undefined
|
||||
? { parentHostMetadata: ctx.persistence.hostMetadata }
|
||||
: {}),
|
||||
...(ctx.abortSignal !== undefined ? { parentAbortSignal: ctx.abortSignal } : {}),
|
||||
...(ctx.toolCallId !== undefined ? { parentToolCallId: ctx.toolCallId } : {}),
|
||||
...(ctx.executionCounter !== undefined
|
||||
|
||||
@@ -1081,7 +1081,6 @@ export class ToolCallExecutor {
|
||||
filesystem,
|
||||
runId: params.runId,
|
||||
toolCallId: params.toolCallId,
|
||||
...(params.persistence?.threadId ? { threadId: params.persistence.threadId } : {}),
|
||||
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { toJsonValue } from '@n8n/utils/json/to-json-value';
|
||||
|
||||
import type { AgentMessage, MessageContent } from '../../types/sdk/message';
|
||||
import type { AgentDbMessage, AgentMessage, MessageContent } from '../../types/sdk/message';
|
||||
import type { JSONObject, JSONValue } from '../../types/utils/json';
|
||||
import {
|
||||
isToolResultPath,
|
||||
storeToolResult,
|
||||
type ToolResultKind,
|
||||
type ToolResultStorageScope,
|
||||
@@ -37,6 +38,14 @@ interface OffloadedToolResult extends JSONObject {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const EXPIRED_OFFLOADED_TOOL_RESULT = {
|
||||
_offloaded: true,
|
||||
expired: true,
|
||||
message: 'The stored tool result expired with its originating run.',
|
||||
} satisfies JSONObject;
|
||||
|
||||
const EXPIRED_OFFLOADED_TOOL_RESULT_JSON = JSON.stringify(EXPIRED_OFFLOADED_TOOL_RESULT);
|
||||
|
||||
export interface ToolResultGuardStorage extends ToolResultStorageScope {
|
||||
filesystem: WorkspaceFilesystem;
|
||||
}
|
||||
@@ -120,6 +129,54 @@ export async function guardToolMessageForModel(
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOffloadedToolResult(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value._offloaded === true &&
|
||||
typeof value.path === 'string' &&
|
||||
isToolResultPath(value.path)
|
||||
);
|
||||
}
|
||||
|
||||
function isSerializedOffloadedToolResult(value: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return isOffloadedToolResult(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeOffloadedToolResultsForMemory(
|
||||
messages: AgentDbMessage[],
|
||||
): AgentDbMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (!('content' in message)) return { ...message };
|
||||
|
||||
const content = message.content.map((block): MessageContent => {
|
||||
if (block.type === 'tool-call') {
|
||||
if (block.state === 'resolved' && isOffloadedToolResult(block.output)) {
|
||||
return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } };
|
||||
}
|
||||
if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) {
|
||||
return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) {
|
||||
return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
|
||||
}
|
||||
|
||||
return { ...block };
|
||||
});
|
||||
return { ...message, content };
|
||||
});
|
||||
}
|
||||
|
||||
async function tryOffloadResult(
|
||||
serialized: string,
|
||||
tokenCount: number,
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from '../runtime/event';
|
||||
import type { SerializedMessageList } from '../runtime/message-list';
|
||||
import type { BuiltTelemetry } from '../telemetry';
|
||||
import type { JSONValue } from '../utils/json';
|
||||
import type { JSONObject, JSONValue } from '../utils/json';
|
||||
|
||||
export type SmoothStreamOptions = NonNullable<Parameters<typeof smoothStream>[0]>;
|
||||
|
||||
@@ -432,6 +432,7 @@ export interface SerializableAgentState {
|
||||
export type AgentPersistenceOptions = {
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
hostMetadata?: JSONObject;
|
||||
/** Internal child runs must only be resumed through their suspended parent. */
|
||||
delegated?: true;
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ToolExecutionContext {
|
||||
persistence?: {
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
hostMetadata?: JSONObject;
|
||||
};
|
||||
/** Internal runtime event bridge for platform-managed tools. */
|
||||
emitEvent?: (event: AgentEventData) => void;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { Workspace } from './workspace';
|
||||
export { createScopedWorkspace } from './scoped-workspace';
|
||||
export { getToolResultThreadDirectory } from './tool-result-storage';
|
||||
export {
|
||||
getToolResultRunDirectory,
|
||||
reconcileToolResultRuns,
|
||||
removeToolResultRun,
|
||||
} from './tool-result-storage';
|
||||
|
||||
export { BaseFilesystem } from './filesystem/base-filesystem';
|
||||
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { WorkspaceFilesystem } from './types';
|
||||
import type { FileEntry, WorkspaceFilesystem } from './types';
|
||||
import { raceWithAbort } from '../sdk/abort';
|
||||
|
||||
export type ToolResultKind = 'result' | 'error' | 'message';
|
||||
|
||||
export interface ToolResultStorageScope {
|
||||
threadId?: string;
|
||||
runId: string;
|
||||
toolCallId: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
const TOOL_RESULTS_DIRECTORY = 'tool-results';
|
||||
const TOOL_RESULT_RUNS_DIRECTORY = `${TOOL_RESULTS_DIRECTORY}/runs`;
|
||||
const HASHED_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const TOOL_RESULT_FILE_PATTERN = /^([A-Za-z0-9_-]{43})\.(result|error|message)\.json$/;
|
||||
const MAX_RECONCILIATION_CANDIDATES = 100;
|
||||
const TOOL_RESULT_CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const TOOL_RESULT_RECONCILIATION_TIMEOUT_MS = 5_000;
|
||||
|
||||
function hashPathSegment(value: string): string {
|
||||
return createHash('sha256').update(value).digest('base64url');
|
||||
}
|
||||
|
||||
export function getToolResultThreadDirectory(threadId: string): string {
|
||||
return `${TOOL_RESULTS_DIRECTORY}/threads/${hashPathSegment(threadId)}`;
|
||||
export function getToolResultRunDirectory(runId: string): string {
|
||||
return `${TOOL_RESULT_RUNS_DIRECTORY}/${hashPathSegment(runId)}`;
|
||||
}
|
||||
|
||||
export function isToolResultPath(path: string): boolean {
|
||||
const segments = path.split('/');
|
||||
if (segments[0] !== TOOL_RESULTS_DIRECTORY) return false;
|
||||
|
||||
if (segments[1] === 'threads' && segments.length === 5) {
|
||||
return (
|
||||
HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) &&
|
||||
HASHED_PATH_SEGMENT_PATTERN.test(segments[3]) &&
|
||||
TOOL_RESULT_FILE_PATTERN.test(segments[4])
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === 'runs' && segments.length === 4) {
|
||||
return (
|
||||
HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) && TOOL_RESULT_FILE_PATTERN.test(segments[3])
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
return (
|
||||
segments.length === 4 &&
|
||||
segments[0] === TOOL_RESULTS_DIRECTORY &&
|
||||
segments[1] === 'runs' &&
|
||||
HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) &&
|
||||
TOOL_RESULT_FILE_PATTERN.test(segments[3])
|
||||
);
|
||||
}
|
||||
|
||||
function getToolResultPath(scope: ToolResultStorageScope, kind: ToolResultKind): string {
|
||||
const runDirectory = scope.threadId
|
||||
? `${getToolResultThreadDirectory(scope.threadId)}/${hashPathSegment(scope.runId)}`
|
||||
: `${TOOL_RESULTS_DIRECTORY}/runs/${hashPathSegment(scope.runId)}`;
|
||||
|
||||
return `${runDirectory}/${hashPathSegment(scope.toolCallId)}.${kind}.json`;
|
||||
return `${getToolResultRunDirectory(scope.runId)}/${hashPathSegment(scope.toolCallId)}.${kind}.json`;
|
||||
}
|
||||
|
||||
export async function storeToolResult(
|
||||
@@ -66,3 +56,56 @@ export async function storeToolResult(
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function removeToolResultRun(
|
||||
filesystem: WorkspaceFilesystem,
|
||||
runId: string,
|
||||
): Promise<void> {
|
||||
const abortSignal = AbortSignal.timeout(TOOL_RESULT_CLEANUP_TIMEOUT_MS);
|
||||
await raceWithAbort(async () => {
|
||||
const runDirectory = getToolResultRunDirectory(runId);
|
||||
if (!(await filesystem.exists(runDirectory, { abortSignal }))) return;
|
||||
await filesystem.rmdir(runDirectory, { recursive: true, force: true, abortSignal });
|
||||
}, abortSignal);
|
||||
}
|
||||
|
||||
export async function reconcileToolResultRuns(
|
||||
filesystem: WorkspaceFilesystem,
|
||||
protectedRunIds: Iterable<string>,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const abortSignal = AbortSignal.timeout(TOOL_RESULT_RECONCILIATION_TIMEOUT_MS);
|
||||
await raceWithAbort(async () => {
|
||||
let entries: FileEntry[];
|
||||
try {
|
||||
entries = await filesystem.readdir(TOOL_RESULT_RUNS_DIRECTORY, { abortSignal });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const protectedDirectories = new Set(
|
||||
Array.from(protectedRunIds, (runId) => hashPathSegment(runId)),
|
||||
);
|
||||
const cutoff = Date.now() - ttlMs;
|
||||
let examined = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (examined >= MAX_RECONCILIATION_CANDIDATES) break;
|
||||
if (entry.type !== 'directory' || !HASHED_PATH_SEGMENT_PATTERN.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (protectedDirectories.has(entry.name)) continue;
|
||||
examined++;
|
||||
|
||||
const path = `${TOOL_RESULT_RUNS_DIRECTORY}/${entry.name}`;
|
||||
try {
|
||||
const { modifiedAt } = await filesystem.stat(path, { abortSignal });
|
||||
const modifiedAtMs = modifiedAt.getTime();
|
||||
if (!Number.isFinite(modifiedAtMs) || modifiedAtMs >= cutoff) continue;
|
||||
await filesystem.rmdir(path, { recursive: true, force: true, abortSignal });
|
||||
} catch {
|
||||
if (abortSignal.aborted) return;
|
||||
}
|
||||
}
|
||||
}, abortSignal);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface SubAgentSpawnRequest {
|
||||
parentThreadId?: string;
|
||||
/** Parent's episodic-memory resource id, inherited so the child shares its scope. */
|
||||
parentResourceId?: string;
|
||||
/** Parent's workspace principal hash, inherited by configured first-class children. */
|
||||
parentSandboxPrincipalHash?: string;
|
||||
/**
|
||||
* This delegation's task path — already assigned and policy-checked by the SDK
|
||||
* delegate tool, then validated by `@n8n/agents` (`assertSubAgentTaskPath`)
|
||||
|
||||
@@ -730,15 +730,6 @@ describe('GlobalConfig', () => {
|
||||
expect(config.agents.tracingRecordOutputs).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse N8N_AGENTS_AI_SANDBOX_EPHEMERAL from env variables', () => {
|
||||
process.env = {
|
||||
N8N_AGENTS_AI_SANDBOX_EPHEMERAL: 'true',
|
||||
};
|
||||
const config = Container.get(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',
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SubworkflowPolicyChecker,
|
||||
} from '@/executions/pre-execution-checks';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-principal';
|
||||
import { AgentWorkflowExecutionService } from '@/modules/agents/agent-workflow-execution.service';
|
||||
import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
@@ -1339,6 +1340,20 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
const MESSAGE = 'hello';
|
||||
const EXEC_ID = 'exec-id';
|
||||
const THREAD_ID = 'thread-id';
|
||||
const executionSandboxScope = {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-execution',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: EXEC_ID,
|
||||
}),
|
||||
};
|
||||
const sessionSandboxScope = {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-session',
|
||||
workflowId: 'workflow-1',
|
||||
sessionId: THREAD_ID,
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -1428,6 +1443,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1463,6 +1479,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
true,
|
||||
outputSchema,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1476,6 +1493,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
workflowId: 'workflow-1',
|
||||
workflowName: 'My workflow',
|
||||
callingNodeName: 'Message an Agent',
|
||||
hasCallerSessionId: true,
|
||||
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||
runExecutionData: { resultData: { runData: {} } } as unknown as IRunExecutionData,
|
||||
};
|
||||
@@ -1501,6 +1519,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
true,
|
||||
undefined,
|
||||
workflowContext,
|
||||
sessionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1534,6 +1553,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1596,6 +1616,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1628,6 +1649,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1662,6 +1684,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
executionSandboxScope,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+138
-8
@@ -18,6 +18,11 @@ import { AgentExecutionOrchestratorService } from '../agent-execution-orchestrat
|
||||
import type { AgentExecutionService } from '../agent-execution.service';
|
||||
import type { AgentRunTracingService } from '../agent-run-tracing.service';
|
||||
import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
} from '../agent-sandbox-principal';
|
||||
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
|
||||
import type { IntegrationMessageContextService } from '../integrations/integration-message-context.service';
|
||||
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
|
||||
import type { ToolRegistry } from '../tool-registry';
|
||||
@@ -26,6 +31,14 @@ const agentId = 'agent-1';
|
||||
const projectId = 'project-1';
|
||||
const userId = 'user-1';
|
||||
const user = mock<User>({ id: userId });
|
||||
const userPrincipalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId });
|
||||
const integrationPrincipalHash = hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: 'credential-1',
|
||||
platform: 'slack',
|
||||
platformUserId: 'platform-user-1',
|
||||
});
|
||||
const taskPrincipalHash = hashAgentSandboxPrincipal({ type: 'scheduled-task', taskId: 'task-1' });
|
||||
|
||||
const schema: AgentJsonConfig = {
|
||||
name: 'Support Agent',
|
||||
@@ -99,7 +112,7 @@ function makeRuntime(chunks: StreamChunk[] = [{ type: 'finish', finishReason: 's
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
function makeService(sandboxEnabled = false) {
|
||||
const checkpointStorage = mock<N8NCheckpointStorage>();
|
||||
const executionService = mock<AgentExecutionService>();
|
||||
const telemetry = mock<Telemetry>();
|
||||
@@ -107,6 +120,9 @@ function makeService() {
|
||||
const integrationMessageContextService = mock<IntegrationMessageContextService>();
|
||||
const agentRunTracingService = mock<AgentRunTracingService>();
|
||||
const externalHooks = mock<ExternalHooks>();
|
||||
const agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>({
|
||||
isEnabled: () => sandboxEnabled,
|
||||
});
|
||||
|
||||
executionService.startExecutionRecording.mockResolvedValue('execution-1');
|
||||
executionService.finalizeExecution.mockResolvedValue('execution-1');
|
||||
@@ -121,6 +137,7 @@ function makeService() {
|
||||
integrationMessageContextService,
|
||||
agentRunTracingService,
|
||||
externalHooks,
|
||||
agentSandboxRuntimeService,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -132,6 +149,7 @@ function makeService() {
|
||||
integrationMessageContextService,
|
||||
agentRunTracingService,
|
||||
externalHooks,
|
||||
agentSandboxRuntimeService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +214,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -249,6 +268,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
);
|
||||
@@ -257,7 +277,14 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
expect(runtime.agent.stream).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({
|
||||
persistence: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
persistence: {
|
||||
threadId: 'thread-1',
|
||||
resourceId: 'resource-1',
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({
|
||||
projectId,
|
||||
principalHash: userPrincipalHash,
|
||||
}),
|
||||
},
|
||||
executionCounter: expect.any(Object),
|
||||
abortSignal: abortController.signal,
|
||||
}),
|
||||
@@ -288,6 +315,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
onExecutionRecorded,
|
||||
}),
|
||||
);
|
||||
@@ -310,6 +338,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -344,6 +373,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
projectId,
|
||||
integrationType: N8N_CHAT_INTEGRATION_TYPE,
|
||||
user,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
});
|
||||
expect(integrationMessageContextService.setLatest).toHaveBeenCalledWith(
|
||||
'thread-1',
|
||||
@@ -359,12 +389,6 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
expect(
|
||||
integrationMessageContextService.setLatest.mock.invocationCallOrder[0] ?? 0,
|
||||
).toBeLessThan(runtime.agent.stream.mock.invocationCallOrder[0] ?? 0);
|
||||
expect(runtime.agent.stream).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({
|
||||
persistence: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
}),
|
||||
);
|
||||
expect(externalHooks.run).not.toHaveBeenCalled();
|
||||
expect(executionService.finalizeExecution).toHaveBeenCalledWith(
|
||||
'execution-1',
|
||||
@@ -404,6 +428,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
message: 'from slack',
|
||||
memory: { threadId: 'thread-1', resourceId: 'platform-user-1' },
|
||||
integrationType: 'slack',
|
||||
sandboxPrincipalHash: integrationPrincipalHash,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -412,6 +437,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
projectId,
|
||||
integrationType: 'slack',
|
||||
usePublishedVersion: true,
|
||||
sandboxPrincipalHash: integrationPrincipalHash,
|
||||
});
|
||||
expect(externalHooks.run).toHaveBeenCalledWith('agent.preExecute', [agentId]);
|
||||
expect(externalHooks.run).toHaveBeenCalledTimes(1);
|
||||
@@ -460,6 +486,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
projectId,
|
||||
integrationType: 'task',
|
||||
usePublishedVersion: true,
|
||||
sandboxPrincipalHash: taskPrincipalHash,
|
||||
});
|
||||
expect(externalHooks.run).toHaveBeenCalledWith('agent.preExecute', [agentId]);
|
||||
expect(externalHooks.run).toHaveBeenCalledTimes(1);
|
||||
@@ -521,6 +548,11 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
);
|
||||
|
||||
expect(externalHooks.run).not.toHaveBeenCalled();
|
||||
expect(runtimeCacheService.getRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the max-iterations assistant text before the finish chunk and persists it', async () => {
|
||||
@@ -536,6 +568,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -573,6 +606,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('reader failed while consuming stream');
|
||||
@@ -609,6 +643,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
abortSignal: abortController.signal,
|
||||
onExecutionRecorded: vi.fn(),
|
||||
});
|
||||
@@ -727,6 +762,100 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reconstructs a resumed runtime from the persisted sandbox scope', async () => {
|
||||
const { service, checkpointStorage, runtimeCacheService } = makeService(true);
|
||||
const runtime = makeRuntime();
|
||||
checkpointStorage.getStatus.mockResolvedValue({
|
||||
status: 'active',
|
||||
checkpoint: {
|
||||
persistence: {
|
||||
threadId: 'thread-1',
|
||||
resourceId: 'platform-user-1',
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({
|
||||
projectId,
|
||||
principalHash: integrationPrincipalHash,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
runtimeCacheService.getRuntime.mockResolvedValue(runtime);
|
||||
|
||||
await collect(
|
||||
service.resumeForChat({
|
||||
agentId,
|
||||
projectId,
|
||||
runId: 'run-1',
|
||||
toolCallId: 'tc-1',
|
||||
resumeData: { value: 'yes' },
|
||||
integrationType: 'slack',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtimeCacheService.getRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sandboxPrincipalHash: integrationPrincipalHash }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a draft resume when the checkpoint principal differs from the caller', async () => {
|
||||
const { service, checkpointStorage, runtimeCacheService } = makeService(true);
|
||||
checkpointStorage.getStatus.mockResolvedValue({
|
||||
status: 'active',
|
||||
checkpoint: makeCheckpoint(
|
||||
{},
|
||||
{
|
||||
threadId: 'thread-1',
|
||||
resourceId: 'draft-chat:user-2',
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({
|
||||
projectId,
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'n8n-user',
|
||||
userId: 'user-2',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
),
|
||||
});
|
||||
runtimeCacheService.getRuntime.mockResolvedValue(makeRuntime());
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
service.resumeForChat({
|
||||
agentId,
|
||||
projectId,
|
||||
runId: 'run-1',
|
||||
toolCallId: 'tc-1',
|
||||
resumeData: { value: 'yes' },
|
||||
user,
|
||||
usePublishedVersion: false,
|
||||
integrationType: N8N_CHAT_INTEGRATION_TYPE,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('unavailable');
|
||||
expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an old checkpoint without sandbox scope when workspaces are enabled', async () => {
|
||||
const { service, checkpointStorage, runtimeCacheService } = makeService(true);
|
||||
checkpointStorage.getStatus.mockResolvedValue({
|
||||
status: 'active',
|
||||
checkpoint: { persistence: { threadId: 'thread-1', resourceId: 'platform-user-1' } },
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
service.resumeForChat({
|
||||
agentId,
|
||||
projectId,
|
||||
runId: 'run-1',
|
||||
toolCallId: 'tc-1',
|
||||
resumeData: { value: 'yes' },
|
||||
integrationType: 'slack',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('unavailable');
|
||||
expect(runtimeCacheService.getRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists an aborted resumed stream as cancelled without discarding partial output', async () => {
|
||||
const { service, checkpointStorage, runtimeCacheService, executionService } = makeService();
|
||||
const abortController = new AbortController();
|
||||
@@ -959,6 +1088,7 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
telemetry: telemetryContext,
|
||||
sandboxPrincipalHash: userPrincipalHash,
|
||||
}),
|
||||
);
|
||||
expect(runtime.agent.stream).toHaveBeenCalledWith(
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { Telemetry } from '@/telemetry';
|
||||
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
import { AgentExecutionService, type RecordMessageParams } from '../agent-execution.service';
|
||||
import type { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
|
||||
import type { AgentWorkspaceService } from '../agent-workspace.service';
|
||||
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
|
||||
import type { AgentExecution } from '../entities/agent-execution.entity';
|
||||
import type { MessageRecord, TimelineEvent } from '../execution-recorder';
|
||||
@@ -67,7 +66,6 @@ describe('AgentExecutionService', () => {
|
||||
let errorReporter: Mocked<ErrorReporter>;
|
||||
let agentChatAttachmentService: Mocked<AgentChatAttachmentService>;
|
||||
let executionUpdateBroadcaster: Mocked<AgentExecutionUpdateBroadcaster>;
|
||||
let agentWorkspaceService: Mocked<AgentWorkspaceService>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -85,8 +83,6 @@ describe('AgentExecutionService', () => {
|
||||
errorReporter = mock<ErrorReporter>();
|
||||
agentChatAttachmentService = mock<AgentChatAttachmentService>();
|
||||
executionUpdateBroadcaster = mock<AgentExecutionUpdateBroadcaster>();
|
||||
agentWorkspaceService = mock<AgentWorkspaceService>();
|
||||
agentWorkspaceService.cleanupThreadWorkspace.mockResolvedValue();
|
||||
|
||||
service = new AgentExecutionService(
|
||||
mockLogger(),
|
||||
@@ -99,7 +95,6 @@ describe('AgentExecutionService', () => {
|
||||
storageConfig,
|
||||
errorReporter,
|
||||
executionUpdateBroadcaster,
|
||||
agentWorkspaceService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,7 +281,6 @@ describe('AgentExecutionService', () => {
|
||||
storageConfig,
|
||||
errorReporter,
|
||||
executionUpdateBroadcaster,
|
||||
agentWorkspaceService,
|
||||
);
|
||||
|
||||
const record = makeMessageRecord({
|
||||
@@ -351,7 +345,6 @@ describe('AgentExecutionService', () => {
|
||||
storageConfig,
|
||||
errorReporter,
|
||||
executionUpdateBroadcaster,
|
||||
agentWorkspaceService,
|
||||
);
|
||||
|
||||
const record = makeMessageRecord({
|
||||
@@ -765,7 +758,6 @@ describe('AgentExecutionService', () => {
|
||||
storageConfig,
|
||||
errorReporter,
|
||||
executionUpdateBroadcaster,
|
||||
agentWorkspaceService,
|
||||
);
|
||||
const partial = [{ type: 'text', content: 'Partial', timestamp: 1, endTime: 2 }] as const;
|
||||
agentExecutionRepository.updateIfRunning.mockResolvedValue(true);
|
||||
@@ -926,7 +918,7 @@ describe('AgentExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('deleteThread', () => {
|
||||
it('performs workspace cleanup when deleting an execution thread', async () => {
|
||||
it('deletes thread memory, attachments, and the execution thread', async () => {
|
||||
agentExecutionThreadRepository.findOneBy.mockResolvedValue({
|
||||
id: 'thread-1',
|
||||
agentId: 'agent-1',
|
||||
@@ -947,11 +939,6 @@ describe('AgentExecutionService', () => {
|
||||
expect(agentChatAttachmentService.deleteByThread).toHaveBeenCalledWith('thread-1', {
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(agentWorkspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
'agent-1',
|
||||
'thread-1',
|
||||
);
|
||||
expect(agentExecutionThreadRepository.delete).toHaveBeenCalledWith({ id: 'thread-1' });
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ function makeRuntimeService(
|
||||
runtime: AgentSandboxRuntime,
|
||||
): ReturnType<typeof mock<AgentSandboxRuntimeService>> {
|
||||
const service = mock<AgentSandboxRuntimeService>();
|
||||
service.acquireSandbox.mockResolvedValue(runtime);
|
||||
service.acquireKnowledgeSandbox.mockResolvedValue(runtime);
|
||||
service.executeSandboxCommand.mockImplementation(
|
||||
async (sandbox, command, timeout) =>
|
||||
await (sandbox.executeCommand?.(command, [], { timeout }) ??
|
||||
@@ -206,6 +206,19 @@ describe('AgentKnowledgeMirrorService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty search without acquiring a sandbox when no files exist', async () => {
|
||||
const agentFileRepository = mock<AgentFileRepository>();
|
||||
agentFileRepository.findByAgentId.mockResolvedValue([]);
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
agentRepository.existsBy.mockResolvedValue(true);
|
||||
const service = makeService({ runtimeService, agentFileRepository, agentRepository });
|
||||
|
||||
const result = await service.searchKnowledge(projectId, agentId, { pattern: 'anything' });
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ matches: [], hasMore: false }));
|
||||
expect(runtimeService.acquireKnowledgeSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('mirror sync', () => {
|
||||
function isManifestReadCommand(command: string): boolean {
|
||||
return command.startsWith('cat ') && command.includes('/manifest');
|
||||
@@ -259,6 +272,8 @@ describe('AgentKnowledgeMirrorService', () => {
|
||||
let commands = sandbox.executeCommand.mock.calls.map(([command]) => command);
|
||||
expect(commands.filter(isManifestReadCommand)).toHaveLength(1);
|
||||
expect(commands.filter(isMirrorSyncCommand)).toHaveLength(1);
|
||||
expect(runtimeService.acquireKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
expect(runtimeService.acquireWorkspaceSandbox).not.toHaveBeenCalled();
|
||||
expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2);
|
||||
const stagingDir = filesystem.mkdir.mock.calls[0][0];
|
||||
expect(stagingDir).toContain(`${knowledgePaths.stagingDir}/`);
|
||||
@@ -299,6 +314,19 @@ describe('AgentKnowledgeMirrorService', () => {
|
||||
expect(syncCommands[0]).toContain('file-1-replacement\tdoc1.txt');
|
||||
});
|
||||
|
||||
it('reads mirrored knowledge through the knowledge sandbox', async () => {
|
||||
sandbox.executeCommand.mockImplementation(async (command) =>
|
||||
makeCommandResult(isManifestReadCommand(command) ? 'file-id\tfile.txt\n' : '0\t1\thello\n'),
|
||||
);
|
||||
const agentFileRepository = mock<AgentFileRepository>();
|
||||
agentFileRepository.findByAgentId.mockResolvedValue([makeAgentFile()]);
|
||||
const service = makeMirrorService({ fileRepository: agentFileRepository });
|
||||
|
||||
const result = await service.readKnowledge(projectId, agentId, { file: 'file.txt' });
|
||||
|
||||
expect(result.ranges[0].text).toBe('1|hello');
|
||||
});
|
||||
|
||||
it('runs a queued mirror sync with a newer file snapshot after an in-flight sync', async () => {
|
||||
let releaseFirstManifestRead!: () => void;
|
||||
const firstManifestRead = new Promise<void>((resolve) => {
|
||||
@@ -357,10 +385,10 @@ describe('AgentKnowledgeMirrorService', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ provider: 'daytona', deterministicId: 'agent-instance-1-project-1-agent-1' },
|
||||
{ provider: 'daytona', deterministicId: 'agent-kb-a54b9053-9f50-51e5-b971-e02942ff7b6b' },
|
||||
{
|
||||
provider: 'n8n-sandbox',
|
||||
deterministicId: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0',
|
||||
deterministicId: 'a54b9053-9f50-51e5-b971-e02942ff7b6b',
|
||||
},
|
||||
] satisfies Array<{ provider: SandboxProvider; deterministicId: string }>)(
|
||||
'resyncs the mirror when a $provider sandbox is recreated under the same ID',
|
||||
@@ -369,7 +397,7 @@ describe('AgentKnowledgeMirrorService', () => {
|
||||
const replacementSandbox = makeSandbox(provider, deterministicId);
|
||||
const staleFilesystem = mock<WorkspaceFilesystem>();
|
||||
const replacementFilesystem = mock<WorkspaceFilesystem>();
|
||||
runtimeService.acquireSandbox
|
||||
runtimeService.acquireKnowledgeSandbox
|
||||
.mockResolvedValueOnce(makeRuntime(staleSandbox, staleFilesystem))
|
||||
.mockResolvedValueOnce(makeRuntime(replacementSandbox, replacementFilesystem));
|
||||
const agentFileRepository = mock<AgentFileRepository>();
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('AgentKnowledgeService', () => {
|
||||
expect(agentKnowledgeFileStore.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the DB row and its blob', async () => {
|
||||
it('deletes the final file, its blob, and the knowledge sandbox', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId } as never);
|
||||
await agentFileRepository.save(makeAgentFile({ id: 'file-1', storedAt: 'fs' }));
|
||||
|
||||
@@ -370,6 +370,11 @@ describe('AgentKnowledgeService', () => {
|
||||
expect(agentKnowledgeFileStore.delete).toHaveBeenCalledWith([
|
||||
{ storedAt: 'fs', storageKey: `agents/${agentId}/knowledge-files/file-1/content` },
|
||||
]);
|
||||
expect(agentSandboxRuntimeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
agentId,
|
||||
);
|
||||
expect(agentKnowledgeMirrorService.prewarmMirrorInBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs blob deletion failures without restoring the DB row', async () => {
|
||||
@@ -407,15 +412,27 @@ describe('AgentKnowledgeService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('delegates warmup to the sandbox service for an unpublished agent', async () => {
|
||||
it('warms the knowledge sandbox for an unpublished agent', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue({
|
||||
id: agentId,
|
||||
projectId,
|
||||
activeVersionId: null,
|
||||
} as never);
|
||||
await agentFileRepository.save(makeAgentFile());
|
||||
|
||||
await expect(service.warmSandbox(agentId, projectId)).resolves.toBeUndefined();
|
||||
expect(agentSandboxRuntimeService.warmSandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
await expect(service.warmKnowledgeSandbox(agentId, projectId)).resolves.toBeUndefined();
|
||||
expect(agentSandboxRuntimeService.warmKnowledgeSandbox).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
agentId,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not warm the knowledge sandbox without files', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue({ id: agentId, projectId } as never);
|
||||
|
||||
await expect(service.warmKnowledgeSandbox(agentId, projectId)).resolves.toBeUndefined();
|
||||
|
||||
expect(agentSandboxRuntimeService.warmKnowledgeSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pre-warms the mirror after a successful upload', async () => {
|
||||
@@ -443,13 +460,14 @@ describe('AgentKnowledgeService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-warms the mirror after a file deletion', async () => {
|
||||
it('pre-warms the mirror when files remain after a deletion', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue({
|
||||
id: agentId,
|
||||
projectId,
|
||||
activeVersionId: 'version-1',
|
||||
} as never);
|
||||
await agentFileRepository.save(makeAgentFile({ id: 'file-1' }));
|
||||
await agentFileRepository.save(makeAgentFile({ id: 'file-2', fileName: 'second.txt' }));
|
||||
|
||||
await service.deleteFile(agentId, projectId, 'file-1');
|
||||
|
||||
@@ -457,5 +475,6 @@ describe('AgentKnowledgeService', () => {
|
||||
projectId,
|
||||
agentId,
|
||||
);
|
||||
expect(agentSandboxRuntimeService.destroyKnowledgeSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
|
||||
import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
|
||||
import { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
|
||||
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
import type { ToolRegistry } from '../tool-registry';
|
||||
@@ -41,11 +43,17 @@ function makeRuntime() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeService({ multiMain = false }: { multiMain?: boolean } = {}) {
|
||||
function makeService({
|
||||
multiMain = false,
|
||||
sandboxEnabled = false,
|
||||
}: { multiMain?: boolean; sandboxEnabled?: boolean } = {}) {
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
const publisher = mock<Publisher>();
|
||||
const reconstructionService = mock<AgentRuntimeReconstructionService>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const sandboxRuntimeService = mock<AgentSandboxRuntimeService>({
|
||||
isEnabled: () => sandboxEnabled,
|
||||
});
|
||||
const globalConfig = { multiMainSetup: { enabled: multiMain } } as GlobalConfig;
|
||||
|
||||
publisher.publishCommand.mockResolvedValue();
|
||||
@@ -57,6 +65,7 @@ function makeService({ multiMain = false }: { multiMain?: boolean } = {}) {
|
||||
globalConfig,
|
||||
reconstructionService,
|
||||
credentialsService,
|
||||
sandboxRuntimeService,
|
||||
);
|
||||
|
||||
return { service, agentRepository, publisher, reconstructionService };
|
||||
@@ -94,9 +103,37 @@ describe('AgentRuntimeCacheService', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
'manual',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('defers closing an expired runtime until its active lease is released', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
const { service, agentRepository, reconstructionService } = makeService();
|
||||
const expiredRuntime = makeRuntime();
|
||||
const freshRuntime = makeRuntime();
|
||||
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
|
||||
reconstructionService.reconstructFromAgentEntity
|
||||
.mockResolvedValueOnce(expiredRuntime)
|
||||
.mockResolvedValueOnce(freshRuntime);
|
||||
|
||||
const leasedRuntime = await service.getRuntime({ agentId, projectId });
|
||||
vi.setSystemTime(Date.now() + 30 * 60 * 1000 + 1);
|
||||
const result = await service.getRuntime({ agentId, projectId });
|
||||
|
||||
expect(expiredRuntime.agent.close).not.toHaveBeenCalled();
|
||||
expect(result.agent).toBe(freshRuntime.agent);
|
||||
|
||||
service.releaseRuntimeLease(leasedRuntime.agent);
|
||||
expect(expiredRuntime.agent.close).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps draft runtimes separate by integration type', async () => {
|
||||
const { service, agentRepository, reconstructionService } = makeService();
|
||||
const agent = makeAgent();
|
||||
@@ -127,6 +164,7 @@ describe('AgentRuntimeCacheService', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
'manual',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +198,7 @@ describe('AgentRuntimeCacheService', () => {
|
||||
userA,
|
||||
undefined,
|
||||
'manual',
|
||||
undefined,
|
||||
);
|
||||
expect(reconstructionService.reconstructFromAgentEntity).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
@@ -170,6 +209,52 @@ describe('AgentRuntimeCacheService', () => {
|
||||
userB,
|
||||
undefined,
|
||||
'manual',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses an attached runtime for the same principal and isolates different principals', async () => {
|
||||
const { service, agentRepository, reconstructionService } = makeService({
|
||||
sandboxEnabled: true,
|
||||
});
|
||||
const agent = makeAgent();
|
||||
const firstRuntime = makeRuntime();
|
||||
const secondRuntime = makeRuntime();
|
||||
const firstPrincipal = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-a' });
|
||||
const secondPrincipal = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-b' });
|
||||
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
|
||||
reconstructionService.reconstructFromAgentEntity
|
||||
.mockResolvedValueOnce(firstRuntime)
|
||||
.mockResolvedValueOnce(secondRuntime);
|
||||
|
||||
const first = await service.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
sandboxPrincipalHash: firstPrincipal,
|
||||
});
|
||||
const repeated = await service.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
sandboxPrincipalHash: firstPrincipal,
|
||||
});
|
||||
const second = await service.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
sandboxPrincipalHash: secondPrincipal,
|
||||
});
|
||||
|
||||
expect(repeated).toBe(first);
|
||||
expect(second).not.toBe(first);
|
||||
expect(
|
||||
reconstructionService.reconstructFromAgentEntity.mock.calls.map((call) => call[7]),
|
||||
).toEqual([firstPrincipal, secondPrincipal]);
|
||||
});
|
||||
|
||||
it('requires a principal when sandbox workspaces are enabled', async () => {
|
||||
const enabled = makeService({ sandboxEnabled: true });
|
||||
await expect(enabled.service.getRuntime({ agentId, projectId })).rejects.toThrow(
|
||||
'workspace scope is missing',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -296,6 +381,7 @@ describe('AgentRuntimeCacheService', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
'integrated',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -322,6 +408,7 @@ describe('AgentRuntimeCacheService', () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
|
||||
reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime);
|
||||
await service.getRuntime({ agentId, projectId });
|
||||
service.releaseRuntimeLease(runtime.agent);
|
||||
|
||||
service.clearRuntimes(agentId);
|
||||
|
||||
|
||||
+6
-5
@@ -235,6 +235,9 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||
const agentRuntimeReconstructionService = mock<AgentRuntimeReconstructionService>();
|
||||
const chatIntegrationRegistry = mock<ChatIntegrationRegistry>();
|
||||
const agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>({
|
||||
isEnabled: () => false,
|
||||
});
|
||||
|
||||
runtimeCacheService = new AgentRuntimeCacheService(
|
||||
logger,
|
||||
@@ -243,6 +246,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
||||
globalConfig,
|
||||
agentRuntimeReconstructionService,
|
||||
credentialsService,
|
||||
agentSandboxRuntimeService,
|
||||
);
|
||||
Container.set(AgentRuntimeCacheService, runtimeCacheService);
|
||||
const modificationTelemetry = mock<AgentModificationTelemetryService>();
|
||||
@@ -275,6 +279,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
||||
mock<IntegrationMessageContextService>(),
|
||||
mock<AgentRunTracingService>(),
|
||||
mock<ExternalHooks>(),
|
||||
agentSandboxRuntimeService,
|
||||
);
|
||||
agentIntegrationPersistenceService = new AgentIntegrationPersistenceService(
|
||||
agentRepository,
|
||||
@@ -310,11 +315,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
|
||||
mock<AgentSetupCompletionService>(),
|
||||
mock<AgentModificationTelemetryService>(),
|
||||
);
|
||||
agentTestChatService = new AgentTestChatService(
|
||||
n8nMemory,
|
||||
mock<AgentChatAttachmentService>(),
|
||||
mock<AgentWorkspaceService>(),
|
||||
);
|
||||
agentTestChatService = new AgentTestChatService(n8nMemory, mock<AgentChatAttachmentService>());
|
||||
agentsService = new AgentsService(
|
||||
logger,
|
||||
agentRepository,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type AgentSandboxPrincipal, hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
|
||||
describe('hashAgentSandboxPrincipal', () => {
|
||||
it('produces stable hashes for every principal kind', () => {
|
||||
const cases: Array<[AgentSandboxPrincipal, string]> = [
|
||||
[{ type: 'n8n-user', userId: 'user/123:raw' }, 'Gt4H3q6RzhJe9cTxQm6be0AdIZQlifuy3w9OPSykmYo'],
|
||||
[
|
||||
{
|
||||
type: 'integration-user',
|
||||
connectionId: 'connection/raw:id',
|
||||
platform: 'slack',
|
||||
platformUserId: 'U/raw:123',
|
||||
},
|
||||
'7Zqe0BHA0mDnH7Ci9p-Zy7W2uVPQhf_4h01KkKpHnlU',
|
||||
],
|
||||
[
|
||||
{ type: 'workflow-session', workflowId: 'workflow|one', sessionId: 'session|two' },
|
||||
'U1yUIOzcWHMbn3uOWB_UKJCbW1yZ_aqsOZJl_o3omc0',
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'workflow-execution',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
'5PiyOW90m1b3G7sUBU89WQEDdW7jT8E2rnaqMtRilc4',
|
||||
],
|
||||
[{ type: 'scheduled-task', taskId: 'task-1' }, 'zNDZ3KUSonoPGuBjyiJ1huc8pr8psw4zGbu7t5vlJFA'],
|
||||
];
|
||||
|
||||
for (const [principal, expectedHash] of cases) {
|
||||
expect(hashAgentSandboxPrincipal(principal)).toBe(expectedHash);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not collide when delimiter characters move between fields', () => {
|
||||
const first = hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: 'a',
|
||||
platform: 'b|c',
|
||||
platformUserId: 'd',
|
||||
});
|
||||
const second = hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: 'a|b',
|
||||
platform: 'c',
|
||||
platformUserId: 'd',
|
||||
});
|
||||
|
||||
expect(first).toBe('f1q_0RPATJAPJMFC_AVuRad38wcVRFv0MfTsU_k1K90');
|
||||
expect(second).toBe('U1sl-BmMZxJ_coVScXBuMDpW3mI4oQZOUbi2-xYbn0c');
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,8 @@ import type { AiService } from '../../../services/ai.service';
|
||||
import type { SandboxSettingsService } from '../../../services/sandbox-settings.service';
|
||||
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import {
|
||||
AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX,
|
||||
AgentSandboxRuntimeService,
|
||||
} from '../agent-sandbox-runtime.service';
|
||||
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
import { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
|
||||
import type { AgentRepository } from '../repositories/agent.repository';
|
||||
|
||||
const { createSandboxMock, createFilesystemMock } = vi.hoisted(() => ({
|
||||
@@ -34,14 +32,23 @@ vi.mock('@n8n/agents/sandbox', async (importOriginal) => ({
|
||||
const instanceId = 'instance-1';
|
||||
const projectId = 'project-1';
|
||||
const agentId = 'agent-1';
|
||||
const principalHash = hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: 'connection/raw:id',
|
||||
platform: 'slack',
|
||||
platformUserId: 'U/raw:123',
|
||||
});
|
||||
const otherPrincipalHash = hashAgentSandboxPrincipal({
|
||||
type: 'n8n-user',
|
||||
userId: 'user/123:raw',
|
||||
});
|
||||
const workspaceSandboxId = 'ed4a5e7b-acf0-5f78-b2b3-8fc4182d3c0c';
|
||||
const otherWorkspaceSandboxId = '4197eecc-3092-54b8-9196-a4fecccea156';
|
||||
const knowledgeSandboxId = 'a54b9053-9f50-51e5-b971-e02942ff7b6b';
|
||||
|
||||
type TestWorkspaceSandbox = WorkspaceSandbox &
|
||||
Required<Pick<WorkspaceSandbox, '_start' | 'destroy' | 'executeCommand'>>;
|
||||
|
||||
function buildExpectedSandboxName(): string {
|
||||
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase();
|
||||
}
|
||||
|
||||
function makeAiService(overrides: Partial<AiService> = {}): AiService {
|
||||
const aiService = mock<AiService>();
|
||||
aiService.isProxyEnabled.mockReturnValue(false);
|
||||
@@ -145,7 +152,7 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
createFilesystemMock.mockReturnValue(mock<WorkspaceFilesystem>());
|
||||
});
|
||||
|
||||
it('creates and starts a deterministic direct-mode Daytona sandbox', async () => {
|
||||
it('creates and starts the deterministic direct-mode Daytona knowledge sandbox', async () => {
|
||||
const aiService = makeAiService();
|
||||
const sandboxSettingsService = makeSandboxSettingsService();
|
||||
const service = makeService({
|
||||
@@ -156,9 +163,9 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
aiService,
|
||||
sandboxSettingsService,
|
||||
});
|
||||
const expectedName = buildExpectedSandboxName();
|
||||
const expectedName = `agent-kb-${knowledgeSandboxId}`;
|
||||
|
||||
await service.warmSandbox(projectId, agentId);
|
||||
await service.warmKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
expect(aiService.getClient).not.toHaveBeenCalled();
|
||||
expect(sandboxSettingsService.resolveDaytonaConfig).toHaveBeenCalled();
|
||||
@@ -174,21 +181,26 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
'n8n-agents-knowledgebase': 'true',
|
||||
'n8n-project-id': projectId,
|
||||
'n8n-agent-id': agentId,
|
||||
'n8n-agent-sandbox-kind': 'knowledge',
|
||||
},
|
||||
timeout: 300_000,
|
||||
createTimeoutSeconds: 300,
|
||||
image: 'daytonaio/sandbox:0.5.0',
|
||||
snapshot: 'n8n/agent-knowledge:1.2.3',
|
||||
ephemeral: true,
|
||||
autoStopInterval: 5,
|
||||
autoStopInterval: 15,
|
||||
autoArchiveInterval: 60,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
(createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig).autoDeleteInterval,
|
||||
).toBeUndefined();
|
||||
expect(sandbox._start).toHaveBeenCalled();
|
||||
expect(createFilesystemMock).toHaveBeenCalledWith(sandbox);
|
||||
});
|
||||
|
||||
it('single-flights concurrent acquisition for the same project and agent', async () => {
|
||||
it('single-flights concurrent knowledge acquisition for the same project and agent', async () => {
|
||||
let resolveCreation: (value: WorkspaceSandbox) => void;
|
||||
createSandboxMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
@@ -197,29 +209,91 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
);
|
||||
const service = makeService();
|
||||
|
||||
const first = service.warmSandbox(projectId, agentId);
|
||||
const second = service.warmSandbox(projectId, agentId);
|
||||
const first = service.warmKnowledgeSandbox(projectId, agentId);
|
||||
const second = service.warmKnowledgeSandbox(projectId, agentId);
|
||||
await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(1));
|
||||
resolveCreation!(sandbox);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it('uses a stable UUID for the n8n sandbox', async () => {
|
||||
const expectedId = 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0';
|
||||
it('uses deterministic, isolated Daytona identities and labels', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await service.acquireWorkspaceSandbox(projectId, agentId, principalHash);
|
||||
await service.acquireWorkspaceSandbox(projectId, agentId, otherPrincipalHash);
|
||||
await service.acquireKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
const configs = createSandboxMock.mock.calls.map(([config]) => config as DaytonaSandboxConfig);
|
||||
expect(configs.map(({ id, name }) => ({ id, name }))).toEqual([
|
||||
{
|
||||
id: `agent-ws-${workspaceSandboxId}`,
|
||||
name: `agent-ws-${workspaceSandboxId}`,
|
||||
},
|
||||
{
|
||||
id: `agent-ws-${otherWorkspaceSandboxId}`,
|
||||
name: `agent-ws-${otherWorkspaceSandboxId}`,
|
||||
},
|
||||
{
|
||||
id: `agent-kb-${knowledgeSandboxId}`,
|
||||
name: `agent-kb-${knowledgeSandboxId}`,
|
||||
},
|
||||
]);
|
||||
expect(configs[0].labels).toEqual({
|
||||
'n8n-project-id': projectId,
|
||||
'n8n-agent-id': agentId,
|
||||
'n8n-agent-sandbox-kind': 'workspace',
|
||||
'n8n-agent-principal-hash': principalHash,
|
||||
});
|
||||
expect(
|
||||
configs.map(({ ephemeral, autoStopInterval, autoArchiveInterval, autoDeleteInterval }) => [
|
||||
ephemeral,
|
||||
autoStopInterval,
|
||||
autoArchiveInterval,
|
||||
autoDeleteInterval,
|
||||
]),
|
||||
).toEqual([
|
||||
[true, 5, undefined, undefined],
|
||||
[true, 5, undefined, undefined],
|
||||
[false, 15, 60, 10_080],
|
||||
]);
|
||||
});
|
||||
|
||||
it('single-flights the same workspace identity without coalescing different principals', async () => {
|
||||
const resolveCreations: Array<(value: WorkspaceSandbox) => void> = [];
|
||||
createSandboxMock.mockImplementation(
|
||||
async () =>
|
||||
await new Promise((resolve) => {
|
||||
resolveCreations.push(resolve);
|
||||
}),
|
||||
);
|
||||
const service = makeService();
|
||||
|
||||
const first = service.acquireWorkspaceSandbox(projectId, agentId, principalHash);
|
||||
const duplicate = service.acquireWorkspaceSandbox(projectId, agentId, principalHash);
|
||||
const distinct = service.acquireWorkspaceSandbox(projectId, agentId, otherPrincipalHash);
|
||||
await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(2));
|
||||
for (const resolveCreation of resolveCreations) resolveCreation(sandbox);
|
||||
await Promise.all([first, duplicate, distinct]);
|
||||
});
|
||||
|
||||
it('uses distinct deterministic workspace and knowledge IDs for the n8n sandbox', async () => {
|
||||
const service = makeService({
|
||||
sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'),
|
||||
});
|
||||
|
||||
await service.warmSandbox(projectId, agentId);
|
||||
await service.acquireWorkspaceSandbox(projectId, agentId, principalHash);
|
||||
await service.acquireKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
expect(createSandboxMock).toHaveBeenCalledWith(
|
||||
expect(createSandboxMock.mock.calls.map(([config]) => config.id)).toEqual([
|
||||
workspaceSandboxId,
|
||||
knowledgeSandboxId,
|
||||
]);
|
||||
expect(createSandboxMock.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
provider: 'n8n-sandbox',
|
||||
id: expectedId,
|
||||
serviceUrl: 'https://sandbox.example',
|
||||
apiKey: 'sandbox-key',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -228,7 +302,7 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
settingsService.resolveN8nSandboxConfig.mockResolvedValue({});
|
||||
const service = makeService({ sandboxSettingsService: settingsService });
|
||||
|
||||
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
|
||||
await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow(
|
||||
/N8N_SANDBOX_SERVICE_URL/,
|
||||
);
|
||||
expect(createSandboxMock).not.toHaveBeenCalled();
|
||||
@@ -249,7 +323,7 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
aiService,
|
||||
});
|
||||
|
||||
await service.warmSandbox(projectId, agentId);
|
||||
await service.warmKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
|
||||
expect(config.daytonaApiUrl).toBe('https://sandbox-proxy.example');
|
||||
@@ -270,7 +344,7 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
aiService: makeProxyAiService(),
|
||||
});
|
||||
|
||||
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
|
||||
await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow(
|
||||
/requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s,
|
||||
);
|
||||
expect(createSandboxMock).not.toHaveBeenCalled();
|
||||
@@ -283,41 +357,36 @@ describe('AgentSandboxRuntimeService', () => {
|
||||
aiService: makeProxyAiService(),
|
||||
});
|
||||
|
||||
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing');
|
||||
await expect(service.warmKnowledgeSandbox(projectId, agentId)).rejects.toThrow(
|
||||
'snapshot missing',
|
||||
);
|
||||
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
|
||||
expect(config.snapshot).toBe('n8n/agent-knowledge:missing');
|
||||
expect(config.image).toBeUndefined();
|
||||
});
|
||||
|
||||
it('best-effort destroys both provider sandboxes by their deterministic identities', async () => {
|
||||
const daytonaSandbox = makeSandbox('daytona', buildExpectedSandboxName());
|
||||
daytonaSandbox.destroy.mockRejectedValue(new Error('remote unavailable'));
|
||||
const n8nSandbox = makeSandbox('n8n-sandbox', 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0');
|
||||
createSandboxMock.mockImplementation(async (config) =>
|
||||
config.provider === 'daytona' ? daytonaSandbox : n8nSandbox,
|
||||
);
|
||||
const service = makeService({ configOverrides: { sandboxEnabled: false } });
|
||||
it('best-effort destroys workspace and knowledge sandboxes by only their exact identities', async () => {
|
||||
const destroyedIds: string[] = [];
|
||||
createSandboxMock.mockImplementation(async (config) => {
|
||||
const target = makeSandbox(config.provider, config.id);
|
||||
target.destroy.mockImplementation(async () => {
|
||||
destroyedIds.push(config.id);
|
||||
if (config.id === `agent-kb-${knowledgeSandboxId}`) {
|
||||
throw new Error('remote unavailable');
|
||||
}
|
||||
});
|
||||
return target;
|
||||
});
|
||||
const service = makeService();
|
||||
|
||||
await service.destroySandbox(projectId, agentId);
|
||||
await service.destroyWorkspaceSandbox(projectId, agentId, principalHash);
|
||||
await service.destroyKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
expect(createSandboxMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
provider: 'daytona',
|
||||
id: buildExpectedSandboxName(),
|
||||
name: buildExpectedSandboxName(),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(createSandboxMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
provider: 'n8n-sandbox',
|
||||
id: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(daytonaSandbox.destroy).toHaveBeenCalled();
|
||||
expect(n8nSandbox.destroy).toHaveBeenCalled();
|
||||
expect(destroyedIds).toEqual([
|
||||
`agent-ws-${workspaceSandboxId}`,
|
||||
workspaceSandboxId,
|
||||
`agent-kb-${knowledgeSandboxId}`,
|
||||
knowledgeSandboxId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('AgentSandboxController', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(202);
|
||||
expect(agentKnowledgeService.warmSandbox).toHaveBeenCalledWith('agent-1', 'project-1');
|
||||
expect(agentKnowledgeService.warmKnowledgeSandbox).toHaveBeenCalledWith('agent-1', 'project-1');
|
||||
});
|
||||
|
||||
it('rejects warmup when the knowledge base is disabled', async () => {
|
||||
@@ -45,6 +45,6 @@ describe('AgentSandboxController', () => {
|
||||
'agent-1',
|
||||
),
|
||||
).rejects.toThrow('Agent knowledge base is not enabled');
|
||||
expect(agentKnowledgeService.warmSandbox).not.toHaveBeenCalled();
|
||||
expect(agentKnowledgeService.warmKnowledgeSandbox).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
import { AgentTestChatService, chatThreadId } from '../agent-test-chat.service';
|
||||
import type { AgentWorkspaceService } from '../agent-workspace.service';
|
||||
import type { N8nMemory } from '../integrations/n8n-memory';
|
||||
|
||||
const agentId = 'agent-1';
|
||||
@@ -13,15 +12,13 @@ function makeService() {
|
||||
const n8nMemory = mock<N8nMemory>();
|
||||
const memory = mock<MemoryImplementation>();
|
||||
const attachmentService = mock<AgentChatAttachmentService>();
|
||||
const workspaceService = mock<AgentWorkspaceService>();
|
||||
n8nMemory.getImplementation.mockReturnValue(memory);
|
||||
|
||||
return {
|
||||
service: new AgentTestChatService(n8nMemory, attachmentService, workspaceService),
|
||||
service: new AgentTestChatService(n8nMemory, attachmentService),
|
||||
n8nMemory,
|
||||
memory,
|
||||
attachmentService,
|
||||
workspaceService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,16 +39,14 @@ describe('AgentTestChatService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('performs workspace cleanup for one user thread without changing all-agent cleanup', async () => {
|
||||
const { service, memory, workspaceService } = makeService();
|
||||
it('clears one user thread without changing all-agent cleanup', async () => {
|
||||
const { service, memory, attachmentService } = makeService();
|
||||
|
||||
await service.clearTestChatMessages('project-1', agentId, userId);
|
||||
await service.clearTestChatMessages(agentId, userId);
|
||||
expect(memory.deleteThread).toHaveBeenCalledWith(`test-${agentId}:${userId}`);
|
||||
expect(workspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
expect(attachmentService.deleteByThread).toHaveBeenCalledWith(`test-${agentId}:${userId}`, {
|
||||
agentId,
|
||||
`test-${agentId}:${userId}`,
|
||||
);
|
||||
});
|
||||
|
||||
await service.clearAllTestChatMessages(agentId);
|
||||
expect(memory.deleteThreadsByPrefix).toHaveBeenCalledWith(`test-${agentId}`);
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('AgentTestRunService', () => {
|
||||
expect.objectContaining({
|
||||
agentId,
|
||||
projectId,
|
||||
user,
|
||||
source: 'instance-ai',
|
||||
memory: {
|
||||
threadId: result.sessionId,
|
||||
|
||||
@@ -14,6 +14,10 @@ import type { Telemetry } from '@/telemetry';
|
||||
import type { AgentExecutionService } from '../agent-execution.service';
|
||||
import type { AgentRunTracingService } from '../agent-run-tracing.service';
|
||||
import type { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
} from '../agent-sandbox-principal';
|
||||
import { AgentWorkflowExecutionService } from '../agent-workflow-execution.service';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
import type { NodeToolAiGatewayService } from '../json-config/node-tool-ai-gateway.service';
|
||||
@@ -242,16 +246,68 @@ describe('AgentWorkflowExecutionService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the execution-scoped workspace principal', async () => {
|
||||
const { service, agentRepository, reconstructionService } = makeService();
|
||||
const runtime = makeRuntime();
|
||||
const principalHash = hashAgentSandboxPrincipal({
|
||||
type: 'workflow-execution',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
});
|
||||
const sandboxScope = { principalHash };
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
|
||||
reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime);
|
||||
|
||||
await service.executeForWorkflow(
|
||||
agentId,
|
||||
'hello',
|
||||
'execution-1',
|
||||
'wf:workflow-1:execution-1-0',
|
||||
projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
sandboxScope,
|
||||
);
|
||||
|
||||
expect(reconstructionService.reconstructFromAgentEntity.mock.calls[0][7]).toBe(principalHash);
|
||||
expect(runtime.agent.stream).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.objectContaining({
|
||||
persistence: expect.objectContaining({
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records workflow stream setup failures', async () => {
|
||||
const { service, agentRepository, reconstructionService, executionService } = makeService();
|
||||
const runtime = makeRuntime();
|
||||
const principalHash = hashAgentSandboxPrincipal({
|
||||
type: 'workflow-execution',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
});
|
||||
runtime.agent.stream.mockRejectedValue(new Error('stream setup failed'));
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent());
|
||||
reconstructionService.reconstructFromAgentEntity.mockResolvedValue(runtime);
|
||||
executionService.startExecutionRecording.mockResolvedValue('fallback-execution-1');
|
||||
|
||||
await expect(
|
||||
service.executeForWorkflow(agentId, 'hello', 'execution-1', 'thread-1', projectId),
|
||||
service.executeForWorkflow(
|
||||
agentId,
|
||||
'hello',
|
||||
'execution-1',
|
||||
'thread-1',
|
||||
projectId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ principalHash },
|
||||
),
|
||||
).rejects.toThrow('stream setup failed');
|
||||
|
||||
expect(executionService.finalizeExecution).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { WorkspaceFilesystem, WorkspaceSandbox } from '@n8n/agents/sandbox';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { AgentsConfig } from '@n8n/config';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
|
||||
import { AgentWorkspaceService } from '../agent-workspace.service';
|
||||
import {
|
||||
CHECKPOINT_RECONCILIATION_OVERFLOW,
|
||||
type N8NCheckpointStorage,
|
||||
} from '../integrations/n8n-checkpoint-storage';
|
||||
|
||||
const projectId = 'project-1';
|
||||
const agentId = 'agent-1';
|
||||
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
|
||||
|
||||
function makeService(sandboxEnabled = true) {
|
||||
function makeService() {
|
||||
const filesystem = mock<WorkspaceFilesystem>();
|
||||
const sandbox = mock<WorkspaceSandbox>({
|
||||
id: 'sandbox-id',
|
||||
@@ -17,8 +24,11 @@ function makeService(sandboxEnabled = true) {
|
||||
status: 'running',
|
||||
});
|
||||
const runtimeService = mock<AgentSandboxRuntimeService>();
|
||||
runtimeService.isEnabled.mockReturnValue(sandboxEnabled);
|
||||
runtimeService.acquireSandbox.mockResolvedValue({
|
||||
const checkpointStorage = mock<N8NCheckpointStorage>();
|
||||
const agentsConfig = mock<AgentsConfig>({ checkpointTtlSeconds: 60 });
|
||||
filesystem.readdir.mockResolvedValue([]);
|
||||
checkpointStorage.getActiveRunIdsForSandbox.mockResolvedValue(new Set());
|
||||
runtimeService.acquireWorkspaceSandbox.mockResolvedValue({
|
||||
provider: 'daytona',
|
||||
sandbox,
|
||||
filesystem,
|
||||
@@ -27,19 +37,31 @@ function makeService(sandboxEnabled = true) {
|
||||
});
|
||||
|
||||
return {
|
||||
service: new AgentWorkspaceService(mock<Logger>(), runtimeService),
|
||||
service: new AgentWorkspaceService(
|
||||
mock<Logger>(),
|
||||
runtimeService,
|
||||
checkpointStorage,
|
||||
agentsConfig,
|
||||
),
|
||||
filesystem,
|
||||
sandbox,
|
||||
runtimeService,
|
||||
checkpointStorage,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentWorkspaceService', () => {
|
||||
it('eagerly creates a scoped workspace with only the core workspace tools', async () => {
|
||||
const { service, filesystem } = makeService();
|
||||
const { service, filesystem, runtimeService } = makeService();
|
||||
|
||||
const workspace = await service.getAgentWorkspace(projectId, agentId);
|
||||
const workspace = await service.getAgentWorkspace(projectId, agentId, principalHash);
|
||||
|
||||
expect(runtimeService.acquireWorkspaceSandbox).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
agentId,
|
||||
principalHash,
|
||||
);
|
||||
expect(runtimeService.acquireKnowledgeSandbox).not.toHaveBeenCalled();
|
||||
expect(filesystem.mkdir).toHaveBeenCalledWith('/home/daytona/workspace', {
|
||||
recursive: true,
|
||||
});
|
||||
@@ -53,24 +75,34 @@ describe('AgentWorkspaceService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes only the hashed thread result directory without destroying the sandbox', async () => {
|
||||
const { service, filesystem, sandbox } = makeService();
|
||||
|
||||
await service.cleanupThreadWorkspace(projectId, agentId, '../../knowledge-mirror');
|
||||
|
||||
expect(filesystem.rmdir).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/home\/daytona\/workspace\/tool-results\/threads\/[A-Za-z0-9_-]+$/),
|
||||
{ recursive: true, force: true },
|
||||
it('returns the workspace without waiting for reconciliation and deduplicates concurrent sweeps', async () => {
|
||||
const { service, checkpointStorage } = makeService();
|
||||
let resolveReconciliation!: (activeRunIds: Set<string>) => void;
|
||||
checkpointStorage.getActiveRunIdsForSandbox.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveReconciliation = resolve;
|
||||
}),
|
||||
);
|
||||
expect(sandbox.destroy).not.toHaveBeenCalled();
|
||||
|
||||
const firstWorkspace = await service.getAgentWorkspace(projectId, agentId, principalHash);
|
||||
const secondWorkspace = await service.getAgentWorkspace(projectId, agentId, principalHash);
|
||||
|
||||
expect(firstWorkspace.filesystem?.basePath).toBe('/home/daytona/workspace');
|
||||
expect(secondWorkspace.filesystem?.basePath).toBe('/home/daytona/workspace');
|
||||
expect(checkpointStorage.getActiveRunIdsForSandbox).toHaveBeenCalledOnce();
|
||||
|
||||
resolveReconciliation(new Set());
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it('does not reject when workspace cleanup fails', async () => {
|
||||
const { service, runtimeService } = makeService();
|
||||
runtimeService.acquireSandbox.mockRejectedValue(new Error('sandbox unavailable'));
|
||||
it('skips orphan reconciliation when checkpoint protection overflows', async () => {
|
||||
const { service, filesystem, checkpointStorage } = makeService();
|
||||
checkpointStorage.getActiveRunIdsForSandbox.mockResolvedValue(
|
||||
CHECKPOINT_RECONCILIATION_OVERFLOW,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.cleanupThreadWorkspace(projectId, agentId, 'thread-1'),
|
||||
).resolves.toBeUndefined();
|
||||
await service.getAgentWorkspace(projectId, agentId, principalHash);
|
||||
|
||||
expect(filesystem.readdir).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+35
-12
@@ -37,6 +37,7 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'
|
||||
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
|
||||
import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
|
||||
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
|
||||
import { hashAgentSandboxPrincipal } from '../agent-sandbox-principal';
|
||||
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
|
||||
import type { AgentWorkspaceService } from '../agent-workspace.service';
|
||||
import type { Agent } from '../entities/agent.entity';
|
||||
@@ -258,6 +259,19 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP w
|
||||
});
|
||||
|
||||
describe('AgentRuntimeReconstructionService — workspace attachment', () => {
|
||||
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
|
||||
const reconstructWithWorkspace = async (service: AgentRuntimeReconstructionService) =>
|
||||
await service.reconstructFromAgentEntity(
|
||||
makeAgentEntity(),
|
||||
mock<CredentialProvider>(),
|
||||
'production',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'manual',
|
||||
principalHash,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
builtAgent.hasCheckpointStorage.mockReturnValue(true);
|
||||
@@ -278,13 +292,14 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => {
|
||||
agentWorkspaceService,
|
||||
});
|
||||
|
||||
await service.reconstructFromAgentEntity(
|
||||
makeAgentEntity(),
|
||||
mock<CredentialProvider>(),
|
||||
'production',
|
||||
);
|
||||
await reconstructWithWorkspace(service);
|
||||
|
||||
expect(builtAgent.workspace).toHaveBeenCalledWith(workspace);
|
||||
expect(agentWorkspaceService.getAgentWorkspace).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
'agent-1',
|
||||
principalHash,
|
||||
);
|
||||
expect(getInjectedToolNames()).not.toContain('find_file');
|
||||
});
|
||||
|
||||
@@ -302,11 +317,7 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => {
|
||||
agentWorkspaceService,
|
||||
});
|
||||
|
||||
await service.reconstructFromAgentEntity(
|
||||
makeAgentEntity(),
|
||||
mock<CredentialProvider>(),
|
||||
'production',
|
||||
);
|
||||
await reconstructWithWorkspace(service);
|
||||
|
||||
expect(getInjectedToolNames()).toEqual(
|
||||
expect.arrayContaining(['find_file', 'search_text', 'read_file']),
|
||||
@@ -355,14 +366,26 @@ describe('AgentRuntimeReconstructionService — workspace attachment', () => {
|
||||
agentWorkspaceService,
|
||||
});
|
||||
|
||||
await expect(reconstructWithWorkspace(service)).resolves.toEqual(
|
||||
expect.objectContaining({ agent: builtAgent }),
|
||||
);
|
||||
expect(builtAgent.workspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a first-class runtime without a workspace principal', async () => {
|
||||
const service = makeReconstructionService({
|
||||
agentSandboxRuntimeService: mock<AgentSandboxRuntimeService>({
|
||||
isEnabled: () => true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.reconstructFromAgentEntity(
|
||||
makeAgentEntity(),
|
||||
mock<CredentialProvider>(),
|
||||
'production',
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ agent: builtAgent }));
|
||||
expect(builtAgent.workspace).not.toHaveBeenCalled();
|
||||
).rejects.toThrow('workspace scope is missing');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import type { InstanceAiConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
|
||||
import { AiService } from '@/services/ai.service';
|
||||
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
|
||||
|
||||
@@ -20,57 +17,21 @@ describe('AgentsModule', () => {
|
||||
|
||||
describe('settings()', () => {
|
||||
it.each([
|
||||
{
|
||||
agentsSandboxEnabled: true,
|
||||
instanceAiSandboxEnabled: false,
|
||||
proxyEnabled: false,
|
||||
knowledgeBaseEnabled: true,
|
||||
},
|
||||
{
|
||||
agentsSandboxEnabled: false,
|
||||
instanceAiSandboxEnabled: true,
|
||||
proxyEnabled: false,
|
||||
knowledgeBaseEnabled: true,
|
||||
},
|
||||
{
|
||||
agentsSandboxEnabled: false,
|
||||
instanceAiSandboxEnabled: false,
|
||||
proxyEnabled: true,
|
||||
knowledgeBaseEnabled: false,
|
||||
},
|
||||
{ sandboxEnabled: true, proxyEnabled: false },
|
||||
{ sandboxEnabled: false, proxyEnabled: true },
|
||||
])(
|
||||
'enables knowledge base=$knowledgeBaseEnabled for Agents sandbox=$agentsSandboxEnabled and Instance AI sandbox=$instanceAiSandboxEnabled',
|
||||
async ({
|
||||
agentsSandboxEnabled,
|
||||
instanceAiSandboxEnabled,
|
||||
proxyEnabled,
|
||||
knowledgeBaseEnabled,
|
||||
}) => {
|
||||
const agentsConfig = mock<AgentsConfig>({
|
||||
modules: [],
|
||||
sandboxEnabled: agentsSandboxEnabled,
|
||||
});
|
||||
Container.set(AgentsConfig, agentsConfig);
|
||||
'keeps knowledge base ($sandboxEnabled) and proxy ($proxyEnabled) availability independent',
|
||||
async ({ sandboxEnabled, proxyEnabled }) => {
|
||||
Container.set(AgentsConfig, mock<AgentsConfig>({ modules: [] }));
|
||||
Container.set(
|
||||
SandboxSettingsService,
|
||||
new SandboxSettingsService(
|
||||
{
|
||||
agents: agentsConfig,
|
||||
instanceAi: mock<InstanceAiConfig>({
|
||||
sandboxEnabled: instanceAiSandboxEnabled,
|
||||
sandboxProvider: 'n8n-sandbox',
|
||||
}),
|
||||
deployment: { type: 'default' },
|
||||
} as never,
|
||||
mock<InstanceCredentialBroker>(),
|
||||
mock<Logger>(),
|
||||
),
|
||||
mock<SandboxSettingsService>({ isAgentSandboxEnabled: () => sandboxEnabled }),
|
||||
);
|
||||
Container.set(AiService, mock<AiService>({ isProxyEnabled: () => proxyEnabled }));
|
||||
|
||||
const settings = await module.settings();
|
||||
|
||||
expect(settings.knowledgeBaseEnabled).toBe(knowledgeBaseEnabled);
|
||||
expect(settings.knowledgeBaseEnabled).toBe(sandboxEnabled);
|
||||
expect(settings.proxyEnabled).toBe(proxyEnabled);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('AgentsService', () => {
|
||||
);
|
||||
expect(agentTaskService.requestReconcile).toHaveBeenCalledWith(agentId);
|
||||
expect(testChatService.clearAllTestChatMessages).toHaveBeenCalledWith(agentId);
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId });
|
||||
});
|
||||
|
||||
@@ -375,7 +375,7 @@ describe('AgentsService', () => {
|
||||
|
||||
await expect(service.delete(agentId, projectId)).resolves.toBe(true);
|
||||
expect(agentRepository.remove).toHaveBeenCalledWith(agent);
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(projectId, agentId);
|
||||
});
|
||||
|
||||
it('returns false when deleting a missing agent', async () => {
|
||||
|
||||
@@ -368,7 +368,7 @@ export class AgentChatController {
|
||||
const { projectId, agentId } = req.params;
|
||||
const agent = await this.agentsService.findById(agentId, projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
|
||||
await this.agentTestChatService.clearTestChatMessages(projectId, agentId, req.user.id);
|
||||
await this.agentTestChatService.clearTestChatMessages(agentId, req.user.id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
} from './agent-execution.service';
|
||||
import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service';
|
||||
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
|
||||
import {
|
||||
decodeAgentSandboxHostMetadata,
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from './agent-sandbox-principal';
|
||||
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
|
||||
import { ExecutionRecorder, type MessageRecord } from './execution-recorder';
|
||||
import { IntegrationMessageContextService } from './integrations/integration-message-context.service';
|
||||
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
|
||||
@@ -69,11 +76,11 @@ export interface ExecuteForChatPublishedConfig {
|
||||
memory: AgentMemoryScope;
|
||||
attachments?: StoredAttachmentRef[];
|
||||
integrationType?: string;
|
||||
sandboxPrincipalHash: AgentSandboxPrincipalHash;
|
||||
// No `user` field here: a published chat integration (Slack, Telegram, …)
|
||||
// run is triggered by an inbound platform event, not an interactive n8n
|
||||
// session — there is no n8n `User` to attach. This path keeps the
|
||||
// project-scoped trust boundary that existed before per-user tool
|
||||
// gating; the admin who published the agent is the one who approved its
|
||||
// session — there is no n8n `User` to attach. The admin who published the
|
||||
// agent is the one who approved its
|
||||
// tools, and Layer A's node denylist (`EphemeralNodeExecutor`) still
|
||||
// applies regardless.
|
||||
}
|
||||
@@ -157,6 +164,7 @@ export interface StreamChatResponseConfig {
|
||||
/** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */
|
||||
onExecutionRecorded?: (executionId: string) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
sandboxPrincipalHash: AgentSandboxPrincipalHash;
|
||||
}
|
||||
|
||||
function getMaxIterationsChunks(): StreamChunk[] {
|
||||
@@ -235,6 +243,7 @@ export class AgentExecutionOrchestratorService {
|
||||
private readonly integrationMessageContextService: IntegrationMessageContextService,
|
||||
private readonly agentRunTracingService: AgentRunTracingService,
|
||||
private readonly externalHooks: ExternalHooks,
|
||||
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -341,6 +350,20 @@ export class AgentExecutionOrchestratorService {
|
||||
) {
|
||||
throw new UserError(`Checkpoint ${runId} does not belong to this chat`);
|
||||
}
|
||||
const sandboxScope = decodeAgentSandboxHostMetadata(memoryScope.hostMetadata);
|
||||
const sandboxPrincipalHash = sandboxScope?.principalHash;
|
||||
if (
|
||||
this.agentSandboxRuntimeService.isEnabled() &&
|
||||
(!sandboxScope ||
|
||||
sandboxScope.projectId !== projectId ||
|
||||
!sandboxPrincipalHash ||
|
||||
(!usePublishedVersion &&
|
||||
(!user ||
|
||||
sandboxPrincipalHash !==
|
||||
hashAgentSandboxPrincipal({ type: 'n8n-user', userId: user.id }))))
|
||||
) {
|
||||
throw new UserError(`Checkpoint ${runId} is unavailable and cannot be resumed`);
|
||||
}
|
||||
|
||||
const threadId = memoryScope.threadId;
|
||||
|
||||
@@ -357,18 +380,28 @@ export class AgentExecutionOrchestratorService {
|
||||
// `AgentChatController.chatResume`), and only then does the caller's
|
||||
// `user` actually reach the cache/reconstruction layer.
|
||||
user: usePublishedVersion ? undefined : user,
|
||||
...(sandboxPrincipalHash ? { sandboxPrincipalHash } : {}),
|
||||
});
|
||||
|
||||
const { agent: agentInstance, toolRegistry } = runtime;
|
||||
let executionId: string | undefined;
|
||||
const recorder = this.createRecorder(toolRegistry, () => executionId, {
|
||||
projectId,
|
||||
agentId,
|
||||
threadId,
|
||||
});
|
||||
const startedAt = recorder.startedAt;
|
||||
const runType: AgentRunTelemetryType = usePublishedVersion ? 'production' : 'test';
|
||||
let executionSource = source;
|
||||
let recorder: ExecutionRecorder;
|
||||
let startedAt: Date;
|
||||
let runType: AgentRunTelemetryType;
|
||||
let executionSource: string | undefined;
|
||||
try {
|
||||
recorder = this.createRecorder(toolRegistry, () => executionId, {
|
||||
projectId,
|
||||
agentId,
|
||||
threadId,
|
||||
});
|
||||
startedAt = recorder.startedAt;
|
||||
runType = usePublishedVersion ? 'production' : 'test';
|
||||
executionSource = source;
|
||||
} catch (error) {
|
||||
this.runtimeCacheService.releaseRuntimeLease(agentInstance);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// A resume request carries no `source` of its own — recover it from
|
||||
@@ -427,29 +460,36 @@ export class AgentExecutionOrchestratorService {
|
||||
recorder.record({ type: 'finish', finishReason: 'error' });
|
||||
throw error;
|
||||
} finally {
|
||||
// Always record resumed executions — even if they suspend again (chained HITL)
|
||||
// or fail while streaming. Don't repeat the original user message — the
|
||||
// pre-suspension execution already has it.
|
||||
const messageRecord = normalizeAbortedMessageRecord(recorder.getMessageRecord(), abortSignal);
|
||||
await this.persistRecordedExecution({
|
||||
executionId,
|
||||
onExecutionRecorded,
|
||||
failureMessage: 'Failed to record resumed agent execution',
|
||||
params: {
|
||||
threadId,
|
||||
agentId,
|
||||
agentName: agentInstance.name,
|
||||
projectId,
|
||||
userMessage: null,
|
||||
...(executionSource !== undefined ? { source: executionSource } : {}),
|
||||
record: messageRecord,
|
||||
hitlStatus: recorder.suspended ? 'suspended' : 'resumed',
|
||||
telemetry: {
|
||||
runType,
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
try {
|
||||
// Always record resumed executions — even if they suspend again (chained HITL)
|
||||
// or fail while streaming. Don't repeat the original user message — the
|
||||
// pre-suspension execution already has it.
|
||||
const messageRecord = normalizeAbortedMessageRecord(
|
||||
recorder.getMessageRecord(),
|
||||
abortSignal,
|
||||
);
|
||||
await this.persistRecordedExecution({
|
||||
executionId,
|
||||
onExecutionRecorded,
|
||||
failureMessage: 'Failed to record resumed agent execution',
|
||||
params: {
|
||||
threadId,
|
||||
agentId,
|
||||
agentName: agentInstance.name,
|
||||
projectId,
|
||||
userMessage: null,
|
||||
...(executionSource !== undefined ? { source: executionSource } : {}),
|
||||
record: messageRecord,
|
||||
hitlStatus: recorder.suspended ? 'suspended' : 'resumed',
|
||||
telemetry: {
|
||||
runType,
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.runtimeCacheService.releaseRuntimeLease(agentInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,38 +511,48 @@ export class AgentExecutionOrchestratorService {
|
||||
|
||||
// `user` is always set (see ExecuteForChatConfig) — this builds/reuses a
|
||||
// runtime scoped to this specific user's tool access.
|
||||
const sandboxPrincipalHash = hashAgentSandboxPrincipal({
|
||||
type: 'n8n-user',
|
||||
userId: user.id,
|
||||
});
|
||||
const runtime = await this.runtimeCacheService.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
integrationType: N8N_CHAT_INTEGRATION_TYPE,
|
||||
user,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
|
||||
await this.integrationMessageContextService.setLatest(memory.threadId, memory.resourceId, {
|
||||
integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE,
|
||||
platform: N8N_CHAT_INTEGRATION_TYPE,
|
||||
target: { type: 'dm', userId: user.id, threadId: memory.threadId },
|
||||
interactingUserId: user.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
try {
|
||||
await this.integrationMessageContextService.setLatest(memory.threadId, memory.resourceId, {
|
||||
integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE,
|
||||
platform: N8N_CHAT_INTEGRATION_TYPE,
|
||||
target: { type: 'dm', userId: user.id, threadId: memory.threadId },
|
||||
interactingUserId: user.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId: user.id,
|
||||
message,
|
||||
attachments,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source,
|
||||
telemetry: {
|
||||
runType: 'test',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
onExecutionRecorded,
|
||||
abortSignal,
|
||||
});
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId: user.id,
|
||||
message,
|
||||
attachments,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source,
|
||||
telemetry: {
|
||||
runType: 'test',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
onExecutionRecorded,
|
||||
abortSignal,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
} finally {
|
||||
this.runtimeCacheService.releaseRuntimeLease(runtime.agent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -513,34 +563,46 @@ export class AgentExecutionOrchestratorService {
|
||||
async *executeForChatPublished(
|
||||
config: ExecuteForChatPublishedConfig,
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const { agentId, projectId, message, memory, integrationType, attachments } = config;
|
||||
const {
|
||||
agentId,
|
||||
projectId,
|
||||
message,
|
||||
memory,
|
||||
integrationType,
|
||||
attachments,
|
||||
sandboxPrincipalHash,
|
||||
} = config;
|
||||
await this.externalHooks.run('agent.preExecute', [agentId]);
|
||||
|
||||
// No `user` (see ExecuteForChatPublishedConfig): this is the shared,
|
||||
// project-scoped runtime — every caller of this published agent through
|
||||
// this integration reuses the same cache entry regardless of who
|
||||
// triggered the platform event.
|
||||
// Published integration runtimes have no n8n user but are isolated by
|
||||
// their external caller's hashed workspace principal.
|
||||
const runtime = await this.runtimeCacheService.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
integrationType,
|
||||
usePublishedVersion: true,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
message,
|
||||
attachments,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: integrationType,
|
||||
telemetry: {
|
||||
runType: 'production',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
});
|
||||
try {
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
message,
|
||||
attachments,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: integrationType,
|
||||
telemetry: {
|
||||
runType: 'production',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
} finally {
|
||||
this.runtimeCacheService.releaseRuntimeLease(runtime.agent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,30 +615,36 @@ export class AgentExecutionOrchestratorService {
|
||||
const { agentId, projectId, message, memory, taskId, taskVersionId } = config;
|
||||
await this.externalHooks.run('agent.preExecute', [agentId]);
|
||||
|
||||
// No `user` (see ExecuteForTaskPublishedConfig): cron-fired, no human to
|
||||
// attach — same shared, project-scoped runtime for every tick.
|
||||
// Cron-fired runs have no n8n user and reuse the scheduled task's scope.
|
||||
const sandboxPrincipalHash = hashAgentSandboxPrincipal({ type: 'scheduled-task', taskId });
|
||||
const runtime = await this.runtimeCacheService.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
integrationType: 'task',
|
||||
usePublishedVersion: true,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
message,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: 'task',
|
||||
taskId,
|
||||
taskVersionId,
|
||||
telemetry: {
|
||||
runType: 'production',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
});
|
||||
try {
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
message,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: 'task',
|
||||
taskId,
|
||||
taskVersionId,
|
||||
telemetry: {
|
||||
runType: 'production',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
} finally {
|
||||
this.runtimeCacheService.releaseRuntimeLease(runtime.agent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,27 +657,37 @@ export class AgentExecutionOrchestratorService {
|
||||
// `user` is always set (see ExecuteForTaskNowConfig) — manual "Run now"
|
||||
// runs get a runtime scoped to the requesting user's tool access, same
|
||||
// as the in-app test chat.
|
||||
const sandboxPrincipalHash = hashAgentSandboxPrincipal({
|
||||
type: 'n8n-user',
|
||||
userId: user.id,
|
||||
});
|
||||
const runtime = await this.runtimeCacheService.getRuntime({
|
||||
agentId,
|
||||
projectId,
|
||||
user,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId: user.id,
|
||||
message,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: 'task',
|
||||
taskId,
|
||||
telemetry: {
|
||||
runType: 'test',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
});
|
||||
try {
|
||||
yield* this.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId: user.id,
|
||||
message,
|
||||
memory,
|
||||
projectId: runtime.projectId,
|
||||
source: 'task',
|
||||
taskId,
|
||||
telemetry: {
|
||||
runType: 'test',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
} finally {
|
||||
this.runtimeCacheService.releaseRuntimeLease(runtime.agent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,6 +709,7 @@ export class AgentExecutionOrchestratorService {
|
||||
telemetry,
|
||||
onExecutionRecorded,
|
||||
abortSignal,
|
||||
sandboxPrincipalHash,
|
||||
} = config;
|
||||
const { threadId, resourceId } = memory;
|
||||
|
||||
@@ -653,8 +732,12 @@ export class AgentExecutionOrchestratorService {
|
||||
});
|
||||
|
||||
const input = attachments?.length ? buildInboundUserMessage(message, attachments) : message;
|
||||
const hostMetadata = encodeAgentSandboxHostMetadata({
|
||||
projectId,
|
||||
principalHash: sandboxPrincipalHash,
|
||||
});
|
||||
const resultStream = await agentInstance.stream(input, {
|
||||
persistence: { threadId, resourceId },
|
||||
persistence: { threadId, resourceId, hostMetadata },
|
||||
executionCounter: createAgentExecutionCounter(this.telemetry, {
|
||||
agentId,
|
||||
userId,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type StoredAttachmentRef,
|
||||
} from './agent-chat-attachment.service';
|
||||
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
|
||||
import { AgentWorkspaceService } from './agent-workspace.service';
|
||||
import { AgentExecutionThread } from './entities/agent-execution-thread.entity';
|
||||
import { AgentExecution } from './entities/agent-execution.entity';
|
||||
import type { MessageRecord, TimelineEvent } from './execution-recorder';
|
||||
@@ -104,7 +103,6 @@ export class AgentExecutionService {
|
||||
private readonly storageConfig: StorageConfig,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly executionUpdateBroadcaster: AgentExecutionUpdateBroadcaster,
|
||||
private readonly agentWorkspaceService: AgentWorkspaceService,
|
||||
) {}
|
||||
|
||||
async startExecutionRecording(params: StartExecutionParams, startedAt: Date): Promise<string> {
|
||||
@@ -445,7 +443,6 @@ export class AgentExecutionService {
|
||||
await this.n8nMemory.getImplementation(agentId).deleteThread(threadId);
|
||||
await this.agentChatAttachmentService.deleteByThread(threadId, { projectId });
|
||||
await Promise.all([
|
||||
this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId),
|
||||
this.agentExecutionThreadRepository.delete({ id: threadId }),
|
||||
this.agentExecutionLogStore.delete(
|
||||
blobRefs.map((r) => ({ agentId, threadId, executionId: r.id, storedAt: r.storedAt })),
|
||||
|
||||
@@ -525,7 +525,10 @@ export class AgentKnowledgeMirrorService {
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<AgentKnowledgeMirrorRuntime> {
|
||||
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
|
||||
const runtime = await this.agentSandboxRuntimeService.acquireKnowledgeSandbox(
|
||||
projectId,
|
||||
agentId,
|
||||
);
|
||||
return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,11 @@ export class AgentKnowledgeService {
|
||||
return files.map((file) => toAgentFileDto(file));
|
||||
}
|
||||
|
||||
async warmSandbox(agentId: string, projectId: string): Promise<void> {
|
||||
async warmKnowledgeSandbox(agentId: string, projectId: string): Promise<void> {
|
||||
await this.ensureAgentBelongsToProject(agentId, projectId);
|
||||
await this.agentSandboxRuntimeService.warmSandbox(projectId, agentId);
|
||||
if (!(await this.agentFileRepository.hasFilesForAgent(agentId))) return;
|
||||
|
||||
await this.agentSandboxRuntimeService.warmKnowledgeSandbox(projectId, agentId);
|
||||
}
|
||||
|
||||
async deleteFile(agentId: string, projectId: string, fileId: string): Promise<void> {
|
||||
@@ -112,7 +114,11 @@ export class AgentKnowledgeService {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
});
|
||||
this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId);
|
||||
if (await this.agentFileRepository.hasFilesForAgent(agentId)) {
|
||||
this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId);
|
||||
} else {
|
||||
await this.agentSandboxRuntimeService.destroyKnowledgeSandbox(projectId, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllFilesForAgent(_projectId: string, agentId: string): Promise<void> {
|
||||
@@ -136,8 +142,8 @@ export class AgentKnowledgeService {
|
||||
}
|
||||
|
||||
/** Best-effort passthrough for agent/project deletion; never throws. */
|
||||
async destroySandbox(projectId: string, agentId: string): Promise<void> {
|
||||
await this.agentSandboxRuntimeService.destroySandbox(projectId, agentId);
|
||||
async destroyKnowledgeSandbox(projectId: string, agentId: string): Promise<void> {
|
||||
await this.agentSandboxRuntimeService.destroyKnowledgeSandbox(projectId, agentId);
|
||||
}
|
||||
|
||||
/** Stores the file's bytes via AgentKnowledgeFileStore, then reserves its DB row. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Time } from '@n8n/constants';
|
||||
import type { User } from '@n8n/db';
|
||||
import { OnPubSubEvent } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
@@ -13,6 +14,11 @@ import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { TtlMap } from '@/utils/ttl-map';
|
||||
|
||||
import {
|
||||
hashAgentSandboxPrincipal,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from './agent-sandbox-principal';
|
||||
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
|
||||
import { buildAgentConfigurationTelemetry } from './agent-telemetry';
|
||||
import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service';
|
||||
import type { Agent } from './entities/agent.entity';
|
||||
@@ -30,10 +36,11 @@ export interface GetRuntimeParams {
|
||||
/**
|
||||
* The calling n8n user. When present, the runtime is built with node/workflow
|
||||
* tools filtered down to what this user can access, and the cache key is
|
||||
* scoped to the user so different users never share a runtime. Absent for
|
||||
* scoped to the caller so different users never share a runtime. Absent for
|
||||
* published/integration runs, which keep today's project-scoped runtime.
|
||||
*/
|
||||
user?: User;
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash;
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
@@ -53,8 +60,8 @@ interface RuntimeInitialization {
|
||||
export class AgentRuntimeCacheService {
|
||||
/**
|
||||
* Cached agent runtimes. Keys follow the pattern:
|
||||
* Draft: `{agentId}:draft[:{integrationType}][:user:{userId}]`
|
||||
* Published: `{agentId}:published[:{integrationType}]`
|
||||
* Draft: `{agentId}:draft[:{integrationType}][:{callerScope}]`
|
||||
* Published: `{agentId}:published[:{integrationType}][:{callerScope}]`
|
||||
*
|
||||
* TTL = 30 minutes — entries are evicted when the agent is idle so that
|
||||
* memory is freed without requiring an explicit shutdown step.
|
||||
@@ -62,18 +69,21 @@ export class AgentRuntimeCacheService {
|
||||
* Separating draft and published with explicit prefixes prevents a draft
|
||||
* runtime from being mistakenly returned to a published-agent execution.
|
||||
*
|
||||
* The `:user:{userId}` suffix only ever appears on draft keys — published
|
||||
* runs never carry a `user` (see `GetRuntimeParams.user`), since they have
|
||||
* no interactive n8n session to gate tools against. A draft runtime's tool
|
||||
* list is filtered per-user at build time (see
|
||||
* `AgentRuntimeReconstructionService.reconstructFromAgentEntity`), so two
|
||||
* different users hitting the same draft agent must never resolve to the
|
||||
* same cache entry — that would leak one user's tool access to the other.
|
||||
* With sandbox support enabled, caller scope is the workspace principal.
|
||||
* Without it, draft runtimes retain equivalent per-user isolation via a hash.
|
||||
*/
|
||||
private readonly runtimes = new TtlMap<string, AgentRuntime>(30 * Time.minutes.toMilliseconds);
|
||||
private readonly runtimes = new TtlMap<string, AgentRuntime>(
|
||||
30 * Time.minutes.toMilliseconds,
|
||||
undefined,
|
||||
(runtime) => this.closeAgentResources(runtime.agent, runtime.agentId),
|
||||
);
|
||||
|
||||
private readonly runtimeInitializations = new Map<string, RuntimeInitialization>();
|
||||
|
||||
private readonly activeRuntimeLeases = new WeakMap<RuntimeAgent, number>();
|
||||
|
||||
private readonly runtimesPendingClose = new WeakMap<RuntimeAgent, string>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
@@ -81,20 +91,21 @@ export class AgentRuntimeCacheService {
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly agentRuntimeReconstructionService: AgentRuntimeReconstructionService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
|
||||
) {}
|
||||
|
||||
private computeRuntimeCacheKey(params: GetRuntimeParams): string {
|
||||
if (params.usePublishedVersion) {
|
||||
const parts = [params.agentId, 'published'];
|
||||
if (params.integrationType) parts.push(params.integrationType);
|
||||
return parts.join(':');
|
||||
}
|
||||
const parts = [params.agentId, 'draft'];
|
||||
const sandboxEnabled = this.agentSandboxRuntimeService.isEnabled();
|
||||
const parts = [params.agentId, params.usePublishedVersion ? 'published' : 'draft'];
|
||||
if (params.integrationType) parts.push(params.integrationType);
|
||||
// Per-user runtimes have node/workflow tools filtered by that user's
|
||||
// access — keying by user id keeps them from colliding with each other
|
||||
// or with the unscoped (no-user) runtime.
|
||||
if (params.user) parts.push(`user:${params.user.id}`);
|
||||
if (sandboxEnabled && params.sandboxPrincipalHash) {
|
||||
parts.push(`sandbox:${params.sandboxPrincipalHash}`);
|
||||
} else if (!params.usePublishedVersion && params.user) {
|
||||
parts.push(`user:${hashAgentSandboxPrincipal({ type: 'n8n-user', userId: params.user.id })}`);
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
@@ -160,7 +171,12 @@ export class AgentRuntimeCacheService {
|
||||
* which disposes the runtime and disconnects any attached MCP clients.
|
||||
* Errors are logged but never thrown.
|
||||
*/
|
||||
private closeAgentResources(agent: { close(): Promise<void> }, agentId: string): void {
|
||||
private closeAgentResources(agent: RuntimeAgent, agentId: string): void {
|
||||
if (this.activeRuntimeLeases.has(agent)) {
|
||||
this.runtimesPendingClose.set(agent, agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
agent.close().catch((error) => {
|
||||
this.logger.warn('[AgentRuntimeCacheService] Failed to close agent resources on eviction', {
|
||||
agentId,
|
||||
@@ -169,17 +185,45 @@ export class AgentRuntimeCacheService {
|
||||
});
|
||||
}
|
||||
|
||||
private acquireRuntimeLease(runtime: AgentRuntime): AgentRuntime {
|
||||
const { agent } = runtime;
|
||||
this.activeRuntimeLeases.set(agent, (this.activeRuntimeLeases.get(agent) ?? 0) + 1);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
releaseRuntimeLease(agent: RuntimeAgent): void {
|
||||
const activeLeases = this.activeRuntimeLeases.get(agent);
|
||||
if (activeLeases === undefined) return;
|
||||
if (activeLeases > 1) {
|
||||
this.activeRuntimeLeases.set(agent, activeLeases - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRuntimeLeases.delete(agent);
|
||||
const agentId = this.runtimesPendingClose.get(agent);
|
||||
if (agentId !== undefined) {
|
||||
this.runtimesPendingClose.delete(agent);
|
||||
this.closeAgentResources(agent, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cached runtime, or reconstruct one from the DB.
|
||||
* Return a leased cached runtime, or reconstruct one from the DB.
|
||||
* Callers must release the lease in a `finally` block.
|
||||
*/
|
||||
async getRuntime(params: GetRuntimeParams): Promise<AgentRuntime> {
|
||||
if (this.agentSandboxRuntimeService.isEnabled() && !params.sandboxPrincipalHash) {
|
||||
throw new UserError(
|
||||
'Agent workspace scope is missing and the runtime cannot be reconstructed',
|
||||
);
|
||||
}
|
||||
const cacheKey = this.computeRuntimeCacheKey(params);
|
||||
|
||||
const cached = this.runtimes.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (cached) return this.acquireRuntimeLease(cached);
|
||||
|
||||
const initialization = this.runtimeInitializations.get(cacheKey);
|
||||
if (initialization) return await initialization.promise;
|
||||
if (initialization) return this.acquireRuntimeLease(await initialization.promise);
|
||||
|
||||
const token = Symbol(cacheKey);
|
||||
const runtimeInitialization: RuntimeInitialization = {
|
||||
@@ -204,11 +248,12 @@ export class AgentRuntimeCacheService {
|
||||
});
|
||||
this.runtimeInitializations.set(cacheKey, runtimeInitialization);
|
||||
|
||||
return await runtimeInitialization.promise;
|
||||
return this.acquireRuntimeLease(await runtimeInitialization.promise);
|
||||
}
|
||||
|
||||
private async reconstructRuntime(params: GetRuntimeParams): Promise<AgentRuntime> {
|
||||
const { agentId, projectId, integrationType, usePublishedVersion, user } = params;
|
||||
const { agentId, projectId, integrationType, usePublishedVersion, user, sandboxPrincipalHash } =
|
||||
params;
|
||||
|
||||
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`);
|
||||
@@ -228,16 +273,17 @@ export class AgentRuntimeCacheService {
|
||||
projectId,
|
||||
user,
|
||||
);
|
||||
const { agent: agentInstance, toolRegistry } =
|
||||
await this.agentRuntimeReconstructionService.reconstructFromAgentEntity(
|
||||
agentData,
|
||||
credentialProvider,
|
||||
usePublishedVersion ? 'production' : 'test',
|
||||
integrationType,
|
||||
user,
|
||||
undefined,
|
||||
usePublishedVersion ? 'integrated' : 'manual',
|
||||
);
|
||||
const reconstruction = this.agentRuntimeReconstructionService.reconstructFromAgentEntity(
|
||||
agentData,
|
||||
credentialProvider,
|
||||
usePublishedVersion ? 'production' : 'test',
|
||||
integrationType,
|
||||
user,
|
||||
undefined,
|
||||
usePublishedVersion ? 'integrated' : 'manual',
|
||||
sandboxPrincipalHash,
|
||||
);
|
||||
const { agent: agentInstance, toolRegistry } = await reconstruction;
|
||||
|
||||
return {
|
||||
agent: agentInstance,
|
||||
|
||||
@@ -49,6 +49,7 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import { AgentKnowledgeMirrorService } from './agent-knowledge-mirror.service';
|
||||
import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal';
|
||||
import {
|
||||
AgentSandboxRuntimeService,
|
||||
sanitizeSandboxErrorDetail,
|
||||
@@ -135,6 +136,7 @@ export interface ReconstructAgentRuntimeParams {
|
||||
user?: User;
|
||||
/** Runtime seams inherited from the delegating parent run (see {@link AgentRuntimeInstrumentation}). */
|
||||
instrumentation?: AgentRuntimeInstrumentation;
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash;
|
||||
}
|
||||
|
||||
async function getChatIntegrationToolServices() {
|
||||
@@ -195,6 +197,7 @@ export class AgentRuntimeReconstructionService {
|
||||
user?: User,
|
||||
instrumentation?: AgentRuntimeInstrumentation,
|
||||
workflowToolExecutionMode: WorkflowToolExecutionMode = 'manual',
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash,
|
||||
): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
|
||||
let config = agentEntity.schema;
|
||||
if (!config) {
|
||||
@@ -242,6 +245,7 @@ export class AgentRuntimeReconstructionService {
|
||||
subAgentDelegation,
|
||||
user,
|
||||
instrumentation,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,6 +359,7 @@ export class AgentRuntimeReconstructionService {
|
||||
subAgentDelegation: SubAgentDelegationConfig;
|
||||
user?: User;
|
||||
instrumentation?: AgentRuntimeInstrumentation;
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash;
|
||||
}): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
|
||||
const {
|
||||
config,
|
||||
@@ -373,6 +378,7 @@ export class AgentRuntimeReconstructionService {
|
||||
subAgentDelegation,
|
||||
user,
|
||||
instrumentation,
|
||||
sandboxPrincipalHash,
|
||||
} = options;
|
||||
|
||||
const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName);
|
||||
@@ -454,6 +460,7 @@ export class AgentRuntimeReconstructionService {
|
||||
credentialIntegrations,
|
||||
user,
|
||||
instrumentation,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
|
||||
return { agent: reconstructed, toolRegistry: buildToolRegistry(resolvedTools) };
|
||||
@@ -579,6 +586,7 @@ export class AgentRuntimeReconstructionService {
|
||||
credentialIntegrations: AgentIntegrationConfig[];
|
||||
user?: User;
|
||||
instrumentation?: AgentRuntimeInstrumentation;
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
agent,
|
||||
@@ -595,13 +603,25 @@ export class AgentRuntimeReconstructionService {
|
||||
credentialIntegrations,
|
||||
user,
|
||||
instrumentation,
|
||||
sandboxPrincipalHash,
|
||||
} = params;
|
||||
|
||||
agent.tool(createGetEnvironmentTool());
|
||||
|
||||
if (runtimeProfile !== 'inline' && this.agentSandboxRuntimeService.isEnabled()) {
|
||||
if (!sandboxPrincipalHash) {
|
||||
throw new UserError(
|
||||
'Agent workspace scope is missing and the runtime cannot be reconstructed',
|
||||
);
|
||||
}
|
||||
try {
|
||||
agent.workspace(await this.agentWorkspaceService.getAgentWorkspace(projectId, agentId));
|
||||
agent.workspace(
|
||||
await this.agentWorkspaceService.getAgentWorkspace(
|
||||
projectId,
|
||||
agentId,
|
||||
sandboxPrincipalHash,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to attach agent workspace', {
|
||||
projectId,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { JSONObject } from '@n8n/agents';
|
||||
|
||||
export type AgentSandboxPrincipal =
|
||||
| { type: 'n8n-user'; userId: string }
|
||||
| {
|
||||
type: 'integration-user';
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
platformUserId: string;
|
||||
}
|
||||
| { type: 'workflow-session'; workflowId: string; sessionId: string }
|
||||
| {
|
||||
type: 'workflow-execution';
|
||||
workflowId: string;
|
||||
executionId: string;
|
||||
}
|
||||
| { type: 'scheduled-task'; taskId: string };
|
||||
|
||||
declare const agentSandboxPrincipalHashBrand: unique symbol;
|
||||
|
||||
export type AgentSandboxPrincipalHash = string & {
|
||||
readonly [agentSandboxPrincipalHashBrand]: true;
|
||||
};
|
||||
|
||||
export interface AgentSandboxPersistenceScope {
|
||||
projectId: string;
|
||||
principalHash: AgentSandboxPrincipalHash;
|
||||
}
|
||||
|
||||
const AGENT_SANDBOX_HOST_METADATA_KEY = 'n8nAgentSandbox';
|
||||
|
||||
export function isAgentSandboxPrincipalHash(value: string): value is AgentSandboxPrincipalHash {
|
||||
return /^[A-Za-z0-9_-]{43}$/.test(value);
|
||||
}
|
||||
|
||||
export function encodeAgentSandboxHostMetadata(scope: AgentSandboxPersistenceScope): JSONObject {
|
||||
return {
|
||||
[AGENT_SANDBOX_HOST_METADATA_KEY]: {
|
||||
projectId: scope.projectId,
|
||||
principalHash: scope.principalHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeAgentSandboxHostMetadata(
|
||||
hostMetadata: JSONObject | undefined,
|
||||
): AgentSandboxPersistenceScope | undefined {
|
||||
const scope = hostMetadata?.[AGENT_SANDBOX_HOST_METADATA_KEY];
|
||||
if (
|
||||
typeof scope !== 'object' ||
|
||||
scope === null ||
|
||||
Array.isArray(scope) ||
|
||||
Object.keys(scope).length !== 2
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const { projectId, principalHash } = scope;
|
||||
if (
|
||||
typeof projectId !== 'string' ||
|
||||
typeof principalHash !== 'string' ||
|
||||
!isAgentSandboxPrincipalHash(principalHash)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { projectId, principalHash };
|
||||
}
|
||||
|
||||
export function hashAgentSandboxPrincipal(
|
||||
principal: AgentSandboxPrincipal,
|
||||
): AgentSandboxPrincipalHash {
|
||||
let canonicalPrincipal: string[];
|
||||
|
||||
switch (principal.type) {
|
||||
case 'n8n-user':
|
||||
canonicalPrincipal = [principal.type, principal.userId];
|
||||
break;
|
||||
case 'integration-user':
|
||||
canonicalPrincipal = [
|
||||
principal.type,
|
||||
principal.connectionId,
|
||||
principal.platform,
|
||||
principal.platformUserId,
|
||||
];
|
||||
break;
|
||||
case 'workflow-session':
|
||||
canonicalPrincipal = [principal.type, principal.workflowId, principal.sessionId];
|
||||
break;
|
||||
case 'workflow-execution':
|
||||
canonicalPrincipal = [principal.type, principal.workflowId, principal.executionId];
|
||||
break;
|
||||
case 'scheduled-task':
|
||||
canonicalPrincipal = [principal.type, principal.taskId];
|
||||
break;
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(canonicalPrincipal))
|
||||
.digest('base64url') as AgentSandboxPrincipalHash;
|
||||
}
|
||||
@@ -24,19 +24,31 @@ import { SandboxSettingsService } from '@/services/sandbox-settings.service';
|
||||
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
|
||||
|
||||
import { assertKnowledgePathSegment } from './agent-knowledge-storage';
|
||||
import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
|
||||
export const AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-';
|
||||
|
||||
const AGENT_KNOWLEDGE_SANDBOX_NAMESPACE = '5b5fd8cd-59c1-5914-aabc-cf7257fb46bc';
|
||||
const WORKSPACE_SANDBOX_NAMESPACE = '38348f53-e947-42c7-8c04-83aa154be385';
|
||||
const KNOWLEDGE_SANDBOX_NAMESPACE = '51989c13-3ae7-4167-8e64-d874dd068795';
|
||||
const WORKSPACE_SANDBOX_NAME_PREFIX = 'agent-ws-';
|
||||
const KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-kb-';
|
||||
const MAX_SANDBOX_ERROR_DETAIL_CHARS = 2_000;
|
||||
|
||||
const LABEL_KNOWLEDGE_BASE = 'n8n-agents-knowledgebase';
|
||||
const LABEL_PROJECT_ID = 'n8n-project-id';
|
||||
const LABEL_AGENT_ID = 'n8n-agent-id';
|
||||
const LABEL_SANDBOX_KIND = 'n8n-agent-sandbox-kind';
|
||||
const LABEL_PRINCIPAL_HASH = 'n8n-agent-principal-hash';
|
||||
|
||||
const DEFAULT_SANDBOX_IMAGE = 'daytonaio/sandbox:0.5.0';
|
||||
const AUTO_STOP_INTERVAL_MINUTES = 5;
|
||||
const WORKSPACE_AUTO_STOP_INTERVAL_MINUTES = 5;
|
||||
const KNOWLEDGE_AUTO_STOP_INTERVAL_MINUTES = 15;
|
||||
const KNOWLEDGE_AUTO_ARCHIVE_INTERVAL_MINUTES = 60;
|
||||
const KNOWLEDGE_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60;
|
||||
|
||||
type DaytonaSandboxLifecycle = Pick<
|
||||
DaytonaSandboxConfig,
|
||||
'ephemeral' | 'autoStopInterval' | 'autoArchiveInterval' | 'autoDeleteInterval'
|
||||
>;
|
||||
|
||||
export interface AgentSandboxRuntime {
|
||||
provider: SandboxProvider;
|
||||
@@ -46,23 +58,48 @@ export interface AgentSandboxRuntime {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
function buildSandboxName(scope: {
|
||||
function buildWorkspaceSandboxId(scope: {
|
||||
instanceId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
principalHash: AgentSandboxPrincipalHash;
|
||||
}): string {
|
||||
return uuidv5(
|
||||
JSON.stringify([scope.instanceId, scope.projectId, scope.agentId, scope.principalHash]),
|
||||
WORKSPACE_SANDBOX_NAMESPACE,
|
||||
);
|
||||
}
|
||||
|
||||
function buildKnowledgeSandboxId(scope: {
|
||||
instanceId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
}): string {
|
||||
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${scope.instanceId}-${scope.projectId}-${scope.agentId}`.toLowerCase();
|
||||
return uuidv5(
|
||||
JSON.stringify([scope.instanceId, scope.projectId, scope.agentId]),
|
||||
KNOWLEDGE_SANDBOX_NAMESPACE,
|
||||
);
|
||||
}
|
||||
|
||||
function buildN8nSandboxId(sandboxName: string): string {
|
||||
return uuidv5(sandboxName, AGENT_KNOWLEDGE_SANDBOX_NAMESPACE);
|
||||
function buildWorkspaceLabels(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
[LABEL_PROJECT_ID]: projectId,
|
||||
[LABEL_AGENT_ID]: agentId,
|
||||
[LABEL_SANDBOX_KIND]: 'workspace',
|
||||
[LABEL_PRINCIPAL_HASH]: principalHash,
|
||||
};
|
||||
}
|
||||
|
||||
function buildScopeLabels(projectId: string, agentId: string): Record<string, string> {
|
||||
function buildKnowledgeLabels(projectId: string, agentId: string): Record<string, string> {
|
||||
return {
|
||||
[LABEL_KNOWLEDGE_BASE]: 'true',
|
||||
[LABEL_PROJECT_ID]: projectId,
|
||||
[LABEL_AGENT_ID]: agentId,
|
||||
[LABEL_SANDBOX_KIND]: 'knowledge',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,39 +126,109 @@ export class AgentSandboxRuntimeService {
|
||||
private readonly sandboxSettingsService: SandboxSettingsService,
|
||||
) {}
|
||||
|
||||
async warmSandbox(projectId: string, agentId: string): Promise<void> {
|
||||
async warmKnowledgeSandbox(projectId: string, agentId: string): Promise<void> {
|
||||
this.assertSandboxConfiguration(projectId, agentId);
|
||||
await this.acquireSandbox(projectId, agentId);
|
||||
await this.acquireKnowledgeSandbox(projectId, agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sandbox teardown for agent/project deletion. Never throws —
|
||||
* callers must not have cleanup failures block the parent delete operation.
|
||||
*/
|
||||
async destroySandbox(projectId: string, agentId: string): Promise<void> {
|
||||
const sandboxName = buildSandboxName({
|
||||
async destroyWorkspaceSandbox(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
): Promise<void> {
|
||||
const sandboxId = buildWorkspaceSandboxId({
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
projectId,
|
||||
agentId,
|
||||
principalHash,
|
||||
});
|
||||
await this.destroySandboxByIdentity(
|
||||
projectId,
|
||||
agentId,
|
||||
`${WORKSPACE_SANDBOX_NAME_PREFIX}${sandboxId}`,
|
||||
sandboxId,
|
||||
buildWorkspaceLabels(projectId, agentId, principalHash),
|
||||
);
|
||||
}
|
||||
|
||||
async destroyKnowledgeSandbox(projectId: string, agentId: string): Promise<void> {
|
||||
const sandboxId = buildKnowledgeSandboxId({
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
projectId,
|
||||
agentId,
|
||||
});
|
||||
const sandboxes = [
|
||||
['daytona', sandboxName],
|
||||
['n8n-sandbox', buildN8nSandboxId(sandboxName)],
|
||||
] as const;
|
||||
|
||||
for (const [provider, sandboxId] of sandboxes) {
|
||||
await this.tryDestroySandbox(projectId, agentId, provider, sandboxId);
|
||||
}
|
||||
await this.destroySandboxByIdentity(
|
||||
projectId,
|
||||
agentId,
|
||||
`${KNOWLEDGE_SANDBOX_NAME_PREFIX}${sandboxId}`,
|
||||
sandboxId,
|
||||
buildKnowledgeLabels(projectId, agentId),
|
||||
);
|
||||
}
|
||||
|
||||
async acquireSandbox(projectId: string, agentId: string): Promise<AgentSandboxRuntime> {
|
||||
async acquireWorkspaceSandbox(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
): Promise<AgentSandboxRuntime> {
|
||||
const provider = this.sandboxSettingsService.getProvider();
|
||||
const sandboxName = buildSandboxName({
|
||||
const sandboxId = buildWorkspaceSandboxId({
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
projectId,
|
||||
agentId,
|
||||
principalHash,
|
||||
});
|
||||
return await this.acquireSandboxByIdentity(
|
||||
projectId,
|
||||
agentId,
|
||||
provider,
|
||||
`${WORKSPACE_SANDBOX_NAME_PREFIX}${sandboxId}`,
|
||||
sandboxId,
|
||||
buildWorkspaceLabels(projectId, agentId, principalHash),
|
||||
`${provider}:workspace:${sandboxId}`,
|
||||
{
|
||||
ephemeral: true,
|
||||
autoStopInterval: WORKSPACE_AUTO_STOP_INTERVAL_MINUTES,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async acquireKnowledgeSandbox(projectId: string, agentId: string): Promise<AgentSandboxRuntime> {
|
||||
const provider = this.sandboxSettingsService.getProvider();
|
||||
const sandboxId = buildKnowledgeSandboxId({
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
projectId,
|
||||
agentId,
|
||||
});
|
||||
const cacheKey = `${provider}:${sandboxName}`;
|
||||
return await this.acquireSandboxByIdentity(
|
||||
projectId,
|
||||
agentId,
|
||||
provider,
|
||||
`${KNOWLEDGE_SANDBOX_NAME_PREFIX}${sandboxId}`,
|
||||
sandboxId,
|
||||
buildKnowledgeLabels(projectId, agentId),
|
||||
`${provider}:knowledge:${sandboxId}`,
|
||||
{
|
||||
ephemeral: this.agentsConfig.sandboxEphemeral,
|
||||
autoStopInterval: KNOWLEDGE_AUTO_STOP_INTERVAL_MINUTES,
|
||||
autoArchiveInterval: KNOWLEDGE_AUTO_ARCHIVE_INTERVAL_MINUTES,
|
||||
...(this.agentsConfig.sandboxEphemeral
|
||||
? {}
|
||||
: { autoDeleteInterval: KNOWLEDGE_AUTO_DELETE_INTERVAL_MINUTES }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async acquireSandboxByIdentity(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
provider: SandboxProvider,
|
||||
daytonaName: string,
|
||||
n8nSandboxId: string,
|
||||
labels: Record<string, string>,
|
||||
cacheKey: string,
|
||||
daytonaLifecycle: DaytonaSandboxLifecycle,
|
||||
): Promise<AgentSandboxRuntime> {
|
||||
let pending = this.pendingSandboxAcquisitions.get(cacheKey);
|
||||
|
||||
if (!pending) {
|
||||
@@ -129,8 +236,11 @@ export class AgentSandboxRuntimeService {
|
||||
projectId,
|
||||
agentId,
|
||||
provider,
|
||||
sandboxName,
|
||||
daytonaName,
|
||||
n8nSandboxId,
|
||||
labels,
|
||||
cacheKey,
|
||||
daytonaLifecycle,
|
||||
).finally(() => {
|
||||
this.pendingSandboxAcquisitions.delete(cacheKey);
|
||||
});
|
||||
@@ -162,16 +272,34 @@ export class AgentSandboxRuntimeService {
|
||||
this.assertValidPathSegments(projectId, agentId);
|
||||
}
|
||||
|
||||
private async destroySandboxByIdentity(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
daytonaName: string,
|
||||
n8nSandboxId: string,
|
||||
labels: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const sandboxes = [
|
||||
['daytona', daytonaName],
|
||||
['n8n-sandbox', n8nSandboxId],
|
||||
] as const;
|
||||
|
||||
for (const [provider, sandboxId] of sandboxes) {
|
||||
await this.tryDestroySandbox(projectId, agentId, provider, sandboxId, labels);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryDestroySandbox(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
provider: SandboxProvider,
|
||||
sandboxId: string,
|
||||
labels: Record<string, string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config =
|
||||
provider === 'daytona'
|
||||
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxId)
|
||||
? await this.resolveDaytonaSandboxConfig(projectId, sandboxId, labels)
|
||||
: await this.resolveN8nSandboxConfig(sandboxId);
|
||||
const sandbox = await createSandbox(config, { logger: this.logger });
|
||||
if (!sandbox?.destroy) {
|
||||
@@ -193,8 +321,11 @@ export class AgentSandboxRuntimeService {
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
provider: SandboxProvider,
|
||||
sandboxName: string,
|
||||
daytonaName: string,
|
||||
n8nSandboxId: string,
|
||||
labels: Record<string, string>,
|
||||
cacheKey: string,
|
||||
daytonaLifecycle: DaytonaSandboxLifecycle,
|
||||
): Promise<AgentSandboxRuntime> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
@@ -203,8 +334,8 @@ export class AgentSandboxRuntimeService {
|
||||
|
||||
const config =
|
||||
provider === 'daytona'
|
||||
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxName)
|
||||
: await this.resolveN8nSandboxConfig(buildN8nSandboxId(sandboxName));
|
||||
? await this.resolveDaytonaSandboxConfig(projectId, daytonaName, labels, daytonaLifecycle)
|
||||
: await this.resolveN8nSandboxConfig(n8nSandboxId);
|
||||
return await this.startSandbox(config, projectId, agentId, cacheKey);
|
||||
}
|
||||
|
||||
@@ -239,8 +370,9 @@ export class AgentSandboxRuntimeService {
|
||||
|
||||
private async resolveDaytonaSandboxConfig(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sandboxId: string,
|
||||
labels: Record<string, string>,
|
||||
lifecycle: DaytonaSandboxLifecycle = {},
|
||||
): Promise<DaytonaSandboxConfig> {
|
||||
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
|
||||
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
|
||||
@@ -249,11 +381,10 @@ export class AgentSandboxRuntimeService {
|
||||
provider: 'daytona',
|
||||
id: sandboxId,
|
||||
name: sandboxId,
|
||||
labels: buildScopeLabels(projectId, agentId),
|
||||
labels,
|
||||
timeout: this.agentsConfig.sandboxTimeout,
|
||||
createTimeoutSeconds: Math.ceil(this.agentsConfig.sandboxTimeout / 1000),
|
||||
ephemeral: this.agentsConfig.sandboxEphemeral,
|
||||
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
|
||||
...lifecycle,
|
||||
};
|
||||
|
||||
if (!this.aiService.isProxyEnabled()) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class AgentSandboxController {
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.agentKnowledgeService.warmSandbox(agentId, projectId);
|
||||
await this.agentKnowledgeService.warmKnowledgeSandbox(agentId, projectId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to warm agent knowledge sandbox', {
|
||||
projectId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
|
||||
import { AgentWorkspaceService } from './agent-workspace.service';
|
||||
import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names';
|
||||
import { N8nMemory } from './integrations/n8n-memory';
|
||||
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
|
||||
@@ -17,7 +16,6 @@ export class AgentTestChatService {
|
||||
constructor(
|
||||
private readonly n8nMemory: N8nMemory,
|
||||
private readonly agentChatAttachmentService: AgentChatAttachmentService,
|
||||
private readonly agentWorkspaceService: AgentWorkspaceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -35,11 +33,10 @@ export class AgentTestChatService {
|
||||
/**
|
||||
* Clear the current user's test-chat messages for an agent.
|
||||
*/
|
||||
async clearTestChatMessages(projectId: string, agentId: string, userId: string) {
|
||||
async clearTestChatMessages(agentId: string, userId: string) {
|
||||
const threadId = chatThreadId(agentId, userId);
|
||||
await this.n8nMemory.getImplementation(agentId).deleteThread(threadId);
|
||||
await this.agentChatAttachmentService.deleteByThread(threadId, { agentId });
|
||||
await this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId);
|
||||
}
|
||||
|
||||
/** Delete all test-chat messages + the thread row — used when the agent itself is deleted. */
|
||||
|
||||
@@ -27,6 +27,10 @@ import { Telemetry } from '@/telemetry';
|
||||
import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service';
|
||||
import { AgentRunTracingService } from './agent-run-tracing.service';
|
||||
import { AgentRuntimeReconstructionService } from './agent-runtime-reconstruction.service';
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from './agent-sandbox-principal';
|
||||
import {
|
||||
buildAgentConfigurationTelemetry,
|
||||
buildAgentConfigurationTelemetryFromConfig,
|
||||
@@ -44,6 +48,10 @@ import { streamAgentChunks } from './utils/agent-stream';
|
||||
import { validateNodeToolConfigs, validateNodeToolExpressions } from './utils/node-tool-validation';
|
||||
import { describeStructuredOutputError } from './utils/structured-output-error';
|
||||
|
||||
interface WorkflowSandboxScope {
|
||||
principalHash: AgentSandboxPrincipalHash;
|
||||
}
|
||||
|
||||
function getFinalWorkflowResponse(messageRecord: MessageRecord): string {
|
||||
let lastToolCallIndex = -1;
|
||||
for (let index = messageRecord.timeline.length - 1; index >= 0; index--) {
|
||||
@@ -137,6 +145,7 @@ export class AgentWorkflowExecutionService {
|
||||
runType: AgentRunTelemetryType,
|
||||
outputSchema?: JSONSchema7,
|
||||
extraTools?: BuiltTool[],
|
||||
sandboxPrincipalHash?: AgentSandboxPrincipalHash,
|
||||
): Promise<{ ok: boolean; agent?: BuiltAgent; error?: string }> {
|
||||
if (!agentEntity.schema) {
|
||||
return { ok: false, error: 'Agent has no JSON config. Create a config first.' };
|
||||
@@ -155,6 +164,11 @@ export class AgentWorkflowExecutionService {
|
||||
agentEntity,
|
||||
credentialProvider,
|
||||
runType,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'manual',
|
||||
sandboxPrincipalHash,
|
||||
);
|
||||
return this.applyPerCallAgentExtras(reconstructed, outputSchema, extraTools);
|
||||
} catch (e) {
|
||||
@@ -235,6 +249,7 @@ export class AgentWorkflowExecutionService {
|
||||
nodeName?: string;
|
||||
};
|
||||
recordingParams?: StartExecutionParams;
|
||||
sandboxScope?: { projectId: string; principalHash: AgentSandboxPrincipalHash };
|
||||
}): Promise<WorkflowAgentRunOutcome> {
|
||||
const {
|
||||
agentInstance,
|
||||
@@ -246,6 +261,7 @@ export class AgentWorkflowExecutionService {
|
||||
outputSchema,
|
||||
tracing,
|
||||
recordingParams,
|
||||
sandboxScope,
|
||||
} = params;
|
||||
|
||||
let agentExecutionId: string | undefined;
|
||||
@@ -301,7 +317,11 @@ export class AgentWorkflowExecutionService {
|
||||
// caller-supplied session id actually continue the conversation.
|
||||
// The previous key — the execution id — changed every run and hid
|
||||
// all prior messages of the thread from the model.
|
||||
persistence: { resourceId: threadId, threadId },
|
||||
persistence: {
|
||||
resourceId: threadId,
|
||||
threadId,
|
||||
...(sandboxScope ? { hostMetadata: encodeAgentSandboxHostMetadata(sandboxScope) } : {}),
|
||||
},
|
||||
executionCounter: createAgentExecutionCounter(this.telemetry, {
|
||||
agentId: telemetryAgentId,
|
||||
userId: telemetryUserId,
|
||||
@@ -454,6 +474,33 @@ export class AgentWorkflowExecutionService {
|
||||
useDraftVersion?: boolean,
|
||||
outputSchema?: JSONSchema7,
|
||||
workflowContext?: ExecuteAgentWorkflowContext,
|
||||
sandboxScope?: WorkflowSandboxScope,
|
||||
): Promise<ExecuteAgentData> {
|
||||
return await this.executeForWorkflowInternal(
|
||||
agentId,
|
||||
message,
|
||||
executionId,
|
||||
threadId,
|
||||
projectId,
|
||||
telemetryUserId,
|
||||
useDraftVersion,
|
||||
outputSchema,
|
||||
workflowContext,
|
||||
sandboxScope,
|
||||
);
|
||||
}
|
||||
|
||||
private async executeForWorkflowInternal(
|
||||
agentId: string,
|
||||
message: string,
|
||||
executionId: string,
|
||||
threadId: string,
|
||||
projectId: string,
|
||||
telemetryUserId?: string,
|
||||
useDraftVersion?: boolean,
|
||||
outputSchema?: JSONSchema7,
|
||||
workflowContext?: ExecuteAgentWorkflowContext,
|
||||
sandboxScope?: WorkflowSandboxScope,
|
||||
): Promise<ExecuteAgentData> {
|
||||
const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agentEntity) {
|
||||
@@ -477,6 +524,7 @@ export class AgentWorkflowExecutionService {
|
||||
runType,
|
||||
outputSchema,
|
||||
extraTools?.length ? extraTools : undefined,
|
||||
sandboxScope?.principalHash,
|
||||
);
|
||||
if (!compiled.ok || !compiled.agent) {
|
||||
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
|
||||
@@ -510,6 +558,14 @@ export class AgentWorkflowExecutionService {
|
||||
configuration: telemetryConfiguration,
|
||||
},
|
||||
},
|
||||
...(sandboxScope
|
||||
? {
|
||||
sandboxScope: {
|
||||
projectId,
|
||||
principalHash: sandboxScope.principalHash,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (run.agentExecutionId) {
|
||||
|
||||
@@ -1,57 +1,96 @@
|
||||
import {
|
||||
CORE_WORKSPACE_TOOL_NAMES,
|
||||
createScopedWorkspace,
|
||||
getToolResultThreadDirectory,
|
||||
reconcileToolResultRuns,
|
||||
Workspace,
|
||||
} from '@n8n/agents';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { AgentSandboxPrincipalHash } from './agent-sandbox-principal';
|
||||
import {
|
||||
AgentSandboxRuntimeService,
|
||||
sanitizeSandboxErrorDetail,
|
||||
} from './agent-sandbox-runtime.service';
|
||||
import {
|
||||
CHECKPOINT_RECONCILIATION_OVERFLOW,
|
||||
N8NCheckpointStorage,
|
||||
} from './integrations/n8n-checkpoint-storage';
|
||||
|
||||
@Service()
|
||||
export class AgentWorkspaceService {
|
||||
private readonly pendingReconciliations = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
|
||||
private readonly checkpointStorage: N8NCheckpointStorage,
|
||||
private readonly agentsConfig: AgentsConfig,
|
||||
) {}
|
||||
|
||||
async getAgentWorkspace(projectId: string, agentId: string): Promise<Workspace> {
|
||||
async getAgentWorkspace(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
): Promise<Workspace> {
|
||||
this.agentSandboxRuntimeService.assertSandboxConfiguration(projectId, agentId);
|
||||
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
|
||||
const runtime = await this.agentSandboxRuntimeService.acquireWorkspaceSandbox(
|
||||
projectId,
|
||||
agentId,
|
||||
principalHash,
|
||||
);
|
||||
await runtime.filesystem.mkdir(runtime.workspaceRoot, { recursive: true });
|
||||
const workspace = createScopedWorkspace(
|
||||
new Workspace({ filesystem: runtime.filesystem, sandbox: runtime.sandbox }),
|
||||
runtime.workspaceRoot,
|
||||
);
|
||||
this.reconcileToolResultsInBackground(
|
||||
runtime.cacheKey,
|
||||
projectId,
|
||||
agentId,
|
||||
principalHash,
|
||||
workspace,
|
||||
);
|
||||
const getTools = workspace.getTools.bind(workspace);
|
||||
workspace.getTools = () =>
|
||||
getTools().filter((tool) => CORE_WORKSPACE_TOOL_NAMES.has(tool.name));
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async cleanupThreadWorkspace(
|
||||
private reconcileToolResultsInBackground(
|
||||
cacheKey: string,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
threadId: string,
|
||||
): Promise<void> {
|
||||
if (!this.agentSandboxRuntimeService.isEnabled()) return;
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
workspace: Workspace,
|
||||
): void {
|
||||
if (this.pendingReconciliations.has(cacheKey)) return;
|
||||
this.pendingReconciliations.add(cacheKey);
|
||||
|
||||
try {
|
||||
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
|
||||
await runtime.filesystem.rmdir(
|
||||
`${runtime.workspaceRoot}/${getToolResultThreadDirectory(threadId)}`,
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to clean up agent thread workspace', {
|
||||
projectId,
|
||||
void (async () => {
|
||||
const activeRunIds = await this.checkpointStorage.getActiveRunIdsForSandbox(
|
||||
agentId,
|
||||
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
|
||||
principalHash,
|
||||
);
|
||||
if (activeRunIds !== CHECKPOINT_RECONCILIATION_OVERFLOW && workspace.filesystem) {
|
||||
await reconcileToolResultRuns(
|
||||
workspace.filesystem,
|
||||
activeRunIds,
|
||||
this.agentsConfig.checkpointTtlSeconds * Time.seconds.toMilliseconds,
|
||||
);
|
||||
}
|
||||
})()
|
||||
.catch((error) => {
|
||||
this.logger.warn('Failed to reconcile agent workspace tool results', {
|
||||
projectId,
|
||||
agentId,
|
||||
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.pendingReconciliations.delete(cacheKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export class AgentsService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.agentKnowledgeService.destroySandbox(projectId, agentId);
|
||||
await this.agentKnowledgeService.destroyKnowledgeSandbox(projectId, agentId);
|
||||
|
||||
try {
|
||||
await this.agentChatAttachmentService.deleteByAgent(agentId);
|
||||
|
||||
@@ -19,6 +19,8 @@ import { SlackIntegration } from '../platforms/slack/slack-integration';
|
||||
import type { AgentIntegrationConfig } from '@n8n/api-types';
|
||||
import type { RichCardComponentType } from '@n8n/api-types';
|
||||
|
||||
import { hashAgentSandboxPrincipal } from '../../agent-sandbox-principal';
|
||||
|
||||
type ChatBotLike = ConstructorParameters<typeof AgentChatBridge>[0];
|
||||
|
||||
interface FakeThread {
|
||||
@@ -668,6 +670,12 @@ describe('AgentChatBridge — consumeStream', () => {
|
||||
expect(agentExecutor.executeForChatPublished).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
sandboxPrincipalHash: hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: 'cred-1',
|
||||
platform: 'test-streaming',
|
||||
platformUserId: 'u1',
|
||||
}),
|
||||
memory: expect.objectContaining({
|
||||
threadId: expect.objectContaining({ id: 'agent-1:thread-1' }),
|
||||
resourceId: 'integration:test-streaming:u1',
|
||||
|
||||
+69
-1
@@ -5,9 +5,16 @@ import type { AgentsConfig } from '@n8n/config';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
} from '../../agent-sandbox-principal';
|
||||
import type { AgentCheckpoint } from '../../entities/agent-checkpoint.entity';
|
||||
import type { AgentCheckpointRepository } from '../../repositories/agent-checkpoint.repository';
|
||||
import { N8NCheckpointStorage } from '../n8n-checkpoint-storage';
|
||||
import {
|
||||
CHECKPOINT_RECONCILIATION_OVERFLOW,
|
||||
N8NCheckpointStorage,
|
||||
} from '../n8n-checkpoint-storage';
|
||||
|
||||
const suspendedState: SerializableAgentState = {
|
||||
status: 'suspended',
|
||||
@@ -22,6 +29,7 @@ const suspendedState: SerializableAgentState = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
|
||||
|
||||
function makeService() {
|
||||
const repository = mock<AgentCheckpointRepository>();
|
||||
@@ -177,4 +185,64 @@ describe('N8NCheckpointStorage', () => {
|
||||
|
||||
expect(repository.expireByRunIdAndAgentId).toHaveBeenCalledWith('run-1', 'agent-1');
|
||||
});
|
||||
|
||||
it('returns every persisted active run for the principal workspace', async () => {
|
||||
const { service, repository } = makeService();
|
||||
const otherPrincipalHash = hashAgentSandboxPrincipal({
|
||||
type: 'n8n-user',
|
||||
userId: 'user-2',
|
||||
});
|
||||
const stateFor = (
|
||||
status: SerializableAgentState['status'],
|
||||
hash = principalHash,
|
||||
): SerializableAgentState => ({
|
||||
...suspendedState,
|
||||
status,
|
||||
persistence: {
|
||||
...suspendedState.persistence!,
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({
|
||||
projectId: 'project-1',
|
||||
principalHash: hash,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const checkpoint = (
|
||||
runId: string,
|
||||
state: string | null,
|
||||
overrides: Partial<AgentCheckpoint> = {},
|
||||
) =>
|
||||
({
|
||||
runId,
|
||||
agentId: 'agent-1',
|
||||
expired: false,
|
||||
state,
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as AgentCheckpoint;
|
||||
repository.findForSandboxReconciliation.mockResolvedValue([
|
||||
checkpoint('run-running', JSON.stringify(stateFor('running'))),
|
||||
checkpoint('run-suspended', JSON.stringify(stateFor('suspended'))),
|
||||
checkpoint('run-cancelled', JSON.stringify(stateFor('cancelled'))),
|
||||
checkpoint('run-other-principal', JSON.stringify(stateFor('running', otherPrincipalHash))),
|
||||
checkpoint('run-old', JSON.stringify(stateFor('suspended')), { updatedAt: new Date(0) }),
|
||||
checkpoint('run-expired', JSON.stringify(stateFor('running')), { expired: true }),
|
||||
checkpoint('run-malformed', '{'),
|
||||
checkpoint('run-empty', null),
|
||||
]);
|
||||
|
||||
await expect(service.getActiveRunIdsForSandbox('agent-1', principalHash)).resolves.toEqual(
|
||||
new Set(['run-running', 'run-suspended', 'run-old']),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports overflow instead of returning a partial set of protected runs', async () => {
|
||||
const { service, repository } = makeService();
|
||||
repository.findForSandboxReconciliation.mockResolvedValue(
|
||||
Array.from({ length: 101 }, (_, index) => ({ runId: `run-${index}` }) as AgentCheckpoint),
|
||||
);
|
||||
|
||||
await expect(service.getActiveRunIdsForSandbox('agent-1', principalHash)).resolves.toBe(
|
||||
CHECKPOINT_RECONCILIATION_OVERFLOW,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MAX_AGENT_CHAT_ATTACHMENT_SIZE_BYTES,
|
||||
MAX_AGENT_CHAT_ATTACHMENT_SIZE_MB,
|
||||
MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE,
|
||||
type AgentIntegrationConfig,
|
||||
} from '@n8n/api-types';
|
||||
import { LockService } from '@n8n/backend-common';
|
||||
import { type HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
|
||||
@@ -11,12 +12,17 @@ import { Container } from '@n8n/di';
|
||||
import type { Attachment, Author, Chat, Message, Thread } from 'chat';
|
||||
import type { Logger } from 'n8n-workflow';
|
||||
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
|
||||
import {
|
||||
AgentChatAttachmentService,
|
||||
type StoredAttachmentRef,
|
||||
} from '../agent-chat-attachment.service';
|
||||
import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import {
|
||||
hashAgentSandboxPrincipal,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from '../agent-sandbox-principal';
|
||||
import { integrationMemoryResourceId } from '../utils/agent-memory-scope';
|
||||
import { resolveInboundMimeType } from '../utils/inbound-attachments';
|
||||
import type {
|
||||
@@ -37,7 +43,6 @@ import type { ComponentMapper, ShortenCallback } from './component-mapper';
|
||||
import { IntegrationMessageContextService } from './integration-message-context.service';
|
||||
import type { ReplyExpectation } from './integration-tools';
|
||||
import { downloadDiscordAttachment } from './platforms/discord-operations';
|
||||
import type { AgentIntegrationConfig } from '@n8n/api-types';
|
||||
|
||||
import { type InternalThread, toInternalThreadId } from './types';
|
||||
|
||||
@@ -49,6 +54,7 @@ interface AgentExecutor {
|
||||
attachments?: StoredAttachmentRef[];
|
||||
memory: { threadId: InternalThread; resourceId: string };
|
||||
integrationType?: string;
|
||||
sandboxPrincipalHash: AgentSandboxPrincipalHash;
|
||||
}): AsyncGenerator<StreamChunk>;
|
||||
|
||||
resumeForChat(config: {
|
||||
@@ -186,6 +192,7 @@ export class AgentChatBridge {
|
||||
message,
|
||||
attachments,
|
||||
integrationType,
|
||||
sandboxPrincipalHash,
|
||||
}) {
|
||||
yield* agentService.executeForChatPublished({
|
||||
agentId: aid,
|
||||
@@ -200,6 +207,7 @@ export class AgentChatBridge {
|
||||
}),
|
||||
},
|
||||
integrationType,
|
||||
sandboxPrincipalHash,
|
||||
});
|
||||
},
|
||||
async *resumeForChat(config) {
|
||||
@@ -368,6 +376,12 @@ export class AgentChatBridge {
|
||||
resourceId,
|
||||
},
|
||||
integrationType: this.integration.type,
|
||||
sandboxPrincipalHash: hashAgentSandboxPrincipal({
|
||||
type: 'integration-user',
|
||||
connectionId: this.integration.credentialId,
|
||||
platform: this.integration.type,
|
||||
platformUserId: message.author.userId,
|
||||
}),
|
||||
});
|
||||
|
||||
consumeStarted = true;
|
||||
|
||||
@@ -12,6 +12,10 @@ import { InstanceSettings } from 'n8n-core';
|
||||
import { jsonParse, UnexpectedError, UserError } from 'n8n-workflow';
|
||||
import { strict } from 'node:assert';
|
||||
|
||||
import {
|
||||
decodeAgentSandboxHostMetadata,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from '../agent-sandbox-principal';
|
||||
import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository';
|
||||
|
||||
/** File parts are checkpointed reference-only (a `Uint8Array` would not survive JSON round-tripping). */
|
||||
@@ -37,6 +41,9 @@ type CheckpointStatus =
|
||||
checkpoint: SerializableAgentState;
|
||||
};
|
||||
|
||||
const MAX_SANDBOX_RECONCILIATION_CHECKPOINTS = 100;
|
||||
export const CHECKPOINT_RECONCILIATION_OVERFLOW = Symbol('checkpoint-reconciliation-overflow');
|
||||
|
||||
@Service()
|
||||
export class N8NCheckpointStorage {
|
||||
private pruneTimeout: NodeJS.Timeout | undefined;
|
||||
@@ -66,6 +73,33 @@ export class N8NCheckpointStorage {
|
||||
};
|
||||
}
|
||||
|
||||
async getActiveRunIdsForSandbox(
|
||||
agentId: string,
|
||||
principalHash: AgentSandboxPrincipalHash,
|
||||
): Promise<Set<string> | typeof CHECKPOINT_RECONCILIATION_OVERFLOW> {
|
||||
const checkpoints = await this.agentCheckpointRepository.findForSandboxReconciliation(agentId);
|
||||
if (checkpoints.length > MAX_SANDBOX_RECONCILIATION_CHECKPOINTS) {
|
||||
return CHECKPOINT_RECONCILIATION_OVERFLOW;
|
||||
}
|
||||
|
||||
const runIds = new Set<string>();
|
||||
|
||||
for (const checkpoint of checkpoints) {
|
||||
if (checkpoint.expired || checkpoint.state === null) continue;
|
||||
|
||||
try {
|
||||
const state = jsonParse<SerializableAgentState>(checkpoint.state);
|
||||
if (state.status !== 'running' && state.status !== 'suspended') continue;
|
||||
const scope = decodeAgentSandboxHostMetadata(state.persistence?.hostMetadata);
|
||||
if (scope?.principalHash === principalHash) runIds.add(checkpoint.runId);
|
||||
} catch {
|
||||
// A malformed checkpoint must not block workspace acquisition.
|
||||
}
|
||||
}
|
||||
|
||||
return runIds;
|
||||
}
|
||||
|
||||
init() {
|
||||
strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set');
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export class AgentCheckpointRepository extends Repository<AgentCheckpoint> {
|
||||
});
|
||||
}
|
||||
|
||||
async findForSandboxReconciliation(agentId: string): Promise<AgentCheckpoint[]> {
|
||||
return await this.find({
|
||||
where: { agentId, expired: false },
|
||||
order: { updatedAt: 'DESC' },
|
||||
take: 101,
|
||||
});
|
||||
}
|
||||
|
||||
async claimForResume(
|
||||
runId: string,
|
||||
agentId: string,
|
||||
|
||||
+12
-2
@@ -13,6 +13,10 @@ import { OperationalError, UserError } from 'n8n-workflow';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
} from '../../agent-sandbox-principal';
|
||||
import {
|
||||
createN8nDelegateSubAgentTool,
|
||||
formatSubAgentToolOutput,
|
||||
@@ -168,7 +172,8 @@ describe('createN8nDelegateSubAgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the parent persistence thread id and resource id to the runner', async () => {
|
||||
it('forwards the parent persistence scope to the runner', async () => {
|
||||
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
|
||||
const tool = createN8nDelegateSubAgentTool({
|
||||
runner,
|
||||
sourcesById: { 'agent-2': source },
|
||||
@@ -181,7 +186,11 @@ describe('createN8nDelegateSubAgentTool', () => {
|
||||
{ subAgentId: 'agent-2', taskName: 'Research API', goal: 'Find behavior.' },
|
||||
{
|
||||
runId: 'parent-run-1',
|
||||
persistence: { threadId: 'parent-thread-1', resourceId: 'resource-1' },
|
||||
persistence: {
|
||||
threadId: 'parent-thread-1',
|
||||
resourceId: 'resource-1',
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -189,6 +198,7 @@ describe('createN8nDelegateSubAgentTool', () => {
|
||||
expect.objectContaining({
|
||||
parentThreadId: 'parent-thread-1',
|
||||
parentResourceId: 'resource-1',
|
||||
parentSandboxPrincipalHash: principalHash,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
+61
@@ -18,6 +18,10 @@ import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { AgentExecutionService } from '../../agent-execution.service';
|
||||
import { AgentRuntimeReconstructionService } from '../../agent-runtime-reconstruction.service';
|
||||
import {
|
||||
encodeAgentSandboxHostMetadata,
|
||||
hashAgentSandboxPrincipal,
|
||||
} from '../../agent-sandbox-principal';
|
||||
import type { N8NCheckpointStorage } from '../../integrations/n8n-checkpoint-storage';
|
||||
import { SubAgentForegroundRunner } from '../sub-agent-foreground-runner';
|
||||
import type {
|
||||
@@ -302,6 +306,63 @@ describe('SubAgentForegroundRunner', () => {
|
||||
expect(result.threadId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('inherits the parent workspace principal on the initial run and resume', async () => {
|
||||
const principalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', userId: 'user-1' });
|
||||
const result = await runner.runForeground(
|
||||
{ ...spawnRequest, parentSandboxPrincipalHash: principalHash },
|
||||
{
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType: 'production',
|
||||
},
|
||||
);
|
||||
|
||||
expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sandboxPrincipalHash: principalHash }),
|
||||
);
|
||||
expect(childAgent.stream).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
persistence: expect.objectContaining({
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
checkpointStorage.load.mockResolvedValue({
|
||||
status: 'suspended',
|
||||
persistence: {
|
||||
threadId: result.threadId,
|
||||
resourceId: result.threadId,
|
||||
delegated: true,
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({ projectId, principalHash }),
|
||||
},
|
||||
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
|
||||
pendingToolCalls: {},
|
||||
});
|
||||
childAgent.resume.mockResolvedValue(makeStreamResult(defaultStreamChunks));
|
||||
await runner.resumeForeground(
|
||||
{
|
||||
...delegatedRequest,
|
||||
childRunId: 'child-run-1',
|
||||
childToolCallId: 'tool-call-1',
|
||||
childThreadId: result.threadId,
|
||||
resumeData: { approved: true },
|
||||
resumeContext: { agentId: 'agent-1', versionId: 'version-7' },
|
||||
parentThreadId,
|
||||
},
|
||||
{
|
||||
projectId,
|
||||
credentialProvider,
|
||||
runType: 'production',
|
||||
},
|
||||
);
|
||||
|
||||
expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sandboxPrincipalHash: principalHash }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the saved n8n agent id as memory owner and records parent linkage', async () => {
|
||||
sourceResolver.resolveForRuntime.mockResolvedValue({
|
||||
...runtimeSource,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
|
||||
|
||||
import { decodeAgentSandboxHostMetadata } from '../agent-sandbox-principal';
|
||||
import type {
|
||||
SubAgentForegroundRunContext,
|
||||
SubAgentForegroundResult,
|
||||
@@ -63,6 +64,7 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent
|
||||
error: `No configured subagent matched "${request.subAgentId}". Use "inline" for an inline sub-agent, or pass one of the configured subagent IDs.`,
|
||||
};
|
||||
}
|
||||
const parentSandboxScope = decodeAgentSandboxHostMetadata(request.parentHostMetadata);
|
||||
|
||||
const result = await runner.runForeground(
|
||||
{
|
||||
@@ -80,6 +82,9 @@ export function createN8nDelegateSubAgentTool(options: CreateN8nDelegateSubAgent
|
||||
...(request.parentResourceId !== undefined
|
||||
? { parentResourceId: request.parentResourceId }
|
||||
: {}),
|
||||
...(parentSandboxScope?.projectId === runContext.projectId
|
||||
? { parentSandboxPrincipalHash: parentSandboxScope.principalHash }
|
||||
: {}),
|
||||
taskPath: request.taskPath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,12 @@ import type { AgentRunTelemetryType } from '@/interfaces';
|
||||
|
||||
import { AgentExecutionService } from '../agent-execution.service';
|
||||
import type { AgentRuntimeInstrumentation } from '../agent-runtime-instrumentation';
|
||||
import {
|
||||
decodeAgentSandboxHostMetadata,
|
||||
encodeAgentSandboxHostMetadata,
|
||||
isAgentSandboxPrincipalHash,
|
||||
type AgentSandboxPrincipalHash,
|
||||
} from '../agent-sandbox-principal';
|
||||
import { buildAgentConfigurationTelemetryFromConfig } from '../agent-telemetry';
|
||||
import type { MessageRecord } from '../execution-recorder';
|
||||
import { ExecutionRecorder } from '../execution-recorder';
|
||||
@@ -167,6 +173,11 @@ export class SubAgentForegroundRunner {
|
||||
const threadId = operation.type === 'run' ? uuid() : operation.threadId;
|
||||
const resourceId =
|
||||
operation.type === 'run' ? (operation.request.parentResourceId ?? threadId) : threadId;
|
||||
const sandboxPrincipalHash = await this.resolveSandboxPrincipalHash(
|
||||
operation,
|
||||
runtimeSource.source.sourceId,
|
||||
context.projectId,
|
||||
);
|
||||
const reconstructionService = await getReconstructionService();
|
||||
const childConfig =
|
||||
context.instrumentation?.transformDelegatedAgentConfig?.(runtimeSource.source.config, {
|
||||
@@ -186,6 +197,7 @@ export class SubAgentForegroundRunner {
|
||||
parentAgentIdForDelegation: context.parentAgentId,
|
||||
user: context.user,
|
||||
instrumentation: context.instrumentation,
|
||||
...(sandboxPrincipalHash !== undefined ? { sandboxPrincipalHash } : {}),
|
||||
});
|
||||
|
||||
const telemetry = deriveSubAgentTelemetry(context.telemetry);
|
||||
@@ -219,6 +231,14 @@ export class SubAgentForegroundRunner {
|
||||
resourceId,
|
||||
threadId,
|
||||
delegated: true,
|
||||
...(sandboxPrincipalHash !== undefined
|
||||
? {
|
||||
hostMetadata: encodeAgentSandboxHostMetadata({
|
||||
projectId: context.projectId,
|
||||
principalHash: sandboxPrincipalHash,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: await agent.resume('stream', operation.request.resumeData, {
|
||||
@@ -322,6 +342,32 @@ export class SubAgentForegroundRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSandboxPrincipalHash(
|
||||
operation: ForegroundOperation,
|
||||
childAgentId: string,
|
||||
projectId: string,
|
||||
): Promise<AgentSandboxPrincipalHash | undefined> {
|
||||
if (operation.type === 'run') {
|
||||
const value = operation.request.parentSandboxPrincipalHash;
|
||||
if (value === undefined) return undefined;
|
||||
if (!isAgentSandboxPrincipalHash(value)) {
|
||||
throw new UserError('Configured sub-agent workspace scope is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const checkpoint = await this.checkpointStorage.load(
|
||||
operation.request.childRunId,
|
||||
childAgentId,
|
||||
);
|
||||
const scope = decodeAgentSandboxHostMetadata(checkpoint?.persistence?.hostMetadata);
|
||||
if (!scope) return undefined;
|
||||
if (scope.projectId !== projectId) {
|
||||
throw new UserError('Configured sub-agent workspace scope is invalid');
|
||||
}
|
||||
return scope.principalHash;
|
||||
}
|
||||
|
||||
private async recordSubAgentExecution(params: {
|
||||
runtimeSource: ResolvedSubAgentSource;
|
||||
projectId: string;
|
||||
|
||||
+3
-3
@@ -33,7 +33,7 @@ function getToolHandler(tool: BuiltTool): NonNullable<BuiltTool['handler']> {
|
||||
}
|
||||
|
||||
describe('createKnowledgeRetrievalTools', () => {
|
||||
it('finds files with the project-scoped sandbox using a catch-all pattern', async () => {
|
||||
it('finds files through the Agent-scoped knowledge mirror', async () => {
|
||||
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
|
||||
knowledgeMirrorService.globKnowledgeFiles.mockResolvedValue({
|
||||
files: [],
|
||||
@@ -55,7 +55,7 @@ describe('createKnowledgeRetrievalTools', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('searches knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => {
|
||||
it('searches the Agent-scoped knowledge mirror regardless of the memory resource', async () => {
|
||||
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
|
||||
knowledgeMirrorService.searchKnowledge.mockResolvedValue({
|
||||
outputMode: 'content',
|
||||
@@ -74,7 +74,7 @@ describe('createKnowledgeRetrievalTools', () => {
|
||||
expect(knowledgeMirrorService.searchKnowledge).toHaveBeenCalledWith(projectId, agentId, input);
|
||||
});
|
||||
|
||||
it('reads knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => {
|
||||
it('reads the Agent-scoped knowledge mirror regardless of the memory resource', async () => {
|
||||
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
|
||||
knowledgeMirrorService.readKnowledge.mockResolvedValue({
|
||||
file: 'notes.txt',
|
||||
|
||||
+12
-10
@@ -48,7 +48,7 @@ vi.mock('../mcp-mock-fetch', () => ({ createMcpMockFetch: vi.fn(() => vi.fn()) }
|
||||
vi.mock('../mock-handler', () => ({ createLlmMockHandler: vi.fn() }));
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const user = mock<User>();
|
||||
const user = mock<User>({ id: 'user/123:raw' });
|
||||
|
||||
const findByIdAndProjectId = vi.fn();
|
||||
const reconstructFromAgentEntity = vi.fn();
|
||||
@@ -263,20 +263,22 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The runtime was built with the eval instrumentation, uncached.
|
||||
const [entityArg, , runType, integrationType, userArg, instrumentation] =
|
||||
reconstructFromAgentEntity.mock.calls[0] as [
|
||||
AgentEntity,
|
||||
unknown,
|
||||
string,
|
||||
string | undefined,
|
||||
User,
|
||||
{ modelFetch?: unknown },
|
||||
];
|
||||
const call = reconstructFromAgentEntity.mock.calls[0] as [
|
||||
AgentEntity,
|
||||
unknown,
|
||||
string,
|
||||
string | undefined,
|
||||
User,
|
||||
{ modelFetch?: unknown },
|
||||
...unknown[],
|
||||
];
|
||||
const [entityArg, , runType, integrationType, userArg, instrumentation] = call;
|
||||
expect(entityArg.id).toBe('agent-1');
|
||||
expect(runType).toBe('test');
|
||||
expect(integrationType).toBeUndefined();
|
||||
expect(userArg).toBe(user);
|
||||
expect(instrumentation.modelFetch).toBeDefined();
|
||||
expect(call[7]).toBe('Gt4H3q6RzhJe9cTxQm6be0AdIZQlifuy3w9OPSykmYo');
|
||||
});
|
||||
|
||||
it('attributes MCP calls when the server name requires normalization', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
|
||||
// Static agents-module imports are safe here: the ModuleRegistry gate decides
|
||||
// availability at runtime.
|
||||
import { AgentRuntimeReconstructionService } from '@/modules/agents/agent-runtime-reconstruction.service';
|
||||
import { hashAgentSandboxPrincipal } from '@/modules/agents/agent-sandbox-principal';
|
||||
import type { Agent as AgentEntity } from '@/modules/agents/entities/agent.entity';
|
||||
import { sanitizeToolName } from '@/modules/agents/json-config/agent-config-composition';
|
||||
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
@@ -326,6 +327,8 @@ export class EvalAgentExecutionService {
|
||||
);
|
||||
},
|
||||
},
|
||||
'manual',
|
||||
hashAgentSandboxPrincipal({ type: 'n8n-user', userId: user.id }),
|
||||
));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -649,8 +649,14 @@ describe('ProjectService', () => {
|
||||
expect(agentKnowledgeService.deleteAllFilesForAgent.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
projectRepository.remove.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-1');
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-2');
|
||||
expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(
|
||||
project.id,
|
||||
'agent-1',
|
||||
);
|
||||
expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(
|
||||
project.id,
|
||||
'agent-2',
|
||||
);
|
||||
expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-2');
|
||||
});
|
||||
@@ -766,7 +772,10 @@ describe('ProjectService', () => {
|
||||
|
||||
await expect(projectService.deleteProject(user, project.id)).resolves.toBeUndefined();
|
||||
|
||||
expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(project.id, 'agent-1');
|
||||
expect(agentKnowledgeService.destroyKnowledgeSandbox).toHaveBeenCalledWith(
|
||||
project.id,
|
||||
'agent-1',
|
||||
);
|
||||
expect(agentExecutionService.deleteExecutionLogsForAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(projectRepository.remove).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,20 @@ describe('SandboxSettingsService', () => {
|
||||
service = new SandboxSettingsService(globalConfig as never, instanceCredentialBroker, logger);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ agentsEnabled: true, instanceAiEnabled: false, expected: true },
|
||||
{ agentsEnabled: false, instanceAiEnabled: true, expected: true },
|
||||
{ agentsEnabled: false, instanceAiEnabled: false, expected: false },
|
||||
])(
|
||||
'enables Agent sandboxes=$expected for Agents=$agentsEnabled and Instance AI=$instanceAiEnabled',
|
||||
({ agentsEnabled, instanceAiEnabled, expected }) => {
|
||||
globalConfig.agents.sandboxEnabled = agentsEnabled;
|
||||
globalConfig.instanceAi.sandboxEnabled = instanceAiEnabled;
|
||||
|
||||
expect(service.isAgentSandboxEnabled()).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses environment config when no credentials are assigned', async () => {
|
||||
Object.assign(globalConfig.instanceAi, {
|
||||
daytonaApiUrl: 'https://env.daytona.example.com',
|
||||
|
||||
@@ -284,7 +284,7 @@ export class ProjectService {
|
||||
});
|
||||
}
|
||||
|
||||
await agentKnowledgeService.destroySandbox(project.id, agent.id);
|
||||
await agentKnowledgeService.destroyKnowledgeSandbox(project.id, agent.id);
|
||||
await agentExecutionService.deleteExecutionLogsForAgent(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,28 @@ export class TtlMap<K, V> {
|
||||
* @param ttlMs Time-to-live for each entry in milliseconds.
|
||||
* @param sweepIntervalMs How often to run the background sweep (defaults to `ttlMs`).
|
||||
* Set to `0` to disable the background sweep entirely.
|
||||
* @param onExpire Called after an entry is removed because its TTL elapsed.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
sweepIntervalMs: number = ttlMs,
|
||||
private readonly onExpire?: (value: V) => void,
|
||||
) {
|
||||
if (sweepIntervalMs > 0) {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs).unref();
|
||||
}
|
||||
}
|
||||
|
||||
private deleteIfExpired(
|
||||
key: K,
|
||||
entry: { value: V; expiresAt: number },
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (now <= entry.expiresAt) return false;
|
||||
if (this.store.delete(key)) this.onExpire?.(entry.value);
|
||||
return true;
|
||||
}
|
||||
|
||||
set(key: K, value: V): this {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||
return this;
|
||||
@@ -37,22 +49,13 @@ export class TtlMap<K, V> {
|
||||
get(key: K): V | undefined {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
if (this.deleteIfExpired(key, entry)) return undefined;
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
if (!this.store.has(key)) return false;
|
||||
const expiresAt = this.store.get(key)?.expiresAt;
|
||||
if (!expiresAt) return false;
|
||||
if (Date.now() > expiresAt) {
|
||||
this.store.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const entry = this.store.get(key);
|
||||
return entry !== undefined && !this.deleteIfExpired(key, entry);
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
@@ -94,9 +97,7 @@ export class TtlMap<K, V> {
|
||||
sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.store) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
this.deleteIfExpired(key, entry, now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -416,7 +416,24 @@ export async function executeAgent(
|
||||
);
|
||||
}
|
||||
|
||||
const { hashAgentSandboxPrincipal } = await import('@/modules/agents/agent-sandbox-principal.js');
|
||||
const useDraftVersion = isManualOrChatExecution(executionMode);
|
||||
const sandboxScope =
|
||||
workflowContext?.hasCallerSessionId === true
|
||||
? {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-session',
|
||||
workflowId: additionalData.workflowId,
|
||||
sessionId: threadId,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
principalHash: hashAgentSandboxPrincipal({
|
||||
type: 'workflow-execution',
|
||||
workflowId: additionalData.workflowId,
|
||||
executionId,
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await agentWorkflowExecutionService.executeForWorkflow(
|
||||
source.agentId,
|
||||
@@ -428,6 +445,7 @@ export async function executeAgent(
|
||||
useDraftVersion,
|
||||
outputSchema,
|
||||
workflowContext,
|
||||
sandboxScope,
|
||||
);
|
||||
|
||||
// Callers see the session id they supplied (or the derived per-call id), so
|
||||
|
||||
Reference in New Issue
Block a user