diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx
index 14d5b967c4..60f9e9db6c 100644
--- a/docs/agents/index.mdx
+++ b/docs/agents/index.mdx
@@ -287,6 +287,47 @@ 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:
+ remove:
+ # 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.
+
+- 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.
+- Do not emit a normal assistant answer before calling `switch_agent`.
+
+Use these defaults:
+
+- Implementation tasks → `exec`
+- Planning/design tasks → `plan`
+- Conversational Q&A, explanations, or investigation → `ask`
+
+Only switch to agents visible in the UI (e.g. `exec`, `plan`, `ask`). Do not target hidden agents like `explore`, `compact`, or `system1_bash`.
+```
+
+
+
### 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)
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/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/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/builtinAgents/auto.md b/src/node/builtinAgents/auto.md
new file mode 100644
index 0000000000..8450efac34
--- /dev/null
+++ b/src/node/builtinAgents/auto.md
@@ -0,0 +1,30 @@
+---
+name: Auto
+description: Automatically selects the best agent for your task
+base: exec
+ui:
+ color: var(--color-auto-mode)
+subagent:
+ runnable: false
+tools:
+ remove:
+ # 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.
+
+- 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.
+- Do not emit a normal assistant answer before calling `switch_agent`.
+
+Use these defaults:
+
+- Implementation tasks → `exec`
+- Planning/design tasks → `plan`
+- Conversational Q&A, explanations, or investigation → `ask`
+
+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/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/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts
index 6d7e5430bf..5e334718ac 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 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/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 },
diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts
index 215f62b267..ca6ec7bc70 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,104 @@ 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("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 degrades safely for invalid runtime combinations", () => {
+ const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }];
+
+ 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", () => {
+ 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 +166,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 +189,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: true,
disableTaskToolsForDepth: false,
+ enableAgentSwitchTool: false,
});
expect(policy).toEqual([
@@ -79,7 +197,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 +211,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: false,
disableTaskToolsForDepth: true,
+ enableAgentSwitchTool: false,
});
expect(policy).toEqual([
@@ -99,6 +220,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 +230,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: true,
disableTaskToolsForDepth: true,
+ enableAgentSwitchTool: false,
});
expect(policy).toEqual([
@@ -116,7 +239,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 +253,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 +268,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 +287,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: false,
disableTaskToolsForDepth: false,
+ enableAgentSwitchTool: false,
});
expect(policy).toEqual([
@@ -164,6 +296,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 +310,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: false,
disableTaskToolsForDepth: false,
+ enableAgentSwitchTool: false,
});
// exec: deny-all → enable .* → disable propose_plan
@@ -186,6 +320,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 +335,7 @@ describe("resolveToolPolicyForAgent", () => {
agents,
isSubagent: false,
disableTaskToolsForDepth: false,
+ enableAgentSwitchTool: false,
});
// base: deny-all → enable file_read → enable bash
@@ -212,6 +348,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 +362,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..1d0703841e 100644
--- a/src/node/services/agentDefinitions/resolveToolPolicy.ts
+++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts
@@ -21,11 +21,20 @@ export interface ResolveToolPolicyOptions {
agents: readonly AgentLikeForPolicy[];
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.
-// 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 +60,19 @@ 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,
+ requireSwitchAgentTool = false,
+ } = options;
+
+ // 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" }];
@@ -87,6 +108,18 @@ 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" });
+
+ // Auto is a strict router: force a switch_agent tool call before producing prose.
+ if (shouldRequireSwitchAgentTool) {
+ runtimePolicy.push({ regex_match: "switch_agent", action: "require" });
+ }
+ }
+
if (isSubagent) {
runtimePolicy.push(...SUBAGENT_HARD_DENY);
diff --git a/src/node/services/agentResolution.ts b/src/node/services/agentResolution.ts
index 466fd55d44..81e01ab96c 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,
@@ -220,10 +223,18 @@ 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";
+ // 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: shouldRequireSwitchAgentTool,
});
// The Chat with Mux system workspace must remain sandboxed regardless of caller-supplied
diff --git a/src/node/services/agentSession.switchAgent.test.ts b/src/node/services/agentSession.switchAgent.test.ts
new file mode 100644
index 0000000000..cae083cf01
--- /dev/null
+++ b/src/node/services/agentSession.switchAgent.test.ts
@@ -0,0 +1,261 @@
+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("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");
+
+ 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(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("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");
+
+ 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(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("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;
+
+ 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: "auto" },
+ "openai:gpt-4o"
+ );
+
+ 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 ec3f2d283f..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,
@@ -58,11 +59,16 @@ 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 } 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";
+import type { StreamEndEvent, StreamStartEvent } from "@/common/types/stream";
import { CompactionHandler } from "./compactionHandler";
import type { TelemetryService } from "./telemetryService";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
@@ -105,6 +111,18 @@ interface CompactionRequestMetadata {
};
}
+interface SwitchAgentResult {
+ agentId: string;
+ reason?: string;
+ followUp?: string;
+}
+
+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 {
@@ -186,6 +204,15 @@ 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;
+
private idleWaiters: Array<() => void> = [];
private readonly messageQueue = new MessageQueue();
private readonly compactionHandler: CompactionHandler;
@@ -771,6 +798,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;
@@ -1197,6 +1229,61 @@ export class AgentSession {
return Ok(undefined);
}
+ private async syncAgentSwitchingForResolvedAgent(
+ requestedAgentId: string | undefined,
+ resolvedAgentId: string | undefined
+ ): Promise {
+ assert(
+ typeof requestedAgentId === "string" || requestedAgentId === undefined,
+ "syncAgentSwitchingForResolvedAgent requestedAgentId must be string|undefined"
+ );
+ assert(
+ typeof resolvedAgentId === "string" || resolvedAgentId === undefined,
+ "syncAgentSwitchingForResolvedAgent resolvedAgentId must be string|undefined"
+ );
+
+ 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;
+ }
+
+ try {
+ const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId);
+ if (!metadataResult.success) {
+ 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 === shouldEnableAgentSwitching) {
+ return;
+ }
+
+ await this.config.updateWorkspaceMetadata(this.workspaceId, {
+ 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),
+ });
+ }
+ }
+
private async streamWithHistory(
modelString: string,
options?: SendMessageOptions,
@@ -1964,6 +2051,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;
+ }
+
+ this.pendingAgentSwitchingSync = this.syncAgentSwitchingForResolvedAgent(
+ this.activeStreamContext?.options?.agentId,
+ streamStartPayload.agentId
+ );
});
forward("stream-delta", (payload) => {
this.activeStreamHadAnyDelta = true;
@@ -2024,6 +2123,7 @@ export class AgentSession {
if (hadCompactionRequest && !this.disposed) {
this.clearQueue();
}
+ this.pendingAgentSwitchingSync = undefined;
this.emitChatEvent(payload);
this.setTurnPhase(TurnPhase.IDLE);
});
@@ -2032,10 +2132,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);
@@ -2069,6 +2172,24 @@ 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(
+ 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) {
@@ -2093,7 +2214,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);
@@ -2286,6 +2408,342 @@ 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,
+ };
+ }
+
+ private async isAgentSwitchTargetValid(
+ agentId: string,
+ disableWorkspaceAgents?: boolean
+ ): 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);
+
+ // 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,
+ discoveryPath,
+ 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.
+ // Mirrors the same logic in agents.list (src/node/orpc/router.ts).
+ 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;
+ }
+
+ // 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", {
+ workspaceId: this.workspaceId,
+ targetAgentId: parsedAgentId.data,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return false;
+ }
+ }
+
+ 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,
+ 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;
+ }
+
+ let targetAgentId = switchResult.agentId;
+
+ const targetValid = await this.isAgentSwitchTargetValid(
+ targetAgentId,
+ currentOptions?.disableWorkspaceAgents
+ );
+ if (!targetValid) {
+ 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 =
+ targetAgentId === switchResult.agentId
+ ? trimmedFollowUp != null && trimmedFollowUp.length > 0
+ ? trimmedFollowUp
+ : "Continue."
+ : this.buildAgentSwitchFallbackFollowUp(switchResult);
+ const normalizedOptionModel = currentOptions?.model?.trim();
+ const effectiveModel =
+ normalizedOptionModel && normalizedOptionModel.length > 0
+ ? 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 = {
+ model: effectiveModel,
+ agentId: targetAgentId,
+ // 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,
+ }),
+ ...(currentOptions?.disableWorkspaceAgents != null && {
+ disableWorkspaceAgents: currentOptions.disableWorkspaceAgents,
+ }),
+ ...(currentOptions?.toolPolicy != null && { toolPolicy: currentOptions.toolPolicy }),
+ ...(currentOptions?.additionalSystemInstructions != null && {
+ additionalSystemInstructions: currentOptions.additionalSystemInstructions,
+ }),
+ skipAiSettingsPersistence: true,
+ };
+
+ const sendResult = await this.sendMessage(followUpText, followUpOptions, {
+ synthetic: true,
+ });
+
+ if (!sendResult.success) {
+ log.warn("Failed to dispatch switch_agent follow-up", {
+ workspaceId: this.workspaceId,
+ requestedTargetAgentId: switchResult.agentId,
+ dispatchedTargetAgentId: targetAgentId,
+ 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/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,
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/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) {
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,
+ });
+ });
+});
diff --git a/src/node/services/tools/switch_agent.ts b/src/node/services/tools/switch_agent.ts
new file mode 100644
index 0000000000..f7cf1a5197
--- /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: (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,
+ };
+ },
+ });
+};