feat(core): Add a guardrail runner to the agent loop (#39621)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anne Aguirre 2026-10-01 10:21:42 +00:00 committed by GitHub
parent 5b499d512f
commit 3745c835b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1597 additions and 8 deletions

View File

@ -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
}
]

View File

@ -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
}
]

View File

@ -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
}
]

View File

@ -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
}
]

View File

@ -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 });
});
});

View File

@ -6,6 +6,13 @@ export type {
BuiltEpisodicMemoryCaptureStore,
BuiltEpisodicMemoryStore,
BuiltGuardrail,
GuardrailDecision,
GuardrailModelCallContext,
GuardrailModelCallSource,
GuardrailStop,
GuardrailToolCallContext,
GuardrailsOptions,
ModelGuardrail,
PiiDetectionType,
BuiltEval,
RunOptions,

View File

@ -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<ModelGuardrail>]: MockedFunction<NonNullable<ModelGuardrail[K]>>;
};
/** A hook whose every method is a spy. `before`/`beforeTool` allow unless overridden. */
function makeGuardrail(overrides: Partial<GuardrailSpies> = {}): GuardrailSpies {
return {
before: vi.fn<NonNullable<ModelGuardrail['before']>>().mockResolvedValue({ action: 'allow' }),
after: vi.fn<NonNullable<ModelGuardrail['after']>>().mockResolvedValue(undefined),
beforeTool: vi
.fn<NonNullable<ModelGuardrail['beforeTool']>>()
.mockResolvedValue({ action: 'allow' }),
afterTool: vi.fn<NonNullable<ModelGuardrail['afterTool']>>().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<unknown>) {
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<NonNullable<ModelGuardrail['before']>>()
.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();

View File

@ -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<ModelGuardrail> = {}) {
return {
before: vi.fn<NonNullable<ModelGuardrail['before']>>().mockResolvedValue(allow),
after: vi.fn<NonNullable<ModelGuardrail['after']>>().mockResolvedValue(undefined),
beforeTool: vi.fn<NonNullable<ModelGuardrail['beforeTool']>>().mockResolvedValue(allow),
afterTool: vi.fn<NonNullable<ModelGuardrail['afterTool']>>().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);
});
});
});

View File

@ -0,0 +1,73 @@
import type {
GuardrailModelCallContext,
GuardrailModelCallSource,
GuardrailStop,
GuardrailToolCallContext,
GuardrailsOptions,
ModelGuardrail,
TokenUsage,
} from '../../types';
type GuardrailIds = Pick<GuardrailsOptions, 'agentId' | 'threadId'>;
/**
* 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<GuardrailStop | undefined> {
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<void> {
for (const hook of this.hooks) {
await hook.after?.(ctx, usage);
}
}
async beforeTool(ctx: GuardrailToolCallContext): Promise<GuardrailStop | undefined> {
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<void> {
for (const hook of this.hooks) {
await hook.afterTool?.(ctx, result);
}
}
}

View File

@ -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<T> = { 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<T>,
state: LoopState,
modelCallContext: ModelCallContext,
): Promise<ModelTurnResult> {
let turn = await sink.callModel(modelCallContext);
): Promise<ModelTurnResult | undefined> {
const guardrails = GuardrailRunner.from(ctx.options?.guardrails);
const callModel = async (): Promise<ModelTurnResult | undefined> => {
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;
}

View File

@ -102,6 +102,7 @@ export class GenerateSink implements RunOutputSink<GenerateResult> {
...(structuredOutput !== undefined && { structuredOutput }),
...(this.toolCallSummary.length > 0 && { toolCalls: this.toolCallSummary }),
getState: () => this.services.getState(),
...(emission.guardrail && { guardrail: emission.guardrail }),
};
}
}

View File

@ -471,6 +471,7 @@ export class StreamSink implements RunOutputSink<void> {
...(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());

View File

@ -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,

View File

@ -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<GuardrailRunner['toolCallContext']> | undefined,
): Promise<ToolCallOutcome> {
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);
}

View File

@ -210,3 +210,13 @@ export type {
ResolvedCredential,
CredentialListItem,
} from './sdk/credential-provider';
export type {
GuardrailsOptions,
ModelGuardrail,
GuardrailModelCallSource,
GuardrailModelCallContext,
GuardrailToolCallContext,
GuardrailDecision,
GuardrailStop,
} from './sdk/guardrail';

View File

@ -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;
}
/**

View File

@ -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. */

View File

@ -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<Parameters<typeof smoothStream>[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<string, unknown>;
/** Tool calls made during the run (with merged results when available). */
toolCalls?: ToolResultEntry[];

View File

@ -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<string, unknown>;
}
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<GuardrailDecision | undefined>;
after?(ctx: GuardrailModelCallContext, usage: TokenUsage | undefined): Promise<void>;
beforeTool?(ctx: GuardrailToolCallContext): Promise<GuardrailDecision | undefined>;
afterTool?(ctx: GuardrailToolCallContext, result: unknown): Promise<void>;
}
/** Host-supplied on `ExecutionOptions`. The SDK never looks up settings. */
export interface GuardrailsOptions {
hooks: ModelGuardrail[];
agentId?: string;
threadId?: string;
}

View File

@ -25,6 +25,7 @@ function statusForFinishReason(finishReason: FinishReason): DiscoveryStreamStatu
case 'length':
case 'content-filter':
case 'error':
case 'guardrail':
case 'other':
return 'errored';
default: {