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 a2c7556728a..5aa7cbc30ed 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -1815,62 +1815,6 @@ 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 fe290702bfb..c67b96204f6 100644 --- a/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts +++ b/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts @@ -46,9 +46,7 @@ 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, ...(decision.canceled ? { canceled: true } : {}) }; - } + if (decision?.action === 'stop') return { code: decision.code }; } return undefined; } @@ -62,9 +60,7 @@ 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, ...(decision.canceled ? { canceled: true } : {}) }; - } + if (decision?.action === 'stop') return { code: decision.code }; } 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 84bc8948364..2b4b458f2d3 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts @@ -137,12 +137,6 @@ 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 cc0c93e51ef..8512501e46e 100644 --- a/packages/@n8n/agents/src/types/sdk/guardrail.ts +++ b/packages/@n8n/agents/src/types/sdk/guardrail.ts @@ -12,13 +12,9 @@ export interface BuiltGuardrail { /** @internal */ readonly _config: Record; } -export type GuardrailDecision = { action: 'allow' } | ({ action: 'stop' } & GuardrailStop); +export type GuardrailDecision = { action: 'allow' } | { action: 'stop'; code: string }; -export type GuardrailStop = { - code: string; - /** Record a stopped model or tool call as canceled. */ - canceled?: true; -}; +export type GuardrailStop = { code: string }; export type GuardrailModelCallSource = 'turn' | 'title' | 'memory'; export interface GuardrailModelCallContext {