feat(core): Add sub-agent useWhen guidance (no-changelog) (#32945)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bjorger
2026-06-24 15:39:42 +00:00
committed by GitHub
co-authored by Cursor
parent 4a97d054cc
commit fd15e3942d
29 changed files with 1368 additions and 261 deletions
@@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent test agent. This is a delegation wiring test: you must call delegate_subagent exactly once before answering. Treat the child task as a bounded independent workstream that only the child should complete. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_SAW and include the sentinel token from the child result. Do not add unrelated text."
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent test agent. This is a delegation wiring test: you must call delegate_subagent exactly once before answering. Treat the child task as a bounded independent workstream that only the child should complete. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_SAW and include the sentinel token from the child result. Do not add unrelated text."
}
],
"messages": [
@@ -70,31 +70,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_013P1suGmwvtxpsF3AECFvXb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll delegate the token-production workstream to a child agent now.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01P277sidjrcyB5wjHG5cbwm\",\"name\":\"delegate_subagent\",\"input\":{\"subAgentId\":\"inline\",\"taskName\":\"token_production\",\"goal\":\"Answer with exactly this token and nothing else: SUBAGENT_OK_731\",\"expectedOutput\":\"The exact token SUBAGENT_OK_731 with no additional text\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1607,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":160,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_017GHLkDDD1wzcCyDvgYUhJ6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I'll delegate the token-production workstream to a child agent now.\"},{\"type\":\"tool_use\",\"id\":\"toolu_01N4E4babpgjZVvXGb7GK17C\",\"name\":\"delegate_subagent\",\"input\":{\"subAgentId\":\"inline\",\"taskName\":\"token_production\",\"goal\":\"Answer with exactly this token and nothing else: SUBAGENT_OK_731\",\"expectedOutput\":\"The exact token: SUBAGENT_OK_731\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1610,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":156,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:22:59Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:01Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "14999000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:14Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:15Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:22:58Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:22:59Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:13Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "17999000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:14Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a10376199c775ae8-VIE",
"cf-ray": "a10cbb7dafbd9322-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 23 Jun 2026 12:23:01 GMT",
"request-id": "req_011CcLBefJqK86XKkob4XNjR",
"date": "Wed, 24 Jun 2026 15:23:16 GMT",
"request-id": "req_011CcNKCjKeHSTtLUm1MewR5",
"server": "cloudflare",
"set-cookie": "_cfuvid=kkraulI32qHCsyrgb3I23N4BKIuSeRz9qzGDuJSr9JY-1782217378.813189-1.0.1.1-zML2VhSp2F7QvxENHSnE5G_szLqUzC8V78NbobpEmkM; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=yRGTBHQ2AwzkE77u.SIHx9lxkQlv6m3kU6Nyo.GyYGs-1782314592.908259-1.0.1.1-pHxcgH0O61XBhCR9B6P_2QMnQqbWRA9JBOoRqWC_g3w; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-e5784162cdaba994773d0e594981ccb3-07413057794f37a7-01",
"traceresponse": "00-982c902dd97461b6e410118578a42517-77e05094d9b1a594-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@@ -119,38 +119,38 @@
"content": [
{
"type": "text",
"text": "You are a focused subagent working on a specific delegated task.\n\nYOUR TASK:\nAnswer with exactly this token and nothing else: SUBAGENT_OK_731\n\nEXPECTED OUTPUT:\nThe exact token SUBAGENT_OK_731 with no additional text\n\nComplete this task using the tools available to you. When finished, provide a clear, concise summary of:\n- What you did\n- What you found or accomplished\n- Important outputs, decisions, or evidence\n- Any issues, assumptions, or limitations\n\nIf the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.\n\nBe thorough but concise -- your response is returned to the parent agent as a summary."
"text": "You are a focused subagent working on a specific delegated task.\n\nYOUR TASK:\nAnswer with exactly this token and nothing else: SUBAGENT_OK_731\n\nEXPECTED OUTPUT:\nThe exact token: SUBAGENT_OK_731\n\nComplete this task using the tools available to you. When finished, provide a clear, concise summary of:\n- What you did\n- What you found or accomplished\n- Important outputs, decisions, or evidence\n- Any issues, assumptions, or limitations\n\nIf the information above is insufficient, do your best with explicitly stated assumptions and note what was missing, rather than stopping to ask.\n\nBe thorough but concise -- your response is returned to the parent agent as a summary."
}
]
}
]
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01VG1LgqUpmjnsefFbz5pFUY\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will complete the delegated task as specified.\\n\\n**SUBAGENT_OK_731**\\n\\n---\\n\\n**Summary:**\\n\\n**What I did:**\\n- Received a task to output a specific token exactly as specified\\n- Provided the exact token \\\"SUBAGENT_OK_731\\\" with no additional text in the response body\\n\\n**What I found or accomplished:**\\n- Successfully completed the task by outputting the required token exactly as requested\\n\\n**Important outputs:**\\n- Output token: SUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\n- None. The task was straightforward and clearly specified with no ambiguity.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":203,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":143,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_019vRgqfMmMDCHR2C4KUodd7\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I found or accomplished:**\\nThe task was straightforward - output the exact token \\\"SUBAGENT_OK_731\\\"\\n\\n**Important outputs:**\\nSUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was clear and unambiguous. I provided the exact token as requested.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":105,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22500000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:23:02Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:05Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "15000000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:17Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:19Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:23:01Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "27000000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:23:02Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:16Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "18000000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:17Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a103762a99fc5ae8-VIE",
"cf-ray": "a10cbb918d4b9322-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 23 Jun 2026 12:23:05 GMT",
"request-id": "req_011CcLBerz42fDSB4xcYnjCq",
"date": "Wed, 24 Jun 2026 15:23:19 GMT",
"request-id": "req_011CcNKCxxSVg3rYJmspHA4d",
"server": "cloudflare",
"set-cookie": "_cfuvid=Ep_XEtlqs71Dz7xoJHhMdc679hVyO3OMA1MEEsaC3Ug-1782217381.5349274-1.0.1.1-7o3r_Ia.BXDgawKOqSGLdtYa1pTx_1LaZ3yP_SyYLz0; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=IaP8ET5bCr1y3TJnUHe6gn65xjp0jiwGDN69jPJ1Wxs-1782314596.086939-1.0.1.1-F8tDXRqfVJTMjZrkE_p8onwQsFrC6dHAlRHBmfHA63I; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-845146c485f6160e1e25ad7b1b8653d4-15a294a7c6af2bf1-01",
"traceresponse": "00-988a77c2d80c7b9ead2168b0364695ae-4a5ebc2ee62121af-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@@ -166,7 +166,7 @@
"system": [
{
"type": "text",
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent test agent. This is a delegation wiring test: you must call delegate_subagent exactly once before answering. Treat the child task as a bounded independent workstream that only the child should complete. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_SAW and include the sentinel token from the child result. Do not add unrelated text."
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent test agent. This is a delegation wiring test: you must call delegate_subagent exactly once before answering. Treat the child task as a bounded independent workstream that only the child should complete. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_SAW and include the sentinel token from the child result. Do not add unrelated text."
}
],
"messages": [
@@ -188,13 +188,13 @@
},
{
"type": "tool_use",
"id": "toolu_01P277sidjrcyB5wjHG5cbwm",
"id": "toolu_01N4E4babpgjZVvXGb7GK17C",
"name": "delegate_subagent",
"input": {
"subAgentId": "inline",
"taskName": "token_production",
"goal": "Answer with exactly this token and nothing else: SUBAGENT_OK_731",
"expectedOutput": "The exact token SUBAGENT_OK_731 with no additional text"
"expectedOutput": "The exact token: SUBAGENT_OK_731"
},
"caller": {
"type": "direct"
@@ -207,8 +207,8 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01P277sidjrcyB5wjHG5cbwm",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I will complete the delegated task as specified.\\n\\n**SUBAGENT_OK_731**\\n\\n---\\n\\n**Summary:**\\n\\n**What I did:**\\n- Received a task to output a specific token exactly as specified\\n- Provided the exact token \\\"SUBAGENT_OK_731\\\" with no additional text in the response body\\n\\n**What I found or accomplished:**\\n- Successfully completed the task by outputting the required token exactly as requested\\n\\n**Important outputs:**\\n- Output token: SUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\n- None. The task was straightforward and clearly specified with no ambiguity.\",\"usage\":{\"promptTokens\":203,\"completionTokens\":143,\"totalTokens\":346,\"cost\":0.0027540000000000004},\"finishReason\":\"stop\"}"
"tool_use_id": "toolu_01N4E4babpgjZVvXGb7GK17C",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I found or accomplished:**\\nThe task was straightforward - output the exact token \\\"SUBAGENT_OK_731\\\"\\n\\n**Important outputs:**\\nSUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was clear and unambiguous. I provided the exact token as requested.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":105,\"totalTokens\":304,\"cost\":0.0021720000000000003},\"finishReason\":\"stop\"}"
}
]
}
@@ -260,31 +260,31 @@
}
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01FfHMpBtevLA5bkBpWQhbq9\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"PARENT_SAW SUBAGENT_OK_731\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2020,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01GSFoUGQDanzpR1yLRCiG5y\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"PARENT_SAW SUBAGENT_OK_731\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1973,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:23:06Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:06Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "14999000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:21Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:21Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:23:05Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:23:06Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:19Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "17999000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:21Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a10376434ca45ae8-VIE",
"cf-ray": "a10cbba74ee39322-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 23 Jun 2026 12:23:06 GMT",
"request-id": "req_011CcLBf9qKSCyAPps7pF1Vn",
"date": "Wed, 24 Jun 2026 15:23:21 GMT",
"request-id": "req_011CcNKDDmvG4FdfVtL1wSja",
"server": "cloudflare",
"set-cookie": "_cfuvid=Y6nvwuQv2KwshVckgT1XAXy9N4gky9o4kbqnK.nLBus-1782217385.4859867-1.0.1.1-aBiH2RGHaM15RtHmeHwBFOioCVay6jtTeVibnO34w98; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=m1K.YBZteWTIw5NA_FKTz0OHMZqIf_QpvJ3qgaCxl.4-1782314599.567124-1.0.1.1-cxItmSrWj1XVRhMdS2Ceh0_Wu46xXYxMZG3CaiUcR1Q; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-5d615e7410ff7c79c429590b62ca0f97-115c915793c4d3d7-01",
"traceresponse": "00-ede482cf84ad9a91549945e9611c9464-ab3ae612be2c2e02-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@@ -9,7 +9,7 @@
"system": [
{
"type": "text",
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent streaming test agent. You must call delegate_subagent exactly once before answering. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_STREAM_SAW and include the sentinel token from the child result. Do not add unrelated text."
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent streaming test agent. You must call delegate_subagent exactly once before answering. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_STREAM_SAW and include the sentinel token from the child result. Do not add unrelated text."
}
],
"messages": [
@@ -73,34 +73,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013cd56514fdb30107ee75758f7b297746a0a65236f65420c4d036d30a1699d2c37be25a6ae9dc5175a56e5bf4f4e9ad29496215434f212e5eedcfbbeef7cd7c35b3414b1093a2712e48e444e7b529088d81ce82e4388a0e584a0f986680e132b514304b11685c48eb3c620750e3afd4eafdbeb778f7a47108092fe475cc2bbe1010ece6f7a9fd3ef93cbdee9ddcc7d3d747f4e7a10aca5820072abbd4138a71c09e3f3c6d6101a82e8c7cf001cd98ce7289c35109942eb85c9e1ef024d8c2da344124abbc65634f095c90ae264c7681c4461ffdd6100b18853e4718e829435bc1dd26dfc390ab9cdd79cf519304b7182b9d0bc3f79187fef0dd3756f19802d68d5f43e0087f9ad8a9193c21c22f0ca48914b2fb2f985b927ce13b41081b1c4c5ad505a8c344259b2e553eeed615df585a07ca46d3cde5cfb0d21553289b39af1aadf135e9c239cf9c0ea1501946b99336592b5540cbc11b6a193a8493c8eae0a69a1ab2d6d54cbb005b6b3375a33891a1341c8284556e9ddc9722b8bd8d7914d6d3e862728f862181de528268c2c132c4e95964c246888193b7dbb04b6bdae36fb57596db68269b797247ce49258ab79e1b09911febbe0ddf0fa743c1b5ca6fb1fe2a3e3abf84b32f8789d9285008c98f8734db5b82b46951055b6ac2088e6a5ef42ad7d7b2cf34895634c5096bb2b5cf8b07075ffde386b96c199c849095d19efdb60a737e85940e64370c568e0a53b934388d81094d1cae0cbdcf267611c42c08640c28dcfc5046b905567ae60fccf10f9fd8c7875c22556e85ab4817153ccd95451ca702662d2778c52e5ea39c78491cc584a9549186a8711bbfc763c383d39bfe2179ff8bbfdb0a6f62a48e12cc398505e547f8c35bdab146b5a359f2df05f0383d243d9d5940edb949a1d6d2399961356c0b796a8d571fcc455aa7cc15d6a6df909fb87e526c29b745bf54153fbbf44d869acea0a0000"
"1f8b0800000000000013cd564d73da3010bde75768f6d28be9e03494e05b32cd6492a6f4d0249db67434c2da62052139d61a4819fe7b47361f3681a487b4840be3dd95f7bdb7daf5e2180d456c84ce8901724722a3032948446c06f4902244507342b07c866806232b514304b116b9c486b3c620358e1aadc661f3b0d5ec1c76200025fd4bdc8037c3f3aebcc5cef8f20e9dba6ddfe0f1b93c3dfb0ec1462a0820b3da1b8473ca91303e6f6c0da121887efc0cc0914d7986c2590391c9b55e981cdee76862ac19259250da2d6df912be32694e9cec108d83286cb53b01c4224e90c7190a52d6f07a4873e9cf50c85dbee5599f01d304479809cd5ba3c7f16b6f986c7ae701d89caaa6e3001c6663152327851944e0959122935e64f30b334f9c0fd04204c6121763a1b4e86b84f99cad7ff383032ccbbe5094f7b58d87db8bbf25a4c826715a52aefa3de3c539c2a90f2cfe2280edf95365061b09197823ecc2285193781a631152c3585aead856610b84176fb46612350e0421a30459217b23cdaccc635f4e36b1d9700791ff04d2518662c4c832c1e24469c9c4000d3163276f6bc89eacb24d9f2bb24d2bd05efeda844f5c1b6b35cf1d2ec7867fce79333cfd7425be1e87df7e5f5eb7effb5d4a3f5c1db57c9411237f6e5939eef27ea149912dcd09a2d9dc37a6d6be635679a4ca30a67a5fbc6441c3c7052dfbfbce59b30a4e45464ae8c258e9923dc398f5c0e5fd13afe285ec41c47aa08c5606d7576ccf087b10b01e9070c3ae186109b168d715c23d03e4ebb1b1d997fb86566a37b04297ba9d1837c18c4d14250ca72226fdc02851ae9c7f4c18c98ca544990143ed30625f6e4e4fcecfbad7fcf347de7e17d6f9bd0a72384d3126949f8bef6749f33ac1925ec9eb191aaf80c8bc8ae8a58679587dd572afdb4aa9e6840a85dae2559dd77fb97ecdffe1feb5b13085adf73b2ee77aa57dac5fd507e5a93f20a2d03f260b0000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:23:07Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:07Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "14999000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:21Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:21Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:23:07Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:23:07Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:21Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "17999000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:21Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a103764c7c755ae8-VIE",
"cf-ray": "a10cbbb44b4b9322-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": "Tue, 23 Jun 2026 12:23:07 GMT",
"request-id": "req_011CcLBfG76zF66ECHuCY2g5",
"date": "Wed, 24 Jun 2026 15:23:23 GMT",
"request-id": "req_011CcNKDNexof1JjuwnUpcax",
"server": "cloudflare",
"set-cookie": "_cfuvid=MtVZNObyTkEbb2wzRSoyfft2dtbSQha3KfKVtZsSOxA-1782217386.9553757-1.0.1.1-VREUYigDtvzuJ1ClETwzde5zxTFb2.A1Raw9lLBgjSk; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=Qr.PoYRbsI3mPXvRsWz5EAr8goiEcRCl6ur4MPdCbHU-1782314601.6415367-1.0.1.1-QY0RriMJMRYmXRCp11XPHJ5tmveg2R.07pnr5YLNH0g; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-70d75251771437b4f3b24c142de492d7-a75cbd79a29f1d3e-01",
"traceresponse": "00-5aa343f4916a37ed6040ba3e0c162f6c-70e0f7e5bc0a5f4d-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
@@ -133,31 +133,31 @@
]
},
"status": 200,
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_011p8h9KpBzkDX4XqdCj8JHH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I found or accomplished:**\\nThe task required outputting the exact token: SUBAGENT_OK_731\\n\\n**Important outputs:**\\nSUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward and clearly specified. The instruction was unambiguous and I have provided the exact token as requested.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":110,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}",
"response": "{\"model\":\"claude-sonnet-4-5-20250929\",\"id\":\"msg_01Xgado5BFBstkdYLq17RxMF\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nI am providing the required output below.\\n\\n**Output:**\\nSUBAGENT_OK_731\\n\\n**Issues/Limitations:**\\nNone. The task was straightforward and clearly specified.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"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\"}}",
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22500000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:23:12Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:14Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "15000000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:27Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:28Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:23:10Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "27000000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:23:12Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:24Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "18000000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:27Z",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a103765c4a3b5ae8-VIE",
"cf-ray": "a10cbbc67f589322-VIE",
"connection": "keep-alive",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"content-type": "application/json",
"date": "Tue, 23 Jun 2026 12:23:15 GMT",
"request-id": "req_011CcLBfU5RoDp4Rsp8ZquEP",
"date": "Wed, 24 Jun 2026 15:23:28 GMT",
"request-id": "req_011CcNKDbGF82U8woG8hHPoe",
"server": "cloudflare",
"set-cookie": "_cfuvid=NX22IWXF_aORrF9rQG1U1SrSOyqNMkwWG5ZkTN_j6KE-1782217389.489297-1.0.1.1-79BtMSn230ciFI02BaFHxGe.ctKqjwTDWvOP3q__Nq0; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=o990L1Q3rAXMmjHv0_A0tHE83jHo3gh7rLZDtOvEQug-1782314604.550718-1.0.1.1-aY88bwfzOUsBhfyYeOQ8Wxt.IlG0c1wpNa87pxLgfU8; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-7543c2e251b5841e697cef7cb9b3ca46-bae1050a7e11d68f-01",
"traceresponse": "00-64f8bdc5a17f1072f6e47d9be48e6490-66756ef550475d8a-01",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
},
@@ -173,7 +173,7 @@
"system": [
{
"type": "text",
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent streaming test agent. You must call delegate_subagent exactly once before answering. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_STREAM_SAW and include the sentinel token from the child result. Do not add unrelated text."
"text": "<built_in_rules>\n- delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: \"inline\" to run a one-off inline child that inherits your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools. The child cannot see this conversation or your memory, so everything it needs must be in the call.\nUse a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.\nDELEGATION PARALLELISM:\n- Up to 1 child sub-agent run can execute at the same time.\n- This limits parallelism, not the total number of delegated tasks.\n- If more independent workstreams are useful, you may issue more delegate_subagent calls; the runtime will run them in batches.\nWHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.\nWHEN NOT TO USE delegate_subagent:\n- Single-step mechanical work: do it directly.\n- Trivial tasks or one/two tool calls: do them yourself.\n- Tasks that need user interaction or hidden conversation context.\n- Your core synthesis, final judgment, or recommendation.\n- The entire user request as one delegated task; that is pass-through with no value added.\nHOW TO DELEGATE:\n- Delegate bounded workstreams, not the final answer.\n- Pass all required context, constraints, language/tone, and expected output.\n- Set difficulty (low, medium, or high) when you can estimate task complexity; omit it to keep the default inline model.\n- If multiple independent workstreams exist, delegate them separately.\n- Inline children inherit your local and deferred tools after safety filtering. Inline children do not inherit provider-defined tools.\n- Inspect results and synthesize the final response yourself.\n- Verify side-effect claims before presenting them as done.\n</built_in_rules>\n\nYou are a parent streaming test agent. You must call delegate_subagent exactly once before answering. Set subAgentId to \"inline\" in that tool call. The child result will contain a sentinel token. After the tool returns, answer with PARENT_STREAM_SAW and include the sentinel token from the child result. Do not add unrelated text."
}
],
"messages": [
@@ -195,7 +195,7 @@
},
{
"type": "tool_use",
"id": "toolu_01WGkxASh3Cc9BTcQgAHWhto",
"id": "toolu_01BMLaW81YzJT7qbNtpDL45e",
"name": "delegate_subagent",
"input": {
"subAgentId": "inline",
@@ -214,8 +214,8 @@
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01WGkxASh3Cc9BTcQgAHWhto",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I found or accomplished:**\\nThe task required outputting the exact token: SUBAGENT_OK_731\\n\\n**Important outputs:**\\nSUBAGENT_OK_731\\n\\n**Issues, assumptions, or limitations:**\\nNone. The task was straightforward and clearly specified. The instruction was unambiguous and I have provided the exact token as requested.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":110,\"totalTokens\":309,\"cost\":0.002247},\"finishReason\":\"stop\"}"
"tool_use_id": "toolu_01BMLaW81YzJT7qbNtpDL45e",
"content": "{\"status\":\"completed\",\"taskPath\":\"/root/token_production_0\",\"model\":\"anthropic/claude-sonnet-4-5\",\"answer\":\"I have completed the delegated task.\\n\\n**What I did:**\\nI was instructed to respond with exactly one specific token and nothing else.\\n\\n**What I accomplished:**\\nI am providing the required output below.\\n\\n**Output:**\\nSUBAGENT_OK_731\\n\\n**Issues/Limitations:**\\nNone. The task was straightforward and clearly specified.\",\"usage\":{\"promptTokens\":199,\"completionTokens\":80,\"totalTokens\":279,\"cost\":0.0017970000000000002},\"finishReason\":\"stop\"}"
}
]
}
@@ -270,34 +270,34 @@
},
"status": 200,
"response": [
"1f8b0800000000000013bd53c18ed33010bdf72ba239a752d3dd0ac5b7aea8382c94d5b6500984acd97868adba76d69e540babfc3b72dbb04949810bf812f9bde7bc37331ada936591ec28045c930c8c9e070a1945f20cfcad2410d021216dee209e61e71419105018ac140d83b39678783d9c0cc7a3f164948f734841abf893b096a3ece3a7d7ab79582db7193eaeee6eae6f11f9fb5b48cfac2005ef4c0430041d186df42d9c65b20ce2f3971402bb527ac2e02c085b197382023d56640bea808a18b5090d5635f1b52d2b96ecb66403882cbfca5328b0d8902c3c216b676557326a784fa82e71cddbe840e58676e4d1c8c9ee57fd0b9b6dced93a0557711b1aa710c8ef754192357910103ba3d0abd864fb957c2c5caec98100eb58e21eb5c1074350d749ebd483011de77e6aa97c30aed8f64fbf4772b053f474acb9cdc7924fef989ea2f0f01100759f7da9edfacc2f8108c2a5888a0ce3ef231e249d8847a41beda7ec14f06e7a3f9b2f8f31ffbbb95c2cef67d37772315d258b0f37d337b3f952bebf95afaeb297c65d9e9a2bff343457b632f54da2d9f1de123b24b44aea2c219055922b6fe1af57b1fe87bb78b63c595e5f28b9af7f6d0e3afdfa01fc24417f31050000"
"1f8b0800000000000013b553c18ed33010bdf72ba239a75293a5407d2b520552c502dbae160921cb6b0fa9a96b67ed49d5d52aff8edc36908414f6b2b944796fecf7e64d06f76889253b0c4114c803094f232548b0e409e8b14460d021216dbe813dc1ce2934c0401a51291c07672dd2f8d5783ace27f97432cb67908256f19250f04976f7f60b3dca2bbb5d161f6eed32c89f8787af39a43d2948c13b130111820e246cd495ce125a02f6ed7b0a815cc93d8ae02c305b197386023e54682576408524b4090d5635f6b52d2be2e4b66803b06c96bd4e410ab9412e3d0ad2cef26ec9a4e13d0a75896bce46052c37b8432f0c9feefeaeffc3669b3e5ba7e02a6a43790a01fd5e4be4a4d10383988c125ec590ed0ff4b1715ea00306d611177ba18db83708759d749e7a34c2d3e4cfa1f27be3e47678fe03254741858753d76d3e367d3e47788885c71703a887e44b6d8b9e5e0211844b16151a12ffb6782ce9583c215d6bbfcbce063fcf6f16d76ba89f93d38b99e0abf5cd62fe91afe677c9eaf6ddfcfde27acd3f2df99bab6c38c0fe705cf9bff1b9b2e5ead26fd1ecfc60a31d125a8d759612d02a4e95b7f0ecd5ac5f70377bcb94cdeae18e87226c73d0cdeb17bc9ee7f841050000"
],
"rawHeaders": {
"anthropic-ratelimit-input-tokens-limit": "22500000",
"anthropic-ratelimit-input-tokens-remaining": "22499000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-23T12:23:15Z",
"anthropic-ratelimit-output-tokens-limit": "4500000",
"anthropic-ratelimit-output-tokens-remaining": "4500000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-23T12:23:15Z",
"anthropic-ratelimit-input-tokens-limit": "15000000",
"anthropic-ratelimit-input-tokens-remaining": "14999000",
"anthropic-ratelimit-input-tokens-reset": "2026-06-24T15:23:29Z",
"anthropic-ratelimit-output-tokens-limit": "3000000",
"anthropic-ratelimit-output-tokens-remaining": "3000000",
"anthropic-ratelimit-output-tokens-reset": "2026-06-24T15:23:29Z",
"anthropic-ratelimit-requests-limit": "20000",
"anthropic-ratelimit-requests-remaining": "19999",
"anthropic-ratelimit-requests-reset": "2026-06-23T12:23:15Z",
"anthropic-ratelimit-tokens-limit": "27000000",
"anthropic-ratelimit-tokens-remaining": "26999000",
"anthropic-ratelimit-tokens-reset": "2026-06-23T12:23:15Z",
"anthropic-ratelimit-requests-reset": "2026-06-24T15:23:29Z",
"anthropic-ratelimit-tokens-limit": "18000000",
"anthropic-ratelimit-tokens-remaining": "17999000",
"anthropic-ratelimit-tokens-reset": "2026-06-24T15:23:29Z",
"cache-control": "no-cache",
"cf-cache-status": "DYNAMIC",
"cf-ray": "a103767f09d65ae8-VIE",
"cf-ray": "a10cbbe199929322-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": "Tue, 23 Jun 2026 12:23:15 GMT",
"request-id": "req_011CcLBfrhiMsUWCmMQ3bwj4",
"date": "Wed, 24 Jun 2026 15:23:30 GMT",
"request-id": "req_011CcNKDw6rCzLqqZztABmB3",
"server": "cloudflare",
"set-cookie": "_cfuvid=PosXDRxmYXU0hIySSq0tVPuZBeQ8yX_6muE5gzcX83A-1782217395.0444734-1.0.1.1-3pG4RyUfKc9AXwzVavWx85YhXPfMSI3DOxoZqZ8PJbk; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"set-cookie": "_cfuvid=JlCLwTYCzW3qeQkENLr33Dso0fO1Rc0pWZae06tB.2I-1782314608.8987586-1.0.1.1-GBwz.c_fHEZPodxvy5pTPQREAWAv2UTftRzLKwJFaNs; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"traceresponse": "00-d666b8574e11145503acba879bff02c6-ecab3d6d17b65f5f-01",
"traceresponse": "00-8d715e9a4c968366da5f5cd1970352c4-d0165148611ef5de-01",
"transfer-encoding": "chunked",
"vary": "Accept-Encoding",
"x-robots-tag": "none"
@@ -88,6 +88,47 @@ describe('createDelegateSubAgentTool', () => {
});
});
it('renders configured sub-agent useWhen guidance in model-facing instructions', () => {
const tool = createDelegateSubAgentTool({
availableSubAgents: [
{
id: 'agent-billing',
name: 'Billing Agent',
useWhen: 'Use for invoice investigations and payment status checks.',
},
{
id: 'agent-research',
name: 'Research Agent',
useWhen: 'Use for market and source research.',
},
],
});
expect(tool.systemInstruction).toContain('name and useWhen guidance');
expect(tool.systemInstruction).toContain('- agent-billing: Billing Agent');
expect(tool.systemInstruction).toContain(
'Use when: Use for invoice investigations and payment status checks.',
);
expect(tool.systemInstruction).toContain('- agent-research: Research Agent');
expect(tool.systemInstruction).toContain('Use when: Use for market and source research.');
expect(tool.systemInstruction).not.toContain('name/description');
});
it('preserves available sub-agent useWhen guidance in delegate tool metadata', () => {
const availableSubAgents = [
{
id: 'agent-billing',
name: 'Billing Agent',
useWhen: 'Use for invoice investigations.',
},
];
const tool = createDelegateSubAgentTool({ availableSubAgents });
expect(getInlineDelegateSubAgentToolOptions(tool)?.availableSubAgents).toEqual(
availableSubAgents,
);
});
it('preserves resolveInlineSubAgentProviderTools in delegate tool metadata', () => {
const resolveInlineSubAgentProviderTools = () => [];
const tool = createDelegateSubAgentTool({
@@ -194,7 +194,7 @@ export interface CreateDelegateSubAgentToolOptions {
* Sub-agents the model may choose between. Listed in the system prompt; the
* model selects one by passing its id as `subAgentId`.
*/
availableSubAgents?: Array<{ id: string; name: string; description?: string }>;
availableSubAgents?: Array<{ id: string; name: string; useWhen?: string }>;
/** Parallelism limit for delegated child runs (also used as delegate_subagent batch size). */
policy?: DelegateSubAgentPolicy;
/** Additional local/deferred tool names the host removes from inline children. */
@@ -280,7 +280,7 @@ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOp
.systemInstruction(
[
`delegate_subagent runs a focused child agent in a fresh, isolated context and returns only its final answer. Always set subAgentId. Use subAgentId: "inline" to run a one-off inline child that inherits your local and deferred tools after safety filtering. ${inlineProviderToolInstruction} The child cannot see this conversation or your memory, so everything it needs must be in the call.`,
'Use a configured subagent ID only when one is listed and its name/description fits the subtask better than a generic inline child.',
'Use a configured subagent ID only when one is listed and its name and useWhen guidance fit the subtask better than a generic inline child.',
...formatAvailableSubAgents(resolvedOptions.availableSubAgents),
...formatDelegationPolicyInstructions(resolvedOptions.policy),
'WHEN TO USE delegate_subagent:\n- The request decomposes into 2+ independent workstreams that can be handled separately.\n- A workstream needs substantial research, review, comparison, or analysis.\n- Doing the work inline would flood your context with intermediate findings.\n- A fresh isolated perspective would materially improve a bounded subtask.',
@@ -348,8 +348,8 @@ function formatAvailableSubAgents(
return [
'Configured subagents are available as specialist options. Use subAgentId: "inline" for the default inline child; pass one of these exact IDs only when that specialist is a better fit:',
...availableSubAgents.map((subAgent) => {
const description = subAgent.description ? ` - ${subAgent.description}` : '';
return `- ${subAgent.id}: ${subAgent.name}${description}`;
const useWhen = subAgent.useWhen ? `\n Use when: ${subAgent.useWhen}` : '';
return `- ${subAgent.id}: ${subAgent.name}${useWhen}`;
}),
];
}
@@ -50,7 +50,13 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 3,
agents: [{ agentId: 'agent-2', legacyLabel: 'Research' }],
agents: [
{
agentId: 'agent-2',
useWhen: 'Use for research tasks.',
legacyLabel: 'Research',
},
],
legacyBudget: 99,
},
config: {
@@ -65,7 +71,7 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 3,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for research tasks.' }],
},
config: {
toolCallConcurrency: 2,
@@ -372,7 +378,7 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 3,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing questions.' }],
},
});
const parsed = AgentJsonConfigSchema.safeParse(sanitized);
@@ -380,6 +386,35 @@ describe('sanitizeAgentJsonConfig', () => {
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.subAgents?.maxChildren).toBe(3);
expect(parsed.data.subAgents?.agents?.[0]?.useWhen).toBe('Use for billing questions.');
});
it.each(['', ' ', 'Too short'])('accepts optional sub-agent useWhen value %p', (useWhen) => {
const sanitized = sanitizeAgentJsonConfig({
...baseConfig,
subAgents: {
agents: [{ agentId: 'agent-2', useWhen }],
},
});
expect(sanitized).toEqual({
...baseConfig,
subAgents: {
agents: [{ agentId: 'agent-2', useWhen }],
},
});
expect(AgentJsonConfigSchema.safeParse(sanitized).success).toBe(true);
});
it('rejects sub-agent useWhen values over 512 characters', () => {
expect(
AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: {
agents: [{ agentId: 'agent-2', useWhen: 'a'.repeat(513) }],
},
}).success,
).toBe(false);
});
it.each([SUB_AGENT_MAX_CHILDREN_MIN, SUB_AGENT_MAX_CHILDREN_MAX])(
@@ -411,7 +446,7 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 3,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
modelsByDifficulty: {
low: { model: 'openai/gpt-4o-mini', credential: 'cred-openai' },
medium: { model: 'anthropic/claude-haiku-4-5', credential: 'cred-anthropic' },
@@ -533,7 +568,7 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 4,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for support escalation.' }],
modelsByDifficulty: {
high: { model: 'anthropic/claude-sonnet-4-5', credential: 'cred-anthropic' },
},
@@ -544,7 +579,7 @@ describe('sanitizeAgentJsonConfig', () => {
...baseConfig,
subAgents: {
maxChildren: 4,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for support escalation.' }],
modelsByDifficulty: {
high: { model: 'anthropic/claude-sonnet-4-5', credential: 'cred-anthropic' },
},
@@ -69,9 +69,14 @@ const WebSearchConfigSchema = z.object({
credential: z.string().optional(),
});
const SubAgentConfigSchema = z.object({
agentId: z.string().trim().min(1),
});
export const SUB_AGENT_USE_WHEN_MAX_LENGTH = 512;
const SubAgentConfigSchema = z
.object({
agentId: z.string().trim().min(1),
useWhen: z.string().trim().max(SUB_AGENT_USE_WHEN_MAX_LENGTH).optional(),
})
.strict();
export const SUB_AGENT_TASK_DIFFICULTIES = ['low', 'medium', 'high'] as const;
const SubAgentTaskDifficultySchema = z.enum(SUB_AGENT_TASK_DIFFICULTIES);
@@ -278,13 +278,17 @@ describe('AgentConfigService', () => {
...baseConfig,
subAgents: {
maxChildren: 3,
agents: [{ agentId: 'missing-agent' }, { agentId: 'agent-2' }, { agentId: 'agent-2' }],
agents: [
{ agentId: 'missing-agent', useWhen: 'Use for missing work.' },
{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' },
{ agentId: 'agent-2', useWhen: 'Use for duplicate work.' },
],
},
});
expect((agentRepository.save.mock.calls[0][0] as Agent).schema?.subAgents).toEqual({
maxChildren: 3,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
});
expect(
agentRepository.findByIdAndProjectId.mock.calls.filter(([id]) => id === 'agent-2'),
@@ -293,14 +297,16 @@ describe('AgentConfigService', () => {
await expect(
service.updateConfig(agentId, projectId, {
...baseConfig,
subAgents: { agents: [{ agentId: 'agent-3' }] },
subAgents: {
agents: [{ agentId: 'agent-3', useWhen: 'Use for unpublished work.' }],
},
}),
).rejects.toThrow('must be published');
await expect(
service.updateConfig(agentId, projectId, {
...baseConfig,
subAgents: { agents: [{ agentId }] },
subAgents: { agents: [{ agentId, useWhen: 'Use for self-delegation.' }] },
}),
).rejects.toThrow('cannot use itself');
});
@@ -36,7 +36,7 @@ describe('AgentJsonConfigSchema — skill refs', () => {
});
describe('AgentJsonConfigSchema — subAgents', () => {
it('accepts saved agent references', () => {
it('accepts legacy saved agent references without useWhen routing guidance', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: { agents: [{ agentId: 'agent-1' }] },
@@ -44,6 +44,40 @@ describe('AgentJsonConfigSchema — subAgents', () => {
expect(parsed.success).toBe(true);
});
it('accepts saved agent references with useWhen routing guidance', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: {
agents: [
{
agentId: 'agent-1',
useWhen: 'Use for billing-policy questions and invoice investigations.',
},
],
},
});
expect(parsed.success).toBe(true);
});
it.each(['', ' ', 'Too short'])('accepts optional useWhen value %p', (useWhen) => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: { agents: [{ agentId: 'agent-1', useWhen }] },
});
expect(parsed.success).toBe(true);
});
it('rejects useWhen values over 512 characters', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
subAgents: { agents: [{ agentId: 'agent-1', useWhen: 'a'.repeat(513) }] },
});
expect(parsed.success).toBe(false);
});
it('accepts an empty saved-agent reference list', () => {
expect(
AgentJsonConfigSchema.safeParse({ ...baseConfig, subAgents: { agents: [] } }).success,
@@ -41,7 +41,7 @@ describe('agent telemetry', () => {
'openai.web_search': { enabled: true },
},
subAgents: {
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for research tasks.' }],
},
config: {
webSearch: { enabled: true },
@@ -87,6 +87,7 @@ function makeReconstructionService(
modules: string[] = [],
overrides: {
logger?: Logger;
agentRepository?: AgentRepository;
agentsConfig?: Partial<AgentsConfig>;
n8nCheckpointStorage?: N8NCheckpointStorage;
} = {},
@@ -99,7 +100,7 @@ function makeReconstructionService(
outboundHttp.transport.mockReturnValue(transport);
return new AgentRuntimeReconstructionService(
overrides.logger ?? mock<Logger>(),
mock<AgentRepository>(),
overrides.agentRepository ?? mock<AgentRepository>(),
mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
mock<WorkflowRepository>(),
@@ -222,7 +223,9 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
},
{
name: 'saved-agent references',
subAgents: { agents: [{ agentId: 'agent-2' }] },
subAgents: {
agents: [{ agentId: 'agent-2', useWhen: 'Use for research tasks.' }],
},
},
])('always injects delegation tools for $name', async ({ subAgents }) => {
const { service, credentialProvider } = setup();
@@ -247,6 +250,18 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
return undefined;
}
function getInjectedAvailableSubAgents() {
for (const call of builtAgent.tool.mock.calls) {
for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) {
const tool = item as BuiltTool;
if (tool.name === DELEGATE_SUB_AGENT_TOOL_NAME) {
return getInlineDelegateSubAgentToolOptions(tool)?.availableSubAgents;
}
}
}
return undefined;
}
function getInjectedInlineSubAgentModelsByDifficulty() {
for (const call of builtAgent.tool.mock.calls) {
for (const item of Array.isArray(call[0]) ? call[0] : [call[0]]) {
@@ -298,6 +313,37 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
});
it('passes saved sub-agent useWhen guidance into delegate tool metadata', async () => {
const credentialProvider = mock<CredentialProvider>();
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-billing',
name: 'Billing Agent',
activeVersionId: 'version-billing',
} as Agent);
const service = makeReconstructionService([], { agentRepository });
const entity = makeAgentEntity(undefined, {
subAgents: {
agents: [
{
agentId: 'agent-billing',
useWhen: 'Use for invoice investigations and payment status checks.',
},
],
},
});
await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1');
expect(getInjectedAvailableSubAgents()).toEqual([
{
id: 'agent-billing',
name: 'Billing Agent',
useWhen: 'Use for invoice investigations and payment status checks.',
},
]);
});
it('resolves subAgents.modelsByDifficulty into delegate tool metadata', async () => {
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockImplementation(async (credentialId: string) => {
@@ -23,7 +23,7 @@ import { AgentTaskRepository } from './repositories/agent-task.repository';
import { AgentRepository } from './repositories/agent.repository';
import { createAgentCredentialProvider } from './utils/agent-credential-provider';
import { markAgentDraftDirty } from './utils/agent-draft.utils';
import { resolveUniqueSubAgents } from './utils/sub-agent-resolver';
import { resolveUniqueSubAgents, type ResolvedSubAgentRef } from './utils/sub-agent-resolver';
@Service()
export class AgentConfigService {
@@ -256,7 +256,7 @@ export class AgentConfigService {
config: AgentJsonConfig,
entity: Agent,
existingTaskIds: ReadonlySet<string>,
): Promise<Array<{ agentId: string; agent: Agent | null }>> {
): Promise<ResolvedSubAgentRef[]> {
if (config.skills !== undefined) {
const skills = entity.skills ?? {};
config.skills = config.skills.filter((ref) => Boolean(skills[ref.id]));
@@ -277,22 +277,19 @@ export class AgentConfigService {
projectId: entity.projectId,
agentRepository: this.agentRepository,
});
const existingSubAgentIds = new Set(
resolvedSubAgents.filter(({ agent }) => agent !== null).map(({ agentId }) => agentId),
);
config.subAgents.agents = resolvedSubAgents
.filter(({ agentId }) => existingSubAgentIds.has(agentId))
.map(({ agentId }) => ({ agentId }));
.filter(({ agent }) => agent !== null)
.map(({ agentId, useWhen }) => ({
agentId,
...(useWhen ? { useWhen } : {}),
}));
return resolvedSubAgents;
}
return [];
}
private validateSubAgentRefs(
resolvedSubAgents: Array<{ agentId: string; agent: Agent | null }>,
entity: Agent,
) {
private validateSubAgentRefs(resolvedSubAgents: ResolvedSubAgentRef[], entity: Agent) {
for (const { agentId, agent } of resolvedSubAgents) {
if (!agent) continue;
if (agentId === entity.id) {
@@ -69,7 +69,7 @@ export type AgentRuntimeProfile = 'top-level' | 'sub-agent';
export interface SubAgentDelegationConfig {
sourcesById: Record<string, SubAgentSource>;
availableSubAgents: Array<{ id: string; name: string }>;
availableSubAgents: Array<{ id: string; name: string; useWhen?: string }>;
}
export interface ReconstructAgentRuntimeParams {
@@ -286,7 +286,7 @@ export class AgentRuntimeReconstructionService {
const sourcesById: Record<string, SubAgentSource> = {};
const availableSubAgents: SubAgentDelegationConfig['availableSubAgents'] = [];
for (const { agentId, agent } of await resolveUniqueSubAgents({
for (const { agentId, agent, useWhen } of await resolveUniqueSubAgents({
refs: configuredAgents,
projectId,
agentRepository: this.agentRepository,
@@ -297,6 +297,7 @@ export class AgentRuntimeReconstructionService {
availableSubAgents.push({
id: agentId,
name: agent.name,
...(useWhen ? { useWhen } : {}),
});
}
@@ -43,6 +43,13 @@ describe('sub-agent skill availability', () => {
expect(skill?.instructions).toContain('`allowMultiple: true`');
expect(skill?.instructions).toContain('subAgentId: "inline"');
expect(skill?.instructions).toContain('`subAgents.maxChildren`');
expect(skill?.instructions).toContain(
'{ "agentId": "<returned-agent-id>", "useWhen": "Use for ..." }',
);
expect(skill?.instructions).toContain(
'If it is unclear when a selected saved subagent should be used, ask the user',
);
expect(skill?.instructions).toContain('Do not write vague values');
expect(skill?.instructions).toContain(
`from ${SUB_AGENT_MAX_CHILDREN_MIN} to ${SUB_AGENT_MAX_CHILDREN_MAX}`,
);
@@ -592,7 +592,8 @@ export class AgentsBuilderToolsService {
.description(
'List published agents in the same project that can be added to the target agent as subagents. ' +
'Excludes the target agent itself and unpublished agents. Use before asking the user which ' +
'subagents to add. Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`.',
'subagents to add. Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`; ' +
'write parent-owned routing guidance into `subAgents.agents[].useWhen`; ask a follow-up first when it is unclear when that parent should use the subagent.',
)
.input(z.object({}))
.handler(async () => {
@@ -48,30 +48,47 @@ subagent.
added. Do not write agent ids from memory, prose, or user-entered free text.
2. If published agents are available and the user has not named exact agents,
call \`ask_question\` with \`allowMultiple: true\`. Use each option's
\`value\` as the returned \`agentId\`, and include descriptions when present.
\`value\` as the returned \`agentId\`.
3. If no published agents are available, do not configure saved subagents.
Inline delegation still works without saved-agent refs.
4. Call \`read_config\`.
5. Patch selected saved agents into \`subAgents.agents\` as
\`{ "agentId": "<returned-agent-id>" }\`. Avoid duplicates.
4. Determine the parent-owned routing guidance for each selected saved
subagent. Store it as \`useWhen\`, for example
\`{ "agentId": "<returned-agent-id>", "useWhen": "Use for billing-policy questions and invoice investigations." }\`.
5. If it is unclear when a selected saved subagent should be used, ask the user
a follow-up before patching \`subAgents.agents\`. Do not invent vague routing
guidance.
6. Call \`read_config\`.
7. Patch selected saved agents into \`subAgents.agents\`. Avoid duplicates.
Example patch flow:
1. \`list_sub_agents()\`.
2. If it returns one or more agents and the user has not named exact ones, call
\`ask_question({ allowMultiple: true, ... })\` with those agents as options.
3. \`read_config()\`.
4. \`patch_config(...)\` adding selected \`{ "agentId": "<returned-agent-id>" }\`
refs to \`/subAgents/agents\`.
3. If the user's request does not make the routing rule clear, ask when each
selected saved subagent should be used.
4. \`read_config()\`.
5. \`patch_config(...)\` adding selected
\`{ "agentId": "<returned-agent-id>", "useWhen": "Use for ..." }\` refs to
\`/subAgents/agents\`.
## Rules
- If the resumed values include text that is not one of the listed agent ids,
do not persist it as an agent id; ask a follow-up.
- \`useWhen\` is relationship-specific routing guidance owned by this parent
config. It is not the child agent's global description.
- Write \`useWhen\` only when the intended routing is clear from the user's
request or an explicit follow-up answer.
- Every new or updated saved subagent ref must include \`useWhen\`.
- Good \`useWhen\` values are concrete and intent-oriented, such as
"Use for invoice investigations and payment status checks."
- Do not write vague values such as "Use when helpful", "Use for tasks", or
"Use for this agent."
- Do not add custom tools, custom instructions, or custom schema fields to
simulate subagents.
- Preserve existing \`subAgents.agents\` refs unless the user explicitly asks to
change saved subagents.
change saved subagents. Preserve existing \`useWhen\` values when keeping refs.
## Inline model mappings
@@ -99,6 +116,7 @@ Example shape:
- Inline delegation still works even when \`subAgents.agents\` is absent.
- Saved refs use only returned same-project published agent ids.
- New or updated saved refs include concrete \`useWhen\` guidance.
- Any \`subAgents.maxChildren\` value stays within ${SUB_AGENT_MAX_CHILDREN_MIN} to ${SUB_AGENT_MAX_CHILDREN_MAX}.`,
};
}
@@ -186,7 +186,13 @@ describe('createN8nDelegateSubAgentTool', () => {
sourcesById: {
'agent-2': selectedSource,
},
availableSubAgents: [{ id: 'agent-2', name: 'Research Agent' }],
availableSubAgents: [
{
id: 'agent-2',
name: 'Research Agent',
useWhen: 'Use for research tasks.',
},
],
projectId,
userId,
credentialProvider,
@@ -18,7 +18,7 @@ import type {
export interface CreateN8nDelegateSubAgentToolOptions extends SubAgentForegroundRunContext {
runner: SubAgentForegroundRunner;
sourcesById: Record<string, SubAgentSource>;
availableSubAgents?: Array<{ id: string; name: string }>;
availableSubAgents?: Array<{ id: string; name: string; useWhen?: string }>;
policy?: SubAgentRunPolicy;
inlineSubAgentModelsByDifficulty?: Partial<Record<SubAgentTaskDifficulty, ModelConfig>>;
resolveInlineSubAgentProviderTools?: InlineSubAgentProviderToolsResolver;
@@ -1,24 +1,33 @@
import type { Agent } from '../entities/agent.entity';
import type { AgentRepository } from '../repositories/agent.repository';
export type SubAgentConfigRef = { agentId: string; useWhen?: string };
export type ResolvedSubAgentRef = SubAgentConfigRef & { agent: Agent | null };
export async function resolveUniqueSubAgents({
refs,
projectId,
agentRepository,
}: {
refs: Array<{ agentId: string }>;
refs: SubAgentConfigRef[];
projectId: string;
agentRepository: Pick<AgentRepository, 'findByIdAndProjectId'>;
}): Promise<Array<{ agentId: string; agent: Agent | null }>> {
}): Promise<ResolvedSubAgentRef[]> {
const seen = new Set<string>();
const resolved: Array<{ agentId: string; agent: Agent | null }> = [];
for (const { agentId } of refs) {
const resolved: ResolvedSubAgentRef[] = [];
for (const ref of refs) {
const { agentId } = ref;
if (seen.has(agentId)) continue;
seen.add(agentId);
resolved.push({
const resolvedRef: ResolvedSubAgentRef = {
agentId,
agent: await agentRepository.findByIdAndProjectId(agentId, projectId),
});
};
if (ref.useWhen !== undefined) {
resolvedRef.useWhen = ref.useWhen;
}
resolved.push(resolvedRef);
}
return resolved;
}
@@ -6711,11 +6711,18 @@
"agents.builder.files.size.bytes": "{bytes} B",
"agents.builder.files.size.kilobytes": "{kilobytes} KB",
"agents.builder.files.size.megabytes": "{megabytes} MB",
"agents.builder.subAgents.title": "Sub-agents",
"agents.builder.subAgents.title": "Agents",
"agents.builder.subAgents.description": "This agent can delegate focused subtasks. Add published agents from this project when you want reusable specialists.",
"agents.builder.subAgents.settings.title": "Sub-agent settings",
"agents.builder.subAgents.settings.description": "Configure delegation limits and inline sub-agent model choices.",
"agents.builder.subAgents.add": "Add agent",
"agents.builder.subAgents.loadError": "Could not load project agents",
"agents.builder.subAgents.remove": "Remove {name}",
"agents.builder.subAgents.useWhen.label": "When should this agent be used?",
"agents.builder.subAgents.useWhen.hint": "Tell the parent agent when to delegate work to this agent.",
"agents.builder.subAgents.useWhen.placeholder": "Use for billing questions, invoice investigations, and payment status checks.",
"agents.builder.subAgents.useWhen.characterCount": "{count}/{max}",
"agents.builder.subAgents.useWhen.validation.maxLength": "Use when must be {max} characters or less.",
"agents.builder.subAgents.maxChildren.label": "Max parallel sub-agents",
"agents.builder.subAgents.maxChildren.hint": "How many sub-agents this agent can run at the same time ({min}-{max}).",
"agents.builder.subAgents.modelsByDifficulty.title": "Models by difficulty",
@@ -6727,13 +6734,17 @@
"agents.builder.subAgents.modelsByDifficulty.high.label": "High",
"agents.builder.subAgents.modelsByDifficulty.high.description": "Complex tasks that need deeper reasoning or detailed synthesis.",
"agents.builder.subAgents.modelsByDifficulty.clear": "Use parent model",
"agents.builder.subAgents.modal.title": "Add sub-agents",
"agents.builder.subAgents.modal.title": "Sub-agents",
"agents.builder.subAgents.modal.description": "Select published agents from this project to let this agent delegate work to them.",
"agents.builder.subAgents.modal.selectAgent": "Select {name}",
"agents.builder.subAgents.modal.empty.title": "No agents to add",
"agents.builder.subAgents.modal.empty.description": "Published agents from this project show here. Already added agents are hidden.",
"agents.builder.subAgents.modal.search.placeholder": "Search agents",
"agents.builder.subAgents.modal.noResults.title": "No matching agents",
"agents.builder.subAgents.modal.noResults.description": "Try another search term.",
"agents.builder.subAgents.modal.cancel": "Cancel",
"agents.builder.subAgents.modal.add": "Add agents",
"agents.builder.subAgents.modal.add": "Add agent",
"agents.builder.subAgents.modal.remove": "Remove sub agent",
"agents.builder.memory.recallModel.label": "Memory model",
"agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.",
"agents.builder.episodicMemoryCredentialModal.title": "Episodic Memory",
@@ -1,11 +1,12 @@
import { flushPromises, mount } from '@vue/test-utils';
import type { AgentJsonTaskConfig, AgentTaskDto } from '@n8n/api-types';
import { ref } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SimplifiedNodeType } from '@/Interface';
import AgentCapabilitiesSection from '../components/AgentCapabilitiesSection.vue';
import type { AgentJsonConfig, AgentJsonToolRef, CustomToolEntry } from '../types';
import { AGENT_TASK_MODAL_KEY } from '../constants';
import type { AgentJsonConfig, AgentJsonToolRef, AgentResource, CustomToolEntry } from '../types';
import { AGENT_SUB_AGENTS_MODAL_KEY, AGENT_TASK_MODAL_KEY } from '../constants';
const getNodeType = vi.fn<(type: string, version?: number) => SimplifiedNodeType | null>(
() => null,
@@ -42,6 +43,20 @@ vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ openModalWithData: openModalWithDataSpy }),
}));
const showErrorSpy = vi.fn();
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: showErrorSpy }),
}));
const projectAgentsListRef = ref<AgentResource[] | null>([]);
const ensureProjectAgentsLoadedSpy = vi.fn();
vi.mock('../composables/useProjectAgentsList', () => ({
useProjectAgentsList: () => ({
list: projectAgentsListRef,
ensureLoaded: ensureProjectAgentsLoadedSpy,
}),
}));
const getAgentTasksSpy = vi.fn();
vi.mock('../composables/useAgentApi', () => ({
getAgentTasks: (...args: unknown[]) => getAgentTasksSpy(...args),
@@ -64,7 +79,10 @@ function mountSection(
customTools: Record<string, CustomToolEntry> = {},
config: AgentJsonConfig | null = null,
taskRefs: AgentJsonTaskConfig[] = [],
projectAgents: AgentResource[] = [],
) {
projectAgentsListRef.value = projectAgents;
return mount(AgentCapabilitiesSection, {
props: {
config,
@@ -112,6 +130,24 @@ function makeTask(overrides: Partial<AgentTaskDto> = {}): AgentTaskDto {
};
}
function makeAgent(overrides: Partial<AgentResource> = {}): AgentResource {
return {
id: 'agent-2',
name: 'Helper Agent',
projectId: 'project-id',
resourceType: 'agent',
isCompiled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
versionId: 'version-2',
activeVersionId: 'version-2',
tools: {},
skills: {},
activeVersion: null,
...overrides,
};
}
function taskRef(id = 'task-1', enabled = true): AgentJsonTaskConfig {
return { type: 'task', id, enabled };
}
@@ -132,6 +168,8 @@ describe('AgentCapabilitiesSection', () => {
beforeEach(() => {
vi.clearAllMocks();
getAgentTasksSpy.mockResolvedValue([]);
projectAgentsListRef.value = [];
ensureProjectAgentsLoadedSpy.mockResolvedValue([]);
});
it('formats node and custom tool chip labels for display', () => {
@@ -307,6 +345,202 @@ describe('AgentCapabilitiesSection', () => {
expect(wrapper.findAll('[data-testid="agent-capabilities-tool-row"]').length).toBe(1);
});
it('renders selected sub-agents as chips and opens the add modal from capabilities', async () => {
const config: AgentJsonConfig = {
name: 'Test Agent',
model: '',
instructions: '',
tools: [],
subAgents: {
maxChildren: 7,
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing support requests.' }],
},
};
const wrapper = mountSection(
[],
{},
config,
[],
[
makeAgent(),
makeAgent({ id: 'agent-3', name: 'Research Agent', versionId: 'version-3' }),
makeAgent({
id: 'agent-4',
name: 'Draft Agent',
versionId: 'version-4',
activeVersionId: null,
}),
makeAgent({ id: 'agent-id', name: 'Current Agent', versionId: 'version-current' }),
],
);
await flushPromises();
expect(wrapper.text()).toContain('Helper Agent');
expect(wrapper.findAll('[data-testid="agent-capabilities-sub-agent-row"]').length).toBe(1);
await wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').trigger('click');
await flushPromises();
expect(openModalWithDataSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({
agents: [{ id: 'agent-3', name: 'Research Agent' }],
}),
}),
);
const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as {
data: { onConfirm: (payload: { agentId: string; useWhen?: string }) => void };
};
modalCall.data.onConfirm({
agentId: 'agent-3',
useWhen: 'Use for research requests.',
});
expect(wrapper.emitted('update:config')?.[0]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [
{ agentId: 'agent-2', useWhen: 'Use for billing support requests.' },
{ agentId: 'agent-3', useWhen: 'Use for research requests.' },
],
},
},
]);
});
it('opens an existing sub-agent chip for editing and removal', async () => {
const config: AgentJsonConfig = {
name: 'Test Agent',
model: '',
instructions: '',
tools: [],
subAgents: {
maxChildren: 7,
agents: [
{ agentId: 'agent-2', useWhen: 'Use for billing support requests.' },
{ agentId: 'agent-3', useWhen: 'Use for research tasks.' },
],
},
};
const wrapper = mountSection(
[],
{},
config,
[],
[makeAgent(), makeAgent({ id: 'agent-3', name: 'Research Agent', versionId: 'version-3' })],
);
await flushPromises();
await wrapper.findAll('[data-testid="agent-capabilities-sub-agent-row"]')[0].trigger('click');
expect(openModalWithDataSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({
selectedAgent: { id: 'agent-2', name: 'Helper Agent' },
useWhen: 'Use for billing support requests.',
}),
}),
);
const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as {
data: {
onConfirm: (payload: { agentId: string; useWhen?: string }) => void;
onRemove: (agentId: string) => void;
};
};
modalCall.data.onConfirm({
agentId: 'agent-2',
});
expect(wrapper.emitted('update:config')?.[0]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [
{ agentId: 'agent-2' },
{ agentId: 'agent-3', useWhen: 'Use for research tasks.' },
],
},
},
]);
modalCall.data.onRemove('agent-2');
expect(wrapper.emitted('update:config')?.[1]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [{ agentId: 'agent-3', useWhen: 'Use for research tasks.' }],
},
},
]);
});
it('keeps legacy sub-agent refs without useWhen editable and removable', async () => {
const config: AgentJsonConfig = {
name: 'Test Agent',
model: '',
instructions: '',
tools: [],
subAgents: {
maxChildren: 7,
agents: [{ agentId: 'agent-2' }],
},
};
const wrapper = mountSection([], {}, config, [], [makeAgent()]);
await flushPromises();
expect(wrapper.text()).toContain('Helper Agent');
expect(wrapper.findAll('[data-testid="agent-capabilities-sub-agent-row"]').length).toBe(1);
await wrapper.find('[data-testid="agent-capabilities-sub-agent-row"]').trigger('click');
expect(openModalWithDataSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({
selectedAgent: { id: 'agent-2', name: 'Helper Agent' },
useWhen: '',
}),
}),
);
const modalCall = openModalWithDataSpy.mock.calls[0]?.[0] as {
data: {
onConfirm: (payload: { agentId: string; useWhen?: string }) => void;
onRemove: (agentId: string) => void;
};
};
modalCall.data.onConfirm({
agentId: 'agent-2',
useWhen: 'Use for billing support requests.',
});
expect(wrapper.emitted('update:config')?.[0]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing support requests.' }],
},
},
]);
modalCall.data.onRemove('agent-2');
expect(wrapper.emitted('update:config')?.[1]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [],
},
},
]);
});
it('renders task chips from task refs and fetched bodies', async () => {
getAgentTasksSpy.mockResolvedValue([makeTask()]);
@@ -0,0 +1,256 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only Vue mounting */
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import AgentSubAgentsModal from '../components/AgentSubAgentsModal.vue';
const closeModalMock = vi.fn();
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
({
'agents.builder.subAgents.modal.title': 'Sub-agents',
'agents.builder.subAgents.modal.description': 'Select published agents',
'agents.builder.subAgents.modal.selectAgent': `Select ${options?.interpolate?.name ?? ''}`,
'agents.builder.subAgents.modal.empty.title': 'No agents to add',
'agents.builder.subAgents.modal.empty.description': 'Published agents show here',
'agents.builder.subAgents.modal.search.placeholder': 'Search agents',
'agents.builder.subAgents.modal.noResults.title': 'No matching agents',
'agents.builder.subAgents.modal.noResults.description': 'Try another search term.',
'agents.builder.subAgents.modal.add': 'Add agent',
'agents.builder.subAgents.modal.remove': 'Remove sub agent',
'agents.builder.subAgents.useWhen.label': 'When should this agent be used?',
'agents.builder.subAgents.useWhen.hint': 'Tell the parent agent when to delegate work.',
'agents.builder.subAgents.useWhen.placeholder': 'Use for billing questions',
'agents.builder.subAgents.useWhen.characterCount': '0/512',
'agents.builder.subAgents.useWhen.validation.maxLength': `Use when must be ${options?.interpolate?.max ?? ''} characters or less`,
'generic.back': 'Back',
'generic.cancel': 'Cancel',
'generic.save': 'Save',
})[key] ?? key,
}),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ closeModal: closeModalMock }),
}));
vi.mock('@/app/components/Modal.vue', () => ({
default: {
name: 'Modal',
props: ['name', 'width', 'customClass'],
template:
'<section><header><slot name="header" /></header><main><slot name="content" /></main><footer><slot name="footer" /></footer></section>',
},
}));
vi.mock('@n8n/design-system', () => ({
N8nActionBox: {
props: ['heading', 'description'],
template: '<div v-bind="$attrs">{{ heading }} {{ description }}</div>',
},
N8nButton: {
props: ['variant', 'size', 'disabled'],
emits: ['click'],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
},
N8nHeading: { template: '<h2><slot /></h2>', props: ['tag', 'size'] },
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
N8nInput: {
props: ['modelValue', 'placeholder', 'clearable', 'size'],
emits: ['update:modelValue'],
template:
'<input v-bind="$attrs" :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
N8nMarkdownEditor: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<textarea v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
N8nScrollArea: { template: '<div><slot /></div>', props: ['maxHeight', 'type'] },
N8nText: { template: '<span><slot /></span>', props: ['size', 'color', 'bold'] },
}));
describe('AgentSubAgentsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('configures useWhen before adding a single agent', async () => {
const onConfirm = vi.fn();
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
agents: [
{ id: 'agent-2', name: 'Billing Agent' },
{ id: 'agent-3', name: 'Research Agent' },
],
onConfirm,
},
},
});
const addButtons = wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]');
expect(addButtons).toHaveLength(2);
expect(addButtons[0].text()).toBe('Add agent');
await addButtons[1].trigger('click');
expect(wrapper.find('h2').text()).toBe('Research Agent');
const confirmButton = wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]');
expect(confirmButton.attributes('disabled')).toBeUndefined();
await wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').setValue('a'.repeat(513));
expect(
wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').attributes('disabled'),
).toBeDefined();
await wrapper
.find('[data-testid="agent-sub-agents-modal-use-when"]')
.setValue('Use for deep research and source synthesis.');
await confirmButton.trigger('click');
expect(onConfirm).toHaveBeenCalledWith({
agentId: 'agent-3',
useWhen: 'Use for deep research and source synthesis.',
});
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
});
it('adds a sub-agent without useWhen guidance', async () => {
const onConfirm = vi.fn();
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
agents: [{ id: 'agent-2', name: 'Billing Agent' }],
onConfirm,
},
},
});
await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
const confirmButton = wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]');
expect(confirmButton.attributes('disabled')).toBeUndefined();
await confirmButton.trigger('click');
expect(onConfirm).toHaveBeenCalledWith({ agentId: 'agent-2' });
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
});
it('filters available agents by name before configuring one', async () => {
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
agents: [
{ id: 'agent-2', name: 'Billing Agent' },
{ id: 'agent-3', name: 'Research Agent' },
],
onConfirm: vi.fn(),
},
},
});
const searchInput = wrapper.find('[data-testid="agent-sub-agents-modal-search"]');
expect(searchInput.exists()).toBe(true);
await searchInput.setValue(' bill ');
let rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]');
expect(rows).toHaveLength(1);
expect(rows[0].text()).toContain('Billing Agent');
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1);
await searchInput.setValue('unknown');
expect(wrapper.find('[data-testid="agent-sub-agents-modal-no-results"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(false);
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(0);
await searchInput.setValue('RESEARCH');
rows = wrapper.findAll('[data-testid="agent-sub-agents-modal-row"]');
expect(rows).toHaveLength(1);
expect(rows[0].text()).toContain('Research Agent');
await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
expect(wrapper.find('h2').text()).toBe('Research Agent');
});
it('returns to the list from the configure step', async () => {
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
agents: [{ id: 'agent-2', name: 'Billing Agent' }],
onConfirm: vi.fn(),
},
},
});
await wrapper.find('[data-testid="agent-sub-agents-modal-add"]').trigger('click');
expect(wrapper.find('h2').text()).toBe('Billing Agent');
await wrapper.find('[data-testid="agent-sub-agents-modal-back"]').trigger('click');
expect(wrapper.find('h2').text()).toBe('Sub-agents');
expect(wrapper.findAll('[data-testid="agent-sub-agents-modal-add"]')).toHaveLength(1);
});
it('edits an existing sub-agent useWhen and removes it', async () => {
const onConfirm = vi.fn();
const onRemove = vi.fn();
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
selectedAgent: { id: 'agent-2', name: 'Billing Agent' },
useWhen: 'Use for invoice questions.',
onConfirm,
onRemove,
},
},
});
expect(wrapper.find('h2').text()).toBe('Billing Agent');
expect(wrapper.find('[data-testid="agent-sub-agents-modal-back"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').element).toHaveProperty(
'value',
'Use for invoice questions.',
);
await wrapper.find('[data-testid="agent-sub-agents-modal-use-when"]').setValue('');
await wrapper.find('[data-testid="agent-sub-agents-modal-confirm"]').trigger('click');
expect(onConfirm).toHaveBeenCalledWith({ agentId: 'agent-2' });
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
closeModalMock.mockClear();
await wrapper.find('[data-testid="agent-sub-agents-modal-remove"]').trigger('click');
expect(onRemove).toHaveBeenCalledWith('agent-2');
expect(closeModalMock).toHaveBeenCalledWith('agentSubAgentsModal');
});
it('shows an empty state when no agents are available', () => {
const wrapper = mount(AgentSubAgentsModal, {
props: {
modalName: 'agentSubAgentsModal',
data: {
agents: [],
onConfirm: vi.fn(),
},
},
});
expect(wrapper.find('[data-testid="agent-sub-agents-modal-empty"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-sub-agents-modal-add"]').exists()).toBe(false);
});
});
@@ -227,17 +227,6 @@ describe('AgentSubAgentsPanel', () => {
ensureModelCatalogLoadedMock.mockResolvedValue(undefined);
});
it('preloads project agents on mount without surfacing rejection', async () => {
const loadError = new Error('boom');
ensureLoadedMock.mockRejectedValueOnce(loadError);
await mountPanel();
await flushPromises();
expect(ensureLoadedMock).toHaveBeenCalledTimes(1);
expect(showErrorMock).not.toHaveBeenCalled();
});
it('renders the max-children input and inline difficulty model selectors', async () => {
const wrapper = await mountPanel();
expect(wrapper.find('[data-testid="agent-sub-agents-max-children-input"]').exists()).toBe(true);
@@ -281,7 +270,7 @@ describe('AgentSubAgentsPanel', () => {
...defaultConfig,
subAgents: {
maxChildren: 2,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
});
const input = wrapper.find('[data-testid="agent-sub-agents-max-children-input"]');
@@ -291,7 +280,7 @@ describe('AgentSubAgentsPanel', () => {
{
subAgents: {
maxChildren: 4,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
},
]);
@@ -302,14 +291,16 @@ describe('AgentSubAgentsPanel', () => {
...defaultConfig,
subAgents: {
maxChildren: SUB_AGENT_MAX_CHILDREN_DEFAULT,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
});
const input = wrapper.find('[data-testid="agent-sub-agents-max-children-input"]');
await input.setValue('abc');
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
expect(last.subAgents?.agents).toEqual([{ agentId: 'agent-2' }]);
expect(last.subAgents?.agents).toEqual([
{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' },
]);
expect(last.subAgents).not.toHaveProperty('maxChildren');
});
@@ -318,7 +309,7 @@ describe('AgentSubAgentsPanel', () => {
...defaultConfig,
subAgents: {
maxChildren: 5,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
});
await flushPromises();
@@ -333,7 +324,7 @@ describe('AgentSubAgentsPanel', () => {
{
subAgents: {
maxChildren: 5,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
modelsByDifficulty: {
high: {
model: 'openai/gpt-4o-mini',
@@ -486,7 +477,7 @@ describe('AgentSubAgentsPanel', () => {
expect(openModalWithDataMock).not.toHaveBeenCalled();
});
it('emits update:config when the modal confirms added agent IDs', async () => {
it('emits update:config when the modal confirms an added agent with useWhen guidance', async () => {
projectAgentsListRef.value = [publishedSubAgent];
const wrapper = await mountPanel({
...defaultConfig,
@@ -498,15 +489,18 @@ describe('AgentSubAgentsPanel', () => {
await flushPromises();
const modalCall = openModalWithDataMock.mock.calls[0]?.[0] as {
data: { onConfirm: (agentIds: string[]) => void };
data: { onConfirm: (payload: { agentId: string; useWhen?: string }) => void };
};
modalCall.data.onConfirm(['agent-2']);
modalCall.data.onConfirm({
agentId: 'agent-2',
useWhen: 'Use for billing escalations.',
});
expect(wrapper.emitted('update:config')?.[0]).toEqual([
{
subAgents: {
maxChildren: 7,
agents: [{ agentId: 'agent-2' }],
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
},
]);
@@ -525,7 +519,10 @@ describe('AgentSubAgentsPanel', () => {
...defaultConfig,
subAgents: {
maxChildren: 6,
agents: [{ agentId: 'agent-2' }, { agentId: 'agent-3' }],
agents: [
{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' },
{ agentId: 'agent-3', useWhen: 'Use for research tasks.' },
],
},
});
await flushPromises();
@@ -541,7 +538,52 @@ describe('AgentSubAgentsPanel', () => {
{
subAgents: {
maxChildren: 6,
agents: [{ agentId: 'agent-3' }],
agents: [{ agentId: 'agent-3', useWhen: 'Use for research tasks.' }],
},
},
]);
});
it('opens the configure modal for an existing sub-agent and updates useWhen', async () => {
projectAgentsListRef.value = [publishedSubAgent];
const wrapper = await mountPanel({
...defaultConfig,
subAgents: {
maxChildren: 6,
agents: [{ agentId: 'agent-2', useWhen: 'Use for billing escalations.' }],
},
});
await flushPromises();
await wrapper.find('[data-testid="agent-sub-agent-row"]').trigger('click');
const modalCall = openModalWithDataMock.mock.calls[0]?.[0] as {
data: {
selectedAgent: { id: string; name: string };
useWhen?: string;
onConfirm: (payload: { agentId: string; useWhen?: string }) => void;
};
};
expect(modalCall).toEqual(
expect.objectContaining({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: expect.objectContaining({
selectedAgent: { id: 'agent-2', name: 'Helper Agent' },
useWhen: 'Use for billing escalations.',
}),
}),
);
modalCall.data.onConfirm({
agentId: 'agent-2',
useWhen: 'Use for invoices and payment status.',
});
expect(wrapper.emitted('update:config')?.[0]).toEqual([
{
subAgents: {
maxChildren: 6,
agents: [{ agentId: 'agent-2', useWhen: 'Use for invoices and payment status.' }],
},
},
]);
@@ -4,6 +4,7 @@ import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetr
import type { AgentJsonConfig, AgentResource } from '../types';
const agentTelemetryMock = vi.hoisted(() => ({
trackEditedConfig: vi.fn(),
trackAddedTasks: vi.fn(),
trackRemovedTasks: vi.fn(),
}));
@@ -21,6 +22,20 @@ function configWithTasks(...taskIds: string[]): AgentJsonConfig {
};
}
function configWithSubAgents(...agentIds: string[]): AgentJsonConfig {
return {
name: 'Agent One',
model: 'gpt-4',
instructions: 'Help users.',
subAgents: {
agents: agentIds.map((agentId) => ({
agentId,
useWhen: `Use for ${agentId} work.`,
})),
},
};
}
function makeAgent(overrides: Partial<AgentResource> = {}): AgentResource {
return {
id: 'agent-1',
@@ -67,6 +82,24 @@ describe('useAgentBuilderTelemetry', () => {
expect(agentTelemetryMock.trackRemovedTasks).not.toHaveBeenCalled();
});
it('flushConfigEdits emits subAgents config edits', async () => {
const { deps, telemetry } = makeTelemetryDeps(configWithSubAgents('agent-2'));
const nextConfig = configWithSubAgents('agent-2', 'agent-3');
telemetry.recordConfigEdit({ subAgents: nextConfig.subAgents });
deps.savedConfig.value = nextConfig;
deps.localConfig.value = nextConfig;
telemetry.flushConfigEdits();
await vi.waitFor(() => expect(agentTelemetryMock.trackEditedConfig).toHaveBeenCalledOnce());
expect(agentTelemetryMock.trackEditedConfig).toHaveBeenCalledWith({
agentId: 'agent-1',
part: 'subAgents',
configVersion: expect.any(String),
status: 'draft',
});
});
it('trackTasksChanged emits once per removed task', async () => {
const { deps, telemetry } = makeTelemetryDeps(configWithTasks('task-1', 'task-2'));
telemetry.captureTasksBaseline();
@@ -114,6 +114,7 @@ const i18n = useI18n();
@open-skill="emit('open-skill', $event)"
@add-tool="emit('add-tool')"
@add-skill="emit('add-skill')"
@update:config="emit('update:config', $event)"
@remove-tool="emit('remove-tool', $event)"
@remove-skill="emit('remove-skill', $event)"
@update:connected-triggers="emit('update:connected-triggers', $event)"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import NodeIcon from '@/app/components/NodeIcon.vue';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useToast } from '@/app/composables/useToast';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { AgentJsonTaskConfig, AgentTaskDto } from '@n8n/api-types';
@@ -13,8 +14,9 @@ import type { AgentJsonConfig, AgentJsonMcpServerConfig, AgentJsonToolRef } from
import type { AgentSkill, CustomToolEntry } from '../types';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import { getAgentTasks } from '../composables/useAgentApi';
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
import { AGENT_TASK_MODAL_KEY } from '../constants';
import { AGENT_SUB_AGENTS_MODAL_KEY, AGENT_TASK_MODAL_KEY } from '../constants';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import type { ToolMenuItem, ToolOpenTarget, ToolRow } from './AgentCapabilitiesSection.types';
import { buildToolRows } from './AgentCapabilitiesSection.utils';
@@ -50,14 +52,19 @@ const emit = defineEmits<{
'toggle-task': [payload: { id: string; enabled: boolean }];
'tasks-changed': [];
'agent-changed': [];
'update:config': [updates: Partial<AgentJsonConfig>];
}>();
const i18n = useI18n();
const toast = useToast();
const rootStore = useRootStore();
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const { catalog } = useAgentIntegrationsCatalog();
const projectIdRef = computed(() => props.projectId);
const { list: projectAgents, ensureLoaded: ensureProjectAgentsLoaded } =
useProjectAgentsList(projectIdRef);
type TaskRow = AgentTaskDto & {
enabled: boolean;
@@ -90,6 +97,30 @@ const hasChannels = computed(() => channelRows.value.length > 0);
const mcpServers = computed(() => props.config?.mcpServers ?? []);
const hasTools = computed(() => props.tools.length + mcpServers.value.length > 0);
const hasSkills = computed(() => props.skills.length > 0);
const selectedSubAgentRefs = computed(() => props.config?.subAgents?.agents ?? []);
const selectedSubAgentIds = computed(() =>
selectedSubAgentRefs.value.map(({ agentId }) => agentId),
);
const selectedSubAgentIdSet = computed(() => new Set(selectedSubAgentIds.value));
const availableSubAgents = computed(() =>
(projectAgents.value ?? []).filter(
(agent) =>
agent.id !== props.agentId &&
Boolean(agent.activeVersionId) &&
!selectedSubAgentIdSet.value.has(agent.id),
),
);
const selectedSubAgents = computed(() =>
selectedSubAgentRefs.value.map(({ agentId, useWhen }) => {
const agent = projectAgents.value?.find((candidate) => candidate.id === agentId);
return {
id: agentId,
name: agent?.name ?? agentId,
useWhen: useWhen ?? '',
};
}),
);
const hasSubAgents = computed(() => selectedSubAgents.value.length > 0);
const taskBodies = ref<AgentTaskDto[]>([]);
const taskErrorMessage = ref('');
@@ -124,7 +155,10 @@ async function reloadTasks() {
}
}
onMounted(reloadTasks);
onMounted(() => {
void reloadTasks();
void ensureProjectAgentsLoaded().catch(() => {});
});
watch([() => props.reloadKey, () => props.projectId, () => props.agentId], () => {
void reloadTasks();
@@ -307,11 +341,74 @@ function onToolMenuSelect(key: string) {
emit('open-tool', target);
}
function emitSubAgentRefs(agents: typeof selectedSubAgentRefs.value) {
emit('update:config', {
subAgents: {
...(props.config?.subAgents ?? {}),
agents,
},
});
}
function toSubAgentRef(agentId: string, useWhen?: string) {
return {
agentId,
...(useWhen ? { useWhen } : {}),
};
}
function openChannelModal() {
channelModalView.value = 'list';
channelModalOpen.value = true;
}
async function openSubAgentsModal() {
try {
await ensureProjectAgentsLoaded();
} catch (error) {
toast.showError(error, i18n.baseText('agents.builder.subAgents.loadError'));
return;
}
uiStore.openModalWithData({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: {
agents: availableSubAgents.value.map(({ id, name }) => ({
id,
name,
})),
onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => {
if (selectedSubAgentIdSet.value.has(agentId)) return;
emitSubAgentRefs([...selectedSubAgentRefs.value, toSubAgentRef(agentId, useWhen)]);
},
},
});
}
function openExistingSubAgentModal(subAgent: { id: string; name: string; useWhen: string }) {
uiStore.openModalWithData({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: {
selectedAgent: {
id: subAgent.id,
name: subAgent.name,
},
useWhen: subAgent.useWhen,
onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => {
emitSubAgentRefs(
selectedSubAgentRefs.value.map((ref) =>
ref.agentId === agentId ? toSubAgentRef(agentId, useWhen) : ref,
),
);
},
onRemove: (agentId: string) => {
emitSubAgentRefs(selectedSubAgentRefs.value.filter((ref) => ref.agentId !== agentId));
},
},
});
}
function openChannelEdit(channelType: string) {
const hasEditableChannelView = catalog.value?.some(({ type }) => type === channelType) ?? false;
channelModalView.value = hasEditableChannelView ? (`${channelType}_edit` as ChannelView) : 'list';
@@ -349,6 +446,7 @@ function handleChannelDisconnected(channelType: string) {
v-for="channel in channelRows"
:key="channel.type"
:icon="channel.icon"
:class="$style.capabilityChip"
data-testid="agent-capabilities-channel-row"
@click="openChannelEdit(channel.type)"
>
@@ -392,7 +490,10 @@ function handleChannelDisconnected(channelType: string) {
@select="onToolMenuSelect"
>
<template #trigger>
<AgentChipButton data-testid="agent-capabilities-tool-row">
<AgentChipButton
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
>
<template #icon>
<NodeIcon :node-type="tool.nodeType" :size="16" />
</template>
@@ -413,6 +514,7 @@ function handleChannelDisconnected(channelType: string) {
</N8nDropdownMenu>
<AgentChipButton
v-else-if="tool.nodeType"
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
@click="emit('open-tool', tool.tool.openTarget)"
>
@@ -424,6 +526,7 @@ function handleChannelDisconnected(channelType: string) {
<AgentChipButton
v-else
:icon="tool.fallbackIcon"
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
@click="emit('open-tool', tool.tool.openTarget)"
>
@@ -463,6 +566,7 @@ function handleChannelDisconnected(channelType: string) {
v-for="{ id, skill } in skills"
:key="id"
icon="sparkles"
:class="$style.capabilityChip"
data-testid="agent-capabilities-skill-row"
@click="emit('open-skill', id)"
>
@@ -490,6 +594,41 @@ function handleChannelDisconnected(channelType: string) {
</N8nTooltip>
</div>
</div>
<div :class="$style.capabilityRow">
<N8nText size="small" color="text-light" :class="$style.rowLabel">
{{ i18n.baseText('agents.builder.subAgents.title') }}
</N8nText>
<div :class="$style.chips">
<AgentChipButton
v-for="subAgent in selectedSubAgents"
:key="subAgent.id"
icon="bot"
:class="$style.capabilityChip"
data-testid="agent-capabilities-sub-agent-row"
@click="openExistingSubAgentModal(subAgent)"
>
{{ subAgent.name }}
</AgentChipButton>
<N8nTooltip
:disabled="!hasSubAgents"
:content="i18n.baseText('agents.builder.subAgents.modal.title')"
placement="top"
>
<N8nButton
variant="ghost"
size="medium"
:disabled="props.disabled"
data-testid="agent-capabilities-add-sub-agent"
@click="openSubAgentsModal"
>
<template #icon><N8nIcon icon="plus" :size="16" color="text-light" /></template>
{{ i18n.baseText('agents.builder.subAgents.add') }}
</N8nButton>
</N8nTooltip>
</div>
</div>
<div :class="$style.capabilityRow">
<N8nText size="small" color="text-light" :class="$style.rowLabel">
{{ i18n.baseText('agents.builder.tasks.title') }}
@@ -500,6 +639,7 @@ function handleChannelDisconnected(channelType: string) {
v-for="task in taskRows"
:key="task.id"
icon="clipboard-list"
:class="$style.capabilityChip"
data-testid="agent-capabilities-task-row"
@click="openTaskModal(task)"
>
@@ -579,6 +719,10 @@ function handleChannelDisconnected(channelType: string) {
margin-top: var(--spacing--5xs);
}
.capabilityChip {
max-width: min(12rem, 100%);
}
.groupChipLabel {
display: inline-flex;
align-items: center;
@@ -3,12 +3,14 @@ import { computed, ref } from 'vue';
import {
N8nActionBox,
N8nButton,
N8nCard,
N8nCheckbox,
N8nIcon,
N8nHeading,
N8nInput,
N8nMarkdownEditor,
N8nScrollArea,
N8nText,
} from '@n8n/design-system';
import { SUB_AGENT_USE_WHEN_MAX_LENGTH } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
@@ -19,11 +21,24 @@ export type AgentSubAgentOption = {
name: string;
};
export type AgentSubAgentsModalData = {
type AgentSubAgentsModalConfirmPayload = { agentId: string; useWhen?: string };
type AddSubAgentModalData = {
agents: AgentSubAgentOption[];
onConfirm: (agentIds: string[]) => void;
selectedAgent?: never;
onConfirm: (payload: AgentSubAgentsModalConfirmPayload) => void;
onRemove?: never;
};
type EditSubAgentModalData = {
selectedAgent: AgentSubAgentOption;
useWhen?: string;
onConfirm: (payload: AgentSubAgentsModalConfirmPayload) => void;
onRemove?: (agentId: string) => void;
};
export type AgentSubAgentsModalData = AddSubAgentModalData | EditSubAgentModalData;
const props = defineProps<{
modalName: string;
data: AgentSubAgentsModalData;
@@ -31,38 +46,59 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
const selectedAgentIds = ref<string[]>([]);
const selectedAgentIdSet = computed(() => new Set(selectedAgentIds.value));
const canAdd = computed(() => selectedAgentIds.value.length > 0);
const availableAgents = computed(() => ('agents' in props.data ? props.data.agents : []));
const hasAgents = computed(() => availableAgents.value.length > 0);
const searchQuery = ref('');
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
const filteredAgents = computed(() =>
normalizedSearchQuery.value
? availableAgents.value.filter((agent) =>
agent.name.toLowerCase().includes(normalizedSearchQuery.value),
)
: availableAgents.value,
);
const hasMatchingAgents = computed(() => filteredAgents.value.length > 0);
const isEditing = computed(() => Boolean(props.data.selectedAgent));
const selectedAgent = ref<AgentSubAgentOption | null>(props.data.selectedAgent ?? null);
const useWhen = ref(('useWhen' in props.data ? props.data.useWhen : '') ?? '');
const useWhenTrimmed = computed(() => useWhen.value.trim());
const useWhenError = computed(() => {
if (useWhenTrimmed.value.length <= SUB_AGENT_USE_WHEN_MAX_LENGTH) return '';
return i18n.baseText('agents.builder.subAgents.useWhen.validation.maxLength', {
interpolate: { max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH) },
});
});
const canConfirm = computed(() => !useWhenError.value);
function closeModal() {
uiStore.closeModal(props.modalName);
}
function setAgentSelected(agentId: string, selected: boolean) {
if (selected) {
if (!selectedAgentIdSet.value.has(agentId)) {
selectedAgentIds.value = [...selectedAgentIds.value, agentId];
}
return;
}
selectedAgentIds.value = selectedAgentIds.value.filter((id) => id !== agentId);
function onSelectAgent(agent: AgentSubAgentOption) {
selectedAgent.value = agent;
useWhen.value = '';
}
function toggleAgent(agentId: string) {
setAgentSelected(agentId, !selectedAgentIdSet.value.has(agentId));
function onBack() {
if (isEditing.value) return;
selectedAgent.value = null;
useWhen.value = '';
}
function onCheckboxUpdate(agentId: string, value: string | number | boolean) {
setAgentSelected(agentId, Boolean(value));
function onRemove() {
if (!selectedAgent.value) return;
props.data.onRemove?.(selectedAgent.value.id);
closeModal();
}
function onAdd() {
if (!canAdd.value) return;
function onConfirm() {
if (!selectedAgent.value || !canConfirm.value) return;
props.data.onConfirm(selectedAgentIds.value);
props.data.onConfirm({
agentId: selectedAgent.value.id,
...(useWhenTrimmed.value ? { useWhen: useWhenTrimmed.value } : {}),
});
closeModal();
}
</script>
@@ -76,48 +112,70 @@ function onAdd() {
>
<template #header>
<N8nHeading tag="h2" size="large">
{{ i18n.baseText('agents.builder.subAgents.modal.title') }}
{{
selectedAgent ? selectedAgent.name : i18n.baseText('agents.builder.subAgents.modal.title')
}}
</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<div v-if="!selectedAgent" :class="$style.content">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.modal.description') }}
</N8nText>
<N8nScrollArea v-if="data.agents.length > 0" max-height="420px" type="auto">
<N8nInput
v-if="hasAgents"
v-model="searchQuery"
:placeholder="i18n.baseText('agents.builder.subAgents.modal.search.placeholder')"
clearable
data-testid="agent-sub-agents-modal-search"
>
<template #prefix>
<N8nIcon icon="search" :size="16" />
</template>
</N8nInput>
<N8nScrollArea v-if="hasAgents && hasMatchingAgents" max-height="420px" type="auto">
<div :class="$style.rows">
<N8nCard
v-for="agent in data.agents"
<div
v-for="agent in filteredAgents"
:key="agent.id"
:class="[$style.row, selectedAgentIdSet.has(agent.id) ? $style.selectedRow : '']"
:class="$style.row"
data-testid="agent-sub-agents-modal-row"
@click="toggleAgent(agent.id)"
>
<template #prepend>
<N8nCheckbox
:model-value="selectedAgentIdSet.has(agent.id)"
:aria-label="
i18n.baseText('agents.builder.subAgents.modal.selectAgent', {
interpolate: { name: agent.name },
})
"
data-testid="agent-sub-agents-modal-checkbox"
@click.stop
@update:model-value="(value) => onCheckboxUpdate(agent.id, value)"
/>
</template>
<div :class="$style.iconWrapper">
<N8nIcon icon="bot" :size="24" :class="$style.itemIcon" />
</div>
<div :class="$style.rowBody">
<N8nText size="small" color="text-dark" :bold="true" :class="$style.name">
<N8nText size="small" color="text-dark" :class="$style.name">
{{ agent.name }}
</N8nText>
</div>
</N8nCard>
<div :class="$style.actions">
<N8nButton
variant="subtle"
size="small"
data-testid="agent-sub-agents-modal-add"
@click="onSelectAgent(agent)"
>
{{ i18n.baseText('agents.builder.subAgents.modal.add') }}
</N8nButton>
</div>
</div>
</div>
</N8nScrollArea>
<N8nActionBox
v-else-if="hasAgents && !hasMatchingAgents"
:icon="{ type: 'icon', value: 'bot' }"
:heading="i18n.baseText('agents.builder.subAgents.modal.noResults.title')"
:description="i18n.baseText('agents.builder.subAgents.modal.noResults.description')"
data-testid="agent-sub-agents-modal-no-results"
/>
<N8nActionBox
v-else
:icon="{ type: 'icon', value: 'bot' }"
@@ -126,21 +184,73 @@ function onAdd() {
data-testid="agent-sub-agents-modal-empty"
/>
</div>
<div v-else :class="[$style.content, $style.configureContent]">
<div :class="$style.field">
<label :class="$style.label">
<N8nText size="small" :bold="true">
{{ i18n.baseText('agents.builder.subAgents.useWhen.label') }}
</N8nText>
</label>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.subAgents.useWhen.hint') }}
</N8nText>
<N8nMarkdownEditor
:class="$style.useWhenEditor"
:model-value="useWhen"
:placeholder="i18n.baseText('agents.builder.subAgents.useWhen.placeholder')"
max-height="100%"
data-testid="agent-sub-agents-modal-use-when"
@update:model-value="useWhen = $event"
/>
<N8nText v-if="useWhenError" size="small" color="danger">
{{ useWhenError }}
</N8nText>
<N8nText size="xsmall" color="text-light">
{{
i18n.baseText('agents.builder.subAgents.useWhen.characterCount', {
interpolate: {
count: String(useWhen.length),
max: String(SUB_AGENT_USE_WHEN_MAX_LENGTH),
},
})
}}
</N8nText>
</div>
</div>
</template>
<template #footer>
<template v-if="selectedAgent" #footer>
<div :class="$style.footer">
<N8nButton variant="subtle" @click="closeModal">
{{ i18n.baseText('agents.builder.subAgents.modal.cancel') }}
<N8nButton
v-if="isEditing && data.onRemove"
variant="subtle"
data-testid="agent-sub-agents-modal-remove"
@click="onRemove"
>
{{ i18n.baseText('agents.builder.subAgents.modal.remove') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canAdd"
data-testid="agent-sub-agents-modal-add"
@click="onAdd"
v-else
variant="subtle"
data-testid="agent-sub-agents-modal-back"
@click="onBack"
>
{{ i18n.baseText('agents.builder.subAgents.modal.add') }}
{{ i18n.baseText('generic.back') }}
</N8nButton>
<div :class="$style.footerActions">
<N8nButton variant="subtle" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
variant="solid"
:disabled="!canConfirm"
data-testid="agent-sub-agents-modal-confirm"
@click="onConfirm"
>
{{ i18n.baseText(isEditing ? 'generic.save' : 'agents.builder.subAgents.modal.add') }}
</N8nButton>
</div>
</div>
</template>
</Modal>
@@ -161,28 +271,38 @@ function onAdd() {
padding: var(--spacing--lg);
}
.configureContent {
min-height: 460px;
gap: var(--spacing--lg);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-right: var(--spacing--xs);
padding-right: var(--spacing--lg);
}
.row {
--card--prepend--width: auto;
display: flex;
align-items: center;
gap: var(--spacing--sm);
flex-shrink: 0;
cursor: pointer;
padding-block: var(--spacing--sm);
}
.selectedRow {
border-color: var(--color--primary);
.iconWrapper {
flex-shrink: 0;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.rowBody {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
width: 100%;
gap: var(--spacing--5xs);
min-width: 0;
}
@@ -191,12 +311,45 @@ function onAdd() {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--line-height--md);
max-width: 100%;
}
.itemIcon {
color: var(--text-color--subtle);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-shrink: 0;
}
.field {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
flex: 1;
min-height: 0;
}
.label {
display: block;
}
.useWhenEditor {
height: 300px;
}
.footer {
display: flex;
justify-content: flex-end;
justify-content: space-between;
gap: var(--spacing--2xs);
}
.footerActions {
display: flex;
gap: var(--spacing--2xs);
}
</style>
@@ -122,11 +122,12 @@ const availableSubAgents = computed(() =>
),
);
const selectedSubAgents = computed(() =>
selectedSubAgentIds.value.map((agentId) => {
selectedSubAgentRefs.value.map(({ agentId, useWhen }) => {
const agent = projectAgents.value?.find((candidate) => candidate.id === agentId);
return {
id: agentId,
name: agent?.name ?? agentId,
...(useWhen !== undefined ? { useWhen } : {}),
};
}),
);
@@ -281,19 +282,41 @@ async function onOpenAddSubAgentsModal() {
id,
name,
})),
onConfirm: (agentIds: string[]) => {
const newAgentRefs = agentIds
.filter((agentId) => !selectedSubAgentIdSet.value.has(agentId))
.map((agentId) => ({ agentId }));
onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => {
if (selectedSubAgentIdSet.value.has(agentId)) return;
if (newAgentRefs.length === 0) return;
emitSubAgentsAgents([...selectedSubAgentRefs.value, ...newAgentRefs]);
emitSubAgentsAgents([
...selectedSubAgentRefs.value,
{ agentId, ...(useWhen ? { useWhen } : {}) },
]);
},
},
});
}
function onOpenEditSubAgentModal(subAgent: { id: string; name: string; useWhen?: string }) {
if (props.disabled) return;
uiStore.openModalWithData({
name: AGENT_SUB_AGENTS_MODAL_KEY,
data: {
selectedAgent: {
id: subAgent.id,
name: subAgent.name,
},
...(subAgent.useWhen ? { useWhen: subAgent.useWhen } : {}),
onConfirm: ({ agentId, useWhen }: { agentId: string; useWhen?: string }) => {
emitSubAgentsAgents(
selectedSubAgentRefs.value.map((ref) =>
ref.agentId === agentId ? { agentId, ...(useWhen ? { useWhen } : {}) } : ref,
),
);
},
onRemove: onRemoveSubAgent,
},
});
}
function onRemoveSubAgent(agentId: string) {
if (props.disabled) return;
@@ -432,6 +455,7 @@ function onRemoveSubAgent(agentId: string) {
:key="subAgent.id"
:class="$style.row"
data-testid="agent-sub-agent-row"
@click="onOpenEditSubAgentModal(subAgent)"
>
<template #prepend>
<N8nIcon icon="bot" size="medium" :class="$style.itemIcon" />
@@ -462,7 +486,7 @@ function onRemoveSubAgent(agentId: string) {
})
"
data-testid="agent-sub-agent-remove"
@click="onRemoveSubAgent(subAgent.id)"
@click.stop="onRemoveSubAgent(subAgent.id)"
/>
</N8nTooltip>
</template>
@@ -606,6 +630,7 @@ function onRemoveSubAgent(agentId: string) {
.row {
--card--append--width: auto;
flex-shrink: 0;
cursor: pointer;
}
.itemIcon {
@@ -46,6 +46,7 @@ const TRACKED_CONFIG_KEYS = [
'memory',
'tools',
'skills',
'subAgents',
'name',
] as const satisfies ReadonlyArray<keyof AgentJsonConfig & AgentConfigPart>;
@@ -12,6 +12,7 @@ export type AgentConfigPart =
| 'tools'
| 'skills'
| 'triggers'
| 'subAgents'
| 'name'
| 'description';