From f0b01572a84bd3d773ff52101e243675d0e3c341 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 27 May 2026 16:09:56 -0500 Subject: [PATCH 1/5] docs: rewrite agents reference page Accurate map of the Agent system: frontmatter fields, base resolution, tool-policy layering, runtime restrictions. Fixes the wrong agent picker keybind and documents previously-undocumented fields (top-level disabled, ui.routable, ui.requires, subagent.append_prompt, tools.require, regex anchoring, switch_agent literal opt-in, plan-mode file-edit restriction). --- docs/agents/index.mdx | 243 +++++++++-------- src/node/builtinSkills/mux-docs.md | 2 +- .../builtInSkillContent.generated.ts | 245 ++++++++++-------- 3 files changed, 274 insertions(+), 216 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index 7db176d563..da513b855f 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -1,30 +1,25 @@ --- title: Agents -description: Define custom agents (modes + subagents) with Markdown files +description: Define custom agents (modes + subagents) as Markdown files --- ## Overview -Mux uses **agents** to control the model's: +An **agent** in Mux owns two things for a given turn: -- **System prompt** (what the assistant "is") -- **Tool access policy** (which tools it can call) +- **System prompt** — what the assistant is and how it should behave +- **Tool policy** — which tools it can call (and which it must call) -This unifies two older concepts: +The same definition can be used in two places: -- **UI modes** (Plan/Exec/Compact) -- **Subagents** (the presets used by the `task` tool) +- **Selected in the UI** as the workspace's current mode (Exec, Plan, or your own) +- **Spawned via the `task` tool** as a subagent in a child workspace -An **Agent Definition** is a Markdown file: - -- The **YAML frontmatter** defines metadata + policy. -- The **Markdown body** becomes the agent's system prompt (layered with Mux's base prelude). +An agent definition is a Markdown file: YAML frontmatter declares metadata, policy, and AI defaults; the body becomes the agent's instruction prompt. ## Quick Start -**Switch agents:** Press `Cmd+Shift+M` (Mac) or `Ctrl+Shift+M` (Windows/Linux), or use the agent selector in the chat input. - -**Create a custom agent:** Add a markdown file with YAML frontmatter to `.mux/agents/` in your project: +Drop a Markdown file in `.mux/agents/` (project) or `~/.mux/agents/` (global): ```md --- @@ -32,7 +27,6 @@ name: Review description: Terse reviewer-style feedback base: exec tools: - # Remove editing tools from exec base (this is a read-only reviewer) remove: - file_edit_.* - task @@ -45,9 +39,13 @@ You are a code reviewer. - Prefer short, actionable comments. ``` -## Discovery + Precedence +The filename (without `.md`) is the **agent id**, used by `base:` and `task({ agentId })`. IDs must match `^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$` (1–64 chars, lowercase). + +**Switch agents in the UI:** `Cmd/Ctrl+Shift+A` opens the agent picker; `Cmd/Ctrl+.` cycles through visible agents. -Mux discovers agent definitions from (non-recursive): +## Discovery and Precedence + +Mux scans three roots, **non-recursive** (only direct `.md` children): | Location | Scope | Priority | | -------------------- | ------- | -------- | @@ -55,83 +53,143 @@ Mux discovers agent definitions from (non-recursive): | `~/.mux/agents/*.md` | Global | Medium | | Built-in | System | Lowest | -Higher-priority definitions override lower-priority ones with the same **agent id**. - -### Agent IDs - -The **agent id** is derived from the filename: +Higher-priority definitions **override** lower-priority ones with the same id. -- `review.md` → `agentId = "review"` - -Agent ids are lowercase and should be simple (letters/numbers with `-`/`_`). +Definitions larger than 1 MB are rejected at parse time. Filenames that don't match the id schema are skipped with a warning. ## File Format -### Frontmatter Schema +### Frontmatter ```yaml --- -# Required -name: My Agent # Display name in UI +# Identity +name: My Agent # Required. Display name. +description: What this does # Optional. Shown in tooltips and tool descriptions. -# Optional -description: What this agent does # Shown in tooltips -base: exec # Inherit from another agent (exec, plan, or custom agent id) +# Inheritance +base: exec # Optional. Inherit from another agent (built-in or custom). -# UI settings -ui: - hidden: false # Set true to hide from agent selector - disabled: false # Set true to completely disable (useful to hide built-ins) - color: "#6b5bff" # UI accent color (inherited from base if not set) +# Kill switch +disabled: false # Optional. When true, fully excludes this definition. -# Prompt behavior +# UI +ui: + hidden: false # Hide from the agent picker. + routable: false # Allow switch_agent to route here even when hidden. + requires: # Capability gates: omit unless needed. + - desktop # "desktop" | "plan" + color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset. + +# System prompt. +# When prompt.append is false, the child body REPLACES the base body +# (tools, AI defaults, and other frontmatter still inherit). prompt: - append: true # Append body to base agent's body (default); set false to replace + append: true # Default. -# Subagent configuration +# Subagent behavior (consulted only when spawned via the task tool). +# skip_init_hook only skips the .mux/init hook; runtime provisioning +# (SSH sync, Docker setup) still runs. subagent: - runnable: false # Allow spawning via task({ agentId: ... }) - skip_init_hook: false # When true, skip the project's .mux/init hook for this sub-agent + runnable: false # Required for task({ agentId: ... }) to spawn this. + skip_init_hook: false + append_prompt: | # Appended to the prompt ONLY when running as a subagent. + Extra instructions only the subagent should see. -# AI defaults (override user settings) +# Per-agent AI defaults (overridable by user settings) ai: - model: sonnet # Or full ID like "anthropic:claude-sonnet-4-5" - thinkingLevel: medium + model: sonnet # Abbreviation or full ID like "anthropic:claude-sonnet-4-5". + thinkingLevel: medium # "off" | "low" | "medium" | "high" -# Tool configuration (regex patterns, processed in order during inheritance) +# Tool policy tools: - add: # Patterns to add/enable + add: # Regex patterns to enable. - file_read - file_edit_.* - - bash - remove: # Patterns to remove/disable (applied after add) + remove: # Regex patterns to disable. Applied after add in the same layer. - task_.* + require: # Literal tool name (no regex). Forces the model to call it this turn. + - propose_plan # Last entry wins; child overrides base. --- ``` -### Markdown Body (Instructions) +### Markdown body + +The body becomes the agent's instruction prompt, layered with Mux's base prelude, the workspace environment context, and `AGENTS.md`. + +- With `prompt.append: true` (default), a child's body is **appended** to its base's body, separated by a blank line. +- With `prompt.append: false`, the child's body **replaces** the base's body. Tool policy, AI defaults, and other frontmatter still inherit normally. + +## Inheritance and Tool Policy + +### Base resolution + +`base: ` walks the same discovery roots, with two non-obvious rules: + +- **Same name as the current file** — Mux skips the current file's scope, so a project `exec.md` with `base: exec` resolves to the global or built-in `exec` (not itself). +- **Different name** — Mux anchors the lookup at the current scope or lower. A built-in's `base: foo` cannot pull in a project-level `foo.md`. + +The chain is followed up to 10 levels deep. Cycles are detected and logged. + +### Tool patterns are anchored regex + +Patterns in `tools.add` and `tools.remove` are regular expressions implicitly anchored as `^pattern$`. `task_.*` matches `task_await` but **not** `task` — list `task` separately if you need both. + +If no agent in the resolved chain declares any `tools`, the agent has no tools. + +### Layer ordering + +For each layer in the chain, from **base → child**: -The markdown body after the frontmatter becomes the agent's system prompt, layered with Mux's base prelude. +1. Apply every `tools.add` pattern as enable. +2. Apply every `tools.remove` pattern as disable. +3. If `tools.require` is present, the **last literal entry** becomes that layer's effective required tool; child layers fully replace base layers. -**Inheritance behavior:** By default, when an agent has a `base`, the child's body is **appended** to the base agent's body. Set `prompt.append: false` to **replace** the base body entirely—useful when you want to completely override the base agent's instructions while keeping its tool policies or AI defaults. +Later rules win, so a child's `remove` can drop something the base enabled, and a child's `add` can re-enable something the base removed. -## Disabling Built-in Agents +### `tools.require` semantics -To hide a built-in agent, create a file with the same name and `ui.disabled: true`: +`require` is not the same as `add`: + +- It enables the tool **and forces the model to emit a call** to it this turn. +- The value must be a literal tool name. Regex metacharacters cause the entry to be dropped silently. +- Only one tool can be required per turn. Multiple entries collapse to the last one; child layers fully override the base layer. +- The required tool must be one the agent is otherwise allowed to call — subagent hard-denies still apply. + +### Runtime restrictions + +These rules are applied last and cannot be overridden by frontmatter: + +| Condition | Effect | +| ----------------------------------- | ----------------------------------------------------------------------- | +| Always | `switch_agent` is disabled unless the chain explicitly opts in (below). | +| Subagent workspace | `ask_user_question` and `switch_agent` are disabled. | +| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. | +| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. | +| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. | +| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. | +| Plan agent editing files | `file_edit_*` is restricted to the plan file path. | + +A chain is "plan-like" when the resolved tool policy enables `propose_plan`. + +### Enabling `switch_agent` + +`switch_agent` is opt-in even when allowed by a broad pattern. The top-level (non-subagent) chain must contain the **literal** string `"switch_agent"` in `tools.add` or `tools.require`, and must not remove it later. Broad patterns like `.*` do not count as opt-in. + +## Disabling and Extending Built-ins + +**Disable** a built-in by creating a same-name file with `disabled: true`: ```md --- name: Plan -ui: - disabled: true +disabled: true --- ``` -This completely removes the agent from discovery. To override (replace) a built-in instead, omit `disabled` and provide your own configuration. - -## Extending Built-in Agents +`exec`, `plan`, and `compact` are always enabled and cannot be disabled this way. -You can extend a built-in agent by creating a file with the **same name** and using `base` to inherit from it: +**Extend** a built-in by giving your file the same id and using `base`: ```md --- @@ -139,41 +197,20 @@ name: Exec base: exec --- -Additional project-specific instructions that append to built-in exec. +Additional project-specific instructions appended to built-in exec. ``` -This works because when resolving `base: exec`, Mux skips the current scope (project) and looks for `exec` in lower-priority scopes (global, then built-in). Your project-local `exec.md` extends the built-in exec, not itself. - -**Common pattern:** Add repo-specific guidance (CI commands, test patterns) without duplicating the built-in instructions. - -## Tool Policy Semantics - -Tools are controlled via an explicit **whitelist**. The `tools` array lists patterns (exact names or regex) that the agent can use. If `tools` is omitted or empty, no tools are available. - -**Inheritance:** Use `base` to inherit behavior from another agent: - -- `base: plan` — Plan-mode behaviors and built-in planning guidance (enables `ask_user_question`, `propose_plan`) -- `base: exec` — Exec-mode behaviors (standard coding workflow) -- `base: ` — Inherit from any custom agent - -Inheritance is multi-level: if `my-agent` has `base: plan`, agents inheriting from `my-agent` also get plan-like behavior. - -**Hard denies in subagents:** Even if an agent definition allows them, Mux blocks these tools in child workspaces: - -- `task`, `task_await`, `task_list`, `task_terminate` (no recursive spawning) -- `propose_plan`, `ask_user_question` (UI-only tools) +This works because same-name `base` resolution skips the current scope. Use it to add repo-specific guidance (CI commands, test patterns) without copying the built-in body. ## Using Agents -### Main Agent - -Use the agent selector in the chat input to switch agents. +### As the workspace agent -Keyboard: `Cmd+Shift+M` (mac) / `Ctrl+Shift+M` (win/linux) cycles between agents. +Switch via the agent picker in the chat input, `Cmd/Ctrl+Shift+A`, or `Cmd/Ctrl+.` to cycle. -### Subagents (task tool) +If a workspace's stored agent has been disabled or deleted, top-level workspaces silently fall back to `exec`; subagent workspaces error out instead. -Spawn a subagent workspace with: +### As a subagent via the `task` tool ```ts task({ @@ -183,22 +220,22 @@ task({ }); ``` -Only agents with `subagent.runnable: true` can be used this way. +The agent must have `subagent.runnable: true`. Subagents see the body **plus** `subagent.append_prompt`, and must complete via `agent_report` (or `propose_plan` for plan-like chains). ### Run-context AI defaults -The same agent identity can use different default model and thinking settings depending on how it runs: +Each agent has two independent default slots, configured in **Settings → Agents**: -- **UI defaults** (`agentAiDefaults`) apply when you select the agent directly in the UI, such as choosing Exec in the chat input. -- **Subagent defaults** (`subagentAiDefaults`) apply when that agent is spawned through the `task` tool. +- **UI defaults** (`agentAiDefaults`) — used when you select the agent in the workspace. +- **Subagent defaults** (`subagentAiDefaults`) — used when the agent is spawned via `task`. -Subagent defaults inherit from UI defaults per field. If the subagent model is unset, Mux uses the matching UI agent model; if subagent thinking is unset, Mux uses the matching UI agent thinking level. You can override one subagent field and keep the other inherited. +Subagent defaults inherit per field from UI defaults; UI defaults inherit per field from the agent's frontmatter `ai` block. You can override one field (e.g. only `thinkingLevel`) and leave the other inherited. -Mux resolves the subagent model and thinking level when the `task` call creates the child workspace. Those resolved values are stored with that child workspace, so changing defaults later affects future subagent tasks only. +Subagent settings are resolved at task creation and frozen on the child workspace. Changing defaults later only affects future spawns. ## Examples -### Security Audit Agent +### Security audit (read-only) ```md --- @@ -206,24 +243,18 @@ name: Security Audit description: Security-focused code review base: exec tools: - # Remove editing/task tools - this is read-only analysis remove: - file_edit_.* - task - task_.* --- -You are a security auditor. Analyze the codebase for: - -- Authentication/authorization issues -- Injection vulnerabilities -- Data exposure risks -- Insecure dependencies - -Provide a structured report with severity levels. Do not make changes. +You are a security auditor. Analyze the codebase for authentication, +injection, data exposure, and dependency risks. Provide a structured +report with severity levels. Do not make changes. ``` -### Documentation Agent +### Documentation-only ```md --- @@ -231,15 +262,13 @@ name: Docs description: Focus on documentation tasks base: exec tools: - # Remove task delegation - keep it simple for doc tasks remove: - task - task_.* --- -You are in Documentation mode. Focus on improving documentation: -README files, code comments, API docs, and guides. Avoid -refactoring code unless it's purely for documentation purposes. +You are in Documentation mode. Improve READMEs, code comments, API +docs, and guides. Avoid code refactors unless purely for documentation. ``` ## Built-in Agents diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 786ad58e5b..1092be7896 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -77,7 +77,7 @@ Use this index to find a page's: - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks - **Agents** - - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) with Markdown files + - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) as Markdown files - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index adf0183ba7..f1a56dd64c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -942,31 +942,26 @@ export const BUILTIN_SKILL_FILES: Record> = { "references/docs/agents/index.mdx": [ "---", "title: Agents", - "description: Define custom agents (modes + subagents) with Markdown files", + "description: Define custom agents (modes + subagents) as Markdown files", "---", "", "## Overview", "", - "Mux uses **agents** to control the model's:", + "An **agent** in Mux owns two things for a given turn:", "", - '- **System prompt** (what the assistant "is")', - "- **Tool access policy** (which tools it can call)", + "- **System prompt** — what the assistant is and how it should behave", + "- **Tool policy** — which tools it can call (and which it must call)", "", - "This unifies two older concepts:", + "The same definition can be used in two places:", "", - "- **UI modes** (Plan/Exec/Compact)", - "- **Subagents** (the presets used by the `task` tool)", + "- **Selected in the UI** as the workspace's current mode (Exec, Plan, or your own)", + "- **Spawned via the `task` tool** as a subagent in a child workspace", "", - "An **Agent Definition** is a Markdown file:", - "", - "- The **YAML frontmatter** defines metadata + policy.", - "- The **Markdown body** becomes the agent's system prompt (layered with Mux's base prelude).", + "An agent definition is a Markdown file: YAML frontmatter declares metadata, policy, and AI defaults; the body becomes the agent's instruction prompt.", "", "## Quick Start", "", - "**Switch agents:** Press `Cmd+Shift+M` (Mac) or `Ctrl+Shift+M` (Windows/Linux), or use the agent selector in the chat input.", - "", - "**Create a custom agent:** Add a markdown file with YAML frontmatter to `.mux/agents/` in your project:", + "Drop a Markdown file in `.mux/agents/` (project) or `~/.mux/agents/` (global):", "", "```md", "---", @@ -974,7 +969,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "description: Terse reviewer-style feedback", "base: exec", "tools:", - " # Remove editing tools from exec base (this is a read-only reviewer)", " remove:", " - file_edit_.*", " - task", @@ -987,9 +981,13 @@ export const BUILTIN_SKILL_FILES: Record> = { "- Prefer short, actionable comments.", "```", "", - "## Discovery + Precedence", + "The filename (without `.md`) is the **agent id**, used by `base:` and `task({ agentId })`. IDs must match `^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$` (1–64 chars, lowercase).", + "", + "**Switch agents in the UI:** `Cmd/Ctrl+Shift+A` opens the agent picker; `Cmd/Ctrl+.` cycles through visible agents.", "", - "Mux discovers agent definitions from (non-recursive):", + "## Discovery and Precedence", + "", + "Mux scans three roots, **non-recursive** (only direct `.md` children):", "", "| Location | Scope | Priority |", "| -------------------- | ------- | -------- |", @@ -997,83 +995,143 @@ export const BUILTIN_SKILL_FILES: Record> = { "| `~/.mux/agents/*.md` | Global | Medium |", "| Built-in | System | Lowest |", "", - "Higher-priority definitions override lower-priority ones with the same **agent id**.", - "", - "### Agent IDs", - "", - "The **agent id** is derived from the filename:", + "Higher-priority definitions **override** lower-priority ones with the same id.", "", - '- `review.md` → `agentId = "review"`', - "", - "Agent ids are lowercase and should be simple (letters/numbers with `-`/`_`).", + "Definitions larger than 1 MB are rejected at parse time. Filenames that don't match the id schema are skipped with a warning.", "", "## File Format", "", - "### Frontmatter Schema", + "### Frontmatter", "", "```yaml", "---", - "# Required", - "name: My Agent # Display name in UI", + "# Identity", + "name: My Agent # Required. Display name.", + "description: What this does # Optional. Shown in tooltips and tool descriptions.", "", - "# Optional", - "description: What this agent does # Shown in tooltips", - "base: exec # Inherit from another agent (exec, plan, or custom agent id)", + "# Inheritance", + "base: exec # Optional. Inherit from another agent (built-in or custom).", "", - "# UI settings", - "ui:", - " hidden: false # Set true to hide from agent selector", - " disabled: false # Set true to completely disable (useful to hide built-ins)", - ' color: "#6b5bff" # UI accent color (inherited from base if not set)', + "# Kill switch", + "disabled: false # Optional. When true, fully excludes this definition.", "", - "# Prompt behavior", + "# UI", + "ui:", + " hidden: false # Hide from the agent picker.", + " routable: false # Allow switch_agent to route here even when hidden.", + " requires: # Capability gates: omit unless needed.", + ' - desktop # "desktop" | "plan"', + ' color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset.', + "", + "# System prompt.", + "# When prompt.append is false, the child body REPLACES the base body", + "# (tools, AI defaults, and other frontmatter still inherit).", "prompt:", - " append: true # Append body to base agent's body (default); set false to replace", + " append: true # Default.", "", - "# Subagent configuration", + "# Subagent behavior (consulted only when spawned via the task tool).", + "# skip_init_hook only skips the .mux/init hook; runtime provisioning", + "# (SSH sync, Docker setup) still runs.", "subagent:", - " runnable: false # Allow spawning via task({ agentId: ... })", - " skip_init_hook: false # When true, skip the project's .mux/init hook for this sub-agent", + " runnable: false # Required for task({ agentId: ... }) to spawn this.", + " skip_init_hook: false", + " append_prompt: | # Appended to the prompt ONLY when running as a subagent.", + " Extra instructions only the subagent should see.", "", - "# AI defaults (override user settings)", + "# Per-agent AI defaults (overridable by user settings)", "ai:", - ' model: sonnet # Or full ID like "anthropic:claude-sonnet-4-5"', - " thinkingLevel: medium", + ' model: sonnet # Abbreviation or full ID like "anthropic:claude-sonnet-4-5".', + ' thinkingLevel: medium # "off" | "low" | "medium" | "high"', "", - "# Tool configuration (regex patterns, processed in order during inheritance)", + "# Tool policy", "tools:", - " add: # Patterns to add/enable", + " add: # Regex patterns to enable.", " - file_read", " - file_edit_.*", - " - bash", - " remove: # Patterns to remove/disable (applied after add)", + " remove: # Regex patterns to disable. Applied after add in the same layer.", " - task_.*", + " require: # Literal tool name (no regex). Forces the model to call it this turn.", + " - propose_plan # Last entry wins; child overrides base.", "---", "```", "", - "### Markdown Body (Instructions)", + "### Markdown body", + "", + "The body becomes the agent's instruction prompt, layered with Mux's base prelude, the workspace environment context, and `AGENTS.md`.", + "", + "- With `prompt.append: true` (default), a child's body is **appended** to its base's body, separated by a blank line.", + "- With `prompt.append: false`, the child's body **replaces** the base's body. Tool policy, AI defaults, and other frontmatter still inherit normally.", + "", + "## Inheritance and Tool Policy", + "", + "### Base resolution", + "", + "`base: ` walks the same discovery roots, with two non-obvious rules:", + "", + "- **Same name as the current file** — Mux skips the current file's scope, so a project `exec.md` with `base: exec` resolves to the global or built-in `exec` (not itself).", + "- **Different name** — Mux anchors the lookup at the current scope or lower. A built-in's `base: foo` cannot pull in a project-level `foo.md`.", + "", + "The chain is followed up to 10 levels deep. Cycles are detected and logged.", + "", + "### Tool patterns are anchored regex", + "", + "Patterns in `tools.add` and `tools.remove` are regular expressions implicitly anchored as `^pattern$`. `task_.*` matches `task_await` but **not** `task` — list `task` separately if you need both.", + "", + "If no agent in the resolved chain declares any `tools`, the agent has no tools.", + "", + "### Layer ordering", + "", + "For each layer in the chain, from **base → child**:", "", - "The markdown body after the frontmatter becomes the agent's system prompt, layered with Mux's base prelude.", + "1. Apply every `tools.add` pattern as enable.", + "2. Apply every `tools.remove` pattern as disable.", + "3. If `tools.require` is present, the **last literal entry** becomes that layer's effective required tool; child layers fully replace base layers.", "", - "**Inheritance behavior:** By default, when an agent has a `base`, the child's body is **appended** to the base agent's body. Set `prompt.append: false` to **replace** the base body entirely—useful when you want to completely override the base agent's instructions while keeping its tool policies or AI defaults.", + "Later rules win, so a child's `remove` can drop something the base enabled, and a child's `add` can re-enable something the base removed.", "", - "## Disabling Built-in Agents", + "### `tools.require` semantics", "", - "To hide a built-in agent, create a file with the same name and `ui.disabled: true`:", + "`require` is not the same as `add`:", + "", + "- It enables the tool **and forces the model to emit a call** to it this turn.", + "- The value must be a literal tool name. Regex metacharacters cause the entry to be dropped silently.", + "- Only one tool can be required per turn. Multiple entries collapse to the last one; child layers fully override the base layer.", + "- The required tool must be one the agent is otherwise allowed to call — subagent hard-denies still apply.", + "", + "### Runtime restrictions", + "", + "These rules are applied last and cannot be overridden by frontmatter:", + "", + "| Condition | Effect |", + "| ----------------------------------- | ----------------------------------------------------------------------- |", + "| Always | `switch_agent` is disabled unless the chain explicitly opts in (below). |", + "| Subagent workspace | `ask_user_question` and `switch_agent` are disabled. |", + "| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. |", + "| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. |", + "| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. |", + '| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. |', + "| Plan agent editing files | `file_edit_*` is restricted to the plan file path. |", + "", + 'A chain is "plan-like" when the resolved tool policy enables `propose_plan`.', + "", + "### Enabling `switch_agent`", + "", + '`switch_agent` is opt-in even when allowed by a broad pattern. The top-level (non-subagent) chain must contain the **literal** string `"switch_agent"` in `tools.add` or `tools.require`, and must not remove it later. Broad patterns like `.*` do not count as opt-in.', + "", + "## Disabling and Extending Built-ins", + "", + "**Disable** a built-in by creating a same-name file with `disabled: true`:", "", "```md", "---", "name: Plan", - "ui:", - " disabled: true", + "disabled: true", "---", "```", "", - "This completely removes the agent from discovery. To override (replace) a built-in instead, omit `disabled` and provide your own configuration.", - "", - "## Extending Built-in Agents", + "`exec`, `plan`, and `compact` are always enabled and cannot be disabled this way.", "", - "You can extend a built-in agent by creating a file with the **same name** and using `base` to inherit from it:", + "**Extend** a built-in by giving your file the same id and using `base`:", "", "```md", "---", @@ -1081,41 +1139,20 @@ export const BUILTIN_SKILL_FILES: Record> = { "base: exec", "---", "", - "Additional project-specific instructions that append to built-in exec.", + "Additional project-specific instructions appended to built-in exec.", "```", "", - "This works because when resolving `base: exec`, Mux skips the current scope (project) and looks for `exec` in lower-priority scopes (global, then built-in). Your project-local `exec.md` extends the built-in exec, not itself.", - "", - "**Common pattern:** Add repo-specific guidance (CI commands, test patterns) without duplicating the built-in instructions.", - "", - "## Tool Policy Semantics", - "", - "Tools are controlled via an explicit **whitelist**. The `tools` array lists patterns (exact names or regex) that the agent can use. If `tools` is omitted or empty, no tools are available.", - "", - "**Inheritance:** Use `base` to inherit behavior from another agent:", - "", - "- `base: plan` — Plan-mode behaviors and built-in planning guidance (enables `ask_user_question`, `propose_plan`)", - "- `base: exec` — Exec-mode behaviors (standard coding workflow)", - "- `base: ` — Inherit from any custom agent", - "", - "Inheritance is multi-level: if `my-agent` has `base: plan`, agents inheriting from `my-agent` also get plan-like behavior.", - "", - "**Hard denies in subagents:** Even if an agent definition allows them, Mux blocks these tools in child workspaces:", - "", - "- `task`, `task_await`, `task_list`, `task_terminate` (no recursive spawning)", - "- `propose_plan`, `ask_user_question` (UI-only tools)", + "This works because same-name `base` resolution skips the current scope. Use it to add repo-specific guidance (CI commands, test patterns) without copying the built-in body.", "", "## Using Agents", "", - "### Main Agent", - "", - "Use the agent selector in the chat input to switch agents.", + "### As the workspace agent", "", - "Keyboard: `Cmd+Shift+M` (mac) / `Ctrl+Shift+M` (win/linux) cycles between agents.", + "Switch via the agent picker in the chat input, `Cmd/Ctrl+Shift+A`, or `Cmd/Ctrl+.` to cycle.", "", - "### Subagents (task tool)", + "If a workspace's stored agent has been disabled or deleted, top-level workspaces silently fall back to `exec`; subagent workspaces error out instead.", "", - "Spawn a subagent workspace with:", + "### As a subagent via the `task` tool", "", "```ts", "task({", @@ -1125,22 +1162,22 @@ export const BUILTIN_SKILL_FILES: Record> = { "});", "```", "", - "Only agents with `subagent.runnable: true` can be used this way.", + "The agent must have `subagent.runnable: true`. Subagents see the body **plus** `subagent.append_prompt`, and must complete via `agent_report` (or `propose_plan` for plan-like chains).", "", "### Run-context AI defaults", "", - "The same agent identity can use different default model and thinking settings depending on how it runs:", + "Each agent has two independent default slots, configured in **Settings → Agents**:", "", - "- **UI defaults** (`agentAiDefaults`) apply when you select the agent directly in the UI, such as choosing Exec in the chat input.", - "- **Subagent defaults** (`subagentAiDefaults`) apply when that agent is spawned through the `task` tool.", + "- **UI defaults** (`agentAiDefaults`) — used when you select the agent in the workspace.", + "- **Subagent defaults** (`subagentAiDefaults`) — used when the agent is spawned via `task`.", "", - "Subagent defaults inherit from UI defaults per field. If the subagent model is unset, Mux uses the matching UI agent model; if subagent thinking is unset, Mux uses the matching UI agent thinking level. You can override one subagent field and keep the other inherited.", + "Subagent defaults inherit per field from UI defaults; UI defaults inherit per field from the agent's frontmatter `ai` block. You can override one field (e.g. only `thinkingLevel`) and leave the other inherited.", "", - "Mux resolves the subagent model and thinking level when the `task` call creates the child workspace. Those resolved values are stored with that child workspace, so changing defaults later affects future subagent tasks only.", + "Subagent settings are resolved at task creation and frozen on the child workspace. Changing defaults later only affects future spawns.", "", "## Examples", "", - "### Security Audit Agent", + "### Security audit (read-only)", "", "```md", "---", @@ -1148,24 +1185,18 @@ export const BUILTIN_SKILL_FILES: Record> = { "description: Security-focused code review", "base: exec", "tools:", - " # Remove editing/task tools - this is read-only analysis", " remove:", " - file_edit_.*", " - task", " - task_.*", "---", "", - "You are a security auditor. Analyze the codebase for:", - "", - "- Authentication/authorization issues", - "- Injection vulnerabilities", - "- Data exposure risks", - "- Insecure dependencies", - "", - "Provide a structured report with severity levels. Do not make changes.", + "You are a security auditor. Analyze the codebase for authentication,", + "injection, data exposure, and dependency risks. Provide a structured", + "report with severity levels. Do not make changes.", "```", "", - "### Documentation Agent", + "### Documentation-only", "", "```md", "---", @@ -1173,15 +1204,13 @@ export const BUILTIN_SKILL_FILES: Record> = { "description: Focus on documentation tasks", "base: exec", "tools:", - " # Remove task delegation - keep it simple for doc tasks", " remove:", " - task", " - task_.*", "---", "", - "You are in Documentation mode. Focus on improving documentation:", - "README files, code comments, API docs, and guides. Avoid", - "refactoring code unless it's purely for documentation purposes.", + "You are in Documentation mode. Improve READMEs, code comments, API", + "docs, and guides. Avoid code refactors unless purely for documentation.", "```", "", "## Built-in Agents", @@ -6640,7 +6669,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment", " - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks", " - **Agents**", - " - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) with Markdown files", + " - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) as Markdown files", " - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files", " - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills", " - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution", From 2bcdc147fb446e58ce324d5c5484d875a10cf7eb Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 27 May 2026 18:04:53 -0500 Subject: [PATCH 2/5] refactor: simplify agent visibility/disabled schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed three sources of duplication in the Agent definition system: - `ui.selectable` (legacy opt-in) had been kept solely as a fallback alongside `ui.hidden` (modern opt-out). No built-in used it, no docs referenced it. The five inline resolver ladders that supported both forms collapse into one helper. - `ui.disabled` was functionally identical to top-level `disabled`, kept only because the same-name precedence rule existed. Standardize on top-level `disabled` (it gates `task()` and `switch_agent`, not just UI) and drop the dead second field. - `AgentDiscoveryEntry.disabled` was an internal flag the resolver code computed from `ui.disabled` for "debugging/logging only" — no reader in the codebase. Goes away with `ui.disabled`. Extract `resolveAgentVisibility(ui)` so the four call sites that had re-implemented the `hidden → selectable → routable` ladder inline (agentDefinitionsService, router, streamContextBuilder, agentSession, acp/configOptions) all share one definition. No user-visible behavior change: built-ins use neither `ui.selectable` nor `ui.disabled`, and top-level `disabled` semantics are preserved. --- src/browser/stories/mocks/orpc.ts | 2 +- src/common/orpc/schemas/agentDefinition.ts | 17 +-- src/node/acp/configOptions.ts | 14 +- src/node/orpc/router.ts | 11 +- .../agentDefinitionsService.ts | 123 ++++-------------- .../agentDefinitions/agentEnablement.test.ts | 17 +-- .../agentDefinitions/agentEnablement.ts | 13 +- .../agentDefinitions/agentVisibility.ts | 26 ++++ .../parseAgentDefinitionMarkdown.test.ts | 17 --- src/node/services/agentSession.ts | 19 +-- src/node/services/streamContextBuilder.ts | 11 +- 11 files changed, 81 insertions(+), 189 deletions(-) create mode 100644 src/node/services/agentDefinitions/agentVisibility.ts diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 1d4b48bd51..86381a66ac 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -931,7 +931,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl name: descriptor.name, description: descriptor.description, base: descriptor.base, - ui: { selectable: descriptor.uiSelectable }, + ui: { hidden: !descriptor.uiSelectable }, subagent: { runnable: descriptor.subagentRunnable }, ai: descriptor.aiDefaults, tools: descriptor.tools, diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 2ca11c0e88..47772e085b 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -10,15 +10,10 @@ const AgentDefinitionUiRequirementSchema = z.enum(["plan", "desktop"]); const AgentDefinitionUiSchema = z .object({ - // New: hidden is opt-out. Default: visible. + // Opt out of the agent picker. Hidden agents may still be routable + // (set `routable: true`) or runnable as subagents (set `subagent.runnable: true`). hidden: z.boolean().optional(), - // Legacy: selectable was opt-in. Keep for backwards compatibility. - selectable: z.boolean().optional(), - - // When true, completely hides this agent (useful for disabling built-ins) - disabled: z.boolean().optional(), - // UI color (CSS color value). Inherited from base agent if not specified. color: z.string().min(1).optional(), @@ -82,12 +77,8 @@ export const AgentDefinitionFrontmatterSchema = z // Inheritance: reference a built-in or custom agent ID base: AgentIdSchema.optional(), - // When true, this agent is disabled by default. - // - // Notes: - // - This is a top-level flag (separate from ui.disabled) so repos can ship agents that are - // present on disk but opt-in. - // - When both are set, `disabled` takes precedence over `ui.disabled`. + // When true, this agent is hidden from discovery — useful for shipping an + // opt-in agent or for disabling a built-in by creating a same-name override. disabled: z.boolean().optional(), // UI metadata (color, visibility, etc.) diff --git a/src/node/acp/configOptions.ts b/src/node/acp/configOptions.ts index 3f714eb24b..ed8182b8ae 100644 --- a/src/node/acp/configOptions.ts +++ b/src/node/acp/configOptions.ts @@ -6,6 +6,7 @@ import { getThinkingOptionLabel, isThinkingLevel } from "@/common/types/thinking import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; import { resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; import { getBuiltInAgentDefinitions } from "@/node/services/agentDefinitions/builtInAgentDefinitions"; +import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import type { ORPCClient } from "./serverConnection"; import { resolveAgentAiSettings, type ResolvedAiSettings } from "./resolveAgentAiSettings"; @@ -25,19 +26,10 @@ interface ExposedAgentMode { } function isUiSelectableAgentMode(frontmatter: AgentDefinitionFrontmatter): boolean { - if (frontmatter.disabled === true || frontmatter.ui?.disabled === true) { + if (frontmatter.disabled === true) { return false; } - - if (frontmatter.ui?.hidden != null) { - return !frontmatter.ui.hidden; - } - - if (frontmatter.ui?.selectable != null) { - return frontmatter.ui.selectable; - } - - return true; + return resolveAgentVisibility(frontmatter.ui).selectable; } const BUILTIN_AGENT_MODE_ORDER = getBuiltInAgentDefinitions() diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d15e4d4f7d..0d6f9cf103 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -80,6 +80,7 @@ import { resolveAgentFrontmatter, } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; +import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import { isWorkspaceArchived } from "@/common/utils/archive"; import assert from "node:assert/strict"; import * as fsPromises from "fs/promises"; @@ -1480,13 +1481,9 @@ export const router = (authToken?: string) => { return null; } - // 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; + const { selectable: uiSelectableBase } = resolveAgentVisibility( + resolvedFrontmatter.ui + ); return { kind: "resolved" as const, diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts index 82182377fc..28380fd15d 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.ts @@ -24,6 +24,7 @@ import { log } from "@/node/services/log"; import { validateFileSize } from "@/node/services/tools/fileCommon"; import { getBuiltInAgentDefinitions } from "./builtInAgentDefinitions"; +import { resolveAgentVisibility } from "./agentVisibility"; import { AgentDefinitionParseError, parseAgentDefinitionMarkdown, @@ -81,65 +82,6 @@ export function computeBaseSkipScope( const GLOBAL_AGENTS_ROOT = "~/.mux/agents"; -interface AgentDefinitionUiFlags { - hidden?: boolean; - selectable?: boolean; - disabled?: boolean; - routable?: boolean; -} - -// TODO: The visibility/routability resolution logic (hidden → selectable, routable) -// is duplicated across agentDefinitionsService.ts, agentSession.ts, -// streamContextBuilder.ts, and orpc/router.ts. Consider extracting a single -// resolveAgentVisibility(ui) → { selectable, routable, disabled } helper. -function resolveUiSelectable(ui: AgentDefinitionUiFlags | undefined): boolean { - if (!ui) { - return true; - } - - if (typeof ui.hidden === "boolean") { - return !ui.hidden; - } - - if (typeof ui.selectable === "boolean") { - return ui.selectable; - } - - return true; -} - -/** - * Resolve whether an agent can be targeted by switch_agent. - * - * Defaults to the same value as uiSelectable: visible agents are routable by - * default (a human-pickable agent should also be agent-pickable). This differs - * from subagentRunnable which defaults to false, because routing via - * switch_agent has lower impact than spawning a task sub-agent. - * - * Hidden agents must explicitly set `ui.routable: true` to be switch targets. - */ -function resolveUiRoutable(ui: AgentDefinitionUiFlags | undefined): boolean { - if (typeof ui?.routable === "boolean") { - return ui.routable; - } - - return resolveUiSelectable(ui); -} - -function resolveUiDisabled(ui: AgentDefinitionUiFlags | undefined): boolean { - return ui?.disabled === true; -} - -/** - * Internal type for tracking agent definitions during discovery. - * Includes a legacy `disabled` flag (from ui.disabled) for debugging/logging only. - * Filtering is applied at higher layers so Settings can surface opt-in agents. - */ -interface AgentDiscoveryEntry { - descriptor: AgentDefinitionDescriptor; - disabled: boolean; -} - export interface AgentDefinitionsRoots { projectRoot: string; globalRoot: string; @@ -248,12 +190,12 @@ function getAgentIdFromFilename(filename: string): AgentId | null { return idParsed.data; } -async function readAgentDescriptorFromFileWithDisabled( +async function readAgentDescriptorFromFile( runtime: Runtime, filePath: string, agentId: AgentId, scope: Exclude -): Promise { +): Promise { let stat; try { stat = await runtime.stat(filePath); @@ -282,21 +224,17 @@ async function readAgentDescriptorFromFileWithDisabled( try { const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); - const uiSelectable = resolveUiSelectable(parsed.frontmatter.ui); - const uiRoutable = resolveUiRoutable(parsed.frontmatter.ui); - const uiColor = parsed.frontmatter.ui?.color; - const subagentRunnable = parsed.frontmatter.subagent?.runnable ?? false; - const disabled = resolveUiDisabled(parsed.frontmatter.ui); + const { selectable, routable } = resolveAgentVisibility(parsed.frontmatter.ui); const descriptor: AgentDefinitionDescriptor = { id: agentId, scope, name: parsed.frontmatter.name, description: parsed.frontmatter.description, - uiSelectable, - uiRoutable, - uiColor, - subagentRunnable, + uiSelectable: selectable, + uiRoutable: routable, + uiColor: parsed.frontmatter.ui?.color, + subagentRunnable: parsed.frontmatter.subagent?.runnable ?? false, base: parsed.frontmatter.base, aiDefaults: parsed.frontmatter.ai, tools: parsed.frontmatter.tools, @@ -308,7 +246,7 @@ async function readAgentDescriptorFromFileWithDisabled( return null; } - return { descriptor: validated.data, disabled }; + return validated.data; } catch (err) { const message = err instanceof AgentDefinitionParseError ? err.message : getErrorMessage(err); log.warn(`Skipping invalid agent definition '${agentId}' (${scope}): ${message}`); @@ -327,31 +265,24 @@ export async function discoverAgentDefinitions( const roots = options?.roots ?? getDefaultAgentDefinitionsRoots(runtime, workspacePath); - const byId = new Map(); + const byId = new Map(); // Seed built-ins (lowest precedence). for (const pkg of getBuiltInAgentDefinitions()) { - const uiSelectable = resolveUiSelectable(pkg.frontmatter.ui); - const uiRoutable = resolveUiRoutable(pkg.frontmatter.ui); - const uiColor = pkg.frontmatter.ui?.color; - const subagentRunnable = pkg.frontmatter.subagent?.runnable ?? false; - const disabled = resolveUiDisabled(pkg.frontmatter.ui); + const { selectable, routable } = resolveAgentVisibility(pkg.frontmatter.ui); byId.set(pkg.id, { - descriptor: { - id: pkg.id, - scope: "built-in", - name: pkg.frontmatter.name, - description: pkg.frontmatter.description, - uiSelectable, - uiRoutable, - uiColor, - subagentRunnable, - base: pkg.frontmatter.base, - aiDefaults: pkg.frontmatter.ai, - tools: pkg.frontmatter.tools, - }, - disabled, + id: pkg.id, + scope: "built-in", + name: pkg.frontmatter.name, + description: pkg.frontmatter.description, + uiSelectable: selectable, + uiRoutable: routable, + uiColor: pkg.frontmatter.ui?.color, + subagentRunnable: pkg.frontmatter.subagent?.runnable ?? false, + base: pkg.frontmatter.base, + aiDefaults: pkg.frontmatter.ai, + tools: pkg.frontmatter.tools, }); } @@ -379,23 +310,21 @@ export async function discoverAgentDefinitions( } const filePath = scan.runtime.normalizePath(filename, resolvedRoot); - const result = await readAgentDescriptorFromFileWithDisabled( + const descriptor = await readAgentDescriptorFromFile( scan.runtime, filePath, agentId, scan.scope ); - if (!result) continue; + if (!descriptor) continue; - byId.set(agentId, result); + byId.set(agentId, descriptor); } } // Return all discovered agents (including those disabled by front-matter). // Filtering is applied at higher layers (e.g., agents.list) so Settings can still surface opt-in agents. - return Array.from(byId.values()) - .map((entry) => entry.descriptor) - .sort((a, b) => a.name.localeCompare(b.name)); + return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); } export interface ReadAgentDefinitionOptions { diff --git a/src/node/services/agentDefinitions/agentEnablement.test.ts b/src/node/services/agentDefinitions/agentEnablement.test.ts index eb3feefdf1..b0465d5ccd 100644 --- a/src/node/services/agentDefinitions/agentEnablement.test.ts +++ b/src/node/services/agentDefinitions/agentEnablement.test.ts @@ -17,23 +17,24 @@ function cfgWithOverrides(overrides: Record): Pro } describe("agentEnablement", () => { - test("disabled field takes precedence over ui.disabled", () => { + test("disabled: true reports the agent as disabled", () => { const frontmatter: AgentDefinitionFrontmatter = { name: "Test", - disabled: false, - ui: { disabled: true }, + disabled: true, }; - expect(isAgentDisabledByFrontmatter(frontmatter)).toBe(false); + expect(isAgentDisabledByFrontmatter(frontmatter)).toBe(true); }); - test("falls back to ui.disabled when disabled is unset", () => { - const frontmatter: AgentDefinitionFrontmatter = { + test("disabled: false (or unset) reports the agent as enabled", () => { + const enabledExplicit: AgentDefinitionFrontmatter = { name: "Test", - ui: { disabled: true }, + disabled: false, }; + const enabledImplicit: AgentDefinitionFrontmatter = { name: "Test" }; - expect(isAgentDisabledByFrontmatter(frontmatter)).toBe(true); + expect(isAgentDisabledByFrontmatter(enabledExplicit)).toBe(false); + expect(isAgentDisabledByFrontmatter(enabledImplicit)).toBe(false); }); test("user override enabled:true re-enables a disabled agent", () => { diff --git a/src/node/services/agentDefinitions/agentEnablement.ts b/src/node/services/agentDefinitions/agentEnablement.ts index 0b5340b85a..65fbba64a6 100644 --- a/src/node/services/agentDefinitions/agentEnablement.ts +++ b/src/node/services/agentDefinitions/agentEnablement.ts @@ -7,18 +7,7 @@ const ALWAYS_ENABLED_AGENT_IDS = new Set(["exec", "plan", "compact", "m export function isAgentDisabledByFrontmatter(frontmatter: AgentDefinitionFrontmatter): boolean { assert(frontmatter, "isAgentDisabledByFrontmatter: frontmatter is required"); - - // `disabled` is the new top-level field. - // When both are set, disabled takes precedence over ui.disabled. - if (typeof frontmatter.disabled === "boolean") { - return frontmatter.disabled; - } - - if (typeof frontmatter.ui?.disabled === "boolean") { - return frontmatter.ui.disabled; - } - - return false; + return frontmatter.disabled === true; } export function resolveAgentEnabledOverride( diff --git a/src/node/services/agentDefinitions/agentVisibility.ts b/src/node/services/agentDefinitions/agentVisibility.ts new file mode 100644 index 0000000000..a5ee6746cb --- /dev/null +++ b/src/node/services/agentDefinitions/agentVisibility.ts @@ -0,0 +1,26 @@ +import type { AgentDefinitionFrontmatter } from "@/common/types/agentDefinition"; + +/** + * Resolved visibility/routability for an agent. + * + * - `selectable` controls whether the agent appears in the human picker, the + * ACP `agentMode` option list, and `agents.list` (subject to capability gating). + * - `routable` controls whether `switch_agent` can hand off to the agent. + * Defaults to `selectable` when `ui.routable` is unset: visible agents are + * routable by default; hidden agents must opt back in via `ui.routable: true`. + * + * This is the single source of truth for the rule; do not re-implement it + * inline. Use {@link resolveAgentVisibility} everywhere these booleans are needed. + */ +export interface AgentVisibility { + selectable: boolean; + routable: boolean; +} + +export function resolveAgentVisibility( + ui: AgentDefinitionFrontmatter["ui"] | undefined +): AgentVisibility { + const selectable = ui?.hidden !== true; + const routable = typeof ui?.routable === "boolean" ? ui.routable : selectable; + return { selectable, routable }; +} diff --git a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts index 524422cfd4..ef29489447 100644 --- a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts +++ b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts @@ -47,23 +47,6 @@ Do the thing. expect(result.body).toContain("# Instructions"); }); - test("accepts legacy ui.selectable", () => { - const content = `--- -name: Legacy UI -ui: - selectable: false ---- -Body -`; - - const result = parseAgentDefinitionMarkdown({ - content, - byteSize: Buffer.byteLength(content, "utf-8"), - }); - - expect(result.frontmatter.ui?.selectable).toBe(false); - }); - test("parses ui.requires", () => { const content = `--- name: Requires Capabilities diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d47a6b295f..3fd1af251c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -84,6 +84,7 @@ import { resolveAgentFrontmatter, } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; +import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { MessageQueue } from "./messageQueue"; import { @@ -5142,21 +5143,9 @@ export class AgentSession { 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; - // An agent is routable if explicitly opted in via ui.routable, - // or if ui.routable is unset and the agent is UI-selectable. - // This means explicit ui.routable: false blocks routing even for - // visible agents. - const isRoutable = resolvedFrontmatter.ui?.routable ?? uiSelectableBase; - - if (!isRoutable) { + const { routable } = resolveAgentVisibility(resolvedFrontmatter.ui); + + if (!routable) { log.warn("switch_agent target is not routable; skipping synthetic follow-up", { workspaceId: this.workspaceId, targetAgentId: parsedAgentId.data, diff --git a/src/node/services/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 645b3abb5e..343ce0e5f5 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -40,6 +40,7 @@ import { type AgentDefinitionsRoots, } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; +import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { discoverAgentSkills, @@ -668,19 +669,13 @@ export async function discoverAvailableSubagentsForToolContext(args: { return null; } + const { routable } = resolveAgentVisibility(resolvedFrontmatter.ui); return { ...descriptor, // Important: descriptor.subagentRunnable comes from the agent's own frontmatter only. // Re-resolve with inheritance so derived agents inherit runnable: true from their base. subagentRunnable: resolvedFrontmatter.subagent?.runnable ?? false, - uiRoutable: - typeof resolvedFrontmatter.ui?.routable === "boolean" - ? resolvedFrontmatter.ui.routable - : typeof resolvedFrontmatter.ui?.hidden === "boolean" - ? !resolvedFrontmatter.ui.hidden - : typeof resolvedFrontmatter.ui?.selectable === "boolean" - ? resolvedFrontmatter.ui.selectable - : true, + uiRoutable: routable, }; } catch { // Best-effort: keep the descriptor if enablement or inheritance can't be resolved. From 5605579656cf72438f62b3bffd49253112d1245d Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 27 May 2026 18:51:11 -0500 Subject: [PATCH 3/5] refactor: remove switch_agent subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch_agent tool was originally introduced for the Auto agent (#2717) and let it dynamically hand off to other agents — most notably the hidden `mux` agent for global config edits. Auto mode was removed in #3087 and the Chat-with-Mux flow in #3123, leaving switch_agent as opt-in machinery used by exactly one built-in (desktop, as a routable target) and no built-in producer. The desktop agent is already documented to be invoked via `task` (exec.md system prompt says "delegate to the `desktop` agent via task"), and the task tool filters by `subagent.runnable`, not `ui.routable`. So removing switch_agent does not compromise the desktop workflow. Removed: - `switch_agent` tool: schema, factory, handler, tests, UI component + story, icon mapping, tool-component registry entry, oRPC schema field `ui.routable` + descriptor `uiRoutable`. - `forceToolChoice` plumbing in streamManager + aiService (the only required-tool that needed forced toolChoice was switch_agent). - `dispatchAgentSwitch` and its 9 helper methods in agentSession.ts (~400 lines), including the consecutive-switch ping-pong guard, fallback target resolver, and stream-end dispatch block. - switch_agent literal opt-in machinery in `resolveToolPolicy` (the `isExplicitSwitchAgentEnablePattern` / `matchesSwitchAgentPattern` helpers and runtime require/disable rules). - switch_agent entry in `SUBAGENT_HARD_DENIED_TOOLS` and `EXCLUDED_TOOLS` (ToolBridge). - `routable: true` on `desktop.md` and its assertion in `builtInAgentDefinitions.test.ts`. Kept: - `ui.requires` (`"plan" | "desktop"`). This gates picker visibility and — more importantly — `task`-tool subagent discovery. Without `requires: [desktop]`, the LLM could try to spawn a desktop subagent on a workspace with no desktop capability. Net: +49 / -916 lines across 32 files. All 4,090 non-integration tests pass. --- docs/agents/index.mdx | 23 +- docs/hooks/tools.mdx | 11 - .../AgentModePicker/AgentModePicker.test.tsx | 4 - src/browser/contexts/AgentContext.test.tsx | 5 - .../Settings/Sections/TasksSection.agents.ts | 6 - .../Tools/ProposePlanToolCall.test.tsx | 1 - .../features/Tools/Shared/ToolPrimitives.tsx | 2 - .../features/Tools/Shared/getToolComponent.ts | 5 - .../SwitchAgentToolCall.stories.tsx | 25 - .../features/Tools/SwitchAgentToolCall.tsx | 78 -- src/browser/stories/mocks/orpc.ts | 4 - src/common/orpc/schemas/agentDefinition.ts | 13 +- src/common/utils/tools/toolDefinitions.ts | 21 - src/common/utils/tools/tools.ts | 2 - src/node/builtinAgents/desktop.md | 1 - .../__tests__/devToolsService.test.ts | 2 +- .../agentDefinitionsService.test.ts | 2 +- .../agentDefinitionsService.ts | 6 +- .../agentDefinitions/agentVisibility.ts | 12 +- .../builtInAgentContent.generated.ts | 2 +- .../builtInAgentDefinitions.test.ts | 1 - .../parseAgentDefinitionMarkdown.test.ts | 4 +- .../resolveToolPolicy.test.ts | 163 +--- .../agentDefinitions/resolveToolPolicy.ts | 39 +- .../services/agentSession.budgetGate.test.ts | 2 +- .../services/agentSession.switchAgent.test.ts | 863 ------------------ src/node/services/agentSession.ts | 468 +--------- .../builtInSkillContent.generated.ts | 34 +- src/node/services/aiService.test.ts | 10 +- src/node/services/aiService.ts | 5 - src/node/services/ptc/toolBridge.ts | 1 - src/node/services/streamContextBuilder.ts | 3 - src/node/services/streamManager.test.ts | 47 - src/node/services/streamManager.ts | 62 +- src/node/services/tools/switch_agent.test.ts | 180 ---- src/node/services/tools/switch_agent.ts | 42 - tests/ipc/acp.configOptions.test.ts | 4 - 37 files changed, 49 insertions(+), 2104 deletions(-) delete mode 100644 src/browser/features/Tools/SwitchAgent/SwitchAgentToolCall.stories.tsx delete mode 100644 src/browser/features/Tools/SwitchAgentToolCall.tsx delete mode 100644 src/node/services/agentSession.switchAgent.test.ts delete mode 100644 src/node/services/tools/switch_agent.test.ts delete mode 100644 src/node/services/tools/switch_agent.ts diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index da513b855f..62a1c07ac8 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -76,7 +76,6 @@ disabled: false # Optional. When true, fully excludes this definition. # UI ui: hidden: false # Hide from the agent picker. - routable: false # Allow switch_agent to route here even when hidden. requires: # Capability gates: omit unless needed. - desktop # "desktop" | "plan" color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset. @@ -160,22 +159,17 @@ Later rules win, so a child's `remove` can drop something the base enabled, and These rules are applied last and cannot be overridden by frontmatter: -| Condition | Effect | -| ----------------------------------- | ----------------------------------------------------------------------- | -| Always | `switch_agent` is disabled unless the chain explicitly opts in (below). | -| Subagent workspace | `ask_user_question` and `switch_agent` are disabled. | -| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. | -| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. | -| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. | -| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. | -| Plan agent editing files | `file_edit_*` is restricted to the plan file path. | +| Condition | Effect | +| ----------------------------------- | ------------------------------------------------------- | +| Subagent workspace | `ask_user_question` is disabled. | +| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. | +| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. | +| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. | +| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. | +| Plan agent editing files | `file_edit_*` is restricted to the plan file path. | A chain is "plan-like" when the resolved tool policy enables `propose_plan`. -### Enabling `switch_agent` - -`switch_agent` is opt-in even when allowed by a broad pattern. The top-level (non-subagent) chain must contain the **literal** string `"switch_agent"` in `tools.add` or `tools.require`, and must not remove it later. Broad patterns like `.*` do not count as opt-in. - ## Disabling and Extending Built-ins **Disable** a built-in by creating a same-name file with `disabled: true`: @@ -479,7 +473,6 @@ description: Visual desktop automation agent for GUI-heavy, screenshot-intensive base: exec ui: hidden: true - routable: true requires: - desktop subagent: diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index dfa40b8dff..286c25ff03 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -621,17 +621,6 @@ 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 | — | - -
-
task (8) diff --git a/src/browser/components/AgentModePicker/AgentModePicker.test.tsx b/src/browser/components/AgentModePicker/AgentModePicker.test.tsx index 3ad0332186..9a9a156749 100644 --- a/src/browser/components/AgentModePicker/AgentModePicker.test.tsx +++ b/src/browser/components/AgentModePicker/AgentModePicker.test.tsx @@ -14,7 +14,6 @@ const BUILT_INS: AgentDefinitionDescriptor[] = [ scope: "built-in", name: "Exec", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }, { @@ -22,7 +21,6 @@ const BUILT_INS: AgentDefinitionDescriptor[] = [ scope: "built-in", name: "Plan", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, base: "plan", }, @@ -33,7 +31,6 @@ const HIDDEN_AGENT: AgentDefinitionDescriptor = { scope: "built-in", name: "Explore", uiSelectable: false, - uiRoutable: false, subagentRunnable: true, base: "exec", }; @@ -43,7 +40,6 @@ const CUSTOM_AGENT: AgentDefinitionDescriptor = { name: "Review", description: "Review changes", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }; diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 55d5b3b818..03e48b84c5 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -104,7 +104,6 @@ const EXEC_AGENT: AgentDefinitionDescriptor = { scope: "built-in", name: "Exec", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }; @@ -113,7 +112,6 @@ const PLAN_AGENT: AgentDefinitionDescriptor = { scope: "built-in", name: "Plan", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }; @@ -122,7 +120,6 @@ const AUTO_PROJECT_AGENT: AgentDefinitionDescriptor = { scope: "project", name: "Auto", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }; @@ -131,7 +128,6 @@ const REVIEW_PROJECT_AGENT: AgentDefinitionDescriptor = { scope: "project", name: "Review", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, }; @@ -140,7 +136,6 @@ const LOCKED_AGENT: AgentDefinitionDescriptor = { scope: "built-in", name: "Locked Agent", uiSelectable: false, - uiRoutable: true, subagentRunnable: false, }; diff --git a/src/browser/features/Settings/Sections/TasksSection.agents.ts b/src/browser/features/Settings/Sections/TasksSection.agents.ts index b7c6217fa8..a4d9cdf29b 100644 --- a/src/browser/features/Settings/Sections/TasksSection.agents.ts +++ b/src/browser/features/Settings/Sections/TasksSection.agents.ts @@ -11,7 +11,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Plan", description: "Create a plan before coding", uiSelectable: true, - uiRoutable: true, subagentRunnable: true, base: "plan", }, @@ -21,7 +20,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Exec", description: "Implement changes in the repository", uiSelectable: true, - uiRoutable: true, subagentRunnable: true, }, { @@ -30,7 +28,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Compact", description: "History compaction (internal)", uiSelectable: false, - uiRoutable: false, subagentRunnable: false, }, { @@ -39,7 +36,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Desktop", description: "Visual desktop automation agent for GUI-heavy, screenshot-intensive workflows", uiSelectable: false, - uiRoutable: true, subagentRunnable: true, base: "exec", aiDefaults: { @@ -75,7 +71,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Explore", description: "Read-only repository exploration", uiSelectable: false, - uiRoutable: false, subagentRunnable: true, base: "exec", }, @@ -85,7 +80,6 @@ export const FALLBACK_AGENTS: AgentDefinitionDescriptor[] = [ name: "Name Workspace", description: "Generate workspace name and title from user message", uiSelectable: false, - uiRoutable: false, subagentRunnable: false, tools: { require: ["propose_name"], diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index a7748e6288..b6ada6b507 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -177,7 +177,6 @@ function createTestAgent( name, scope: "built-in", uiSelectable: true, - uiRoutable: true, subagentRunnable: true, aiDefaults: { model, thinkingLevel }, }; diff --git a/src/browser/features/Tools/Shared/ToolPrimitives.tsx b/src/browser/features/Tools/Shared/ToolPrimitives.tsx index ddf62bfbc9..b3ce53eea9 100644 --- a/src/browser/features/Tools/Shared/ToolPrimitives.tsx +++ b/src/browser/features/Tools/Shared/ToolPrimitives.tsx @@ -7,7 +7,6 @@ import { import type { LucideIcon } from "lucide-react"; import { ArrowDownUp, - ArrowRightLeft, Bell, BookOpen, CircleCheck, @@ -259,7 +258,6 @@ export const TOOL_NAME_TO_ICON: Partial> = { file_edit_replace_string: Pencil, file_edit_replace_lines: Pencil, todo_write: List, - switch_agent: ArrowRightLeft, web_fetch: Globe, web_search: Globe, notify: Bell, diff --git a/src/browser/features/Tools/Shared/getToolComponent.ts b/src/browser/features/Tools/Shared/getToolComponent.ts index 315501eee5..178c231a78 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.ts @@ -25,7 +25,6 @@ import { AskUserQuestionToolCall } from "../AskUserQuestionToolCall"; import { ProposePlanToolCall } from "../ProposePlanToolCall"; import { TodoToolCall } from "../TodoToolCall"; import { StatusSetToolCall } from "../StatusSetToolCall"; -import { SwitchAgentToolCall } from "../SwitchAgentToolCall"; import { NotifyToolCall } from "../NotifyToolCall"; import { ReviewPaneUpdateToolCall } from "../ReviewPaneUpdateToolCall"; import { ReviewPaneGetToolCall } from "../ReviewPaneGetToolCall"; @@ -134,10 +133,6 @@ const TOOL_REGISTRY: Record = { todo_write: { component: TodoToolCall, schema: TOOL_DEFINITIONS.todo_write.schema }, // Legacy-only transcript renderer for historical status_set calls. status_set: { component: StatusSetToolCall, schema: legacyStatusSetSchema }, - switch_agent: { - component: SwitchAgentToolCall, - schema: TOOL_DEFINITIONS.switch_agent.schema, - }, notify: { component: NotifyToolCall, schema: TOOL_DEFINITIONS.notify.schema }, analytics_query: { component: AnalyticsQueryToolCall, diff --git a/src/browser/features/Tools/SwitchAgent/SwitchAgentToolCall.stories.tsx b/src/browser/features/Tools/SwitchAgent/SwitchAgentToolCall.stories.tsx deleted file mode 100644 index abb7bd1110..0000000000 --- a/src/browser/features/Tools/SwitchAgent/SwitchAgentToolCall.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SwitchAgentToolCall } from "@/browser/features/Tools/SwitchAgentToolCall"; -import { lightweightMeta } from "@/browser/stories/meta.js"; - -const meta = { - ...lightweightMeta, - title: "App/Chat/Tools/SwitchAgent", - component: SwitchAgentToolCall, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -/** switch_agent tool call rendered with custom handoff card UI */ -export const SwitchAgentHandoff: Story = { - args: { - args: { - agentId: "plan", - reason: "This requires a scoped rollout plan with risk assessment before making code edits.", - followUp: "Draft a migration plan that lists dependencies, sequencing, and rollback steps.", - }, - status: "completed", - }, -}; diff --git a/src/browser/features/Tools/SwitchAgentToolCall.tsx b/src/browser/features/Tools/SwitchAgentToolCall.tsx deleted file mode 100644 index a25d9eb970..0000000000 --- a/src/browser/features/Tools/SwitchAgentToolCall.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { formatAgentIdLabel } from "@/browser/components/AgentModePicker/AgentModePicker"; -import { - ToolContainer, - ToolHeader, - ExpandIcon, - StatusIndicator, - ToolDetails, - DetailSection, - DetailLabel, - DetailContent, - ToolIcon, -} from "./Shared/ToolPrimitives"; -import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./Shared/toolUtils"; - -interface SwitchAgentToolCallProps { - args: { - agentId: string; - reason?: string | null; - followUp?: string | null; - }; - status?: ToolStatus; -} - -function getAgentTextColorClass(agentId: string): string { - switch (agentId) { - case "plan": - case "explore": - return "text-plan-mode"; - case "exec": - return "text-exec-mode"; - case "ask": - return "text-ask-mode"; - default: - return "text-muted-foreground"; - } -} - -export const SwitchAgentToolCall: React.FC = (props) => { - const { expanded, toggleExpanded } = useToolExpansion(false); - const status = props.status ?? "pending"; - const hasReason = typeof props.args.reason === "string" && props.args.reason.trim().length > 0; - const statusDisplay = getStatusDisplay(status); - const targetAgentLabel = formatAgentIdLabel(props.args.agentId); - const targetAgentColorClass = getAgentTextColorClass(props.args.agentId); - const handoffLabel = - status === "completed" - ? "Switched to" - : status === "executing" || status === "pending" - ? "Delegating to" - : "Switch to"; - - // followUp is intentionally omitted from this tool card because it is - // already injected as the synthetic follow-up message in the transcript. - return ( - - - {hasReason && } - - - {handoffLabel}: {targetAgentLabel} - - {statusDisplay} - - - {expanded && hasReason && ( - - - Reason - - {props.args.reason} - - - - )} - - ); -}; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 86381a66ac..5a611070ed 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -458,7 +458,6 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl name: "Plan", description: "Create a plan before coding", uiSelectable: true, - uiRoutable: true, subagentRunnable: false, base: "plan", uiColor: "var(--color-plan-mode)", @@ -469,7 +468,6 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl name: "Exec", description: "Implement changes in the repository", uiSelectable: true, - uiRoutable: true, subagentRunnable: true, uiColor: "var(--color-exec-mode)", }, @@ -479,7 +477,6 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl name: "Compact", description: "History compaction (internal)", uiSelectable: false, - uiRoutable: false, subagentRunnable: false, }, { @@ -488,7 +485,6 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl name: "Explore", description: "Read-only repository exploration", uiSelectable: false, - uiRoutable: false, subagentRunnable: true, base: "exec", }, diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 47772e085b..661abfaec8 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -10,19 +10,15 @@ const AgentDefinitionUiRequirementSchema = z.enum(["plan", "desktop"]); const AgentDefinitionUiSchema = z .object({ - // Opt out of the agent picker. Hidden agents may still be routable - // (set `routable: true`) or runnable as subagents (set `subagent.runnable: true`). + // Opt out of the agent picker. Hidden agents can still run as subagents + // (set `subagent.runnable: true`). hidden: z.boolean().optional(), // UI color (CSS color value). Inherited from base agent if not specified. color: z.string().min(1).optional(), - // When true, this agent is eligible for switch_agent routing even when hidden. - // Defaults to the same policy as uiSelectable when omitted. - routable: z.boolean().optional(), - - // Requirements for this agent to be selectable in the UI. - // Enforced in agents.list by toggling uiSelectable. + // Capability requirements. Enforced by `agents.list` (toggles uiSelectable) + // and by the task tool's subagent discovery (filters out unavailable agents). requires: z.array(AgentDefinitionUiRequirementSchema).min(1).optional(), }) .strip(); @@ -104,7 +100,6 @@ export const AgentDefinitionDescriptorSchema = z name: z.string().min(1).max(128), description: z.string().min(1).max(1024).optional(), uiSelectable: z.boolean(), - uiRoutable: z.boolean(), uiColor: z.string().min(1).optional(), subagentRunnable: z.boolean(), // Base agent ID for inheritance (e.g., "exec", "plan", or custom agent) diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 85ed235008..2041960c33 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -770,18 +770,6 @@ 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().nullish(), - }) - .strict(); - export const AgentReportToolResultSchema = z.object({ success: z.literal(true) }).strict(); const FILE_TOOL_PATH = z .string() @@ -1502,14 +1490,6 @@ 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 agents listed below can be targeted. " + - "The current stream will end and a new stream will start with the selected agent.", - schema: SwitchAgentToolArgsSchema, - }, - get_goal: { description: "Read the current workspace goal. Returns null when no goal is available in this turn.", @@ -2350,7 +2330,6 @@ export function getAvailableTools( "task_terminate", "task_list", ...(enableAgentReport ? ["agent_report"] : []), - "switch_agent", "get_goal", "complete_goal", "todo_write", diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index b5400f1c2d..896445def9 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -43,7 +43,6 @@ import { createMuxAgentsWriteTool } from "@/node/services/tools/mux_agents_write import { createMuxConfigReadTool } from "@/node/services/tools/mux_config_read"; import { createMuxConfigWriteTool } from "@/node/services/tools/mux_config_write"; import { createAgentReportTool } from "@/node/services/tools/agent_report"; -import { createSwitchAgentTool } from "@/node/services/tools/switch_agent"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { withHooks, type HookConfig } from "@/node/services/tools/withHooks"; import { log } from "@/node/services/log"; @@ -485,7 +484,6 @@ export async function getToolsForModel( ...(config.goalService && config.enableGoalTools?.completeGoal ? { complete_goal: createCompleteGoalTool(config) } : {}), - switch_agent: createSwitchAgentTool(config), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), review_pane_update: createReviewPaneUpdateTool(config), diff --git a/src/node/builtinAgents/desktop.md b/src/node/builtinAgents/desktop.md index 8f5ce70bd6..ad25ac1353 100644 --- a/src/node/builtinAgents/desktop.md +++ b/src/node/builtinAgents/desktop.md @@ -4,7 +4,6 @@ description: Visual desktop automation agent for GUI-heavy, screenshot-intensive base: exec ui: hidden: true - routable: true requires: - desktop subagent: diff --git a/src/node/services/__tests__/devToolsService.test.ts b/src/node/services/__tests__/devToolsService.test.ts index 524c6d49bb..81cffbaefb 100644 --- a/src/node/services/__tests__/devToolsService.test.ts +++ b/src/node/services/__tests__/devToolsService.test.ts @@ -223,7 +223,7 @@ describe("DevToolsService", () => { it("stores pending metadata per metadata id for overlapping requests", async () => { const service = new DevToolsService(createTestConfig({ sessionsDir, enabled: true })); const policyA = [{ regex_match: "propose_plan", action: "require" as const }]; - const policyB = [{ regex_match: "switch_agent", action: "disable" as const }]; + const policyB = [{ regex_match: "task_.*", action: "disable" as const }]; service.setPendingRunMetadata("ws-1", "metadata-a", { toolPolicy: policyA }); service.setPendingRunMetadata("ws-1", "metadata-b", { toolPolicy: policyB }); diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.test.ts b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts index d1b9e9071f..3218eb2b0a 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.test.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts @@ -686,7 +686,7 @@ tools: remove: - b require: - - switch_agent + - propose_plan --- `, "utf-8" diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts index 28380fd15d..846dbe7c73 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.ts @@ -224,7 +224,7 @@ async function readAgentDescriptorFromFile( try { const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); - const { selectable, routable } = resolveAgentVisibility(parsed.frontmatter.ui); + const { selectable } = resolveAgentVisibility(parsed.frontmatter.ui); const descriptor: AgentDefinitionDescriptor = { id: agentId, @@ -232,7 +232,6 @@ async function readAgentDescriptorFromFile( name: parsed.frontmatter.name, description: parsed.frontmatter.description, uiSelectable: selectable, - uiRoutable: routable, uiColor: parsed.frontmatter.ui?.color, subagentRunnable: parsed.frontmatter.subagent?.runnable ?? false, base: parsed.frontmatter.base, @@ -269,7 +268,7 @@ export async function discoverAgentDefinitions( // Seed built-ins (lowest precedence). for (const pkg of getBuiltInAgentDefinitions()) { - const { selectable, routable } = resolveAgentVisibility(pkg.frontmatter.ui); + const { selectable } = resolveAgentVisibility(pkg.frontmatter.ui); byId.set(pkg.id, { id: pkg.id, @@ -277,7 +276,6 @@ export async function discoverAgentDefinitions( name: pkg.frontmatter.name, description: pkg.frontmatter.description, uiSelectable: selectable, - uiRoutable: routable, uiColor: pkg.frontmatter.ui?.color, subagentRunnable: pkg.frontmatter.subagent?.runnable ?? false, base: pkg.frontmatter.base, diff --git a/src/node/services/agentDefinitions/agentVisibility.ts b/src/node/services/agentDefinitions/agentVisibility.ts index a5ee6746cb..ba9a34abc7 100644 --- a/src/node/services/agentDefinitions/agentVisibility.ts +++ b/src/node/services/agentDefinitions/agentVisibility.ts @@ -1,26 +1,20 @@ import type { AgentDefinitionFrontmatter } from "@/common/types/agentDefinition"; /** - * Resolved visibility/routability for an agent. + * Resolved visibility for an agent. * * - `selectable` controls whether the agent appears in the human picker, the * ACP `agentMode` option list, and `agents.list` (subject to capability gating). - * - `routable` controls whether `switch_agent` can hand off to the agent. - * Defaults to `selectable` when `ui.routable` is unset: visible agents are - * routable by default; hidden agents must opt back in via `ui.routable: true`. * * This is the single source of truth for the rule; do not re-implement it - * inline. Use {@link resolveAgentVisibility} everywhere these booleans are needed. + * inline. Use {@link resolveAgentVisibility} everywhere this boolean is needed. */ export interface AgentVisibility { selectable: boolean; - routable: boolean; } export function resolveAgentVisibility( ui: AgentDefinitionFrontmatter["ui"] | undefined ): AgentVisibility { - const selectable = ui?.hidden !== true; - const routable = typeof ui?.routable === "boolean" ? ui.routable : selectable; - return { selectable, routable }; + return { selectable: ui?.hidden !== true }; } diff --git a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts index 3afbc98da5..131220aaf4 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 = { "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", - "desktop": "---\nname: Desktop\ndescription: Visual desktop automation agent for GUI-heavy, screenshot-intensive workflows\nbase: exec\nui:\n hidden: true\n routable: true\n requires:\n - desktop\nsubagent:\n runnable: true\n append_prompt: |\n You are a desktop automation sub-agent running in a child workspace.\n\n - Your job: interact with the desktop GUI via screenshot-driven automation.\n - Always take a screenshot before starting a GUI interaction sequence.\n - Follow the grounding loop: screenshot → identify target → act → screenshot to verify.\n - After completing the task, summarize the outcome back to the parent with only\n the result plus selected evidence (e.g., a final screenshot path).\n - Do not expand scope beyond the delegated desktop task.\n - Call `agent_report` exactly once when done.\nprompt:\n append: true\nai:\n thinkingLevel: medium\ntools:\n add:\n - desktop_screenshot\n - desktop_move_mouse\n - desktop_click\n - desktop_double_click\n - desktop_drag\n - desktop_scroll\n - desktop_type\n - desktop_key_press\n remove:\n # Desktop agent should not recursively orchestrate child agents\n - task\n - task_await\n - task_list\n - task_terminate\n - task_apply_git_patch\n # No planning tools\n - propose_plan\n - ask_user_question\n # Global config and catalog tools\n - mux_agents_.*\n - agent_skill_write\n---\n\nYou are a desktop automation agent.\n\n- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.\n- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.\n- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.\n- **Defensive interaction patterns:**\n - Wait briefly after clicks before verifying because menus and dialogs may animate.\n - For text input, click the target field first, verify focus, then type.\n - For drag operations, verify both the start and end positions with screenshots.\n - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.\n- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.\n- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.\n- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.\n", + "desktop": "---\nname: Desktop\ndescription: Visual desktop automation agent for GUI-heavy, screenshot-intensive workflows\nbase: exec\nui:\n hidden: true\n requires:\n - desktop\nsubagent:\n runnable: true\n append_prompt: |\n You are a desktop automation sub-agent running in a child workspace.\n\n - Your job: interact with the desktop GUI via screenshot-driven automation.\n - Always take a screenshot before starting a GUI interaction sequence.\n - Follow the grounding loop: screenshot → identify target → act → screenshot to verify.\n - After completing the task, summarize the outcome back to the parent with only\n the result plus selected evidence (e.g., a final screenshot path).\n - Do not expand scope beyond the delegated desktop task.\n - Call `agent_report` exactly once when done.\nprompt:\n append: true\nai:\n thinkingLevel: medium\ntools:\n add:\n - desktop_screenshot\n - desktop_move_mouse\n - desktop_click\n - desktop_double_click\n - desktop_drag\n - desktop_scroll\n - desktop_type\n - desktop_key_press\n remove:\n # Desktop agent should not recursively orchestrate child agents\n - task\n - task_await\n - task_list\n - task_terminate\n - task_apply_git_patch\n # No planning tools\n - propose_plan\n - ask_user_question\n # Global config and catalog tools\n - mux_agents_.*\n - agent_skill_write\n---\n\nYou are a desktop automation agent.\n\n- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.\n- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.\n- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.\n- **Defensive interaction patterns:**\n - Wait briefly after clicks before verifying because menus and dialogs may animate.\n - For text input, click the target field first, verify focus, then type.\n - For drag operations, verify both the start and end positions with screenshots.\n - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.\n- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.\n- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.\n- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.\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 - If the task brief includes clear starting points and acceptance criteria (or a concrete approved plan handoff) — implement it directly.\n Do not spawn `explore` tasks or write a \"mini-plan\" unless you are concretely blocked by a missing fact (e.g., a file path that doesn't exist, an unknown symbol name, or an error that contradicts the brief).\n - When you do need repo context you don't have, prefer 1–3 narrow `explore` tasks (possibly in parallel) over broad manual file-reading.\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 - Never amend existing commits — always create new commits on top.\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 # Global config and catalog tools stay out of general-purpose agents\n - mux_agents_.*\n - agent_skill_write\n - agent_skill_delete\n - mux_config_read\n - mux_config_write\n - skills_catalog_.*\n - analytics_query\n---\n\nYou are in Exec mode.\n\n- If an accepted `` block is provided, treat it as the contract and implement it directly. Only do extra exploration if the plan references non-existent files/symbols or if errors contradict it.\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\n## Desktop Automation\n\nWhen a task involves repeated screenshot/action/verify loops for desktop GUI interaction (for example, clicking through application UIs, filling desktop app forms, or visually verifying GUI state), delegate to the `desktop` agent via `task` rather than performing desktop automation inline. The desktop agent is purpose-built for the screenshot → act → verify grounding loop.\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; skill tools are kept)\n remove:\n - image_.*\n - file_edit_.*\n - task\n - task_apply_git_patch\n - task_.*\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", "name_workspace": "---\nname: Name Workspace\ndescription: Generate workspace name and title from user message\nui:\n hidden: true\nsubagent:\n runnable: false\ntools:\n require:\n - propose_name\n---\n\nYou are a workspace naming assistant. Your only job is to call the `propose_name` tool with a suitable name and title.\n\nDo not emit text responses. Call the `propose_name` tool immediately.\n", diff --git a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts index f4ce6a45bf..dfbc3291a0 100644 --- a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts +++ b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts @@ -24,7 +24,6 @@ describe("built-in agent definitions", () => { expect(desktop).toBeTruthy(); expect(desktop?.frontmatter.base).toBe("exec"); expect(desktop?.frontmatter.ui?.hidden).toBe(true); - expect(desktop?.frontmatter.ui?.routable).toBe(true); expect(desktop?.frontmatter.ui?.requires).toContain("desktop"); expect(desktop?.frontmatter.subagent?.runnable).toBe(true); expect(desktop?.frontmatter.ai?.thinkingLevel).toBe("medium"); diff --git a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts index ef29489447..a0b3a0e3d0 100644 --- a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts +++ b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts @@ -104,7 +104,7 @@ tools: remove: - task require: - - switch_agent + - propose_plan --- Body `; @@ -117,7 +117,7 @@ Body expect(result.frontmatter.tools).toEqual({ add: ["file_read", "bash.*", "task_.*"], remove: ["task"], - require: ["switch_agent"], + require: ["propose_plan"], }); }); }); diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts index 0fb3a7f398..b9de20fd5c 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.test.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.test.ts @@ -15,27 +15,7 @@ describe("resolveToolPolicyForAgent", () => { disableTaskToolsForDepth: false, }); - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("switch_agent is disabled by default when not explicitly requested", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "file_read", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); + expect(policy).toEqual([{ regex_match: ".*", action: "disable" }, advisorDisabledRule]); }); test("tools.add enables specified patterns", () => { @@ -50,7 +30,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash.*", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -67,42 +46,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, { regex_match: "file_read", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("top-level agents can explicitly re-enable switch_agent via tools.add", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { add: ["file_read", "switch_agent"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "file_read", action: "enable" }, - { regex_match: "switch_agent", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, - { regex_match: "switch_agent", action: "require" }, - advisorDisabledRule, - ]); - }); - - test("top-level agents can require switch_agent via tools.require", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { require: ["switch_agent"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "require" }, - { regex_match: "switch_agent", action: "disable" }, - { regex_match: "switch_agent", action: "require" }, advisorDisabledRule, ]); }); @@ -111,7 +54,7 @@ describe("resolveToolPolicyForAgent", () => { // Chain: child → base const agents: AgentLikeForPolicy[] = [ { tools: { require: ["agent_report"] } }, - { tools: { require: ["switch_agent"] } }, + { tools: { require: ["propose_plan"] } }, ]; const policy = resolveToolPolicyForAgent({ agents, @@ -122,45 +65,12 @@ describe("resolveToolPolicyForAgent", () => { expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "agent_report", action: "require" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("broad wildcard add does not implicitly unlock switch_agent", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { add: [".*"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: ".*", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("non-literal regex add that matches switch_agent does not unlock switch_agent", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { add: [".+"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: ".+", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); test("tools.require uses only the last entry in a layer", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { require: ["switch_agent", "agent_report"] } }]; + const agents: AgentLikeForPolicy[] = [{ tools: { require: ["propose_plan", "agent_report"] } }]; const policy = resolveToolPolicyForAgent({ agents, isSubagent: false, @@ -170,7 +80,6 @@ describe("resolveToolPolicyForAgent", () => { expect(policy).toEqual([ { regex_match: ".*", action: "disable" }, { regex_match: "agent_report", action: "require" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -183,51 +92,7 @@ describe("resolveToolPolicyForAgent", () => { disableTaskToolsForDepth: false, }); - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("wildcard remove clears switch_agent enablement from earlier explicit add", () => { - // Chain: child → base. Base explicitly enables switch_agent, then child strips all tools. - const agents: AgentLikeForPolicy[] = [ - { tools: { remove: [".*"] } }, - { tools: { add: ["switch_agent"] } }, - ]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: false, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "enable" }, - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); - }); - - test("subagents still hard-deny switch_agent even when explicitly requested", () => { - const agents: AgentLikeForPolicy[] = [{ tools: { require: ["switch_agent"] } }]; - const policy = resolveToolPolicyForAgent({ - agents, - isSubagent: true, - disableTaskToolsForDepth: false, - }); - - expect(policy).toEqual([ - { regex_match: ".*", 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: "require" }, - advisorDisabledRule, - ]); + expect(policy).toEqual([{ regex_match: ".*", action: "disable" }, advisorDisabledRule]); }); test("subagents skip require filters for hard-denied ask_user_question", () => { @@ -240,9 +105,7 @@ describe("resolveToolPolicyForAgent", () => { expect(policy).toEqual([ { regex_match: ".*", 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: "require" }, advisorDisabledRule, @@ -261,9 +124,7 @@ describe("resolveToolPolicyForAgent", () => { { 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: "require" }, advisorDisabledRule, @@ -285,9 +146,7 @@ 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: "require" }, { regex_match: "agent_report", action: "disable" }, advisorDisabledRule, @@ -308,7 +167,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "file_read", action: "enable" }, { regex_match: "task", action: "disable" }, { regex_match: "task_.*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -327,9 +185,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" }, { regex_match: "ask_user_question", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, { regex_match: "propose_plan", action: "disable" }, { regex_match: "agent_report", action: "require" }, advisorDisabledRule, @@ -344,11 +200,7 @@ describe("resolveToolPolicyForAgent", () => { disableTaskToolsForDepth: false, }); - expect(policy).toEqual([ - { regex_match: ".*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, - advisorDisabledRule, - ]); + expect(policy).toEqual([{ regex_match: ".*", action: "disable" }, advisorDisabledRule]); }); test("whitespace in tool patterns is trimmed", () => { @@ -363,7 +215,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -384,7 +235,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "bash", action: "enable" }, { regex_match: "task", action: "enable" }, { regex_match: "task", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -408,7 +258,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "enable" }, { regex_match: "propose_plan", action: "disable" }, { regex_match: "file_edit_.*", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -436,7 +285,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: "task", action: "enable" }, { regex_match: "bash", action: "disable" }, { regex_match: "task", action: "disable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); @@ -457,7 +305,6 @@ describe("resolveToolPolicyForAgent", () => { { regex_match: ".*", action: "disable" }, { regex_match: "file_read", action: "enable" }, { regex_match: "bash", action: "enable" }, - { regex_match: "switch_agent", action: "disable" }, advisorDisabledRule, ]); }); diff --git a/src/node/services/agentDefinitions/resolveToolPolicy.ts b/src/node/services/agentDefinitions/resolveToolPolicy.ts index d1ae6f54f6..ca07fb1944 100644 --- a/src/node/services/agentDefinitions/resolveToolPolicy.ts +++ b/src/node/services/agentDefinitions/resolveToolPolicy.ts @@ -28,7 +28,7 @@ export interface ResolveToolPolicyOptions { // Tools that are never allowed in autonomous sub-agent flows. // Single source of truth: SUBAGENT_HARD_DENY is derived from this list. -const SUBAGENT_HARD_DENIED_TOOLS = ["ask_user_question", "switch_agent"] as const; +const SUBAGENT_HARD_DENIED_TOOLS = ["ask_user_question"] as const; const SUBAGENT_HARD_DENY: ToolPolicy = SUBAGENT_HARD_DENIED_TOOLS.map((tool) => ({ regex_match: tool, @@ -49,26 +49,6 @@ function matchesToolPattern(pattern: string, toolName: string): boolean { } } -function matchesSwitchAgentPattern(pattern: string): boolean { - const trimmed = pattern.trim(); - if (trimmed.length === 0) { - return false; - } - - return matchesToolPattern(trimmed, "switch_agent"); -} - -function isExplicitSwitchAgentEnablePattern(pattern: string): boolean { - const trimmed = pattern.trim(); - if (trimmed.length === 0) { - return false; - } - - // switch_agent opt-in must be explicit and literal; broad or alternate regexes - // should not implicitly unlock autonomous handoff behavior. - return trimmed === "switch_agent"; -} - function matchesSubagentHardDeniedTool(pattern: string): boolean { return SUBAGENT_HARD_DENIED_TOOLS.some((toolName) => matchesToolPattern(pattern, toolName)); } @@ -100,7 +80,6 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To // Process inheritance chain: base → child const configs = collectToolConfigsFromResolvedChain(agents); - let switchAgentEnabledByConfig = false; let effectiveRequirePattern: string | undefined; for (const config of configs) { // Enable tools from add list (treated as regex patterns) @@ -109,9 +88,6 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To const trimmed = pattern.trim(); if (trimmed.length > 0) { agentPolicy.push({ regex_match: trimmed, action: "enable" }); - if (isExplicitSwitchAgentEnablePattern(trimmed)) { - switchAgentEnabledByConfig = true; - } } } } @@ -122,9 +98,6 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To const trimmed = pattern.trim(); if (trimmed.length > 0) { agentPolicy.push({ regex_match: trimmed, action: "disable" }); - if (matchesSwitchAgentPattern(trimmed)) { - switchAgentEnabledByConfig = false; - } } } } @@ -145,9 +118,6 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To // required tool can collapse the entire toolset. if (!(isSubagent && matchesSubagentHardDeniedTool(effectiveRequirePattern))) { agentPolicy.push({ regex_match: effectiveRequirePattern, action: "require" }); - if (!isSubagent && isExplicitSwitchAgentEnablePattern(effectiveRequirePattern)) { - switchAgentEnabledByConfig = true; - } } } @@ -158,13 +128,6 @@ export function resolveToolPolicyForAgent(options: ResolveToolPolicyOptions): To runtimePolicy.push(...DEPTH_HARD_DENY); } - // switch_agent is disabled by default and only re-enabled when the resolved - // agent chain explicitly requests it (e.g. tools.require: ["switch_agent"]). - runtimePolicy.push({ regex_match: "switch_agent", action: "disable" }); - if (!isSubagent && switchAgentEnabledByConfig) { - runtimePolicy.push({ regex_match: "switch_agent", action: "require" }); - } - if (isSubagent) { runtimePolicy.push(...SUBAGENT_HARD_DENY); diff --git a/src/node/services/agentSession.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index 6d3c32338b..6c8ebfa4e5 100644 --- a/src/node/services/agentSession.budgetGate.test.ts +++ b/src/node/services/agentSession.budgetGate.test.ts @@ -109,7 +109,7 @@ async function createSessionHarness(workspaceId: string): Promise Promise; - sendMessage: ( - message: string, - options?: SendMessageOptions, - internal?: { synthetic?: boolean } - ) => Promise<{ success: boolean }>; -} - -interface SessionHarness { - session: AgentSession; - aiEmitter: EventEmitter; -} - -function createAiService( - projectPath: string, - aiEmitter: EventEmitter, - metadataOverrides?: Partial, - providersConfig?: ProvidersConfigMap | null -): AIService { - const workspaceMetadata: WorkspaceMetadata = { - id: "workspace-switch", - name: "workspace-switch-name", - projectName: "workspace-switch-project", - projectPath, - runtimeConfig: DEFAULT_RUNTIME_CONFIG, - ...metadataOverrides, - }; - - return Object.assign(aiEmitter, { - getWorkspaceMetadata: mock(() => - Promise.resolve({ - success: true as const, - data: workspaceMetadata, - }) - ), - getProvidersConfig: mock(() => providersConfig ?? null), - stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), - }) as unknown as AIService; -} - -function createSessionHarness( - historyService: HistoryService, - sessionDir: string, - projectPath: string, - metadataOverrides?: Partial, - providersConfig?: ProvidersConfigMap | null -): SessionHarness { - const aiEmitter = new EventEmitter(); - 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; - - const session = new AgentSession({ - workspaceId: "workspace-switch", - config, - historyService, - aiService: createAiService(projectPath, aiEmitter, metadataOverrides, providersConfig), - initStateManager, - backgroundProcessManager, - }); - - return { session, aiEmitter }; -} - -function createSession( - historyService: HistoryService, - sessionDir: string, - projectPath: string, - metadataOverrides?: Partial, - providersConfig?: ProvidersConfigMap | null -): AgentSession { - return createSessionHarness( - historyService, - sessionDir, - projectPath, - metadataOverrides, - providersConfig - ).session; -} - -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" - ); -} - -function getLatestStreamError( - events: WorkspaceChatMessage[] -): Extract | undefined { - const streamErrors = events.filter( - (event): event is Extract => - event.type === "stream-error" - ); - return streamErrors.at(-1); -} - -describe("AgentSession switch_agent target validation", () => { - let historyCleanup: (() => Promise) | undefined; - - afterEach(async () => { - await historyCleanup?.(); - }); - - test("inherits model/thinking from outgoing stream when target has no aiSettingsByAgent entry", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-valid"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path, { - // Legacy workspace aiSettings should not override the active stream - // when switch_agent has no explicit target-agent override. - aiSettings: { - model: "openai:gpt-4.1", - thinkingLevel: "high", - }, - }); - - 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", thinkingLevel: "low" }, - "openai:gpt-4o", - GOAL_CONTINUATION_KIND - ); - - 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; goalKind?: typeof GOAL_CONTINUATION_KIND }, - ]; - expect(messageArg).toBe("Create a plan."); - expect(optionsArg.agentId).toBe("plan"); - expect(optionsArg.model).toBe("openai:gpt-4o-mini"); - expect(optionsArg.thinkingLevel).toBe("low"); - expect(internalArg).toEqual({ synthetic: true, goalKind: GOAL_CONTINUATION_KIND }); - } finally { - session.dispose(); - } - }); - - test("uses target agent settings from aiSettingsByAgent over outgoing stream", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-agent-settings"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path, { - aiSettings: { - model: "openai:gpt-4.1", - thinkingLevel: "off", - }, - aiSettingsByAgent: { - plan: { - model: "anthropic:claude-sonnet-4-5", - thinkingLevel: "high", - }, - }, - }); - - 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", thinkingLevel: "low" }, - "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("anthropic:claude-sonnet-4-5"); - expect(optionsArg.thinkingLevel).toBe("high"); - expect(internalArg).toEqual({ synthetic: true }); - } finally { - session.dispose(); - } - }); - - describe("1M context preservation", () => { - async function dispatchSwitchAndCaptureOptions( - currentOptions: SendMessageOptions, - targetModel: string, - providersConfig?: ProvidersConfigMap | null - ): Promise { - using projectDir = new DisposableTempDir("agent-session-switch-1m-context"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession( - historyService, - projectDir.path, - projectDir.path, - { - aiSettingsByAgent: { - plan: { - model: targetModel, - thinkingLevel: "high", - }, - }, - }, - providersConfig - ); - - 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", - followUp: "Create a plan.", - }, - currentOptions, - "openai:gpt-4o" - ); - - expect(result).toBe(true); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - - const firstCall = sendMessageMock.mock.calls[0]; - expect(firstCall).toBeDefined(); - const [, optionsArg] = firstCall as unknown as [string, SendMessageOptions]; - return optionsArg; - } finally { - session.dispose(); - } - } - - test("preserves beta 1M context when source has use1MContextModels and target model supports the beta", async () => { - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude-sonnet-4-5", - providerOptions: { - anthropic: { - use1MContextModels: ["anthropic:claude-sonnet-4-5"], - }, - }, - }, - "anthropic:claude-sonnet-4-20250514" - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).toBe(true); - }); - - test("preserves beta 1M intent when source model is an alias resolved via providersConfig", async () => { - const providersConfig: ProvidersConfigMap = { - anthropic: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - models: [ - { - id: "claude/sonnet", - mappedToModel: "anthropic:claude-sonnet-4-5-20250929", - }, - ], - }, - }; - - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude/sonnet", - providerOptions: { - anthropic: { - use1MContextModels: ["anthropic:claude/sonnet"], - }, - }, - }, - "anthropic:claude-sonnet-4-5", - providersConfig - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).toBe(true); - }); - - test("preserves beta 1M context when source has use1MContext boolean", async () => { - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude-sonnet-4-5", - providerOptions: { - anthropic: { - use1MContext: true, - }, - }, - }, - "anthropic:claude-sonnet-4-20250514" - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).toBe(true); - }); - - test("does NOT set 1M context when disableBetaFeatures is true", async () => { - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude-sonnet-4-5", - providerOptions: { - anthropic: { - use1MContextModels: ["anthropic:claude-sonnet-4-5"], - disableBetaFeatures: true, - }, - }, - }, - "anthropic:claude-sonnet-4-20250514" - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).not.toBe(true); - }); - - test("does NOT set 1M context when target model does not support 1M", async () => { - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude-sonnet-4-5", - providerOptions: { - anthropic: { - use1MContextModels: ["anthropic:claude-sonnet-4-5"], - }, - }, - }, - "openai:gpt-4o" - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).not.toBe(true); - }); - - test("does NOT set 1M context when source had no 1M intent", async () => { - const followUpOptions = await dispatchSwitchAndCaptureOptions( - { - agentId: "exec", - model: "anthropic:claude-sonnet-4-5", - providerOptions: { - anthropic: { - use1MContextModels: [], - }, - }, - }, - "anthropic:claude-sonnet-4-20250514" - ); - - expect(followUpOptions.providerOptions?.anthropic?.use1MContext).not.toBe(true); - }); - }); - - 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, { - aiSettingsByAgent: { - exec: { - model: "anthropic:claude-sonnet-4-5", - thinkingLevel: "high", - }, - }, - }); - - 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", thinkingLevel: "low" }, - "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"); - expect(optionsArg.model).toBe("openai:gpt-4o-mini"); - expect(optionsArg.thinkingLevel).toBe("low"); - } finally { - session.dispose(); - } - }); - - test("allows switch to hidden agent with ui.routable: true", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-hidden-routable"); - await writeAgentDefinition( - projectDir.path, - "hidden-routable-agent", - "ui:\n hidden: true\n routable: true\n" - ); - - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path, { - name: 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-routable-agent", - followUp: "Route to hidden routable agent", - }, - { model: "openai:gpt-4o-mini", agentId: "exec", thinkingLevel: "low" }, - "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("Route to hidden routable agent"); - expect(optionsArg.agentId).toBe("hidden-routable-agent"); - expect(optionsArg.model).toBe("openai:gpt-4o-mini"); - expect(optionsArg.thinkingLevel).toBe("low"); - expect(internalArg).toEqual({ synthetic: true }); - } finally { - session.dispose(); - } - }); - - test("rejects switch to disabled agent even with ui.routable: true", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-disabled-routable"); - await writeAgentDefinition( - projectDir.path, - "disabled-routable-agent", - "ui:\n disabled: true\n routable: true\n" - ); - - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path, { - aiSettingsByAgent: { - exec: { - model: "anthropic:claude-sonnet-4-5", - thinkingLevel: "high", - }, - }, - }); - - 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-routable-agent", - followUp: "Should not send", - }, - { model: "openai:gpt-4o-mini", agentId: "exec", thinkingLevel: "low" }, - "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-routable-agent" is unavailable'); - expect(optionsArg.agentId).toBe("exec"); - expect(optionsArg.model).toBe("openai:gpt-4o-mini"); - expect(optionsArg.thinkingLevel).toBe("low"); - } 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 caller 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: "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 "missing-agent" is unavailable'); - expect(optionsArg.agentId).toBe("exec"); - } finally { - session.dispose(); - } - }); - - test("skips auto caller when unresolved switch target falls back", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-auto-fallback"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - await writeAgentDefinition(projectDir.path, "auto", ""); - - 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(); - } - }); - - test("emits stream-error when switch loop guard blocks synthetic follow-up", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-loop-guard"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path); - const events: WorkspaceChatMessage[] = []; - session.onChatEvent((event) => { - events.push(event.message); - }); - - 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"]; - - for (let attempt = 0; attempt < 3; attempt += 1) { - const allowed = await internals.dispatchAgentSwitch( - { agentId: "plan", followUp: `Attempt ${attempt + 1}` }, - { model: "openai:gpt-4o-mini", agentId: "exec" }, - "openai:gpt-4o" - ); - expect(allowed).toBe(true); - } - - const blockedResult = await internals.dispatchAgentSwitch( - { agentId: "plan", followUp: "blocked" }, - { model: "openai:gpt-4o-mini", agentId: "exec" }, - "openai:gpt-4o" - ); - - expect(blockedResult).toBe(false); - expect(sendMessageMock).toHaveBeenCalledTimes(3); - - const streamError = getLatestStreamError(events); - expect(streamError).toBeDefined(); - expect(streamError?.error).toContain("Agent switch loop detected"); - } finally { - session.dispose(); - } - }); - - test("emits stream-error with formatted classification when switch follow-up dispatch send fails", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-send-failure"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path); - const events: WorkspaceChatMessage[] = []; - session.onChatEvent((event) => { - events.push(event.message); - }); - - try { - const internals = session as unknown as SessionInternals; - internals.sendMessage = mock(() => - Promise.resolve({ - success: false as const, - error: { type: "api_key_not_found", provider: "anthropic" }, - }) - ) as unknown as SessionInternals["sendMessage"]; - - const result = await internals.dispatchAgentSwitch( - { - agentId: "plan", - followUp: "Create a plan.", - }, - { model: "openai:gpt-4o-mini", agentId: "exec" }, - "openai:gpt-4o" - ); - - expect(result).toBe(false); - - const streamError = getLatestStreamError(events); - expect(streamError).toBeDefined(); - expect(streamError?.errorType).toBe("authentication"); - expect(streamError?.error).toContain( - 'Failed to switch to agent "plan": API key not configured for Anthropic.' - ); - } finally { - session.dispose(); - } - }); - - test("does not emit duplicate stream-error when nested send already reported failure", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-send-deduped"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const session = createSession(historyService, projectDir.path, projectDir.path); - const events: WorkspaceChatMessage[] = []; - session.onChatEvent((event) => { - events.push(event.message); - }); - - try { - const internals = session as unknown as SessionInternals & { - activeStreamFailureHandled: boolean; - activeStreamErrorEventReceived: boolean; - }; - internals.activeStreamFailureHandled = true; - internals.activeStreamErrorEventReceived = false; - internals.sendMessage = mock(() => - Promise.resolve({ - success: false as const, - error: { type: "provider_not_supported", provider: "anthropic" }, - }) - ) as unknown as SessionInternals["sendMessage"]; - - const result = await internals.dispatchAgentSwitch( - { - agentId: "plan", - followUp: "Create a plan.", - }, - { model: "openai:gpt-4o-mini", agentId: "exec" }, - "openai:gpt-4o" - ); - - expect(result).toBe(false); - expect(events.some((event) => event.type === "stream-error")).toBe(false); - } finally { - session.dispose(); - } - }); - - test("emits stream-error when stream-end handoff throws unexpectedly", async () => { - using projectDir = new DisposableTempDir("agent-session-switch-stream-end-throw"); - const { historyService, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const { session, aiEmitter } = createSessionHarness( - historyService, - projectDir.path, - projectDir.path - ); - const events: WorkspaceChatMessage[] = []; - session.onChatEvent((event) => { - events.push(event.message); - }); - - try { - const internals = session as unknown as SessionInternals; - internals.dispatchAgentSwitch = (() => - Promise.reject(new Error("handoff exploded"))) as SessionInternals["dispatchAgentSwitch"]; - - aiEmitter.emit("stream-end", { - type: "stream-end", - workspaceId: "workspace-switch", - messageId: "assistant-switch-stream-end", - parts: [ - { - type: "dynamic-tool", - state: "output-available", - toolCallId: "tool-switch-agent", - toolName: "switch_agent", - input: { agentId: "plan", followUp: "Continue." }, - output: { ok: true, agentId: "plan", followUp: "Continue." }, - }, - ], - metadata: { - model: "openai:gpt-4o-mini", - contextUsage: { - inputTokens: 12, - outputTokens: 3, - totalTokens: 15, - }, - providerMetadata: {}, - }, - }); - - const deadline = Date.now() + 1500; - while (!events.some((event) => event.type === "stream-error") && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - const streamError = getLatestStreamError(events); - expect(streamError).toBeDefined(); - expect(streamError?.error).toContain( - "An unexpected error occurred during agent handoff: handoff exploded" - ); - } finally { - session.dispose(); - } - }); -}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 3fd1af251c..2d5ff8ed19 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -44,7 +44,6 @@ import { type StreamErrorPayload, } from "@/node/services/utils/sendMessageError"; import { - createAssistantMessageId, createUserMessageId, createFileSnapshotMessageId, createAgentSkillSnapshotMessageId, @@ -77,14 +76,8 @@ import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, } from "@/node/runtime/runtimeHelpers"; -import { hasNonEmptyPlanFile } from "@/node/utils/runtime/helpers"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; -import { - readAgentDefinition, - resolveAgentFrontmatter, -} from "@/node/services/agentDefinitions/agentDefinitionsService"; -import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; -import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; +import { readAgentDefinition } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { MessageQueue } from "./messageQueue"; import { @@ -125,10 +118,7 @@ import { isValidModelFormat, supports1MContext, } from "@/common/utils/ai/models"; -import { - isAnthropic1MEffectivelyEnabled, - preserveAnthropic1MContextForFollowUp, -} from "@/common/utils/ai/providerOptions"; +import { isAnthropic1MEffectivelyEnabled } from "@/common/utils/ai/providerOptions"; import { isNonRetryableSendError, isNonRetryableStreamError, @@ -149,7 +139,6 @@ import { renderAgentSkillSnapshotText } from "@/common/utils/agentSkills/skillSn import { materializeFileAtMentions } from "@/node/services/fileAtMentions"; import { getErrorMessage } from "@/common/utils/errors"; import { CompactionMonitor, type CompactionStatusEvent } from "./compactionMonitor"; -import { coerceNonEmptyString } from "@/node/services/taskUtils"; /** * Tracked file state for detecting external edits. @@ -189,12 +178,6 @@ interface AutoRetryResumeRequest { goalKind?: GoalSyntheticMessageKind; } -interface SwitchAgentResult { - agentId: string; - reason?: string; - followUp?: string; -} - function stripGoalInterventionPolicy(options: SendMessageOptions): SendMessageOptions { const streamOptions: SendMessageOptions = { ...options }; delete streamOptions.goalInterventionPolicy; @@ -220,12 +203,6 @@ function coerceGoalSyntheticMessageKind(value: unknown): GoalSyntheticMessageKin return undefined; } -const MAX_CONSECUTIVE_AGENT_SWITCHES = 3; - -const SAFE_AGENT_SWITCH_FALLBACK_CANDIDATES = ["exec", "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"; const ACP_PROMPT_ID_METADATA_KEY = "acpPromptId"; const ACP_DELEGATED_TOOLS_METADATA_KEY = "acpDelegatedTools"; @@ -379,8 +356,6 @@ export class AgentSession { private activePreparedTurnAbortController: AbortController | null = null; // 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(); @@ -2200,7 +2175,7 @@ export class AgentSession { const isManualUserMessage = internal?.synthetic !== true; // Last-line-of-defence pricing gate: every dispatch path (initial sends, - // sendQueuedMessages, dispatchPendingFollowUp, dispatchAgentSwitch, + // sendQueuedMessages, dispatchPendingFollowUp, // post-compaction follow-ups) lands here, so a budgeted goal that became // resumable while a queued unpriced-model message waited cannot bypass // enforcement. The WorkspaceService-level gate already runs first for @@ -2243,10 +2218,6 @@ export class AgentSession { } } - if (isManualUserMessage) { - this.consecutiveAgentSwitches = 0; - } - const goalKind = internal?.goalKind ?? (internal?.goalContinuation === true ? GOAL_CONTINUATION_KIND : undefined); @@ -4558,7 +4529,7 @@ export class AgentSession { streamEndedAtMs: number; } | null = null; let emittedStreamEnd = false; - let handoffFailureMessage: string | undefined; + try { const completedCompactionRequest = this.activeCompactionRequest; this.activeCompactionRequest = undefined; @@ -4629,24 +4600,6 @@ export class AgentSession { await this.dispatchPendingFollowUp(); } - const switchResult = this.extractSwitchAgentResult(streamEndPayload); - if (switchResult) { - try { - const dispatchedSwitchFollowUp = await this.dispatchAgentSwitch( - switchResult, - activeStreamOptions, - streamEndPayload.metadata.model, - activeStreamGoalKind - ); - if (dispatchedSwitchFollowUp) { - return; - } - } catch (error) { - handoffFailureMessage = getErrorMessage(error); - throw error; - } - } - // 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. const hadQueuedMessages = this.hasQueuedMessages(); @@ -4693,16 +4646,6 @@ export class AgentSession { error: streamEndCleanupError, }); - if (handoffFailureMessage != null) { - this.emitChatEvent( - createStreamErrorMessage({ - messageId: createAssistantMessageId(), - error: `An unexpected error occurred during agent handoff: ${handoffFailureMessage}`, - errorType: "unknown", - }) - ); - } - // Defense-in-depth: unblock renderer if compaction handler threw before we emitted. if (!emittedStreamEnd) { try { @@ -4713,7 +4656,7 @@ export class AgentSession { } } finally { // Only clean up if we're still in COMPLETING — a new turn started by - // dispatchPendingFollowUp(), dispatchAgentSwitch(), or sendQueuedMessages() + // dispatchPendingFollowUp() or sendQueuedMessages() // owns the stream state now. if (this.turnPhase === TurnPhase.COMPLETING) { this.resetActiveStreamState(); @@ -5006,407 +4949,6 @@ export class AgentSession { return SILENT_CONTINUATION_COMPLETION_SUMMARY_FALLBACK; } - /** 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; - } - - // Verify the tool succeeded. - if (!this.isOkSwitchAgentOutput(part.output)) { - continue; - } - - // Primary path: read switch details from tool input args. - const parsedInput = this.parseSwitchAgentInput(part.input); - if (parsedInput) { - return parsedInput; - } - - // Defensive fallback: degraded streams can lose input metadata (input=null) - // when tool-call correlation fails. Recover from output if possible. - const parsedOutput = this.parseSwitchAgentOutput(part.output); - if (parsedOutput) { - return parsedOutput; - } - } - - return undefined; - } - - private isOkSwitchAgentOutput(output: unknown): boolean { - if (typeof output !== "object" || output === null) { - return false; - } - - const candidate = output as Record; - return candidate.ok === true; - } - - private parseSwitchAgentInput(input: unknown): SwitchAgentResult | undefined { - return this.parseSwitchAgentCandidate(input); - } - - private parseSwitchAgentOutput(output: unknown): SwitchAgentResult | undefined { - return this.parseSwitchAgentCandidate(output); - } - - private parseSwitchAgentCandidate(value: unknown): SwitchAgentResult | undefined { - if (typeof value !== "object" || value === null) { - return undefined; - } - - const candidate = value as Record; - 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, workspacePath } = createRuntimeContextForWorkspace(metadata); - - // 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; - } - - const { routable } = resolveAgentVisibility(resolvedFrontmatter.ui); - - if (!routable) { - log.warn("switch_agent target is not routable; skipping synthetic follow-up", { - workspaceId: this.workspaceId, - targetAgentId: parsedAgentId.data, - }); - return false; - } - - // Check ui.requires gating (e.g., a custom agent that 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. - // Legacy sessions and custom project agents can still use the reserved - // `auto` id, and immediately falling back to that router risks re-entering - // the same switch loop instead of degrading to a safe built-in agent. - 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, - goalKind?: GoalSyntheticMessageKind - ): 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, - }); - this.emitChatEvent( - createStreamErrorMessage({ - messageId: createAssistantMessageId(), - error: `Agent switch loop detected (${this.consecutiveAgentSwitches} consecutive switches). The agent was stopped to prevent an infinite loop.`, - errorType: "unknown", - }) - ); - 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); - // switch_agent hands off execution to a different agent, so prefer that - // agent's persisted model/thinking settings. If no per-agent override - // exists, inherit from the outgoing stream options. - const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); - // If we had to reroute to a safe fallback target (hidden/disabled/missing - // requested target), keep recovery in the current stream settings instead of - // applying persisted per-agent overrides for the fallback agent. - const usedFallbackTarget = targetAgentId !== switchResult.agentId; - const targetAgentSettings = - metadataResult.success === true && !usedFallbackTarget - ? metadataResult.data.aiSettingsByAgent?.[targetAgentId] - : undefined; - const workspaceAiSettings = - metadataResult.success === true ? metadataResult.data.aiSettings : undefined; - - const effectiveModel = - coerceNonEmptyString(targetAgentSettings?.model) ?? - coerceNonEmptyString(currentOptions?.model) ?? - coerceNonEmptyString(workspaceAiSettings?.model) ?? - fallbackModel.trim(); - - const effectiveThinkingLevel = - targetAgentSettings?.thinkingLevel ?? - currentOptions?.thinkingLevel ?? - workspaceAiSettings?.thinkingLevel; - - const sourceModel = coerceNonEmptyString(currentOptions?.model) ?? fallbackModel.trim(); - const followUpProviderOptions = preserveAnthropic1MContextForFollowUp( - sourceModel, - effectiveModel, - currentOptions?.providerOptions, - this.aiService.getProvidersConfig() - ); - - // 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 - ...(effectiveThinkingLevel != null && { thinkingLevel: effectiveThinkingLevel }), - ...(followUpProviderOptions != null && { - providerOptions: followUpProviderOptions, - }), - ...(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, - goalKind, - }); - - if (!sendResult.success) { - log.warn("Failed to dispatch switch_agent follow-up", { - workspaceId: this.workspaceId, - requestedTargetAgentId: switchResult.agentId, - dispatchedTargetAgentId: targetAgentId, - error: sendResult.error, - }); - const dispatchStreamError = buildStreamErrorEventData(sendResult.error); - const nestedSendAlreadyReportedError = - this.activeStreamFailureHandled && - (this.activeStreamErrorEventReceived || - (sendResult.error.type !== "runtime_not_ready" && - sendResult.error.type !== "runtime_start_failed")); - - if (!nestedSendAlreadyReportedError) { - this.emitChatEvent( - createStreamErrorMessage({ - messageId: dispatchStreamError.messageId, - error: `Failed to switch to agent "${targetAgentId}": ${dispatchStreamError.error}`, - errorType: dispatchStreamError.errorType, - }) - ); - } - 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/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index f1a56dd64c..301d19e5e4 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1018,7 +1018,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "# UI", "ui:", " hidden: false # Hide from the agent picker.", - " routable: false # Allow switch_agent to route here even when hidden.", " requires: # Capability gates: omit unless needed.", ' - desktop # "desktop" | "plan"', ' color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset.', @@ -1102,22 +1101,17 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "These rules are applied last and cannot be overridden by frontmatter:", "", - "| Condition | Effect |", - "| ----------------------------------- | ----------------------------------------------------------------------- |", - "| Always | `switch_agent` is disabled unless the chain explicitly opts in (below). |", - "| Subagent workspace | `ask_user_question` and `switch_agent` are disabled. |", - "| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. |", - "| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. |", - "| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. |", - '| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. |', - "| Plan agent editing files | `file_edit_*` is restricted to the plan file path. |", + "| Condition | Effect |", + "| ----------------------------------- | ------------------------------------------------------- |", + "| Subagent workspace | `ask_user_question` is disabled. |", + "| Subagent + plan-like chain | `propose_plan` is required, `agent_report` is disabled. |", + "| Subagent + non-plan chain | `agent_report` is required, `propose_plan` is disabled. |", + "| Task depth ≥ Max Task Nesting Depth | `task` and `task_.*` are disabled. |", + '| Plan agent calling `task` | Only `agentId: "explore"` may be spawned. |', + "| Plan agent editing files | `file_edit_*` is restricted to the plan file path. |", "", 'A chain is "plan-like" when the resolved tool policy enables `propose_plan`.', "", - "### Enabling `switch_agent`", - "", - '`switch_agent` is opt-in even when allowed by a broad pattern. The top-level (non-subagent) chain must contain the **literal** string `"switch_agent"` in `tools.add` or `tools.require`, and must not remove it later. Broad patterns like `.*` do not count as opt-in.', - "", "## Disabling and Extending Built-ins", "", "**Disable** a built-in by creating a same-name file with `disabled: true`:", @@ -1421,7 +1415,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "base: exec", "ui:", " hidden: true", - " routable: true", " requires:", " - desktop", "subagent:", @@ -4620,17 +4613,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "", "
", - "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 | — |", - "", - "
", - "", - "
", "task (8)", "", "| Env var | JSON path | Type | Description |", diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index c38fb430ec..dd7c782808 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -1165,9 +1165,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }; } - const START_STREAM_ON_CHUNK_INDEX = 22; - const START_STREAM_ON_STEP_MESSAGES_INDEX = 23; - const START_STREAM_RUNTIME_TEMP_DIR_INDEX = 24; + const START_STREAM_ON_CHUNK_INDEX = 21; + const START_STREAM_ON_STEP_MESSAGES_INDEX = 22; + const START_STREAM_RUNTIME_TEMP_DIR_INDEX = 23; interface AdvisorRuntimeForTests { createModel: (modelString: string) => Promise; @@ -2343,7 +2343,7 @@ describe("AIService.streamMessage model parameter overrides", () => { function callSettingsOverridesFromStartStreamCall( startStreamArgs: unknown[] ): Record { - const callSettingsOverrides = startStreamArgs[21]; + const callSettingsOverrides = startStreamArgs[20]; if ( !callSettingsOverrides || typeof callSettingsOverrides !== "object" || @@ -2542,7 +2542,7 @@ describe("AIService.streamMessage model parameter overrides", () => { spyOn(harness.config, "loadProvidersConfig").mockReturnValue({}); const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(startStreamArgs[21]).toEqual({}); + expect(startStreamArgs[20]).toEqual({}); }); it("preserves Mux-built provider options when provider extras conflict", async () => { diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 5dd208bbb5..64bea0295c 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1982,10 +1982,6 @@ export class AIService extends EventEmitter { workspaceLog.warn("Failed to capture debug LLM request snapshot", { error: errMsg }); } const toolsForStream = tools; - // Top-level agents need a belt-and-suspenders toolChoice safety net for - // required routing/completion tools. Sub-agents rely on taskService.ts - // post-stream recovery when a required tool is skipped. - const forceToolChoice = !isSubagentWorkspace; const canQueueDevToolsRunMetadata = this.devToolsService?.enabled === true && @@ -2044,7 +2040,6 @@ export class AIService extends EventEmitter { effectiveThinkingLevel, requestHeaders, effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - forceToolChoice, resolvedOverrides.standard, advisorToolEligible ? onAdvisorChunk : undefined, advisorToolEligible diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index eb02b90056..7acd376aea 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -18,7 +18,6 @@ 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/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 343ce0e5f5..1e42a01433 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -40,7 +40,6 @@ import { type AgentDefinitionsRoots, } from "@/node/services/agentDefinitions/agentDefinitionsService"; import { isAgentEffectivelyDisabled } from "@/node/services/agentDefinitions/agentEnablement"; -import { resolveAgentVisibility } from "@/node/services/agentDefinitions/agentVisibility"; import { resolveAgentInheritanceChain } from "@/node/services/agentDefinitions/resolveAgentInheritanceChain"; import { discoverAgentSkills, @@ -669,13 +668,11 @@ export async function discoverAvailableSubagentsForToolContext(args: { return null; } - const { routable } = resolveAgentVisibility(resolvedFrontmatter.ui); return { ...descriptor, // Important: descriptor.subagentRunnable comes from the agent's own frontmatter only. // Re-resolve with inheritance so derived agents inherit runnable: true from their base. subagentRunnable: resolvedFrontmatter.subagent?.runnable ?? false, - uiRoutable: routable, }; } catch { // Best-effort: keep the descriptor if enablement or inheritance can't be resolved. diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 0c030f8552..49beb03db5 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -383,14 +383,6 @@ describe("StreamManager - stopWhen configuration", () => { toolPolicy: [{ regex_match: "^agent_report$", action: "require" }], assertions: [{ toolName: "agent_report", output: { success: true }, expected: true }], }, - { - name: "stops on successful switch_agent when required by policy", - toolPolicy: [{ regex_match: "switch_agent", action: "require" }], - assertions: [ - { toolName: "switch_agent", output: { ok: true }, expected: true }, - { toolName: "switch_agent", output: { ok: false }, expected: false }, - ], - }, { name: "stops on successful propose_plan when required by policy", toolPolicy: [{ regex_match: "propose_plan", action: "require" }], @@ -416,42 +408,6 @@ describe("StreamManager - stopWhen configuration", () => { } }); } - - test("sets toolChoice for required literal tool when forced and still uses stopWhen", () => { - const streamManager = new StreamManager(historyService); - const buildRequestConfig = Reflect.get(streamManager, "buildStreamRequestConfig") as - | ((...args: unknown[]) => { - toolChoice?: { type: "tool"; toolName: string }; - hasQueuedMessage?: () => boolean; - toolPolicy?: ToolPolicy; - }) - | undefined; - expect(typeof buildRequestConfig).toBe("function"); - - const model = createAnthropic({ apiKey: "test" })("claude-sonnet-4-5"); - const request = buildRequestConfig!( - model, - "claude-sonnet-4-5", - [{ role: "user", content: "route this" }], - "system", - { switch_agent: {} }, - undefined, - undefined, - undefined, - [{ regex_match: "switch_agent", action: "require" }], - true, - () => false, - undefined, - undefined - ); - - expect(request.toolChoice).toEqual({ type: "tool", toolName: "switch_agent" }); - const [, , requiredToolCondition] = buildStopWhenForTests(streamManager)({ - hasQueuedMessage: request.hasQueuedMessage, - toolPolicy: request.toolPolicy, - }); - expect(requiredToolCondition(stepsWithToolResult("switch_agent", { ok: true }))).toBe(true); - }); }); describe("StreamManager - Anthropic cache TTL overrides", () => { interface StreamRequestConfigForTests { @@ -506,7 +462,6 @@ describe("StreamManager - Anthropic cache TTL overrides", () => { undefined, undefined, undefined, - false, undefined, undefined, "1h" @@ -797,7 +752,6 @@ describe("StreamManager - call settings overrides", () => { options.maxOutputTokens, options.callSettingsOverrides, undefined, - false, undefined, undefined, undefined @@ -899,7 +853,6 @@ describe("StreamManager - call settings overrides", () => { undefined, undefined, undefined, - false, undefined, undefined, undefined, diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 9e30ee6441..bc69ad3763 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -63,7 +63,6 @@ import { getErrorMessage } from "@/common/utils/errors"; import { runLanguageModelCleanup } from "./languageModelCleanup"; import { shellQuote } from "@/common/utils/shell"; import { classify429Capacity } from "@/common/utils/errors/classify429Capacity"; -import { normalizeLiteralRequiredToolPattern } from "@/common/utils/agentTools"; import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; // Disable noisy AI SDK warning logging. @@ -130,10 +129,6 @@ interface StreamRequestConfig { /** Optional hook for callers that need the live prepared step transcript. */ onStepMessages?: (messages: ModelMessage[]) => void; toolPolicy?: ToolPolicy; - // Belt-and-suspenders for top-level agents: force the model to call the - // required tool immediately (for example, switch_agent in auto mode). - // Sub-agents rely on taskService.ts post-stream recovery instead. - toolChoice?: { type: "tool"; toolName: string }; } function isRecord(value: unknown): value is Record { @@ -1152,14 +1147,13 @@ export class StreamManager extends EventEmitter { maxOutputTokens?: number, callSettingsOverrides?: ResolvedCallSettingsOverrides, toolPolicy?: ToolPolicy, - forceToolChoice?: boolean, hasQueuedMessage?: () => boolean, headers?: Record, anthropicCacheTtlOverride?: AnthropicCacheTtl, onChunk?: StreamTextOnChunk, onStepMessages?: (messages: ModelMessage[]) => void ): StreamRequestConfig { - let finalProviderOptions = providerOptions; + const finalProviderOptions = providerOptions; // Apply cache control for Anthropic models let finalMessages = messages; @@ -1198,50 +1192,6 @@ export class StreamManager extends EventEmitter { const effectiveMaxOutputTokens = maxOutputTokens ?? configMaxOutputTokens ?? resolvedModelStats?.max_output_tokens; - let toolChoice: StreamRequestConfig["toolChoice"] | undefined; - if (forceToolChoice && toolPolicy && finalTools) { - // Only force toolChoice for routing tools that need immediate execution - // (e.g., switch_agent in auto mode). Investigation-then-complete tools - // (propose_plan, agent_report) must NOT be forced — those agents need to - // read files, run commands, etc. before calling the completion tool. - // Sub-agents rely on taskService.ts post-stream recovery instead of forcing. - // Scan all require entries for switch_agent — it may not be the first - // required tool if the agent inherits other require rules. - const hasSwitchAgentRequire = toolPolicy.some( - (filter) => - filter.action === "require" && - normalizeLiteralRequiredToolPattern(filter.regex_match) === "switch_agent" - ); - if (hasSwitchAgentRequire && "switch_agent" in finalTools) { - toolChoice = { type: "tool", toolName: "switch_agent" }; - } - } - - // Anthropic Extended Thinking is incompatible with forced tool choice. - // If a tool is forced, disable thinking for this request to avoid API errors. - if (toolChoice) { - const [provider] = normalizeToCanonical(modelString).split(":", 2); - if ( - provider === "anthropic" && - providerOptions && - typeof providerOptions === "object" && - "anthropic" in providerOptions - ) { - const anthropicOptions = (providerOptions as { anthropic?: unknown }).anthropic; - if ( - anthropicOptions && - typeof anthropicOptions === "object" && - "thinking" in anthropicOptions - ) { - const { thinking: _thinking, ...rest } = anthropicOptions as Record; - finalProviderOptions = { - ...providerOptions, - anthropic: rest, - }; - } - } - } - return { model, messages: finalMessages, @@ -1258,7 +1208,6 @@ export class StreamManager extends EventEmitter { onChunk, onStepMessages, toolPolicy, - toolChoice, }; } @@ -1266,7 +1215,7 @@ export class StreamManager extends EventEmitter { request: Pick ): Array> { // Completion-tool stop check: completion/routing tools use explicit - // success/ok markers (agent_report, propose_plan, switch_agent). + // success/ok markers (agent_report, propose_plan). // When a marker is present, respect it — success:false means the tool // should be retried, so don't stop. When no marker is present (e.g., // MCP tools, arbitrary required tools), treat non-null object results @@ -1342,9 +1291,6 @@ export class StreamManager extends EventEmitter { }, onChunk: request.onChunk, tools: request.tools, - // When set (top-level agents), force the model to call the required tool. - // stopWhen still runs and ends the stream once a successful result appears. - toolChoice: request.toolChoice, stopWhen: this.createStopWhenCondition(request), // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment providerOptions: request.providerOptions as any, // Pass provider-specific options (thinking/reasoning config) @@ -1374,7 +1320,6 @@ export class StreamManager extends EventEmitter { providerOptions?: Record, maxOutputTokens?: number, toolPolicy?: ToolPolicy, - forceToolChoice?: boolean, callSettingsOverrides?: ResolvedCallSettingsOverrides, hasQueuedMessage?: () => boolean, workspaceName?: string, @@ -1398,7 +1343,6 @@ export class StreamManager extends EventEmitter { maxOutputTokens, callSettingsOverrides, toolPolicy, - forceToolChoice, hasQueuedMessage, headers, anthropicCacheTtlOverride, @@ -2893,7 +2837,6 @@ export class StreamManager extends EventEmitter { thinkingLevel?: string, headers?: Record, anthropicCacheTtlOverride?: AnthropicCacheTtl, - forceToolChoice?: boolean, callSettingsOverrides?: ResolvedCallSettingsOverrides, onChunk?: StreamTextOnChunk, onStepMessages?: (messages: ModelMessage[]) => void, @@ -2972,7 +2915,6 @@ export class StreamManager extends EventEmitter { providerOptions, maxOutputTokens, toolPolicy, - forceToolChoice, callSettingsOverrides, hasQueuedMessage, workspaceName, diff --git a/src/node/services/tools/switch_agent.test.ts b/src/node/services/tools/switch_agent.test.ts deleted file mode 100644 index 2441a6092e..0000000000 --- a/src/node/services/tools/switch_agent.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ToolExecutionOptions } from "ai"; - -import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; -import type { ToolConfiguration } from "@/common/utils/tools/tools"; -import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; - -import { buildSwitchAgentDescription, createSwitchAgentTool } from "./switch_agent"; -import { TestTempDir, createTestToolConfig } from "./testHelpers"; - -const mockToolCallOptions: ToolExecutionOptions = { - toolCallId: "test-call-id", - messages: [], -}; - -function createAgentDescriptor( - id: string, - options: { description?: string; uiSelectable: boolean; uiRoutable: boolean } -): AgentDefinitionDescriptor { - return { - id, - scope: "project", - name: id, - description: options.description, - uiSelectable: options.uiSelectable, - uiRoutable: options.uiRoutable, - subagentRunnable: false, - }; -} - -function buildDescriptionWithAgents(availableSubagents: AgentDefinitionDescriptor[]): string { - const config = { - availableSubagents, - } as unknown as ToolConfiguration; - - return buildSwitchAgentDescription(config); -} - -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", - }); - }); - - 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", - }); - }); -}); - -describe("buildSwitchAgentDescription", () => { - test("includes visible agents", () => { - const description = buildDescriptionWithAgents([ - createAgentDescriptor("exec", { - description: "Execution mode", - uiSelectable: true, - uiRoutable: true, - }), - ]); - - expect(description).toContain("Available agents (use `agentId` parameter):"); - expect(description).toContain("- exec: Execution mode"); - }); - - test("excludes visible agent with explicit routable: false", () => { - const desc = buildSwitchAgentDescription({ - availableSubagents: [ - { - id: "restricted", - name: "Restricted", - description: "Restricted agent", - uiSelectable: true, - subagentRunnable: false, - scope: "project", - uiRoutable: false, - }, - ], - } as unknown as ToolConfiguration); - - expect(desc).not.toContain("restricted"); - expect(desc).not.toContain("Available agents"); - }); - - test("includes hidden agents with uiRoutable: true", () => { - const description = buildDescriptionWithAgents([ - createAgentDescriptor("secret-router", { - description: "Hidden but routable", - uiSelectable: false, - uiRoutable: true, - }), - ]); - - expect(description).toContain("Available agents (use `agentId` parameter):"); - expect(description).toContain("- secret-router: Hidden but routable"); - }); - - test("does not duplicate agent that is both selectable and routable", () => { - const description = buildDescriptionWithAgents([ - createAgentDescriptor("exec", { - description: "Implement changes", - uiSelectable: true, - uiRoutable: true, - }), - ]); - - const matches = description.match(/exec/g); - expect(matches?.length).toBe(1); - }); - - test("handles agent with no description", () => { - const description = buildDescriptionWithAgents([ - createAgentDescriptor("custom", { - uiSelectable: true, - uiRoutable: true, - }), - ]); - - expect(description).toContain("- custom"); - expect(description).not.toContain("- custom:"); - }); - - test("excludes hidden agents without uiRoutable", () => { - const description = buildDescriptionWithAgents([ - createAgentDescriptor("visible", { - description: "Visible", - uiSelectable: true, - uiRoutable: true, - }), - createAgentDescriptor("hidden", { - description: "Hidden", - uiSelectable: false, - uiRoutable: false, - }), - ]); - - expect(description).toContain("- visible: Visible"); - expect(description).not.toContain("- hidden: Hidden"); - }); - - test("returns base description when no routable agents", () => { - const description = buildDescriptionWithAgents([]); - - expect(description).toBe(TOOL_DEFINITIONS.switch_agent.description); - expect(description).not.toContain("Available agents (use `agentId` parameter):"); - }); -}); diff --git a/src/node/services/tools/switch_agent.ts b/src/node/services/tools/switch_agent.ts deleted file mode 100644 index 16d235cdec..0000000000 --- a/src/node/services/tools/switch_agent.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { tool } from "ai"; -import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; -import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; - -export function buildSwitchAgentDescription(config: ToolConfiguration): string { - const baseDescription = TOOL_DEFINITIONS.switch_agent.description; - // uiRoutable already incorporates uiSelectable as a fallback (via resolveUiRoutable), - // so checking uiRoutable alone is sufficient and respects explicit routable: false. - const availableAgents = config.availableSubagents?.filter((agent) => agent.uiRoutable) ?? []; - - if (availableAgents.length === 0) { - return baseDescription; - } - - const agentLines = availableAgents.map((agent) => { - const desc = agent.description ? `: ${agent.description}` : ""; - return `- ${agent.id}${desc}`; - }); - - return `${baseDescription}\n\nAvailable agents (use \`agentId\` parameter):\n${agentLines.join("\n")}`; -} - -export const createSwitchAgentTool: ToolFactory = (config: ToolConfiguration) => { - return tool({ - description: buildSwitchAgentDescription(config), - inputSchema: TOOL_DEFINITIONS.switch_agent.schema, - execute: (args) => { - // Validation of whether the target agent is UI-routable happens in the - // AgentSession follow-up handler, not here. This tool is a signal tool: - // StreamManager stops the stream on success, and AgentSession reads - // switch details from the tool input before enqueueing a follow-up. - // - // Defensive fallback: include target agentId in output so degraded streams - // that lose input metadata can still recover the destination agent without - // repeating follow-up payload in context. - return { - ok: true, - agentId: args.agentId, - }; - }, - }); -}; diff --git a/tests/ipc/acp.configOptions.test.ts b/tests/ipc/acp.configOptions.test.ts index 26c9e5c285..67aecfb9d5 100644 --- a/tests/ipc/acp.configOptions.test.ts +++ b/tests/ipc/acp.configOptions.test.ts @@ -28,7 +28,6 @@ const DEFAULT_AGENT_DESCRIPTORS: Awaited { name: "Ask", description: "Custom hidden ask agent", uiSelectable: false, - uiRoutable: true, subagentRunnable: false, }, ], From aa5dbfe0b1ccac0c7bba4576bfa3f3c096ccf375 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 27 May 2026 19:19:04 -0500 Subject: [PATCH 4/5] refactor: drop ui.requires; hardcode the desktop-capability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui.requires had two values: - requires: ["plan"] — used by the (now-deleted) switch_agent plan-gated check and by a UI hook that re-ran agents.list after propose_plan succeeded. Zero built-ins used it, no shipped docs surface featured it as a stable user-facing knob. - requires: ["desktop"] — used by the desktop built-in. After audit, the only remaining live job was hiding desktop from the task tool's subagent menu on workspaces without desktop capability. Since desktop is already hidden: true in the picker and never selectable that way, the agents.list gate was decorative. Replaced the generic field with a hardcoded check in the one place that still mattered: discoverAvailableSubagentsForToolContext now filters when `descriptor.id === "desktop"`. The router.ts agents.list machinery (plan-ready probe, desktop-capability probe, requires gating) is gone. The propose_plan → agents-refresh path in the chat aggregator is gone (kept the AGENTS_REFRESH_REQUESTED event; it is still dispatched from Settings when users toggle agent enablement manually). Net: +29 / -244 lines. 4,460 / 4,460 non-integration tests pass. --- docs/agents/index.mdx | 4 - ...pplyWorkspaceChatEventToAggregator.test.ts | 68 ------------- .../applyWorkspaceChatEventToAggregator.ts | 30 ------ src/common/orpc/schemas/agentDefinition.ts | 6 -- src/node/builtinAgents/desktop.md | 2 - src/node/orpc/router.ts | 53 +--------- src/node/orpc/server.test.ts | 96 ------------------- .../agentDefinitionsService.test.ts | 3 - .../builtInAgentContent.generated.ts | 2 +- .../builtInAgentDefinitions.test.ts | 1 - .../parseAgentDefinitionMarkdown.test.ts | 19 ---- .../builtInSkillContent.generated.ts | 4 - src/node/services/aiService.test.ts | 36 ++----- src/node/services/streamContextBuilder.ts | 5 +- 14 files changed, 13 insertions(+), 316 deletions(-) diff --git a/docs/agents/index.mdx b/docs/agents/index.mdx index 62a1c07ac8..3b09f8a5c6 100644 --- a/docs/agents/index.mdx +++ b/docs/agents/index.mdx @@ -76,8 +76,6 @@ disabled: false # Optional. When true, fully excludes this definition. # UI ui: hidden: false # Hide from the agent picker. - requires: # Capability gates: omit unless needed. - - desktop # "desktop" | "plan" color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset. # System prompt. @@ -473,8 +471,6 @@ description: Visual desktop automation agent for GUI-heavy, screenshot-intensive base: exec ui: hidden: true - requires: - - desktop subagent: runnable: true append_prompt: | diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts index a5a4bc1b1c..078c85939f 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.test.ts @@ -271,74 +271,6 @@ describe("applyWorkspaceChatEventToAggregator", () => { expect(aggregator.calls).toEqual(["handleToolCallDelta:tool-1"]); }); - test("tool-call-end dispatches AGENTS_REFRESH_REQUESTED for successful propose_plan", () => { - const aggregator = new StubAggregator(); - - const event: WorkspaceChatMessage = { - type: "tool-call-end", - workspaceId: "ws-1", - messageId: "msg-1", - toolCallId: "tool-1", - toolName: "propose_plan", - result: { success: true, planPath: "~/.mux/plans/demo/ws-1.md" }, - timestamp: 1, - }; - - withDispatchSpy((dispatched) => { - const hint = applyWorkspaceChatEventToAggregator(aggregator, event); - - expect(hint).toBe("immediate"); - expect(aggregator.calls).toEqual(["handleToolCallEnd:tool-1"]); - expect(dispatched).toHaveLength(1); - expect(dispatched[0]?.type).toBe(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED); - }); - }); - - test("tool-call-end does not dispatch AGENTS_REFRESH_REQUESTED for replayed/failed/non-plan events", () => { - const aggregator = new StubAggregator(); - - const replayedProposePlan: WorkspaceChatMessage = { - type: "tool-call-end", - workspaceId: "ws-1", - messageId: "msg-1", - toolCallId: "tool-replay", - toolName: "propose_plan", - result: { success: true, planPath: "~/.mux/plans/demo/ws-1.md" }, - timestamp: 1, - replay: true, - }; - - const failedProposePlan: WorkspaceChatMessage = { - type: "tool-call-end", - workspaceId: "ws-1", - messageId: "msg-2", - toolCallId: "tool-failed", - toolName: "propose_plan", - result: { success: false, error: "Plan file missing" }, - timestamp: 2, - }; - - const nonPlanTool: WorkspaceChatMessage = { - type: "tool-call-end", - workspaceId: "ws-1", - messageId: "msg-3", - toolCallId: "tool-bash", - toolName: "bash", - result: { success: true }, - timestamp: 3, - }; - - withDispatchSpy((dispatched) => { - expect(applyWorkspaceChatEventToAggregator(aggregator, replayedProposePlan)).toBe( - "immediate" - ); - expect(applyWorkspaceChatEventToAggregator(aggregator, failedProposePlan)).toBe("immediate"); - expect(applyWorkspaceChatEventToAggregator(aggregator, nonPlanTool)).toBe("immediate"); - - expect(dispatched).toHaveLength(0); - }); - }); - test("message routes to handleMessage", () => { const aggregator = new StubAggregator(); diff --git a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts index 2af1bb3c97..d8cb10f1b6 100644 --- a/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts +++ b/src/browser/utils/messages/applyWorkspaceChatEventToAggregator.ts @@ -86,30 +86,6 @@ export interface WorkspaceChatEventAggregator { clearTokenState(messageId: string): void; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} - -function isSuccessfulProposePlanResult(result: unknown): boolean { - if (!isRecord(result)) return false; - if (result.success !== true) return false; - - const hasFileResult = typeof result.planPath === "string"; - const hasLegacyResult = typeof result.title === "string" && typeof result.plan === "string"; - return hasFileResult || hasLegacyResult; -} - -function shouldRefreshAgentsAfterToolCallEnd(event: ToolCallEndEvent): boolean { - if (event.replay === true) return false; - if (event.toolName !== "propose_plan") return false; - return isSuccessfulProposePlanResult(event.result); -} - -function dispatchAgentsRefreshRequested(): void { - if (typeof window === "undefined") return; - window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED)); -} - function dispatchSkillsRefreshRequested(): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.SKILLS_REFRESH_REQUESTED)); @@ -199,12 +175,6 @@ export function applyWorkspaceChatEventToAggregator( if (isToolCallEnd(event)) { aggregator.handleToolCallEnd(event); - if (allowSideEffects && shouldRefreshAgentsAfterToolCallEnd(event)) { - // Keep agent discovery in sync when propose_plan succeeds so conditionally visible - // agents (for example, those gated by `ui.requires: ["plan"]`) appear immediately. - dispatchAgentsRefreshRequested(); - } - if ( allowSideEffects && event.replay !== true && diff --git a/src/common/orpc/schemas/agentDefinition.ts b/src/common/orpc/schemas/agentDefinition.ts index 661abfaec8..47486fc7f9 100644 --- a/src/common/orpc/schemas/agentDefinition.ts +++ b/src/common/orpc/schemas/agentDefinition.ts @@ -6,8 +6,6 @@ export const AgentDefinitionScopeSchema = z.enum(["built-in", "project", "global export { AgentIdSchema } from "@/common/schemas/ids"; -const AgentDefinitionUiRequirementSchema = z.enum(["plan", "desktop"]); - const AgentDefinitionUiSchema = z .object({ // Opt out of the agent picker. Hidden agents can still run as subagents @@ -16,10 +14,6 @@ const AgentDefinitionUiSchema = z // UI color (CSS color value). Inherited from base agent if not specified. color: z.string().min(1).optional(), - - // Capability requirements. Enforced by `agents.list` (toggles uiSelectable) - // and by the task tool's subagent discovery (filters out unavailable agents). - requires: z.array(AgentDefinitionUiRequirementSchema).min(1).optional(), }) .strip(); diff --git a/src/node/builtinAgents/desktop.md b/src/node/builtinAgents/desktop.md index ad25ac1353..1497e98d24 100644 --- a/src/node/builtinAgents/desktop.md +++ b/src/node/builtinAgents/desktop.md @@ -4,8 +4,6 @@ description: Visual desktop automation agent for GUI-heavy, screenshot-intensive base: exec ui: hidden: true - requires: - - desktop subagent: runnable: true append_prompt: | diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 0d6f9cf103..0118d9e221 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -41,7 +41,7 @@ import { createReplayBufferedStreamMessageRelay } from "./replayBufferedStreamMe import { createRuntime, checkRuntimeAvailability } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; -import { hasNonEmptyPlanFile, readPlanFile } from "@/node/utils/runtime/helpers"; +import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; @@ -1430,26 +1430,7 @@ export const router = (authToken?: string) => { await context.aiService.waitForInit(input.workspaceId); } - const { runtime, discoveryPath, metadata } = await resolveAgentDiscoveryContext( - context, - input - ); - - // Agents can require a plan file before they're selectable (via `ui.requires: ["plan"]`). - // Fail closed: if plan state cannot be determined, treat it as missing. - let planReady = false; - if (input.workspaceId && metadata) { - try { - planReady = await hasNonEmptyPlanFile( - runtime, - metadata.name, - metadata.projectName, - input.workspaceId - ); - } catch { - planReady = false; - } - } + const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); const descriptors = await discoverAgentDefinitions(runtime, discoveryPath); @@ -1497,26 +1478,6 @@ export const router = (authToken?: string) => { }) ); - const needsDesktopCapability = resolved.some( - (entry) => - entry?.kind === "resolved" && - (entry.resolvedFrontmatter.ui?.requires?.includes("desktop") ?? false) - ); - // Fail closed: desktop-only agents stay non-selectable unless this request proves - // the active workspace has desktop capability. - let desktopCapabilityAvailable = false; - if (needsDesktopCapability && input.workspaceId) { - try { - // DesktopSessionManager.getCapability() is the source of truth for desktop-only UI - // gating. Reuse one request-scoped probe for every desktop-required agent. - desktopCapabilityAvailable = ( - await context.desktopSessionManager.getCapability(input.workspaceId) - ).available; - } catch { - desktopCapabilityAvailable = false; - } - } - return resolved.flatMap((entry) => { if (!entry) { return []; @@ -1525,20 +1486,12 @@ export const router = (authToken?: string) => { return [entry.descriptor]; } - const requiresPlan = entry.resolvedFrontmatter.ui?.requires?.includes("plan") ?? false; - const requiresDesktop = - entry.resolvedFrontmatter.ui?.requires?.includes("desktop") ?? false; - const uiSelectable = - entry.uiSelectableBase && - (!requiresPlan || planReady) && - (!requiresDesktop || desktopCapabilityAvailable); - return [ { ...entry.descriptor, name: entry.resolvedFrontmatter.name, description: entry.resolvedFrontmatter.description, - uiSelectable, + uiSelectable: entry.uiSelectableBase, uiColor: entry.resolvedFrontmatter.ui?.color, subagentRunnable: entry.resolvedFrontmatter.subagent?.runnable ?? false, base: entry.resolvedFrontmatter.base, diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts index 011c34eee9..f6aa908bb8 100644 --- a/src/node/orpc/server.test.ts +++ b/src/node/orpc/server.test.ts @@ -1633,102 +1633,6 @@ describe("createOrpcServer", () => { await runCase(true); }); - test("agents.list gates desktop-only agents with one capability probe", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agents-list-desktop-")); - const projectPath = path.join(tempRoot, "project"); - const agentsRoot = path.join(projectPath, ".mux", "agents"); - const config = new Config(tempRoot); - const metadata = { - id: "workspace-1", - name: "desktop-workspace", - projectName: "project", - projectPath, - runtimeConfig: { type: "local" as const }, - }; - - await fs.mkdir(agentsRoot, { recursive: true }); - await fs.writeFile( - path.join(agentsRoot, "desktop-one.md"), - `---\nname: Desktop One\nui:\n requires:\n - desktop\n---\nBody\n`, - "utf-8" - ); - await fs.writeFile( - path.join(agentsRoot, "desktop-two.md"), - `---\nname: Desktop Two\nui:\n requires:\n - desktop\n---\nBody\n`, - "utf-8" - ); - await fs.writeFile( - path.join(agentsRoot, "plain.md"), - `---\nname: Plain Agent\n---\nBody\n`, - "utf-8" - ); - - async function runCase(available: boolean): Promise { - const waitForInit = mock(() => Promise.resolve(undefined)); - const getWorkspaceMetadata = mock(() => - Promise.resolve({ success: true as const, data: metadata }) - ); - const getCapability = mock(() => - Promise.resolve( - available - ? { - available: true as const, - width: 1440, - height: 900, - sessionId: `desktop:${metadata.id}`, - } - : { - available: false as const, - reason: "unsupported_runtime" as const, - } - ) - ); - - const stubContext: Partial = { - config, - aiService: { - waitForInit, - getWorkspaceMetadata, - } as unknown as ORPCContext["aiService"], - desktopSessionManager: { - getCapability, - } as unknown as ORPCContext["desktopSessionManager"], - }; - - let server: Awaited> | null = null; - - try { - server = await createOrpcServer({ - host: "127.0.0.1", - port: 0, - context: stubContext as ORPCContext, - authToken: "test-token", - }); - - const client = createHttpClient(server.baseUrl, { - Authorization: "Bearer test-token", - }); - const agents = await Promise.resolve(client.agents.list({ workspaceId: metadata.id })); - - expect(waitForInit).toHaveBeenCalledTimes(1); - expect(getWorkspaceMetadata).toHaveBeenCalledTimes(1); - expect(getCapability).toHaveBeenCalledTimes(1); - expect(agents.find((agent) => agent.id === "desktop-one")?.uiSelectable).toBe(available); - expect(agents.find((agent) => agent.id === "desktop-two")?.uiSelectable).toBe(available); - expect(agents.find((agent) => agent.id === "plain")?.uiSelectable).toBe(true); - } finally { - await server?.close(); - } - } - - try { - await runCase(false); - await runCase(true); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } - }); - test("general.restartApp delegates to the window service restart hook", async () => { const restartApp = mock(() => Promise.resolve({ supported: true as const })); const stubContext: Partial = { diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.test.ts b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts index 3218eb2b0a..e839f1e4fe 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.test.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.test.ts @@ -576,8 +576,6 @@ description: Base description ui: hidden: true color: red - requires: - - plan subagent: runnable: true append_prompt: Base subagent prompt @@ -617,7 +615,6 @@ Project body. expect(frontmatter.description).toBe("Base description"); expect(frontmatter.ui?.hidden).toBe(true); expect(frontmatter.ui?.color).toBe("blue"); - expect(frontmatter.ui?.requires).toEqual(["plan"]); expect(frontmatter.subagent?.runnable).toBe(true); expect(frontmatter.subagent?.append_prompt).toBe("Base subagent prompt"); expect(frontmatter.subagent?.skip_init_hook).toBe(true); diff --git a/src/node/services/agentDefinitions/builtInAgentContent.generated.ts b/src/node/services/agentDefinitions/builtInAgentContent.generated.ts index 131220aaf4..26d9021938 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 = { "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", - "desktop": "---\nname: Desktop\ndescription: Visual desktop automation agent for GUI-heavy, screenshot-intensive workflows\nbase: exec\nui:\n hidden: true\n requires:\n - desktop\nsubagent:\n runnable: true\n append_prompt: |\n You are a desktop automation sub-agent running in a child workspace.\n\n - Your job: interact with the desktop GUI via screenshot-driven automation.\n - Always take a screenshot before starting a GUI interaction sequence.\n - Follow the grounding loop: screenshot → identify target → act → screenshot to verify.\n - After completing the task, summarize the outcome back to the parent with only\n the result plus selected evidence (e.g., a final screenshot path).\n - Do not expand scope beyond the delegated desktop task.\n - Call `agent_report` exactly once when done.\nprompt:\n append: true\nai:\n thinkingLevel: medium\ntools:\n add:\n - desktop_screenshot\n - desktop_move_mouse\n - desktop_click\n - desktop_double_click\n - desktop_drag\n - desktop_scroll\n - desktop_type\n - desktop_key_press\n remove:\n # Desktop agent should not recursively orchestrate child agents\n - task\n - task_await\n - task_list\n - task_terminate\n - task_apply_git_patch\n # No planning tools\n - propose_plan\n - ask_user_question\n # Global config and catalog tools\n - mux_agents_.*\n - agent_skill_write\n---\n\nYou are a desktop automation agent.\n\n- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.\n- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.\n- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.\n- **Defensive interaction patterns:**\n - Wait briefly after clicks before verifying because menus and dialogs may animate.\n - For text input, click the target field first, verify focus, then type.\n - For drag operations, verify both the start and end positions with screenshots.\n - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.\n- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.\n- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.\n- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.\n", + "desktop": "---\nname: Desktop\ndescription: Visual desktop automation agent for GUI-heavy, screenshot-intensive workflows\nbase: exec\nui:\n hidden: true\nsubagent:\n runnable: true\n append_prompt: |\n You are a desktop automation sub-agent running in a child workspace.\n\n - Your job: interact with the desktop GUI via screenshot-driven automation.\n - Always take a screenshot before starting a GUI interaction sequence.\n - Follow the grounding loop: screenshot → identify target → act → screenshot to verify.\n - After completing the task, summarize the outcome back to the parent with only\n the result plus selected evidence (e.g., a final screenshot path).\n - Do not expand scope beyond the delegated desktop task.\n - Call `agent_report` exactly once when done.\nprompt:\n append: true\nai:\n thinkingLevel: medium\ntools:\n add:\n - desktop_screenshot\n - desktop_move_mouse\n - desktop_click\n - desktop_double_click\n - desktop_drag\n - desktop_scroll\n - desktop_type\n - desktop_key_press\n remove:\n # Desktop agent should not recursively orchestrate child agents\n - task\n - task_await\n - task_list\n - task_terminate\n - task_apply_git_patch\n # No planning tools\n - propose_plan\n - ask_user_question\n # Global config and catalog tools\n - mux_agents_.*\n - agent_skill_write\n---\n\nYou are a desktop automation agent.\n\n- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.\n- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.\n- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.\n- **Defensive interaction patterns:**\n - Wait briefly after clicks before verifying because menus and dialogs may animate.\n - For text input, click the target field first, verify focus, then type.\n - For drag operations, verify both the start and end positions with screenshots.\n - If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.\n- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.\n- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.\n- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.\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 - If the task brief includes clear starting points and acceptance criteria (or a concrete approved plan handoff) — implement it directly.\n Do not spawn `explore` tasks or write a \"mini-plan\" unless you are concretely blocked by a missing fact (e.g., a file path that doesn't exist, an unknown symbol name, or an error that contradicts the brief).\n - When you do need repo context you don't have, prefer 1–3 narrow `explore` tasks (possibly in parallel) over broad manual file-reading.\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 - Never amend existing commits — always create new commits on top.\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 # Global config and catalog tools stay out of general-purpose agents\n - mux_agents_.*\n - agent_skill_write\n - agent_skill_delete\n - mux_config_read\n - mux_config_write\n - skills_catalog_.*\n - analytics_query\n---\n\nYou are in Exec mode.\n\n- If an accepted `` block is provided, treat it as the contract and implement it directly. Only do extra exploration if the plan references non-existent files/symbols or if errors contradict it.\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\n## Desktop Automation\n\nWhen a task involves repeated screenshot/action/verify loops for desktop GUI interaction (for example, clicking through application UIs, filling desktop app forms, or visually verifying GUI state), delegate to the `desktop` agent via `task` rather than performing desktop automation inline. The desktop agent is purpose-built for the screenshot → act → verify grounding loop.\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; skill tools are kept)\n remove:\n - image_.*\n - file_edit_.*\n - task\n - task_apply_git_patch\n - task_.*\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", "name_workspace": "---\nname: Name Workspace\ndescription: Generate workspace name and title from user message\nui:\n hidden: true\nsubagent:\n runnable: false\ntools:\n require:\n - propose_name\n---\n\nYou are a workspace naming assistant. Your only job is to call the `propose_name` tool with a suitable name and title.\n\nDo not emit text responses. Call the `propose_name` tool immediately.\n", diff --git a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts index dfbc3291a0..eafc8ec537 100644 --- a/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts +++ b/src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts @@ -24,7 +24,6 @@ describe("built-in agent definitions", () => { expect(desktop).toBeTruthy(); expect(desktop?.frontmatter.base).toBe("exec"); expect(desktop?.frontmatter.ui?.hidden).toBe(true); - expect(desktop?.frontmatter.ui?.requires).toContain("desktop"); expect(desktop?.frontmatter.subagent?.runnable).toBe(true); expect(desktop?.frontmatter.ai?.thinkingLevel).toBe("medium"); expect(desktop?.frontmatter.tools?.add ?? []).toEqual([ diff --git a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts index a0b3a0e3d0..b28525c06d 100644 --- a/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts +++ b/src/node/services/agentDefinitions/parseAgentDefinitionMarkdown.test.ts @@ -47,25 +47,6 @@ Do the thing. expect(result.body).toContain("# Instructions"); }); - test("parses ui.requires", () => { - const content = `--- -name: Requires Capabilities -ui: - requires: - - plan - - desktop ---- -Body -`; - - const result = parseAgentDefinitionMarkdown({ - content, - byteSize: Buffer.byteLength(content, "utf-8"), - }); - - expect(result.frontmatter.ui?.requires).toEqual(["plan", "desktop"]); - }); - test("parses subagent.skip_init_hook", () => { const content = `--- name: Skip Init diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 301d19e5e4..766a9bfa43 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1018,8 +1018,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "# UI", "ui:", " hidden: false # Hide from the agent picker.", - " requires: # Capability gates: omit unless needed.", - ' - desktop # "desktop" | "plan"', ' color: "var(--color-exec-mode)" # CSS color or var; inherited from base if unset.', "", "# System prompt.", @@ -1415,8 +1413,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "base: exec", "ui:", " hidden: true", - " requires:", - " - desktop", "subagent:", " runnable: true", " append_prompt: |", diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index dd7c782808..84ee145741 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2847,23 +2847,12 @@ describe("discoverAvailableSubagentsForToolContext", () => { } }); - it("filters desktop-only agents with a single capability probe", async () => { + it("filters the desktop agent when capability is unavailable", async () => { using project = new DisposableTempDir("available-subagents-desktop"); using muxHome = new DisposableTempDir("available-subagents-desktop-home"); const agentsRoot = path.join(project.path, ".mux", "agents"); await fs.mkdir(agentsRoot, { recursive: true }); - - await fs.writeFile( - path.join(agentsRoot, "desktop-one.md"), - `---\nname: Desktop One\nbase: exec\nui:\n requires:\n - desktop\n---\nBody\n`, - "utf-8" - ); - await fs.writeFile( - path.join(agentsRoot, "desktop-two.md"), - `---\nname: Desktop Two\nbase: exec\nui:\n requires:\n - desktop\n---\nBody\n`, - "utf-8" - ); await fs.writeFile( path.join(agentsRoot, "plain.md"), `---\nname: Plain Agent\nbase: exec\n---\nBody\n`, @@ -2890,25 +2879,15 @@ describe("discoverAvailableSubagentsForToolContext", () => { loadDesktopCapability, }); - expect(loadDesktopCapability).toHaveBeenCalledTimes(1); - expect(availableSubagents.find((agent) => agent.id === "desktop-one")).toBeUndefined(); - expect(availableSubagents.find((agent) => agent.id === "desktop-two")).toBeUndefined(); + // The built-in `desktop` agent should be filtered out when capability is unavailable. + expect(availableSubagents.find((agent) => agent.id === "desktop")).toBeUndefined(); expect(availableSubagents.find((agent) => agent.id === "plain")?.subagentRunnable).toBe(true); }); - it("keeps desktop-only agents when desktop capability is available", async () => { + it("keeps the desktop agent when capability is available", async () => { using project = new DisposableTempDir("available-subagents-desktop-enabled"); using muxHome = new DisposableTempDir("available-subagents-desktop-enabled-home"); - const agentsRoot = path.join(project.path, ".mux", "agents"); - await fs.mkdir(agentsRoot, { recursive: true }); - - await fs.writeFile( - path.join(agentsRoot, "desktop-enabled.md"), - `---\nname: Desktop Enabled\nbase: exec\nui:\n requires:\n - desktop\n---\nBody\n`, - "utf-8" - ); - const runtime = new LocalRuntime(project.path); const cfg = new Config(muxHome.path).loadConfigOrDefault(); const loadDesktopCapability = mock(() => @@ -2925,15 +2904,12 @@ describe("discoverAvailableSubagentsForToolContext", () => { workspacePath: project.path, cfg, roots: { - projectRoot: agentsRoot, + projectRoot: path.join(project.path, "empty-project-agents"), globalRoot: path.join(project.path, "empty-global-agents"), }, loadDesktopCapability, }); - expect(loadDesktopCapability).toHaveBeenCalledTimes(1); - expect( - availableSubagents.find((agent) => agent.id === "desktop-enabled")?.subagentRunnable - ).toBe(true); + expect(availableSubagents.find((agent) => agent.id === "desktop")?.subagentRunnable).toBe(true); }); }); diff --git a/src/node/services/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 1e42a01433..4784b89df0 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -663,8 +663,9 @@ export async function discoverAvailableSubagentsForToolContext(args: { return null; } - const requiresDesktop = resolvedFrontmatter.ui?.requires?.includes("desktop") ?? false; - if (requiresDesktop && !(await isDesktopAvailable())) { + // Desktop is the only built-in that depends on runtime capability. Hide it from + // the task tool's subagent menu when the active workspace has no desktop session. + if (descriptor.id === "desktop" && !(await isDesktopAvailable())) { return null; } From 8b382786dfd1cd501c871458cffff1fb031a74f3 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 28 May 2026 10:20:42 -0500 Subject: [PATCH 5/5] fix: scope desktop capability gate to built-in agents A user-defined .mux/agents/desktop.md (or global equivalent) replaces the built-in semantics entirely. Without the scope check, the runtime gate silently hid such overrides whenever the workspace lacked desktop capability, even when the override no longer depended on it. Tighten the check to descriptor.scope === "built-in" so only the shipped built-in is gated; same-name overrides resolve via the agent discovery override chain as usual. Addresses Codex review on PR #3408. --- src/node/services/aiService.test.ts | 41 +++++++++++++++++++++++ src/node/services/streamContextBuilder.ts | 13 +++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 84ee145741..6d77aa22e9 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2912,4 +2912,45 @@ describe("discoverAvailableSubagentsForToolContext", () => { expect(availableSubagents.find((agent) => agent.id === "desktop")?.subagentRunnable).toBe(true); }); + + it("keeps a project-scope `desktop.md` override even when capability is unavailable", async () => { + using project = new DisposableTempDir("available-subagents-desktop-override"); + using muxHome = new DisposableTempDir("available-subagents-desktop-override-home"); + + const agentsRoot = path.join(project.path, ".mux", "agents"); + await fs.mkdir(agentsRoot, { recursive: true }); + // A user-defined `desktop` agent that does not need real desktop capability. + // The built-in same-name agent should be shadowed by this project-scope override; + // the runtime gate must not hide the override just because it shares the `desktop` id. + await fs.writeFile( + path.join(agentsRoot, "desktop.md"), + `---\nname: Custom Desktop\nbase: exec\nsubagent:\n runnable: true\n---\nBody\n`, + "utf-8" + ); + + const runtime = new LocalRuntime(project.path); + const cfg = new Config(muxHome.path).loadConfigOrDefault(); + const loadDesktopCapability = mock(() => + Promise.resolve({ + available: false as const, + reason: "unsupported_runtime" as const, + }) + ); + + const availableSubagents = await discoverAvailableSubagentsForToolContext({ + runtime, + workspacePath: project.path, + cfg, + roots: { + projectRoot: agentsRoot, + globalRoot: path.join(project.path, "empty-global-agents"), + }, + loadDesktopCapability, + }); + + const desktop = availableSubagents.find((agent) => agent.id === "desktop"); + expect(desktop).toBeDefined(); + expect(desktop?.scope).toBe("project"); + expect(desktop?.subagentRunnable).toBe(true); + }); }); diff --git a/src/node/services/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 4784b89df0..2eeb3e1269 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -663,9 +663,16 @@ export async function discoverAvailableSubagentsForToolContext(args: { return null; } - // Desktop is the only built-in that depends on runtime capability. Hide it from - // the task tool's subagent menu when the active workspace has no desktop session. - if (descriptor.id === "desktop" && !(await isDesktopAvailable())) { + // The built-in `desktop` agent is the only definition whose subagent menu visibility + // depends on runtime capability. Limit the gate to the built-in scope so a user + // override at `.mux/agents/desktop.md` (or the global equivalent) replaces the built-in + // semantics entirely and is not silently hidden when the workspace lacks a desktop + // session. + if ( + descriptor.id === "desktop" && + descriptor.scope === "built-in" && + !(await isDesktopAvailable()) + ) { return null; }