diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 5aa7cbc30ed..a2c7556728a 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -1815,6 +1815,62 @@ describe('AgentRuntime — guardrails', () => { expect(call.state === 'rejected' && call.error).toContain('tool.stop'); }); + it.each(['generate', 'stream'] as const)( + 'records canceled calls without errors and stops the next %s model turn', + async (mode) => { + const canceled: GuardrailDecision = { action: 'stop', code: 'user.cancel', canceled: true }; + const calls = ['tc-1', 'tc-2'].map((toolCallId) => ({ + toolCallId, + toolName: 'echo', + args: { v: 'x' }, + })); + generateText.mockResolvedValueOnce(makeGenerateWithToolCalls(calls)); + streamText.mockReturnValueOnce(makeStreamWithToolCalls(calls)); + const handler = vi.fn(async () => 'Must not run'); + const hook = makeGuardrail({ + before: vi.fn().mockResolvedValueOnce({ action: 'allow' }).mockResolvedValue(canceled), + beforeTool: vi.fn().mockResolvedValue(canceled), + }); + const runtime = createRuntimeWithEchoTool(handler); + const options = { guardrails: guardrailsOption(hook) }; + if (mode === 'generate') { + const result = await runtime.generate('go', options); + const blocks = result.messages.flatMap((message) => + isLlmMessage(message) + ? message.content.filter((block) => block.type === 'tool-call') + : [], + ); + expect(blocks).toHaveLength(2); + expect(blocks).toEqual([ + expect.objectContaining({ state: 'resolved', canceled: true }), + expect.objectContaining({ state: 'resolved', canceled: true }), + ]); + expect(result.error).toBeUndefined(); + expect(result.guardrail).toEqual({ code: 'user.cancel', canceled: true }); + expect(generateText).toHaveBeenCalledTimes(1); + } else { + const { stream } = await runtime.stream('go', options); + const chunks = await collectChunks(stream); + const results = chunks.filter((chunk) => chunk.type === 'tool-result'); + expect(results).toHaveLength(2); + for (const result of results) { + expect(result.canceled).toBe(true); + expect(result.output).toContain('user.cancel'); + expect(result.output).not.toContain('User said'); + expect(result.isError).not.toBe(true); + } + expect(chunks.some((chunk) => chunk.type === 'error')).toBe(false); + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + guardrail: { code: 'user.cancel', canceled: true }, + }); + expect(streamText).toHaveBeenCalledTimes(1); + } + expect(handler).not.toHaveBeenCalled(); + expect(hook.afterTool).not.toHaveBeenCalled(); + }, + ); + it('passes the handler result to afterTool() when the tool call is allowed', async () => { generateText .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'echo', { v: 'x' })) diff --git a/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts b/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts index c67b96204f6..fe290702bfb 100644 --- a/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts +++ b/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts @@ -46,7 +46,9 @@ export class GuardrailRunner { async before(ctx: GuardrailModelCallContext): Promise { for (const hook of this.hooks) { const decision = await hook.before?.(ctx); - if (decision?.action === 'stop') return { code: decision.code }; + if (decision?.action === 'stop') { + return { code: decision.code, ...(decision.canceled ? { canceled: true } : {}) }; + } } return undefined; } @@ -60,7 +62,9 @@ export class GuardrailRunner { async beforeTool(ctx: GuardrailToolCallContext): Promise { for (const hook of this.hooks) { const decision = await hook.beforeTool?.(ctx); - if (decision?.action === 'stop') return { code: decision.code }; + if (decision?.action === 'stop') { + return { code: decision.code, ...(decision.canceled ? { canceled: true } : {}) }; + } } return undefined; } diff --git a/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts index 2b4b458f2d3..84bc8948364 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts @@ -137,6 +137,12 @@ export class ToolCallRunner { if (guardrails && guardCtx && !params.previouslySuspended) { const stop = await guardrails.beforeTool(guardCtx); if (stop) { + if (stop.canceled) + return this.buildCancelledOutcome( + params, + 'Tool call canceled', + `[Tool call stopped by guardrail: ${stop.code}]`, + ); return await this.toolError( params, new Error(`Tool call stopped by guardrail: ${stop.code}`), diff --git a/packages/@n8n/agents/src/types/sdk/guardrail.ts b/packages/@n8n/agents/src/types/sdk/guardrail.ts index 8512501e46e..cc0c93e51ef 100644 --- a/packages/@n8n/agents/src/types/sdk/guardrail.ts +++ b/packages/@n8n/agents/src/types/sdk/guardrail.ts @@ -12,9 +12,13 @@ export interface BuiltGuardrail { /** @internal */ readonly _config: Record; } -export type GuardrailDecision = { action: 'allow' } | { action: 'stop'; code: string }; +export type GuardrailDecision = { action: 'allow' } | ({ action: 'stop' } & GuardrailStop); -export type GuardrailStop = { code: string }; +export type GuardrailStop = { + code: string; + /** Record a stopped model or tool call as canceled. */ + canceled?: true; +}; export type GuardrailModelCallSource = 'turn' | 'title' | 'memory'; export interface GuardrailModelCallContext {