mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Add sub-agent useWhen guidance (no-changelog) (#32945)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+55
-55
@@ -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"
|
||||
},
|
||||
|
||||
+53
-53
@@ -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 },
|
||||
|
||||
+48
-2
@@ -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}.`,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-1
@@ -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",
|
||||
|
||||
+236
-2
@@ -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()]);
|
||||
|
||||
|
||||
+256
@@ -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);
|
||||
});
|
||||
});
|
||||
+65
-23
@@ -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.' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
+1
@@ -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)"
|
||||
|
||||
+147
-3
@@ -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;
|
||||
|
||||
+215
-62
@@ -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 {
|
||||
|
||||
+1
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user