feat(core): Add writable workspaces to agents (#36033)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann 2026-08-12 14:24:33 +02:00 committed by GitHub
parent 64dcebb649
commit 01ce4490d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 4046 additions and 1404 deletions

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_016dfmhE8qzLins851cV3H8y\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01SfNPaEFaLMtWjQ5VwTscF3\",\"name\":\"workspace_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"old_str\":\"OLD_REGION\",\"new_str\":\"EU_CENTRAL\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_015nqvmWf68HbECwWegWz8sc\",\"name\":\"workspace_batch_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"replacements\":[{\"old_str\":\"OLD_OWNER\",\"new_str\":\"API_TEAM\"},{\"old_str\":\"OLD_STATUS\",\"new_str\":\"READY\"}]},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2695,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":235,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvps9x1PZJTaRXwscm4i\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll perform these exact workspace edits as requested.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01VvQRTAXwEHkyi3p8ED8FKa\",\"name\":\"workspace_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"old_str\":\"OLD_REGION\",\"new_str\":\"EU_CENTRAL\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01Q7r7gq1UMemkioXZb3K4uu\",\"name\":\"workspace_batch_str_replace_file\",\"input\":{\"path\":\"/project/config.ts\",\"replacements\":[{\"old_str\":\"OLD_OWNER\",\"new_str\":\"API_TEAM\"},{\"old_str\":\"OLD_STATUS\",\"new_str\":\"READY\"}]},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3098,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":246,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:40Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:41Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:01Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:40Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:40Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa52091b9cca-VIE",
"cf-ray": "a296a6eeebf025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:02 GMT",
"request-id": "req_011CceuzsFwTUirDKjA1WcAd",
"date": "Tue, 11 Aug 2026 10:45:41 GMT",
"request-id": "req_011Cdvps9agnsjmw9EVXdHsA",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-e40b64c96d7fb552737d103ae83cada7-5a55d8fabfc4d941-01",
"traceresponse": "00-bed2fea9fc1bc58cca3b89eb8b71fed6-ab0980a9e1c845ec-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file editor. Use workspace_str_replace_file for single exact replacements. Use workspace_batch_str_replace_file for batch exact replacements. Do not rewrite the whole file.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -411,9 +453,13 @@
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll perform these exact workspace edits as requested."
},
{
"type": "tool_use",
"id": "toolu_01SfNPaEFaLMtWjQ5VwTscF3",
"id": "toolu_01VvQRTAXwEHkyi3p8ED8FKa",
"name": "workspace_str_replace_file",
"input": {
"path": "/project/config.ts",
@ -426,7 +472,7 @@
},
{
"type": "tool_use",
"id": "toolu_015nqvmWf68HbECwWegWz8sc",
"id": "toolu_01Q7r7gq1UMemkioXZb3K4uu",
"name": "workspace_batch_str_replace_file",
"input": {
"path": "/project/config.ts",
@ -452,12 +498,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01SfNPaEFaLMtWjQ5VwTscF3",
"tool_use_id": "toolu_01VvQRTAXwEHkyi3p8ED8FKa",
"content": "{\"success\":true,\"result\":\"Edit applied successfully.\"}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_015nqvmWf68HbECwWegWz8sc",
"tool_use_id": "toolu_01Q7r7gq1UMemkioXZb3K4uu",
"content": "{\"success\":true,\"result\":\"All 2 replacements applied successfully.\"}"
}
]
@ -485,6 +531,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -796,30 +884,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01T4otvEXmVvxBiiMoLzNgeq\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3010,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsH39vxMjMiW9skyNg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3424,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:02Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:42Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa5ccad49cca-VIE",
"cf-ray": "a296a6f92c2525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:03 GMT",
"request-id": "req_011CceuzzcTFdAF5b7BKHiNL",
"date": "Tue, 11 Aug 2026 10:45:42 GMT",
"request-id": "req_011CdvpsGcMfay6mqpRFwN7K",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-55b4e50bd84506d145e265639d6be8a7-6aca1a5a81b91f13-01",
"traceresponse": "00-27597d9f81748b032913a1498e1fe1ee-10723a592821a25d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01EKdY3sCJpJ34NFfzbFxszT\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01MEW257FPx9fz1MW3C7GPKU\",\"name\":\"workspace_append_file\",\"input\":{\"path\":\"/project/source.txt\",\"content\":\"-beta\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2717,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":79,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprZfFZSt3spHTvny9k\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_014ZKsAn1jZg6LSG2MdNRwh4\",\"name\":\"workspace_append_file\",\"input\":{\"path\":\"/project/source.txt\",\"content\":\"-beta\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3120,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":79,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:32Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:54Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:33Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa262f169cca-VIE",
"cf-ray": "a296a6bc8ec625c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:55 GMT",
"request-id": "req_011CceuzMFQJ91oP93iiVNsZ",
"date": "Tue, 11 Aug 2026 10:45:33 GMT",
"request-id": "req_011CdvprZBEULGuPqvU8KbgA",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-6ddd3c82dc3a4340fb8cd8b54da73a9d-509c284e82389845-01",
"traceresponse": "00-136d85c3f51da67a3786c288cafc89ed-d0f8d85336bd22bd-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -430,7 +472,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -458,6 +500,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -769,30 +853,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_013W8BnSB4sccwn7TkTJFX4v\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 2:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01AAGAAT3ZyuToHCpTvw1UY4\",\"name\":\"workspace_copy_file\",\"input\":{\"src\":\"/project/source.txt\",\"dest\":\"/project/copied.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2812,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":106,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprgAfpsbZ2N5TiZ88g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 2:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01H1FQ8SRbigEQtxc8fwQpBm\",\"name\":\"workspace_copy_file\",\"input\":{\"src\":\"/project/source.txt\",\"dest\":\"/project/copied.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3215,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":107,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:34Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:34Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:33Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:55Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:34Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa2ccfb29cca-VIE",
"cf-ray": "a296a6c3d88525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:56 GMT",
"request-id": "req_011CceuzRqUqf6kW8XcixnnJ",
"date": "Tue, 11 Aug 2026 10:45:35 GMT",
"request-id": "req_011CdvprevFw1U1jm1thzx9r",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-4608436a24e376fdf5d5503f1da1299d-2742fcc997418ac3-01",
"traceresponse": "00-4b7fab311d2b4039d16df4b27528c21d-800ed256314c13f8-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -808,7 +892,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -826,7 +910,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -843,7 +927,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -853,11 +937,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -875,7 +959,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -903,6 +987,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1214,30 +1340,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_019YqkNADyLzcpp7qVRppJaJ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 3:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01L6PpMLB7XoKWWzDvtgpDZw\",\"name\":\"workspace_move_file\",\"input\":{\"src\":\"/project/copied.txt\",\"dest\":\"/project/moved.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2934,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":106,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprmgK3DiBfXzNuQYMn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 3:\"},{\"type\":\"tool_use\",\"id\":\"toolu_019tbVwDcpgXv5Zbti1PwRuv\",\"name\":\"workspace_move_file\",\"input\":{\"src\":\"/project/copied.txt\",\"dest\":\"/project/moved.txt\",\"overwrite\":true},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3338,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":107,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:35Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:57Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:36Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:35Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:56Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:35Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa34ac929cca-VIE",
"cf-ray": "a296a6ce9caf25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:57 GMT",
"request-id": "req_011CceuzXTZjDSZG3o9ygFy5",
"date": "Tue, 11 Aug 2026 10:45:36 GMT",
"request-id": "req_011CdvprmMDAJzCZsQ5M8Kyb",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=1439",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-e029a29f91fdc77fc51f6da7dbf0e249-a0ae69375f60dda5-01",
"traceresponse": "00-197eacff06bc1829a0d46b9e69ea2e14-e3bd7495e1169f5e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -1253,7 +1380,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -1271,7 +1398,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -1288,7 +1415,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -1298,11 +1425,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -1320,7 +1447,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -1330,11 +1457,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -1352,7 +1479,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -1380,6 +1507,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1691,30 +1860,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01QPZuSmpo52mP7eSuwVBYs2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 4:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01CSgw7v1W3Pk5TGwDgo227J\",\"name\":\"workspace_delete_file\",\"input\":{\"path\":\"/project/source.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3056,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":66,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpru82f34NT1senXnve\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 4:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01F118HcAu54KDWaxwU7ey1g\",\"name\":\"workspace_delete_file\",\"input\":{\"path\":\"/project/source.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3461,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":67,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:37Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:37Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:57Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:36Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:58Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:37Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa3c98859cca-VIE",
"cf-ray": "a296a6d8fe9c25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:58 GMT",
"request-id": "req_011Cceuzcja5wK24kE1VzCa9",
"date": "Tue, 11 Aug 2026 10:45:37 GMT",
"request-id": "req_011CdvprtZ3s4XurCaY3tGmG",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-67429a2bbcf8573821fecee3ac204783-4f1598ea01cf69b5-01",
"traceresponse": "00-f2544e4e958d13bc28089d62fa90b971-25fd6f2b5753ac5d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -1730,7 +1899,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -1748,7 +1917,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -1765,7 +1934,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -1775,11 +1944,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -1797,7 +1966,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -1807,11 +1976,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -1829,7 +1998,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -1839,11 +2008,11 @@
"content": [
{
"type": "text",
"text": "Then call 4:"
"text": "Now calling step 4:"
},
{
"type": "tool_use",
"id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"name": "workspace_delete_file",
"input": {
"path": "/project/source.txt"
@ -1859,7 +2028,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"tool_use_id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"content": "{\"success\":true}"
}
]
@ -1887,6 +2056,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -2198,30 +2409,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01BKvd9PLDS1vtttJ6YWL5Br\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Then call 5:\"},{\"type\":\"tool_use\",\"id\":\"toolu_019j3t7qZMGdn3MysWxpmGAT\",\"name\":\"workspace_rmdir\",\"input\":{\"path\":\"/project/remove-me\",\"recursive\":true,\"force\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3138,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":99,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpryic3Grraepf53xbo\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now calling step 5:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01XEifn4vNpCAjuMTJi6Cqy7\",\"name\":\"workspace_rmdir\",\"input\":{\"path\":\"/project/remove-me\",\"recursive\":true,\"force\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3544,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":100,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:38Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:59Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:38Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa43d8699cca-VIE",
"cf-ray": "a296a6dee9c425c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:59 GMT",
"request-id": "req_011CceuzhY3ehpVEwNd5qCJt",
"date": "Tue, 11 Aug 2026 10:45:38 GMT",
"request-id": "req_011CdvprxdNRpWtgeiNgkt1p",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-591183da0fbe2f5ae52974f8a4befdde-773aca87367d033e-01",
"traceresponse": "00-232cb0cb9bc94bb5aafa3f715d9ad7b7-ad93f6789eb6e1b4-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -2237,7 +2448,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use the exact workspace tools requested by the user. Do not substitute other workspace tools when exact tools are named. Be concise after the tools complete.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -2255,7 +2466,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"name": "workspace_append_file",
"input": {
"path": "/project/source.txt",
@ -2272,7 +2483,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MEW257FPx9fz1MW3C7GPKU",
"tool_use_id": "toolu_014ZKsAn1jZg6LSG2MdNRwh4",
"content": "{\"success\":true}"
}
]
@ -2282,11 +2493,11 @@
"content": [
{
"type": "text",
"text": "Then call 2:"
"text": "Now calling step 2:"
},
{
"type": "tool_use",
"id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"name": "workspace_copy_file",
"input": {
"src": "/project/source.txt",
@ -2304,7 +2515,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01AAGAAT3ZyuToHCpTvw1UY4",
"tool_use_id": "toolu_01H1FQ8SRbigEQtxc8fwQpBm",
"content": "{\"success\":true}"
}
]
@ -2314,11 +2525,11 @@
"content": [
{
"type": "text",
"text": "Then call 3:"
"text": "Now calling step 3:"
},
{
"type": "tool_use",
"id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"name": "workspace_move_file",
"input": {
"src": "/project/copied.txt",
@ -2336,7 +2547,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01L6PpMLB7XoKWWzDvtgpDZw",
"tool_use_id": "toolu_019tbVwDcpgXv5Zbti1PwRuv",
"content": "{\"success\":true}"
}
]
@ -2346,11 +2557,11 @@
"content": [
{
"type": "text",
"text": "Then call 4:"
"text": "Now calling step 4:"
},
{
"type": "tool_use",
"id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"name": "workspace_delete_file",
"input": {
"path": "/project/source.txt"
@ -2366,7 +2577,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01CSgw7v1W3Pk5TGwDgo227J",
"tool_use_id": "toolu_01F118HcAu54KDWaxwU7ey1g",
"content": "{\"success\":true}"
}
]
@ -2376,11 +2587,11 @@
"content": [
{
"type": "text",
"text": "Then call 5:"
"text": "Now calling step 5:"
},
{
"type": "tool_use",
"id": "toolu_019j3t7qZMGdn3MysWxpmGAT",
"id": "toolu_01XEifn4vNpCAjuMTJi6Cqy7",
"name": "workspace_rmdir",
"input": {
"path": "/project/remove-me",
@ -2398,7 +2609,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_019j3t7qZMGdn3MysWxpmGAT",
"tool_use_id": "toolu_01XEifn4vNpCAjuMTJi6Cqy7",
"content": "{\"success\":true}"
}
]
@ -2426,6 +2637,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -2737,30 +2990,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Y4brQawzXvWux5QWcgu9JU\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3253,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvps5AH9YtHeFbtoE98E\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Done.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3660,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:01Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:39Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:00Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:39Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa4a0f449cca-VIE",
"cf-ray": "a296a6e7b90a25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:01 GMT",
"request-id": "req_011Cceuzmnn1Zw4Vj9JYxguv",
"date": "Tue, 11 Aug 2026 10:45:39 GMT",
"request-id": "req_011Cdvps4hVmVjpscyMHhGfD",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-2256b07748acafdb04818e29c981f466-220e5914e62a5fef-01",
"traceresponse": "00-b6189b5d9d07cb78db54eae7f3370d90-d356c8a0edfea2fb-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01V3z9YdWdjUannYcTqKh9fG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll help you with these tasks. Let me execute them step by step.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01Bu1fxpsZxRB4HPSAEyyMh4\",\"name\":\"workspace_mkdir\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_016muGHEPHrgqWnNcrQRnJ7z\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/app/config.ts\",\"content\":\"export default {}\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2628,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsLbiNCvjQ5t8uuoT8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll help you with these tasks. Let me execute them step by step.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01PssxbtZdNs3YBHtifmF5hN\",\"name\":\"workspace_mkdir\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01WPf5Fdv3L8D738QFTgZgnb\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/app/config.ts\",\"content\":\"export default {}\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3031,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:43Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:43Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:03Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:42Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:04Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:43Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa62887d9cca-VIE",
"cf-ray": "a296a6fe6e7725c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:05 GMT",
"request-id": "req_011Ccev14YMCHAwNXGdLawou",
"date": "Tue, 11 Aug 2026 10:45:43 GMT",
"request-id": "req_011CdvpsLAAyvpGkp3LhU5JW",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5fbd3b07fe1d8a3e7bf502ec9093abcb-9deb31c7ba75cb08-01",
"traceresponse": "00-ea9a7c39ba842d38b3030987afc44193-44b95e84dd813e87-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -417,7 +459,7 @@
},
{
"type": "tool_use",
"id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"name": "workspace_mkdir",
"input": {
"path": "/app"
@ -428,7 +470,7 @@
},
{
"type": "tool_use",
"id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"name": "workspace_write_file",
"input": {
"path": "/app/config.ts",
@ -445,12 +487,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"tool_use_id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"tool_use_id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"content": "{\"success\":true}"
}
]
@ -478,6 +520,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -789,30 +873,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01XgerQjXzKnpycGPo4TSxR6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me list the files and read the config file:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01VaD1z3Nj56KZkfnoCQYEFr\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_017ddge4okwcWsUKeHmUWh5J\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/app/config.ts\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2834,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":112,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsRTuotAB1J7wmHouH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me list the files and read the contents:\"},{\"type\":\"tool_use\",\"id\":\"toolu_01PDqhojRM2sUwN2BA12fD6k\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/app\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01VsSW68Un1y5Z4WSKgC98TW\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/app/config.ts\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3237,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":111,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:06Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:44Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:05Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:44Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa6adc519cca-VIE",
"cf-ray": "a296a70608e025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:06 GMT",
"request-id": "req_011Ccev1AEtxSx7jNJn5dpat",
"date": "Tue, 11 Aug 2026 10:45:45 GMT",
"request-id": "req_011CdvpsRAoY9wE2wJUA47KM",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=890",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-94b954f1851f06119a96cf4643ea2e3e-6bd3a9e17e35680a-01",
"traceresponse": "00-a65659e5bffea15b715f4da3117c061a-40ee649bcd94476f-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -828,7 +913,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Follow the user's instructions step by step using workspace tools. Available: workspace_mkdir, workspace_write_file, workspace_list_files, workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -850,7 +935,7 @@
},
{
"type": "tool_use",
"id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"name": "workspace_mkdir",
"input": {
"path": "/app"
@ -861,7 +946,7 @@
},
{
"type": "tool_use",
"id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"name": "workspace_write_file",
"input": {
"path": "/app/config.ts",
@ -878,12 +963,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Bu1fxpsZxRB4HPSAEyyMh4",
"tool_use_id": "toolu_01PssxbtZdNs3YBHtifmF5hN",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_016muGHEPHrgqWnNcrQRnJ7z",
"tool_use_id": "toolu_01WPf5Fdv3L8D738QFTgZgnb",
"content": "{\"success\":true}"
}
]
@ -893,11 +978,11 @@
"content": [
{
"type": "text",
"text": "Now let me list the files and read the config file:"
"text": "Now let me list the files and read the contents:"
},
{
"type": "tool_use",
"id": "toolu_01VaD1z3Nj56KZkfnoCQYEFr",
"id": "toolu_01PDqhojRM2sUwN2BA12fD6k",
"name": "workspace_list_files",
"input": {
"path": "/app"
@ -908,7 +993,7 @@
},
{
"type": "tool_use",
"id": "toolu_017ddge4okwcWsUKeHmUWh5J",
"id": "toolu_01VsSW68Un1y5Z4WSKgC98TW",
"name": "workspace_read_file",
"input": {
"path": "/app/config.ts"
@ -924,12 +1009,12 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01VaD1z3Nj56KZkfnoCQYEFr",
"tool_use_id": "toolu_01PDqhojRM2sUwN2BA12fD6k",
"content": "{\"entries\":[{\"name\":\"config.ts\",\"type\":\"file\"}]}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_017ddge4okwcWsUKeHmUWh5J",
"tool_use_id": "toolu_01VsSW68Un1y5Z4WSKgC98TW",
"content": "{\"content\":\"export default {}\"}"
}
]
@ -957,6 +1042,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -1268,30 +1395,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Ai4Pb2PC3dfF9kwaUnrPid\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! Here's a summary:\\n\\n1. ✅ Created directory `/app`\\n2. ✅ Wrote to `/app/config.ts`\\n3. ✅ Files in `/app`: `config.ts` (file)\\n4. ✅ Contents of `/app/config.ts`: `export default {}`\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3036,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":80,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpsWL7BkYFfE8w2cjBn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! Here's a summary of what was completed:\\n\\n1. ✅ Created directory `/app`\\n2. ✅ Wrote \\\"export default {}\\\" to `/app/config.ts`\\n3. ✅ Files in `/app`: `config.ts`\\n4. ✅ Contents of `/app/config.ts`: `export default {}`\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3438,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":86,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:49:07Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:45Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:49:08Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:46Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:49:06Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:45Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:49:07Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:45Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa712cb19cca-VIE",
"cf-ray": "a296a70c7edc25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:49:08 GMT",
"request-id": "req_011Ccev1EYrTiraV6AfkNn9h",
"date": "Tue, 11 Aug 2026 10:45:46 GMT",
"request-id": "req_011CdvpsVnNHyhKrGgWZbCtt",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-9e5e0057531b057eed928bdb4ce3bbcb-b966db309639d9a1-01",
"traceresponse": "00-629d998844f7d0fbb6f65d4d907a0f5a-50e23e19f926749d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01NrCrHWQDPXXHHswb8LF95M\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_0183ghVMwEjV19cYYorGp1ys\",\"name\":\"workspace_execute_command\",\"input\":{\"command\":\"echo \\\"n8n workspace test\\\"\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2571,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":63,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqvMfnU5aJ3yCRjaLJ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01KesV1Uan4Xp39aa2E3TDYv\",\"name\":\"workspace_execute_command\",\"input\":{\"command\":\"echo \\\"n8n workspace test\\\"\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2974,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":63,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:44Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:24Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9e7ddef9cca-VIE",
"cf-ray": "a296a6863d9025c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:45 GMT",
"request-id": "req_011Cceuyd2u9H7XtcJH2sCyc",
"date": "Tue, 11 Aug 2026 10:45:24 GMT",
"request-id": "req_011CdvpquwcXhiQKiWLvMrhj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5d0b495348935d7e83760dd23dd74ba4-e5f89c7f27d4b843-01",
"traceresponse": "00-3204edbed9c00a397be309fbe2303d89-fa32de201d974130-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a shell assistant. When asked to run a command, use workspace_execute_command. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_0183ghVMwEjV19cYYorGp1ys",
"id": "toolu_01KesV1Uan4Xp39aa2E3TDYv",
"name": "workspace_execute_command",
"input": {
"command": "echo \"n8n workspace test\""
@ -429,7 +471,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_0183ghVMwEjV19cYYorGp1ys",
"tool_use_id": "toolu_01KesV1Uan4Xp39aa2E3TDYv",
"content": "{\"success\":true,\"exitCode\":0,\"stdout\":\"n8n workspace test\\n\",\"stderr\":\"\",\"executionTimeMs\":1}"
}
]
@ -457,6 +499,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -768,30 +852,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SxmnPVnQL86ZDoRmHYhwAk\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The command executed successfully! Output:\\n```\\nn8n workspace test\\n```\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2675,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqzYSS7MhPobDpcqnV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The command executed successfully! Output:\\n```\\nn8n workspace test\\n```\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3078,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:24Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:45Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:25Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9edec9b9cca-VIE",
"cf-ray": "a296a68bb85a25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:45 GMT",
"request-id": "req_011Cceuygp7MnpR9rmqJXNgv",
"date": "Tue, 11 Aug 2026 10:45:25 GMT",
"request-id": "req_011CdvpqywUKAn9aFUMLeMHP",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-f2caa093f45be43784dbbfd396bca714-f378d4930a294336-01",
"traceresponse": "00-1cd6e5a346e3490ec9abb5a203aba136-3432a2f3b1ab137e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01L688PfiC1tEQq6tJ7o1nrG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01LZe3MA7uNmpYCAR2eYNBJZ\",\"name\":\"workspace_file_stat\",\"input\":{\"path\":\"/data.json\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2575,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":59,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprQiEyfNFpgrX1ry9w\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_017yP2GghaPPLJqn9fioyzt7\",\"name\":\"workspace_file_stat\",\"input\":{\"path\":\"/data.json\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2978,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":59,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:30Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:52Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:31Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa184a909cca-VIE",
"cf-ray": "a296a6af6cf325c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:52 GMT",
"request-id": "req_011CceuzBuLAUxWFojAeUTZo",
"date": "Tue, 11 Aug 2026 10:45:31 GMT",
"request-id": "req_011CdvprQDyPsxpSSNXXnKkM",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5a3de610c4eb046e905b0d7972552f19-61b28ef29abf29e1-01",
"traceresponse": "00-8948e7d79706a0c7d896fc42a60fbe74-254d451470eaf110-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. Use workspace_file_stat to get file info. Report the file size and type. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +455,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01LZe3MA7uNmpYCAR2eYNBJZ",
"id": "toolu_017yP2GghaPPLJqn9fioyzt7",
"name": "workspace_file_stat",
"input": {
"path": "/data.json"
@ -429,7 +471,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01LZe3MA7uNmpYCAR2eYNBJZ",
"tool_use_id": "toolu_017yP2GghaPPLJqn9fioyzt7",
"content": "{\"name\":\"data.json\",\"path\":\"/data.json\",\"type\":\"file\",\"size\":29,\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"modifiedAt\":\"2026-01-01T00:00:00.000Z\"}"
}
]
@ -457,6 +499,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -768,30 +852,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01KV6ecsLbrFJK3Zpa5jjrH5\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/data.json` is a **file** with a size of **29 bytes**.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2708,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":24,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprVicbbrnqRreCzJNb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/data.json` is a **JSON file** with a size of **29 bytes**.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3111,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":25,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:32Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:31Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:53Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:31Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa1ffe169cca-VIE",
"cf-ray": "a296a6b63a2925c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:54 GMT",
"request-id": "req_011CceuzH39f3khocSTMQyZ5",
"date": "Tue, 11 Aug 2026 10:45:32 GMT",
"request-id": "req_011CdvprVFqTXKM1R9qYWTmt",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-33d5d91d179b8dd6dd6da787cb31e0e5-09b5c7a7810bd736-01",
"traceresponse": "00-da957029974f7fe2c88f56fdccabd5b7-2fc012bbeb67589a-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01EFqZ1oSL6ZccHPPmzvqUAv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01MTqZWjgtM6PbqCmcQByBhq\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/project\",\"recursive\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2598,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011Cdvpr5xNfJCdri8TnwgaW\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01EqvFgp1qoNJaJ8i6LS9r8Q\",\"name\":\"workspace_list_files\",\"input\":{\"path\":\"/project\",\"recursive\":false},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3001,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":74,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:26Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:26Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:25Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:46Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:26Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9f25dec9cca-VIE",
"cf-ray": "a296a6946bee25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:46 GMT",
"request-id": "req_011Cceuyjx817Sxxa64ZCyQN",
"date": "Tue, 11 Aug 2026 10:45:26 GMT",
"request-id": "req_011Cdvpr5SNDL6qin8xEbvZz",
"server": "cloudflare",
"server-timing": "x-originResponse;dur=785",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-1b899442916f7b18b25284aea082803d-128a81c83d6b7193-01",
"traceresponse": "00-2cab80b88020ad5d644db648376f4b01-1cb7da94faf3f85e-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +438,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. For directory listing requests, call workspace_list_files with the exact path the user provides. Be concise and list the filenames you find.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,7 +456,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01MTqZWjgtM6PbqCmcQByBhq",
"id": "toolu_01EqvFgp1qoNJaJ8i6LS9r8Q",
"name": "workspace_list_files",
"input": {
"path": "/project",
@ -430,7 +473,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01MTqZWjgtM6PbqCmcQByBhq",
"tool_use_id": "toolu_01EqvFgp1qoNJaJ8i6LS9r8Q",
"content": "{\"entries\":[{\"name\":\"index.ts\",\"type\":\"file\"},{\"name\":\"README.md\",\"type\":\"file\"}]}"
}
]
@ -458,6 +501,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -769,30 +854,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01AVUiagNUFo5J4iX6gUAJ49\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"index.ts\\nREADME.md\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2711,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":10,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvprATF9r7MvJWQVHLkH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"index.ts\\nREADME.md\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3114,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":10,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:47Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:27Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9f98f269cca-VIE",
"cf-ray": "a296a69ada9525c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:47 GMT",
"request-id": "req_011CceuypgszzZEYxjkCPto8",
"date": "Tue, 11 Aug 2026 10:45:27 GMT",
"request-id": "req_011CdvprA2CM8CZGBaTei2Dj",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-ba0fb474f7ca4ddc086107751464c890-694ed1936623aa7d-01",
"traceresponse": "00-2c1a778bceedf9c4b2bbaed5eb212fba-8fb9396ab5538a3d-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -356,30 +398,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Du5tVa5DhioiiEvWGUBcU7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll write the file first, then read it back.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01TrVd5Gx19YUnAnjbfoN3Kp\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/greeting.txt\",\"content\":\"Hello from n8n!\"},\"caller\":{\"type\":\"direct\"}},{\"type\":\"tool_use\",\"id\":\"toolu_01622M3pBYgZBPSbA8pzfp2U\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/greeting.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2603,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":133,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqarARajhQkNQPhfo8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll write the file and then read it back for you.\"},{\"type\":\"tool_use\",\"id\":\"toolu_012z9je1zy6boKjnifomrQFg\",\"name\":\"workspace_write_file\",\"input\":{\"path\":\"/greeting.txt\",\"content\":\"Hello from n8n!\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3006,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":93,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:42Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:19Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:41Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:19Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9d75b0d9cca-VIE",
"cf-ray": "a296a6697e5e25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:42 GMT",
"request-id": "req_011CceuyRLgycRNJ6vqy2Wn4",
"date": "Tue, 11 Aug 2026 10:45:20 GMT",
"request-id": "req_011CdvpqaFwXapQca6aRhvPo",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-6efc27f89c7d4ec073ce03995671eb74-4c73623b0aaf1a22-01",
"traceresponse": "00-00713cc5710e0bbf851448f131ae18a7-1af996c79299dad1-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@ -395,7 +437,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -413,11 +455,11 @@
"content": [
{
"type": "text",
"text": "I'll write the file first, then read it back."
"text": "I'll write the file and then read it back for you."
},
{
"type": "tool_use",
"id": "toolu_01TrVd5Gx19YUnAnjbfoN3Kp",
"id": "toolu_012z9je1zy6boKjnifomrQFg",
"name": "workspace_write_file",
"input": {
"path": "/greeting.txt",
@ -426,10 +468,488 @@
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_012z9je1zy6boKjnifomrQFg",
"content": "{\"success\":true}"
}
]
}
],
"tools": [
{
"name": "workspace_read_file",
"description": "Read the contents of a file from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read"
},
"encoding": {
"type": "string",
"enum": ["utf-8", "base64"],
"description": "File encoding (default: utf-8)"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"old_str": {
"type": "string",
"description": "Exact text to replace. Must match exactly and be unique."
},
"new_str": {
"type": "string",
"description": "Replacement text to write in place of old_str."
}
},
"required": ["path", "old_str", "new_str"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_batch_str_replace_file",
"description": "Apply multiple exact text replacements to a workspace file atomically. If any replacement fails, no changes are written.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"replacements": {
"type": "array",
"items": {
"type": "object",
"properties": {
"old_str": {
"type": "string",
"description": "Exact text to replace. Must match exactly and be unique."
},
"new_str": {
"type": "string",
"description": "Replacement text to write in place of old_str."
}
},
"required": ["old_str", "new_str"],
"additionalProperties": false
},
"description": "Ordered exact string replacements applied atomically."
}
},
"required": ["path", "replacements"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_write_file",
"description": "Write content to a file in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"recursive": {
"type": "boolean",
"description": "Create parent directories if they do not exist"
}
},
"required": ["path", "content"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_list_files",
"description": "List files and directories in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list"
},
"recursive": {
"type": "boolean",
"description": "List files recursively"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_file_stat",
"description": "Get metadata about a file or directory",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file or directory"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_mkdir",
"description": "Create a directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the directory to create"
},
"recursive": {
"type": "boolean",
"description": "Create parent directories if needed"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_delete_file",
"description": "Delete a file or directory from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file or directory to delete"
},
"recursive": {
"type": "boolean",
"description": "Delete directories and their contents recursively"
},
"force": {
"type": "boolean",
"description": "Ignore errors if the file does not exist"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_append_file",
"description": "Append content to a file in the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to append to"
},
"content": {
"type": "string",
"description": "Content to append to the file"
}
},
"required": ["path", "content"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_copy_file",
"description": "Copy a file or directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"src": {
"type": "string",
"description": "Source path of the file or directory to copy"
},
"dest": {
"type": "string",
"description": "Destination path for the copy"
},
"overwrite": {
"type": "boolean",
"description": "Overwrite the destination if it already exists"
}
},
"required": ["src", "dest"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_move_file",
"description": "Move or rename a file or directory in the workspace",
"input_schema": {
"type": "object",
"properties": {
"src": {
"type": "string",
"description": "Source path of the file or directory to move"
},
"dest": {
"type": "string",
"description": "Destination path"
},
"overwrite": {
"type": "boolean",
"description": "Overwrite the destination if it already exists"
}
},
"required": ["src", "dest"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_rmdir",
"description": "Remove a directory from the workspace",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of the directory to remove"
},
"recursive": {
"type": "boolean",
"description": "Remove the directory and its contents recursively"
},
"force": {
"type": "boolean",
"description": "Ignore errors if the directory does not exist"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_execute_command",
"description": "Execute a shell command in the sandbox",
"input_schema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"cwd": {
"type": "string",
"description": "Working directory for the command"
},
"timeout": {
"type": "number",
"description": "Timeout in milliseconds"
}
},
"required": ["command"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_list_processes",
"description": "List all background processes spawned in the sandbox",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_kill_process",
"description": "Kill a background process in the sandbox by PID",
"input_schema": {
"type": "object",
"properties": {
"pid": {
"type": "number",
"description": "PID of the process to kill"
}
},
"required": ["pid"],
"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_011CdvpqfdQ23sZ4RbpyKY3R\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Now let me read the file back:\"},{\"type\":\"tool_use\",\"id\":\"toolu_0192ztBzne1P5eg2SYMroS1F\",\"name\":\"workspace_read_file\",\"input\":{\"path\":\"/greeting.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3115,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":67,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:20Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:21Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:20Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:20Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a296a670ad3d25c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 11 Aug 2026 10:45:21 GMT",
"request-id": "req_011Cdvpqf9sgYdsuZaU6vTvJ",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-3620259cf7d15aa7a1ed9e440835a25e-75c9a5ca2afeb9b2-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": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. When asked to read a file, use workspace_read_file. Be concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Write \"Hello from n8n!\" to /greeting.txt, then read it back and tell me the contents. You MUST call both tools"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll write the file and then read it back for you."
},
{
"type": "tool_use",
"id": "toolu_01622M3pBYgZBPSbA8pzfp2U",
"id": "toolu_012z9je1zy6boKjnifomrQFg",
"name": "workspace_write_file",
"input": {
"path": "/greeting.txt",
"content": "Hello from n8n!"
},
"caller": {
"type": "direct"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_012z9je1zy6boKjnifomrQFg",
"content": "{\"success\":true}"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Now let me read the file back:"
},
{
"type": "tool_use",
"id": "toolu_0192ztBzne1P5eg2SYMroS1F",
"name": "workspace_read_file",
"input": {
"path": "/greeting.txt"
@ -445,12 +965,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01TrVd5Gx19YUnAnjbfoN3Kp",
"content": "{\"success\":true}"
},
{
"type": "tool_result",
"tool_use_id": "toolu_01622M3pBYgZBPSbA8pzfp2U",
"tool_use_id": "toolu_0192ztBzne1P5eg2SYMroS1F",
"content": "{\"content\":\"Hello from n8n!\"}"
}
]
@ -478,6 +993,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -789,30 +1346,30 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01GVqiS8p7pay78EJf1QQQ7E\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! I've successfully written to and read from the file. The contents of `/greeting.txt` are:\\n\\n**Hello from n8n!**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2816,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":35,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdvpqntCyYynSvQhsVWvi\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Perfect! The file has been created and read successfully. The contents of `/greeting.txt` are:\\n\\n**Hello from n8n!**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":3203,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":33,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:44Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:23Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:21Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:43Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:23Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154f9e06a5d9cca-VIE",
"cf-ray": "a296a6771aa625c0-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Fri, 03 Jul 2026 09:48:44 GMT",
"request-id": "req_011CceuyXXWCdG9Qd1JvxpkB",
"date": "Tue, 11 Aug 2026 10:45:23 GMT",
"request-id": "req_011CdvpqjhDXR596EH2BRxUe",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-752e0492478df0f43c55a51cfaa0e47a-0b4cc00ad19937e8-01",
"traceresponse": "00-b76abc360b302febf5486c73741e38e3-e07b415fd4cc2435-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},

View File

@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -45,6 +45,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -358,33 +400,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013cd564d8fda3010bdf32ba2398736b0855df9b6dddbaa877ea8ab6a9bca9a4d067071ec604f602b94ff5e190810c26e7ba9686e9e37f6bcf726f1849664584405798f53929ed1712f474611ad817f9504025a20c4cd1ac41a0a9b93060199c62aa7fe0cd5bceabfeb8ffac364381a24c90062507938c34f653278b85dcdedc7c76fe8eec6f4a158dc2f6eef8b1b884f2a410cceea1040ef956734a16c660d936110df7fc4e0d996d2117a6b40984aeb5dc8d3a22293512b9813a3d2be89550d7b65ca8a25db39190f6238ba1ec59061362399394256d6c8764ad2e08e307f096bf6860a54cea820875a8e8a6efe011dcc4ed13a065bf171689cc4e0c92d55469215391010acc9d1e5c16533211794cb295910602c4b5ca2d2f8a409ea3ada3c75af47db8eefdc944fda66f3f37d3f93b22994d3f356ee311ed4eef6b1b55a569e9ade87752593c1a7d5f8cbc3d54df17837f1ef73e4abaf93cf8b39c460b008fb56d6cd7d8919c995534c72a2f4e68c600c88751d0cd63a08df97ca95a38cf7f29ae720b354667aa22b82108497acc84933be6ec526a565c53672e0b56de64f6fcd3eb944c70af5260802a0ee70bd088f750a25f22c0dbed497a793c2db19696ddf407d6126fccce9a14bdd37eb22a4e2286d4e4d41b4d95d985a94826747582873f1de4da3d57fe54db8d852a85ffdc0c2a4fad30d6ccb23226d5dcd903eaba805c29182d6183dbeb8ff7298d6ff709a9e8cbfebebce0c3bfc9874ad3bc6a0fb11ff0635769ee1f8080000"
"1f8b0800000000000013cd564d6fdb300cbde757183c3b9b932ec8a6db3e8eeb50acd807360f0263b3b11a457225daed10f8bf174ae2244ed26e97c1f34d2445bef7284ba49a0c8b6849dee39ca467743cc89151442be0df2581808e13e2760d62054b9b93060199c62aa761816a510d5f0d27c371329e8c92640431a83ce4f073998c46eff3ba741fcaabfa6efcf5cbfcddc7eb6b55cf203eaa043138ab8301bd579ed184b299354c8641fcfc1583675b4a47e8ad01612aadb7264f7715998c3ac69c1895f6adad6ad12b53562cd92ec87810e337d3d731649815243347c8ca1ad90d495abf23cc9ff2b57b43052a0b5a92432d27cbd3f8bd77541c7b9b186cc51d7c93183cb95a652459910301419a1c5d1e543637e4027339270b028c6589352a8d334dd03451f89ac180360ddf8a2967da668bf36d3f13b2ae93d3c386eda13f90ddee636bb5ac3cb5ad0feb4a26a3cbdbe9ccbc9de2a71ff5b7ab8b8b3bfacce3cbef1083c165d8776fddc2979891bc778a49de28bdce117401b16a82be5a07debb52b9729471cbee905fa9ccfc885004c1084f699093667c5e83754847838d650f68d3c45b6fcd2eb844c70af5da0802e0146b2f38562994c845ba03d4331e11a5f0129a9e5114a4b57dc10f0c4ddf82b49de919461ca5216bef9d09e9d2f529d9fd417d4be3d9112ed777ca7f81270a3768ef8df22934fb163dfbe8d8f24f6f8e2d0fd09ccdd90e2767d9759c70c0a6333e1cbe587f394434ff708a387af6a7d333e77d3f929d6a78e883a8fb3583c12342bc62eaf2090000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9999000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:27Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11999000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:48Z",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:27Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa000f789cca-VIE",
"cf-ray": "a296a69f797b25c0-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:48:49 GMT",
"request-id": "req_011CceuyuA1a3DJbhtARzSLq",
"date": "Tue, 11 Aug 2026 10:45:28 GMT",
"request-id": "req_011CdvprDERqVk87V64E9c8F",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-35661f42dfd6c284dfdf72f8af70d659-754025b36fb2493a-01",
"traceresponse": "00-3b35b498fba615a01c32fc04801e263f-ff74b075c4b93ceb-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
@ -401,7 +443,7 @@
"system": [
{
"type": "text",
"text": "You are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
"text": "<built_in_rules>\n- When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.\n</built_in_rules>\n\nYou are a file manager. When asked to create a file, use workspace_write_file. Be very concise.\n\nFake sandbox for executing commands.\n\nIn-memory filesystem. All file paths are relative to /mem."
}
],
"messages": [
@ -419,7 +461,7 @@
"content": [
{
"type": "tool_use",
"id": "toolu_01Qw6SV38mZCfsBdat3UfRqk",
"id": "toolu_01Mj7bnA7aNZvWP33qeRt2MX",
"name": "workspace_write_file",
"input": {
"path": "/hello.txt",
@ -436,7 +478,7 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01Qw6SV38mZCfsBdat3UfRqk",
"tool_use_id": "toolu_01Mj7bnA7aNZvWP33qeRt2MX",
"content": "{\"success\":true}"
}
]
@ -464,6 +506,48 @@
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_read_tool_result",
"description": "Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact path from an oversized tool-result envelope"
},
"view": {
"type": "string",
"enum": ["describe", "json"],
"description": "Inspect the selected value shape, or read a bounded JSON value"
},
"pointer": {
"type": "string",
"description": "RFC 6901 JSON Pointer. Omit or use an empty string for the root value."
},
"offset": {
"type": "integer",
"minimum": 0,
"description": "String character or container child offset"
},
"maxChars": {
"type": "integer",
"minimum": 1,
"maximum": 20000,
"description": "Maximum characters to return from a selected JSON string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Maximum object or array children to return"
}
},
"required": ["path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "workspace_str_replace_file",
"description": "Replace one exact, unique text match in a workspace file without rewriting the whole file.",
@ -777,33 +861,33 @@
},
"status": 200,
"response": [
"1f8b0800000000000013b554c1729b3010bdfb2bd4bdf48253a0b1ebea9ce94c3b39f4d643d35164b401d542a2d262bb93e1df3b6068c1c5492ee1c2f0dea2f7f62d0beed11267258620731481a4a7859224397b04fa5d21709890100dcfc01fa1740a0d70c88cac152e0ba977f5f27ab95aa671ba4ae2388108b46acf08b988934f9bcde6c30dbe5f5f7ffcb2bffd7afb53e3b7c316a2332588c03bd30232041d48da56367396d012f0ef3f2208e42ae151066781dbda981e0af8ab469be1045448529b3060f5e05edbaa26416e8736004fd7eb4d0499cc0a14994749da59312d8907dea35497b8e1dd5601ab024bf4d28855f97ffd3f3629ced9260257d3184a2208e8f73a43411a3d70689351d2ab3664fb80be6d5ce4e880837524e45e6a23b706a169d8e86a160b3c8dbd8f546c8dcb76f3c39f29e9e4141e4f3d8ff9b6e5fe3dc2635bd8dd38c0050395b6f999228316844b26151a924f9bec4a26264fc8d4dcdfb2dee28db308cdc5705e4df70dfbfc768facfb6a50312a903d6883ecfe5d81c6b82b3ad23d3b682a3aaa57647710c8a32cb5cdd9c1f95db883ab69c84f0eda55cfcdd955a34ec6470d7f83d94026248c0298ec2ba055826a6fe1c55bdbbce2da9eed599a3633edce6536e66014fa1f0a855c8555050000"
"1f8b0800000000000013bd544d8fd33010bdf7579839a74bb250217c437c5c800b0784c422ef341e12ab8e9db5276dd12aff1db94d76936e77e102be447e6fecf766c613da9263291a8a112b529131f04223a314b7c0bf5a02093312b2710ff2161aafc98284d262a76959a3d974cb97cbd5f232bf5c15795e400646a73b62a5f2a278abb76df8a4bf7c2cbf7d7ebfbbf9ba7ea37105d989126410bc4d00c66822a34bb2a5774c8e417eff914164dfaa4018bd03e93a6b0728d24d47aea419a889d1d83862dde8deb8b663c57e432e827c91bf2a3228b1ac499581908d776a1e928f7c20d48f71e3d9a4406d4d0d05b46ad53c8cbf678bfa94ed33f01d4fa1228348616b4a526c28808454198d41a722bb9f1452e2aa220f129c67855b3416d796a0ef8510a25f2ce8d8eea1946a6d7db939dff4332107194dfb63ae533ea53a9c63daa7c0c34702f433ddd6b8ea44484002e1316f9a2ce3d3de0e21336f4764eee92e6c70f6ce3b3ab81bd67f37f04c7c3096c4e1b59016c8e2fa794dd6fa0bdef3b5d819ae472be20a2207c2c6b84aec7cd8c42bb8989a7fb2b9befd536f7d3b713fbb6b1cfdb355989130c97a369c404e2bee8283bf1ed1fe1fcee8e950bdee4feb77ffbb7b58b92997deec6fb20bcd5a3f050000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "10000000",
"anthropic-ratelimit-input-tokens-remaining": "9998000",
"anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-input-tokens-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-output-tokens-limit": "2000000",
"anthropic-ratelimit-output-tokens-remaining": "2000000",
"anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-output-tokens-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-requests-reset": "2026-08-11T10:45:29Z",
"anthropic-ratelimit-tokens-limit": "12000000",
"anthropic-ratelimit-tokens-remaining": "11998000",
"anthropic-ratelimit-tokens-reset": "2026-07-03T09:48:49Z",
"anthropic-ratelimit-tokens-reset": "2026-08-11T10:45:29Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a154fa08db3e9cca-VIE",
"cf-ray": "a296a6a9aafe25c0-VIE",
"connection": "keep-alive",
"content-encoding": "gzip",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "text/event-stream; charset=utf-8",
"date": "Fri, 03 Jul 2026 09:48:51 GMT",
"request-id": "req_011Cceuz1CQRsHjaRFJjWeNX",
"date": "Tue, 11 Aug 2026 10:45:29 GMT",
"request-id": "req_011CdvprLBPKMksnVdhVe21B",
"server": "cloudflare",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-75c3a9e72f9fe676c85e3e65bc2b13e4-81812382f8587bcd-01",
"traceresponse": "00-f65c2bd509f39372a31913ac2591efd2-16dc82c2807ba48f-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"

View File

@ -63,6 +63,7 @@ describe('createWorkspaceTools', () => {
expect(names).toEqual([
'workspace_read_file',
'workspace_read_tool_result',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_write_file',
@ -100,10 +101,11 @@ describe('createWorkspaceTools', () => {
const names = tools.map((t) => t.name);
expect(names).toContain('workspace_read_file');
expect(names).toContain('workspace_read_tool_result');
expect(names).toContain('workspace_str_replace_file');
expect(names).toContain('workspace_batch_str_replace_file');
expect(names).toContain('workspace_execute_command');
expect(names).toHaveLength(13);
expect(names).toHaveLength(14);
});
describe('tool handlers', () => {
@ -121,6 +123,131 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ content: 'file content' });
});
it('read_tool_result describes and pages a nested result through escaped pointers', async () => {
const hash = 'a'.repeat(43);
const path = `tool-results/threads/${hash}/${hash}/${hash}.result.json`;
const escapedKey = 'folder/name~version';
const largeString = '"\\\n'.repeat(12_000);
const storedResult = {
[escapedKey]: {
entries: [{ large: largeString }, { value: 42 }],
enabled: true,
},
};
const fs = makeFakeFilesystem({
readFile: vi.fn().mockResolvedValue(JSON.stringify(storedResult)),
});
const tools = createWorkspaceTools({ filesystem: fs });
const readToolResult = tools.find((tool) => tool.name === 'workspace_read_tool_result')!;
await expect(
readToolResult.handler!({ path, view: 'describe' }, {} as never),
).resolves.toEqual({
view: 'describe',
pointer: '',
type: 'object',
childCount: 1,
});
const objectPage = (await readToolResult.handler!(
{ path, view: 'json', limit: 1 },
{} as never,
)) as {
children: Array<{ pointer: string; type: string }>;
};
expect(objectPage.children).toEqual([
{ pointer: '/folder~1name~0version', type: 'object', childCount: 2, key: escapedKey },
]);
const arrayPage = (await readToolResult.handler!(
{
path,
view: 'json',
pointer: '/folder~1name~0version/entries',
limit: 1,
},
{} as never,
)) as {
children: Array<{ pointer: string; type: string }>;
nextOffset: number | null;
hasMore: boolean;
};
expect(arrayPage.children).toEqual([
{
pointer: '/folder~1name~0version/entries/0',
type: 'object',
childCount: 1,
index: 0,
},
]);
expect(arrayPage).toMatchObject({ nextOffset: 1, hasMore: true });
let reconstructed = '';
let offset = 0;
do {
const page = (await readToolResult.handler!(
{
path,
view: 'json',
pointer: '/folder~1name~0version/entries/0/large',
offset,
maxChars: 20_000,
},
{} as never,
)) as {
content: string;
nextOffset: number | null;
hasMore: boolean;
};
expect(Buffer.byteLength(JSON.stringify(page), 'utf-8')).toBeLessThanOrEqual(40_000);
reconstructed += page.content;
if (page.nextOffset === null) break;
expect(page.nextOffset).toBeGreaterThan(offset);
offset = page.nextOffset;
} while (offset < largeString.length);
expect(reconstructed).toBe(largeString);
});
it.each([
{
name: 'an arbitrary workspace path',
path: '/tmp/result.json',
content: '{}',
pointer: undefined,
error: 'Path is not a stored tool result',
},
{
name: 'corrupt JSON',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: 'not-json',
pointer: undefined,
error: 'Stored tool result is not valid JSON',
},
{
name: 'a malformed pointer',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: '{}',
pointer: '/bad~2pointer',
error: 'Invalid JSON Pointer',
},
{
name: 'a missing pointer segment',
path: `tool-results/runs/${'a'.repeat(43)}/${'b'.repeat(43)}.result.json`,
content: '{}',
pointer: '/missing',
error: 'JSON Pointer does not reference a stored value',
},
])('read_tool_result rejects $name', async ({ path, content, pointer, error }) => {
const fs = makeFakeFilesystem({ readFile: vi.fn().mockResolvedValue(content) });
const tools = createWorkspaceTools({ filesystem: fs });
const readToolResult = tools.find((tool) => tool.name === 'workspace_read_tool_result')!;
await expect(
readToolResult.handler!({ path, view: 'json', pointer }, {} as never),
).rejects.toThrow(error);
});
it('targeted edit input schemas serialize with a top-level object type', () => {
const tools = createWorkspaceTools({ filesystem: makeFakeFilesystem() });
const strReplaceTool = tools.find((t) => t.name === 'workspace_str_replace_file')!;
@ -281,36 +408,6 @@ describe('createWorkspaceTools', () => {
expect(result).toEqual({ success: true });
});
it('execute_command handler includes sandbox default command environment', async () => {
const executeCommand = vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: 'ok',
stderr: '',
executionTimeMs: 5,
});
const sandbox = makeFakeSandbox({
executeCommand,
getDefaultCommandEnv: () => ({ CUSTOM_ENV: 'enabled' }),
});
const tools = createWorkspaceTools({ sandbox });
const commandTool = tools.find((t) => t.name === 'workspace_execute_command')!;
const abortController = new AbortController();
const result = await commandTool.handler!(
{ command: 'node script.mjs', cwd: '/home/daytona/workspace' },
{ abortSignal: abortController.signal } as never,
);
expect(executeCommand).toHaveBeenCalledWith('node script.mjs', undefined, {
cwd: '/home/daytona/workspace',
env: { CUSTOM_ENV: 'enabled' },
timeout: undefined,
abortSignal: abortController.signal,
});
expect(result).toMatchObject({ success: true, stdout: 'ok' });
});
it('list_files handler calls filesystem.readdir', async () => {
const fs = makeFakeFilesystem();
const tools = createWorkspaceTools({ filesystem: fs });

View File

@ -418,7 +418,12 @@ export type {
export { Workspace } from './workspace';
export { BaseFilesystem } from './workspace';
export { BaseSandbox } from './workspace';
export { createWorkspaceTools } from './workspace';
export {
CORE_WORKSPACE_TOOL_NAMES,
createScopedWorkspace,
createWorkspaceTools,
getToolResultThreadDirectory,
} from './workspace';
export { SandboxProcessManager, ProcessHandle } from './workspace';
export type {

View File

@ -3,6 +3,8 @@ import type { JSONSchema7 } from 'json-schema';
import type { Mock, MockedFunction } from 'vitest';
import { z } from 'zod';
import { InMemoryFilesystem } from '../../__tests__/workspace/test-utils';
import { Agent } from '../../sdk/agent';
import { createCancellation } from '../../sdk/cancellation';
import { isLlmMessage } from '../../sdk/message';
import { Tool, Tool as ToolBuilder } from '../../sdk/tool';
@ -13,6 +15,7 @@ import type { StreamChunk } from '../../types/sdk/agent';
import type { AgentDbMessage, ContentToolCall, Message } from '../../types/sdk/message';
import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../../types/sdk/tool';
import type { BuiltTelemetry } from '../../types/telemetry';
import { Workspace, createWorkspaceTools } from '../../workspace';
import { AgentRuntime } from '../loop/agent-runtime';
import { InMemoryMemory } from '../memory/memory-store';
import { AgentEventBus } from '../state/event-bus';
@ -7786,6 +7789,233 @@ describe('AgentRuntime — oversized tool results', () => {
expect(envelope.tail).toContain('MESSAGE_TAIL');
expect(fileBlock).toMatchObject({ type: 'file', mediaType: 'text/plain', data: fileData });
});
describe('Workspace-backed oversized tool results', () => {
type OffloadedEnvelope = {
_offloaded: true;
path: string;
originalCharCount: number;
estimatedTokenCount: number;
requiredAction: {
toolName: 'workspace_read_tool_result';
input: { path: string; view: 'describe' };
};
};
function parseOffloadedEnvelope(value: string | undefined): OffloadedEnvelope {
try {
return JSON.parse(value ?? '') as OffloadedEnvelope;
} catch {
throw new Error('Expected a valid offloaded result envelope');
}
}
function modelToolResults(): unknown[] {
const call = generateText.mock.calls[1][0] as {
messages: Array<{
role: string;
content: Array<{ type: string; output?: { type: string; value: unknown } }>;
}>;
};
return call.messages
.filter((message) => message.role === 'tool')
.flatMap((message) => message.content)
.filter((part) => part.type === 'tool-result')
.map((part) => part.output?.value);
}
function createWorkspaceAgent(filesystem: InMemoryFilesystem, tools: BuiltTool[]) {
return new Agent('workspace-result-test')
.model('openai/gpt-4o-mini')
.instructions('Test')
.tool(tools)
.toolCallConcurrency(Infinity)
.workspace(new Workspace({ filesystem }));
}
it('stores complete concurrent transformed results without changing raw outputs', async () => {
const filesystem = new InMemoryFilesystem();
const transformedOutputs = {
first: { value: `FIRST${'a '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}` },
second: { value: `SECOND${'b '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}` },
};
const tools = (Object.keys(transformedOutputs) as Array<keyof typeof transformedOutputs>).map(
(name): BuiltTool => ({
name,
description: name,
inputSchema: z.object({}),
handler: async () => await Promise.resolve({ raw: name }),
toModelOutput: () => transformedOutputs[name],
}),
);
const agent = createWorkspaceAgent(filesystem, tools);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([
{ toolCallId: 'tc/first', toolName: 'first', args: {} },
{ toolCallId: 'tc/second', toolName: 'second', args: {} },
]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'slack:C123:123.456', resourceId: 'user-1' },
});
const envelopes = modelToolResults() as OffloadedEnvelope[];
expect(envelopes).toHaveLength(2);
expect(new Set(envelopes.map(({ path }) => path)).size).toBe(2);
expect(envelopes.every(({ _offloaded }) => _offloaded)).toBe(true);
expect(envelopes.map(({ requiredAction }) => requiredAction)).toEqual(
envelopes.map(({ path }) => ({
toolName: 'workspace_read_tool_result',
input: { path, view: 'describe' },
})),
);
await expect(filesystem.readFile(envelopes[0].path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify(transformedOutputs.first),
);
await expect(filesystem.readFile(envelopes[1].path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify(transformedOutputs.second),
);
expect(result.toolCalls?.map(({ output }) => output)).toEqual([
{ raw: 'first' },
{ raw: 'second' },
]);
const reader = createWorkspaceTools({ filesystem }).find(
(tool) => tool.name === 'workspace_read_tool_result',
);
if (!reader?.handler) throw new Error('Expected workspace_read_tool_result');
await expect(reader.handler(envelopes[0].requiredAction.input, {} as never)).resolves.toEqual(
{
view: 'describe',
pointer: '',
type: 'object',
childCount: 1,
},
);
});
it('stores oversized errors without changing the rejected tool-call state', async () => {
const filesystem = new InMemoryFilesystem();
const tool: BuiltTool = {
name: 'large_error',
description: 'large error',
inputSchema: z.object({}),
handler: () => {
throw new Error(`ERROR_HEAD${'e '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}`);
},
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-error', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
});
const toolCall = result.messages
.flatMap((message) => ('content' in message ? message.content : []))
.find(
(content): content is ContentToolCall =>
content.type === 'tool-call' && content.toolCallId === 'tc-error',
);
const envelope = parseOffloadedEnvelope(
toolCall?.state === 'rejected' ? toolCall.error : undefined,
);
expect(toolCall?.state).toBe('rejected');
expect(envelope._offloaded).toBe(true);
expect(envelope.requiredAction).toEqual({
toolName: 'workspace_read_tool_result',
input: { path: envelope.path, view: 'describe' },
});
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toContain(
'ERROR_HEAD',
);
});
it('stores oversized custom-message text while preserving file content', async () => {
const filesystem = new InMemoryFilesystem();
const fileData = Buffer.from('file').toString('base64');
const messageText = `MESSAGE${'m '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}`;
const tool: BuiltTool = {
name: 'large_message',
description: 'large message',
inputSchema: z.object({}),
handler: async () => await Promise.resolve({ ok: true }),
toMessage: async () =>
await Promise.resolve({
role: 'assistant',
content: [
{ type: 'text', text: messageText },
{ type: 'file', mediaType: 'text/plain', data: fileData },
],
}),
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-message', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
const result = await agent.generate('run', {
persistence: { threadId: 'thread-1', resourceId: 'user-1' },
});
const message = result.messages.find(
(candidate) =>
'content' in candidate &&
candidate.content.some((content) => content.type === 'file' && content.data === fileData),
);
const text =
message && 'content' in message
? message.content.find((content) => content.type === 'text')?.text
: undefined;
const envelope = parseOffloadedEnvelope(text);
expect(envelope._offloaded).toBe(true);
expect(envelope.requiredAction).toEqual({
toolName: 'workspace_read_tool_result',
input: { path: envelope.path, view: 'describe' },
});
expect(message && 'content' in message ? message.content : []).toContainEqual({
type: 'file',
mediaType: 'text/plain',
data: fileData,
});
await expect(filesystem.readFile(envelope.path, { encoding: 'utf8' })).resolves.toBe(
JSON.stringify([{ type: 'text', text: messageText }]),
);
});
it('falls back to bounded truncation when storing the result fails', async () => {
const filesystem = new InMemoryFilesystem();
vi.spyOn(filesystem, 'writeFile').mockRejectedValue(new Error('storage unavailable'));
const tool: BuiltTool = {
name: 'large_result',
description: 'large result',
inputSchema: z.object({}),
handler: async () =>
await Promise.resolve({
value: `HEAD${'x '.repeat(MAX_MODEL_TOOL_RESULT_TOKENS + 10_000)}TAIL`,
}),
};
const agent = createWorkspaceAgent(filesystem, [tool]);
generateText
.mockResolvedValueOnce(
makeGenerateWithToolCalls([{ toolCallId: 'tc-large', toolName: tool.name, args: {} }]),
)
.mockResolvedValueOnce(makeGenerateSuccess());
await agent.generate('run');
expect(modelToolResults()[0]).toMatchObject({ _truncated: true });
});
});
});
// ---------------------------------------------------------------------------

View File

@ -54,6 +54,7 @@ import type { AgentMessage, ContentToolCall } from '../../types/sdk/message';
import type { JSONValue } from '../../types/utils/json';
import { getModelIdString } from '../../utils/model';
import { parseWithSchema } from '../../utils/parse';
import type { WorkspaceFilesystem } from '../../workspace';
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner';
import { generateThreadTitle } from '../memory/title-generation';
@ -93,6 +94,7 @@ export interface AgentRuntimeConfig {
instructionProviderOptions?: ProviderOptions;
tools?: BuiltTool[];
deferredTools?: BuiltTool[];
workspaceFilesystem?: WorkspaceFilesystem;
toolSearch?: {
topK?: number;
};
@ -221,6 +223,7 @@ export class AgentRuntime {
concurrency: config.toolCallConcurrency ?? 1,
onCancelled: () => this.updateState({ status: 'cancelled' }),
tokenCounter,
...(config.workspaceFilesystem ? { workspaceFilesystem: config.workspaceFilesystem } : {}),
});
this.modelCost = config.modelCost;
this.currentState = {

View File

@ -10,6 +10,7 @@ import {
guardToolErrorForModel,
guardToolMessageForModel,
guardToolResultForModel,
type ToolResultGuardStorage,
} from './tool-result-guard';
import { isAbortError, raceWithAbort } from '../../sdk/abort';
import { isCancellation } from '../../sdk/cancellation';
@ -27,6 +28,7 @@ import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/mes
import type { JSONObject, JSONValue } from '../../types/utils/json';
import { parseWithSchema } from '../../utils/parse';
import { isZodSchema } from '../../utils/zod';
import type { WorkspaceFilesystem } from '../../workspace/types';
import { incrementToolCallCount } from '../loop/execution-counter';
import { stringifyError } from '../loop/runtime-helpers';
import type { AgentMessageList } from '../model/message-list';
@ -183,6 +185,7 @@ export interface ToolCallExecutorDeps {
/** Invoked when a run is aborted mid-batch so the runtime can set cancelled state. */
onCancelled: () => void;
tokenCounter: TokenCounter;
workspaceFilesystem?: WorkspaceFilesystem;
}
/**
@ -859,7 +862,11 @@ export class ToolCallExecutor {
});
params.list.setToolCallError(
params.toolCallId,
await guardToolErrorForModel(stringifyError(error), this.deps.tokenCounter),
await guardToolErrorForModel(
stringifyError(error),
this.deps.tokenCounter,
this.getResultStorage(params),
),
);
return { outcome: 'error', error };
}
@ -1028,7 +1035,12 @@ export class ToolCallExecutor {
} catch (error) {
return await this.toolError(params, error);
}
const guardedResult = await guardToolResultForModel(modelResult, this.deps.tokenCounter);
const storage = this.getResultStorage(params);
const guardedResult = await guardToolResultForModel(
modelResult,
this.deps.tokenCounter,
storage,
);
this.eventBus.emit({
type: AgentEvent.ToolExecutionEnd,
@ -1040,9 +1052,9 @@ export class ToolCallExecutor {
list.setToolCallResult(toolCallId, guardedResult.historyOutput);
const customMessage = builtTool.toMessage?.(toolResult);
const customMessage = await builtTool.toMessage?.(toolResult);
const guardedCustomMessage = customMessage
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter)
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage)
: undefined;
if (guardedCustomMessage) {
list.addResponse([guardedCustomMessage]);
@ -1060,4 +1072,17 @@ export class ToolCallExecutor {
customMessage: guardedCustomMessage,
};
}
private getResultStorage(params: ProcessToolCallParams): ToolResultGuardStorage | undefined {
const filesystem = this.deps.workspaceFilesystem;
if (!filesystem) return undefined;
return {
filesystem,
runId: params.runId,
toolCallId: params.toolCallId,
...(params.persistence?.threadId ? { threadId: params.persistence.threadId } : {}),
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
};
}
}

View File

@ -2,6 +2,12 @@ import { toJsonValue } from '@n8n/utils/json/to-json-value';
import type { AgentMessage, MessageContent } from '../../types/sdk/message';
import type { JSONObject, JSONValue } from '../../types/utils/json';
import {
storeToolResult,
type ToolResultKind,
type ToolResultStorageScope,
} from '../../workspace/tool-result-storage';
import type { WorkspaceFilesystem } from '../../workspace/types';
import { estimateObservationTokens, type TokenCounter } from '../model/model-token-counter';
export const MAX_MODEL_TOOL_RESULT_TOKENS = 50_000;
@ -16,58 +22,92 @@ interface TruncatedToolResult extends JSONObject {
tail: string;
}
interface OffloadedToolResult extends JSONObject {
_offloaded: true;
path: string;
originalCharCount: number;
estimatedTokenCount: number;
requiredAction: {
toolName: 'workspace_read_tool_result';
input: {
path: string;
view: 'describe';
};
};
message: string;
}
export interface ToolResultGuardStorage extends ToolResultStorageScope {
filesystem: WorkspaceFilesystem;
}
export interface GuardedToolResult {
historyOutput: JSONValue;
wireOutput: unknown;
truncated: boolean;
offloaded: boolean;
}
export async function guardToolResultForModel(
output: unknown,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
kind: ToolResultKind = 'result',
): Promise<GuardedToolResult> {
const historyOutput = toJsonValue(output);
const serialized = JSON.stringify(historyOutput);
if (isClearlyWithinTokenLimit(serialized)) {
return { historyOutput, wireOutput: output, truncated: false };
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };
}
const tokenCount = await tokenCounter(serialized);
if (tokenCount <= MAX_MODEL_TOOL_RESULT_TOKENS) {
return { historyOutput, wireOutput: output, truncated: false };
return { historyOutput, wireOutput: output, truncated: false, offloaded: false };
}
const offloaded = await tryOffloadResult(serialized, tokenCount, kind, storage);
if (offloaded) {
return {
historyOutput: offloaded,
wireOutput: offloaded,
truncated: false,
offloaded: true,
};
}
const truncated = await buildTruncationEnvelope(serialized, tokenCount, tokenCounter);
return { historyOutput: truncated, wireOutput: truncated, truncated: true };
return {
historyOutput: truncated,
wireOutput: truncated,
truncated: true,
offloaded: false,
};
}
export async function guardToolErrorForModel(
errorText: string,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
): Promise<string> {
const guarded = await guardToolResultForModel(errorText, tokenCounter);
return guarded.truncated ? JSON.stringify(guarded.historyOutput) : errorText;
const guarded = await guardToolResultForModel(errorText, tokenCounter, storage, 'error');
return guarded.truncated || guarded.offloaded ? JSON.stringify(guarded.historyOutput) : errorText;
}
export async function guardToolMessageForModel(
message: AgentMessage,
tokenCounter: TokenCounter = estimateObservationTokens,
storage?: ToolResultGuardStorage,
): Promise<AgentMessage> {
if (!('content' in message)) return message;
const textBlocks = message.content.filter((block) => block.type === 'text');
if (textBlocks.length === 0) return message;
const serialized = JSON.stringify(textBlocks.map(({ text }) => text));
if (isClearlyWithinTokenLimit(serialized)) return message;
const guarded = await guardToolResultForModel(textBlocks, tokenCounter, storage, 'message');
if (!guarded.truncated && !guarded.offloaded) return message;
const tokenCount = await tokenCounter(serialized);
if (tokenCount <= MAX_MODEL_TOOL_RESULT_TOKENS) return message;
const replacement = JSON.stringify(
await buildTruncationEnvelope(serialized, tokenCount, tokenCounter),
);
const replacement = JSON.stringify(guarded.historyOutput);
let replacedText = false;
const content = message.content.flatMap((block): MessageContent[] => {
if (block.type !== 'text') return [block];
@ -80,6 +120,33 @@ export async function guardToolMessageForModel(
return { ...message, content };
}
async function tryOffloadResult(
serialized: string,
tokenCount: number,
kind: ToolResultKind,
storage?: ToolResultGuardStorage,
): Promise<OffloadedToolResult | undefined> {
if (!storage || storage.filesystem.readOnly === true) return undefined;
try {
const path = await storeToolResult(storage.filesystem, storage, kind, serialized);
return {
_offloaded: true,
path,
originalCharCount: serialized.length,
estimatedTokenCount: tokenCount,
requiredAction: {
toolName: 'workspace_read_tool_result',
input: { path, view: 'describe' },
},
message:
'The complete tool result was stored in the workspace. Follow requiredAction before answering, then keep using workspace_read_tool_result with relevant JSON Pointers and nextOffset pages until you have enough evidence. Do not assume the first page is complete when hasMore is true.',
};
} catch {
return undefined;
}
}
function isClearlyWithinTokenLimit(serialized: string): boolean {
return (
Buffer.byteLength(serialized, 'utf8') + SMALL_PAYLOAD_OVERHEAD_BYTES <=

View File

@ -1104,6 +1104,9 @@ export class Agent implements BuiltAgent, AgentBuilder {
instructions,
tools: allTools.length > 0 ? allTools : undefined,
deferredTools: finalDeferredTools.length > 0 ? finalDeferredTools : undefined,
...(this.workspaceInstance?.filesystem && this.workspaceInstance.filesystem.readOnly !== true
? { workspaceFilesystem: this.workspaceInstance.filesystem }
: {}),
toolSearch,
instructionProviderOptions: this.instructionProviderOpts,
providerTools: this.providerTools.length > 0 ? this.providerTools : undefined,

View File

@ -136,7 +136,9 @@ export interface BuiltTool {
readonly handleCancellation?: boolean;
/** Run cleanup before the runtime auto-cancels a suspended tool call. */
readonly onCancellation?: (input: unknown, ctx: ToolCancellationContext) => Promise<void>;
readonly toMessage?: (output: unknown) => AgentMessage | undefined;
readonly toMessage?: (
output: unknown,
) => AgentMessage | undefined | Promise<AgentMessage | undefined>;
/**
* Transform the handler output before sending it to the LLM as a tool result.
* The raw output is stored in history; only the transformed version goes to the model.

View File

@ -1,12 +1,8 @@
import {
Workspace,
type CommandResult,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents';
import type { Mock } from 'vitest';
import { createScopedWorkspace } from '../scoped-workspace';
import type { CommandResult, WorkspaceFilesystem, WorkspaceSandbox } from '../types';
import { Workspace } from '../workspace';
function createFilesystem(overrides: Partial<WorkspaceFilesystem> = {}): WorkspaceFilesystem {
return {
@ -80,7 +76,7 @@ function createSandbox(executeCommand: Mock | null = vi.fn()): WorkspaceSandbox
describe('createScopedWorkspace', () => {
const root = '/workspace/builders/agent-1';
it('resolves relative filesystem paths inside the builder root', async () => {
it('resolves relative filesystem paths inside the workspace root', async () => {
const filesystem = createFilesystem();
const workspace = createScopedWorkspace(new Workspace({ filesystem }), root);
@ -93,17 +89,17 @@ describe('createScopedWorkspace', () => {
);
});
it('rejects filesystem paths outside the builder root', async () => {
it('rejects filesystem paths outside the workspace root', async () => {
const filesystem = createFilesystem();
const workspace = createScopedWorkspace(new Workspace({ filesystem }), root);
await expect(
workspace.filesystem?.readFile('/workspace/builders/agent-2/src/workflow.ts'),
).rejects.toThrow('Path escapes builder workspace root');
).rejects.toThrow('Path escapes workspace root');
expect(filesystem.readFile).not.toHaveBeenCalled();
});
it('runs commands from the builder root and merges scoped environment variables', async () => {
it('runs commands from the workspace root and merges scoped environment variables', async () => {
const executeCommand = vi.fn();
const sandbox = createSandbox(executeCommand);
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root, {
@ -121,7 +117,7 @@ describe('createScopedWorkspace', () => {
});
});
it('rejects command working directories outside the builder root', async () => {
it('rejects command working directories outside the workspace root', async () => {
const executeCommand = vi.fn();
const sandbox = createSandbox(executeCommand);
const workspace = createScopedWorkspace(new Workspace({ sandbox }), root);
@ -130,7 +126,7 @@ describe('createScopedWorkspace', () => {
workspace.sandbox?.executeCommand?.('npm test', [], {
cwd: '/workspace/builders/agent-2',
}),
).rejects.toThrow('Path escapes builder workspace root');
).rejects.toThrow('Path escapes workspace root');
expect(executeCommand).not.toHaveBeenCalled();
});

View File

@ -1,10 +1,12 @@
export { Workspace } from './workspace';
export { createScopedWorkspace } from './scoped-workspace';
export { getToolResultThreadDirectory } from './tool-result-storage';
export { BaseFilesystem } from './filesystem/base-filesystem';
export { BaseSandbox } from './sandbox/base-sandbox';
export { createWorkspaceTools } from './tools/workspace-tools';
export { CORE_WORKSPACE_TOOL_NAMES, createWorkspaceTools } from './tools/workspace-tools';
export { callLifecycle } from './lifecycle';
export type { BaseFilesystemOptions, FilesystemLifecycleHook } from './filesystem/base-filesystem';

View File

@ -1,22 +1,23 @@
import {
Workspace,
type AbortableOptions,
type AppendOptions,
type CopyOptions,
type FileContent,
type FileEntry,
type FileStat,
type ListOptions,
type MkdirOptions,
type ProviderStatus,
type ReadOptions,
type RemoveOptions,
type WorkspaceFilesystem,
type WorkspaceSandbox,
type WriteOptions,
} from '@n8n/agents';
import { join as posixJoin, normalize as posixNormalize } from 'node:path/posix';
import type {
AbortableOptions,
AppendOptions,
CopyOptions,
FileContent,
FileEntry,
FileStat,
ListOptions,
MkdirOptions,
ProviderStatus,
ReadOptions,
RemoveOptions,
WorkspaceFilesystem,
WorkspaceSandbox,
WriteOptions,
} from './types';
import { Workspace } from './workspace';
function isInsideRoot(path: string, root: string): boolean {
const boundary = root.endsWith('/') ? root : `${root}/`;
return path === root || path.startsWith(boundary);
@ -29,7 +30,7 @@ function resolvePath(root: string, path: string): string {
: posixNormalize(posixJoin(normalizedRoot, path));
if (!isInsideRoot(normalizedPath, normalizedRoot)) {
throw new Error(`Path escapes builder workspace root: ${path}`);
throw new Error(`Path escapes workspace root: ${path}`);
}
return normalizedPath;
@ -46,7 +47,7 @@ class ScopedFilesystem implements WorkspaceFilesystem {
}
get name() {
return `${this.filesystem.name} (builder scoped)`;
return `${this.filesystem.name} (scoped)`;
}
get provider() {
@ -156,7 +157,7 @@ class ScopedSandbox implements WorkspaceSandbox {
}
get name() {
return `${this.sandbox.name} (builder scoped)`;
return `${this.sandbox.name} (scoped)`;
}
get provider() {

View File

@ -0,0 +1,68 @@
import { createHash } from 'node:crypto';
import type { WorkspaceFilesystem } from './types';
export type ToolResultKind = 'result' | 'error' | 'message';
export interface ToolResultStorageScope {
threadId?: string;
runId: string;
toolCallId: string;
abortSignal?: AbortSignal;
}
const TOOL_RESULTS_DIRECTORY = 'tool-results';
const HASHED_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const TOOL_RESULT_FILE_PATTERN = /^([A-Za-z0-9_-]{43})\.(result|error|message)\.json$/;
function hashPathSegment(value: string): string {
return createHash('sha256').update(value).digest('base64url');
}
export function getToolResultThreadDirectory(threadId: string): string {
return `${TOOL_RESULTS_DIRECTORY}/threads/${hashPathSegment(threadId)}`;
}
export function isToolResultPath(path: string): boolean {
const segments = path.split('/');
if (segments[0] !== TOOL_RESULTS_DIRECTORY) return false;
if (segments[1] === 'threads' && segments.length === 5) {
return (
HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) &&
HASHED_PATH_SEGMENT_PATTERN.test(segments[3]) &&
TOOL_RESULT_FILE_PATTERN.test(segments[4])
);
}
if (segments[1] === 'runs' && segments.length === 4) {
return (
HASHED_PATH_SEGMENT_PATTERN.test(segments[2]) && TOOL_RESULT_FILE_PATTERN.test(segments[3])
);
}
return false;
}
function getToolResultPath(scope: ToolResultStorageScope, kind: ToolResultKind): string {
const runDirectory = scope.threadId
? `${getToolResultThreadDirectory(scope.threadId)}/${hashPathSegment(scope.runId)}`
: `${TOOL_RESULTS_DIRECTORY}/runs/${hashPathSegment(scope.runId)}`;
return `${runDirectory}/${hashPathSegment(scope.toolCallId)}.${kind}.json`;
}
export async function storeToolResult(
filesystem: WorkspaceFilesystem,
scope: ToolResultStorageScope,
kind: ToolResultKind,
serialized: string,
): Promise<string> {
const path = getToolResultPath(scope, kind);
await filesystem.writeFile(path, serialized, {
recursive: true,
overwrite: true,
abortSignal: scope.abortSignal,
});
return path;
}

View File

@ -27,10 +27,8 @@ export function createExecuteCommandTool(sandbox: WorkspaceSandbox): BuiltTool {
if (!sandbox.executeCommand) {
throw new Error('Sandbox does not support command execution');
}
const env = sandbox.getDefaultCommandEnv?.();
const result = await sandbox.executeCommand(input.command, undefined, {
cwd: input.cwd,
...(env ? { env } : {}),
timeout: input.timeout,
abortSignal: ctx.abortSignal,
});

View File

@ -0,0 +1,404 @@
import { z } from 'zod';
import { Tool } from '../../sdk/tool';
import type { BuiltTool } from '../../types/sdk/tool';
import { isToolResultPath } from '../tool-result-storage';
import type { WorkspaceFilesystem } from '../types';
const DEFAULT_CONTAINER_LIMIT = 20;
const MAX_CONTAINER_LIMIT = 100;
const MAX_STRING_CHARS = 20_000;
const MAX_INLINE_STRING_CHARS = 500;
const MAX_SERIALIZED_OUTPUT_BYTES = 40_000;
const jsonValueTypeSchema = z.enum(['null', 'boolean', 'number', 'string', 'array', 'object']);
const scalarValueSchema = z.union([z.null(), z.boolean(), z.number(), z.string()]);
const childSchema = z.object({
pointer: z.string(),
type: jsonValueTypeSchema,
key: z.string().optional(),
index: z.number().int().nonnegative().optional(),
childCount: z.number().int().nonnegative().optional(),
charCount: z.number().int().nonnegative().optional(),
value: scalarValueSchema.optional(),
});
const describeOutputSchema = z.object({
view: z.literal('describe'),
pointer: z.string(),
type: jsonValueTypeSchema,
childCount: z.number().int().nonnegative().optional(),
charCount: z.number().int().nonnegative().optional(),
});
const stringOutputSchema = z.object({
view: z.literal('json'),
pointer: z.string(),
type: z.literal('string'),
content: z.string(),
offset: z.number().int().nonnegative(),
totalChars: z.number().int().nonnegative(),
nextOffset: z.number().int().nonnegative().nullable(),
hasMore: z.boolean(),
});
const containerOutputSchema = z.object({
view: z.literal('json'),
pointer: z.string(),
type: z.enum(['array', 'object']),
children: z.array(childSchema),
offset: z.number().int().nonnegative(),
totalChildren: z.number().int().nonnegative(),
nextOffset: z.number().int().nonnegative().nullable(),
hasMore: z.boolean(),
});
const scalarOutputSchema = z.object({
view: z.literal('json'),
pointer: z.string(),
type: z.enum(['null', 'boolean', 'number']),
value: z.union([z.null(), z.boolean(), z.number()]),
});
const outputSchema = z.union([
describeOutputSchema,
stringOutputSchema,
containerOutputSchema,
scalarOutputSchema,
]);
const inputSchema = z
.object({
path: z.string().describe('Exact path from an oversized tool-result envelope'),
view: z
.enum(['describe', 'json'])
.optional()
.describe('Inspect the selected value shape, or read a bounded JSON value'),
pointer: z
.string()
.optional()
.describe('RFC 6901 JSON Pointer. Omit or use an empty string for the root value.'),
offset: z
.number()
.int()
.min(0)
.optional()
.describe('String character or container child offset'),
maxChars: z
.number()
.int()
.min(1)
.max(MAX_STRING_CHARS)
.optional()
.describe('Maximum characters to return from a selected JSON string'),
limit: z
.number()
.int()
.min(1)
.max(MAX_CONTAINER_LIMIT)
.optional()
.describe('Maximum object or array children to return'),
})
.strict();
type ToolResultInput = z.infer<typeof inputSchema>;
type ToolResultOutput = z.infer<typeof outputSchema>;
type ToolResultChild = z.infer<typeof childSchema>;
type DescribeOutput = z.infer<typeof describeOutputSchema>;
type StringOutput = z.infer<typeof stringOutputSchema>;
type ContainerOutput = z.infer<typeof containerOutputSchema>;
type JsonValueType = z.infer<typeof jsonValueTypeSchema>;
export function createReadToolResultTool(filesystem: WorkspaceFilesystem): BuiltTool {
return new Tool('workspace_read_tool_result')
.description(
'Inspect an oversized tool result stored in the workspace. Start with view=describe, then use view=json with relevant JSON Pointers and nextOffset values. Continue until you have enough evidence for the user request; do not assume the first page is complete when hasMore is true.',
)
.systemInstruction(
'When a tool result is offloaded, inspect it with workspace_read_tool_result before answering. Continue through relevant JSON Pointers and nextOffset pages until you have enough evidence for the user request. Do not read the entire result when the remaining content cannot affect the answer.',
)
.input(inputSchema)
.output(outputSchema)
.handler(async (input, ctx) => await readToolResult(filesystem, input, ctx.abortSignal))
.build();
}
async function readToolResult(
filesystem: WorkspaceFilesystem,
input: ToolResultInput,
abortSignal?: AbortSignal,
): Promise<ToolResultOutput> {
if (!isToolResultPath(input.path)) {
throw new Error('Path is not a stored tool result');
}
const content = await filesystem.readFile(input.path, {
encoding: 'utf-8',
abortSignal,
});
const serialized = typeof content === 'string' ? content : content.toString('utf-8');
let root: unknown;
try {
root = JSON.parse(serialized);
} catch {
throw new Error('Stored tool result is not valid JSON');
}
const pointer = input.pointer ?? '';
const value = resolveJsonPointer(root, pointer);
if ((input.view ?? 'describe') === 'describe') {
return ensureWithinOutputBudget(describeValue(value, pointer));
}
return readJsonValue(value, pointer, input);
}
function readJsonValue(value: unknown, pointer: string, input: ToolResultInput): ToolResultOutput {
if (value === null) {
return ensureWithinOutputBudget({ view: 'json', pointer, type: 'null', value: null });
}
if (typeof value === 'boolean') {
return ensureWithinOutputBudget({ view: 'json', pointer, type: 'boolean', value });
}
if (typeof value === 'number') {
return ensureWithinOutputBudget({ view: 'json', pointer, type: 'number', value });
}
if (typeof value === 'string') {
return readStringPage(value, pointer, input.offset ?? 0, input.maxChars ?? MAX_STRING_CHARS);
}
if (Array.isArray(value)) {
return readContainerPage(
value,
pointer,
'array',
input.offset ?? 0,
input.limit ?? DEFAULT_CONTAINER_LIMIT,
);
}
if (isJsonObject(value)) {
return readContainerPage(
value,
pointer,
'object',
input.offset ?? 0,
input.limit ?? DEFAULT_CONTAINER_LIMIT,
);
}
throw new Error('Stored tool result contains an unsupported JSON value');
}
function describeValue(value: unknown, pointer: string): DescribeOutput {
const type = getJsonValueType(value);
const output: DescribeOutput = { view: 'describe', pointer, type };
if (typeof value === 'string') {
output.charCount = value.length;
} else if (Array.isArray(value)) {
output.childCount = value.length;
} else if (isJsonObject(value)) {
output.childCount = Object.keys(value).length;
}
return output;
}
function readStringPage(
value: string,
pointer: string,
offset: number,
maxChars: number,
): StringOutput {
const available = Math.max(0, value.length - offset);
let low = 0;
let high = Math.min(maxChars, available);
let best: StringOutput | undefined;
while (low <= high) {
const length = Math.floor((low + high) / 2);
const output = buildStringOutput(value, pointer, offset, length);
if (isWithinOutputBudget(output)) {
best = output;
low = length + 1;
} else {
high = length - 1;
}
}
if (!best || (available > 0 && best.content.length === 0)) {
throw new Error('Tool result value cannot fit within the read limit');
}
return best;
}
function buildStringOutput(
value: string,
pointer: string,
offset: number,
length: number,
): StringOutput {
const content = value.slice(offset, offset + length);
const consumedOffset = offset + content.length;
const hasMore = consumedOffset < value.length;
return {
view: 'json',
pointer,
type: 'string',
content,
offset,
totalChars: value.length,
nextOffset: hasMore ? consumedOffset : null,
hasMore,
};
}
function readContainerPage(
value: unknown[] | Record<string, unknown>,
pointer: string,
type: 'array' | 'object',
offset: number,
limit: number,
): ContainerOutput {
const keys = Array.isArray(value) ? undefined : Object.keys(value);
const totalChildren = Array.isArray(value) ? value.length : (keys?.length ?? 0);
const children: ToolResultChild[] = [];
const pageEnd = Math.min(totalChildren, offset + limit);
for (let index = offset; index < pageEnd; index++) {
const key = keys?.[index];
const childValue = Array.isArray(value) ? value[index] : value[key ?? ''];
const child = describeChild(childValue, pointer, key, Array.isArray(value) ? index : undefined);
const candidate = buildContainerOutput(
type,
pointer,
children.concat(child),
offset,
totalChildren,
);
if (!isWithinOutputBudget(candidate)) {
if (children.length === 0) {
throw new Error('Tool result child cannot fit within the read limit');
}
break;
}
children.push(child);
}
return ensureWithinOutputBudget(
buildContainerOutput(type, pointer, children, offset, totalChildren),
);
}
function buildContainerOutput(
type: 'array' | 'object',
pointer: string,
children: ToolResultChild[],
offset: number,
totalChildren: number,
): ContainerOutput {
const consumedOffset = Math.min(totalChildren, offset + children.length);
const hasMore = consumedOffset < totalChildren;
return {
view: 'json',
pointer,
type,
children,
offset,
totalChildren,
nextOffset: hasMore ? consumedOffset : null,
hasMore,
};
}
function describeChild(
value: unknown,
parentPointer: string,
key?: string,
index?: number,
): ToolResultChild {
const segment = key ?? String(index);
const type = getJsonValueType(value);
const child: ToolResultChild = {
pointer: appendJsonPointer(parentPointer, segment),
type,
...(key === undefined ? {} : { key }),
...(index === undefined ? {} : { index }),
};
if (typeof value === 'string') {
child.charCount = value.length;
if (value.length <= MAX_INLINE_STRING_CHARS) child.value = value;
} else if (Array.isArray(value)) {
child.childCount = value.length;
} else if (isJsonObject(value)) {
child.childCount = Object.keys(value).length;
} else if (value === null || typeof value === 'boolean' || typeof value === 'number') {
child.value = value;
}
return child;
}
function resolveJsonPointer(root: unknown, pointer: string): unknown {
if (pointer === '') return root;
if (!pointer.startsWith('/')) {
throw new Error('Invalid JSON Pointer');
}
let value = root;
for (const encodedSegment of pointer.slice(1).split('/')) {
const segment = decodeJsonPointerSegment(encodedSegment);
if (Array.isArray(value)) {
if (!/^(0|[1-9]\d*)$/.test(segment)) {
throw new Error('JSON Pointer does not reference a stored value');
}
const index = Number(segment);
if (!Number.isSafeInteger(index) || index >= value.length) {
throw new Error('JSON Pointer does not reference a stored value');
}
value = value[index];
continue;
}
if (isJsonObject(value) && Object.hasOwn(value, segment)) {
value = value[segment];
continue;
}
throw new Error('JSON Pointer does not reference a stored value');
}
return value;
}
function decodeJsonPointerSegment(segment: string): string {
if (/~(?:[^01]|$)/.test(segment)) {
throw new Error('Invalid JSON Pointer');
}
return segment.replaceAll('~1', '/').replaceAll('~0', '~');
}
function appendJsonPointer(pointer: string, segment: string): string {
return `${pointer}/${segment.replaceAll('~', '~0').replaceAll('/', '~1')}`;
}
function getJsonValueType(value: unknown): JsonValueType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
if (isJsonObject(value)) return 'object';
if (typeof value === 'string') return 'string';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
throw new Error('Stored tool result contains an unsupported JSON value');
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isWithinOutputBudget(output: ToolResultOutput): boolean {
return Buffer.byteLength(JSON.stringify(output), 'utf-8') <= MAX_SERIALIZED_OUTPUT_BYTES;
}
function ensureWithinOutputBudget<T extends ToolResultOutput>(output: T): T {
if (!isWithinOutputBudget(output)) {
throw new Error('Tool result response cannot fit within the read limit');
}
return output;
}

View File

@ -11,6 +11,7 @@ import { createMkdirTool } from './mkdir';
import { createMoveFileTool } from './move-file';
import { createKillProcessTool, createListProcessesTool } from './process-tools';
import { createReadFileTool } from './read-file';
import { createReadToolResultTool } from './read-tool-result';
import { createRmdirTool } from './rmdir';
import { createStrReplaceFileTool } from './str-replace-file';
import { createWriteFileTool } from './write-file';
@ -20,11 +21,21 @@ interface WorkspaceLike {
sandbox?: WorkspaceSandbox;
}
export const CORE_WORKSPACE_TOOL_NAMES: ReadonlySet<string> = new Set([
'workspace_read_file',
'workspace_read_tool_result',
'workspace_write_file',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_execute_command',
]);
export function createWorkspaceTools(workspace: WorkspaceLike): BuiltTool[] {
const tools: BuiltTool[] = [];
if (workspace.filesystem) {
tools.push(createReadFileTool(workspace.filesystem));
tools.push(createReadToolResultTool(workspace.filesystem));
tools.push(createStrReplaceFileTool(workspace.filesystem));
tools.push(createBatchStrReplaceFileTool(workspace.filesystem));
tools.push(createWriteFileTool(workspace.filesystem));

View File

@ -140,7 +140,6 @@ export interface WorkspaceSandbox {
readonly provider: string;
status: ProviderStatus;
getInstructions?(): string;
getDefaultCommandEnv?(): NodeJS.ProcessEnv;
executeCommand?(
command: string,
args?: string[],

View File

@ -89,8 +89,4 @@ export class AgentsConfig {
/** Default command timeout in the sandbox (milliseconds). */
@Env('N8N_AGENTS_AI_SANDBOX_TIMEOUT')
sandboxTimeout: number = 5 * Time.minutes.toMilliseconds;
/** When true, Daytona deletes the knowledge sandbox when it stops. */
@Env('N8N_AGENTS_AI_SANDBOX_EPHEMERAL')
sandboxEphemeral: boolean = false;
}

View File

@ -684,7 +684,6 @@ describe('GlobalConfig', () => {
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: 'daytonaio/sandbox:0.8.0',
sandboxTimeout: 300000,
sandboxEphemeral: false,
},
} satisfies GlobalConfigShape;
@ -725,15 +724,6 @@ describe('GlobalConfig', () => {
expect(config.agents.tracingRecordOutputs).toBe(false);
});
it('should parse N8N_AGENTS_AI_SANDBOX_EPHEMERAL from env variables', () => {
process.env = {
N8N_AGENTS_AI_SANDBOX_EPHEMERAL: 'true',
};
const config = Container.get(GlobalConfig);
expect(config.agents.sandboxEphemeral).toBe(true);
});
it('should parse N8N_AGENTS_AI_SANDBOX_SNAPSHOT from env variables', () => {
process.env = {
N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3',
@ -759,7 +749,6 @@ describe('GlobalConfig', () => {
N8N_PASSWORD_MIN_LENGTH: '12',
N8N_ENFORCE_GLOBAL_USER_AGENT: 'true',
N8N_GLOBAL_USER_AGENT_VALUE: 'AcmeCorp/1.0',
N8N_AGENTS_AI_SANDBOX_EPHEMERAL: 'true',
N8N_AGENTS_AI_SANDBOX_SNAPSHOT: 'n8n/agent-knowledge:1.2.3',
};
const config = Container.get(GlobalConfig);
@ -817,7 +806,6 @@ describe('GlobalConfig', () => {
},
agents: {
...defaultConfig.agents,
sandboxEphemeral: true,
sandboxSnapshot: 'n8n/agent-knowledge:1.2.3',
},
});

View File

@ -1,6 +1,5 @@
import { createWorkspaceTools } from '@n8n/agents';
import { CORE_WORKSPACE_TOOL_NAMES, createWorkspaceTools } from '@n8n/agents';
import { INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST } from '../../src/workspace/lazy-runtime-workspace';
import { IGNORED_WORKSPACE_TOOLS, REPLAYED_WORKSPACE_TOOL_ARGS } from '../harness/langsmith-seed';
// The seed reconstructor replays @n8n/agents workspace file ops by matching tool
@ -34,27 +33,6 @@ describe('workspace tool contract (@n8n/agents drift guard)', () => {
}
});
it('exposes only the Instance AI allowlisted tools to agents', () => {
const liveTools = createWorkspaceTools({
filesystem: {},
sandbox: {
executeCommand: vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
executionTimeMs: 0,
}),
},
} as unknown as Parameters<typeof createWorkspaceTools>[0]);
const liveNames = new Set(liveTools.map((tool) => tool.name));
for (const name of INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST) {
expect(liveNames.has(name), name).toBe(true);
}
expect(liveNames.size).toBeGreaterThanOrEqual(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.size);
});
it('does not expose removed filesystem helpers via the allowlist', () => {
const removed = [
'workspace_append_file',
@ -67,7 +45,7 @@ describe('workspace tool contract (@n8n/agents drift guard)', () => {
'workspace_move_file',
];
for (const name of removed) {
expect(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.has(name)).toBe(false);
expect(CORE_WORKSPACE_TOOL_NAMES.has(name)).toBe(false);
// Historical seeds may still replay these — they remain in @n8n/agents.
expect(liveByName.has(name), `${name} should still exist for seed replay`).toBe(true);
}

View File

@ -53,6 +53,7 @@ export const REPLAYED_WORKSPACE_TOOL_ARGS: Record<string, readonly string[]> = {
* the contract test can prove every live filesystem tool is classified. */
export const IGNORED_WORKSPACE_TOOLS: readonly string[] = [
'workspace_read_file',
'workspace_read_tool_result',
'workspace_list_files',
'workspace_file_stat',
'workspace_mkdir',

View File

@ -39,7 +39,6 @@ import type * as BuilderTemplatesServiceMod from './workspace/builder-templates-
import type * as CreateWorkspaceMod from './workspace/create-workspace';
import type * as LazyRuntimeWorkspaceMod from './workspace/lazy-runtime-workspace';
import type * as SandboxSetupMod from './workspace/sandbox-setup';
import type * as ScopedWorkspaceMod from './workspace/scoped-workspace';
import type * as SnapshotManagerMod from './workspace/snapshot-manager';
type LazyFunction = (...args: never[]) => unknown;
@ -144,9 +143,6 @@ const loadLazyRuntimeWorkspace = lazyModule(
const loadSandboxSetup = lazyModule(
() => require('./workspace/sandbox-setup') as typeof SandboxSetupMod,
);
const loadScopedWorkspace = lazyModule(
() => require('./workspace/scoped-workspace') as typeof ScopedWorkspaceMod,
);
const loadSnapshotManager = lazyModule(
() => require('./workspace/snapshot-manager') as typeof SnapshotManagerMod,
);
@ -452,9 +448,6 @@ export const setupSandboxWorkspace: typeof SandboxSetupMod.setupSandboxWorkspace
() => loadSandboxSetup().setupSandboxWorkspace,
);
export type BuilderTemplatesService = BuilderTemplatesServiceMod.BuilderTemplatesService;
export const createScopedWorkspace: typeof ScopedWorkspaceMod.createScopedWorkspace = lazyFunction(
() => loadScopedWorkspace().createScopedWorkspace,
);
export const BuilderTemplatesService: typeof BuilderTemplatesServiceMod.BuilderTemplatesService =
lazyClass(() => loadBuilderTemplatesService().BuilderTemplatesService);
export const builderTemplatesOptionsFromEnv: typeof BuilderTemplatesServiceMod.builderTemplatesOptionsFromEnv =

View File

@ -1,14 +1,12 @@
import {
CORE_WORKSPACE_TOOL_NAMES,
Workspace,
type CommandResult,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents';
import {
INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST,
createLazyRuntimeWorkspace,
} from '../lazy-runtime-workspace';
import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace';
function createMockWorkspace() {
const executeCommand = vi.fn<
@ -69,7 +67,6 @@ function createMockWorkspace() {
await Promise.resolve();
}),
getInstructions: vi.fn(() => 'Real sandbox instructions.'),
getDefaultCommandEnv: vi.fn(() => ({ CUSTOM_ENV: 'enabled' })),
executeCommand,
};
@ -91,9 +88,7 @@ describe('createLazyRuntimeWorkspace', () => {
lazyWorkspace.getInstructions();
expect(ensureWorkspace).not.toHaveBeenCalled();
expect(tools.map((tool) => tool.name).sort()).toEqual(
[...INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST].sort(),
);
expect(tools.map((tool) => tool.name).sort()).toEqual([...CORE_WORKSPACE_TOOL_NAMES].sort());
expect(tools.some((tool) => tool.name === 'workspace_list_files')).toBe(false);
expect(tools.some((tool) => tool.name === 'workspace_append_file')).toBe(false);
@ -103,24 +98,6 @@ describe('createLazyRuntimeWorkspace', () => {
expect(ensureWorkspace).toHaveBeenCalledTimes(1);
});
it('merges sandbox default env after the real workspace is created', async () => {
const { workspace, executeCommand } = createMockWorkspace();
const ensureWorkspace = vi.fn(async () => await Promise.resolve(workspace));
const lazyWorkspace = createLazyRuntimeWorkspace({ ensureWorkspace });
const executeCommandTool = lazyWorkspace
.getTools()
.find((tool) => tool.name === 'workspace_execute_command');
const result = await executeCommandTool?.handler?.({ command: 'echo $CUSTOM_ENV' }, {});
expect(result).toMatchObject({ stdout: 'enabled' });
expect(executeCommand.mock.calls[0]?.[0]).toBe('echo $CUSTOM_ENV');
expect(executeCommand.mock.calls[0]?.[1]).toEqual([]);
expect(executeCommand.mock.calls[0]?.[2]?.env).toMatchObject({
CUSTOM_ENV: 'enabled',
});
});
it('retries workspace creation after the first lazy initialization fails', async () => {
const { workspace } = createMockWorkspace();
const ensureWorkspace = vi

View File

@ -1,6 +1,7 @@
import {
BaseFilesystem,
BaseSandbox,
CORE_WORKSPACE_TOOL_NAMES,
Workspace,
raceWithAbort,
type AbortableOptions,
@ -23,15 +24,6 @@ import {
export type RuntimeWorkspaceResolver = () => Promise<Workspace | undefined>;
/** Workspace tools exposed to Instance AI agents — read/write/replace/execute only. */
export const INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST = new Set([
'workspace_read_file',
'workspace_write_file',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_execute_command',
]);
export interface LazyRuntimeWorkspaceOptions {
ensureWorkspace: RuntimeWorkspaceResolver;
id?: string;
@ -69,7 +61,7 @@ export function createLazyRuntimeWorkspace({
const baseGetTools = workspace.getTools.bind(workspace);
workspace.getTools = () =>
baseGetTools().filter((tool) => INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.has(tool.name));
baseGetTools().filter((tool) => CORE_WORKSPACE_TOOL_NAMES.has(tool.name));
return workspace;
}
@ -320,10 +312,6 @@ class LazyRuntimeSandbox extends BaseSandbox {
await this.resolver.destroyResolvedWorkspace();
}
getDefaultCommandEnv(): NodeJS.ProcessEnv {
return this.resolver.current?.sandbox?.getDefaultCommandEnv?.() ?? {};
}
override async executeCommand(
command: string,
args: string[] = [],
@ -334,12 +322,8 @@ class LazyRuntimeSandbox extends BaseSandbox {
throw new Error('Instance AI runtime sandbox does not support command execution.');
}
const defaultEnv = sandbox.getDefaultCommandEnv?.();
try {
return await sandbox.executeCommand(command, args, {
...options,
...(defaultEnv ? { env: { ...defaultEnv, ...options?.env } } : {}),
});
return await sandbox.executeCommand(command, args, options);
} finally {
this.syncStatus(sandbox);
}

View File

@ -8,6 +8,7 @@ import type { Telemetry } from '@/telemetry';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import { AgentExecutionService, type RecordMessageParams } from '../agent-execution.service';
import type { AgentExecutionUpdateBroadcaster } from '../agent-execution-update-broadcaster';
import type { AgentWorkspaceService } from '../agent-workspace.service';
import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity';
import type { AgentExecution } from '../entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from '../execution-recorder';
@ -66,6 +67,7 @@ describe('AgentExecutionService', () => {
let errorReporter: Mocked<ErrorReporter>;
let agentChatAttachmentService: Mocked<AgentChatAttachmentService>;
let executionUpdateBroadcaster: Mocked<AgentExecutionUpdateBroadcaster>;
let agentWorkspaceService: Mocked<AgentWorkspaceService>;
beforeEach(() => {
vi.clearAllMocks();
@ -83,6 +85,8 @@ describe('AgentExecutionService', () => {
errorReporter = mock<ErrorReporter>();
agentChatAttachmentService = mock<AgentChatAttachmentService>();
executionUpdateBroadcaster = mock<AgentExecutionUpdateBroadcaster>();
agentWorkspaceService = mock<AgentWorkspaceService>();
agentWorkspaceService.cleanupThreadWorkspace.mockResolvedValue();
service = new AgentExecutionService(
mockLogger(),
@ -95,6 +99,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
agentWorkspaceService,
);
});
@ -281,6 +286,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
agentWorkspaceService,
);
const record = makeMessageRecord({
@ -345,6 +351,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
agentWorkspaceService,
);
const record = makeMessageRecord({
@ -758,6 +765,7 @@ describe('AgentExecutionService', () => {
storageConfig,
errorReporter,
executionUpdateBroadcaster,
agentWorkspaceService,
);
const partial = [{ type: 'text', content: 'Partial', timestamp: 1, endTime: 2 }] as const;
agentExecutionRepository.updateIfRunning.mockResolvedValue(true);
@ -918,7 +926,7 @@ describe('AgentExecutionService', () => {
});
describe('deleteThread', () => {
it('cleans SDK memory before deleting the execution thread', async () => {
it('performs workspace cleanup when deleting an execution thread', async () => {
agentExecutionThreadRepository.findOneBy.mockResolvedValue({
id: 'thread-1',
agentId: 'agent-1',
@ -939,6 +947,11 @@ describe('AgentExecutionService', () => {
expect(agentChatAttachmentService.deleteByThread).toHaveBeenCalledWith('thread-1', {
projectId: 'project-1',
});
expect(agentWorkspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith(
'project-1',
'agent-1',
'thread-1',
);
expect(agentExecutionThreadRepository.delete).toHaveBeenCalledWith({ id: 'thread-1' });
});

View File

@ -1,144 +1,32 @@
import type { Mocked } from 'vitest';
import type {
CommandResult,
DaytonaSandboxConfig,
SandboxProvider,
WorkspaceFilesystem,
WorkspaceSandbox,
} from '@n8n/agents/sandbox';
import type { Logger } from '@n8n/backend-common';
import type { AgentsConfig } from '@n8n/config';
import type { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { mock } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type { AiService } from '../../../services/ai.service';
import type { SandboxSettingsService } from '../../../services/sandbox-settings.service';
import type { AgentKnowledgeFileStore } from '../agent-knowledge-file-store';
import type { Agent } from '../entities/agent.entity';
import type { AgentFile } from '../entities/agent-file.entity';
import { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
import { getAgentKnowledgePaths } from '../agent-knowledge-storage';
import {
AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX,
AgentKnowledgeSandboxService,
} from '../agent-knowledge-sandbox.service';
import type {
AgentSandboxRuntime,
AgentSandboxRuntimeService,
} from '../agent-sandbox-runtime.service';
import type { AgentFile } from '../entities/agent-file.entity';
import type { AgentFileRepository } from '../repositories/agent-file.repository';
import type { AgentRepository } from '../repositories/agent.repository';
const { createSandboxMock, createFilesystemMock } = vi.hoisted(() => ({
createSandboxMock: vi.fn(),
createFilesystemMock: vi.fn(),
}));
vi.mock('@n8n/agents/sandbox', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents/sandbox')>()),
createSandbox: createSandboxMock,
createFilesystem: createFilesystemMock,
}));
const instanceId = 'instance-1';
const projectId = 'project-1';
const agentId = 'agent-1';
const knowledgePaths = getAgentKnowledgePaths('daytona');
const n8nKnowledgePaths = getAgentKnowledgePaths('n8n-sandbox');
type TestWorkspaceSandbox = WorkspaceSandbox &
Required<Pick<WorkspaceSandbox, '_start' | 'destroy' | 'executeCommand'>>;
function buildExpectedSandboxName(): string {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase();
}
function makeAiService(overrides: Partial<AiService> = {}): AiService {
const aiService = mock<AiService>();
aiService.isProxyEnabled.mockReturnValue(false);
return Object.assign(aiService, overrides);
}
function makeProxyAiService(): AiService {
const client = mock<AiAssistantClient>();
client.getBuilderApiProxyToken.mockResolvedValue({
accessToken: 'proxy-token',
tokenType: 'Bearer',
});
client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example');
return makeAiService({
isProxyEnabled: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockResolvedValue(client),
});
}
function makeAgentRepository(): ReturnType<typeof mock<AgentRepository>> {
const repository = mock<AgentRepository>();
// Unpublished on purpose: the knowledge sandbox must not require a published version.
repository.findByIdAndProjectId.mockResolvedValue({
id: agentId,
projectId,
activeVersionId: null,
} as Agent);
return repository;
}
function makeKnowledgeFileStore(): Mocked<AgentKnowledgeFileStore> {
const store = mock<AgentKnowledgeFileStore>();
store.readAsBuffer.mockResolvedValue(Buffer.from('mock file content'));
return store;
}
function makeSandboxSettingsService(
provider: SandboxProvider = 'daytona',
): Mocked<SandboxSettingsService> {
const service = mock<SandboxSettingsService>();
service.getProvider.mockReturnValue(provider);
service.resolveDaytonaConfig.mockResolvedValue({
apiUrl: 'https://daytona.example',
apiKey: 'test-key',
});
service.resolveN8nSandboxConfig.mockResolvedValue({
serviceUrl: 'https://sandbox.example',
apiKey: 'sandbox-key',
});
return service;
}
function makeService({
configOverrides = {},
logger = mock<Logger>(),
aiService = makeAiService(),
instanceSettings = mock<InstanceSettings>({ instanceId }),
agentFileRepository = mock<AgentFileRepository>(),
agentRepository = makeAgentRepository(),
agentKnowledgeFileStore = makeKnowledgeFileStore(),
sandboxSettingsService = makeSandboxSettingsService(),
}: {
configOverrides?: Partial<AgentsConfig>;
logger?: Logger;
aiService?: AiService;
instanceSettings?: InstanceSettings;
agentFileRepository?: AgentFileRepository;
agentRepository?: AgentRepository;
agentKnowledgeFileStore?: AgentKnowledgeFileStore;
sandboxSettingsService?: SandboxSettingsService;
} = {}): AgentKnowledgeSandboxService {
return new AgentKnowledgeSandboxService(
{
sandboxEnabled: true,
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: '',
sandboxTimeout: 300_000,
sandboxEphemeral: false,
...configOverrides,
} as AgentsConfig,
logger,
aiService,
instanceSettings,
agentFileRepository,
agentRepository,
agentKnowledgeFileStore,
sandboxSettingsService,
);
}
type TestWorkspaceSandbox = WorkspaceSandbox & { provider: SandboxProvider } & Required<
Pick<WorkspaceSandbox, 'executeCommand'>
>;
function makeAgentFile(overrides: Partial<AgentFile> = {}): AgentFile {
const id = overrides.id ?? 'file-id';
@ -157,30 +45,10 @@ function makeAgentFile(overrides: Partial<AgentFile> = {}): AgentFile {
} as AgentFile;
}
function makeFilesystem(): Mocked<WorkspaceFilesystem> {
return mock<WorkspaceFilesystem>();
}
function makeSandbox(
provider: SandboxProvider = 'daytona',
id = 'sandbox-id',
): Mocked<TestWorkspaceSandbox> {
const sandbox = mock<TestWorkspaceSandbox>({
id,
name: provider === 'daytona' ? 'DaytonaSandbox' : 'N8nSandboxServiceSandbox',
provider,
status: 'pending',
});
sandbox._start.mockResolvedValue();
sandbox.destroy.mockResolvedValue();
sandbox.executeCommand.mockResolvedValue({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
executionTimeMs: 1,
});
return sandbox;
function makeKnowledgeFileStore(): ReturnType<typeof mock<AgentKnowledgeFileStore>> {
const store = mock<AgentKnowledgeFileStore>();
store.readAsBuffer.mockResolvedValue(Buffer.from('mock file content'));
return store;
}
function makeCommandResult(stdout = '', stderr = '', exitCode = 0): CommandResult {
@ -193,160 +61,80 @@ function makeCommandResult(stdout = '', stderr = '', exitCode = 0): CommandResul
};
}
describe('AgentKnowledgeSandboxService', () => {
let sandbox: Mocked<TestWorkspaceSandbox>;
let filesystem: Mocked<WorkspaceFilesystem>;
function makeSandbox(
provider: SandboxProvider = 'daytona',
id = 'sandbox-id',
): ReturnType<typeof mock<TestWorkspaceSandbox>> {
const sandbox = mock<TestWorkspaceSandbox>({
id,
name: provider === 'daytona' ? 'DaytonaSandbox' : 'N8nSandboxServiceSandbox',
provider,
status: 'running',
});
sandbox.executeCommand.mockResolvedValue(makeCommandResult());
return sandbox;
}
function makeRuntime(
sandbox: TestWorkspaceSandbox,
filesystem: WorkspaceFilesystem,
): AgentSandboxRuntime {
return {
provider: sandbox.provider,
sandbox,
filesystem,
workspaceRoot:
sandbox.provider === 'daytona' ? '/home/daytona/workspace' : '/home/node/workspace',
cacheKey: `${sandbox.provider}:agent:${sandbox.id}`,
};
}
function makeRuntimeService(
runtime: AgentSandboxRuntime,
): ReturnType<typeof mock<AgentSandboxRuntimeService>> {
const service = mock<AgentSandboxRuntimeService>();
service.acquireSandbox.mockResolvedValue(runtime);
service.executeSandboxCommand.mockImplementation(
async (sandbox, command, timeout) =>
await (sandbox.executeCommand?.(command, [], { timeout }) ??
Promise.reject(new Error('Command execution unavailable'))),
);
return service;
}
function makeService({
runtimeService,
logger = mock<Logger>(),
agentFileRepository = mock<AgentFileRepository>(),
agentRepository = mock<AgentRepository>(),
agentKnowledgeFileStore = makeKnowledgeFileStore(),
}: {
runtimeService: AgentSandboxRuntimeService;
logger?: Logger;
agentFileRepository?: AgentFileRepository;
agentRepository?: AgentRepository;
agentKnowledgeFileStore?: AgentKnowledgeFileStore;
}): AgentKnowledgeMirrorService {
return new AgentKnowledgeMirrorService(
{ sandboxTimeout: 300_000 } as AgentsConfig,
logger,
runtimeService,
agentFileRepository,
agentRepository,
agentKnowledgeFileStore,
);
}
describe('AgentKnowledgeMirrorService', () => {
let sandbox: ReturnType<typeof mock<TestWorkspaceSandbox>>;
let filesystem: ReturnType<typeof mock<WorkspaceFilesystem>>;
let runtimeService: ReturnType<typeof mock<AgentSandboxRuntimeService>>;
beforeEach(() => {
vi.clearAllMocks();
sandbox = makeSandbox();
filesystem = makeFilesystem();
createSandboxMock.mockResolvedValue(sandbox);
createFilesystemMock.mockReturnValue(filesystem);
});
it('creates and starts a deterministic direct-mode Daytona sandbox', async () => {
const aiService = makeAiService();
const sandboxSettingsService = makeSandboxSettingsService();
const service = makeService({
configOverrides: {
sandboxSnapshot: 'n8n/agent-knowledge:1.2.3',
sandboxEphemeral: true,
},
aiService,
sandboxSettingsService,
});
const expectedName = buildExpectedSandboxName();
await service.warmSandbox(projectId, agentId);
expect(aiService.getClient).not.toHaveBeenCalled();
expect(sandboxSettingsService.resolveDaytonaConfig).toHaveBeenCalled();
expect(createSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
provider: 'daytona',
id: expectedName,
name: expectedName,
daytonaApiUrl: 'https://daytona.example',
daytonaApiKey: 'test-key',
labels: {
'n8n-agents-knowledgebase': 'true',
'n8n-project-id': projectId,
'n8n-agent-id': agentId,
},
timeout: 300_000,
createTimeoutSeconds: 300,
image: 'daytonaio/sandbox:0.5.0',
snapshot: 'n8n/agent-knowledge:1.2.3',
ephemeral: true,
autoStopInterval: 5,
}),
expect.anything(),
);
expect(sandbox._start).toHaveBeenCalled();
expect(createFilesystemMock).toHaveBeenCalledWith(sandbox);
});
it('single-flights concurrent acquisition for the same project and agent', async () => {
let resolveCreation: (value: WorkspaceSandbox) => void;
createSandboxMock.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const service = makeService();
const first = service.warmSandbox(projectId, agentId);
const second = service.warmSandbox(projectId, agentId);
await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(1));
resolveCreation!(sandbox);
await Promise.all([first, second]);
});
it('uses a stable UUID for the n8n sandbox', async () => {
const expectedId = 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0';
const service = makeService({
sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'),
});
await service.warmSandbox(projectId, agentId);
expect(createSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'n8n-sandbox',
id: expectedId,
serviceUrl: 'https://sandbox.example',
apiKey: 'sandbox-key',
}),
expect.anything(),
);
});
it('reports how to configure a missing n8n sandbox service URL', async () => {
const settingsService = makeSandboxSettingsService('n8n-sandbox');
settingsService.resolveN8nSandboxConfig.mockResolvedValue({});
const service = makeService({ sandboxSettingsService: settingsService });
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
/N8N_SANDBOX_SERVICE_URL/,
);
expect(createSandboxMock).not.toHaveBeenCalled();
});
it('mints refreshable proxy tokens on demand with project scope', async () => {
const client = mock<AiAssistantClient>();
client.getBuilderApiProxyToken
.mockResolvedValueOnce({ accessToken: 'proxy-token-1', tokenType: 'Bearer' })
.mockResolvedValueOnce({ accessToken: 'proxy-token-2', tokenType: 'Bearer' });
client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example');
const aiService = makeAiService({
isProxyEnabled: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockResolvedValue(client),
});
const service = makeService({
configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' },
aiService,
});
await service.warmSandbox(projectId, agentId);
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
expect(config.daytonaApiUrl).toBe('https://sandbox-proxy.example');
expect(config.snapshot).toBe('n8n/agent-knowledge:1.2.3');
expect(config.image).toBeUndefined();
expect(client.getBuilderApiProxyToken).not.toHaveBeenCalled();
await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-1');
await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-2');
expect(client.getBuilderApiProxyToken).toHaveBeenCalledTimes(2);
expect(client.getBuilderApiProxyToken.mock.calls[0][0]).toEqual({ id: projectId });
expect(client.getBuilderApiProxyToken.mock.calls[1][0]).toEqual({ id: projectId });
});
it('fails with an actionable error instead of creating from an image when the proxy is enabled', async () => {
const service = makeService({
configOverrides: { sandboxSnapshot: '' },
aiService: makeProxyAiService(),
});
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
/requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s,
);
expect(createSandboxMock).not.toHaveBeenCalled();
});
it('surfaces the snapshot failure instead of falling back to an image when the proxy is enabled', async () => {
createSandboxMock.mockRejectedValueOnce(new Error('snapshot missing'));
const service = makeService({
configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:missing' },
aiService: makeProxyAiService(),
});
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing');
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
expect(config.snapshot).toBe('n8n/agent-knowledge:missing');
expect(config.image).toBeUndefined();
filesystem = mock<WorkspaceFilesystem>();
runtimeService = makeRuntimeService(makeRuntime(sandbox, filesystem));
});
describe('globKnowledgeFiles', () => {
@ -357,15 +145,12 @@ describe('AgentKnowledgeSandboxService', () => {
makeAgentFile({ id: 'file-delta', fileName: 'delta.md' }),
];
function makeGlobService(): AgentKnowledgeSandboxService {
function makeGlobService(): AgentKnowledgeMirrorService {
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue(fixtureFiles);
const agentRepository = mock<AgentRepository>();
agentRepository.existsBy.mockResolvedValue(true);
return makeService({
agentFileRepository,
agentRepository,
});
return makeService({ runtimeService, agentFileRepository, agentRepository });
}
it('lists all files with a catch-all pattern, sorted by display name', async () => {
@ -412,7 +197,7 @@ describe('AgentKnowledgeSandboxService', () => {
expect(secondPage.hasMore).toBe(false);
});
it('still rejects unsafe patterns', async () => {
it('rejects unsafe patterns', async () => {
const service = makeGlobService();
await expect(
@ -421,40 +206,6 @@ describe('AgentKnowledgeSandboxService', () => {
});
});
describe('destroySandbox', () => {
it('best-effort destroys both provider sandboxes by their deterministic identities', async () => {
const daytonaSandbox = makeSandbox('daytona', buildExpectedSandboxName());
daytonaSandbox.destroy.mockRejectedValue(new Error('remote unavailable'));
const n8nSandbox = makeSandbox('n8n-sandbox', 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0');
createSandboxMock.mockImplementation(async (config) =>
config.provider === 'daytona' ? daytonaSandbox : n8nSandbox,
);
const service = makeService({ configOverrides: { sandboxEnabled: false } });
await service.destroySandbox(projectId, agentId);
expect(createSandboxMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
provider: 'daytona',
id: buildExpectedSandboxName(),
name: buildExpectedSandboxName(),
}),
expect.anything(),
);
expect(createSandboxMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
provider: 'n8n-sandbox',
id: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0',
}),
expect.anything(),
);
expect(daytonaSandbox.destroy).toHaveBeenCalled();
expect(n8nSandbox.destroy).toHaveBeenCalled();
});
});
describe('mirror sync', () => {
function isManifestReadCommand(command: string): boolean {
return command.startsWith('cat ') && command.includes('/manifest');
@ -468,6 +219,26 @@ describe('AgentKnowledgeSandboxService', () => {
return makeAgentFile({ id, fileName });
}
function makeMirrorService({
fileRepository,
fileStore = makeKnowledgeFileStore(),
logger = mock<Logger>(),
}: {
fileRepository: AgentFileRepository;
fileStore?: AgentKnowledgeFileStore;
logger?: Logger;
}): AgentKnowledgeMirrorService {
const agentRepository = mock<AgentRepository>();
agentRepository.existsBy.mockResolvedValue(true);
return makeService({
runtimeService,
logger,
agentFileRepository: fileRepository,
agentRepository,
agentKnowledgeFileStore: fileStore,
});
}
it('syncs once, checks an unchanged repeat, and recopies a replaced file', async () => {
let manifestState = '';
sandbox.executeCommand.mockImplementation(async (command) =>
@ -478,13 +249,10 @@ describe('AgentKnowledgeSandboxService', () => {
makeMirrorFile('file-1', 'doc1.txt'),
makeMirrorFile('file-2', 'doc2.txt'),
]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const agentKnowledgeFileStore = makeKnowledgeFileStore();
const service = makeService({
agentFileRepository,
agentRepository,
agentKnowledgeFileStore,
const service = makeMirrorService({
fileRepository: agentFileRepository,
fileStore: agentKnowledgeFileStore,
});
await service.searchKnowledge(projectId, agentId, { pattern: 'foo' });
@ -494,15 +262,10 @@ describe('AgentKnowledgeSandboxService', () => {
expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2);
const stagingDir = filesystem.mkdir.mock.calls[0][0];
expect(stagingDir).toContain(`${knowledgePaths.stagingDir}/`);
expect(filesystem.mkdir).toHaveBeenCalledWith(stagingDir, {
recursive: true,
});
expect(filesystem.mkdir).toHaveBeenCalledWith(stagingDir, { recursive: true });
expect(filesystem.writeFile).toHaveBeenCalledTimes(2);
expect(filesystem.writeFile.mock.calls[0][0]).toBe(`${stagingDir}/doc1.txt`);
expect(filesystem.writeFile.mock.calls[1][0]).toBe(`${stagingDir}/doc2.txt`);
expect(filesystem.writeFile.mock.invocationCallOrder[1]).toBeLessThan(
sandbox.executeCommand.mock.invocationCallOrder[1],
);
expect(filesystem.rmdir).toHaveBeenCalledWith(stagingDir, {
recursive: true,
force: true,
@ -528,7 +291,6 @@ describe('AgentKnowledgeSandboxService', () => {
]);
await service.searchKnowledge(projectId, agentId, { pattern: 'baz' });
commands = sandbox.executeCommand.mock.calls.map(([command]) => command);
expect(commands.filter(isManifestReadCommand)).toHaveLength(1);
const syncCommands = commands.filter(isMirrorSyncCommand);
expect(syncCommands).toHaveLength(1);
expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(1);
@ -557,12 +319,7 @@ describe('AgentKnowledgeSandboxService', () => {
makeMirrorFile('file-1', 'doc1.txt'),
makeMirrorFile('file-2', 'doc2.txt'),
]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
agentFileRepository,
agentRepository,
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
const firstSearch = service.searchKnowledge(projectId, agentId, { pattern: 'first' });
await vi.waitFor(() =>
@ -572,7 +329,6 @@ describe('AgentKnowledgeSandboxService', () => {
);
const secondSearch = service.searchKnowledge(projectId, agentId, { pattern: 'second' });
await vi.waitFor(() => expect(createFilesystemMock).toHaveBeenCalledTimes(2));
await new Promise((resolve) => setTimeout(resolve, 0));
releaseFirstManifestRead();
@ -585,24 +341,15 @@ describe('AgentKnowledgeSandboxService', () => {
it('uses the n8n sandbox home for the knowledge mirror', async () => {
sandbox = makeSandbox('n8n-sandbox', 'n8n-sandbox-id');
createSandboxMock.mockResolvedValue(sandbox);
filesystem = mock<WorkspaceFilesystem>();
runtimeService = makeRuntimeService(makeRuntime(sandbox, filesystem));
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
aiService: makeProxyAiService(),
agentFileRepository,
agentRepository,
sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'),
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
await service.searchKnowledge(projectId, agentId, { pattern: 'foo' });
expect(filesystem.mkdir.mock.calls[0][0]).toContain(`${n8nKnowledgePaths.stagingDir}/`);
expect(filesystem.mkdir).toHaveBeenCalledWith(filesystem.mkdir.mock.calls[0][0], {
recursive: true,
});
const searchCall = sandbox.executeCommand.mock.calls.find(([command]) =>
command.includes(' rg '),
);
@ -610,7 +357,7 @@ describe('AgentKnowledgeSandboxService', () => {
});
it.each([
{ provider: 'daytona', deterministicId: buildExpectedSandboxName() },
{ provider: 'daytona', deterministicId: 'agent-instance-1-project-1-agent-1' },
{
provider: 'n8n-sandbox',
deterministicId: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0',
@ -620,23 +367,14 @@ describe('AgentKnowledgeSandboxService', () => {
async ({ provider, deterministicId }) => {
const staleSandbox = makeSandbox(provider, deterministicId);
const replacementSandbox = makeSandbox(provider, deterministicId);
const staleFilesystem = makeFilesystem();
const replacementFilesystem = makeFilesystem();
createSandboxMock
.mockResolvedValueOnce(staleSandbox)
.mockResolvedValueOnce(replacementSandbox);
createFilesystemMock
.mockReturnValueOnce(staleFilesystem)
.mockReturnValueOnce(replacementFilesystem);
const staleFilesystem = mock<WorkspaceFilesystem>();
const replacementFilesystem = mock<WorkspaceFilesystem>();
runtimeService.acquireSandbox
.mockResolvedValueOnce(makeRuntime(staleSandbox, staleFilesystem))
.mockResolvedValueOnce(makeRuntime(replacementSandbox, replacementFilesystem));
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
agentFileRepository,
agentRepository,
sandboxSettingsService: makeSandboxSettingsService(provider),
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
await service.searchKnowledge(projectId, agentId, { pattern: 'first' });
await service.searchKnowledge(projectId, agentId, { pattern: 'second' });
@ -655,12 +393,7 @@ describe('AgentKnowledgeSandboxService', () => {
);
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
agentFileRepository,
agentRepository,
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
const error = await service
.searchKnowledge(projectId, agentId, { pattern: 'foo' })
@ -676,12 +409,7 @@ describe('AgentKnowledgeSandboxService', () => {
filesystem.writeFile.mockRejectedValue(providerError);
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
agentFileRepository,
agentRepository,
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
await expect(service.searchKnowledge(projectId, agentId, { pattern: 'foo' })).rejects.toBe(
providerError,
@ -701,50 +429,38 @@ describe('AgentKnowledgeSandboxService', () => {
);
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const service = makeService({
agentFileRepository,
agentRepository,
});
const service = makeMirrorService({ fileRepository: agentFileRepository });
await expect(service.searchKnowledge(projectId, agentId, { pattern: 'foo' })).rejects.toThrow(
/Agent knowledge mirror sync failed/,
);
});
it('skips a file that fails to load from the knowledge file store and retries it next sync', async () => {
it('retries a knowledge file on the next sync after its first load fails', async () => {
sandbox.executeCommand.mockResolvedValue(makeCommandResult());
const agentFileRepository = mock<AgentFileRepository>();
agentFileRepository.findByAgentId.mockResolvedValue([makeMirrorFile('file-1', 'doc1.txt')]);
const agentRepository = makeAgentRepository();
agentRepository.existsBy.mockResolvedValue(true);
const agentKnowledgeFileStore = makeKnowledgeFileStore();
agentKnowledgeFileStore.readAsBuffer.mockRejectedValueOnce(new Error('missing on disk'));
const logger = mock<Logger>();
const service = makeService({
const service = makeMirrorService({
fileRepository: agentFileRepository,
fileStore: agentKnowledgeFileStore,
logger,
agentFileRepository,
agentRepository,
agentKnowledgeFileStore,
});
await expect(
service.searchKnowledge(projectId, agentId, { pattern: 'foo' }),
).resolves.toBeDefined();
expect(filesystem.writeFile).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to load agent knowledge file for mirror sync',
expect.objectContaining({ file: 'doc1.txt' }),
);
// The failed file remains absent from the remote manifest, so the
// next sync retries it and `readAsBuffer` succeeds.
await expect(
service.searchKnowledge(projectId, agentId, { pattern: 'bar' }),
).resolves.toBeDefined();
expect(agentKnowledgeFileStore.readAsBuffer).toHaveBeenCalledTimes(2);
expect(filesystem.writeFile).toHaveBeenCalledTimes(1);
});

View File

@ -9,8 +9,9 @@ import { MAX_AGENT_KNOWLEDGE_BASE_SIZE_BYTES } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { AgentKnowledgeFileStore } from '../agent-knowledge-file-store';
import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
import { AgentKnowledgeService } from '../agent-knowledge.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
import type { AgentFile } from '../entities/agent-file.entity';
import type { AgentFileRepository } from '../repositories/agent-file.repository';
import type { AgentRepository } from '../repositories/agent.repository';
@ -134,7 +135,8 @@ class InMemoryAgentFileRepository {
describe('AgentKnowledgeService', () => {
let agentRepository: Mocked<AgentRepository>;
let agentFileRepository: InMemoryAgentFileRepository;
let agentKnowledgeSandboxService: Mocked<AgentKnowledgeSandboxService>;
let agentSandboxRuntimeService: Mocked<AgentSandboxRuntimeService>;
let agentKnowledgeMirrorService: Mocked<AgentKnowledgeMirrorService>;
let agentKnowledgeFileStore: Mocked<AgentKnowledgeFileStore>;
let logger: Mocked<Logger>;
let service: AgentKnowledgeService;
@ -143,7 +145,8 @@ describe('AgentKnowledgeService', () => {
vi.clearAllMocks();
agentRepository = mock<AgentRepository>();
agentFileRepository = new InMemoryAgentFileRepository();
agentKnowledgeSandboxService = mock<AgentKnowledgeSandboxService>();
agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>();
agentKnowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
agentKnowledgeFileStore = mock<AgentKnowledgeFileStore>();
agentKnowledgeFileStore.write.mockImplementation(async (ref, content) => {
await drainIfStream(content);
@ -157,7 +160,8 @@ describe('AgentKnowledgeService', () => {
service = new AgentKnowledgeService(
agentRepository,
agentFileRepository as unknown as AgentFileRepository,
agentKnowledgeSandboxService,
agentSandboxRuntimeService,
agentKnowledgeMirrorService,
agentKnowledgeFileStore,
logger,
);
@ -411,7 +415,7 @@ describe('AgentKnowledgeService', () => {
} as never);
await expect(service.warmSandbox(agentId, projectId)).resolves.toBeUndefined();
expect(agentKnowledgeSandboxService.warmSandbox).toHaveBeenCalledWith(projectId, agentId);
expect(agentSandboxRuntimeService.warmSandbox).toHaveBeenCalledWith(projectId, agentId);
});
it('pre-warms the mirror after a successful upload', async () => {
@ -433,7 +437,7 @@ describe('AgentKnowledgeService', () => {
}),
]);
expect(agentKnowledgeSandboxService.prewarmMirrorInBackground).toHaveBeenCalledWith(
expect(agentKnowledgeMirrorService.prewarmMirrorInBackground).toHaveBeenCalledWith(
projectId,
agentId,
);
@ -449,7 +453,7 @@ describe('AgentKnowledgeService', () => {
await service.deleteFile(agentId, projectId, 'file-1');
expect(agentKnowledgeSandboxService.prewarmMirrorInBackground).toHaveBeenCalledWith(
expect(agentKnowledgeMirrorService.prewarmMirrorInBackground).toHaveBeenCalledWith(
projectId,
agentId,
);

View File

@ -40,8 +40,8 @@ import { AgentCustomToolsService } from '../agent-custom-tools.service';
import { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
import type { AgentExecutionService } from '../agent-execution.service';
import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { AgentKnowledgeService } from '../agent-knowledge.service';
import { AgentPublishService } from '../agent-publish.service';
import type { AgentSetupCompletionService } from '../agent-setup-completion.service';
@ -54,6 +54,7 @@ import type { AgentTaskService } from '../agent-task.service';
import { AgentsService } from '../agents.service';
import { AgentTestChatService } from '../agent-test-chat.service';
import { AgentValidationService } from '../agent-validation.service';
import type { AgentWorkspaceService } from '../agent-workspace.service';
import type { AgentHistory } from '../entities/agent-history.entity';
import type { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity';
import type { Agent } from '../entities/agent.entity';
@ -132,7 +133,8 @@ function makeRuntimeReconstructionService(
{ modules } as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
mock<AgentWorkspaceService>(),
mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
@ -311,7 +313,11 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
mock<AgentSetupCompletionService>(),
mock<AgentModificationTelemetryService>(),
);
agentTestChatService = new AgentTestChatService(n8nMemory, mock<AgentChatAttachmentService>());
agentTestChatService = new AgentTestChatService(
n8nMemory,
mock<AgentChatAttachmentService>(),
mock<AgentWorkspaceService>(),
);
agentsService = new AgentsService(
logger,
agentRepository,

View File

@ -23,8 +23,9 @@ import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { AgentWorkspaceService } from '../agent-workspace.service';
import type { Agent } from '../entities/agent.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
@ -135,7 +136,8 @@ function makeService(overrides: {
{ modules: [] } as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
mock<AgentWorkspaceService>(),
mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
credentialsFinderService,

View File

@ -0,0 +1,320 @@
import type {
DaytonaSandboxConfig,
SandboxProvider,
WorkspaceFilesystem,
WorkspaceSandbox,
} from '@n8n/agents/sandbox';
import type { Logger } from '@n8n/backend-common';
import type { AgentsConfig } from '@n8n/config';
import type { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
import { mock } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type { AiService } from '../../../services/ai.service';
import type { SandboxSettingsService } from '../../../services/sandbox-settings.service';
import type { Agent } from '../entities/agent.entity';
import {
AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX,
AgentSandboxRuntimeService,
} from '../agent-sandbox-runtime.service';
import type { AgentRepository } from '../repositories/agent.repository';
const { createSandboxMock, createFilesystemMock } = vi.hoisted(() => ({
createSandboxMock: vi.fn(),
createFilesystemMock: vi.fn(),
}));
vi.mock('@n8n/agents/sandbox', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents/sandbox')>()),
createSandbox: createSandboxMock,
createFilesystem: createFilesystemMock,
}));
const instanceId = 'instance-1';
const projectId = 'project-1';
const agentId = 'agent-1';
type TestWorkspaceSandbox = WorkspaceSandbox &
Required<Pick<WorkspaceSandbox, '_start' | 'destroy' | 'executeCommand'>>;
function buildExpectedSandboxName(): string {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${instanceId}-${projectId}-${agentId}`.toLowerCase();
}
function makeAiService(overrides: Partial<AiService> = {}): AiService {
const aiService = mock<AiService>();
aiService.isProxyEnabled.mockReturnValue(false);
return Object.assign(aiService, overrides);
}
function makeProxyAiService(): AiService {
const client = mock<AiAssistantClient>();
client.getBuilderApiProxyToken.mockResolvedValue({
accessToken: 'proxy-token',
tokenType: 'Bearer',
});
client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example');
return makeAiService({
isProxyEnabled: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockResolvedValue(client),
});
}
function makeAgentRepository(): ReturnType<typeof mock<AgentRepository>> {
const repository = mock<AgentRepository>();
repository.findByIdAndProjectId.mockResolvedValue({
id: agentId,
projectId,
activeVersionId: null,
} as Agent);
return repository;
}
function makeSandboxSettingsService(
provider: SandboxProvider = 'daytona',
): ReturnType<typeof mock<SandboxSettingsService>> {
const service = mock<SandboxSettingsService>();
service.getProvider.mockReturnValue(provider);
service.resolveDaytonaConfig.mockResolvedValue({
apiUrl: 'https://daytona.example',
apiKey: 'test-key',
});
service.resolveN8nSandboxConfig.mockResolvedValue({
serviceUrl: 'https://sandbox.example',
apiKey: 'sandbox-key',
});
return service;
}
function makeService({
configOverrides = {},
logger = mock<Logger>(),
aiService = makeAiService(),
instanceSettings = mock<InstanceSettings>({ instanceId }),
agentRepository = makeAgentRepository(),
sandboxSettingsService = makeSandboxSettingsService(),
}: {
configOverrides?: Partial<AgentsConfig>;
logger?: Logger;
aiService?: AiService;
instanceSettings?: InstanceSettings;
agentRepository?: AgentRepository;
sandboxSettingsService?: SandboxSettingsService;
} = {}): AgentSandboxRuntimeService {
return new AgentSandboxRuntimeService(
{
sandboxEnabled: true,
sandboxImage: 'daytonaio/sandbox:0.5.0',
sandboxSnapshot: '',
sandboxTimeout: 300_000,
...configOverrides,
} as AgentsConfig,
logger,
aiService,
instanceSettings,
agentRepository,
sandboxSettingsService,
);
}
function makeSandbox(
provider: SandboxProvider = 'daytona',
id = 'sandbox-id',
): ReturnType<typeof mock<TestWorkspaceSandbox>> {
const sandbox = mock<TestWorkspaceSandbox>({
id,
name: provider === 'daytona' ? 'DaytonaSandbox' : 'N8nSandboxServiceSandbox',
provider,
status: 'pending',
});
sandbox._start.mockResolvedValue();
sandbox.destroy.mockResolvedValue();
return sandbox;
}
describe('AgentSandboxRuntimeService', () => {
let sandbox: ReturnType<typeof mock<TestWorkspaceSandbox>>;
beforeEach(() => {
vi.clearAllMocks();
sandbox = makeSandbox();
createSandboxMock.mockResolvedValue(sandbox);
createFilesystemMock.mockReturnValue(mock<WorkspaceFilesystem>());
});
it('creates and starts a persistent deterministic direct-mode Daytona sandbox', async () => {
const aiService = makeAiService();
const sandboxSettingsService = makeSandboxSettingsService();
const service = makeService({
configOverrides: {
sandboxSnapshot: 'n8n/agent-knowledge:1.2.3',
},
aiService,
sandboxSettingsService,
});
const expectedName = buildExpectedSandboxName();
await service.warmSandbox(projectId, agentId);
expect(aiService.getClient).not.toHaveBeenCalled();
expect(sandboxSettingsService.resolveDaytonaConfig).toHaveBeenCalled();
expect(createSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
provider: 'daytona',
id: expectedName,
name: expectedName,
daytonaApiUrl: 'https://daytona.example',
daytonaApiKey: 'test-key',
labels: {
'n8n-agents-knowledgebase': 'true',
'n8n-project-id': projectId,
'n8n-agent-id': agentId,
},
timeout: 300_000,
createTimeoutSeconds: 300,
image: 'daytonaio/sandbox:0.5.0',
snapshot: 'n8n/agent-knowledge:1.2.3',
ephemeral: false,
autoStopInterval: 5,
}),
expect.anything(),
);
expect(sandbox._start).toHaveBeenCalled();
expect(createFilesystemMock).toHaveBeenCalledWith(sandbox);
});
it('single-flights concurrent acquisition for the same project and agent', async () => {
let resolveCreation: (value: WorkspaceSandbox) => void;
createSandboxMock.mockReturnValue(
new Promise((resolve) => {
resolveCreation = resolve;
}),
);
const service = makeService();
const first = service.warmSandbox(projectId, agentId);
const second = service.warmSandbox(projectId, agentId);
await vi.waitFor(() => expect(createSandboxMock).toHaveBeenCalledTimes(1));
resolveCreation!(sandbox);
await Promise.all([first, second]);
});
it('uses a stable UUID for the n8n sandbox', async () => {
const expectedId = 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0';
const service = makeService({
sandboxSettingsService: makeSandboxSettingsService('n8n-sandbox'),
});
await service.warmSandbox(projectId, agentId);
expect(createSandboxMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'n8n-sandbox',
id: expectedId,
serviceUrl: 'https://sandbox.example',
apiKey: 'sandbox-key',
}),
expect.anything(),
);
});
it('reports how to configure a missing n8n sandbox service URL', async () => {
const settingsService = makeSandboxSettingsService('n8n-sandbox');
settingsService.resolveN8nSandboxConfig.mockResolvedValue({});
const service = makeService({ sandboxSettingsService: settingsService });
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
/N8N_SANDBOX_SERVICE_URL/,
);
expect(createSandboxMock).not.toHaveBeenCalled();
});
it('mints refreshable proxy tokens on demand with project scope', async () => {
const client = mock<AiAssistantClient>();
client.getBuilderApiProxyToken
.mockResolvedValueOnce({ accessToken: 'proxy-token-1', tokenType: 'Bearer' })
.mockResolvedValueOnce({ accessToken: 'proxy-token-2', tokenType: 'Bearer' });
client.getSandboxProxyBaseUrl.mockReturnValue('https://sandbox-proxy.example');
const aiService = makeAiService({
isProxyEnabled: vi.fn().mockReturnValue(true),
getClient: vi.fn().mockResolvedValue(client),
});
const service = makeService({
configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:1.2.3' },
aiService,
});
await service.warmSandbox(projectId, agentId);
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
expect(config.daytonaApiUrl).toBe('https://sandbox-proxy.example');
expect(config.snapshot).toBe('n8n/agent-knowledge:1.2.3');
expect(config.image).toBeUndefined();
expect(client.getBuilderApiProxyToken).not.toHaveBeenCalled();
await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-1');
await expect(config.getAuthToken?.()).resolves.toBe('proxy-token-2');
expect(client.getBuilderApiProxyToken).toHaveBeenCalledTimes(2);
expect(client.getBuilderApiProxyToken.mock.calls[0][0]).toEqual({ id: projectId });
expect(client.getBuilderApiProxyToken.mock.calls[1][0]).toEqual({ id: projectId });
});
it('requires a proxy snapshot instead of creating from an image', async () => {
const service = makeService({
configOverrides: { sandboxSnapshot: '' },
aiService: makeProxyAiService(),
});
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow(
/requires a snapshot.*N8N_AGENTS_AI_SANDBOX_SNAPSHOT/s,
);
expect(createSandboxMock).not.toHaveBeenCalled();
});
it('surfaces a snapshot failure without falling back to an image', async () => {
createSandboxMock.mockRejectedValueOnce(new Error('snapshot missing'));
const service = makeService({
configOverrides: { sandboxSnapshot: 'n8n/agent-knowledge:missing' },
aiService: makeProxyAiService(),
});
await expect(service.warmSandbox(projectId, agentId)).rejects.toThrow('snapshot missing');
const config = createSandboxMock.mock.calls[0][0] as DaytonaSandboxConfig;
expect(config.snapshot).toBe('n8n/agent-knowledge:missing');
expect(config.image).toBeUndefined();
});
it('best-effort destroys both provider sandboxes by their deterministic identities', async () => {
const daytonaSandbox = makeSandbox('daytona', buildExpectedSandboxName());
daytonaSandbox.destroy.mockRejectedValue(new Error('remote unavailable'));
const n8nSandbox = makeSandbox('n8n-sandbox', 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0');
createSandboxMock.mockImplementation(async (config) =>
config.provider === 'daytona' ? daytonaSandbox : n8nSandbox,
);
const service = makeService({ configOverrides: { sandboxEnabled: false } });
await service.destroySandbox(projectId, agentId);
expect(createSandboxMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
provider: 'daytona',
id: buildExpectedSandboxName(),
name: buildExpectedSandboxName(),
}),
expect.anything(),
);
expect(createSandboxMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
provider: 'n8n-sandbox',
id: 'eaa9416e-fd18-5dd5-bb92-5e8fc51eb5d0',
}),
expect.anything(),
);
expect(daytonaSandbox.destroy).toHaveBeenCalled();
expect(n8nSandbox.destroy).toHaveBeenCalled();
});
});

View File

@ -37,6 +37,41 @@ export default new Tool('double')
.handler(async (input) => ({ result: input.value * 2 }));
`;
const TOOL_WITH_MESSAGE_CODE = `
import { Tool } from '@n8n/agents';
import { z } from 'zod';
export default new Tool('double_with_message')
.description('Doubles a number and renders a message')
.input(z.object({ value: z.number() }))
.handler(async (input) => ({ result: input.value * 2 }))
.toMessage((output) => ({
role: 'assistant',
content: [{ type: 'text', text: \`Doubled result: \${output.result}\` }],
}));
`;
const TOOL_WITH_BINARY_MESSAGE_CODE = `
import { Tool } from '@n8n/agents';
import { z } from 'zod';
export default new Tool('binary_message')
.description('Returns binary file message parts')
.input(z.object({}))
.handler(async () => ({
typedArray: new Uint8Array([0, 127, 255]),
arrayBuffer: new Uint8Array([1, 2, 3]).buffer,
}))
.toMessage((output) => ({
role: 'assistant',
content: [
{ type: 'file', mediaType: 'application/octet-stream', data: output.typedArray },
{ type: 'file', mediaType: 'image/png', data: output.arrayBuffer },
{ type: 'file', mediaType: 'application/octet-stream', data: output.nodeBuffer },
],
}));
`;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -334,6 +369,40 @@ describe('AgentSecureRuntime', () => {
expect(result).toEqual({ result: 42 });
});
it('executes a tool message transform through the tool executor', async () => {
const executor = runtime.createToolExecutor({
double_with_message: TOOL_WITH_MESSAGE_CODE,
});
const message = await executor.executeToMessage('double_with_message', {
result: 42,
});
expect(message).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'Doubled result: 42' }],
});
});
it('preserves binary data through tool execution and message transforms', async () => {
const executor = runtime.createToolExecutor({
binary_message: TOOL_WITH_BINARY_MESSAGE_CODE,
});
const output = (await executor.executeTool('binary_message', {}, {})) as Record<
string,
unknown
>;
const message = await executor.executeToMessage('binary_message', {
...output,
nodeBuffer: Buffer.from([4, 5, 6]),
});
if (!message || !('content' in message)) throw new Error('Expected a binary message');
const files = message.content.filter((part) => part.type === 'file');
expect(Array.from(files[0]?.data as Uint8Array)).toEqual([0, 127, 255]);
expect(Array.from(new Uint8Array(files[1]?.data as ArrayBuffer))).toEqual([1, 2, 3]);
expect(Array.from(files[2]?.data as Uint8Array)).toEqual([4, 5, 6]);
});
it('concurrent describeToolSecurely calls all resolve', async () => {
const results = await Promise.all([
runtime.describeToolSecurely(SIMPLE_TOOL_CODE),

View File

@ -2,6 +2,7 @@ import { mock } from 'vitest-mock-extended';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import { AgentTestChatService, chatThreadId } from '../agent-test-chat.service';
import type { AgentWorkspaceService } from '../agent-workspace.service';
import type { N8nMemory } from '../integrations/n8n-memory';
const agentId = 'agent-1';
@ -12,13 +13,15 @@ function makeService() {
const n8nMemory = mock<N8nMemory>();
const memory = mock<MemoryImplementation>();
const attachmentService = mock<AgentChatAttachmentService>();
const workspaceService = mock<AgentWorkspaceService>();
n8nMemory.getImplementation.mockReturnValue(memory);
return {
service: new AgentTestChatService(n8nMemory, attachmentService),
service: new AgentTestChatService(n8nMemory, attachmentService, workspaceService),
n8nMemory,
memory,
attachmentService,
workspaceService,
};
}
@ -39,11 +42,16 @@ describe('AgentTestChatService', () => {
});
});
it('clears one user thread or every test-chat thread for an agent', async () => {
const { service, memory } = makeService();
it('performs workspace cleanup for one user thread without changing all-agent cleanup', async () => {
const { service, memory, workspaceService } = makeService();
await service.clearTestChatMessages(agentId, userId);
await service.clearTestChatMessages('project-1', agentId, userId);
expect(memory.deleteThread).toHaveBeenCalledWith(`test-${agentId}:${userId}`);
expect(workspaceService.cleanupThreadWorkspace).toHaveBeenCalledWith(
'project-1',
agentId,
`test-${agentId}:${userId}`,
);
await service.clearAllTestChatMessages(agentId);
expect(memory.deleteThreadsByPrefix).toHaveBeenCalledWith(`test-${agentId}`);

View File

@ -0,0 +1,81 @@
import type { WorkspaceFilesystem, WorkspaceSandbox } from '@n8n/agents/sandbox';
import type { Logger } from '@n8n/backend-common';
import type { AgentsConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service';
import { AgentWorkspaceService } from '../agent-workspace.service';
const projectId = 'project-1';
const agentId = 'agent-1';
function makeService(sandboxEnabled = true) {
const filesystem = mock<WorkspaceFilesystem>();
const sandbox = mock<WorkspaceSandbox>({
id: 'sandbox-id',
name: 'sandbox',
provider: 'daytona',
status: 'running',
});
const runtimeService = mock<AgentSandboxRuntimeService>();
runtimeService.acquireSandbox.mockResolvedValue({
provider: 'daytona',
sandbox,
filesystem,
workspaceRoot: '/home/daytona/workspace',
cacheKey: 'daytona:agent:sandbox-id',
});
return {
service: new AgentWorkspaceService(
{ sandboxEnabled } as AgentsConfig,
mock<Logger>(),
runtimeService,
),
filesystem,
sandbox,
runtimeService,
};
}
describe('AgentWorkspaceService', () => {
it('eagerly creates a scoped workspace with only the core workspace tools', async () => {
const { service, filesystem } = makeService();
const workspace = await service.getAgentWorkspace(projectId, agentId);
expect(filesystem.mkdir).toHaveBeenCalledWith('/home/daytona/workspace', {
recursive: true,
});
expect(workspace.filesystem?.basePath).toBe('/home/daytona/workspace');
expect(workspace.getTools().map(({ name }) => name)).toEqual([
'workspace_read_file',
'workspace_read_tool_result',
'workspace_str_replace_file',
'workspace_batch_str_replace_file',
'workspace_write_file',
'workspace_execute_command',
]);
});
it('removes only the hashed thread result directory without destroying the sandbox', async () => {
const { service, filesystem, sandbox } = makeService();
await service.cleanupThreadWorkspace(projectId, agentId, '../../knowledge-mirror');
expect(filesystem.rmdir).toHaveBeenCalledWith(
expect.stringMatching(/^\/home\/daytona\/workspace\/tool-results\/threads\/[A-Za-z0-9_-]+$/),
{ recursive: true, force: true },
);
expect(sandbox.destroy).not.toHaveBeenCalled();
});
it('does not reject when workspace cleanup fails', async () => {
const { service, runtimeService } = makeService();
runtimeService.acquireSandbox.mockRejectedValue(new Error('sandbox unavailable'));
await expect(
service.cleanupThreadWorkspace(projectId, agentId, 'thread-1'),
).resolves.toBeUndefined();
});
});

View File

@ -3,6 +3,7 @@ import {
DEFAULT_SUB_AGENT_MAX_CHILDREN,
getInlineDelegateSubAgentToolOptions,
WRITE_TODOS_TOOL_NAME,
Workspace,
} from '@n8n/agents';
import type * as agents from '@n8n/agents';
import type { CredentialProvider, BuiltTool } from '@n8n/agents';
@ -34,8 +35,9 @@ import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
import type { AgentKnowledgeMirrorService } from '../agent-knowledge-mirror.service';
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { AgentWorkspaceService } from '../agent-workspace.service';
import type { Agent } from '../entities/agent.entity';
import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
import { ChatIntegrationActionExecutor } from '../integrations/integration-action-executor';
@ -93,6 +95,9 @@ function makeReconstructionService(
agentRepository?: AgentRepository;
agentsConfig?: Partial<AgentsConfig>;
n8nCheckpointStorage?: N8NCheckpointStorage;
agentFileRepository?: AgentFileRepository;
agentWorkspaceService?: AgentWorkspaceService;
agentKnowledgeMirrorService?: AgentKnowledgeMirrorService;
} = {},
): AgentRuntimeReconstructionService {
const secureRuntime = mock<AgentSecureRuntime>();
@ -101,10 +106,13 @@ function makeReconstructionService(
transport.asCustomFetch.mockReturnValue(vi.fn() as unknown as CustomFetch);
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue(transport);
const defaultAgentWorkspaceService = mock<AgentWorkspaceService>();
defaultAgentWorkspaceService.getAgentWorkspace.mockResolvedValue(new Workspace({}));
const agentWorkspaceService = overrides.agentWorkspaceService ?? defaultAgentWorkspaceService;
return new AgentRuntimeReconstructionService(
overrides.logger ?? mock<Logger>(),
overrides.agentRepository ?? mock<AgentRepository>(),
mock<AgentFileRepository>(),
overrides.agentFileRepository ?? mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
mock<WorkflowRepository>(),
mock<UrlService>(),
@ -119,7 +127,8 @@ function makeReconstructionService(
} as unknown as AgentsConfig,
mock<AiService>(),
outboundHttp,
mock<AgentKnowledgeSandboxService>(),
agentWorkspaceService,
overrides.agentKnowledgeMirrorService ?? mock<AgentKnowledgeMirrorService>(),
mock<SsrfProtectionConfig>({ enabled: true }),
mock<SsrfProtectionService>(),
mock<CredentialsFinderService>(),
@ -251,6 +260,103 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP w
});
});
describe('AgentRuntimeReconstructionService — workspace attachment', () => {
beforeEach(() => {
vi.clearAllMocks();
builtAgent.hasCheckpointStorage.mockReturnValue(true);
});
it('attaches a workspace without requiring knowledge files', async () => {
const agentFileRepository = mock<AgentFileRepository>();
const agentWorkspaceService = mock<AgentWorkspaceService>();
const workspace = new Workspace({});
agentFileRepository.hasFilesForAgent.mockResolvedValue(false);
agentWorkspaceService.getAgentWorkspace.mockResolvedValue(workspace);
const service = makeReconstructionService([], {
agentsConfig: { sandboxEnabled: true },
agentFileRepository,
agentWorkspaceService,
});
await service.reconstructFromAgentEntity(
makeAgentEntity(),
mock<CredentialProvider>(),
'production',
);
expect(builtAgent.workspace).toHaveBeenCalledWith(workspace);
expect(getInjectedToolNames()).not.toContain('find_file');
});
it('keeps knowledge tools gated by uploaded files', async () => {
const agentFileRepository = mock<AgentFileRepository>();
const agentWorkspaceService = mock<AgentWorkspaceService>();
agentFileRepository.hasFilesForAgent.mockResolvedValue(true);
agentWorkspaceService.getAgentWorkspace.mockResolvedValue(new Workspace({}));
const service = makeReconstructionService([], {
agentsConfig: { sandboxEnabled: true },
agentFileRepository,
agentWorkspaceService,
});
await service.reconstructFromAgentEntity(
makeAgentEntity(),
mock<CredentialProvider>(),
'production',
);
expect(getInjectedToolNames()).toEqual(
expect.arrayContaining(['find_file', 'search_text', 'read_file']),
);
});
it('does not attach a workspace to inline runtimes', async () => {
const agentWorkspaceService = mock<AgentWorkspaceService>();
const service = makeReconstructionService([], {
agentsConfig: { sandboxEnabled: true },
agentWorkspaceService,
});
await service.reconstructFromResolvedSource({
config: {
name: 'Inline',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Help',
},
memoryOwnerAgentId: 'agent-1',
projectId: 'project-1',
credentialProvider: mock<CredentialProvider>(),
toolDescriptors: {},
toolCodeByName: {},
skills: {},
runtimeProfile: 'inline',
runType: 'production',
parentAgentIdForDelegation: 'parent-agent-1',
});
expect(agentWorkspaceService.getAgentWorkspace).not.toHaveBeenCalled();
expect(builtAgent.workspace).not.toHaveBeenCalled();
});
it('continues reconstruction when the workspace is unavailable', async () => {
const agentWorkspaceService = mock<AgentWorkspaceService>();
agentWorkspaceService.getAgentWorkspace.mockRejectedValue(new Error('sandbox unavailable'));
const service = makeReconstructionService([], {
agentsConfig: { sandboxEnabled: true },
agentWorkspaceService,
});
await expect(
service.reconstructFromAgentEntity(
makeAgentEntity(),
mock<CredentialProvider>(),
'production',
),
).resolves.toEqual(expect.objectContaining({ agent: builtAgent }));
expect(builtAgent.workspace).not.toHaveBeenCalled();
});
});
describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-agent delegation gating', () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@ -68,6 +68,7 @@ describe('buildFromJson()', () => {
const makeMockToolExecutor = (): ToolExecutor => ({
executeTool: vi.fn().mockResolvedValue({ result: 'tool result' }),
executeToMessage: vi.fn().mockResolvedValue(undefined),
});
const makeMockCredentialProvider = () => ({
@ -233,21 +234,41 @@ describe('buildFromJson()', () => {
expect(snap.model.name).toBe('claude-sonnet-4-5');
});
it('wires a custom tool', async () => {
const descriptor = makeToolDescriptor({ name: 'my_search' });
it('executes a custom tool handler and message transform', async () => {
const descriptor = makeToolDescriptor({ name: 'my_search', hasToMessage: true });
const config = makeConfig({ tools: [{ type: 'custom', id: 'search_tool' }] });
const rawOutput = { matches: ['first', 'second'] };
const toolExecutor: ToolExecutor = {
executeTool: async () => rawOutput,
executeToMessage: async (_toolName, output) => ({
role: 'assistant',
content: [{ type: 'text', text: JSON.stringify(output) }],
}),
};
const agent = await buildFromJson(
config,
{ search_tool: descriptor },
{
toolExecutor: makeMockToolExecutor(),
toolExecutor,
credentialProvider: makeMockCredentialProvider(),
memoryFactory: makeMockMemoryFactory(),
},
);
const tool = (
agent as unknown as {
tools: BuiltTool[];
}
).tools.find(({ name }) => name === 'my_search');
if (!tool?.handler || !tool.toMessage) throw new Error('Expected custom tool transforms');
expect(agent.snapshot.tools.some((t) => t.name === 'my_search')).toBe(true);
const output = await tool.handler({ query: 'n8n' }, {} as never);
expect(output).toEqual(rawOutput);
expect(await tool.toMessage(output)).toEqual({
role: 'assistant',
content: [{ type: 'text', text: '{"matches":["first","second"]}' }],
});
});
it('wires attached skills through the shared runtime skill loader without inlining bodies', async () => {

View File

@ -368,7 +368,7 @@ export class AgentChatController {
const { projectId, agentId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
await this.agentTestChatService.clearTestChatMessages(agentId, req.user.id);
await this.agentTestChatService.clearTestChatMessages(projectId, agentId, req.user.id);
return { ok: true };
}
}

View File

@ -13,6 +13,7 @@ import {
type StoredAttachmentRef,
} from './agent-chat-attachment.service';
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
import { AgentWorkspaceService } from './agent-workspace.service';
import { AgentExecutionThread } from './entities/agent-execution-thread.entity';
import { AgentExecution } from './entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from './execution-recorder';
@ -103,6 +104,7 @@ export class AgentExecutionService {
private readonly storageConfig: StorageConfig,
private readonly errorReporter: ErrorReporter,
private readonly executionUpdateBroadcaster: AgentExecutionUpdateBroadcaster,
private readonly agentWorkspaceService: AgentWorkspaceService,
) {}
async startExecutionRecording(params: StartExecutionParams, startedAt: Date): Promise<string> {
@ -443,6 +445,7 @@ export class AgentExecutionService {
await this.n8nMemory.getImplementation(agentId).deleteThread(threadId);
await this.agentChatAttachmentService.deleteByThread(threadId, { projectId });
await Promise.all([
this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId),
this.agentExecutionThreadRepository.delete({ id: threadId }),
this.agentExecutionLogStore.delete(
blobRefs.map((r) => ({ agentId, threadId, executionId: r.id, storedAt: r.storedAt })),

View File

@ -1,27 +1,12 @@
import { redactText } from '@n8n/agents';
import {
createFilesystem,
createSandbox,
type CommandResult,
type DaytonaSandboxConfig,
type N8nSandboxConfig,
type SandboxProvider,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents/sandbox';
import type { CommandResult } from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { v5 as uuidv5 } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import {
buildMirrorFinalizeCommand,
@ -58,26 +43,16 @@ import {
type AgentKnowledgePaths,
storageFileNameForOriginalFileName,
} from './agent-knowledge-storage';
import {
type AgentSandboxRuntime,
AgentSandboxRuntimeService,
sanitizeSandboxErrorDetail,
} from './agent-sandbox-runtime.service';
import { AgentFileRepository } from './repositories/agent-file.repository';
import { AgentRepository } from './repositories/agent.repository';
export const AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-';
const AGENT_KNOWLEDGE_SANDBOX_NAMESPACE = '5b5fd8cd-59c1-5914-aabc-cf7257fb46bc';
const MAX_SANDBOX_ERROR_DETAIL_CHARS = 2_000;
const LABEL_KNOWLEDGE_BASE = 'n8n-agents-knowledgebase';
const LABEL_PROJECT_ID = 'n8n-project-id';
const LABEL_AGENT_ID = 'n8n-agent-id';
const DEFAULT_SANDBOX_IMAGE = 'daytonaio/sandbox:0.5.0';
const AUTO_STOP_INTERVAL_MINUTES = 5;
interface AgentKnowledgeSandboxRuntime {
sandbox: WorkspaceSandbox;
filesystem: WorkspaceFilesystem;
interface AgentKnowledgeMirrorRuntime extends AgentSandboxRuntime {
paths: AgentKnowledgePaths;
cacheKey: string;
}
interface AgentKnowledgeReferenceLookup {
@ -101,21 +76,6 @@ function emptySearchKnowledgeResult(
return { outputMode, matches: [], limit, hasMore: false, truncated: false };
}
// Proxy sandbox-operation routes only match `[a-z0-9][a-z0-9_-]+` — the name
// must be lowercase. instanceId groups one instance's sandboxes together;
// projectId and agentId are a globally unique nanoid pair beneath it.
function buildSandboxName(scope: {
instanceId: string;
projectId: string;
agentId: string;
}): string {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${scope.instanceId}-${scope.projectId}-${scope.agentId}`.toLowerCase();
}
function buildN8nSandboxId(sandboxName: string): string {
return uuidv5(sandboxName, AGENT_KNOWLEDGE_SANDBOX_NAMESPACE);
}
function parseMirrorManifest(output: string): Map<string, string> {
const manifest = new Map<string, string>();
for (const entry of output.split(/\r?\n/)) {
@ -131,24 +91,6 @@ function parseMirrorManifest(output: string): Map<string, string> {
return manifest;
}
function buildScopeLabels(projectId: string, agentId: string): Record<string, string> {
return {
[LABEL_KNOWLEDGE_BASE]: 'true',
[LABEL_PROJECT_ID]: projectId,
[LABEL_AGENT_ID]: agentId,
};
}
function truncateSandboxErrorDetail(value: string): string {
if (value.length <= MAX_SANDBOX_ERROR_DETAIL_CHARS) return value;
return `${value.slice(0, MAX_SANDBOX_ERROR_DETAIL_CHARS)}...[truncated]`;
}
/** Redact secrets before truncating so a match cut in half cannot leak. */
function sanitizeSandboxErrorDetail(value: string): string {
return truncateSandboxErrorDetail(redactText(value).text.trimEnd());
}
function formatSandboxCommandFailure(
operation: 'glob' | 'read' | 'search',
result: CommandResult,
@ -173,76 +115,18 @@ function assertKnowledgeFilesDirectoryAvailable(
}
@Service()
export class AgentKnowledgeSandboxService {
private readonly pendingSandboxAcquisitions = new Map<
string,
Promise<AgentKnowledgeSandboxRuntime>
>();
export class AgentKnowledgeMirrorService {
private readonly pendingMirrorSyncs = new Map<string, Promise<void>>();
constructor(
private readonly agentsConfig: AgentsConfig,
private readonly logger: Logger,
private readonly aiService: AiService,
private readonly instanceSettings: InstanceSettings,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
private readonly agentFileRepository: AgentFileRepository,
private readonly agentRepository: AgentRepository,
private readonly agentKnowledgeFileStore: AgentKnowledgeFileStore,
private readonly sandboxSettingsService: SandboxSettingsService,
) {}
async warmSandbox(projectId: string, agentId: string): Promise<void> {
this.assertKnowledgeConfiguration(projectId, agentId);
await this.acquireSandbox(projectId, agentId);
}
/**
* Best-effort sandbox teardown for agent/project deletion. Never throws —
* callers must not have cleanup failures block the parent delete operation.
*/
async destroySandbox(projectId: string, agentId: string): Promise<void> {
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const sandboxes = [
['daytona', sandboxName],
['n8n-sandbox', buildN8nSandboxId(sandboxName)],
] as const;
for (const [provider, sandboxId] of sandboxes) {
await this.tryDestroySandbox(projectId, agentId, provider, sandboxId);
}
}
private async tryDestroySandbox(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxId: string,
): Promise<void> {
try {
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxId)
: await this.resolveN8nSandboxConfig(sandboxId);
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?.destroy) {
throw new OperationalError('Agent knowledge sandbox does not support provider destroy');
}
await sandbox.destroy();
} catch (error) {
this.logger.warn('Failed to destroy agent knowledge sandbox', {
projectId,
agentId,
provider,
sandboxId,
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
}
async searchKnowledge(
projectId: string,
agentId: string,
@ -380,16 +264,35 @@ export class AgentKnowledgeSandboxService {
};
}
/**
* Fire-and-forget mirror pre-warm. Called after uploads so the mirror copy
* runs while the just-written data is still hot, instead of taxing the
* next search with it.
*/
prewarmMirrorInBackground(projectId: string, agentId: string): void {
void (async () => {
const references = await this.loadKnowledgeReferenceLookup(projectId, agentId);
const runtime = await this.acquireKnowledgeRuntime(projectId, agentId);
await this.ensureMirrorSynced(runtime, references.files);
})().catch((error) => {
this.logger.warn('Agent knowledge mirror pre-warm failed', {
projectId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async executeKnowledgeOperation(
projectId: string,
agentId: string,
command: string,
files: AgentKnowledgeFileReference[],
): Promise<CommandResult> {
const runtime = await this.acquireSandbox(projectId, agentId);
const runtime = await this.acquireKnowledgeRuntime(projectId, agentId);
const scopedCommand = buildScopedKnowledgeShellCommand(command, runtime.paths);
await this.ensureMirrorSynced(runtime, files);
return await this.executeSandboxCommand(
return await this.agentSandboxRuntimeService.executeSandboxCommand(
runtime.sandbox,
scopedCommand,
this.agentsConfig.sandboxTimeout,
@ -402,7 +305,7 @@ export class AgentKnowledgeSandboxService {
* manifest is read every time because providers can reuse sandbox IDs.
*/
private async ensureMirrorSynced(
runtime: AgentKnowledgeSandboxRuntime,
runtime: AgentKnowledgeMirrorRuntime,
files: AgentKnowledgeFileReference[],
): Promise<void> {
const previous = this.pendingMirrorSyncs.get(runtime.cacheKey) ?? Promise.resolve();
@ -421,10 +324,10 @@ export class AgentKnowledgeSandboxService {
}
private async syncMirror(
runtime: AgentKnowledgeSandboxRuntime,
runtime: AgentKnowledgeMirrorRuntime,
files: AgentKnowledgeFileReference[],
): Promise<void> {
const manifestResult = await this.executeSandboxCommand(
const manifestResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
runtime.sandbox,
`cat ${runtime.paths.manifest} 2>/dev/null || true`,
MIRROR_SYNC_TIMEOUT_SECONDS * 1000,
@ -446,8 +349,6 @@ export class AgentKnowledgeSandboxService {
const stagingDir = `${runtime.paths.stagingDir}/${stagingId}`;
try {
const copiedNames = await this.uploadMirrorFiles(runtime, toCopy, stagingId);
// Files that failed to load from the knowledge file store are left out of
// the manifest, so the next sync attempt retries them as `toCopy` again.
const finalManifestFiles = files.filter(
(file) => copiedNames.has(file.file) || present.get(file.file) === file.fileId,
);
@ -455,7 +356,7 @@ export class AgentKnowledgeSandboxService {
.filter((file) => present.has(file.file) && !copiedNames.has(file.file))
.map((file) => file.file);
const syncResult = await this.executeSandboxCommand(
const syncResult = await this.agentSandboxRuntimeService.executeSandboxCommand(
runtime.sandbox,
buildMirrorFinalizeCommand(
[...copiedNames],
@ -497,7 +398,7 @@ export class AgentKnowledgeSandboxService {
* Returns the names of files that were fetched and uploaded successfully.
*/
private async uploadMirrorFiles(
runtime: AgentKnowledgeSandboxRuntime,
runtime: AgentKnowledgeMirrorRuntime,
files: AgentKnowledgeFileReference[],
stagingId: string,
): Promise<Set<string>> {
@ -539,25 +440,6 @@ export class AgentKnowledgeSandboxService {
return copiedNames;
}
/**
* Fire-and-forget mirror pre-warm. Called after uploads so the mirror copy
* runs while the just-written data is still hot, instead of taxing the
* next search with it.
*/
prewarmMirrorInBackground(projectId: string, agentId: string): void {
void (async () => {
const references = await this.loadKnowledgeReferenceLookup(projectId, agentId);
const runtime = await this.acquireSandbox(projectId, agentId);
await this.ensureMirrorSynced(runtime, references.files);
})().catch((error) => {
this.logger.warn('Agent knowledge mirror pre-warm failed', {
projectId,
agentId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async loadKnowledgeReferenceLookup(
projectId: string,
agentId: string,
@ -631,187 +513,20 @@ export class AgentKnowledgeSandboxService {
return file;
}
private async acquireSandbox(
projectId: string,
agentId: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const provider = this.sandboxSettingsService.getProvider();
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const cacheKey = `${provider}:${sandboxName}`;
let pending = this.pendingSandboxAcquisitions.get(cacheKey);
if (!pending) {
pending = this.acquireSandboxFresh(
projectId,
agentId,
provider,
sandboxName,
cacheKey,
).finally(() => {
this.pendingSandboxAcquisitions.delete(cacheKey);
});
this.pendingSandboxAcquisitions.set(cacheKey, pending);
}
return await pending;
}
private async acquireSandboxFresh(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxName: string,
cacheKey: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxName)
: await this.resolveN8nSandboxConfig(buildN8nSandboxId(sandboxName));
return await this.startSandbox(config, projectId, agentId, cacheKey);
}
private async startSandbox(
config: DaytonaSandboxConfig | N8nSandboxConfig,
projectId: string,
agentId: string,
cacheKey: string,
): Promise<AgentKnowledgeSandboxRuntime> {
const { provider } = config;
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?._start) {
throw new OperationalError('Agent knowledge sandbox does not support lifecycle start');
}
await sandbox._start();
const filesystem = createFilesystem(sandbox);
const paths = getAgentKnowledgePaths(provider);
this.logger.debug('Acquired agent knowledge sandbox', {
projectId,
agentId,
provider,
sandboxId: sandbox.id,
});
return { sandbox, filesystem, paths, cacheKey: `${cacheKey}:${sandbox.id}` };
}
private async resolveDaytonaSandboxConfig(
projectId: string,
agentId: string,
sandboxId: string,
): Promise<DaytonaSandboxConfig> {
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
const baseConfig: DaytonaSandboxConfig = {
enabled: true,
provider: 'daytona',
id: sandboxId,
name: sandboxId,
labels: buildScopeLabels(projectId, agentId),
timeout: this.agentsConfig.sandboxTimeout,
createTimeoutSeconds: Math.ceil(this.agentsConfig.sandboxTimeout / 1000),
ephemeral: this.agentsConfig.sandboxEphemeral,
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
};
if (!this.aiService.isProxyEnabled()) {
const daytona = await this.sandboxSettingsService.resolveDaytonaConfig();
return {
...baseConfig,
daytonaApiUrl: daytona.apiUrl,
daytonaApiKey: daytona.apiKey,
image: directImage,
snapshot,
};
}
const client = await this.aiService.getClient();
if (!snapshot) {
throw new OperationalError(
'Agent knowledge sandbox requires a snapshot when Daytona is reached through the AI service proxy. Set N8N_AGENTS_AI_SANDBOX_SNAPSHOT to a snapshot available to the instance.',
);
}
return {
...baseConfig,
daytonaApiUrl: client.getSandboxProxyBaseUrl(),
snapshot,
getAuthToken: async () => {
// The proxy does not enforce per-user identity; the project id is the stable scope.
const token = await callAiServiceWithRetry(
'Agent knowledge sandbox proxy token mint',
async () =>
await client.getBuilderApiProxyToken({ id: projectId }, { userMessageId: nanoid() }),
this.logger,
);
return token.accessToken;
},
};
}
private async resolveN8nSandboxConfig(sandboxId: string): Promise<N8nSandboxConfig> {
const { serviceUrl, apiKey } = await this.sandboxSettingsService.resolveN8nSandboxConfig();
const normalizedServiceUrl = serviceUrl?.trim();
if (!normalizedServiceUrl) {
throw new OperationalError(
'Agent knowledge sandbox requires the n8n sandbox service URL. Set N8N_SANDBOX_SERVICE_URL.',
);
}
return {
enabled: true,
provider: 'n8n-sandbox',
id: sandboxId,
serviceUrl: normalizedServiceUrl,
apiKey,
timeout: this.agentsConfig.sandboxTimeout,
};
}
private async executeSandboxCommand(sandbox: WorkspaceSandbox, command: string, timeout: number) {
if (!sandbox.executeCommand) {
throw new OperationalError('Agent knowledge sandbox does not support command execution');
}
return await sandbox.executeCommand(command, [], { timeout });
}
/**
* Guards the model-facing retrieval entry points: configuration plus a
* defense-in-depth check that the agent actually belongs to the project,
* mirroring `AgentKnowledgeService.ensureAgentBelongsToProject`.
*/
private async assertKnowledgeAccess(projectId: string, agentId: string): Promise<void> {
this.assertKnowledgeConfiguration(projectId, agentId);
this.agentSandboxRuntimeService.assertSandboxConfiguration(projectId, agentId);
const agentExists = await this.agentRepository.existsBy({ id: agentId, projectId });
if (!agentExists) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
}
private assertKnowledgeConfiguration(projectId: string, agentId: string): void {
if (!this.agentsConfig.sandboxEnabled) {
throw new OperationalError('Agent knowledge sandbox is not enabled');
}
this.assertValidPathSegments(projectId, agentId);
}
private assertValidPathSegments(projectId: string, agentId: string): void {
try {
assertKnowledgePathSegment(this.instanceSettings.instanceId, 'instance id');
assertKnowledgePathSegment(projectId, 'project id');
assertKnowledgePathSegment(agentId, 'agent id');
} catch (error) {
throw new OperationalError(
error instanceof Error ? error.message : 'Invalid agent knowledge storage scope',
);
}
private async acquireKnowledgeRuntime(
projectId: string,
agentId: string,
): Promise<AgentKnowledgeMirrorRuntime> {
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
return { ...runtime, paths: getAgentKnowledgePaths(runtime.provider) };
}
}

View File

@ -19,8 +19,9 @@ import {
AgentKnowledgeFileStore,
type StoredAgentKnowledgeFile,
} from './agent-knowledge-file-store';
import { AgentKnowledgeMirrorService } from './agent-knowledge-mirror.service';
import { storageFileNameForOriginalFileName, toAgentFileDto } from './agent-knowledge-storage';
import { AgentKnowledgeSandboxService } from './agent-knowledge-sandbox.service';
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
import type { AgentFile } from './entities/agent-file.entity';
import { AgentFileRepository } from './repositories/agent-file.repository';
import { AgentRepository } from './repositories/agent.repository';
@ -48,7 +49,8 @@ export class AgentKnowledgeService {
constructor(
private readonly agentRepository: AgentRepository,
private readonly agentFileRepository: AgentFileRepository,
private readonly agentKnowledgeSandboxService: AgentKnowledgeSandboxService,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
private readonly agentKnowledgeMirrorService: AgentKnowledgeMirrorService,
private readonly agentKnowledgeFileStore: AgentKnowledgeFileStore,
private readonly logger: Logger,
) {}
@ -73,7 +75,7 @@ export class AgentKnowledgeService {
throw error;
}
this.agentKnowledgeSandboxService.prewarmMirrorInBackground(projectId, agentId);
this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId);
return uploadedFiles.map((file) => toAgentFileDto(file));
} finally {
@ -89,7 +91,7 @@ export class AgentKnowledgeService {
async warmSandbox(agentId: string, projectId: string): Promise<void> {
await this.ensureAgentBelongsToProject(agentId, projectId);
await this.agentKnowledgeSandboxService.warmSandbox(projectId, agentId);
await this.agentSandboxRuntimeService.warmSandbox(projectId, agentId);
}
async deleteFile(agentId: string, projectId: string, fileId: string): Promise<void> {
@ -110,7 +112,7 @@ export class AgentKnowledgeService {
error: error instanceof Error ? error.message : error,
});
});
this.agentKnowledgeSandboxService.prewarmMirrorInBackground(projectId, agentId);
this.agentKnowledgeMirrorService.prewarmMirrorInBackground(projectId, agentId);
}
async deleteAllFilesForAgent(_projectId: string, agentId: string): Promise<void> {
@ -135,7 +137,7 @@ export class AgentKnowledgeService {
/** Best-effort passthrough for agent/project deletion; never throws. */
async destroySandbox(projectId: string, agentId: string): Promise<void> {
await this.agentKnowledgeSandboxService.destroySandbox(projectId, agentId);
await this.agentSandboxRuntimeService.destroySandbox(projectId, agentId);
}
/** Stores the file's bytes via AgentKnowledgeFileStore, then reserves its DB row. */

View File

@ -48,7 +48,9 @@ import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import { AgentKnowledgeSandboxService } from './agent-knowledge-sandbox.service';
import { AgentKnowledgeMirrorService } from './agent-knowledge-mirror.service';
import { sanitizeSandboxErrorDetail } from './agent-sandbox-runtime.service';
import { AgentWorkspaceService } from './agent-workspace.service';
import type { AgentRuntimeInstrumentation } from './agent-runtime-instrumentation';
import { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
@ -173,7 +175,8 @@ export class AgentRuntimeReconstructionService {
private readonly agentsConfig: AgentsConfig,
private readonly aiService: AiService,
private readonly outboundHttp: OutboundHttp,
private readonly agentKnowledgeSandboxService: AgentKnowledgeSandboxService,
private readonly agentWorkspaceService: AgentWorkspaceService,
private readonly agentKnowledgeMirrorService: AgentKnowledgeMirrorService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
private readonly credentialsFinderService: CredentialsFinderService,
@ -593,21 +596,29 @@ export class AgentRuntimeReconstructionService {
agent.tool(createGetEnvironmentTool());
if (
runtimeProfile !== 'inline' &&
this.agentsConfig.sandboxEnabled &&
(await this.agentFileRepository.hasFilesForAgent(agentId))
) {
const { createKnowledgeRetrievalTools } = await import(
'./tools/knowledge/search-knowledge.tool.js'
);
agent.tool(
createKnowledgeRetrievalTools({
if (runtimeProfile !== 'inline' && this.agentsConfig.sandboxEnabled) {
try {
agent.workspace(await this.agentWorkspaceService.getAgentWorkspace(projectId, agentId));
} catch (error) {
this.logger.warn('Failed to attach agent workspace', {
projectId,
agentId,
sandboxService: this.agentKnowledgeSandboxService,
}),
);
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
if (await this.agentFileRepository.hasFilesForAgent(agentId)) {
const { createKnowledgeRetrievalTools } = await import(
'./tools/knowledge/search-knowledge.tool.js'
);
agent.tool(
createKnowledgeRetrievalTools({
projectId,
agentId,
knowledgeMirrorService: this.agentKnowledgeMirrorService,
}),
);
}
}
if (runtimeProfile === 'top-level') {

View File

@ -0,0 +1,319 @@
import { redactText } from '@n8n/agents';
import {
createFilesystem,
createSandbox,
getPromptWorkspaceRoot,
type CommandResult,
type DaytonaSandboxConfig,
type N8nSandboxConfig,
type SandboxProvider,
type WorkspaceFilesystem,
type WorkspaceSandbox,
} from '@n8n/agents/sandbox';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { v5 as uuidv5 } from 'uuid';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AiService } from '@/services/ai.service';
import { SandboxSettingsService } from '@/services/sandbox-settings.service';
import { callAiServiceWithRetry } from '@/utils/ai-service-retry';
import { assertKnowledgePathSegment } from './agent-knowledge-storage';
import { AgentRepository } from './repositories/agent.repository';
export const AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX = 'agent-';
const AGENT_KNOWLEDGE_SANDBOX_NAMESPACE = '5b5fd8cd-59c1-5914-aabc-cf7257fb46bc';
const MAX_SANDBOX_ERROR_DETAIL_CHARS = 2_000;
const LABEL_KNOWLEDGE_BASE = 'n8n-agents-knowledgebase';
const LABEL_PROJECT_ID = 'n8n-project-id';
const LABEL_AGENT_ID = 'n8n-agent-id';
const DEFAULT_SANDBOX_IMAGE = 'daytonaio/sandbox:0.5.0';
const AUTO_STOP_INTERVAL_MINUTES = 5;
export interface AgentSandboxRuntime {
provider: SandboxProvider;
sandbox: WorkspaceSandbox;
filesystem: WorkspaceFilesystem;
workspaceRoot: string;
cacheKey: string;
}
function buildSandboxName(scope: {
instanceId: string;
projectId: string;
agentId: string;
}): string {
return `${AGENT_KNOWLEDGE_SANDBOX_NAME_PREFIX}${scope.instanceId}-${scope.projectId}-${scope.agentId}`.toLowerCase();
}
function buildN8nSandboxId(sandboxName: string): string {
return uuidv5(sandboxName, AGENT_KNOWLEDGE_SANDBOX_NAMESPACE);
}
function buildScopeLabels(projectId: string, agentId: string): Record<string, string> {
return {
[LABEL_KNOWLEDGE_BASE]: 'true',
[LABEL_PROJECT_ID]: projectId,
[LABEL_AGENT_ID]: agentId,
};
}
function truncateSandboxErrorDetail(value: string): string {
if (value.length <= MAX_SANDBOX_ERROR_DETAIL_CHARS) return value;
return `${value.slice(0, MAX_SANDBOX_ERROR_DETAIL_CHARS)}...[truncated]`;
}
/** Redact secrets before truncating so a match cut in half cannot leak. */
export function sanitizeSandboxErrorDetail(value: string): string {
return truncateSandboxErrorDetail(redactText(value).text.trimEnd());
}
@Service()
export class AgentSandboxRuntimeService {
private readonly pendingSandboxAcquisitions = new Map<string, Promise<AgentSandboxRuntime>>();
constructor(
private readonly agentsConfig: AgentsConfig,
private readonly logger: Logger,
private readonly aiService: AiService,
private readonly instanceSettings: InstanceSettings,
private readonly agentRepository: AgentRepository,
private readonly sandboxSettingsService: SandboxSettingsService,
) {}
async warmSandbox(projectId: string, agentId: string): Promise<void> {
this.assertSandboxConfiguration(projectId, agentId);
await this.acquireSandbox(projectId, agentId);
}
/**
* Best-effort sandbox teardown for agent/project deletion. Never throws —
* callers must not have cleanup failures block the parent delete operation.
*/
async destroySandbox(projectId: string, agentId: string): Promise<void> {
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const sandboxes = [
['daytona', sandboxName],
['n8n-sandbox', buildN8nSandboxId(sandboxName)],
] as const;
for (const [provider, sandboxId] of sandboxes) {
await this.tryDestroySandbox(projectId, agentId, provider, sandboxId);
}
}
async acquireSandbox(projectId: string, agentId: string): Promise<AgentSandboxRuntime> {
const provider = this.sandboxSettingsService.getProvider();
const sandboxName = buildSandboxName({
instanceId: this.instanceSettings.instanceId,
projectId,
agentId,
});
const cacheKey = `${provider}:${sandboxName}`;
let pending = this.pendingSandboxAcquisitions.get(cacheKey);
if (!pending) {
pending = this.acquireSandboxFresh(
projectId,
agentId,
provider,
sandboxName,
cacheKey,
).finally(() => {
this.pendingSandboxAcquisitions.delete(cacheKey);
});
this.pendingSandboxAcquisitions.set(cacheKey, pending);
}
return await pending;
}
async executeSandboxCommand(
sandbox: WorkspaceSandbox,
command: string,
timeout: number,
): Promise<CommandResult> {
if (!sandbox.executeCommand) {
throw new OperationalError('Agent knowledge sandbox does not support command execution');
}
return await sandbox.executeCommand(command, [], { timeout });
}
assertSandboxConfiguration(projectId: string, agentId: string): void {
if (!this.agentsConfig.sandboxEnabled) {
throw new OperationalError('Agent knowledge sandbox is not enabled');
}
this.assertValidPathSegments(projectId, agentId);
}
private async tryDestroySandbox(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxId: string,
): Promise<void> {
try {
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxId)
: await this.resolveN8nSandboxConfig(sandboxId);
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?.destroy) {
throw new OperationalError('Agent knowledge sandbox does not support provider destroy');
}
await sandbox.destroy();
} catch (error) {
this.logger.warn('Failed to destroy agent knowledge sandbox', {
projectId,
agentId,
provider,
sandboxId,
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
}
private async acquireSandboxFresh(
projectId: string,
agentId: string,
provider: SandboxProvider,
sandboxName: string,
cacheKey: string,
): Promise<AgentSandboxRuntime> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) {
throw new NotFoundError(`Agent "${agentId}" not found`);
}
const config =
provider === 'daytona'
? await this.resolveDaytonaSandboxConfig(projectId, agentId, sandboxName)
: await this.resolveN8nSandboxConfig(buildN8nSandboxId(sandboxName));
return await this.startSandbox(config, projectId, agentId, cacheKey);
}
private async startSandbox(
config: DaytonaSandboxConfig | N8nSandboxConfig,
projectId: string,
agentId: string,
cacheKey: string,
): Promise<AgentSandboxRuntime> {
const { provider } = config;
const sandbox = await createSandbox(config, { logger: this.logger });
if (!sandbox?._start) {
throw new OperationalError('Agent knowledge sandbox does not support lifecycle start');
}
await sandbox._start();
const filesystem = createFilesystem(sandbox);
const workspaceRoot = getPromptWorkspaceRoot(provider);
this.logger.debug('Acquired agent knowledge sandbox', {
projectId,
agentId,
provider,
sandboxId: sandbox.id,
});
return {
provider,
sandbox,
filesystem,
workspaceRoot,
cacheKey: `${cacheKey}:${sandbox.id}`,
};
}
private async resolveDaytonaSandboxConfig(
projectId: string,
agentId: string,
sandboxId: string,
): Promise<DaytonaSandboxConfig> {
const directImage = this.agentsConfig.sandboxImage || DEFAULT_SANDBOX_IMAGE;
const snapshot = this.agentsConfig.sandboxSnapshot.trim() || undefined;
const baseConfig: DaytonaSandboxConfig = {
enabled: true,
provider: 'daytona',
id: sandboxId,
name: sandboxId,
labels: buildScopeLabels(projectId, agentId),
timeout: this.agentsConfig.sandboxTimeout,
createTimeoutSeconds: Math.ceil(this.agentsConfig.sandboxTimeout / 1000),
ephemeral: false,
autoStopInterval: AUTO_STOP_INTERVAL_MINUTES,
};
if (!this.aiService.isProxyEnabled()) {
const daytona = await this.sandboxSettingsService.resolveDaytonaConfig();
return {
...baseConfig,
daytonaApiUrl: daytona.apiUrl,
daytonaApiKey: daytona.apiKey,
image: directImage,
snapshot,
};
}
const client = await this.aiService.getClient();
if (!snapshot) {
throw new OperationalError(
'Agent knowledge sandbox requires a snapshot when Daytona is reached through the AI service proxy. Set N8N_AGENTS_AI_SANDBOX_SNAPSHOT to a snapshot available to the instance.',
);
}
return {
...baseConfig,
daytonaApiUrl: client.getSandboxProxyBaseUrl(),
snapshot,
getAuthToken: async () => {
const token = await callAiServiceWithRetry(
'Agent knowledge sandbox proxy token mint',
async () =>
await client.getBuilderApiProxyToken({ id: projectId }, { userMessageId: nanoid() }),
this.logger,
);
return token.accessToken;
},
};
}
private async resolveN8nSandboxConfig(sandboxId: string): Promise<N8nSandboxConfig> {
const { serviceUrl, apiKey } = await this.sandboxSettingsService.resolveN8nSandboxConfig();
const normalizedServiceUrl = serviceUrl?.trim();
if (!normalizedServiceUrl) {
throw new OperationalError(
'Agent knowledge sandbox requires the n8n sandbox service URL. Set N8N_SANDBOX_SERVICE_URL.',
);
}
return {
enabled: true,
provider: 'n8n-sandbox',
id: sandboxId,
serviceUrl: normalizedServiceUrl,
apiKey,
timeout: this.agentsConfig.sandboxTimeout,
};
}
private assertValidPathSegments(projectId: string, agentId: string): void {
try {
assertKnowledgePathSegment(this.instanceSettings.instanceId, 'instance id');
assertKnowledgePathSegment(projectId, 'project id');
assertKnowledgePathSegment(agentId, 'agent id');
} catch (error) {
throw new OperationalError(
error instanceof Error ? error.message : 'Invalid agent knowledge storage scope',
);
}
}
}

View File

@ -1,6 +1,7 @@
import { Service } from '@n8n/di';
import { AgentChatAttachmentService } from './agent-chat-attachment.service';
import { AgentWorkspaceService } from './agent-workspace.service';
import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names';
import { N8nMemory } from './integrations/n8n-memory';
import { draftChatMemoryResourceId } from './utils/agent-memory-scope';
@ -16,6 +17,7 @@ export class AgentTestChatService {
constructor(
private readonly n8nMemory: N8nMemory,
private readonly agentChatAttachmentService: AgentChatAttachmentService,
private readonly agentWorkspaceService: AgentWorkspaceService,
) {}
/**
@ -33,10 +35,11 @@ export class AgentTestChatService {
/**
* Clear the current user's test-chat messages for an agent.
*/
async clearTestChatMessages(agentId: string, userId: string) {
async clearTestChatMessages(projectId: string, agentId: string, userId: string) {
const threadId = chatThreadId(agentId, userId);
await this.n8nMemory.getImplementation(agentId).deleteThread(threadId);
await this.agentChatAttachmentService.deleteByThread(threadId, { agentId });
await this.agentWorkspaceService.cleanupThreadWorkspace(projectId, agentId, threadId);
}
/** Delete all test-chat messages + the thread row — used when the agent itself is deleted. */

View File

@ -0,0 +1,59 @@
import {
CORE_WORKSPACE_TOOL_NAMES,
createScopedWorkspace,
getToolResultThreadDirectory,
Workspace,
} from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import {
AgentSandboxRuntimeService,
sanitizeSandboxErrorDetail,
} from './agent-sandbox-runtime.service';
@Service()
export class AgentWorkspaceService {
constructor(
private readonly agentsConfig: AgentsConfig,
private readonly logger: Logger,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
) {}
async getAgentWorkspace(projectId: string, agentId: string): Promise<Workspace> {
this.agentSandboxRuntimeService.assertSandboxConfiguration(projectId, agentId);
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
await runtime.filesystem.mkdir(runtime.workspaceRoot, { recursive: true });
const workspace = createScopedWorkspace(
new Workspace({ filesystem: runtime.filesystem, sandbox: runtime.sandbox }),
runtime.workspaceRoot,
);
const getTools = workspace.getTools.bind(workspace);
workspace.getTools = () =>
getTools().filter((tool) => CORE_WORKSPACE_TOOL_NAMES.has(tool.name));
return workspace;
}
async cleanupThreadWorkspace(
projectId: string,
agentId: string,
threadId: string,
): Promise<void> {
if (!this.agentsConfig.sandboxEnabled) return;
try {
const runtime = await this.agentSandboxRuntimeService.acquireSandbox(projectId, agentId);
await runtime.filesystem.rmdir(
`${runtime.workspaceRoot}/${getToolResultThreadDirectory(threadId)}`,
{ recursive: true, force: true },
);
} catch (error) {
this.logger.warn('Failed to clean up agent thread workspace', {
projectId,
agentId,
error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)),
});
}
}
}

View File

@ -1,5 +1,6 @@
import type {
AgentBuilder,
AgentMessage,
BuiltMemory,
BuiltProviderTool,
BuiltTool,
@ -68,7 +69,7 @@ export type ToolResolver = (
export interface ToolExecutor {
executeTool(toolName: string, input: unknown, ctx: unknown): Promise<unknown>;
executeToMessageSync?(toolName: string, output: unknown): unknown;
executeToMessage(toolName: string, output: unknown): Promise<AgentMessage | undefined>;
}
/** Factory function that reconstructs a BuiltMemory backend from serialized params. */
@ -439,7 +440,6 @@ async function resolveToolRef(
if (!descriptor) {
throw new Error(`Custom tool "${ref.id}" not found in tool descriptors`);
}
const builtTool: BuiltTool = {
name: descriptor.name,
description: descriptor.description,
@ -451,6 +451,12 @@ async function resolveToolRef(
parentTelemetry: ctx.parentTelemetry,
});
},
...(descriptor.hasToMessage
? {
toMessage: async (output: unknown) =>
await options.toolExecutor.executeToMessage(descriptor.name, output),
}
: {}),
providerOptions: descriptor.providerOptions as Record<string, JSONObject> | undefined,
};

View File

@ -1,4 +1,4 @@
import type { ToolDescriptor } from '@n8n/agents';
import type { AgentMessage, ToolDescriptor } from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { readFileSync } from 'fs';
@ -161,6 +161,30 @@ export class AgentSecureRuntime {
return code;
}
private loadToolInContext(
context: ivm.Context,
slot: AgentIsolateSlot,
compiledToolCode: string,
): void {
const script = slot.isolate.compileScriptSync(`
var __me = {};
var __mod = { exports: __me };
(function(exports, require, module) {
${compiledToolCode}
})(__me, require, __mod);
globalThis.__exportedTool = __mod.exports.default || __mod.exports;
if (!globalThis.__exportedTool || typeof globalThis.__exportedTool !== 'object') {
throw new Error('No Tool found');
}
`);
try {
script.runSync(context, { timeout: 5000 });
} finally {
script.release();
}
}
/**
* Safely parse a JSON string that originated from the sandbox.
*
@ -267,26 +291,11 @@ export class AgentSecureRuntime {
return await this.withIsolate(async (slot) => {
const context = slot.createContext();
try {
const setupCode = `
var __me = {};
var __mod = { exports: __me };
(function(exports, require, module) {
${jsCode}
})(__me, require, __mod);
globalThis.__exportedTool = __mod.exports.default || __mod.exports;
if (!globalThis.__exportedTool || typeof globalThis.__exportedTool !== 'object') {
throw new Error('No Tool found');
}
`;
const setupScript = slot.isolate.compileScriptSync(setupCode);
setupScript.runSync(context, { timeout: 5000 });
setupScript.release();
const serializedArgs = JSON.stringify({ input, ctx });
this.loadToolInContext(context, slot, jsCode);
const serializedContext = JSON.stringify(ctx);
const invokeCode = `
(async function() {
return (async function() {
var tool = globalThis.__exportedTool;
var handlerFn = tool.handlerFn || (tool.build ? tool.build().handler : null);
if (!handlerFn) {
@ -295,9 +304,8 @@ export class AgentSecureRuntime {
}
if (!handlerFn) throw new Error('Tool has no handler');
var args = ${serializedArgs};
var suspendPayload = { called: false, data: null };
var ctx = Object.assign({}, args.ctx || {}, {
var ctx = Object.assign({}, ${serializedContext ?? 'undefined'} || {}, {
suspend: function(payload) {
suspendPayload.called = true;
suspendPayload.data = payload;
@ -305,34 +313,68 @@ export class AgentSecureRuntime {
},
});
var result = await handlerFn(args.input, ctx);
var result = await handlerFn($0, ctx);
if (suspendPayload.called) {
return JSON.stringify({ __suspend: true, payload: suspendPayload.data });
return { __suspend: true, payload: suspendPayload.data };
}
return JSON.stringify(result);
return result;
})()
`;
const resultJson = (await context.eval(invokeCode, {
const result: unknown = await context.evalClosure(invokeCode, [input], {
timeout: 5000,
promise: true,
copy: true,
})) as string;
arguments: { copy: true },
result: { promise: true, copy: true },
});
const parsed = this.parseSandboxJson<Record<string, unknown>>(
resultJson,
'executeToolInIsolate',
);
if (parsed?.__suspend) {
if (
typeof result === 'object' &&
result !== null &&
'__suspend' in result &&
result.__suspend
) {
return {
[Symbol.for('n8n.agent.suspend')]: true,
payload: parsed.payload,
payload: 'payload' in result ? result.payload : undefined,
};
}
return parsed;
return result;
} finally {
context.release();
}
});
}
private async executeToMessageInIsolate(
toolCode: string,
output: unknown,
): Promise<AgentMessage | undefined> {
const jsCode = await this.compileTs(toolCode);
return await this.withIsolate(async (slot) => {
const context = slot.createContext();
try {
this.loadToolInContext(context, slot, jsCode);
const invokeCode = `
return (async function() {
var tool = globalThis.__exportedTool;
var built = tool.build ? tool.build() : tool;
var toMessageFn = built.toMessage;
if (!toMessageFn) return null;
return (await toMessageFn($0)) ?? null;
})()
`;
const message = (await context.evalClosure(invokeCode, [output], {
timeout: 5000,
arguments: { copy: true },
result: { promise: true, copy: true },
})) as AgentMessage | null;
return message ?? undefined;
} finally {
context.release();
}
@ -352,6 +394,13 @@ export class AgentSecureRuntime {
}
return await this.executeToolInIsolate(toolCode, input, ctx);
},
executeToMessage: async (toolName: string, output: unknown) => {
const toolCode = toolsByName[toolName];
if (!toolCode) {
throw new Error(`Tool "${toolName}" not found in tools map`);
}
return await this.executeToMessageInIsolate(toolCode, output);
},
};
}

View File

@ -98,7 +98,8 @@ describe('executeWorkflow → execution classification', () => {
expect(runData.workflowData.pinData).toBeUndefined();
});
it('returns output from the completed integrated run when execution data is not retained', async () => {
it('returns complete oversized results from the completed integrated run', async () => {
const answer = 'x'.repeat(25_000);
const completedRun = {
mode: 'integrated',
status: 'success',
@ -111,7 +112,7 @@ describe('executeWorkflow → execution classification', () => {
runData: {
Result: [
{
data: { main: [[{ json: { answer: 42 } }]] },
data: { main: [[{ json: { answer } }]] },
executionIndex: 0,
startTime: 0,
executionTime: 1,
@ -140,7 +141,7 @@ describe('executeWorkflow → execution classification', () => {
expect(result).toEqual({
executionId: 'exec-1',
status: 'success',
data: { Result: [{ answer: 42 }] },
data: { Result: [{ answer }] },
});
});
@ -330,14 +331,10 @@ describe('executeWorkflow → webhook response', () => {
return (result.data?.response as { body: unknown }).body;
};
it('caps an oversized body, keeping a preview and its length', async () => {
it('returns complete oversized results for serializable webhook bodies', async () => {
const body = { blob: 'x'.repeat(50_000) };
expect(await responseBodyOf(body)).toEqual({
_truncated: true,
_charLength: JSON.stringify(body).length,
_preview: expect.stringMatching(/^\{"blob":"x{100}/),
});
expect(await responseBodyOf(body)).toEqual(body);
});
// Whatever its size: measuring a Buffer costs 12x the body, so it is never serialized.

View File

@ -1,20 +1,20 @@
import type { BuiltTool } from '@n8n/agents';
import { mock } from 'vitest-mock-extended';
import type { AgentKnowledgeSandboxService } from '../../../agent-knowledge-sandbox.service';
import type { AgentKnowledgeMirrorService } from '../../../agent-knowledge-mirror.service';
import { createKnowledgeRetrievalTools } from '../search-knowledge.tool';
const projectId = 'project-1';
const agentId = 'agent-1';
function buildTool(
sandboxService: AgentKnowledgeSandboxService,
knowledgeMirrorService: AgentKnowledgeMirrorService,
toolName: 'find_file' | 'search_text' | 'read_file',
): BuiltTool {
const tool = createKnowledgeRetrievalTools({
projectId,
agentId,
sandboxService,
knowledgeMirrorService,
})
.map((builder) => builder.build())
.find((candidate) => candidate.name === toolName);
@ -34,58 +34,62 @@ function getToolHandler(tool: BuiltTool): NonNullable<BuiltTool['handler']> {
describe('createKnowledgeRetrievalTools', () => {
it('finds files with the project-scoped sandbox using a catch-all pattern', async () => {
const sandboxService = mock<AgentKnowledgeSandboxService>();
sandboxService.globKnowledgeFiles.mockResolvedValue({
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
knowledgeMirrorService.globKnowledgeFiles.mockResolvedValue({
files: [],
limit: 20,
offset: 0,
hasMore: false,
});
const tool = buildTool(sandboxService, 'find_file');
const tool = buildTool(knowledgeMirrorService, 'find_file');
const input = { pattern: '*' };
await getToolHandler(tool)(input, {
persistence: { threadId: 'thread-1', resourceId: 'integration:slack:U123' },
});
expect(sandboxService.globKnowledgeFiles).toHaveBeenCalledWith(projectId, agentId, input);
expect(knowledgeMirrorService.globKnowledgeFiles).toHaveBeenCalledWith(
projectId,
agentId,
input,
);
});
it('searches knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => {
const sandboxService = mock<AgentKnowledgeSandboxService>();
sandboxService.searchKnowledge.mockResolvedValue({
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
knowledgeMirrorService.searchKnowledge.mockResolvedValue({
outputMode: 'content',
matches: [],
limit: 10,
hasMore: false,
truncated: false,
});
const tool = buildTool(sandboxService, 'search_text');
const tool = buildTool(knowledgeMirrorService, 'search_text');
const input = { pattern: 'needle', path: ['notes.txt'] };
await getToolHandler(tool)(input, {
persistence: { threadId: 'thread-1', resourceId: 'integration:slack:U123' },
});
expect(sandboxService.searchKnowledge).toHaveBeenCalledWith(projectId, agentId, input);
expect(knowledgeMirrorService.searchKnowledge).toHaveBeenCalledWith(projectId, agentId, input);
});
it('reads knowledge with the project-scoped sandbox even when memory uses an integration resource', async () => {
const sandboxService = mock<AgentKnowledgeSandboxService>();
sandboxService.readKnowledge.mockResolvedValue({
const knowledgeMirrorService = mock<AgentKnowledgeMirrorService>();
knowledgeMirrorService.readKnowledge.mockResolvedValue({
file: 'notes.txt',
fileId: 'file-1',
displayName: 'notes.txt',
ranges: [],
truncated: false,
});
const tool = buildTool(sandboxService, 'read_file');
const tool = buildTool(knowledgeMirrorService, 'read_file');
const input = { file: 'notes.txt', ranges: [{ startLine: 1, endLine: 3 }] };
await getToolHandler(tool)(input, {
persistence: { threadId: 'thread-1', resourceId: 'integration:slack:U123' },
});
expect(sandboxService.readKnowledge).toHaveBeenCalledWith(projectId, agentId, input);
expect(knowledgeMirrorService.readKnowledge).toHaveBeenCalledWith(projectId, agentId, input);
});
});

View File

@ -1,6 +1,6 @@
import { Tool } from '@n8n/agents/tool';
import type { AgentKnowledgeSandboxService } from '../../agent-knowledge-sandbox.service';
import type { AgentKnowledgeMirrorService } from '../../agent-knowledge-mirror.service';
import {
globKnowledgeFilesInputSchema,
MAX_READ_RANGES,
@ -239,11 +239,11 @@ function toReadKnowledgeModelOutput(output: unknown): unknown {
export function createKnowledgeRetrievalTools({
projectId,
agentId,
sandboxService,
knowledgeMirrorService,
}: {
projectId: string;
agentId: string;
sandboxService: AgentKnowledgeSandboxService;
knowledgeMirrorService: AgentKnowledgeMirrorService;
}) {
const globTool = new Tool('find_file')
.description(
@ -253,7 +253,7 @@ export function createKnowledgeRetrievalTools({
.handler(
async (input) =>
await runKnowledgeTool(
async () => await sandboxService.globKnowledgeFiles(projectId, agentId, input),
async () => await knowledgeMirrorService.globKnowledgeFiles(projectId, agentId, input),
),
)
.toModelOutput(toGlobKnowledgeModelOutput);
@ -266,7 +266,7 @@ export function createKnowledgeRetrievalTools({
.handler(
async (input) =>
await runKnowledgeTool(
async () => await sandboxService.searchKnowledge(projectId, agentId, input),
async () => await knowledgeMirrorService.searchKnowledge(projectId, agentId, input),
),
)
.toModelOutput(toSearchKnowledgeModelOutput);
@ -279,7 +279,7 @@ export function createKnowledgeRetrievalTools({
.handler(
async (input) =>
await runKnowledgeTool(
async () => await sandboxService.readKnowledge(projectId, agentId, input),
async () => await knowledgeMirrorService.readKnowledge(projectId, agentId, input),
),
)
.toModelOutput(toReadKnowledgeModelOutput);

View File

@ -73,8 +73,6 @@ void _assertSupportedTriggersInSync;
const INCOMPATIBLE_NODE_TYPES = new Set<string>(INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES);
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_RESULT_CHARS = 20_000;
const MAX_NODE_OUTPUT_BYTES = 5_000;
function isWorkflowToolResponse(value: unknown): value is IExecuteResponsePromiseData {
return isRecord(value) && ('body' in value || 'headers' in value || 'statusCode' in value);
@ -438,7 +436,7 @@ function collectResultData(runData: IRunData, allOutputs: boolean): Record<strin
for (const [nodeName, nodeRuns] of Object.entries(runData)) {
const outputItems = outputItemsFromNodeRuns(nodeRuns);
if (outputItems.length > 0) {
resultData[nodeName] = truncateNodeOutput(outputItems);
resultData[nodeName] = outputItems;
}
}
return resultData;
@ -449,7 +447,7 @@ function collectResultData(runData: IRunData, allOutputs: boolean): Record<strin
if (lastNodeName) {
const outputItems = outputItemsFromNodeRuns(runData[lastNodeName]);
if (outputItems.length > 0) {
resultData[lastNodeName] = truncateNodeOutput(outputItems);
resultData[lastNodeName] = outputItems;
}
}
return resultData;
@ -467,7 +465,7 @@ function formatResult(
return {
executionId,
status: normaliseExecutionStatus(status),
data: Object.keys(resultData).length > 0 ? truncateResultData(resultData) : undefined,
data: Object.keys(resultData).length > 0 ? resultData : undefined,
error: data?.resultData?.error?.message,
};
}
@ -494,34 +492,12 @@ export async function extractResult(
}
// ---------------------------------------------------------------------------
// Truncation helpers (following Instance AI patterns)
// Webhook response safeguards
// ---------------------------------------------------------------------------
function truncateNodeOutput(items: unknown[]): unknown {
const serialized = JSON.stringify(items);
if (serialized.length <= MAX_NODE_OUTPUT_BYTES) return items;
const truncated: unknown[] = [];
let size = 2; // account for "[]"
for (const item of items) {
const itemStr = JSON.stringify(item);
if (size + itemStr.length + 2 > MAX_NODE_OUTPUT_BYTES) break;
truncated.push(item);
size += itemStr.length + 1;
}
return {
items: truncated,
truncated: true,
totalItems: items.length,
shownItems: truncated.length,
message: `Output truncated: showing ${truncated.length} of ${items.length} items.`,
};
}
/**
* Caps a webhook response's body at {@link MAX_RESULT_CHARS}, describing it
* instead of carrying it once over. Headers and status code pass through.
* Describes webhook bodies that cannot safely enter the textual result path.
* Headers and status code pass through.
*
* @remarks A Buffer body is described without being serialized at all:
* `JSON.stringify` turns it into one array element per byte, which costs about
@ -530,59 +506,18 @@ function truncateNodeOutput(items: unknown[]): unknown {
* length nor preview.
*/
function truncateWebhookResponse(response: IExecuteResponsePromiseData): unknown {
if (!isRecord(response)) {
return response;
}
const { body, ...rest } = response;
if (Buffer.isBuffer(body)) {
return { ...rest, body: { _truncated: true, _byteLength: body.length } };
}
let serialized: string;
try {
serialized = JSON.stringify(body) ?? '';
JSON.stringify(body);
} catch {
return { ...rest, body: { _truncated: true } };
}
if (serialized.length <= MAX_RESULT_CHARS) {
return response;
}
return {
...rest,
body: {
_truncated: true,
_charLength: serialized.length,
_preview: serialized.slice(0, MAX_RESULT_CHARS),
},
};
}
function truncateResultData(data: Record<string, unknown>): Record<string, unknown> {
const serialized = JSON.stringify(data);
if (serialized.length <= MAX_RESULT_CHARS) return data;
const truncated: Record<string, unknown> = {};
for (const [nodeName, rawItems] of Object.entries(data)) {
if (!Array.isArray(rawItems) || rawItems.length === 0) {
truncated[nodeName] = rawItems;
continue;
}
const items = rawItems as unknown[];
const firstItem = items[0];
const itemStr = JSON.stringify(firstItem);
const preview = itemStr.length > 1_000 ? `${itemStr.slice(0, 1_000)}…` : firstItem;
truncated[nodeName] = {
_itemCount: items.length,
_truncated: true,
_firstItemPreview: preview,
};
}
return truncated;
return response;
}
// ---------------------------------------------------------------------------

View File

@ -1,4 +1,9 @@
// Manual mocks — must be declared before any imports that touch the mocked modules.
vi.mock('@n8n/agents', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/agents')>()),
createScopedWorkspace: vi.fn((workspace: unknown) => workspace),
}));
vi.mock('@n8n/instance-ai', async () => {
const { z } = await vi.importActual<typeof import('zod')>('zod');
return {
@ -23,7 +28,6 @@ vi.mock('@n8n/instance-ai', async () => {
}),
),
createLazyWorkspaceRuntimeSkillSource: vi.fn(({ source }) => source),
createScopedWorkspace: vi.fn((workspace: unknown) => workspace),
getPromptWorkspaceRoot: vi.fn(() => '/home/daytona/workspace'),
getWorkspaceRoot: vi.fn(async () => '/home/daytona/workspace'),
setupSandboxWorkspace: vi.fn(),

View File

@ -1,4 +1,4 @@
import { AgentEvent, filterRuntimeSkillSource } from '@n8n/agents';
import { AgentEvent, createScopedWorkspace, filterRuntimeSkillSource } from '@n8n/agents';
import type {
Message,
Workspace,
@ -35,7 +35,6 @@ import {
createAllTools,
createLazyRuntimeWorkspace,
createLazyWorkspaceRuntimeSkillSource,
createScopedWorkspace,
getPromptWorkspaceRoot,
getWorkspaceRoot,
loadInstanceAiRuntimeSkillSource,