From 3745c835b2b47ed484246fcd7aefd5e9be3be5bf Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Thu, 1 Oct 2026 10:21:42 +0000 Subject: [PATCH] feat(core): Add a guardrail runner to the agent loop (#39621) Co-authored-by: Cursor --- ...tool-call-and-lets-the-model-continue.json | 190 +++++++++ .../checks-a-suspended-tool-once.json | 180 +++++++++ ...uses-the-first-model-call-in-generate.json | 1 + ...efuses-the-first-model-call-in-stream.json | 1 + .../sees-every-model-call-and-its-usage.json | 189 +++++++++ ...stops-the-run-after-the-crossing-call.json | 82 ++++ .../__tests__/integration/guardrails.test.ts | 192 +++++++++ packages/@n8n/agents/src/index.ts | 7 + .../runtime/__tests__/agent-runtime.test.ts | 375 +++++++++++++++++- .../__tests__/guardrail-runner.test.ts | 170 ++++++++ .../runtime/guardrails/guardrail-runner.ts | 73 ++++ .../agents/src/runtime/loop/agent-runtime.ts | 44 +- .../agents/src/runtime/loop/generate-sink.ts | 1 + .../agents/src/runtime/loop/stream-sink.ts | 1 + .../src/runtime/tools/tool-call-executor.ts | 4 + .../src/runtime/tools/tool-call-runner.ts | 27 +- packages/@n8n/agents/src/types/index.ts | 10 + .../agents/src/types/runtime/agent-loop.ts | 2 + .../src/types/runtime/tool-execution.ts | 5 + packages/@n8n/agents/src/types/sdk/agent.ts | 7 +- .../@n8n/agents/src/types/sdk/guardrail.ts | 43 ++ .../evaluations/discovery/stream-status.ts | 1 + 22 files changed, 1597 insertions(+), 8 deletions(-) create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/blocks-one-tool-call-and-lets-the-model-continue.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/checks-a-suspended-tool-once.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-generate.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-stream.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/sees-every-model-call-and-its-usage.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/stops-the-run-after-the-crossing-call.json create mode 100644 packages/@n8n/agents/src/__tests__/integration/guardrails.test.ts create mode 100644 packages/@n8n/agents/src/runtime/guardrails/__tests__/guardrail-runner.test.ts create mode 100644 packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/blocks-one-tool-call-and-lets-the-model-continue.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/blocks-one-tool-call-and-lets-the-model-continue.json new file mode 100644 index 00000000000..4151cfdd8f4 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/blocks-one-tool-call-and-lets-the-model-continue.json @@ -0,0 +1,190 @@ +[ + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a calculator. When asked to add numbers, use the add_numbers tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is 2 + 3? Use the tool once. If the tool fails, say so in one sentence." + } + ] + } + ], + "tools": [ + { + "name": "add_numbers", + "description": "Add two numbers together and return the result", + "input_schema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["a", "b"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMypYKp2r2Wk9BhvPE6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01AvHg8o8w8rvyMtUJqa9Zxy\",\"name\":\"add_numbers\",\"input\":{\"a\":2,\"b\":3},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":661,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":71,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "10000000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:01Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:01Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:43:01Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "12000000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:01Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a4023737eaa88168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:01 GMT", + "request-id": "req_011CfNMypGwuiyYNFo85wMx3", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-1d7d2a59dc02bae4c3a2d2d7f6dd7f77-609995466a131c70-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a calculator. When asked to add numbers, use the add_numbers tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is 2 + 3? Use the tool once. If the tool fails, say so in one sentence." + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01AvHg8o8w8rvyMtUJqa9Zxy", + "name": "add_numbers", + "input": { + "a": 2, + "b": 3 + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01AvHg8o8w8rvyMtUJqa9Zxy", + "content": "Error: Tool call stopped by guardrail: tool.blocked", + "is_error": true + } + ] + } + ], + "tools": [ + { + "name": "add_numbers", + "description": "Add two numbers together and return the result", + "input_schema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["a", "b"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMystyrRQQwxMfaZbR9\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The tool failed to execute due to a guardrail block.\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":765,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "9999000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:02Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:02Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:43:02Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "11999000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:02Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a402373cec268168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:02 GMT", + "request-id": "req_011CfNMysebz3hvMi5bsjqLr", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-4d97d181f5846c7c30e698bbe47e6ea5-5e40d87c0e2e651d-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + } +] diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/checks-a-suspended-tool-once.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/checks-a-suspended-tool-once.json new file mode 100644 index 00000000000..5c85120df56 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/checks-a-suspended-tool-once.json @@ -0,0 +1,180 @@ +[ + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a file manager. When asked to delete a file, use the delete_file tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Delete the file /tmp/guardrail.txt" + } + ] + } + ], + "tools": [ + { + "name": "delete_file", + "description": "Delete a file at the given path", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path to delete" + } + }, + "required": ["path"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMyveSbeictGQzrWfvq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_019Wed5RVopf1ThLeEUf7Ndj\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/guardrail.txt\"},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":630,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "10000000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:03Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:03Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:43:02Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "12000000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:03Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a4023740c9728168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:03 GMT", + "request-id": "req_011CfNMyvMKL29koHpHie9Qx", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-4533af4453ae3fd61a218dec0dcb5edb-77739d4e760ba069-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a file manager. When asked to delete a file, use the delete_file tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Delete the file /tmp/guardrail.txt" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_019Wed5RVopf1ThLeEUf7Ndj", + "name": "delete_file", + "input": { + "path": "/tmp/guardrail.txt" + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_019Wed5RVopf1ThLeEUf7Ndj", + "content": "{\"deleted\":true,\"path\":\"/tmp/guardrail.txt\"}" + } + ] + } + ], + "tools": [ + { + "name": "delete_file", + "description": "Delete a file at the given path", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "File path to delete" + } + }, + "required": ["path"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMyz92vUy6yf98GFnqw\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/tmp/guardrail.txt` has been successfully deleted.\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":719,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":19,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "10000000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:03Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:03Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:43:03Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "12000000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:03Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a40237460f158168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:03 GMT", + "request-id": "req_011CfNMyyttob2JoJoZYSS2P", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-4ea94ac832c02f45d872fda225fdc80d-b5a3a403193144cc-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + } +] diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-generate.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-generate.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-generate.json @@ -0,0 +1 @@ +[] diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-stream.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-stream.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/refuses-the-first-model-call-in-stream.json @@ -0,0 +1 @@ +[] diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/sees-every-model-call-and-its-usage.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/sees-every-model-call-and-its-usage.json new file mode 100644 index 00000000000..83264c67852 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/sees-every-model-call-and-its-usage.json @@ -0,0 +1,189 @@ +[ + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a calculator. When asked to add numbers, use the add_numbers tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is 2 + 3? Use the tool." + } + ] + } + ], + "tools": [ + { + "name": "add_numbers", + "description": "Add two numbers together and return the result", + "input_schema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["a", "b"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMyfHD9pXKUCYZXWbu6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Dj6HQiSmYctMpqzcE1Lw9n\",\"name\":\"add_numbers\",\"input\":{\"a\":2,\"b\":3},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":649,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":71,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "10000000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:42:59Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:42:59Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:42:59Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "12000000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:42:59Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a4023729bc6d8168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:42:59 GMT", + "request-id": "req_011CfNMyebnKy4nzubssNvTo", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-e3ba3820e5aa2d3227d8033792438e24-b469f989fcd37da0-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a calculator. When asked to add numbers, use the add_numbers tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is 2 + 3? Use the tool." + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Dj6HQiSmYctMpqzcE1Lw9n", + "name": "add_numbers", + "input": { + "a": 2, + "b": 3 + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01Dj6HQiSmYctMpqzcE1Lw9n", + "content": "{\"result\":5}" + } + ] + } + ], + "tools": [ + { + "name": "add_numbers", + "description": "Add two numbers together and return the result", + "input_schema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["a", "b"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMyiZQk7PxBFXNx2mZf\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"2 + 3 = **5**\"}],\"container\":null,\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":736,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":13,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "9999000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:00Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:00Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:42:59Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "11999000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:00Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a402372f28488168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:00 GMT", + "request-id": "req_011CfNMyiKGd6qhCaXEdb1Pm", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-6bebf8ce8daaf6361a000d6410ff062c-9c1afba04f672cdf-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + } +] diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/stops-the-run-after-the-crossing-call.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/stops-the-run-after-the-crossing-call.json new file mode 100644 index 00000000000..1e32353630d --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/guardrails/stops-the-run-after-the-crossing-call.json @@ -0,0 +1,82 @@ +[ + { + "scope": "https://api.anthropic.com:443", + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-haiku-4-5", + "max_tokens": 64000, + "system": [ + { + "type": "text", + "text": "You are a calculator. When asked to add numbers, use the add_numbers tool. Be concise." + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is 2 + 3? Use the tool." + } + ] + } + ], + "tools": [ + { + "name": "add_numbers", + "description": "Add two numbers together and return the result", + "input_schema": { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": ["a", "b"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ], + "tool_choice": { + "type": "auto" + } + }, + "status": 200, + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CfNMykyXDFPEgh9h3Be7z\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01YaASNbMtirUpkLmjJFMDp9\",\"name\":\"add_numbers\",\"input\":{\"a\":2,\"b\":3},\"caller\":{\"type\":\"direct\"}}],\"container\":null,\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":649,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":71,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"},\"diagnostics\":null}", + "rawHeaders": { + "anthropic-ratelimit-input-tokens-limit": "10000000", + "anthropic-ratelimit-input-tokens-remaining": "10000000", + "anthropic-ratelimit-input-tokens-reset": "2026-09-24T13:43:00Z", + "anthropic-ratelimit-output-tokens-limit": "2000000", + "anthropic-ratelimit-output-tokens-remaining": "2000000", + "anthropic-ratelimit-output-tokens-reset": "2026-09-24T13:43:00Z", + "anthropic-ratelimit-requests-limit": "20000", + "anthropic-ratelimit-requests-remaining": "19999", + "anthropic-ratelimit-requests-reset": "2026-09-24T13:43:00Z", + "anthropic-ratelimit-tokens-limit": "12000000", + "anthropic-ratelimit-tokens-remaining": "12000000", + "anthropic-ratelimit-tokens-reset": "2026-09-24T13:43:00Z", + "cf-cache-status": "DYNAMIC", + "cf-ray": "a4023732be728168-LHR", + "connection": "keep-alive", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "content-type": "application/json", + "date": "Thu, 24 Sep 2026 13:43:01 GMT", + "request-id": "req_011CfNMykit5QN5zvHPdwZLh", + "server": "cloudflare", + "strict-transport-security": "max-age=31536000; includeSubDomains; preload", + "traceresponse": "00-d5ba23ebec625ed0fc5f447f57b53c49-fc09062d845260f3-01", + "vary": "Accept-Encoding", + "x-robots-tag": "none" + }, + "responseIsBinary": false + } +] diff --git a/packages/@n8n/agents/src/__tests__/integration/guardrails.test.ts b/packages/@n8n/agents/src/__tests__/integration/guardrails.test.ts new file mode 100644 index 00000000000..cdf7c9527de --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/guardrails.test.ts @@ -0,0 +1,192 @@ +import { expect, it } from 'vitest'; + +import { + chunksOfType, + collectStreamChunks, + createAgentWithAddTool, + createAgentWithInterruptibleTool, + describeIf, + findAllToolResults, + findLastTextContent, + getModel, +} from './helpers'; +import { Agent } from '../../index'; +import type { + GuardrailDecision, + GuardrailModelCallContext, + GuardrailToolCallContext, + GuardrailsOptions, + ModelGuardrail, + TokenUsage, +} from '../../types'; + +const describe = describeIf('anthropic'); + +const IDS = { agentId: 'agent-1', threadId: 'thread-1' }; + +type Decide = { + /** Called with the 1-based index of the model call. */ + before?: (callNumber: number) => GuardrailDecision | undefined; + beforeTool?: (ctx: GuardrailToolCallContext) => GuardrailDecision | undefined; +}; + +/** A hook that records every call and decides through the given functions. */ +function recordingGuardrail(decide: Decide = {}) { + const calls = { + before: [] as GuardrailModelCallContext[], + after: [] as Array<{ ctx: GuardrailModelCallContext; usage: TokenUsage | undefined }>, + beforeTool: [] as GuardrailToolCallContext[], + afterTool: [] as Array<{ ctx: GuardrailToolCallContext; result: unknown }>, + }; + const hook: ModelGuardrail = { + async before(ctx) { + calls.before.push(ctx); + return await Promise.resolve(decide.before?.(calls.before.length)); + }, + async after(ctx, usage) { + calls.after.push({ ctx, usage }); + await Promise.resolve(); + }, + async beforeTool(ctx) { + calls.beforeTool.push(ctx); + return await Promise.resolve(decide.beforeTool?.(ctx)); + }, + async afterTool(ctx, result) { + calls.afterTool.push({ ctx, result }); + await Promise.resolve(); + }, + }; + const guardrails: GuardrailsOptions = { hooks: [hook], ...IDS }; + return { guardrails, calls }; +} + +const stop = (code: string): GuardrailDecision => ({ action: 'stop', code }); + +describe('guardrails integration', () => { + it('refuses the first model call in generate', async () => { + const { guardrails, calls } = recordingGuardrail({ before: () => stop('test.first') }); + const agent = new Agent('guardrail-first-call') + .model(getModel('anthropic')) + .instructions('You are a concise assistant. Reply with one short sentence.'); + + const result = await agent.generate('Say hello.', { guardrails }); + + expect(result.finishReason).toBe('guardrail'); + expect(result.guardrail).toEqual({ code: 'test.first' }); + expect(result.error).toBeUndefined(); + expect(findLastTextContent(result.messages)).toBeUndefined(); + expect(calls.before).toHaveLength(1); + expect(calls.after).toHaveLength(0); + }); + + it('refuses the first model call in stream', async () => { + const { guardrails } = recordingGuardrail({ before: () => stop('test.first') }); + const agent = new Agent('guardrail-first-call-stream') + .model(getModel('anthropic')) + .instructions('You are a concise assistant. Reply with one short sentence.'); + + const { stream } = await agent.stream('Say hello.', { guardrails }); + const chunks = await collectStreamChunks(stream); + + const finishChunks = chunksOfType(chunks, 'finish'); + expect(finishChunks).toHaveLength(1); + expect(finishChunks[0]).toMatchObject({ + finishReason: 'guardrail', + guardrail: { code: 'test.first' }, + }); + expect(chunksOfType(chunks, 'text-delta')).toHaveLength(0); + }); + + it('sees every model call and its usage', async () => { + const { guardrails, calls } = recordingGuardrail(); + const agent = createAgentWithAddTool('anthropic'); + + const result = await agent.generate('What is 2 + 3? Use the tool.', { guardrails }); + + expect(result.finishReason).toBe('stop'); + expect(calls.before.length).toBeGreaterThanOrEqual(2); + expect(calls.after).toHaveLength(calls.before.length); + + const callIds = new Set(calls.before.map((ctx) => ctx.callId)); + expect(callIds.size).toBe(calls.before.length); + for (const ctx of calls.before) { + expect(ctx).toMatchObject({ source: 'turn', model: getModel('anthropic'), ...IDS }); + } + + let totalTokens = 0; + for (const { usage } of calls.after) { + expect(usage?.totalTokens).toBeGreaterThan(0); + totalTokens += usage?.totalTokens ?? 0; + } + expect(totalTokens).toBe(result.usage?.totalTokens); + }); + + it('stops the run after the crossing call', async () => { + const { guardrails, calls } = recordingGuardrail({ + before: (callNumber) => (callNumber === 1 ? { action: 'allow' } : stop('test.second')), + }); + const agent = createAgentWithAddTool('anthropic'); + + const result = await agent.generate('What is 2 + 3? Use the tool.', { guardrails }); + + const toolResults = findAllToolResults(result.messages); + expect(toolResults).toHaveLength(1); + expect(toolResults[0]).toMatchObject({ + toolName: 'add_numbers', + state: 'resolved', + output: { result: 5 }, + }); + expect(calls.after).toHaveLength(1); + expect(result.finishReason).toBe('guardrail'); + expect(result.guardrail).toEqual({ code: 'test.second' }); + }); + + it('blocks one tool call and lets the model continue', async () => { + const { guardrails, calls } = recordingGuardrail({ + beforeTool: (ctx) => (ctx.toolName === 'add_numbers' ? stop('tool.blocked') : undefined), + }); + const agent = createAgentWithAddTool('anthropic'); + + const result = await agent.generate( + 'What is 2 + 3? Use the tool once. If the tool fails, say so in one sentence.', + { guardrails }, + ); + + expect(calls.beforeTool.length).toBeGreaterThanOrEqual(1); + expect(calls.afterTool).toHaveLength(0); + const toolResults = findAllToolResults(result.messages); + expect(toolResults.length).toBeGreaterThanOrEqual(1); + for (const toolResult of toolResults) { + expect(toolResult.state).toBe('rejected'); + expect(JSON.stringify(toolResult.state === 'rejected' && toolResult.error)).toContain( + 'tool.blocked', + ); + } + expect(result.finishReason).toBe('stop'); + expect(findLastTextContent(result.messages)).toEqual(expect.any(String)); + }); + + it('checks a suspended tool once', async () => { + const { guardrails, calls } = recordingGuardrail(); + const agent = createAgentWithInterruptibleTool('anthropic'); + + const first = await agent.generate('Delete the file /tmp/guardrail.txt', { guardrails }); + + expect(first.pendingSuspend).toHaveLength(1); + expect(calls.beforeTool).toHaveLength(1); + expect(calls.beforeTool[0]).toMatchObject({ toolName: 'delete_file', ...IDS }); + expect(calls.afterTool).toHaveLength(0); + + const { runId, toolCallId } = first.pendingSuspend![0]; + const resumed = await agent.resume( + 'generate', + { approved: true }, + { runId, toolCallId, guardrails }, + ); + + expect(resumed.finishReason).toBe('stop'); + expect(calls.beforeTool).toHaveLength(1); + expect(calls.afterTool).toHaveLength(1); + expect(calls.afterTool[0].result).toMatchObject({ deleted: true }); + }); +}); diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 59311aa7ca9..82d735856e2 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -6,6 +6,13 @@ export type { BuiltEpisodicMemoryCaptureStore, BuiltEpisodicMemoryStore, BuiltGuardrail, + GuardrailDecision, + GuardrailModelCallContext, + GuardrailModelCallSource, + GuardrailStop, + GuardrailToolCallContext, + GuardrailsOptions, + ModelGuardrail, PiiDetectionType, BuiltEval, RunOptions, 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 2e2a8d671ce..ac06a40342f 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -16,7 +16,12 @@ import type { RuntimeSkillSource } from '../../skills/types'; import type { CheckpointStore, ModelConfig, SerializableAgentState } from '../../types'; import { AgentEvent } from '../../types/runtime/event'; import type { AgentEventData } from '../../types/runtime/event'; -import type { ExecutionOptions, StreamChunk } from '../../types/sdk/agent'; +import type { ExecutionOptions, GenerateResult, StreamChunk } from '../../types/sdk/agent'; +import type { + GuardrailDecision, + GuardrailsOptions, + ModelGuardrail, +} from '../../types/sdk/guardrail'; import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message'; import type { BuiltTool, @@ -1426,6 +1431,374 @@ describe('AgentRuntime — empty stop turn retry', () => { }); }); +describe('AgentRuntime — guardrails', () => { + beforeEach(() => { + generateText.mockReset(); + streamText.mockReset(); + }); + + const stopDecision = (code: string): GuardrailDecision => ({ action: 'stop', code }); + + type GuardrailSpies = { + [K in keyof Required]: MockedFunction>; + }; + + /** A hook whose every method is a spy. `before`/`beforeTool` allow unless overridden. */ + function makeGuardrail(overrides: Partial = {}): GuardrailSpies { + return { + before: vi.fn>().mockResolvedValue({ action: 'allow' }), + after: vi.fn>().mockResolvedValue(undefined), + beforeTool: vi + .fn>() + .mockResolvedValue({ action: 'allow' }), + afterTool: vi.fn>().mockResolvedValue(undefined), + ...overrides, + }; + } + + function guardrailsOption(hook: ModelGuardrail): GuardrailsOptions { + return { hooks: [hook], agentId: 'agent-1', threadId: 'thread-1' }; + } + + /** A `stop` turn with no output; the loop retries it. */ + function makeGenerateEmpty() { + return { + finishReason: 'stop', + usage: { inputTokens: 10, outputTokens: 0, totalTokens: 10 }, + response: { messages: [] }, + toolCalls: [], + }; + } + + function createRuntimeWithEchoTool(handler: (input: { v: string }) => Promise) { + const echoTool = new ToolBuilder('echo') + .description('Echo the input') + .input(z.object({ v: z.string() })) + .handler(handler) + .build(); + return new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'test', + tools: [echoTool], + checkpointStorage: 'memory', + }); + } + + function findToolCallBlock(messages: GenerateResult['messages']): ContentToolCall { + const assistantMsg = messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ) as Message; + return assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + } + + it('ends a generate run with finishReason guardrail when before() stops', async () => { + const hook = makeGuardrail({ before: vi.fn().mockResolvedValue(stopDecision('test.stop')) }); + const { runtime } = createRuntime(); + + const result = await runtime.generate('hi', { guardrails: guardrailsOption(hook) }); + + expect(generateText).not.toHaveBeenCalled(); + expect(result.finishReason).toBe('guardrail'); + expect(result.guardrail).toEqual({ code: 'test.stop' }); + expect(result.error).toBeUndefined(); + expect(hook.after).not.toHaveBeenCalled(); + }); + + it('ends a stream run with a finish chunk that carries the guardrail stop', async () => { + const hook = makeGuardrail({ before: vi.fn().mockResolvedValue(stopDecision('test.stop')) }); + const { runtime } = createRuntime(); + + const { stream } = await runtime.stream('hi', { guardrails: guardrailsOption(hook) }); + const chunks = await collectChunks(stream); + + expect(streamText).not.toHaveBeenCalled(); + const finish = chunks[chunks.length - 1]; + expect(finish).toMatchObject({ + type: 'finish', + finishReason: 'guardrail', + guardrail: { code: 'test.stop' }, + }); + expect(chunks.some((c) => c.type === 'text-delta')).toBe(false); + }); + + it('reports canContinue false at the completing boundary after a guardrail stop', async () => { + const hook = makeGuardrail({ before: vi.fn().mockResolvedValue(stopDecision('test.stop')) }); + const { runtime } = createRuntime(); + const boundaries: Array<{ completing: boolean; canContinue: boolean }> = []; + + const result = await runtime.generate('hi', { + guardrails: guardrailsOption(hook), + onInputBoundary: async (boundary) => { + boundaries.push({ completing: boundary.completing, canContinue: boundary.canContinue }); + return []; + }, + }); + + expect(result.finishReason).toBe('guardrail'); + expect(generateText).not.toHaveBeenCalled(); + // The run's first boundary, then one completing boundary after the stop. + // The loop ends there instead of draining queued input per iteration. + expect(boundaries).toEqual([ + { completing: false, canContinue: true }, + { completing: true, canContinue: false }, + ]); + }); + + it('ends the run even when a boundary returns input after a guardrail stop', async () => { + const hook = makeGuardrail({ before: vi.fn().mockResolvedValue(stopDecision('test.stop')) }); + const { runtime } = createRuntime(); + const steered: AgentDbMessage = { + id: 'steered-input', + createdAt: new Date(), + role: 'user', + content: [{ type: 'text', text: 'queued while stopping' }], + }; + const onInputBoundary = vi.fn(async () => [structuredClone(steered)]); + + const result = await runtime.generate('hi', { + guardrails: guardrailsOption(hook), + onInputBoundary, + }); + + expect(result.finishReason).toBe('guardrail'); + expect(result.guardrail).toEqual({ code: 'test.stop' }); + expect(generateText).not.toHaveBeenCalled(); + expect(onInputBoundary).toHaveBeenCalledTimes(2); + }); + + it('calls before() and after() once with the same context when the call is allowed', async () => { + generateText.mockResolvedValue(makeGenerateSuccess('Done')); + const hook = makeGuardrail(); + const { runtime } = createRuntime(); + + const result = await runtime.generate('hi', { guardrails: guardrailsOption(hook) }); + + expect(result.finishReason).toBe('stop'); + expect(result.guardrail).toBeUndefined(); + expect(hook.before).toHaveBeenCalledTimes(1); + expect(hook.after).toHaveBeenCalledTimes(1); + + const [beforeCtx] = hook.before.mock.calls[0]; + const [afterCtx, usage] = hook.after.mock.calls[0]; + expect(afterCtx).toBe(beforeCtx); + expect(beforeCtx).toMatchObject({ + callId: expect.any(String), + source: 'turn', + model: 'openai/gpt-4o-mini', + agentId: 'agent-1', + threadId: 'thread-1', + }); + expect(usage).toMatchObject({ promptTokens: 10, completionTokens: 5, totalTokens: 15 }); + }); + + it('checks each empty-turn retry as a new call', async () => { + generateText + .mockResolvedValueOnce(makeGenerateEmpty()) + .mockResolvedValueOnce(makeGenerateSuccess('Recovered')); + const hook = makeGuardrail(); + const { runtime } = createRuntime(); + + const result = await runtime.generate('hi', { guardrails: guardrailsOption(hook) }); + + expect(generateText).toHaveBeenCalledTimes(2); + expect(result.finishReason).toBe('stop'); + expect(hook.before).toHaveBeenCalledTimes(2); + expect(hook.after).toHaveBeenCalledTimes(2); + const [first] = hook.before.mock.calls[0]; + const [second] = hook.before.mock.calls[1]; + expect(first.callId).not.toBe(second.callId); + }); + + it('ends the run when a guardrail stops the empty-turn retry', async () => { + generateText.mockResolvedValue(makeGenerateEmpty()); + const before = vi + .fn>() + .mockResolvedValueOnce({ action: 'allow' }) + .mockResolvedValueOnce(stopDecision('test.retry')); + const hook = makeGuardrail({ before }); + const { runtime } = createRuntime(); + + const result = await runtime.generate('hi', { guardrails: guardrailsOption(hook) }); + + expect(generateText).toHaveBeenCalledTimes(1); + expect(result.finishReason).toBe('guardrail'); + expect(result.guardrail).toEqual({ code: 'test.retry' }); + expect(hook.after).toHaveBeenCalledTimes(1); + }); + + it('refuses a tool call when beforeTool() stops and lets the model continue', async () => { + generateText + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'echo', { v: 'x' })) + .mockResolvedValueOnce(makeGenerateSuccess('Understood')); + const handler = vi.fn(async ({ v }: { v: string }) => await Promise.resolve({ echoed: v })); + const hook = makeGuardrail({ + beforeTool: vi.fn().mockResolvedValue(stopDecision('tool.stop')), + }); + const runtime = createRuntimeWithEchoTool(handler); + + const result = await runtime.generate('go', { guardrails: guardrailsOption(hook) }); + + expect(handler).not.toHaveBeenCalled(); + expect(hook.afterTool).not.toHaveBeenCalled(); + expect(generateText).toHaveBeenCalledTimes(2); + expect(result.finishReason).toBe('stop'); + expect(result.guardrail).toBeUndefined(); + + const call = findToolCallBlock(result.messages); + expect(call.state).toBe('rejected'); + expect(call.state === 'rejected' && call.error).toContain('tool.stop'); + }); + + it('passes the handler result to afterTool() when the tool call is allowed', async () => { + generateText + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'echo', { v: 'x' })) + .mockResolvedValueOnce(makeGenerateSuccess('Done')); + const hook = makeGuardrail(); + const runtime = createRuntimeWithEchoTool( + async ({ v }) => await Promise.resolve({ echoed: v }), + ); + + const result = await runtime.generate('go', { guardrails: guardrailsOption(hook) }); + + expect(result.finishReason).toBe('stop'); + expect(hook.beforeTool).toHaveBeenCalledTimes(1); + expect(hook.afterTool).toHaveBeenCalledTimes(1); + + const [beforeCtx] = hook.beforeTool.mock.calls[0]; + const [afterCtx, toolResult] = hook.afterTool.mock.calls[0]; + expect(beforeCtx).toMatchObject({ + toolCallId: 'tc-1', + toolName: 'echo', + input: { v: 'x' }, + runId: expect.any(String), + agentId: 'agent-1', + threadId: 'thread-1', + }); + expect(afterCtx).toBe(beforeCtx); + expect(toolResult).toEqual({ echoed: 'x' }); + }); + + it('does not run beforeTool() again when a suspended tool resumes', async () => { + const approvalTool = new ToolBuilder('delete') + .description('Delete a record') + .input(z.object({ id: z.string() })) + .requireApproval() + .handler(async ({ id }: { id: string }) => await Promise.resolve({ deleted: id })) + .build(); + generateText + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'delete', { id: 'rec-1' })) + .mockResolvedValueOnce(makeGenerateSuccess('Done')); + const hook = makeGuardrail(); + const guardrails = guardrailsOption(hook); + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'test', + tools: [approvalTool], + checkpointStorage: 'memory', + }); + + const firstResult = await runtime.generate('Delete record rec-1', { guardrails }); + expect(firstResult.finishReason).toBe('tool-calls'); + expect(hook.beforeTool).toHaveBeenCalledTimes(1); + expect(hook.afterTool).not.toHaveBeenCalled(); + + const { runId, toolCallId } = firstResult.pendingSuspend![0]; + const resumeResult = await runtime.resume( + 'generate', + { approved: true }, + { runId, toolCallId, guardrails }, + ); + + expect(resumeResult.finishReason).toBe('stop'); + expect(hook.beforeTool).toHaveBeenCalledTimes(1); + expect(hook.afterTool).toHaveBeenCalledTimes(1); + const [, toolResult] = hook.afterTool.mock.calls[0]; + expect(toolResult).toEqual({ deleted: 'rec-1' }); + }); + + it('checks an unexecuted sibling when the suspended call resumes', async () => { + const suspendTool = makeSuspendingTool('suspend_tool', async (_input, ctx) => { + if (ctx.resumeData) return { approved: true }; + return await ctx.suspend({ reason: 'needs approval' }); + }); + const siblingHandler = vi.fn(async () => await Promise.resolve({ done: true })); + const siblingTool = makeMockTool('normal_tool', siblingHandler); + const { runtime } = createRuntimeWithTools([suspendTool, siblingTool], 1); + const hook = makeGuardrail(); + const guardrails = guardrailsOption(hook); + + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-1', toolName: 'suspend_tool', args: { value: 'a' } }, + { toolCallId: 'tc-2', toolName: 'normal_tool', args: { value: 'b' } }, + ]), + ) + .mockResolvedValueOnce(makeGenerateSuccess('Done')); + + const first = await runtime.generate('run tools', { guardrails }); + + expect(first.finishReason).toBe('tool-calls'); + expect(siblingHandler).not.toHaveBeenCalled(); + expect(hook.beforeTool).toHaveBeenCalledTimes(1); + expect(hook.beforeTool.mock.calls[0][0]).toMatchObject({ toolName: 'suspend_tool' }); + + const { runId, toolCallId } = first.pendingSuspend![0]; + const resumed = await runtime.resume( + 'generate', + { approved: true }, + { runId, toolCallId, guardrails }, + ); + + expect(resumed.finishReason).toBe('stop'); + expect(siblingHandler).toHaveBeenCalledTimes(1); + expect(hook.beforeTool).toHaveBeenCalledTimes(2); + expect(hook.beforeTool.mock.calls[1][0]).toMatchObject({ + toolCallId: 'tc-2', + toolName: 'normal_tool', + }); + expect(hook.afterTool).toHaveBeenCalledTimes(2); + }); + + it('checks an unexecuted pending tool when resume targets it', async () => { + const handler = vi.fn(async (_input: unknown, ctx: InterruptibleToolContext) => { + if (ctx.resumeData) return { approved: true }; + return await ctx.suspend({ reason: 'needs approval' }); + }); + const suspendTool = makeSuspendingTool('suspend_tool', handler); + const { runtime } = createRuntimeWithTools([suspendTool], 1); + const hook = makeGuardrail(); + const guardrails = guardrailsOption(hook); + + generateText.mockResolvedValueOnce( + makeGenerateWithToolCalls([ + { toolCallId: 'tc-1', toolName: 'suspend_tool', args: { value: 'a' } }, + { toolCallId: 'tc-2', toolName: 'suspend_tool', args: { value: 'b' } }, + ]), + ); + + const first = await runtime.generate('run tools', { guardrails }); + + expect(first.finishReason).toBe('tool-calls'); + expect(runtime.getState().pendingToolCalls['tc-2']?.suspended).toBe(false); + expect(hook.beforeTool).toHaveBeenCalledTimes(1); + + const resumed = await runtime.resume( + 'generate', + { approved: true }, + { runId: first.runId, toolCallId: 'tc-2', guardrails }, + ); + + expect(resumed.finishReason).toBe('tool-calls'); + expect(hook.beforeTool).toHaveBeenCalledTimes(2); + expect(hook.beforeTool.mock.calls[1][0]).toMatchObject({ toolCallId: 'tc-2' }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); + describe('AgentRuntime — volatile instruction provider', () => { beforeEach(() => { generateText.mockReset(); diff --git a/packages/@n8n/agents/src/runtime/guardrails/__tests__/guardrail-runner.test.ts b/packages/@n8n/agents/src/runtime/guardrails/__tests__/guardrail-runner.test.ts new file mode 100644 index 00000000000..d37fe9a1d9f --- /dev/null +++ b/packages/@n8n/agents/src/runtime/guardrails/__tests__/guardrail-runner.test.ts @@ -0,0 +1,170 @@ +import type { + GuardrailDecision, + GuardrailModelCallContext, + GuardrailToolCallContext, + ModelGuardrail, + TokenUsage, +} from '../../../types'; +import { GuardrailRunner } from '../guardrail-runner'; + +const allow: GuardrailDecision = { action: 'allow' }; +const stop = (code: string): GuardrailDecision => ({ action: 'stop', code }); + +function makeHook(overrides: Partial = {}) { + return { + before: vi.fn>().mockResolvedValue(allow), + after: vi.fn>().mockResolvedValue(undefined), + beforeTool: vi.fn>().mockResolvedValue(allow), + afterTool: vi.fn>().mockResolvedValue(undefined), + ...overrides, + }; +} + +function runnerFor(hooks: ModelGuardrail[]): GuardrailRunner { + const runner = GuardrailRunner.from({ hooks, agentId: 'agent-1', threadId: 'thread-1' }); + if (!runner) throw new Error('expected a runner'); + return runner; +} + +const modelCtx: GuardrailModelCallContext = { + callId: 'call-1', + model: 'openai/gpt-4o-mini', + source: 'turn', +}; + +const toolCtx: GuardrailToolCallContext = { + toolCallId: 'tc-1', + toolName: 'echo', + input: { v: 'x' }, + runId: 'run-1', +}; + +describe('GuardrailRunner', () => { + describe('from()', () => { + it('returns undefined without options', () => { + expect(GuardrailRunner.from(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty hook list', () => { + expect(GuardrailRunner.from({ hooks: [] })).toBeUndefined(); + }); + + it('returns a runner when at least one hook is present', () => { + expect(GuardrailRunner.from({ hooks: [makeHook()] })).toBeInstanceOf(GuardrailRunner); + }); + }); + + describe('modelCallContext()', () => { + it('mints a distinct callId for every call', () => { + const runner = runnerFor([makeHook()]); + + const first = runner.modelCallContext('turn', 'openai/gpt-4o-mini'); + const second = runner.modelCallContext('turn', 'openai/gpt-4o-mini'); + + expect(first.callId).toEqual(expect.any(String)); + expect(first.callId).not.toBe(second.callId); + }); + + it('copies ids, model and source', () => { + const runner = runnerFor([makeHook()]); + + const ctx = runner.modelCallContext('turn', 'openai/gpt-4o-mini'); + + expect(ctx).toMatchObject({ + agentId: 'agent-1', + threadId: 'thread-1', + model: 'openai/gpt-4o-mini', + source: 'turn', + }); + }); + }); + + describe('toolCallContext()', () => { + it('copies the call fields and the ids', () => { + const runner = runnerFor([makeHook()]); + + const ctx = runner.toolCallContext(toolCtx); + + expect(ctx).toEqual({ ...toolCtx, agentId: 'agent-1', threadId: 'thread-1' }); + }); + }); + + describe('before()', () => { + it('returns the first stop and does not call later hooks', async () => { + const first = makeHook(); + const second = makeHook({ before: vi.fn().mockResolvedValue(stop('a')) }); + const third = makeHook({ before: vi.fn().mockResolvedValue(stop('b')) }); + + const result = await runnerFor([first, second, third]).before(modelCtx); + + expect(result).toEqual({ code: 'a' }); + expect(first.before).toHaveBeenCalledWith(modelCtx); + expect(second.before).toHaveBeenCalledWith(modelCtx); + expect(third.before).not.toHaveBeenCalled(); + }); + + it('skips hooks without before and returns undefined when all allow', async () => { + const noBefore: ModelGuardrail = { after: vi.fn() }; + const allowing = makeHook(); + const silent = makeHook({ before: vi.fn().mockResolvedValue(undefined) }); + + const result = await runnerFor([noBefore, allowing, silent]).before(modelCtx); + + expect(result).toBeUndefined(); + expect(allowing.before).toHaveBeenCalledTimes(1); + expect(silent.before).toHaveBeenCalledTimes(1); + }); + }); + + describe('after()', () => { + it('calls every hook with the same ctx and usage', async () => { + const usage: TokenUsage = { promptTokens: 10, completionTokens: 5, totalTokens: 15 }; + const first = makeHook(); + const noAfter: ModelGuardrail = { before: vi.fn() }; + const second = makeHook(); + + await runnerFor([first, noAfter, second]).after(modelCtx, usage); + + expect(first.after).toHaveBeenCalledWith(modelCtx, usage); + expect(second.after).toHaveBeenCalledWith(modelCtx, usage); + }); + }); + + describe('beforeTool()', () => { + it('returns the first stop and does not call later hooks', async () => { + const first = makeHook(); + const second = makeHook({ beforeTool: vi.fn().mockResolvedValue(stop('tool.a')) }); + const third = makeHook({ beforeTool: vi.fn().mockResolvedValue(stop('tool.b')) }); + + const result = await runnerFor([first, second, third]).beforeTool(toolCtx); + + expect(result).toEqual({ code: 'tool.a' }); + expect(first.beforeTool).toHaveBeenCalledWith(toolCtx); + expect(third.beforeTool).not.toHaveBeenCalled(); + }); + + it('skips hooks without beforeTool and returns undefined when all allow', async () => { + const noBeforeTool: ModelGuardrail = { afterTool: vi.fn() }; + const allowing = makeHook(); + + const result = await runnerFor([noBeforeTool, allowing]).beforeTool(toolCtx); + + expect(result).toBeUndefined(); + expect(allowing.beforeTool).toHaveBeenCalledTimes(1); + }); + }); + + describe('afterTool()', () => { + it('calls every hook with the same ctx and result', async () => { + const first = makeHook(); + const noAfterTool: ModelGuardrail = { beforeTool: vi.fn() }; + const second = makeHook(); + const result = { echoed: 'x' }; + + await runnerFor([first, noAfterTool, second]).afterTool(toolCtx, result); + + expect(first.afterTool).toHaveBeenCalledWith(toolCtx, result); + expect(second.afterTool).toHaveBeenCalledWith(toolCtx, result); + }); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts b/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts new file mode 100644 index 00000000000..c67b96204f6 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/guardrails/guardrail-runner.ts @@ -0,0 +1,73 @@ +import type { + GuardrailModelCallContext, + GuardrailModelCallSource, + GuardrailStop, + GuardrailToolCallContext, + GuardrailsOptions, + ModelGuardrail, + TokenUsage, +} from '../../types'; + +type GuardrailIds = Pick; + +/** + * Calls the host-supplied guardrail hooks around model and tool calls. Hooks + * run in list order, one at a time. The first `stop` wins. The runner holds + * no per-run state; each call site asks it fresh. + */ +export class GuardrailRunner { + private constructor( + private readonly hooks: ModelGuardrail[], + private readonly ids: GuardrailIds, + ) {} + + /** `undefined` when there is nothing to run, so call sites can skip all work. */ + static from(options: GuardrailsOptions | undefined): GuardrailRunner | undefined { + if (!options || options.hooks.length === 0) return undefined; + return new GuardrailRunner(options.hooks, { + agentId: options.agentId, + threadId: options.threadId, + }); + } + + modelCallContext(source: GuardrailModelCallSource, model: string): GuardrailModelCallContext { + return { callId: crypto.randomUUID(), ...this.ids, model, source }; + } + + toolCallContext(call: { + toolCallId: string; + toolName: string; + input: unknown; + runId: string; + }): GuardrailToolCallContext { + return { ...call, ...this.ids }; + } + + 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 }; + } + return undefined; + } + + async after(ctx: GuardrailModelCallContext, usage: TokenUsage | undefined): Promise { + for (const hook of this.hooks) { + await hook.after?.(ctx, usage); + } + } + + 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 }; + } + return undefined; + } + + async afterTool(ctx: GuardrailToolCallContext, result: unknown): Promise { + for (const hook of this.hooks) { + await hook.afterTool?.(ctx, result); + } + } +} diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index e4ab3312a53..26ff532018f 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -48,9 +48,11 @@ import type { ExecutionOptions, ResumeOptions, } from '../../types/sdk/agent'; +import type { GuardrailStop } from '../../types/sdk/guardrail'; import type { AgentMessage, ContentToolCall } from '../../types/sdk/message'; import { getModelIdString } from '../../utils/model'; import { removeToolResultRun } from '../../workspace'; +import { GuardrailRunner } from '../guardrails/guardrail-runner'; import { createFilteredLogger } from '../logger'; import { MemoryOrchestrator } from '../memory/memory-orchestrator'; import { sanitizeOffloadedToolResultsForMemory } from '../memory/tool-result-memory'; @@ -118,6 +120,7 @@ interface LoopState { maxIterations: number; iterationCount: number; reachedStopCondition: boolean; + guardrailStop?: GuardrailStop; } type ToolBatchSettlement = { suspended: false } | { suspended: true; result: T }; @@ -724,6 +727,10 @@ export class AgentRuntime { for (; state.iterationCount < state.maxIterations; state.iterationCount++) { const hasInput = await this.consumeAdditionalInput(prepared, sink, state); + // A guardrail stop is terminal. The boundary above fired with + // canContinue: false, so the consumer released queued input instead of + // consuming it. End the run even if a consumer still returned messages. + if (state.guardrailStop) break; if (state.reachedStopCondition && !hasInput) break; state.reachedStopCondition = false; const settlement = await this.runLoopIteration(prepared, sink, state); @@ -743,6 +750,7 @@ export class AgentRuntime { finishReason: state.lastFinishReason, usage: state.totalUsage, structuredOutput: state.structuredOutput, + guardrail: state.guardrailStop, }); } @@ -760,7 +768,9 @@ export class AgentRuntime { messages: sanitizeOffloadedToolResultsForMemory(ctx.list.turnDelta()), lastCreatedAt: ctx.list.messages().at(-1)?.createdAt.getTime() ?? 0, completing: state.reachedStopCondition, - canContinue: state.iterationCount < state.maxIterations, + // A guardrail stop is terminal: queued input must be released for the + // next run, not consumed into a run that makes no more model calls. + canContinue: state.iterationCount < state.maxIterations && !state.guardrailStop, }); // Only committed input enters the live list. Its IDs and timestamps stay stable. ctx.list.addInput(messages); @@ -844,6 +854,7 @@ export class AgentRuntime { persistence: ctx.options?.persistence, telemetry: ctx.runTelemetry, executionCounter: ctx.options?.executionCounter, + guardrails: ctx.options?.guardrails, approvalContext: ctx.options?.approvalContext, abortSignal: ctx.abortScope.signal, isAborted: () => ctx.abortScope.isAborted, @@ -889,6 +900,13 @@ export class AgentRuntime { this.eventBus.emit({ type: AgentEvent.TurnStart }); const { toolMap, modelCallContext } = await this.prepareModelCall(ctx); const turn = await this.callModelWithRetries(ctx, sink, state, modelCallContext); + // A guardrail refused the call. Usage from any discarded empty attempt + // is already folded in; end the run without a new model turn. + if (!turn) { + state.lastFinishReason = 'guardrail'; + state.reachedStopCondition = true; + return { suspended: false }; + } this.assertNotAborted(ctx.abortScope); for (const message of ctx.list.inputDelta()) ctx.acceptedInputIds.add(message.id); @@ -975,14 +993,30 @@ export class AgentRuntime { sink: RunOutputSink, state: LoopState, modelCallContext: ModelCallContext, - ): Promise { - let turn = await sink.callModel(modelCallContext); + ): Promise { + const guardrails = GuardrailRunner.from(ctx.options?.guardrails); + const callModel = async (): Promise => { + const guardCtx = guardrails?.modelCallContext('turn', this.modelIdString); + if (guardrails && guardCtx) { + const stop = await guardrails.before(guardCtx); + if (stop) { + state.guardrailStop = stop; + return undefined; + } + } + const turn = await sink.callModel(modelCallContext); + if (guardrails && guardCtx) await guardrails.after(guardCtx, turn.usage); + return turn; + }; + + let turn = await callModel(); // Retry empty stop turns. Charge each attempt before checking for cancellation. - for (let retry = 0; retry < MAX_EMPTY_TURN_RETRIES && isEmptyModelTurn(turn); retry++) { + for (let retry = 0; turn && retry < MAX_EMPTY_TURN_RETRIES && isEmptyModelTurn(turn); retry++) { this.recordTurnUsage(ctx, sink, state, turn); this.assertNotAborted(ctx.abortScope); - turn = await sink.callModel(modelCallContext); + turn = await callModel(); } + if (!turn) return undefined; this.recordTurnUsage(ctx, sink, state, turn); return turn; } diff --git a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts index e27b4828d43..838f57b8215 100644 --- a/packages/@n8n/agents/src/runtime/loop/generate-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/generate-sink.ts @@ -102,6 +102,7 @@ export class GenerateSink implements RunOutputSink { ...(structuredOutput !== undefined && { structuredOutput }), ...(this.toolCallSummary.length > 0 && { toolCalls: this.toolCallSummary }), getState: () => this.services.getState(), + ...(emission.guardrail && { guardrail: emission.guardrail }), }; } } diff --git a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts index ffef0313b46..38a2daafeca 100644 --- a/packages/@n8n/agents/src/runtime/loop/stream-sink.ts +++ b/packages/@n8n/agents/src/runtime/loop/stream-sink.ts @@ -471,6 +471,7 @@ export class StreamSink implements RunOutputSink { ...(costUsage && { usage: costUsage }), model: this.services.modelId, ...(structuredOutput !== undefined && { structuredOutput }), + ...(emission.guardrail && { guardrail: emission.guardrail }), }); this.services.updateState({ status: 'success', messageList: list.serialize() }); this.services.emitAgentEnd(list.responseDelta()); diff --git a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts index 00c575d19b4..6acc9ad220f 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-executor.ts @@ -200,6 +200,7 @@ export class ToolCallExecutor { persistence: ctx.persistence, resolvedTelemetry: ctx.telemetry, executionCounter: ctx.executionCounter, + guardrails: ctx.guardrails, approvalContext: ctx.approvalContext, abortSignal: ctx.abortSignal, countToolCall: true, @@ -467,6 +468,7 @@ export class ToolCallExecutor { persistence: ctx.persistence, telemetry: ctx.telemetry, executionCounter: ctx.executionCounter, + guardrails: ctx.guardrails, approvalContext: ctx.approvalContext, abortSignal: ctx.abortSignal, isAborted: ctx.isAborted, @@ -523,9 +525,11 @@ export class ToolCallExecutor { resumeData, resolvedTelemetry: ctx.telemetry, executionCounter: ctx.executionCounter, + guardrails: ctx.guardrails, approvalContext: ctx.approvalContext, abortSignal: ctx.abortSignal, countToolCall: false, + previouslySuspended: entry.suspended, ...(entry.suspended ? { suspendPayload: entry.suspendPayload, 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 0a698f929b2..640efbe86c6 100644 --- a/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts +++ b/packages/@n8n/agents/src/runtime/tools/tool-call-runner.ts @@ -27,6 +27,7 @@ import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/mes import type { JSONValue } from '../../types/utils/json'; import { parseWithSchema } from '../../utils/parse'; import { isZodSchema } from '../../utils/zod'; +import { GuardrailRunner } from '../guardrails/guardrail-runner'; import { incrementToolCallCount } from '../loop/execution-counter'; import { stringifyError } from '../loop/runtime-helpers'; import type { AgentMessageList } from '../model/message-list'; @@ -124,6 +125,25 @@ export class ToolCallRunner { if (!validation.ok) return validation.outcome; const input = validation.input; + const guardrails = GuardrailRunner.from(params.guardrails); + const guardCtx = guardrails?.toolCallContext({ + toolCallId, + toolName, + input, + runId: params.runId, + }); + // Skip only a call that already suspended. An unexecuted pending call + // is still a first execution, even when resume data is present. + if (guardrails && guardCtx && !params.previouslySuspended) { + const stop = await guardrails.beforeTool(guardCtx); + if (stop) { + return await this.toolError( + params, + new Error(`Tool call stopped by guardrail: ${stop.code}`), + ); + } + } + if (shouldEmitToolExecutionStart(builtTool, resumeData)) { this.eventBus.emit({ type: AgentEvent.ToolExecutionStart, @@ -133,13 +153,15 @@ export class ToolCallRunner { }); } - return await this.executeValidatedToolCall(params, builtTool, input); + return await this.executeValidatedToolCall(params, builtTool, input, guardrails, guardCtx); } private async executeValidatedToolCall( params: ProcessToolCallParams, builtTool: BuiltTool, input: JSONValue, + guardrails: GuardrailRunner | undefined, + guardCtx: ReturnType | undefined, ): Promise { let toolResult: unknown; const suspension: InterruptedToolSuspension = { @@ -169,6 +191,9 @@ export class ToolCallRunner { if (isSuspendedToolResult(toolResult)) { return await this.buildSuspendedOutcome(params, builtTool, toolResult); } + + // Final result only: a call that suspended reports once, after resume. + if (guardrails && guardCtx) await guardrails.afterTool(guardCtx, toolResult); return await this.buildSuccessOutcome(params, builtTool, input, toolResult); } diff --git a/packages/@n8n/agents/src/types/index.ts b/packages/@n8n/agents/src/types/index.ts index a587faa4070..eb5c5b9a019 100644 --- a/packages/@n8n/agents/src/types/index.ts +++ b/packages/@n8n/agents/src/types/index.ts @@ -210,3 +210,13 @@ export type { ResolvedCredential, CredentialListItem, } from './sdk/credential-provider'; + +export type { + GuardrailsOptions, + ModelGuardrail, + GuardrailModelCallSource, + GuardrailModelCallContext, + GuardrailToolCallContext, + GuardrailDecision, + GuardrailStop, +} from './sdk/guardrail'; diff --git a/packages/@n8n/agents/src/types/runtime/agent-loop.ts b/packages/@n8n/agents/src/types/runtime/agent-loop.ts index 4d28eadc57b..bd03dd5ca20 100644 --- a/packages/@n8n/agents/src/types/runtime/agent-loop.ts +++ b/packages/@n8n/agents/src/types/runtime/agent-loop.ts @@ -10,6 +10,7 @@ import type { } from 'ai'; import type { FinishReason, SerializableAgentState, TokenUsage } from '../index'; +import type { GuardrailStop } from '../sdk/guardrail'; import type { ExecutionOptions, RunOptions } from '../sdk/agent'; import type { AgentDbMessage, AgentMessage } from '../sdk/message'; import type { JSONObject } from '../utils/json'; @@ -87,6 +88,7 @@ export interface CompleteEmission { finishReason: FinishReason; usage: TokenUsage | undefined; structuredOutput: unknown; + guardrail?: GuardrailStop; } /** diff --git a/packages/@n8n/agents/src/types/runtime/tool-execution.ts b/packages/@n8n/agents/src/types/runtime/tool-execution.ts index 4990b4ee649..a506713d60a 100644 --- a/packages/@n8n/agents/src/types/runtime/tool-execution.ts +++ b/packages/@n8n/agents/src/types/runtime/tool-execution.ts @@ -10,6 +10,7 @@ import type { AgentExecutionCounter, BuiltTelemetry, BuiltTool, + GuardrailsOptions, PendingToolCall, ToolSuspendOptions, ToolApprovalContext, @@ -104,6 +105,7 @@ export interface ToolBatchContext { persistence?: AgentPersistenceOptions; telemetry?: BuiltTelemetry; executionCounter?: AgentExecutionCounter; + guardrails?: GuardrailsOptions; approvalContext?: ToolApprovalContext; abortSignal: AbortSignal; isAborted: () => boolean; @@ -122,10 +124,13 @@ export interface ProcessToolCallParams extends ToolCallIdentity { resumeData?: unknown; resolvedTelemetry?: BuiltTelemetry; executionCounter?: AgentExecutionCounter; + guardrails?: GuardrailsOptions; approvalContext?: ToolApprovalContext; abortSignal?: AbortSignal; /** Whether this counts as a new tool-call invocation. Default `true`; `false` on resume. */ countToolCall?: boolean; + /** The call already suspended, so `beforeTool` already ran. */ + previouslySuspended?: boolean; /** Checkpointed suspend payload of the tool call being resumed. */ suspendPayload?: unknown; /** Checkpointed private continuation of the tool call being resumed. */ diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts index 52c01c0c423..04047018a01 100644 --- a/packages/@n8n/agents/src/types/sdk/agent.ts +++ b/packages/@n8n/agents/src/types/sdk/agent.ts @@ -20,12 +20,13 @@ import type { import type { SerializedMessageList } from '../runtime/message-list'; import type { BuiltTelemetry } from '../telemetry'; import type { JSONObject, JSONValue } from '../utils/json'; - +import type { GuardrailsOptions, GuardrailStop } from './guardrail'; export type SmoothStreamOptions = NonNullable[0]>; export const FINISH_REASONS = [ 'stop', 'max-iterations', + 'guardrail', 'length', 'content-filter', 'tool-calls', @@ -162,6 +163,7 @@ export type StreamChunk = ContentMetadata & usage?: TokenUsage; model?: string; structuredOutput?: unknown; + guardrail?: GuardrailStop; } | { type: 'error'; error: unknown } | { @@ -215,6 +217,7 @@ export interface AgentInputBoundary { messages: AgentDbMessage[]; lastCreatedAt: number; completing: boolean; + /** False when the run cannot accept more input: max iterations reached, or a terminal stop such as a guardrail refusal. */ canContinue: boolean; } @@ -281,6 +284,7 @@ export interface ExecutionOptions { * persistence-backed CheckpointStore; recover via `crashResume()`. */ stepCheckpoints?: boolean; + guardrails?: GuardrailsOptions; } export interface PersistedExecutionOptions { @@ -341,6 +345,7 @@ export interface GenerateResult { /** The model ID used for this generation (e.g. 'anthropic/claude-haiku-4-5'). */ model?: string; finishReason?: FinishReason; + guardrail?: GuardrailStop; providerMetadata?: Record; /** Tool calls made during the run (with merged results when available). */ toolCalls?: ToolResultEntry[]; diff --git a/packages/@n8n/agents/src/types/sdk/guardrail.ts b/packages/@n8n/agents/src/types/sdk/guardrail.ts index 84eab32bbd6..ecf349ffff8 100644 --- a/packages/@n8n/agents/src/types/sdk/guardrail.ts +++ b/packages/@n8n/agents/src/types/sdk/guardrail.ts @@ -1,3 +1,5 @@ +import type { TokenUsage } from './agent'; + export type GuardrailType = 'pii' | 'prompt-injection' | 'moderation' | 'custom'; export type GuardrailStrategy = 'block' | 'redact' | 'warn'; /** Re-exported so the detection vocabulary has one definition (see `@n8n/utils`). */ @@ -9,3 +11,44 @@ export interface BuiltGuardrail { readonly strategy: 'block' | 'redact' | 'warn'; /** @internal */ readonly _config: Record; } + +export type GuardrailDecision = { action: 'allow' } | { action: 'stop'; code: string }; + +export type GuardrailStop = { code: string }; +export type GuardrailModelCallSource = 'turn' | 'title' | 'memory'; + +export interface GuardrailModelCallContext { + /** New id for every attempt, including an empty-turn retry. */ + callId: string; + agentId?: string; + threadId?: string; + model: string; + source: GuardrailModelCallSource; +} + +export interface GuardrailToolCallContext { + toolCallId: string; + toolName: string; + input: unknown; + runId: string; + agentId?: string; + threadId?: string; +} + +/** + * Hooks around model and tool calls. A `before*` hook returns `undefined` or + * `allow` to continue, or `stop` to refuse the call. + */ +export interface ModelGuardrail { + before?(ctx: GuardrailModelCallContext): Promise; + after?(ctx: GuardrailModelCallContext, usage: TokenUsage | undefined): Promise; + beforeTool?(ctx: GuardrailToolCallContext): Promise; + afterTool?(ctx: GuardrailToolCallContext, result: unknown): Promise; +} + +/** Host-supplied on `ExecutionOptions`. The SDK never looks up settings. */ +export interface GuardrailsOptions { + hooks: ModelGuardrail[]; + agentId?: string; + threadId?: string; +} diff --git a/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts b/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts index 4d27b32c6f4..f7a13f18786 100644 --- a/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts +++ b/packages/@n8n/instance-ai/evaluations/discovery/stream-status.ts @@ -25,6 +25,7 @@ function statusForFinishReason(finishReason: FinishReason): DiscoveryStreamStatu case 'length': case 'content-filter': case 'error': + case 'guardrail': case 'other': return 'errored'; default: {