From 2206e6f429abf1fa5cff75a7db3f8feca995e477 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 10:22:39 +0000 Subject: [PATCH 01/20] Add switch_agent tool definition and registration --- src/common/utils/tools/toolDefinitions.ts | 20 +++++++++++++++++++ src/common/utils/tools/tools.ts | 2 ++ src/node/services/ptc/toolBridge.ts | 1 + src/node/services/tools/switch_agent.ts | 24 +++++++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 src/node/services/tools/switch_agent.ts diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index fae488a86b..8699a6c081 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -527,6 +527,18 @@ export const AgentReportToolArgsSchema = z }) .strict(); +// ----------------------------------------------------------------------------- +// switch_agent (agent switching for Auto agent) +// ----------------------------------------------------------------------------- + +export const SwitchAgentToolArgsSchema = z + .object({ + agentId: AgentIdSchema, + reason: z.string().max(512).nullish(), + followUp: z.string().max(2000).nullish(), + }) + .strict(); + export const AgentReportToolResultSchema = z.object({ success: z.literal(true) }).strict(); const FILE_TOOL_PATH = z .string() @@ -886,6 +898,13 @@ export const TOOL_DEFINITIONS = { "Call this exactly once when you have a final answer (after any spawned sub-tasks complete).", schema: AgentReportToolArgsSchema, }, + switch_agent: { + description: + "Switch to a different agent and restart the stream. " + + "Only UI-selectable agents can be targeted. " + + "The current stream will end and a new stream will start with the selected agent.", + schema: SwitchAgentToolArgsSchema, + }, system1_keep_ranges: { description: "Internal tool used by mux to record which line ranges to keep when filtering large bash output.", @@ -1432,6 +1451,7 @@ export function getAvailableTools( "task_terminate", "task_list", ...(enableAgentReport ? ["agent_report"] : []), + "switch_agent", "system1_keep_ranges", "todo_write", "todo_read", diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index d78fff8269..d0bcbfa5bc 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -24,6 +24,7 @@ import { createAgentSkillReadFileTool } from "@/node/services/tools/agent_skill_ import { createMuxGlobalAgentsReadTool } from "@/node/services/tools/mux_global_agents_read"; import { createMuxGlobalAgentsWriteTool } from "@/node/services/tools/mux_global_agents_write"; import { createAgentReportTool } from "@/node/services/tools/agent_report"; +import { createSwitchAgentTool } from "@/node/services/tools/switch_agent"; import { createSystem1KeepRangesTool } from "@/node/services/tools/system1_keep_ranges"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { withHooks, type HookConfig } from "@/node/services/tools/withHooks"; @@ -320,6 +321,7 @@ export async function getToolsForModel( ask_user_question: createAskUserQuestionTool(config), propose_plan: createProposePlanTool(config), ...(config.enableAgentReport ? { agent_report: createAgentReportTool(config) } : {}), + switch_agent: createSwitchAgentTool(config), system1_keep_ranges: createSystem1KeepRangesTool(config), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 7acd376aea..eb02b90056 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -18,6 +18,7 @@ const EXCLUDED_TOOLS = new Set([ "todo_read", // UI-specific "status_set", // UI-specific "agent_report", // Must be top-level for taskService to read args from history + "switch_agent", // Must be top-level for stream-end detection ]); /** diff --git a/src/node/services/tools/switch_agent.ts b/src/node/services/tools/switch_agent.ts new file mode 100644 index 0000000000..3f419057d9 --- /dev/null +++ b/src/node/services/tools/switch_agent.ts @@ -0,0 +1,24 @@ +import { tool } from "ai"; +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +export const createSwitchAgentTool: ToolFactory = (_config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.switch_agent.description, + inputSchema: TOOL_DEFINITIONS.switch_agent.schema, + execute: async (args) => { + // Validation of whether the target agent is UI-selectable happens in the + // AgentSession follow-up handler, not here. This tool is a signal tool: + // StreamManager stops the stream on success, and AgentSession enqueues + // a follow-up with the new agentId. + // + // We do basic validation here (the schema handles agentId format). + return { + ok: true, + agentId: args.agentId, + reason: args.reason ?? undefined, + followUp: args.followUp ?? undefined, + }; + }, + }); +}; From 64351540b74743c1f2c1a6ae214a94c79cdb51ee Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 10:20:47 +0000 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20auto=20built?= =?UTF-8?q?-in=20routing=20agent=20definition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Auto built-in agent markdown definition and a dedicated UI color token. --- src/browser/styles/globals.css | 2 ++ src/node/builtinAgents/auto.md | 35 +++++++++++++++++++ .../builtInAgentContent.generated.ts | 1 + 3 files changed, 38 insertions(+) create mode 100644 src/node/builtinAgents/auto.md diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 3c10f99627..20b9b4775d 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -65,6 +65,8 @@ --color-ask-mode-light: hsl(246 100% 85%); --color-ask-mode-alpha: hsla(246 100% 68% / 0.1); + --color-auto-mode: hsl(168 72% 43%); + --color-exec-mode: hsl(268.56 94.04% 55.19%); --color-exec-mode-hover: hsl(268.56 94.04% 67%); --color-exec-mode-light: hsl(268.56 94.04% 78%); diff --git a/src/node/builtinAgents/auto.md b/src/node/builtinAgents/auto.md new file mode 100644 index 0000000000..be710b0eaa --- /dev/null +++ b/src/node/builtinAgents/auto.md @@ -0,0 +1,35 @@ +--- +name: Auto +description: Automatically selects the best agent for your task +base: exec +ui: + color: var(--color-auto-mode) +subagent: + runnable: false +tools: + add: + # Allow all tools by default (inherit from exec) + - .* + remove: + # Auto doesn't use planning tools directly — it switches to plan agent + - propose_plan + - ask_user_question + # Internal-only tools + - system1_keep_ranges +--- + +You are **Auto**, a routing agent. + +- Analyze the user's request and pick the best agent to handle it. +- Immediately call `switch_agent` with the chosen `agentId`. +- Include an optional follow-up message when it helps hand off context. +- Do not do the work yourself; your sole job is routing. + +Use these defaults: + +- Implementation tasks → `exec` +- Planning/design tasks → `plan` +- Investigation/read-only repo questions → `explore` +- Conversational Q&A/explanations → `ask` + +Available targets include `plan`, `exec`, `explore`, `ask`, and other configured agents. diff --git a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts index 6d7e5430bf..ec3d336d98 100644 --- a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts +++ b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts @@ -4,6 +4,7 @@ export const BUILTIN_AGENT_CONTENT = { "ask": "---\nname: Ask\ndescription: Delegate questions to Explore sub-agents and synthesize an answer.\nbase: exec\nui:\n color: var(--color-ask-mode)\nsubagent:\n runnable: false\ntools:\n # Inherits all tools from exec, then removes editing tools\n remove:\n # Read-only: no file modifications\n - file_edit_.*\n---\n\nYou are **Ask**.\n\nYour job is to answer the user's question by delegating research to sub-agents (typically **Explore**), then synthesizing a concise, actionable response.\n\n## When to delegate\n\n- Delegate when the question requires repository exploration, multiple viewpoints, or verification.\n- If the answer is obvious and does not require looking anything up, answer directly.\n\n## Delegation workflow\n\n1. Break the question into **1–3** focused research threads.\n2. Spawn Explore sub-agents in parallel using the `task` tool:\n - `agentId: \"explore\"` (or `subagent_type: \"explore\"`)\n - Use clear titles like `\"Ask: find callsites\"`, `\"Ask: summarize behavior\"`, etc.\n - Ask for concrete outputs: file paths, symbols, commands to reproduce, and short excerpts.\n3. Wait for results (use `task_await` if you launched tasks in the background).\n4. Synthesize:\n - Provide the final answer first.\n - Then include supporting details (paths, commands, edge cases).\n - Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n\n## Safety rules\n\n- Do **not** modify repository files.\n- Prefer `agentId: \"explore\"`. Only use `\"exec\"` if the user explicitly asks to implement changes.\n", + "auto": "---\nname: Auto\ndescription: Automatically selects the best agent for your task\nbase: exec\nui:\n color: var(--color-auto-mode)\nsubagent:\n runnable: false\ntools:\n add:\n # Allow all tools by default (inherit from exec)\n - .*\n remove:\n # Auto doesn't use planning tools directly — it switches to plan agent\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are **Auto**, a routing agent.\n\n- Analyze the user's request and pick the best agent to handle it.\n- Immediately call `switch_agent` with the chosen `agentId`.\n- Include an optional follow-up message when it helps hand off context.\n- Do not do the work yourself; your sole job is routing.\n\nUse these defaults:\n\n- Implementation tasks → `exec`\n- Planning/design tasks → `plan`\n- Investigation/read-only repo questions → `explore`\n- Conversational Q&A/explanations → `ask`\n\nAvailable targets include `plan`, `exec`, `explore`, `ask`, and other configured agents.\n", "compact": "---\nname: Compact\ndescription: History compaction (internal)\nui:\n hidden: true\nsubagent:\n runnable: false\n---\n\nYou are running a compaction/summarization pass. Your task is to write a concise summary of the conversation so far.\n\nIMPORTANT:\n\n- You have NO tools available. Do not attempt to call any tools or output JSON.\n- Simply write the summary as plain text prose.\n- Follow the user's instructions for what to include in the summary.\n", "exec": "---\nname: Exec\ndescription: Implement changes in the repository\nui:\n color: var(--color-exec-mode)\nsubagent:\n runnable: true\n append_prompt: |\n You are running as a sub-agent in a child workspace.\n\n - Take a single narrowly scoped task and complete it end-to-end. Do not expand scope.\n - Preserve your context window: treat `explore` tasks as a context-saving repo scout for discovery (file locations, callsites, tests, config points, high-level flows).\n If you need repo context, spawn 1–N `explore` tasks (read-only) to scan the codebase and return paths + symbols + minimal excerpts.\n Then open/read only the returned files; avoid broad manual file-reading, and write a short internal \"mini-plan\" before editing.\n If the task brief already includes clear starting points + acceptance criteria, skip the initial explore pass and only explore when blocked.\n Prefer 1–3 narrow `explore` tasks (possibly in parallel).\n - If the task brief is missing critical information (scope, acceptance, or starting points) and you cannot infer it safely after a quick `explore`, do not guess.\n Stop and call `agent_report` once with 1–3 concrete questions/unknowns for the parent agent, and do not create commits.\n - Run targeted verification and create one or more git commits.\n - **Before your stream ends, you MUST call `agent_report` exactly once with:**\n - What changed (paths / key details)\n - What you ran (tests, typecheck, lint)\n - Any follow-ups / risks\n (If you forget, the parent will inject a follow-up message and you'll waste tokens.)\n - You may call task/task_await/task_list/task_terminate to delegate further when available.\n Delegation is limited by Max Task Nesting Depth (Settings → Agents → Task Settings).\n - Do not call propose_plan.\ntools:\n add:\n # Allow all tools by default (includes MCP tools which have dynamic names)\n # Use tools.remove in child agents to restrict specific tools\n - .*\n remove:\n # Exec mode doesn't use planning tools\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are in Exec mode.\n\n- If a `` block was provided (plan → exec handoff) and the user accepted it, treat it as the source of truth and implement it directly.\n Only do extra exploration if the plan is missing critical repo facts or you hit contradictions.\n- Use `explore` sub-agents just-in-time for missing repo context (paths/symbols/tests); don't spawn them by default.\n- Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n- For correctness claims, an Explore sub-agent report counts as having read the referenced files.\n- Make minimal, correct, reviewable changes that match existing codebase patterns.\n- Prefer targeted commands and checks (typecheck/tests) when feasible.\n- Treat as a standing order: keep running checks and addressing failures until they pass or a blocker outside your control arises.\n", "explore": "---\nname: Explore\ndescription: Read-only exploration of repository, environment, web, etc. Useful for investigation before making changes.\nbase: exec\nui:\n hidden: true\nsubagent:\n runnable: true\n skip_init_hook: true\n append_prompt: |\n You are an Explore sub-agent running inside a child workspace.\n\n - Explore the repository to answer the prompt using read-only investigation.\n - Return concise, actionable findings (paths, symbols, callsites, and facts).\n - When you have a final answer, call agent_report exactly once.\n - Do not call agent_report until you have completed the assigned task.\ntools:\n # Remove editing and task tools from exec base (read-only agent)\n remove:\n - file_edit_.*\n - task\n - task_apply_git_patch\n - task_.*\n - agent_skill_read\n - agent_skill_read_file\n---\n\nYou are in Explore mode (read-only).\n\n=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n\n- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.\n- You MUST NOT stage/commit or otherwise modify git state.\n- You MUST NOT use redirect operators (>, >>) or heredocs to write to files.\n - Pipes are allowed for processing, but MUST NOT be used to write to files (for example via `tee`).\n- You MUST NOT run commands that are explicitly about modifying the filesystem or repo state (rm, mv, cp, mkdir, touch, git add/commit, installs, etc.).\n- You MAY run verification commands (fmt-check/lint/typecheck/test) even if they create build artifacts/caches, but they MUST NOT modify tracked files.\n - After running verification, check `git status --porcelain` and report if it is non-empty.\n- Prefer `file_read` for reading file contents (supports offset/limit paging).\n- Use bash for read-only operations (rg, ls, git diff/show/log, etc.) and verification commands.\n", From 3d824b0a755cc3630d3bbebae05f0979da99a572 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 10:28:47 +0000 Subject: [PATCH 03/20] feat: gate switch_agent policy with auto-session metadata flag --- src/common/orpc/schemas/project.ts | 4 + src/common/orpc/schemas/workspace.ts | 4 + src/node/config.ts | 10 ++- .../resolveToolPolicy.test.ts | 90 ++++++++++++++++++- .../agentDefinitions/resolveToolPolicy.ts | 17 +++- src/node/services/agentResolution.ts | 4 + src/node/services/agentSession.ts | 43 +++++++++ src/node/services/aiService.ts | 1 + 8 files changed, 167 insertions(+), 6 deletions(-) diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index f6fe8fee12..baf74e57a6 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -62,6 +62,10 @@ export const WorkspaceConfigSchema = z.object({ description: 'If set, selects an agent definition for this workspace (e.g., "explore" or "exec").', }), + agentSwitchingEnabled: z.boolean().optional().meta({ + description: + "When true, switch_agent tool is enabled for this workspace (set when session starts from Auto agent).", + }), taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({ description: "Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).", diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index b61c11f4df..a025e967f8 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -75,6 +75,10 @@ export const WorkspaceMetadataSchema = z.object({ description: "ISO 8601 timestamp when workspace was last unarchived. Used for recency calculation to bump restored workspaces to top.", }), + agentSwitchingEnabled: z.boolean().optional().meta({ + description: + "When true, switch_agent tool is enabled for this workspace (set when session starts from Auto agent).", + }), sectionId: z.string().optional().meta({ description: "ID of the section this workspace belongs to (optional, unsectioned if absent)", }), diff --git a/src/node/config.ts b/src/node/config.ts index 1a4e99e934..97d657b55e 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -712,6 +712,7 @@ export class Config { : undefined), parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + agentSwitchingEnabled: workspace.agentSwitchingEnabled, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -798,6 +799,7 @@ export class Config { // Preserve tree/task metadata when present in config (metadata.json won't have it) metadata.parentWorkspaceId ??= workspace.parentWorkspaceId; metadata.agentType ??= workspace.agentType; + metadata.agentSwitchingEnabled ??= workspace.agentSwitchingEnabled; metadata.taskStatus ??= workspace.taskStatus; metadata.reportedAt ??= workspace.reportedAt; metadata.taskModelString ??= workspace.taskModelString; @@ -848,6 +850,7 @@ export class Config { : undefined), parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + agentSwitchingEnabled: workspace.agentSwitchingEnabled, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -892,6 +895,7 @@ export class Config { : undefined), parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + agentSwitchingEnabled: workspace.agentSwitchingEnabled, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -952,6 +956,7 @@ export class Config { parentWorkspaceId: metadata.parentWorkspaceId, agentType: metadata.agentType, agentId: metadata.agentId, + agentSwitchingEnabled: metadata.agentSwitchingEnabled, taskStatus: metadata.taskStatus, reportedAt: metadata.reportedAt, taskModelString: metadata.taskModelString, @@ -1007,7 +1012,7 @@ export class Config { */ async updateWorkspaceMetadata( workspaceId: string, - updates: Partial> + updates: Partial> ): Promise { await this.editConfig((config) => { for (const [_projectPath, projectConfig] of config.projects) { @@ -1015,6 +1020,9 @@ export class Config { if (workspace) { if (updates.name !== undefined) workspace.name = updates.name; if (updates.runtimeConfig !== undefined) workspace.runtimeConfig = updates.runtimeConfig; + if (updates.agentSwitchingEnabled !== undefined) { + workspace.agentSwitchingEnabled = updates.agentSwitchingEnabled; + } return config; } } diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 215f62b267..93f131b275 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -11,9 +11,29 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); - expect(policy).toEqual([{ regex_match: ".*", action: "disable" }]); + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, + ]); + }); + + test("switch_agent is disabled by default when auto switch is off", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + const policy = resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, + }); + + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + ]); }); test("tools.add enables specified patterns", () => { @@ -22,12 +42,14 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash.*", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -37,12 +59,51 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + ]); + }); + + test("enableAgentSwitchTool enables switch_agent for top-level workspaces", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + const policy = resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: true, + }); + + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "switch_agent", action: "enable" }, + ]); + }); + + test("subagents still hard-deny switch_agent even when auto switch is enabled", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + const policy = resolveToolPolicyForAgent({ + agents, + isSubagent: true, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: true, + }); + + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "propose_plan", action: "disable" }, + { regex_match: "agent_report", action: "enable" }, ]); }); @@ -52,13 +113,16 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: true, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "task", action: "enable" }, { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "propose_plan", action: "disable" }, { regex_match: "agent_report", action: "enable" }, ]); @@ -72,6 +136,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: true, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ @@ -79,7 +144,9 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "propose_plan", action: "enable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "agent_report", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, { regex_match: "agent_report", action: "disable" }, ]); @@ -91,6 +158,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: true, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ @@ -99,6 +167,7 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "file_read", action: "enable" }, { regex_match: "task", action: "disable" }, { regex_match: "task_.*", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -108,6 +177,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: true, disableTaskToolsForDepth: true, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ @@ -116,7 +186,9 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "file_read", action: "enable" }, { regex_match: "task", action: "disable" }, { regex_match: "task_.*", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, { regex_match: "propose_plan", action: "disable" }, { regex_match: "agent_report", action: "enable" }, ]); @@ -128,9 +200,13 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); - expect(policy).toEqual([{ regex_match: ".*", action: "disable" }]); + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, + ]); }); test("whitespace in tool patterns is trimmed", () => { @@ -139,12 +215,14 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -156,6 +234,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ @@ -164,6 +243,7 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "bash", action: "enable" }, { regex_match: "task", action: "enable" }, { regex_match: "task", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -177,6 +257,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); // exec: deny-all → enable .* → disable propose_plan @@ -186,6 +267,7 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "enable" }, { regex_match: "propose_plan", action: "disable" }, { regex_match: "file_edit_.*", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -200,6 +282,7 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); // base: deny-all → enable file_read → enable bash @@ -212,6 +295,7 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "task", action: "enable" }, { regex_match: "bash", action: "disable" }, { regex_match: "task", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); @@ -225,12 +309,14 @@ describe("resolveToolPolicyForAgent", () => { agents, isSubagent: false, disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, }); expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, ]); }); }); diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.ts b/src/node/services/agentDefinitions/resolveToolPolicy.ts index 097a3c375d..09900c1601 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts @@ -21,11 +21,15 @@ export interface ResolveToolPolicyOptions { agents: readonly AgentLikeForPolicy[]; isSubagent: boolean; disableTaskToolsForDepth: boolean; + enableAgentSwitchTool: boolean; } // Runtime restrictions that cannot be overridden by agent definitions. -// Ask-for-input tools are never allowed in autonomous sub-agent flows. -const SUBAGENT_HARD_DENY: ToolPolicy = [{ regex_match: "ask_user_question", action: "disable" }]; +// Ask-for-input and agent-switch tools are never allowed in autonomous sub-agent flows. +const SUBAGENT_HARD_DENY: ToolPolicy = [ + { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, +]; const DEPTH_HARD_DENY: ToolPolicy = [ { regex_match: "task", action: "disable" }, @@ -51,7 +55,7 @@ const DEPTH_HARD_DENY: ToolPolicy = [ * - non-plan subagents: disable `propose_plan`, enable `agent_report` */ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): ToolPolicy { - const { agents, isSubagent, disableTaskToolsForDepth } = options; + const { agents, isSubagent, disableTaskToolsForDepth, enableAgentSwitchTool } = options; // Start with deny-all baseline const agentPolicy: ToolPolicy = [{ regex_match: ".*", action: "disable" }]; @@ -87,6 +91,13 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To runtimePolicy.push(...DEPTH_HARD_DENY); } + // switch_agent is disabled by default and only enabled for Auto-started sessions. + // This must come before subagent hard-deny so subagents always resolve to disabled. + runtimePolicy.push({ regex_match: "switch_agent", action: "disable" }); + if (enableAgentSwitchTool && !isSubagent) { + runtimePolicy.push({ regex_match: "switch_agent", action: "enable" }); + } + if (isSubagent) { runtimePolicy.push(...SUBAGENT_HARD_DENY); diff --git a/src/node/services/agentResolution.ts b/src/node/services/agentResolution.ts index 466fd55d44..59d18c0111 100644 --- a/src/node/services/agentResolution.ts +++ b/src/node/services/agentResolution.ts @@ -51,6 +51,8 @@ export interface ResolveAgentOptions { requestedAgentId: string | undefined; /** When true, skip workspace-specific agents (for "unbricking" broken agent files). */ disableWorkspaceAgents: boolean; + /** Enable switch_agent tool for sessions that were started from the Auto agent. */ + enableAgentSwitchTool: boolean; modelString: string; /** Caller-supplied tool policy (applied AFTER agent policy for further restriction). */ callerToolPolicy: ToolPolicy | undefined; @@ -102,6 +104,7 @@ export async function resolveAgentForStream( workspacePath, requestedAgentId: rawAgentId, disableWorkspaceAgents, + enableAgentSwitchTool, modelString, callerToolPolicy, cfg, @@ -224,6 +227,7 @@ export async function resolveAgentForStream( agents: agentsForInheritance, isSubagent: isSubagentWorkspace, disableTaskToolsForDepth: shouldDisableTaskToolsForDepth, + enableAgentSwitchTool, }); // The Chat with Mux system workspace must remain sandboxed regardless of caller-supplied diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ec3f2d283f..3b62eaac0a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1197,6 +1197,47 @@ export class AgentSession { return Ok(undefined); } + private async maybeEnableAgentSwitchingForAutoAgent(agentId: string | undefined): Promise { + assert( + typeof agentId === "string" || agentId === undefined, + "maybeEnableAgentSwitchingForAutoAgent agentId must be string|undefined" + ); + + const normalizedAgentId = agentId?.trim().toLowerCase(); + if (normalizedAgentId !== "auto") { + return; + } + + // Guard for test mocks that may not implement getWorkspaceMetadata. + if (typeof this.aiService.getWorkspaceMetadata !== "function") { + return; + } + + try { + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) { + log.warn("Failed to read workspace metadata while enabling agent switching", { + workspaceId: this.workspaceId, + error: metadataResult.error, + }); + return; + } + + if (metadataResult.data.agentSwitchingEnabled === true) { + return; + } + + await this.config.updateWorkspaceMetadata(this.workspaceId, { + agentSwitchingEnabled: true, + }); + } catch (error) { + log.warn("Failed to persist agent switching metadata flag", { + workspaceId: this.workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async streamWithHistory( modelString: string, options?: SendMessageOptions, @@ -1207,6 +1248,8 @@ export class AgentSession { return Ok(undefined); } + await this.maybeEnableAgentSwitchingForAutoAgent(options?.agentId); + // Reset per-stream flags (used for retries / crash-safe bookkeeping). this.ackPendingPostCompactionStateOnStreamEnd = false; this.activeStreamHadAnyDelta = false; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index a3be59b5b9..b0570be36a 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -539,6 +539,7 @@ export class AIService extends EventEmitter { workspacePath, requestedAgentId: agentId, disableWorkspaceAgents: disableWorkspaceAgents ?? false, + enableAgentSwitchTool: metadata.agentSwitchingEnabled === true, modelString, callerToolPolicy: toolPolicy, cfg, From 4a95d6245bfdabf9253a62564f9a2b5b92e357e1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 10:41:12 +0000 Subject: [PATCH 04/20] Handle switch_agent stop and follow-up restart flow --- src/node/services/agentSession.ts | 136 +++++++++++++++++++++++- src/node/services/streamManager.test.ts | 68 ++++++++++-- src/node/services/streamManager.ts | 24 ++++- 3 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 3b62eaac0a..05bf0205e2 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -105,6 +105,14 @@ interface CompactionRequestMetadata { }; } +interface SwitchAgentResult { + agentId: string; + reason?: string; + followUp?: string; +} + +const MAX_CONSECUTIVE_AGENT_SWITCHES = 3; + const PDF_MEDIA_TYPE = "application/pdf"; function normalizeMediaType(mediaType: string): string { @@ -186,6 +194,9 @@ export class AgentSession { private turnPhase: TurnPhase = TurnPhase.IDLE; // When true, stream-end skips auto-flushing queued messages so an edit can truncate first. private deferQueuedFlushUntilAfterEdit = false; + /** Guardrail against synthetic switch_agent ping-pong loops. */ + private consecutiveAgentSwitches = 0; + private idleWaiters: Array<() => void> = []; private readonly messageQueue = new MessageQueue(); private readonly compactionHandler: CompactionHandler; @@ -771,6 +782,11 @@ export class AgentSession { this.assertNotDisposed("sendMessage"); assert(typeof message === "string", "sendMessage requires a string message"); + // Real user sends break any synthetic switch chain. + if (!internal?.synthetic) { + this.consecutiveAgentSwitches = 0; + } + const trimmedMessage = message.trim(); const fileParts = options?.fileParts; const editMessageId = options?.editMessageId; @@ -2075,10 +2091,13 @@ export class AgentSession { forward("stream-end", async (payload) => { this.setTurnPhase(TurnPhase.COMPLETING); + const streamEndPayload = payload as StreamEndEvent; + const activeStreamOptions = this.activeStreamContext?.options; + let emittedStreamEnd = false; try { this.activeCompactionRequest = undefined; - const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent); + const handled = await this.compactionHandler.handleCompletion(streamEndPayload); if (!handled) { this.emitChatEvent(payload); @@ -2112,6 +2131,18 @@ export class AgentSession { await this.dispatchPendingFollowUp(); } + const switchResult = this.extractSwitchAgentResult(streamEndPayload); + if (switchResult) { + const dispatchedSwitchFollowUp = await this.dispatchAgentSwitch( + switchResult, + activeStreamOptions, + streamEndPayload.metadata.model + ); + if (dispatchedSwitchFollowUp) { + return; + } + } + // Stream end: auto-send queued messages (for user messages typed during streaming) // P2: if an edit is waiting, skip the queue flush so the edit truncates first. if (this.deferQueuedFlushUntilAfterEdit) { @@ -2136,7 +2167,8 @@ export class AgentSession { } } finally { // Only clean up if we're still in COMPLETING — a new turn started by - // dispatchPendingFollowUp() or sendQueuedMessages() owns the stream state now. + // dispatchPendingFollowUp(), dispatchAgentSwitch(), or sendQueuedMessages() + // owns the stream state now. if (this.turnPhase === TurnPhase.COMPLETING) { this.resetActiveStreamState(); this.setTurnPhase(TurnPhase.IDLE); @@ -2329,6 +2361,106 @@ export class AgentSession { } } + /** Extract a successful switch_agent tool result from stream-end parts (latest wins). */ + private extractSwitchAgentResult(payload: StreamEndEvent): SwitchAgentResult | undefined { + for (let index = payload.parts.length - 1; index >= 0; index -= 1) { + const part = payload.parts[index]; + if (part.type !== "dynamic-tool") { + continue; + } + if (part.state !== "output-available" || part.toolName !== "switch_agent") { + continue; + } + + const parsedOutput = this.parseSwitchAgentOutput(part.output); + if (parsedOutput) { + return parsedOutput; + } + } + + return undefined; + } + + private parseSwitchAgentOutput(output: unknown): SwitchAgentResult | undefined { + if (typeof output !== "object" || output === null) { + return undefined; + } + + const candidate = output as Record; + if (candidate.ok !== true) { + return undefined; + } + if (typeof candidate.agentId !== "string") { + return undefined; + } + + const agentId = candidate.agentId.trim(); + if (agentId.length === 0) { + return undefined; + } + + return { + agentId, + reason: typeof candidate.reason === "string" ? candidate.reason : undefined, + followUp: typeof candidate.followUp === "string" ? candidate.followUp : undefined, + }; + } + + /** Dispatch follow-up message after switch_agent and guard against ping-pong loops. */ + private async dispatchAgentSwitch( + switchResult: SwitchAgentResult, + currentOptions: SendMessageOptions | undefined, + fallbackModel: string + ): Promise { + assert( + typeof switchResult.agentId === "string" && switchResult.agentId.trim().length > 0, + "dispatchAgentSwitch requires a non-empty switchResult.agentId" + ); + assert( + typeof fallbackModel === "string" && fallbackModel.trim().length > 0, + "dispatchAgentSwitch requires a non-empty fallbackModel" + ); + + this.consecutiveAgentSwitches += 1; + if (this.consecutiveAgentSwitches > MAX_CONSECUTIVE_AGENT_SWITCHES) { + log.warn("switch_agent loop guard triggered; skipping synthetic follow-up", { + workspaceId: this.workspaceId, + count: this.consecutiveAgentSwitches, + limit: MAX_CONSECUTIVE_AGENT_SWITCHES, + targetAgentId: switchResult.agentId, + }); + return false; + } + + const followUpText = switchResult.followUp?.trim() || "Continue."; + const normalizedOptionModel = currentOptions?.model?.trim(); + const effectiveModel = + normalizedOptionModel && normalizedOptionModel.length > 0 + ? normalizedOptionModel + : fallbackModel.trim(); + + const followUpOptions: SendMessageOptions = { + ...(currentOptions ?? {}), + model: effectiveModel, + agentId: switchResult.agentId, + }; + + const sendResult = await this.sendMessage(followUpText, followUpOptions, { + synthetic: true, + }); + + if (!sendResult.success) { + log.warn("Failed to dispatch switch_agent follow-up", { + workspaceId: this.workspaceId, + targetAgentId: switchResult.agentId, + error: sendResult.error, + }); + return false; + } + + return true; + } + /** * Dispatch the pending follow-up from a compaction summary message. * Called after compaction completes - the follow-up is stored on the summary diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index e8236ec231..21bcaf0701 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -108,9 +108,10 @@ describe("StreamManager - stopWhen configuration", () => { if (!Array.isArray(stopWhen)) { throw new Error("Expected autonomous stopWhen to be an array of conditions"); } - expect(stopWhen).toHaveLength(3); + expect(stopWhen).toHaveLength(4); - const [maxStepCondition, queuedMessageCondition, agentReportCondition] = stopWhen; + const [maxStepCondition, queuedMessageCondition, agentReportCondition, switchAgentCondition] = + stopWhen; expect(maxStepCondition({ steps: new Array(99999) })).toBe(false); expect(maxStepCondition({ steps: new Array(100000) })).toBe(true); @@ -123,6 +124,12 @@ describe("StreamManager - stopWhen configuration", () => { steps: [{ toolResults: [{ toolName: "agent_report", output: { success: true } }] }], }) ).toBe(true); + + expect( + switchAgentCondition({ + steps: [{ toolResults: [{ toolName: "switch_agent", output: { ok: true } }] }], + }) + ).toBe(true); }); test("stops only after successful agent_report tool result in autonomous mode", () => { @@ -174,6 +181,55 @@ describe("StreamManager - stopWhen configuration", () => { expect(reportStop({ steps: [] })).toBe(false); }); + test("stops only after successful switch_agent tool result in autonomous mode", () => { + const streamManager = new StreamManager(historyService); + const buildStopWhen = Reflect.get(streamManager, "createStopWhenCondition") as + | BuildStopWhenCondition + | undefined; + expect(typeof buildStopWhen).toBe("function"); + + const stopWhen = buildStopWhen!({ hasQueuedMessage: () => false }); + if (!Array.isArray(stopWhen)) { + throw new Error("Expected autonomous stopWhen to be an array of conditions"); + } + + const [, , , switchStop] = stopWhen; + if (!switchStop) { + throw new Error("Expected autonomous stopWhen to include switch_agent condition"); + } + + // Returns true when step contains successful switch_agent tool result. + expect( + switchStop({ + steps: [{ toolResults: [{ toolName: "switch_agent", output: { ok: true } }] }], + }) + ).toBe(true); + + // Returns false when step contains failed switch_agent output. + expect( + switchStop({ + steps: [{ toolResults: [{ toolName: "switch_agent", output: { ok: false } }] }], + }) + ).toBe(false); + + // Returns false when step only contains switch_agent tool call (no successful result yet). + expect( + switchStop({ + steps: [{ toolCalls: [{ toolName: "switch_agent" }] }], + }) + ).toBe(false); + + // Returns false when step contains other tool results. + expect( + switchStop({ + steps: [{ toolResults: [{ toolName: "bash", output: { ok: true } }] }], + }) + ).toBe(false); + + // Returns false when no steps. + expect(switchStop({ steps: [] })).toBe(false); + }); + test("stops when propose_plan succeeds and flag is enabled", () => { const streamManager = new StreamManager(historyService); const buildStopWhen = Reflect.get(streamManager, "createStopWhenCondition") as @@ -188,7 +244,7 @@ describe("StreamManager - stopWhen configuration", () => { if (!Array.isArray(stopWhen)) { throw new Error("Expected autonomous stopWhen to be an array of conditions"); } - expect(stopWhen).toHaveLength(4); + expect(stopWhen).toHaveLength(5); const proposePlanSuccessSteps = [ { @@ -201,7 +257,7 @@ describe("StreamManager - stopWhen configuration", () => { }, ]; - const proposePlanCondition = stopWhen[3]; + const proposePlanCondition = stopWhen[4]; if (!proposePlanCondition) { throw new Error("Expected stopWhen to include propose_plan condition"); } @@ -236,7 +292,7 @@ describe("StreamManager - stopWhen configuration", () => { }, ]; - const proposePlanCondition = stopWhen[3]; + const proposePlanCondition = stopWhen[4]; if (!proposePlanCondition) { throw new Error("Expected stopWhen to include propose_plan condition"); } @@ -272,7 +328,7 @@ describe("StreamManager - stopWhen configuration", () => { if (!Array.isArray(stopWhen)) { throw new Error("Expected autonomous stopWhen to be an array of conditions"); } - expect(stopWhen).toHaveLength(3); + expect(stopWhen).toHaveLength(4); expect(stopWhen.some((condition) => condition({ steps: proposePlanSuccessSteps }))).toBe( false ); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 2bd849019b..463d994d28 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1066,8 +1066,8 @@ export class StreamManager extends EventEmitter { } // For autonomous loops: cap steps, check for queued messages, and stop after - // a successful agent_report result (`output.success === true`) so the stream ends - // naturally (preserving usage accounting) without allowing post-report tool execution. + // successful agent control tools so streams end naturally (preserving usage accounting) + // without executing unrelated tools after handoff/report completion. const isSuccessfulAgentReportOutput = (value: unknown): boolean => { return ( typeof value === "object" && @@ -1077,6 +1077,15 @@ export class StreamManager extends EventEmitter { ); }; + const isOkSwitchAgentOutput = (value: unknown): boolean => { + return ( + typeof value === "object" && + value !== null && + "ok" in value && + (value as { ok?: unknown }).ok === true + ); + }; + const hasSuccessfulAgentReportResult: ReturnType = ({ steps }) => { const lastStep = steps[steps.length - 1]; return ( @@ -1088,6 +1097,16 @@ export class StreamManager extends EventEmitter { ); }; + const hasSuccessfulSwitchAgentResult: ReturnType = ({ steps }) => { + const lastStep = steps[steps.length - 1]; + return ( + lastStep?.toolResults?.some( + (toolResult) => + toolResult.toolName === "switch_agent" && isOkSwitchAgentOutput(toolResult.output) + ) ?? false + ); + }; + const hasSuccessfulProposePlanResult: ReturnType = ({ steps }) => { const lastStep = steps[steps.length - 1]; return ( @@ -1105,6 +1124,7 @@ export class StreamManager extends EventEmitter { stepCountIs(100000), () => request.hasQueuedMessage?.() ?? false, hasSuccessfulAgentReportResult, + hasSuccessfulSwitchAgentResult, ]; if (request.stopAfterSuccessfulProposePlan) { From 7bcf758d92cc979f7b9506a47675e84125aba7d2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 10:58:49 +0000 Subject: [PATCH 05/20] Validate switch_agent targets and add flow tests --- .../resolveToolPolicy.test.ts | 15 ++ .../services/agentSession.switchAgent.test.ts | 243 ++++++++++++++++++ src/node/services/agentSession.ts | 103 +++++++- src/node/services/tools/switch_agent.test.ts | 60 +++++ 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/node/services/agentSession.switchAgent.test.ts create mode 100644 src/node/services/tools/switch_agent.test.ts diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 93f131b275..2ee39ea335 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -36,6 +36,21 @@ describe("resolveToolPolicyForAgent", () => { ]); }); + test("switch_agent is disabled by default when auto switch is off", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + const policy = resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, + }); + + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + ]); + }); + test("tools.add enables specified patterns", () => { const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read", "bash.*"] } }]; const policy = resolveToolPolicyForAgent({ diff --git a/src/node/services/agentSession.switchAgent.test.ts b/src/node/services/agentSession.switchAgent.test.ts new file mode 100644 index 0000000000..32e648741e --- /dev/null +++ b/src/node/services/agentSession.switchAgent.test.ts @@ -0,0 +1,243 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "events"; +import * as fs from "fs/promises"; +import * as path from "path"; + +import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import type { SendMessageOptions } from "@/common/orpc/types"; +import type { Config } from "@/node/config"; + +import type { AIService } from "./aiService"; +import { AgentSession } from "./agentSession"; +import type { BackgroundProcessManager } from "./backgroundProcessManager"; +import type { HistoryService } from "./historyService"; +import type { InitStateManager } from "./initStateManager"; +import { createTestHistoryService } from "./testHistoryService"; +import { DisposableTempDir } from "./tempDir"; + +interface SessionInternals { + dispatchAgentSwitch: ( + switchResult: { agentId: string; reason?: string; followUp?: string }, + currentOptions: SendMessageOptions | undefined, + fallbackModel: string + ) => Promise; + sendMessage: ( + message: string, + options?: SendMessageOptions, + internal?: { synthetic?: boolean } + ) => Promise<{ success: boolean }>; +} + +function createAiService(projectPath: string): AIService { + const emitter = new EventEmitter(); + + return { + on(eventName: string | symbol, listener: (...args: unknown[]) => void) { + emitter.on(String(eventName), listener); + return this; + }, + off(eventName: string | symbol, listener: (...args: unknown[]) => void) { + emitter.off(String(eventName), listener); + return this; + }, + getWorkspaceMetadata: mock(() => + Promise.resolve({ + success: true as const, + data: { + id: "workspace-switch", + name: "workspace-switch-name", + projectName: "workspace-switch-project", + projectPath, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + }) + ), + stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), + } as unknown as AIService; +} + +function createSession( + historyService: HistoryService, + sessionDir: string, + projectPath: string +): AgentSession { + const initStateManager: InitStateManager = { + on() { + return this; + }, + off() { + return this; + }, + } as unknown as InitStateManager; + + const backgroundProcessManager: BackgroundProcessManager = { + setMessageQueued: mock(() => undefined), + cleanup: mock(() => Promise.resolve()), + } as unknown as BackgroundProcessManager; + + const config: Config = { + srcDir: sessionDir, + getSessionDir: mock(() => sessionDir), + loadConfigOrDefault: mock(() => ({})), + } as unknown as Config; + + return new AgentSession({ + workspaceId: "workspace-switch", + config, + historyService, + aiService: createAiService(projectPath), + initStateManager, + backgroundProcessManager, + }); +} + +async function writeAgentDefinition( + projectPath: string, + agentId: string, + extraFrontmatter: string +): Promise { + const agentsDir = path.join(projectPath, ".mux", "agents"); + await fs.mkdir(agentsDir, { recursive: true }); + await fs.writeFile( + path.join(agentsDir, `${agentId}.md`), + `---\nname: ${agentId}\ndescription: ${agentId} description\n${extraFrontmatter}---\n${agentId} body\n`, + "utf-8" + ); +} + +describe("AgentSession switch_agent target validation", () => { + let historyCleanup: (() => Promise) | undefined; + + afterEach(async () => { + await historyCleanup?.(); + }); + + test("dispatches synthetic follow-up when switch target is valid", async () => { + using projectDir = new DisposableTempDir("agent-session-switch-valid"); + const { historyService, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const session = createSession(historyService, projectDir.path, projectDir.path); + + try { + const internals = session as unknown as SessionInternals; + const sendMessageMock = mock(() => Promise.resolve({ success: true as const })); + internals.sendMessage = sendMessageMock as unknown as SessionInternals["sendMessage"]; + + const result = await internals.dispatchAgentSwitch( + { + agentId: "plan", + reason: "needs planning", + followUp: "Create a plan.", + }, + { model: "openai:gpt-4o-mini", agentId: "exec" }, + "openai:gpt-4o" + ); + + expect(result).toBe(true); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + + const firstCall = sendMessageMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [messageArg, optionsArg, internalArg] = firstCall as unknown as [ + string, + SendMessageOptions, + { synthetic?: boolean }, + ]; + expect(messageArg).toBe("Create a plan."); + expect(optionsArg.agentId).toBe("plan"); + expect(optionsArg.model).toBe("openai:gpt-4o-mini"); + expect(internalArg).toEqual({ synthetic: true }); + } finally { + session.dispose(); + } + }); + + test("rejects hidden switch target and skips follow-up dispatch", async () => { + using projectDir = new DisposableTempDir("agent-session-switch-hidden"); + await writeAgentDefinition(projectDir.path, "hidden-agent", "ui:\n hidden: true\n"); + + const { historyService, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const session = createSession(historyService, projectDir.path, projectDir.path); + + try { + const internals = session as unknown as SessionInternals; + const sendMessageMock = mock(() => Promise.resolve({ success: true as const })); + internals.sendMessage = sendMessageMock as unknown as SessionInternals["sendMessage"]; + + const result = await internals.dispatchAgentSwitch( + { + agentId: "hidden-agent", + followUp: "Should not send", + }, + { model: "openai:gpt-4o-mini", agentId: "exec" }, + "openai:gpt-4o" + ); + + expect(result).toBe(false); + expect(sendMessageMock).toHaveBeenCalledTimes(0); + } finally { + session.dispose(); + } + }); + + test("rejects effectively disabled switch target and skips follow-up dispatch", async () => { + using projectDir = new DisposableTempDir("agent-session-switch-disabled"); + await writeAgentDefinition(projectDir.path, "disabled-agent", "disabled: true\n"); + + const { historyService, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const session = createSession(historyService, projectDir.path, projectDir.path); + + try { + const internals = session as unknown as SessionInternals; + const sendMessageMock = mock(() => Promise.resolve({ success: true as const })); + internals.sendMessage = sendMessageMock as unknown as SessionInternals["sendMessage"]; + + const result = await internals.dispatchAgentSwitch( + { + agentId: "disabled-agent", + followUp: "Should not send", + }, + { model: "openai:gpt-4o-mini", agentId: "exec" }, + "openai:gpt-4o" + ); + + expect(result).toBe(false); + expect(sendMessageMock).toHaveBeenCalledTimes(0); + } finally { + session.dispose(); + } + }); + + test("rejects unresolved switch target and skips follow-up dispatch", async () => { + using projectDir = new DisposableTempDir("agent-session-switch-missing"); + const { historyService, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const session = createSession(historyService, projectDir.path, projectDir.path); + + try { + const internals = session as unknown as SessionInternals; + const sendMessageMock = mock(() => Promise.resolve({ success: true as const })); + internals.sendMessage = sendMessageMock as unknown as SessionInternals["sendMessage"]; + + const result = await internals.dispatchAgentSwitch( + { + agentId: "missing-agent", + followUp: "Should not send", + }, + { model: "openai:gpt-4o-mini", agentId: "exec" }, + "openai:gpt-4o" + ); + + expect(result).toBe(false); + expect(sendMessageMock).toHaveBeenCalledTimes(0); + } finally { + session.dispose(); + } + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 05bf0205e2..69d89cbabc 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -59,7 +59,11 @@ import { import { createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; -import { readAgentDefinition } from "@/node/services/agentDefinitions/agentDefinitionsService"; +import { + readAgentDefinition, + resolveAgentFrontmatter, +} from "@/node/services/agentDefinitions/agentDefinitionsService"; +import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { MessageQueue } from "./messageQueue"; import type { StreamEndEvent } from "@/common/types/stream"; @@ -2406,6 +2410,98 @@ export class AgentSession { }; } + private async isAgentSwitchTargetValid(agentId: string): Promise { + assert( + typeof agentId === "string" && agentId.trim().length > 0, + "isAgentSwitchTargetValid requires a non-empty agentId" + ); + + const normalizedAgentId = agentId.trim(); + const parsedAgentId = AgentIdSchema.safeParse(normalizedAgentId); + if (!parsedAgentId.success) { + log.warn("switch_agent target has invalid agentId format; skipping synthetic follow-up", { + workspaceId: this.workspaceId, + targetAgentId: normalizedAgentId, + }); + return false; + } + + if (typeof this.aiService.getWorkspaceMetadata !== "function") { + log.warn("Cannot validate switch_agent target: workspace metadata API unavailable", { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + }); + return false; + } + + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) { + log.warn("Cannot validate switch_agent target: workspace metadata unavailable", { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + error: metadataResult.error, + }); + return false; + } + + const metadata = metadataResult.data; + const runtime = createRuntimeForWorkspace(metadata); + + // In-place workspaces (CLI/benchmarks) have projectPath === name. + // Use the path directly instead of reconstructing via getWorkspacePath. + const isInPlace = metadata.projectPath === metadata.name; + const workspacePath = isInPlace + ? metadata.projectPath + : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + try { + const resolvedFrontmatter = await resolveAgentFrontmatter( + runtime, + workspacePath, + parsedAgentId.data + ); + const cfg = this.config.loadConfigOrDefault(); + const effectivelyDisabled = isAgentEffectivelyDisabled({ + cfg, + agentId: parsedAgentId.data, + resolvedFrontmatter, + }); + + if (effectivelyDisabled) { + log.warn("switch_agent target is disabled; skipping synthetic follow-up", { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + }); + return false; + } + + // NOTE: hidden is opt-out. selectable is legacy opt-in. + const uiSelectableBase = + typeof resolvedFrontmatter.ui?.hidden === "boolean" + ? !resolvedFrontmatter.ui.hidden + : typeof resolvedFrontmatter.ui?.selectable === "boolean" + ? resolvedFrontmatter.ui.selectable + : true; + + if (!uiSelectableBase) { + log.warn("switch_agent target is not UI-selectable; skipping synthetic follow-up", { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + }); + return false; + } + + return true; + } catch (error) { + log.warn("switch_agent target could not be resolved; skipping synthetic follow-up", { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + /** Dispatch follow-up message after switch_agent and guard against ping-pong loops. */ private async dispatchAgentSwitch( switchResult: SwitchAgentResult, @@ -2432,6 +2528,11 @@ export class AgentSession { return false; } + const targetValid = await this.isAgentSwitchTargetValid(switchResult.agentId); + if (!targetValid) { + return false; + } + const followUpText = switchResult.followUp?.trim() || "Continue."; const normalizedOptionModel = currentOptions?.model?.trim(); const effectiveModel = diff --git a/src/node/services/tools/switch_agent.test.ts b/src/node/services/tools/switch_agent.test.ts new file mode 100644 index 0000000000..68b0765a97 --- /dev/null +++ b/src/node/services/tools/switch_agent.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import type { ToolExecutionOptions } from "ai"; + +import { createSwitchAgentTool } from "./switch_agent"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; + +const mockToolCallOptions: ToolExecutionOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("switch_agent tool", () => { + test("returns ok: true with valid agentId", async () => { + using tempDir = new TestTempDir("test-switch-agent-tool"); + const config = createTestToolConfig(tempDir.path); + const tool = createSwitchAgentTool(config); + + const result: unknown = await Promise.resolve( + tool.execute!( + { + agentId: "plan", + reason: "needs planning", + followUp: "Create a plan.", + }, + mockToolCallOptions + ) + ); + + expect(result).toEqual({ + ok: true, + agentId: "plan", + reason: "needs planning", + followUp: "Create a plan.", + }); + }); + + test("handles nullish optional fields", async () => { + using tempDir = new TestTempDir("test-switch-agent-tool-nullish"); + const config = createTestToolConfig(tempDir.path); + const tool = createSwitchAgentTool(config); + + const result: unknown = await Promise.resolve( + tool.execute!( + { + agentId: "exec", + reason: null, + followUp: null, + }, + mockToolCallOptions + ) + ); + + expect(result).toEqual({ + ok: true, + agentId: "exec", + reason: undefined, + followUp: undefined, + }); + }); +}); From e562f932fa72620d00048a2b5257483d8e60d640 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 11:03:25 +0000 Subject: [PATCH 06/20] fix: lint errors in switch_agent (async, nullish coalescing) --- src/node/services/agentSession.ts | 2 +- src/node/services/tools/switch_agent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 69d89cbabc..911fd82a56 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2533,7 +2533,7 @@ export class AgentSession { return false; } - const followUpText = switchResult.followUp?.trim() || "Continue."; + const followUpText = switchResult.followUp?.trim() ?? "Continue."; const normalizedOptionModel = currentOptions?.model?.trim(); const effectiveModel = normalizedOptionModel && normalizedOptionModel.length > 0 diff --git a/src/node/services/tools/switch_agent.ts b/src/node/services/tools/switch_agent.ts index 3f419057d9..f7cf1a5197 100644 --- a/src/node/services/tools/switch_agent.ts +++ b/src/node/services/tools/switch_agent.ts @@ -6,7 +6,7 @@ export const createSwitchAgentTool: ToolFactory = (_config: ToolConfiguration) = return tool({ description: TOOL_DEFINITIONS.switch_agent.description, inputSchema: TOOL_DEFINITIONS.switch_agent.schema, - execute: async (args) => { + execute: (args) => { // Validation of whether the target agent is UI-selectable happens in the // AgentSession follow-up handler, not here. This tool is a signal tool: // StreamManager stops the stream on success, and AgentSession enqueues From 0f309520dc127888f6ebad85bd5a13a70c51d4a5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 11:04:13 +0000 Subject: [PATCH 07/20] chore: regenerate docs for switch_agent tool and auto agent --- docs/agents/index.mdx | 46 +++++++++++++++++++++++++++++++++++++++++++ docs/hooks/tools.mdx | 11 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index 14d5b967c4..a7f9b95c67 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -287,6 +287,52 @@ Your job is to answer the user's question by delegating research to sub-agents ( +### Auto + +**Automatically selects the best agent for your task** + + + +```md +--- +name: Auto +description: Automatically selects the best agent for your task +base: exec +ui: + color: var(--color-auto-mode) +subagent: + runnable: false +tools: + add: + # Allow all tools by default (inherit from exec) + - .* + remove: + # Auto doesn't use planning tools directly — it switches to plan agent + - propose_plan + - ask_user_question + # Internal-only tools + - system1_keep_ranges +--- + +You are **Auto**, a routing agent. + +- Analyze the user's request and pick the best agent to handle it. +- Immediately call `switch_agent` with the chosen `agentId`. +- Include an optional follow-up message when it helps hand off context. +- Do not do the work yourself; your sole job is routing. + +Use these defaults: + +- Implementation tasks → `exec` +- Planning/design tasks → `plan` +- Investigation/read-only repo questions → `explore` +- Conversational Q&A/explanations → `ask` + +Available targets include `plan`, `exec`, `explore`, `ask`, and other configured agents. +``` + + + ### Exec **Implement changes in the repository** diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 52ecd9c8a4..2fe052b7d8 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -387,6 +387,17 @@ If a value is too large for the environment, it may be omitted (not set). Mux al +
+switch_agent (3) + +| Env var | JSON path | Type | Description | +| -------------------------- | ---------- | ------ | ----------- | +| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | +| `MUX_TOOL_INPUT_FOLLOW_UP` | `followUp` | string | — | +| `MUX_TOOL_INPUT_REASON` | `reason` | string | — | + +
+
system1_keep_ranges (4) From 258dcf62d66660fe93f1394a69fed373a7a75158 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 11:35:32 +0000 Subject: [PATCH 08/20] fix: enforce ui.requires gating + handle empty followUp in switch_agent --- src/node/services/agentSession.ts | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 911fd82a56..d5823d4dcc 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -58,6 +58,7 @@ import { } from "@/common/types/message"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; +import { hasNonEmptyPlanFile } from "@/node/utils/runtime/helpers"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; import { readAgentDefinition, @@ -2476,6 +2477,7 @@ export class AgentSession { } // NOTE: hidden is opt-out. selectable is legacy opt-in. + // Mirrors the same logic in agents.list (src/node/orpc/router.ts). const uiSelectableBase = typeof resolvedFrontmatter.ui?.hidden === "boolean" ? !resolvedFrontmatter.ui.hidden @@ -2491,6 +2493,34 @@ export class AgentSession { return false; } + // Check ui.requires gating (e.g., orchestrator requires a plan file). + // This matches the router's `requiresPlan && !planReady` check. + const requiresPlan = resolvedFrontmatter.ui?.requires?.includes("plan") ?? false; + if (requiresPlan) { + // Fail closed: if plan state cannot be determined, treat as not ready. + let planReady = false; + try { + planReady = await hasNonEmptyPlanFile( + runtime, + metadata.name, + metadata.projectName, + this.workspaceId + ); + } catch { + planReady = false; + } + if (!planReady) { + log.warn( + "switch_agent target requires a plan but no plan file exists; skipping synthetic follow-up", + { + workspaceId: this.workspaceId, + targetAgentId: parsedAgentId.data, + } + ); + return false; + } + } + return true; } catch (error) { log.warn("switch_agent target could not be resolved; skipping synthetic follow-up", { @@ -2533,7 +2563,10 @@ export class AgentSession { return false; } - const followUpText = switchResult.followUp?.trim() ?? "Continue."; + // Fall back to "Continue." for nullish, empty, or whitespace-only followUp strings. + const trimmedFollowUp = switchResult.followUp?.trim(); + const followUpText = + trimmedFollowUp != null && trimmedFollowUp.length > 0 ? trimmedFollowUp : "Continue."; const normalizedOptionModel = currentOptions?.model?.trim(); const effectiveModel = normalizedOptionModel && normalizedOptionModel.length > 0 From 81e48f899de02ed5b2554b2cd244353d59bfadce Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 11:43:51 +0000 Subject: [PATCH 09/20] fix: always disable switch_agent by default in runtime policy --- .../agentDefinitions/resolveToolPolicy.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 2ee39ea335..93f131b275 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -36,21 +36,6 @@ describe("resolveToolPolicyForAgent", () => { ]); }); - test("switch_agent is disabled by default when auto switch is off", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - enableAgentSwitchTool: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "file_read", action: "enable" }, - ]); - }); - test("tools.add enables specified patterns", () => { const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read", "bash.*"] } }]; const policy = resolveToolPolicyForAgent({ From 8e8e391a3c2e7005361a9d5ccb0f0800d7f242f7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 11:52:13 +0000 Subject: [PATCH 10/20] fix: remove hidden explore from Auto defaults + strip edit options from switch follow-up --- src/node/builtinAgents/auto.md | 5 ++--- .../builtInAgentContent.generated.ts | 2 +- src/node/services/agentSession.ts | 18 +++++++++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/node/builtinAgents/auto.md b/src/node/builtinAgents/auto.md index be710b0eaa..1fc17e026b 100644 --- a/src/node/builtinAgents/auto.md +++ b/src/node/builtinAgents/auto.md @@ -29,7 +29,6 @@ Use these defaults: - Implementation tasks → `exec` - Planning/design tasks → `plan` -- Investigation/read-only repo questions → `explore` -- Conversational Q&A/explanations → `ask` +- Conversational Q&A, explanations, or investigation → `ask` -Available targets include `plan`, `exec`, `explore`, `ask`, and other configured agents. +Only switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`. diff --git a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts index ec3d336d98..4bdd671959 100644 --- a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts +++ b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts @@ -4,7 +4,7 @@ export const BUILTIN_AGENT_CONTENT = { "ask": "---\nname: Ask\ndescription: Delegate questions to Explore sub-agents and synthesize an answer.\nbase: exec\nui:\n color: var(--color-ask-mode)\nsubagent:\n runnable: false\ntools:\n # Inherits all tools from exec, then removes editing tools\n remove:\n # Read-only: no file modifications\n - file_edit_.*\n---\n\nYou are **Ask**.\n\nYour job is to answer the user's question by delegating research to sub-agents (typically **Explore**), then synthesizing a concise, actionable response.\n\n## When to delegate\n\n- Delegate when the question requires repository exploration, multiple viewpoints, or verification.\n- If the answer is obvious and does not require looking anything up, answer directly.\n\n## Delegation workflow\n\n1. Break the question into **1–3** focused research threads.\n2. Spawn Explore sub-agents in parallel using the `task` tool:\n - `agentId: \"explore\"` (or `subagent_type: \"explore\"`)\n - Use clear titles like `\"Ask: find callsites\"`, `\"Ask: summarize behavior\"`, etc.\n - Ask for concrete outputs: file paths, symbols, commands to reproduce, and short excerpts.\n3. Wait for results (use `task_await` if you launched tasks in the background).\n4. Synthesize:\n - Provide the final answer first.\n - Then include supporting details (paths, commands, edge cases).\n - Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n\n## Safety rules\n\n- Do **not** modify repository files.\n- Prefer `agentId: \"explore\"`. Only use `\"exec\"` if the user explicitly asks to implement changes.\n", - "auto": "---\nname: Auto\ndescription: Automatically selects the best agent for your task\nbase: exec\nui:\n color: var(--color-auto-mode)\nsubagent:\n runnable: false\ntools:\n add:\n # Allow all tools by default (inherit from exec)\n - .*\n remove:\n # Auto doesn't use planning tools directly — it switches to plan agent\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are **Auto**, a routing agent.\n\n- Analyze the user's request and pick the best agent to handle it.\n- Immediately call `switch_agent` with the chosen `agentId`.\n- Include an optional follow-up message when it helps hand off context.\n- Do not do the work yourself; your sole job is routing.\n\nUse these defaults:\n\n- Implementation tasks → `exec`\n- Planning/design tasks → `plan`\n- Investigation/read-only repo questions → `explore`\n- Conversational Q&A/explanations → `ask`\n\nAvailable targets include `plan`, `exec`, `explore`, `ask`, and other configured agents.\n", + "auto": "---\nname: Auto\ndescription: Automatically selects the best agent for your task\nbase: exec\nui:\n color: var(--color-auto-mode)\nsubagent:\n runnable: false\ntools:\n add:\n # Allow all tools by default (inherit from exec)\n - .*\n remove:\n # Auto doesn't use planning tools directly — it switches to plan agent\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are **Auto**, a routing agent.\n\n- Analyze the user's request and pick the best agent to handle it.\n- Immediately call `switch_agent` with the chosen `agentId`.\n- Include an optional follow-up message when it helps hand off context.\n- Do not do the work yourself; your sole job is routing.\n\nUse these defaults:\n\n- Implementation tasks → `exec`\n- Planning/design tasks → `plan`\n- Conversational Q&A, explanations, or investigation → `ask`\n\nOnly switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`.\n", "compact": "---\nname: Compact\ndescription: History compaction (internal)\nui:\n hidden: true\nsubagent:\n runnable: false\n---\n\nYou are running a compaction/summarization pass. Your task is to write a concise summary of the conversation so far.\n\nIMPORTANT:\n\n- You have NO tools available. Do not attempt to call any tools or output JSON.\n- Simply write the summary as plain text prose.\n- Follow the user's instructions for what to include in the summary.\n", "exec": "---\nname: Exec\ndescription: Implement changes in the repository\nui:\n color: var(--color-exec-mode)\nsubagent:\n runnable: true\n append_prompt: |\n You are running as a sub-agent in a child workspace.\n\n - Take a single narrowly scoped task and complete it end-to-end. Do not expand scope.\n - Preserve your context window: treat `explore` tasks as a context-saving repo scout for discovery (file locations, callsites, tests, config points, high-level flows).\n If you need repo context, spawn 1–N `explore` tasks (read-only) to scan the codebase and return paths + symbols + minimal excerpts.\n Then open/read only the returned files; avoid broad manual file-reading, and write a short internal \"mini-plan\" before editing.\n If the task brief already includes clear starting points + acceptance criteria, skip the initial explore pass and only explore when blocked.\n Prefer 1–3 narrow `explore` tasks (possibly in parallel).\n - If the task brief is missing critical information (scope, acceptance, or starting points) and you cannot infer it safely after a quick `explore`, do not guess.\n Stop and call `agent_report` once with 1–3 concrete questions/unknowns for the parent agent, and do not create commits.\n - Run targeted verification and create one or more git commits.\n - **Before your stream ends, you MUST call `agent_report` exactly once with:**\n - What changed (paths / key details)\n - What you ran (tests, typecheck, lint)\n - Any follow-ups / risks\n (If you forget, the parent will inject a follow-up message and you'll waste tokens.)\n - You may call task/task_await/task_list/task_terminate to delegate further when available.\n Delegation is limited by Max Task Nesting Depth (Settings → Agents → Task Settings).\n - Do not call propose_plan.\ntools:\n add:\n # Allow all tools by default (includes MCP tools which have dynamic names)\n # Use tools.remove in child agents to restrict specific tools\n - .*\n remove:\n # Exec mode doesn't use planning tools\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are in Exec mode.\n\n- If a `` block was provided (plan → exec handoff) and the user accepted it, treat it as the source of truth and implement it directly.\n Only do extra exploration if the plan is missing critical repo facts or you hit contradictions.\n- Use `explore` sub-agents just-in-time for missing repo context (paths/symbols/tests); don't spawn them by default.\n- Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n- For correctness claims, an Explore sub-agent report counts as having read the referenced files.\n- Make minimal, correct, reviewable changes that match existing codebase patterns.\n- Prefer targeted commands and checks (typecheck/tests) when feasible.\n- Treat as a standing order: keep running checks and addressing failures until they pass or a blocker outside your control arises.\n", "explore": "---\nname: Explore\ndescription: Read-only exploration of repository, environment, web, etc. Useful for investigation before making changes.\nbase: exec\nui:\n hidden: true\nsubagent:\n runnable: true\n skip_init_hook: true\n append_prompt: |\n You are an Explore sub-agent running inside a child workspace.\n\n - Explore the repository to answer the prompt using read-only investigation.\n - Return concise, actionable findings (paths, symbols, callsites, and facts).\n - When you have a final answer, call agent_report exactly once.\n - Do not call agent_report until you have completed the assigned task.\ntools:\n # Remove editing and task tools from exec base (read-only agent)\n remove:\n - file_edit_.*\n - task\n - task_apply_git_patch\n - task_.*\n - agent_skill_read\n - agent_skill_read_file\n---\n\nYou are in Explore mode (read-only).\n\n=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n\n- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.\n- You MUST NOT stage/commit or otherwise modify git state.\n- You MUST NOT use redirect operators (>, >>) or heredocs to write to files.\n - Pipes are allowed for processing, but MUST NOT be used to write to files (for example via `tee`).\n- You MUST NOT run commands that are explicitly about modifying the filesystem or repo state (rm, mv, cp, mkdir, touch, git add/commit, installs, etc.).\n- You MAY run verification commands (fmt-check/lint/typecheck/test) even if they create build artifacts/caches, but they MUST NOT modify tracked files.\n - After running verification, check `git status --porcelain` and report if it is non-empty.\n- Prefer `file_read` for reading file contents (supports offset/limit paging).\n- Use bash for read-only operations (rg, ls, git diff/show/log, etc.) and verification commands.\n", diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d5823d4dcc..6bca7b42a1 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2573,10 +2573,26 @@ export class AgentSession { ? normalizedOptionModel : fallbackModel.trim(); + // Build follow-up options from an explicit allowlist. + // Exclude edit-only fields (editMessageId) to prevent the synthetic + // follow-up from entering edit/truncation logic. const followUpOptions: SendMessageOptions = { - ...(currentOptions ?? {}), model: effectiveModel, agentId: switchResult.agentId, + // Preserve relevant settings from the original request + ...(currentOptions?.thinkingLevel != null && { thinkingLevel: currentOptions.thinkingLevel }), + ...(currentOptions?.system1ThinkingLevel != null && { + system1ThinkingLevel: currentOptions.system1ThinkingLevel, + }), + ...(currentOptions?.system1Model != null && { system1Model: currentOptions.system1Model }), + ...(currentOptions?.providerOptions != null && { + providerOptions: currentOptions.providerOptions, + }), + ...(currentOptions?.experiments != null && { experiments: currentOptions.experiments }), + ...(currentOptions?.maxOutputTokens != null && { + maxOutputTokens: currentOptions.maxOutputTokens, + }), + skipAiSettingsPersistence: true, }; const sendResult = await this.sendMessage(followUpText, followUpOptions, { From 1b029b30a1a0b12be9a1c4048710178658905cd5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 12:02:57 +0000 Subject: [PATCH 11/20] fix: preserve disableWorkspaceAgents in switch follow-up options --- src/node/services/agentSession.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6bca7b42a1..4760d8859d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2592,6 +2592,9 @@ export class AgentSession { ...(currentOptions?.maxOutputTokens != null && { maxOutputTokens: currentOptions.maxOutputTokens, }), + ...(currentOptions?.disableWorkspaceAgents != null && { + disableWorkspaceAgents: currentOptions.disableWorkspaceAgents, + }), skipAiSettingsPersistence: true, }; From e6b15e3339f538fe377b209517676b609476d073 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 12:12:10 +0000 Subject: [PATCH 12/20] fix: preserve toolPolicy in switch follow-up options --- src/node/services/agentSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 4760d8859d..2ec5d693e1 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2595,6 +2595,7 @@ export class AgentSession { ...(currentOptions?.disableWorkspaceAgents != null && { disableWorkspaceAgents: currentOptions.disableWorkspaceAgents, }), + ...(currentOptions?.toolPolicy != null && { toolPolicy: currentOptions.toolPolicy }), skipAiSettingsPersistence: true, }; From ce7ce4bc03bf517b4897b0a1a7c210060c4e7921 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 12:20:42 +0000 Subject: [PATCH 13/20] fix: preserve additionalSystemInstructions + scope-aware validation for disableWorkspaceAgents --- src/node/services/agentSession.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2ec5d693e1..021bdb5d83 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2411,7 +2411,10 @@ export class AgentSession { }; } - private async isAgentSwitchTargetValid(agentId: string): Promise { + private async isAgentSwitchTargetValid( + agentId: string, + disableWorkspaceAgents?: boolean + ): Promise { assert( typeof agentId === "string" && agentId.trim().length > 0, "isAgentSwitchTargetValid requires a non-empty agentId" @@ -2455,10 +2458,14 @@ export class AgentSession { ? metadata.projectPath : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + // When disableWorkspaceAgents is active, use project path for discovery + // (only built-in/global agents). Mirrors resolveAgentForStream behavior. + const discoveryPath = disableWorkspaceAgents ? metadata.projectPath : workspacePath; + try { const resolvedFrontmatter = await resolveAgentFrontmatter( runtime, - workspacePath, + discoveryPath, parsedAgentId.data ); const cfg = this.config.loadConfigOrDefault(); @@ -2558,7 +2565,10 @@ export class AgentSession { return false; } - const targetValid = await this.isAgentSwitchTargetValid(switchResult.agentId); + const targetValid = await this.isAgentSwitchTargetValid( + switchResult.agentId, + currentOptions?.disableWorkspaceAgents + ); if (!targetValid) { return false; } @@ -2596,6 +2606,9 @@ export class AgentSession { disableWorkspaceAgents: currentOptions.disableWorkspaceAgents, }), ...(currentOptions?.toolPolicy != null && { toolPolicy: currentOptions.toolPolicy }), + ...(currentOptions?.additionalSystemInstructions != null && { + additionalSystemInstructions: currentOptions.additionalSystemInstructions, + }), skipAiSettingsPersistence: true, }; From a9f72cd48361a50002f4788c66469441a5087a19 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 13 Feb 2026 12:32:51 +0000 Subject: [PATCH 14/20] chore: regenerate docs after auto.md update --- docs/agents/index.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index a7f9b95c67..42ff487030 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -325,10 +325,9 @@ Use these defaults: - Implementation tasks → `exec` - Planning/design tasks → `plan` -- Investigation/read-only repo questions → `explore` -- Conversational Q&A/explanations → `ask` +- Conversational Q&A, explanations, or investigation → `ask` -Available targets include `plan`, `exec`, `explore`, `ask`, and other configured agents. +Only switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`. ``` From c86d97b37c0a03a77dff522abd9207c5ce70ebcb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 09:13:06 +0000 Subject: [PATCH 15/20] fix: make auto strict switch-only router --- docs/agents/index.mdx | 12 ++---- src/node/builtinAgents/auto.md | 12 ++---- .../builtInAgentContent.generated.ts | 2 +- .../resolveToolPolicy.test.ts | 43 +++++++++++++++++++ .../agentDefinitions/resolveToolPolicy.ts | 25 ++++++++++- src/node/services/agentResolution.ts | 1 + 6 files changed, 77 insertions(+), 18 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index 42ff487030..60f9e9db6c 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -303,15 +303,10 @@ ui: subagent: runnable: false tools: - add: - # Allow all tools by default (inherit from exec) - - .* remove: - # Auto doesn't use planning tools directly — it switches to plan agent - - propose_plan - - ask_user_question - # Internal-only tools - - system1_keep_ranges + # Strict router mode: strip all inherited exec tools. + # `switch_agent` is re-enabled at runtime for Auto-started sessions. + - .* --- You are **Auto**, a routing agent. @@ -320,6 +315,7 @@ You are **Auto**, a routing agent. - Immediately call `switch_agent` with the chosen `agentId`. - Include an optional follow-up message when it helps hand off context. - Do not do the work yourself; your sole job is routing. +- Do not emit a normal assistant answer before calling `switch_agent`. Use these defaults: diff --git a/src/node/builtinAgents/auto.md b/src/node/builtinAgents/auto.md index 1fc17e026b..8450efac34 100644 --- a/src/node/builtinAgents/auto.md +++ b/src/node/builtinAgents/auto.md @@ -7,15 +7,10 @@ ui: subagent: runnable: false tools: - add: - # Allow all tools by default (inherit from exec) - - .* remove: - # Auto doesn't use planning tools directly — it switches to plan agent - - propose_plan - - ask_user_question - # Internal-only tools - - system1_keep_ranges + # Strict router mode: strip all inherited exec tools. + # `switch_agent` is re-enabled at runtime for Auto-started sessions. + - .* --- You are **Auto**, a routing agent. @@ -24,6 +19,7 @@ You are **Auto**, a routing agent. - Immediately call `switch_agent` with the chosen `agentId`. - Include an optional follow-up message when it helps hand off context. - Do not do the work yourself; your sole job is routing. +- Do not emit a normal assistant answer before calling `switch_agent`. Use these defaults: diff --git a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts index 4bdd671959..5e334718ac 100644 --- a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts +++ b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts @@ -4,7 +4,7 @@ export const BUILTIN_AGENT_CONTENT = { "ask": "---\nname: Ask\ndescription: Delegate questions to Explore sub-agents and synthesize an answer.\nbase: exec\nui:\n color: var(--color-ask-mode)\nsubagent:\n runnable: false\ntools:\n # Inherits all tools from exec, then removes editing tools\n remove:\n # Read-only: no file modifications\n - file_edit_.*\n---\n\nYou are **Ask**.\n\nYour job is to answer the user's question by delegating research to sub-agents (typically **Explore**), then synthesizing a concise, actionable response.\n\n## When to delegate\n\n- Delegate when the question requires repository exploration, multiple viewpoints, or verification.\n- If the answer is obvious and does not require looking anything up, answer directly.\n\n## Delegation workflow\n\n1. Break the question into **1–3** focused research threads.\n2. Spawn Explore sub-agents in parallel using the `task` tool:\n - `agentId: \"explore\"` (or `subagent_type: \"explore\"`)\n - Use clear titles like `\"Ask: find callsites\"`, `\"Ask: summarize behavior\"`, etc.\n - Ask for concrete outputs: file paths, symbols, commands to reproduce, and short excerpts.\n3. Wait for results (use `task_await` if you launched tasks in the background).\n4. Synthesize:\n - Provide the final answer first.\n - Then include supporting details (paths, commands, edge cases).\n - Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n\n## Safety rules\n\n- Do **not** modify repository files.\n- Prefer `agentId: \"explore\"`. Only use `\"exec\"` if the user explicitly asks to implement changes.\n", - "auto": "---\nname: Auto\ndescription: Automatically selects the best agent for your task\nbase: exec\nui:\n color: var(--color-auto-mode)\nsubagent:\n runnable: false\ntools:\n add:\n # Allow all tools by default (inherit from exec)\n - .*\n remove:\n # Auto doesn't use planning tools directly — it switches to plan agent\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are **Auto**, a routing agent.\n\n- Analyze the user's request and pick the best agent to handle it.\n- Immediately call `switch_agent` with the chosen `agentId`.\n- Include an optional follow-up message when it helps hand off context.\n- Do not do the work yourself; your sole job is routing.\n\nUse these defaults:\n\n- Implementation tasks → `exec`\n- Planning/design tasks → `plan`\n- Conversational Q&A, explanations, or investigation → `ask`\n\nOnly switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`.\n", + "auto": "---\nname: Auto\ndescription: Automatically selects the best agent for your task\nbase: exec\nui:\n color: var(--color-auto-mode)\nsubagent:\n runnable: false\ntools:\n remove:\n # Strict router mode: strip all inherited exec tools.\n # `switch_agent` is re-enabled at runtime for Auto-started sessions.\n - .*\n---\n\nYou are **Auto**, a routing agent.\n\n- Analyze the user's request and pick the best agent to handle it.\n- Immediately call `switch_agent` with the chosen `agentId`.\n- Include an optional follow-up message when it helps hand off context.\n- Do not do the work yourself; your sole job is routing.\n- Do not emit a normal assistant answer before calling `switch_agent`.\n\nUse these defaults:\n\n- Implementation tasks → `exec`\n- Planning/design tasks → `plan`\n- Conversational Q&A, explanations, or investigation → `ask`\n\nOnly switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`.\n", "compact": "---\nname: Compact\ndescription: History compaction (internal)\nui:\n hidden: true\nsubagent:\n runnable: false\n---\n\nYou are running a compaction/summarization pass. Your task is to write a concise summary of the conversation so far.\n\nIMPORTANT:\n\n- You have NO tools available. Do not attempt to call any tools or output JSON.\n- Simply write the summary as plain text prose.\n- Follow the user's instructions for what to include in the summary.\n", "exec": "---\nname: Exec\ndescription: Implement changes in the repository\nui:\n color: var(--color-exec-mode)\nsubagent:\n runnable: true\n append_prompt: |\n You are running as a sub-agent in a child workspace.\n\n - Take a single narrowly scoped task and complete it end-to-end. Do not expand scope.\n - Preserve your context window: treat `explore` tasks as a context-saving repo scout for discovery (file locations, callsites, tests, config points, high-level flows).\n If you need repo context, spawn 1–N `explore` tasks (read-only) to scan the codebase and return paths + symbols + minimal excerpts.\n Then open/read only the returned files; avoid broad manual file-reading, and write a short internal \"mini-plan\" before editing.\n If the task brief already includes clear starting points + acceptance criteria, skip the initial explore pass and only explore when blocked.\n Prefer 1–3 narrow `explore` tasks (possibly in parallel).\n - If the task brief is missing critical information (scope, acceptance, or starting points) and you cannot infer it safely after a quick `explore`, do not guess.\n Stop and call `agent_report` once with 1–3 concrete questions/unknowns for the parent agent, and do not create commits.\n - Run targeted verification and create one or more git commits.\n - **Before your stream ends, you MUST call `agent_report` exactly once with:**\n - What changed (paths / key details)\n - What you ran (tests, typecheck, lint)\n - Any follow-ups / risks\n (If you forget, the parent will inject a follow-up message and you'll waste tokens.)\n - You may call task/task_await/task_list/task_terminate to delegate further when available.\n Delegation is limited by Max Task Nesting Depth (Settings → Agents → Task Settings).\n - Do not call propose_plan.\ntools:\n add:\n # Allow all tools by default (includes MCP tools which have dynamic names)\n # Use tools.remove in child agents to restrict specific tools\n - .*\n remove:\n # Exec mode doesn't use planning tools\n - propose_plan\n - ask_user_question\n # Internal-only tools\n - system1_keep_ranges\n---\n\nYou are in Exec mode.\n\n- If a `` block was provided (plan → exec handoff) and the user accepted it, treat it as the source of truth and implement it directly.\n Only do extra exploration if the plan is missing critical repo facts or you hit contradictions.\n- Use `explore` sub-agents just-in-time for missing repo context (paths/symbols/tests); don't spawn them by default.\n- Trust Explore sub-agent reports as authoritative for repo facts (paths/symbols/callsites). Do not redo the same investigation yourself; only re-check if the report is ambiguous or contradicts other evidence.\n- For correctness claims, an Explore sub-agent report counts as having read the referenced files.\n- Make minimal, correct, reviewable changes that match existing codebase patterns.\n- Prefer targeted commands and checks (typecheck/tests) when feasible.\n- Treat as a standing order: keep running checks and addressing failures until they pass or a blocker outside your control arises.\n", "explore": "---\nname: Explore\ndescription: Read-only exploration of repository, environment, web, etc. Useful for investigation before making changes.\nbase: exec\nui:\n hidden: true\nsubagent:\n runnable: true\n skip_init_hook: true\n append_prompt: |\n You are an Explore sub-agent running inside a child workspace.\n\n - Explore the repository to answer the prompt using read-only investigation.\n - Return concise, actionable findings (paths, symbols, callsites, and facts).\n - When you have a final answer, call agent_report exactly once.\n - Do not call agent_report until you have completed the assigned task.\ntools:\n # Remove editing and task tools from exec base (read-only agent)\n remove:\n - file_edit_.*\n - task\n - task_apply_git_patch\n - task_.*\n - agent_skill_read\n - agent_skill_read_file\n---\n\nYou are in Explore mode (read-only).\n\n=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n\n- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.\n- You MUST NOT stage/commit or otherwise modify git state.\n- You MUST NOT use redirect operators (>, >>) or heredocs to write to files.\n - Pipes are allowed for processing, but MUST NOT be used to write to files (for example via `tee`).\n- You MUST NOT run commands that are explicitly about modifying the filesystem or repo state (rm, mv, cp, mkdir, touch, git add/commit, installs, etc.).\n- You MAY run verification commands (fmt-check/lint/typecheck/test) even if they create build artifacts/caches, but they MUST NOT modify tracked files.\n - After running verification, check `git status --porcelain` and report if it is non-empty.\n- Prefer `file_read` for reading file contents (supports offset/limit paging).\n- Use bash for read-only operations (rg, ls, git diff/show/log, etc.) and verification commands.\n", diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 93f131b275..1379c9fbef 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -87,6 +87,49 @@ describe("resolveToolPolicyForAgent", () => { ]); }); + test("requireSwitchAgentTool forces switch_agent for strict auto routing", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + const policy = resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: true, + requireSwitchAgentTool: true, + }); + + expect(policy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "switch_agent", action: "enable" }, + { regex_match: "switch_agent", action: "require" }, + ]); + }); + + test("requireSwitchAgentTool rejects invalid runtime combinations", () => { + const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; + + expect(() => + resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, + requireSwitchAgentTool: true, + }) + ).toThrow("Invalid tool policy options"); + + expect(() => + resolveToolPolicyForAgent({ + agents, + isSubagent: true, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: true, + requireSwitchAgentTool: true, + }) + ).toThrow("Invalid tool policy options"); + }); + test("subagents still hard-deny switch_agent even when auto switch is enabled", () => { const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; const policy = resolveToolPolicyForAgent({ diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.ts b/src/node/services/agentDefinitions/resolveToolPolicy.ts index 09900c1601..02d0ab2511 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts @@ -22,6 +22,11 @@ export interface ResolveToolPolicyOptions { isSubagent: boolean; disableTaskToolsForDepth: boolean; enableAgentSwitchTool: boolean; + /** + * Force switch_agent as the only required tool for this turn. + * Used by Auto so routing always happens before prose output. + */ + requireSwitchAgentTool?: boolean; } // Runtime restrictions that cannot be overridden by agent definitions. @@ -55,7 +60,20 @@ const DEPTH_HARD_DENY: ToolPolicy = [ * - non-plan subagents: disable `propose_plan`, enable `agent_report` */ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): ToolPolicy { - const { agents, isSubagent, disableTaskToolsForDepth, enableAgentSwitchTool } = options; + const { + agents, + isSubagent, + disableTaskToolsForDepth, + enableAgentSwitchTool, + requireSwitchAgentTool = false, + } = options; + + // Defensive validation: forcing switch_agent only makes sense when the tool can be enabled. + if (requireSwitchAgentTool && (!enableAgentSwitchTool || isSubagent)) { + throw new Error( + "Invalid tool policy options: requireSwitchAgentTool needs a top-level workspace with agent switching enabled." + ); + } // Start with deny-all baseline const agentPolicy: ToolPolicy = [{ regex_match: ".*", action: "disable" }]; @@ -96,6 +114,11 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To runtimePolicy.push({ regex_match: "switch_agent", action: "disable" }); if (enableAgentSwitchTool && !isSubagent) { runtimePolicy.push({ regex_match: "switch_agent", action: "enable" }); + + // Auto is a strict router: force a switch_agent tool call before producing prose. + if (requireSwitchAgentTool) { + runtimePolicy.push({ regex_match: "switch_agent", action: "require" }); + } } if (isSubagent) { diff --git a/src/node/services/agentResolution.ts b/src/node/services/agentResolution.ts index 59d18c0111..eb3ef273eb 100644 --- a/src/node/services/agentResolution.ts +++ b/src/node/services/agentResolution.ts @@ -228,6 +228,7 @@ export async function resolveAgentForStream( isSubagent: isSubagentWorkspace, disableTaskToolsForDepth: shouldDisableTaskToolsForDepth, enableAgentSwitchTool, + requireSwitchAgentTool: agentDefinition.id === "auto", }); // The Chat with Mux system workspace must remain sandboxed regardless of caller-supplied From 8665bba7b7cf37894bfdc25e0fd6fc810d480356 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 09:30:40 +0000 Subject: [PATCH 16/20] fix: gate agent-switch metadata on resolved auto agent --- src/node/services/agentResolution.ts | 4 ++- src/node/services/agentSession.ts | 46 +++++++++++++++++++++------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentResolution.ts b/src/node/services/agentResolution.ts index eb3ef273eb..699bef5721 100644 --- a/src/node/services/agentResolution.ts +++ b/src/node/services/agentResolution.ts @@ -223,11 +223,13 @@ export async function resolveAgentForStream( // --- Tool policy composition --- // Agent policy establishes baseline (deny-all + enable whitelist + runtime restrictions). // Caller policy then narrows further if needed. + // Auto must be able to call switch_agent on its first turn even before metadata persistence. + const shouldEnableAgentSwitchTool = enableAgentSwitchTool || agentDefinition.id === "auto"; const agentToolPolicy = resolveToolPolicyForAgent({ agents: agentsForInheritance, isSubagent: isSubagentWorkspace, disableTaskToolsForDepth: shouldDisableTaskToolsForDepth, - enableAgentSwitchTool, + enableAgentSwitchTool: shouldEnableAgentSwitchTool, requireSwitchAgentTool: agentDefinition.id === "auto", }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 021bdb5d83..a2f1f93288 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -67,7 +67,7 @@ import { import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { MessageQueue } from "./messageQueue"; -import type { StreamEndEvent } from "@/common/types/stream"; +import type { StreamEndEvent, StreamStartEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; import type { TelemetryService } from "./telemetryService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; @@ -1218,17 +1218,27 @@ export class AgentSession { return Ok(undefined); } - private async maybeEnableAgentSwitchingForAutoAgent(agentId: string | undefined): Promise { + private async syncAgentSwitchingForResolvedAgent( + requestedAgentId: string | undefined, + resolvedAgentId: string | undefined + ): Promise { + assert( + typeof requestedAgentId === "string" || requestedAgentId === undefined, + "syncAgentSwitchingForResolvedAgent requestedAgentId must be string|undefined" + ); assert( - typeof agentId === "string" || agentId === undefined, - "maybeEnableAgentSwitchingForAutoAgent agentId must be string|undefined" + typeof resolvedAgentId === "string" || resolvedAgentId === undefined, + "syncAgentSwitchingForResolvedAgent resolvedAgentId must be string|undefined" ); - const normalizedAgentId = agentId?.trim().toLowerCase(); - if (normalizedAgentId !== "auto") { + const normalizedRequestedAgentId = requestedAgentId?.trim().toLowerCase(); + if (normalizedRequestedAgentId !== "auto") { return; } + const normalizedResolvedAgentId = resolvedAgentId?.trim().toLowerCase(); + const shouldEnableAgentSwitching = normalizedResolvedAgentId === "auto"; + // Guard for test mocks that may not implement getWorkspaceMetadata. if (typeof this.aiService.getWorkspaceMetadata !== "function") { return; @@ -1237,23 +1247,27 @@ export class AgentSession { try { const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); if (!metadataResult.success) { - log.warn("Failed to read workspace metadata while enabling agent switching", { + log.warn("Failed to read workspace metadata while syncing agent switching", { workspaceId: this.workspaceId, + requestedAgentId: normalizedRequestedAgentId, + resolvedAgentId: normalizedResolvedAgentId, error: metadataResult.error, }); return; } - if (metadataResult.data.agentSwitchingEnabled === true) { + if (metadataResult.data.agentSwitchingEnabled === shouldEnableAgentSwitching) { return; } await this.config.updateWorkspaceMetadata(this.workspaceId, { - agentSwitchingEnabled: true, + agentSwitchingEnabled: shouldEnableAgentSwitching, }); } catch (error) { log.warn("Failed to persist agent switching metadata flag", { workspaceId: this.workspaceId, + requestedAgentId: normalizedRequestedAgentId, + resolvedAgentId: normalizedResolvedAgentId, error: error instanceof Error ? error.message : String(error), }); } @@ -1269,8 +1283,6 @@ export class AgentSession { return Ok(undefined); } - await this.maybeEnableAgentSwitchingForAutoAgent(options?.agentId); - // Reset per-stream flags (used for retries / crash-safe bookkeeping). this.ackPendingPostCompactionStateOnStreamEnd = false; this.activeStreamHadAnyDelta = false; @@ -2028,6 +2040,18 @@ export class AgentSession { forward("stream-start", (payload) => { this.setTurnPhase(TurnPhase.STREAMING); this.emitChatEvent(payload); + + // Persist agent-switch enablement based on the resolved stream agent, not just the request. + // This avoids leaving switch_agent enabled when an "auto" request falls back to exec. + const streamStartPayload = payload as StreamStartEvent; + if (streamStartPayload.replay) { + return; + } + + void this.syncAgentSwitchingForResolvedAgent( + this.activeStreamContext?.options?.agentId, + streamStartPayload.agentId + ); }); forward("stream-delta", (payload) => { this.activeStreamHadAnyDelta = true; From 54ff496bc88489ddf2206d90b59c9a2a3f46029c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 09:43:15 +0000 Subject: [PATCH 17/20] fix: await switch metadata sync before auto follow-up --- src/node/services/agentSession.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a2f1f93288..42f96ff08d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -199,6 +199,12 @@ export class AgentSession { private turnPhase: TurnPhase = TurnPhase.IDLE; // When true, stream-end skips auto-flushing queued messages so an edit can truncate first. private deferQueuedFlushUntilAfterEdit = false; + /** + * Tracks in-flight persistence for agentSwitchingEnabled that starts at stream-start. + * Stream-end awaits this before dispatching switch follow-ups to avoid metadata races. + */ + private pendingAgentSwitchingSync?: Promise; + /** Guardrail against synthetic switch_agent ping-pong loops. */ private consecutiveAgentSwitches = 0; @@ -2048,7 +2054,7 @@ export class AgentSession { return; } - void this.syncAgentSwitchingForResolvedAgent( + this.pendingAgentSwitchingSync = this.syncAgentSwitchingForResolvedAgent( this.activeStreamContext?.options?.agentId, streamStartPayload.agentId ); @@ -2112,6 +2118,7 @@ export class AgentSession { if (hadCompactionRequest && !this.disposed) { this.clearQueue(); } + this.pendingAgentSwitchingSync = undefined; this.emitChatEvent(payload); this.setTurnPhase(TurnPhase.IDLE); }); @@ -2160,6 +2167,12 @@ export class AgentSession { await this.dispatchPendingFollowUp(); } + const pendingAgentSwitchingSync = this.pendingAgentSwitchingSync; + this.pendingAgentSwitchingSync = undefined; + if (pendingAgentSwitchingSync) { + await pendingAgentSwitchingSync; + } + const switchResult = this.extractSwitchAgentResult(streamEndPayload); if (switchResult) { const dispatchedSwitchFollowUp = await this.dispatchAgentSwitch( From 2ec3146ad391bda2c579d3369924a8d0685fe010 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 10:45:12 +0000 Subject: [PATCH 18/20] fix: register auto built-in agent definition --- .../agentDefinitions/builtInAgentDefinitions.test.ts | 10 ++++++++++ .../agentDefinitions/builtInAgentDefinitions.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts index 1b7f030eb8..c55a848ee7 100644 --- a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts +++ b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts @@ -22,6 +22,16 @@ describe("built-in agent definitions", () => { expect(orchestrator?.body).toContain("counts as having read the referenced files"); }); + test("includes auto router built-in", () => { + const pkgs = getBuiltInAgentDefinitions(); + const byId = new Map(pkgs.map((pkg) => [pkg.id, pkg] as const)); + + const auto = byId.get("auto"); + expect(auto).toBeTruthy(); + expect(auto?.frontmatter.tools?.remove ?? []).toContain(".*"); + expect(auto?.body).toContain("Immediately call `switch_agent`"); + }); + test("orchestrator includes an exec task brief template", () => { const pkgs = getBuiltInAgentDefinitions(); const byId = new Map(pkgs.map((pkg) => [pkg.id, pkg] as const)); diff --git a/src/node/services/agentDefinitions/builtInAgentDefinitions.ts b/src/node/services/agentDefinitions/builtInAgentDefinitions.ts index 4b3e18a77f..88be41bac9 100644 --- a/src/node/services/agentDefinitions/builtInAgentDefinitions.ts +++ b/src/node/services/agentDefinitions/builtInAgentDefinitions.ts @@ -18,6 +18,7 @@ const BUILT_IN_SOURCES: BuiltInSource[] = [ { id: "exec", content: BUILTIN_AGENT_CONTENT.exec }, { id: "plan", content: BUILTIN_AGENT_CONTENT.plan }, { id: "ask", content: BUILTIN_AGENT_CONTENT.ask }, + { id: "auto", content: BUILTIN_AGENT_CONTENT.auto }, { id: "compact", content: BUILTIN_AGENT_CONTENT.compact }, { id: "explore", content: BUILTIN_AGENT_CONTENT.explore }, { id: "system1_bash", content: BUILTIN_AGENT_CONTENT.system1_bash }, From e086c45a715d164a2d824ca646bab19483c74900 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 12:02:55 +0000 Subject: [PATCH 19/20] fix: fallback when switch_agent target is unavailable --- .../services/agentSession.switchAgent.test.ts | 38 +++++--- src/node/services/agentSession.ts | 89 +++++++++++++++++-- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/node/services/agentSession.switchAgent.test.ts b/src/node/services/agentSession.switchAgent.test.ts index 32e648741e..cae083cf01 100644 --- a/src/node/services/agentSession.switchAgent.test.ts +++ b/src/node/services/agentSession.switchAgent.test.ts @@ -153,7 +153,7 @@ describe("AgentSession switch_agent target validation", () => { } }); - test("rejects hidden switch target and skips follow-up dispatch", async () => { + test("falls back to safe agent when switch target is hidden", async () => { using projectDir = new DisposableTempDir("agent-session-switch-hidden"); await writeAgentDefinition(projectDir.path, "hidden-agent", "ui:\n hidden: true\n"); @@ -176,14 +176,20 @@ describe("AgentSession switch_agent target validation", () => { "openai:gpt-4o" ); - expect(result).toBe(false); - expect(sendMessageMock).toHaveBeenCalledTimes(0); + expect(result).toBe(true); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + + const firstCall = sendMessageMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [messageArg, optionsArg] = firstCall as unknown as [string, SendMessageOptions]; + expect(messageArg).toContain('target "hidden-agent" is unavailable'); + expect(optionsArg.agentId).toBe("exec"); } finally { session.dispose(); } }); - test("rejects effectively disabled switch target and skips follow-up dispatch", async () => { + test("falls back to safe agent when switch target is disabled", async () => { using projectDir = new DisposableTempDir("agent-session-switch-disabled"); await writeAgentDefinition(projectDir.path, "disabled-agent", "disabled: true\n"); @@ -206,14 +212,20 @@ describe("AgentSession switch_agent target validation", () => { "openai:gpt-4o" ); - expect(result).toBe(false); - expect(sendMessageMock).toHaveBeenCalledTimes(0); + expect(result).toBe(true); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + + const firstCall = sendMessageMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [messageArg, optionsArg] = firstCall as unknown as [string, SendMessageOptions]; + expect(messageArg).toContain('target "disabled-agent" is unavailable'); + expect(optionsArg.agentId).toBe("exec"); } finally { session.dispose(); } }); - test("rejects unresolved switch target and skips follow-up dispatch", async () => { + test("falls back to exec when auto requests an unresolved switch target", async () => { using projectDir = new DisposableTempDir("agent-session-switch-missing"); const { historyService, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; @@ -230,12 +242,18 @@ describe("AgentSession switch_agent target validation", () => { agentId: "missing-agent", followUp: "Should not send", }, - { model: "openai:gpt-4o-mini", agentId: "exec" }, + { model: "openai:gpt-4o-mini", agentId: "auto" }, "openai:gpt-4o" ); - expect(result).toBe(false); - expect(sendMessageMock).toHaveBeenCalledTimes(0); + expect(result).toBe(true); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + + const firstCall = sendMessageMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [messageArg, optionsArg] = firstCall as unknown as [string, SendMessageOptions]; + expect(messageArg).toContain('target "missing-agent" is unavailable'); + expect(optionsArg.agentId).toBe("exec"); } finally { session.dispose(); } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 42f96ff08d..ddfcb16a37 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -34,6 +34,7 @@ import { type StreamErrorPayload, } from "@/node/services/utils/sendMessageError"; import { + createAssistantMessageId, createUserMessageId, createFileSnapshotMessageId, createAgentSkillSnapshotMessageId, @@ -118,6 +119,10 @@ interface SwitchAgentResult { const MAX_CONSECUTIVE_AGENT_SWITCHES = 3; +const SAFE_AGENT_SWITCH_FALLBACK_CANDIDATES = ["exec", "ask", "plan"] as const; +const SWITCH_AGENT_TARGET_UNAVAILABLE_ERROR = + "Agent handoff failed because the requested target is unavailable. Please retry or choose a different mode."; + const PDF_MEDIA_TYPE = "application/pdf"; function normalizeMediaType(mediaType: string): string { @@ -2576,6 +2581,52 @@ export class AgentSession { } } + private async resolveAgentSwitchFallbackTarget( + currentOptions: SendMessageOptions | undefined + ): Promise { + const preferredAgentId = currentOptions?.agentId?.trim(); + const disableWorkspaceAgents = currentOptions?.disableWorkspaceAgents; + + const candidates: string[] = []; + // Prefer returning to the caller's previous non-auto agent when possible. + if (preferredAgentId != null && preferredAgentId.length > 0 && preferredAgentId !== "auto") { + candidates.push(preferredAgentId); + } + + for (const candidate of SAFE_AGENT_SWITCH_FALLBACK_CANDIDATES) { + candidates.push(candidate); + } + + const seen = new Set(); + for (const candidate of candidates) { + assert(candidate.trim().length > 0, "Fallback candidate agent IDs must be non-empty"); + if (seen.has(candidate)) { + continue; + } + seen.add(candidate); + + if (await this.isAgentSwitchTargetValid(candidate, disableWorkspaceAgents)) { + return candidate; + } + } + + return undefined; + } + + private buildAgentSwitchFallbackFollowUp(switchResult: SwitchAgentResult): string { + const normalizedReason = switchResult.reason?.trim(); + const lines = [ + `Agent handoff failed: target "${switchResult.agentId}" is unavailable in this workspace.`, + "Continue assisting the user's latest request using this mode.", + ]; + + if (normalizedReason != null && normalizedReason.length > 0) { + lines.splice(1, 0, `Router rationale: ${normalizedReason}`); + } + + return lines.join("\n"); + } + /** Dispatch follow-up message after switch_agent and guard against ping-pong loops. */ private async dispatchAgentSwitch( switchResult: SwitchAgentResult, @@ -2602,18 +2653,45 @@ export class AgentSession { return false; } + let targetAgentId = switchResult.agentId; + const targetValid = await this.isAgentSwitchTargetValid( - switchResult.agentId, + targetAgentId, currentOptions?.disableWorkspaceAgents ); if (!targetValid) { - return false; + const fallbackAgentId = await this.resolveAgentSwitchFallbackTarget(currentOptions); + if (fallbackAgentId == null) { + log.warn("switch_agent target invalid and no safe fallback agent is available", { + workspaceId: this.workspaceId, + requestedTargetAgentId: switchResult.agentId, + }); + this.emitChatEvent( + createStreamErrorMessage({ + messageId: createAssistantMessageId(), + error: `${SWITCH_AGENT_TARGET_UNAVAILABLE_ERROR} Requested target: "${switchResult.agentId}".`, + errorType: "unknown", + }) + ); + return false; + } + + log.warn("switch_agent target invalid; routing synthetic follow-up to fallback agent", { + workspaceId: this.workspaceId, + requestedTargetAgentId: switchResult.agentId, + fallbackAgentId, + }); + targetAgentId = fallbackAgentId; } // Fall back to "Continue." for nullish, empty, or whitespace-only followUp strings. const trimmedFollowUp = switchResult.followUp?.trim(); const followUpText = - trimmedFollowUp != null && trimmedFollowUp.length > 0 ? trimmedFollowUp : "Continue."; + targetAgentId === switchResult.agentId + ? trimmedFollowUp != null && trimmedFollowUp.length > 0 + ? trimmedFollowUp + : "Continue." + : this.buildAgentSwitchFallbackFollowUp(switchResult); const normalizedOptionModel = currentOptions?.model?.trim(); const effectiveModel = normalizedOptionModel && normalizedOptionModel.length > 0 @@ -2625,7 +2703,7 @@ export class AgentSession { // follow-up from entering edit/truncation logic. const followUpOptions: SendMessageOptions = { model: effectiveModel, - agentId: switchResult.agentId, + agentId: targetAgentId, // Preserve relevant settings from the original request ...(currentOptions?.thinkingLevel != null && { thinkingLevel: currentOptions.thinkingLevel }), ...(currentOptions?.system1ThinkingLevel != null && { @@ -2656,7 +2734,8 @@ export class AgentSession { if (!sendResult.success) { log.warn("Failed to dispatch switch_agent follow-up", { workspaceId: this.workspaceId, - targetAgentId: switchResult.agentId, + requestedTargetAgentId: switchResult.agentId, + dispatchedTargetAgentId: targetAgentId, error: sendResult.error, }); return false; From ca66e5dbf50bad6326dd293513e6c01a87393009 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 19 Feb 2026 13:48:39 +0000 Subject: [PATCH 20/20] fix: degrade auto switch requirement in subagent policy --- .../resolveToolPolicy.test.ts | 50 +++++++++++-------- .../agentDefinitions/resolveToolPolicy.ts | 13 +++-- src/node/services/agentResolution.ts | 6 ++- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 1379c9fbef..ca6ec7bc70 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -106,28 +106,38 @@ describe("resolveToolPolicyForAgent", () => { ]); }); - test("requireSwitchAgentTool rejects invalid runtime combinations", () => { + test("requireSwitchAgentTool degrades safely for invalid runtime combinations", () => { const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; - expect(() => - resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - enableAgentSwitchTool: false, - requireSwitchAgentTool: true, - }) - ).toThrow("Invalid tool policy options"); - - expect(() => - resolveToolPolicyForAgent({ - agents, - isSubagent: true, - disableTaskToolsForDepth: false, - enableAgentSwitchTool: true, - requireSwitchAgentTool: true, - }) - ).toThrow("Invalid tool policy options"); + const withoutSwitchEnablement = resolveToolPolicyForAgent({ + agents, + isSubagent: false, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: false, + requireSwitchAgentTool: true, + }); + expect(withoutSwitchEnablement).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + ]); + + const subagentPolicy = resolveToolPolicyForAgent({ + agents, + isSubagent: true, + disableTaskToolsForDepth: false, + enableAgentSwitchTool: true, + requireSwitchAgentTool: true, + }); + expect(subagentPolicy).toEqual([ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "ask_user_question", action: "disable" }, + { regex_match: "switch_agent", action: "disable" }, + { regex_match: "propose_plan", action: "disable" }, + { regex_match: "agent_report", action: "enable" }, + ]); }); test("subagents still hard-deny switch_agent even when auto switch is enabled", () => { diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.ts b/src/node/services/agentDefinitions/resolveToolPolicy.ts index 02d0ab2511..1d0703841e 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts @@ -68,12 +68,11 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To requireSwitchAgentTool = false, } = options; - // Defensive validation: forcing switch_agent only makes sense when the tool can be enabled. - if (requireSwitchAgentTool && (!enableAgentSwitchTool || isSubagent)) { - throw new Error( - "Invalid tool policy options: requireSwitchAgentTool needs a top-level workspace with agent switching enabled." - ); - } + // Defensive normalization: requiring switch_agent is only valid when the tool can be enabled. + // Invalid combinations (e.g. stale subagent metadata pointing at Auto) degrade safely + // to the default disabled policy instead of throwing and bricking the workspace. + const shouldRequireSwitchAgentTool = + requireSwitchAgentTool && enableAgentSwitchTool && !isSubagent; // Start with deny-all baseline const agentPolicy: ToolPolicy = [{ regex_match: ".*", action: "disable" }]; @@ -116,7 +115,7 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To runtimePolicy.push({ regex_match: "switch_agent", action: "enable" }); // Auto is a strict router: force a switch_agent tool call before producing prose. - if (requireSwitchAgentTool) { + if (shouldRequireSwitchAgentTool) { runtimePolicy.push({ regex_match: "switch_agent", action: "require" }); } } diff --git a/src/node/services/agentResolution.ts b/src/node/services/agentResolution.ts index 699bef5721..81e01ab96c 100644 --- a/src/node/services/agentResolution.ts +++ b/src/node/services/agentResolution.ts @@ -225,12 +225,16 @@ export async function resolveAgentForStream( // Caller policy then narrows further if needed. // Auto must be able to call switch_agent on its first turn even before metadata persistence. const shouldEnableAgentSwitchTool = enableAgentSwitchTool || agentDefinition.id === "auto"; + // Only force toolChoice=require in top-level workspaces where switch_agent can actually run. + // Corrupted/stale subagent metadata may still point at auto; that should degrade safely. + const shouldRequireSwitchAgentTool = + agentDefinition.id === "auto" && shouldEnableAgentSwitchTool && !isSubagentWorkspace; const agentToolPolicy = resolveToolPolicyForAgent({ agents: agentsForInheritance, isSubagent: isSubagentWorkspace, disableTaskToolsForDepth: shouldDisableTaskToolsForDepth, enableAgentSwitchTool: shouldEnableAgentSwitchTool, - requireSwitchAgentTool: agentDefinition.id === "auto", + requireSwitchAgentTool: shouldRequireSwitchAgentTool, }); // The Chat with Mux system workspace must remain sandboxed regardless of caller-supplied