diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index cb69da51e5..220bb103b9 100644 Binary files a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png and b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png differ diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index c75eb6ee18..b66efd4fa4 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -4,8 +4,9 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" -import { getModeBySlug } from "../../../shared/modes" -import type { Task } from "../../task/Task" +import { useMcpToolTool } from "../../tools/UseMcpToolTool" +import { defaultModeSlug, getModeBySlug } from "../../../shared/modes" +import type { CurrentRequestToolPolicy, Task } from "../../task/Task" vi.mock("../../task/Task") vi.mock("../../../shared/modes", async (importOriginal) => { @@ -67,12 +68,19 @@ interface MockTask { providerRef: { deref: () => { getState: ReturnType - getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } | undefined } } say: ReturnType ask: ReturnType pushToolResultToUserContent: ReturnType + getTaskMode?: () => Promise + getCurrentRequestToolPolicy?: () => CurrentRequestToolPolicy +} + +function presentMockTask(task: MockTask) { + // This focused structural mock implements only the Task members reached by these tests. + return presentAssistantMessage(task as unknown as Task) } describe("presentAssistantMessage - tool usage attribution", () => { @@ -110,6 +118,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { mode: "code", customModes: [], }), + getMcpHub: () => undefined, }), }, say: vi.fn().mockResolvedValue(undefined), @@ -151,6 +160,161 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") }) + it("passes model-excluded tools to fallback execution validation", async () => { + mockTask.api.getModel = () => ({ + id: "test-model", + info: { excludedTools: ["read_file"] }, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_excluded", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][3]).toMatchObject({ read_file: false }) + }) + + it("uses task mode before live state when no request policy exists", async () => { + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_task_mode", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe("architect") + }) + + it("defaults fallback validation and resolves model-included tool aliases", async () => { + mockTask.api.getModel = () => ({ + id: "test-model", + info: { includedTools: ["search_and_replace"] }, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_default_policy", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + const validationCall = vi.mocked(validateToolUse).mock.calls[0] + expect(validationCall[1]).toBe(defaultModeSlug) + expect(validationCall[2]).toEqual([]) + expect(validationCall[6]).toEqual(["edit"]) + }) + + it("uses the request policy without reading the live focused mode", async () => { + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["read_file", "attempt_completion"]), + mode: "architect", + customModes: [], + experiments: {}, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockRejectedValue(new Error("live state should not be read")), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_request_policy", + name: "execute_command", + params: { command: "echo should-not-run" }, + nativeArgs: { command: "echo should-not-run" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("execute_command", expect.any(String)) + expect(mockTask.userMessageContent).toHaveLength(1) + }) + + it("validates available tools with the request mode and effective included set", async () => { + const effectiveToolNames = new Set(["read_file", "apply_patch", "attempt_completion"]) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames, + mode: "architect", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_allowed", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + const validationCall = vi.mocked(validateToolUse).mock.calls[0] + expect(validationCall[1]).toBe("architect") + expect(validationCall[6]).toEqual(Array.from(effectiveToolNames)) + }) + + it("passes global MCP disablement to fallback execution validation", async () => { + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [], mcpEnabled: false }), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp_disabled", + name: "access_mcp_resource", + params: { server_name: "test", uri: "resource://test" }, + nativeArgs: { server_name: "test", uri: "resource://test" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][3]).toMatchObject({ + use_mcp_tool: false, + access_mcp_resource: false, + }) + }) + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { mockTask.assistantMessageContent = [ { @@ -235,7 +399,210 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) + it.each([ + ["custom-mode", "Custom Mode"], + ["missing-mode", "missing-mode"], + ])("describes skipped new_task calls using the resolved mode name for %s", async (mode, expectedName) => { + mockTask.didRejectTool = true + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [ + { + slug: "custom-mode", + name: "Custom Mode", + roleDefinition: "Custom role", + groups: ["read"], + }, + ], + }), + }), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: `call_new_task_${mode}`, + name: "new_task", + params: { mode, message: "Delegate work" }, + nativeArgs: { mode, message: "Delegate work" }, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining(`[new_task in ${expectedName} mode`) }), + ) + }) + describe("native mcp_tool_use block", () => { + it("passes partial MCP blocks through without final validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_partial", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: true, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).toHaveBeenCalledTimes(1) + expect(validateToolUse).not.toHaveBeenCalled() + handleSpy.mockRestore() + }) + + it("applies fallback disablement before executing an MCP block", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.api.getModel = () => ({ + id: "test-model", + info: { excludedTools: ["mcp--blocked-server--blocked-tool"] }, + }) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + disabledTools: ["read_file"], + mcpEnabled: false, + }), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_fallback_blocked", + name: "mcp--blocked-server--blocked-tool", + serverName: "blocked-server", + toolName: "blocked-tool", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("use_mcp_tool", expect.any(String)) + handleSpy.mockRestore() + }) + + it("uses the task mode for fallback MCP validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_task_mode", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe("architect") + expect(vi.mocked(validateToolUse).mock.calls[0][2]).toEqual([]) + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + + it("uses the default mode for fallback MCP validation", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({}), + getMcpHub: () => undefined, + }), + } + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_default_mode", + name: "mcp--test-server--search", + serverName: "test-server", + toolName: "search", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(vi.mocked(validateToolUse).mock.calls[0][1]).toBe(defaultModeSlug) + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + + it("blocks an MCP tool absent from the request policy", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--allowed-server--allowed-tool"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_blocked", + name: "mcp--blocked-server--blocked-tool", + serverName: "blocked-server", + toolName: "blocked-tool", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).not.toHaveBeenCalled() + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(mockTask.userMessageContent).toHaveLength(1) + handleSpy.mockRestore() + }) + + it("matches provider-normalized MCP names against the request policy", async () => { + const handleSpy = vi.spyOn(useMcpToolTool, "handle").mockResolvedValue(undefined) + mockTask.getCurrentRequestToolPolicy = () => ({ + effectiveToolNames: new Set(["mcp--my-server--do-thing"]), + mode: "code", + customModes: [], + experiments: {}, + }) + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_normalized", + name: "mcp__my_server__do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentMockTask(mockTask) + + expect(handleSpy).toHaveBeenCalledTimes(1) + handleSpy.mockRestore() + }) + it("records exactly one attempt once the MCP tool's own validation passes", async () => { mockTask.providerRef = { deref: () => ({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7b25db4e66..2c67a91490 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -40,6 +40,7 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +import { isMcpTool, toolNamesMatch } from "../../utils/mcp-name" /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. @@ -289,6 +290,58 @@ export async function presentAssistantMessage(cline: Task) { }, } + if (!mcpBlock.partial) { + const requestPolicy = cline.getCurrentRequestToolPolicy?.() + const state = requestPolicy ? undefined : await cline.providerRef.deref()?.getState() + const mode = requestPolicy?.mode ?? (await cline.getTaskMode?.()) ?? state?.mode ?? defaultModeSlug + const customModes = requestPolicy?.customModes ?? state?.customModes + const experiments = requestPolicy?.experiments ?? state?.experiments + const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") + const unavailableTools = requestPolicy + ? [] + : [ + ...(state?.disabledTools ?? []), + ...(cline.api.getModel().info.excludedTools ?? []), + ...(state?.mcpEnabled === false ? ["use_mcp_tool", "access_mcp_resource"] : []), + ] + const toolRequirements = unavailableTools.reduce((acc: Record, toolName: string) => { + acc[toolName] = false + acc[resolveToolAlias(toolName)] = false + return acc + }, {}) + + try { + if ( + requestPolicy && + !Array.from(requestPolicy.effectiveToolNames).some( + (toolName) => isMcpTool(toolName) && toolNamesMatch(toolName, mcpBlock.name), + ) + ) { + throw new Error(`Tool "${mcpBlock.name}" is not available for this request.`) + } + if ( + !requestPolicy && + unavailableTools.some((toolName) => toolNamesMatch(toolName, mcpBlock.name)) + ) { + throw new Error(`Tool "${mcpBlock.name}" is not available for this model.`) + } + validateToolUse( + "use_mcp_tool", + mode, + customModes ?? [], + toolRequirements, + syntheticToolUse.params, + experiments, + requestPolicy ? Array.from(requestPolicy.effectiveToolNames) : undefined, + ) + } catch (error) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_mcp_tool", error.message) + pushToolResult(formatResponse.toolError(error.message)) + break + } + } + await useMcpToolTool.handle(cline, syntheticToolUse, { askApproval, handleError, @@ -342,9 +395,13 @@ export async function presentAssistantMessage(cline: Task) { break } - // Fetch state early so it's available for toolDescription and validation - const state = await cline.providerRef.deref()?.getState() + // Prefer the request snapshot so validation does not depend on mutable focused state. + const requestPolicy = cline.getCurrentRequestToolPolicy?.() + const state = requestPolicy ? undefined : await cline.providerRef.deref()?.getState() const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} + const effectiveMode = requestPolicy?.mode ?? (await cline.getTaskMode?.()) ?? mode ?? defaultModeSlug + const effectiveCustomModes = requestPolicy?.customModes ?? customModes + const effectiveExperiments = requestPolicy?.experiments ?? stateExperiments const toolDescription = (): string => { switch (block.name) { @@ -396,7 +453,7 @@ export async function presentAssistantMessage(cline: Task) { case "new_task": { const mode = block.params.mode ?? defaultModeSlug const message = block.params.message ?? "(no message)" - const modeName = getModeBySlug(mode, customModes)?.name ?? mode + const modeName = getModeBySlug(mode, effectiveCustomModes)?.name ?? mode return `[${block.name} in ${modeName} mode: '${message}']` } case "run_slash_command": @@ -438,8 +495,8 @@ export async function presentAssistantMessage(cline: Task) { // This avoids executing an invalid tool_use block and prevents duplicate/fragmented // error reporting. if (!block.partial) { - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined - const isKnownTool = isValidToolName(String(block.name), stateExperiments) + const customTool = effectiveExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const isKnownTool = isValidToolName(String(block.name), effectiveExperiments) if (isKnownTool && !block.nativeArgs && !customTool) { const errorMessage = `Invalid tool call for '${block.name}': missing nativeArgs. ` + @@ -447,7 +504,10 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ try { - cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage) + cline.recordToolError( + toTelemetryToolName(block.name, false, effectiveExperiments), + errorMessage, + ) } catch { // Best-effort only } @@ -599,29 +659,37 @@ export async function presentAssistantMessage(cline: Task) { // e.g., "edit_file" should resolve to "apply_diff" const rawIncludedTools = modelInfo?.info?.includedTools const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") - const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) + const includedTools = requestPolicy + ? Array.from(requestPolicy.effectiveToolNames) + : rawIncludedTools?.map((tool) => resolveToolAlias(tool)) - const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) + const isCustomTool = Boolean(effectiveExperiments?.customTools && customToolRegistry.has(block.name)) try { - const toolRequirements = - disabledTools?.reduce( - (acc: Record, tool: string) => { - acc[tool] = false - const resolvedToolName = resolveToolAlias(tool) - acc[resolvedToolName] = false - return acc - }, - {} as Record, - ) ?? {} + const unavailableTools = requestPolicy + ? [] + : [ + ...(disabledTools ?? []), + ...(modelInfo?.info?.excludedTools ?? []), + ...(state?.mcpEnabled === false ? ["use_mcp_tool", "access_mcp_resource"] : []), + ] + const toolRequirements = unavailableTools.reduce((acc: Record, tool: string) => { + acc[tool] = false + acc[resolveToolAlias(tool)] = false + return acc + }, {}) + const canonicalToolName = resolveToolAlias(block.name) + if (requestPolicy && !requestPolicy.effectiveToolNames.has(canonicalToolName)) { + throw new Error(`Tool "${block.name}" is not available for this request.`) + } validateToolUse( block.name as ToolName, - mode ?? defaultModeSlug, - customModes ?? [], + effectiveMode, + effectiveCustomModes ?? [], toolRequirements, block.params, - stateExperiments, + effectiveExperiments, includedTools, ) } catch (error) { @@ -643,7 +711,7 @@ export async function presentAssistantMessage(cline: Task) { // Record a safe failure key. Never key telemetry on the raw, // model-controlled tool name. cline.recordToolError( - toTelemetryToolName(block.name, isCustomTool, stateExperiments), + toTelemetryToolName(block.name, isCustomTool, effectiveExperiments), error.message, ) @@ -653,7 +721,7 @@ export async function presentAssistantMessage(cline: Task) { // Validation passed: record exactly one attempt at this single // central point. Individual tool handlers must not also record // usage, or the attempt would be double-counted. - const recordName = toTelemetryToolName(block.name, isCustomTool, stateExperiments) + const recordName = toTelemetryToolName(block.name, isCustomTool, effectiveExperiments) cline.recordToolUsage(recordName) TelemetryService.instance.captureToolUsage(cline.taskId, recordName) @@ -904,7 +972,9 @@ export async function presentAssistantMessage(cline: Task) { break } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const customTool = effectiveExperiments?.customTools + ? customToolRegistry.get(block.name) + : undefined if (customTool) { try { @@ -924,7 +994,7 @@ export async function presentAssistantMessage(cline: Task) { } const result = await customTool.execute(customToolArgs, { - mode: mode ?? defaultModeSlug, + mode: effectiveMode, task: cline, }) diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index df47e83c21..6b430b59b9 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -178,6 +178,8 @@ describe("getEnvironmentDetails", () => { false, mockCline.rooIgnoreController, false, + undefined, + true, ) }) @@ -187,6 +189,18 @@ describe("getEnvironmentDetails", () => { expect(formatResponse.formatFilesList).not.toHaveBeenCalled() }) + it("should not advertise list_files when it is unavailable", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + maxWorkspaceFiles: 0, + }) + + const result = await getEnvironmentDetails(mockCline as Task, true, new Set(["read_file"])) + + expect(result).toContain("Workspace files context disabled") + expect(result).not.toContain("list_files") + }) + it("should handle desktop directory specially", async () => { ;(arePathsEqual as Mock).mockReturnValue(true) const result = await getEnvironmentDetails(mockCline as Task, true) @@ -194,6 +208,16 @@ describe("getEnvironmentDetails", () => { expect(listFiles).not.toHaveBeenCalled() }) + it("should not advertise list_files for the desktop when it is unavailable", async () => { + ;(arePathsEqual as Mock).mockReturnValue(true) + + const result = await getEnvironmentDetails(mockCline as Task, true, new Set(["read_file"])) + + expect(result).toContain("(Desktop files not shown automatically.)") + expect(result).not.toContain("Use list_files") + expect(listFiles).not.toHaveBeenCalled() + }) + it("should skip file listing when maxWorkspaceFiles is 0", async () => { mockProvider.getState.mockResolvedValue({ ...mockState, @@ -375,6 +399,19 @@ describe("getEnvironmentDetails", () => { expect(result).not.toContain("REMINDERS") }) + it("should not advertise update_todo_list when it is unavailable", async () => { + mockProvider.getState.mockResolvedValue({ + ...mockState, + apiConfiguration: { todoListEnabled: true }, + }) + const cline = { ...mockCline, todoList: [{ content: "test", status: "pending" }] } + + const result = await getEnvironmentDetails(cline as Task, false, new Set(["read_file"])) + + expect(result).not.toContain("REMINDERS") + expect(result).not.toContain("update_todo_list") + }) + it("should include REMINDERS section when todoListEnabled is undefined", async () => { mockProvider.getState.mockResolvedValue({ ...mockState, diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 0e7d18a57a..b1c32796e2 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -20,8 +20,14 @@ import { getGitStatus } from "../../utils/git" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" -export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) { +export async function getEnvironmentDetails( + cline: Task, + includeFileDetails: boolean = false, + availableToolNames?: ReadonlySet, +) { let details = "" + const canListFiles = availableToolNames?.has("list_files") ?? true + const canUpdateTodoList = availableToolNames?.has("update_todo_list") ?? true const clineProvider = cline.providerRef.deref() const state = await clineProvider?.getState() @@ -233,13 +239,17 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (isDesktop) { // Don't want to immediately access desktop since it would show // permission popup. - details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" + details += canListFiles + ? "(Desktop files not shown automatically. Use list_files to explore if needed.)" + : "(Desktop files not shown automatically.)" } else { const maxFiles = maxWorkspaceFiles ?? 200 // Early return for limit of 0 if (maxFiles === 0) { - details += "(Workspace files context disabled. Use list_files to explore if needed.)" + details += canListFiles + ? "(Workspace files context disabled. Use list_files to explore if needed.)" + : "(Workspace files context disabled.)" } else { try { const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) @@ -251,6 +261,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo didHitLimit, cline.rooIgnoreController, showRooIgnoredFiles, + undefined, + canListFiles, ) details += result @@ -265,6 +277,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo state && typeof state.apiConfiguration?.todoListEnabled === "boolean" ? state.apiConfiguration.todoListEnabled : true - const reminderSection = todoListEnabled ? formatReminderSection(cline.todoList) : "" + const reminderSection = todoListEnabled && canUpdateTodoList ? formatReminderSection(cline.todoList) : "" return `\n${details.trim()}\n${reminderSection}\n` } diff --git a/src/core/prompts/__tests__/responses-rooignore.spec.ts b/src/core/prompts/__tests__/responses-rooignore.spec.ts index 03aae96776..17c6d83030 100644 --- a/src/core/prompts/__tests__/responses-rooignore.spec.ts +++ b/src/core/prompts/__tests__/responses-rooignore.spec.ts @@ -193,6 +193,24 @@ describe("RooIgnore Response Formatting", () => { expect(result).toMatch(/use list_files on specific subdirectories/i) }) + it("should omit the list_files hint when the tool is unavailable", async () => { + const controller = new RooIgnoreController(TEST_CWD) + await controller.initialize() + + const result = formatResponse.formatFilesList( + TEST_CWD, + ["file1.txt", "file2.txt"], + true, + controller, + true, + undefined, + false, + ) + + expect(result).toContain("File list truncated") + expect(result).not.toContain("list_files") + }) + /** * Tests formatFilesList handles empty results */ diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 79d4fad4ca..fa6cb8a757 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -88,6 +88,75 @@ describe("getCapabilitiesSection", () => { expect(result).not.toContain("MCP servers") }) + + it("describes only capabilities backed by the effective tool set", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).toContain("read files") + expect(result).toContain("ask follow-up questions") + expect(result).not.toContain("execute_command") + expect(result).not.toContain("list_files") + expect(result).not.toContain("write and edit files") + }) + + it("describes image generation separately from file editing", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["generate_image"]), + }) + + expect(result).toContain("generate images") + expect(result).not.toContain("write and edit files") + }) + + it("advertises MCP only when an MCP operation is in the effective tool set", () => { + const mockMcpHub = createMockMcpHub(["test-server"]) + const withoutMcpOperation = getCapabilitiesSection(cwd, mockMcpHub, undefined, { + availableToolNames: new Set(["read_file"]), + }) + const withMcpOperation = getCapabilitiesSection(cwd, mockMcpHub, undefined, { + availableToolNames: new Set(["read_file", "mcp--test-server--search"]), + }) + + expect(withoutMcpOperation).not.toContain("MCP servers") + expect(withMcpOperation).toContain("MCP servers") + }) + + it("describes command, listing, search, and unrestricted edit capabilities", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["execute_command", "list_files", "search_files", "write_to_file"]), + }) + + expect(result).toContain("execute CLI commands") + expect(result).toContain("list files") + expect(result).toContain("search source code") + expect(result).toContain("write and edit files") + expect(result).toContain("you can use the list_files tool") + expect(result).toContain("You can use the execute_command tool") + }) + + it("describes edit restrictions using their description or regex", () => { + const withDescription = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["apply_patch"]), + editFileRestriction: { fileRegex: "\\.md$", description: "Markdown files only" }, + }) + const withRegexOnly = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(["apply_patch"]), + editFileRestriction: { fileRegex: "\\.ts$" }, + }) + + expect(withDescription).toContain("edit files matching Markdown files only") + expect(withRegexOnly).toContain("edit files matching \\.ts$") + }) + + it("omits the capability summary when no tools are available", () => { + const result = getCapabilitiesSection(cwd, undefined, undefined, { + availableToolNames: new Set(), + }) + + expect(result).not.toContain("- You have access to tools that let you") + }) }) describe("getRulesSection", () => { @@ -144,6 +213,76 @@ describe("getRulesSection", () => { expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) + + it("omits command guidance and includes the active edit restriction", () => { + const result = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["write_to_file", "ask_followup_question", "attempt_completion"]), + editFileRestriction: { + fileRegex: "\\.md$", + description: "Markdown files only", + }, + }) + + expect(result).not.toContain("execute_command") + expect(result).not.toContain("Actively Running Terminals") + expect(result).toContain('The active mode can only edit files matching "\\.md$" (Markdown files only)') + }) + + it("varies tool guidance with the effective tool set", () => { + const fullResult = getRulesSection( + cwd, + { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + isStealthModel: true, + }, + { + availableToolNames: new Set([ + "execute_command", + "list_files", + "read_file", + "write_to_file", + "ask_followup_question", + "attempt_completion", + "access_mcp_resource", + ]), + }, + ) + const commandOnlyResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["execute_command"]), + }) + const restrictedResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["write_to_file"]), + editFileRestriction: { fileRegex: "\\.md$" }, + }) + const noToolsResult = getRulesSection(cwd, undefined, { + availableToolNames: new Set(), + }) + + expect(fullResult).toContain("Before using the execute_command tool") + expect(fullResult).toContain("list_files tool to list the files") + expect(fullResult).toContain("shouldn't use the read_file tool") + expect(fullResult).toContain("must use the attempt_completion tool") + expect(fullResult).toContain("MCP operations should be used one at a time") + expect(fullResult).toContain("VENDOR CONFIDENTIALITY") + expect(commandOnlyResult).not.toContain("ask_followup_question") + expect(restrictedResult).toContain('The active mode can only edit files matching "\\.md$".') + expect(noToolsResult).not.toContain("Use the tools provided") + expect(noToolsResult).not.toContain("wait for the user's response after each tool use") + }) + + it("includes PowerShell chaining guidance when command execution is available", () => { + vi.spyOn(shellUtils, "getShell").mockReturnValue( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ) + + const result = getRulesSection(cwd, undefined, { + availableToolNames: new Set(["execute_command"]), + }) + + expect(result).toContain("Note: Using `;` for PowerShell command chaining") + }) }) describe("getCommandChainOperator", () => { diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index d8671b2027..8231f178b1 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -192,6 +192,32 @@ const createMockMcpHub = (withServers: boolean = false): McpHub => connections: [], }) as unknown as McpHub +const generatePromptWithTools = ( + toolNames: string[], + mode: Mode = defaultModeSlug, + customModePrompts?: Parameters[6], + customModes?: ModeConfig[], +) => + SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, + undefined, + mode, + customModePrompts, + customModes, + undefined, + {}, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { availableToolNames: new Set(toolNames) }, + ) + describe("SYSTEM_PROMPT", () => { let mockMcpHub: McpHub let experiments: Record | undefined @@ -577,6 +603,62 @@ describe("SYSTEM_PROMPT", () => { expect(prompt).toContain("OBJECTIVE") }) + it("should omit system-owned guidance for unavailable tools", async () => { + const prompt = await generatePromptWithTools(["read_file", "ask_followup_question", "attempt_completion"]) + + expect(prompt).toContain("read files") + expect(prompt).not.toContain("execute_command") + expect(prompt).not.toContain("list_files") + expect(prompt).not.toContain("write and edit files") + }) + + it("should fall back to the default mode configuration for an unknown mode", async () => { + const prompt = await generatePromptWithTools(["read_file"], "unknown-mode") + + expect(prompt).toContain(modes[0].roleDefinition) + expect(prompt).toContain("read files") + }) + + it("should apply Architect tool availability and edit restrictions to built-in instructions", async () => { + const prompt = await generatePromptWithTools( + ["read_file", "write_to_file", "ask_followup_question", "attempt_completion"], + "architect", + ) + + expect(prompt).toContain("write the plan to a markdown file") + expect(prompt).toContain('The active mode can only edit files matching "\\.md$" (Markdown files only)') + expect(prompt).not.toContain("update_todo_list") + expect(prompt).not.toContain("switch_mode") + }) + + it("should preserve user-authored references to unavailable tools", async () => { + const userInstruction = "Document why execute_command is disabled for this workflow." + const prompt = await generatePromptWithTools( + ["read_file", "ask_followup_question", "attempt_completion"], + defaultModeSlug, + { + [defaultModeSlug]: { customInstructions: userInstruction }, + }, + ) + + expect(prompt).toContain(userInstruction) + }) + + it("should still gate stored built-in instructions when they equal the default", async () => { + const architectInstructions = modes.find((mode) => mode.slug === "architect")?.customInstructions + const prompt = await generatePromptWithTools( + ["read_file", "ask_followup_question", "attempt_completion"], + "architect", + { + architect: { customInstructions: architectInstructions }, + }, + ) + + expect(prompt).toContain("present the plan directly in your response") + expect(prompt).not.toContain("update_todo_list") + expect(prompt).not.toContain("switch_mode") + }) + describe("allowedMcpServers filtering in system prompt", () => { it("should exclude MCP capability text when allowedMcpServers is empty array", async () => { mockMcpHub = createMockMcpHub(true) diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123a..7ef969c93c 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -121,6 +121,7 @@ Otherwise, if you have not completed the task and do not need additional informa rooIgnoreController: RooIgnoreController | undefined, showRooIgnoredFiles: boolean, rooProtectedController?: RooProtectedController, + includeListFilesHint: boolean = true, ): string => { const sorted = files .map((file) => { @@ -180,9 +181,10 @@ Otherwise, if you have not completed the task and do not need additional informa } } if (didHitLimit) { - return `${rooIgnoreParsed.join( - "\n", - )}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)` + const hint = includeListFilesHint + ? " Use list_files on specific subdirectories if you need to explore further." + : "" + return `${rooIgnoreParsed.join("\n")}\n\n(File list truncated.${hint})` } else if (rooIgnoreParsed.length === 0 || (rooIgnoreParsed.length === 1 && rooIgnoreParsed[0] === "")) { return "No files found." } else { diff --git a/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts b/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts new file mode 100644 index 0000000000..e305069da7 --- /dev/null +++ b/src/core/prompts/sections/__tests__/markdown-formatting.spec.ts @@ -0,0 +1,13 @@ +import { markdownFormattingSection } from "../markdown-formatting" + +describe("markdownFormattingSection", () => { + it("references attempt_completion only when it is available", () => { + const withCompletion = markdownFormattingSection({ + availableToolNames: new Set(["attempt_completion"]), + }) + const withoutCompletion = markdownFormattingSection({ availableToolNames: new Set() }) + + expect(withCompletion).toContain("and ALSO those in attempt_completion") + expect(withoutCompletion).not.toContain("attempt_completion") + }) +}) diff --git a/src/core/prompts/sections/__tests__/mode-instructions.spec.ts b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts new file mode 100644 index 0000000000..5d554e57d4 --- /dev/null +++ b/src/core/prompts/sections/__tests__/mode-instructions.spec.ts @@ -0,0 +1,87 @@ +import { modes } from "../../../../shared/modes" +import { getBuiltInModeInstructions } from "../mode-instructions" + +function getInstructions(mode: string): string { + return modes.find((candidate) => candidate.slug === mode)?.customInstructions ?? "" +} + +describe("getBuiltInModeInstructions", () => { + it("replaces unavailable Architect control tools with supported planning guidance", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set(["read_file", "write_to_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).not.toContain("update_todo_list") + expect(result).not.toContain("switch_mode") + expect(result).toContain("write the plan to a markdown file") + }) + + it("uses a response plan when Architect has no edit tool", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).toContain("present the plan directly in your response") + expect(result).not.toContain("./plans") + }) + + it("does not treat image generation or replacement-only tools as plan file creation", () => { + const result = getBuiltInModeInstructions("architect", getInstructions("architect"), { + availableToolNames: new Set([ + "generate_image", + "search_replace", + "ask_followup_question", + "attempt_completion", + ]), + }) + + expect(result).toContain("present the plan directly in your response") + expect(result).not.toContain("write the plan to a markdown file") + }) + + it("removes Ask's external-resource claim when no MCP operation is available", () => { + const result = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["read_file", "ask_followup_question", "attempt_completion"]), + }) + + expect(result).not.toContain("access external resources") + }) + + it("preserves Ask's external-resource claim when an MCP operation is available", () => { + const withResourceTool = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["access_mcp_resource"]), + }) + const withDynamicTool = getBuiltInModeInstructions("ask", getInstructions("ask"), { + availableToolNames: new Set(["mcp--test-server--search"]), + }) + + expect(withResourceTool).toContain("access external resources") + expect(withDynamicTool).toContain("access external resources") + }) + + it("leaves other built-in modes unchanged", () => { + const instructions = getInstructions("code") + + expect( + getBuiltInModeInstructions("code", instructions, { + availableToolNames: new Set(), + }), + ).toBe(instructions) + }) + + it("leaves Architect instructions unchanged when all referenced tools are available", () => { + const instructions = getInstructions("architect") + + expect( + getBuiltInModeInstructions("architect", instructions, { + availableToolNames: new Set(["update_todo_list", "switch_mode", "write_to_file"]), + }), + ).toBe(instructions) + }) + + it("leaves built-in instructions unchanged when no prompt context is provided", () => { + const instructions = getInstructions("architect") + + expect(getBuiltInModeInstructions("architect", instructions)).toBe(instructions) + }) +}) diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index f776a326d2..a08a7e0cb2 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -44,4 +44,27 @@ describe("getObjectiveSection", () => { expect(objective).toContain("OBJECTIVE") expect(objective).toContain("You accomplish a given task iteratively") }) + + it("omits tool-specific steps when no tools are available", () => { + const objective = getObjectiveSection({ availableToolNames: new Set() }) + + expect(objective).not.toContain("utilizing available tools") + expect(objective).not.toContain("use attempt_completion") + }) + + it("uses non-interactive parameter guidance when follow-up questions are unavailable", () => { + const objective = getObjectiveSection({ availableToolNames: new Set(["read_file"]) }) + + expect(objective).toContain("DO NOT invoke the tool, including with filler values") + expect(objective).not.toContain("ask_followup_question tool") + }) + + it("includes question and completion guidance only when those tools are available", () => { + const objective = getObjectiveSection({ + availableToolNames: new Set(["ask_followup_question", "attempt_completion"]), + }) + + expect(objective).toContain("ask_followup_question tool") + expect(objective).toContain("use attempt_completion to present the result") + }) }) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 749b53a0fd..814083b6fe 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -63,4 +63,34 @@ describe("getSystemInfoSection", () => { expect(result).toContain("Operating System: win32 10.0.19043") }) + + it("includes workspace guidance only for available tools", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const withTools = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["execute_command", "list_files"]), + }) + const withoutTools = getSystemInfoSection(mockCwd, { availableToolNames: new Set() }) + + expect(withTools).toContain("New terminals will be created") + expect(withTools).toContain("you can use the list_files tool") + expect(withoutTools).not.toContain("New terminals will be created") + expect(withoutTools).not.toContain("you can use the list_files tool") + }) + + it("gates terminal and directory guidance independently", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const withCommandOnly = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["execute_command"]), + }) + const withListOnly = getSystemInfoSection(mockCwd, { + availableToolNames: new Set(["list_files"]), + }) + + expect(withCommandOnly).toContain("New terminals will be created") + expect(withCommandOnly).not.toContain("you can use the list_files tool") + expect(withListOnly).not.toContain("New terminals will be created") + expect(withListOnly).toContain("you can use the list_files tool") + }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 6d1f4b3fbf..ab3085af65 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -36,4 +36,21 @@ describe("getToolUseGuidelinesSection", () => { expect(guidelines).not.toContain("After each tool use, the user will respond with the result") }) + + it("omits tool guidance when no tools are available", () => { + const guidelines = getToolUseGuidelinesSection({ availableToolNames: new Set() }) + + expect(guidelines).toBe("") + }) + + it.each(["list_files", "execute_command"])( + "omits the list-files comparison when only %s is available", + (availableToolName) => { + const guidelines = getToolUseGuidelinesSection({ + availableToolNames: new Set([availableToolName]), + }) + + expect(guidelines).not.toContain("running a command like `ls`") + }, + ) }) diff --git a/src/core/prompts/sections/__tests__/tool-use.spec.ts b/src/core/prompts/sections/__tests__/tool-use.spec.ts index 878db81a1c..a298996f89 100644 --- a/src/core/prompts/sections/__tests__/tool-use.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use.spec.ts @@ -28,4 +28,16 @@ describe("getSharedToolUseSection", () => { expect(section).not.toContain("") expect(section).not.toContain("") }) + + it("omits tool-use instructions when no tools are available", () => { + const section = getSharedToolUseSection({ availableToolNames: new Set() }) + + expect(section).toBe("") + }) + + it("keeps tool-use instructions when a supplied context has an available tool", () => { + const section = getSharedToolUseSection({ availableToolNames: new Set(["read_file"]) }) + + expect(section).toContain("TOOL USE") + }) }) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index c493692401..76897d13a7 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -1,4 +1,6 @@ import { McpHub } from "../../../services/mcp/McpHub" +import { isMcpTool } from "../../../utils/mcp-name" +import { FILE_EDIT_TOOL_NAMES, hasAnyPromptTool, isPromptToolAvailable, type SystemPromptContext } from "../types" /** * Builds the CAPABILITIES section of the system prompt. @@ -16,7 +18,12 @@ import { McpHub } from "../../../services/mcp/McpHub" * @param allowedMcpServers Optional per-mode allowlist of MCP server names. When provided, * the hub's servers are filtered to this set before determining MCP availability. */ -export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpServers?: string[]): string { +export function getCapabilitiesSection( + cwd: string, + mcpHub?: McpHub, + allowedMcpServers?: string[], + context?: SystemPromptContext, +): string { // Determine whether any MCP server is actually available to the current mode. // Filtering the hub's servers by the allowlist (when provided) keeps the capability // text consistent with the tools that are exposed for the mode. @@ -30,14 +37,65 @@ export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpS hasMcpServers = servers.length > 0 } - return `==== + if (!context) { + return `==== CAPABILITIES - You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. - When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. - You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.${ - hasMcpServers + hasMcpServers + ? ` +- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. +` + : "" + }` + } + + const hasTool = (toolName: string) => isPromptToolAvailable(context, toolName) + const hasCommandTool = hasTool("execute_command") + const hasListTool = hasTool("list_files") + const hasReadTool = hasTool("read_file") + const hasSearchTool = hasTool("search_files") || hasTool("codebase_search") + const hasEditTool = hasAnyPromptTool(context, FILE_EDIT_TOOL_NAMES) + const hasImageTool = hasTool("generate_image") + const hasQuestionTool = hasTool("ask_followup_question") + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || Array.from(context.availableToolNames).some(isMcpTool) + + const capabilities: string[] = [] + if (hasCommandTool) capabilities.push("execute CLI commands on the user's computer") + if (hasListTool) capabilities.push("list files") + if (hasReadTool) capabilities.push("read files") + if (hasSearchTool) capabilities.push("search source code") + if (hasEditTool) { + const restriction = context?.editFileRestriction + capabilities.push( + restriction + ? `edit files matching ${restriction.description ?? restriction.fileRegex}` + : "write and edit files", + ) + } + if (hasImageTool) capabilities.push("generate images") + if (hasQuestionTool) capabilities.push("ask follow-up questions") + + const capabilitySummary = capabilities.length + ? `- You have access to tools that let you ${capabilities.join(", ")}.\n` + : "" + const listFilesGuidance = hasListTool + ? " If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop." + : "" + const commandGuidance = hasCommandTool + ? `\n- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.` + : "" + + return `==== + +CAPABILITIES + +${capabilitySummary}- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further.${listFilesGuidance}${commandGuidance}${ + hasMcpOperations ? ` - You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. ` diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index 318cd47bc9..89dfd5d5cc 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -8,3 +8,4 @@ export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" export { markdownFormattingSection } from "./markdown-formatting" export { getSkillsSection } from "./skills" +export { getBuiltInModeInstructions } from "./mode-instructions" diff --git a/src/core/prompts/sections/markdown-formatting.ts b/src/core/prompts/sections/markdown-formatting.ts index 0e47385632..25479cfae5 100644 --- a/src/core/prompts/sections/markdown-formatting.ts +++ b/src/core/prompts/sections/markdown-formatting.ts @@ -1,7 +1,14 @@ -export function markdownFormattingSection(): string { +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function markdownFormattingSection(context?: SystemPromptContext): string { + const completionReference = isPromptToolAvailable(context, "attempt_completion") + ? " and ALSO those in attempt_completion" + : "" + return `==== MARKDOWN RULES -ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses and ALSO those in attempt_completion` +ALL responses MUST show ANY \`language construct\` OR filename reference as clickable, exactly as [\`filename OR language.declaration()\`](relative/file/path.ext:line); line is required for \`syntax\` and optional for filename links. This applies to ALL markdown responses${completionReference}` } diff --git a/src/core/prompts/sections/mode-instructions.ts b/src/core/prompts/sections/mode-instructions.ts new file mode 100644 index 0000000000..173234c115 --- /dev/null +++ b/src/core/prompts/sections/mode-instructions.ts @@ -0,0 +1,72 @@ +import { isMcpTool } from "../../../utils/mcp-name" +import { hasAnyPromptTool, isPromptToolAvailable, type SystemPromptContext } from "../types" + +const ARCHITECT_TODO_STEP = /3\. Once you've gained more context[\s\S]*?(?=\n\n4\. As you gather)/ +const ARCHITECT_TODO_UPDATE_STEP = + /4\. As you gather more information or discover new requirements, update the todo list to reflect the current understanding of what needs to be accomplished\./ +const ARCHITECT_TODO_IMPORTANCE = + /\*\*IMPORTANT: Focus on creating clear, actionable todo lists rather than lengthy markdown documents\. Use the todo list as your primary planning tool to track and organize the work that needs to be done\.\*\*/ +const ARCHITECT_SWITCH_STEP = + /\n\n7\. Use the switch_mode tool to request that the user switch to another mode to implement the solution\./ +const ARCHITECT_PLAN_FILE = + /\n\nUnless told otherwise, if you want to save a plan file, put it in the \.\/plans directory \(a directory named "plans" relative to the workspace root, not the absolute filesystem path \/plans\)/ +const PLAN_FILE_TOOL_NAMES = ["write_to_file", "apply_patch"] as const + +/** Adjusts only Zoo-owned built-in mode instructions; user-authored instructions bypass this function. */ +export function getBuiltInModeInstructions(mode: string, instructions: string, context?: SystemPromptContext): string { + if (!context) { + return instructions + } + + if (mode === "ask") { + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || + Array.from(context.availableToolNames).some(isMcpTool) + return hasMcpOperations ? instructions : instructions.replace(", and access external resources", "") + } + + if (mode !== "architect") { + return instructions + } + + let result = instructions + let changed = false + const hasPlanFileTool = hasAnyPromptTool(context, PLAN_FILE_TOOL_NAMES) + + if (!isPromptToolAvailable(context, "update_todo_list")) { + const planDestination = hasPlanFileTool + ? "write the plan to a markdown file (e.g., `plan.md` or `todo.md`)" + : "present the plan directly in your response" + result = result.replace( + ARCHITECT_TODO_STEP, + `3. Once you've gained more context about the user's request, break down the task into clear, actionable steps and ${planDestination}. Each plan item should be:\n - Specific and actionable\n - Listed in logical execution order\n - Focused on a single, well-defined outcome\n - Clear enough that another mode could execute it independently`, + ) + result = result.replace( + ARCHITECT_TODO_UPDATE_STEP, + "4. As you gather more information or discover new requirements, update the plan to reflect the current understanding of what needs to be accomplished.", + ) + result = result.replace("refine the todo list", "refine the plan") + result = result.replace( + ARCHITECT_TODO_IMPORTANCE, + "**IMPORTANT: Focus on creating a clear, actionable plan rather than a lengthy markdown document.**", + ) + changed = true + } + + if (!isPromptToolAvailable(context, "switch_mode")) { + result = result.replace(ARCHITECT_SWITCH_STEP, "") + changed = true + } + + if (!hasPlanFileTool) { + result = result.replace(ARCHITECT_PLAN_FILE, "") + changed = true + } + + if (changed) { + let step = 0 + result = result.replace(/^\d+\. /gm, () => `${++step}. `) + } + + return result +} diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 2ef32bc144..e0812b6230 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -1,5 +1,9 @@ -export function getObjectiveSection(): string { - return `==== +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function getObjectiveSection(context?: SystemPromptContext): string { + if (!context) { + return `==== OBJECTIVE @@ -10,4 +14,35 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.` + } + + const steps = [ + "Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.", + ] + + if (context.availableToolNames.size > 0) { + const missingParameterGuidance = isPromptToolAvailable(context, "ask_followup_question") + ? " BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool." + : " BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool, including with filler values." + steps.push( + "Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.", + `Remember, the available tools can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use.${missingParameterGuidance} DO NOT ask for more information on optional parameters if it is not provided.`, + ) + } + + if (isPromptToolAvailable(context, "attempt_completion")) { + steps.push("Once the task is complete, use attempt_completion to present the result to the user.") + } + + steps.push( + "The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.", + ) + + return `==== + +OBJECTIVE + +You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. + +${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 4f6e573fa7..71ba75f82d 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,4 +1,11 @@ -import type { SystemPromptSettings } from "../types" +import { isMcpTool } from "../../../utils/mcp-name" +import { + FILE_EDIT_TOOL_NAMES, + hasAnyPromptTool, + isPromptToolAvailable, + type SystemPromptContext, + type SystemPromptSettings, +} from "../types" import { getShell } from "../../../utils/shell" @@ -62,12 +69,13 @@ When asked about your creator, vendor, or company, respond with: - "I don't have information about specific vendors"` } -export function getRulesSection(cwd: string, settings?: SystemPromptSettings): string { +export function getRulesSection(cwd: string, settings?: SystemPromptSettings, context?: SystemPromptContext): string { // Get shell-appropriate command chaining operator const chainOp = getCommandChainOperator() const chainNote = getCommandChainNote() - return `==== + if (!context) { + return `==== RULES @@ -92,4 +100,118 @@ RULES - Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. - MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` + } + + const hasCommandTool = context.availableToolNames.has("execute_command") + const hasListTool = context.availableToolNames.has("list_files") + const hasReadTool = context.availableToolNames.has("read_file") + const hasEditTool = hasAnyPromptTool(context, FILE_EDIT_TOOL_NAMES) + const hasQuestionTool = isPromptToolAvailable(context, "ask_followup_question") + const hasCompletionTool = isPromptToolAvailable(context, "attempt_completion") + const hasMcpOperations = + context.availableToolNames.has("access_mcp_resource") || Array.from(context.availableToolNames).some(isMcpTool) + const hasAnyTools = context.availableToolNames.size > 0 + const rules = [ + `- The project base directory is: ${cwd.toPosix()}`, + `- All file paths must be relative to this directory.${hasCommandTool ? " However, commands may change directories in terminals, so respect working directory specified by the response to execute_command." : ""}`, + `- You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.`, + "- Do not use the ~ character or $HOME to refer to the home directory.", + ] + + if (hasCommandTool) { + rules.push( + `- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""}`, + ) + } + + if (hasEditTool) { + const restriction = context.editFileRestriction + rules.push( + "- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.", + ) + if (restriction) { + rules.push( + ` * The active mode can only edit files matching "${restriction.fileRegex}"${restriction.description ? ` (${restriction.description})` : ""}.`, + ) + } + } + + rules.push( + "- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.", + "- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.", + ) + + const toolGuidance = hasAnyTools + ? " Use the tools provided to accomplish the user's request efficiently and effectively." + : "" + const completionGuidance = hasCompletionTool + ? " When you've completed your task, you must use the attempt_completion tool to present the result to the user." + : "" + rules.push( + `- Do not ask for more information than necessary.${toolGuidance}${completionGuidance} The user may provide feedback, which you can use to make improvements and try again.`, + ) + + if (hasQuestionTool) { + const listExample = hasListTool + ? " However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves." + : "" + rules.push( + `- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence.${listExample}`, + ) + } + + if (hasCommandTool) { + rules.push( + hasQuestionTool + ? "- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you." + : "- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly.", + ) + } + + if (hasReadTool) { + rules.push( + "- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.", + ) + } + + rules.push("- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.") + + if (hasCompletionTool) { + rules.push( + "- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.", + ) + } + + rules.push( + '- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I\'ve updated the CSS" but instead something like "I\'ve updated the CSS". It is important you be clear and technical in your messages.', + "- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.", + "- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.", + ) + + if (hasCommandTool) { + rules.push( + '- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn\'t need to start it again. If no active terminals are listed, proceed with command execution as normal.', + ) + } + + if (hasMcpOperations) { + rules.push( + "- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.", + ) + } + + if (hasAnyTools) { + const editExample = hasEditTool + ? " For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc." + : "" + rules.push( + `- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use.${editExample}`, + ) + } + + return `==== + +RULES + +${rules.join("\n")}${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` } diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index a4af3c6ac9..6fdfd9c226 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -2,8 +2,10 @@ import os from "os" import osName from "os-name" import { getShell } from "../../../utils/shell" +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" -export function getSystemInfoSection(cwd: string): string { +export function getSystemInfoSection(cwd: string, context?: SystemPromptContext): string { // Try to get detailed OS name, fall back to basic info if it fails let osInfo: string try { @@ -15,16 +17,29 @@ export function getSystemInfoSection(cwd: string): string { osInfo = `${platform} ${release}` } - const details = `==== + const header = `==== SYSTEM INFORMATION Operating System: ${osInfo} Default Shell: ${getShell()} Home Directory: ${os.homedir().toPosix()} -Current Workspace Directory: ${cwd.toPosix()} +Current Workspace Directory: ${cwd.toPosix()}` + + if (!context) { + return `${header} The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.` + } + + const terminalGuidance = isPromptToolAvailable(context, "execute_command") + ? " New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory." + : "" + const listFilesGuidance = isPromptToolAvailable(context, "list_files") + ? " If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop." + : "" + + return `${header} - return details +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations.${terminalGuidance} When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further.${listFilesGuidance}` } diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 78193372cc..29b4a46fc9 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,8 +1,20 @@ -export function getToolUseGuidelinesSection(): string { +import type { SystemPromptContext } from "../types" +import { isPromptToolAvailable } from "../types" + +export function getToolUseGuidelinesSection(context?: SystemPromptContext): string { + if (context && context.availableToolNames.size === 0) { + return "" + } + + const listFilesExample = + isPromptToolAvailable(context, "list_files") && isPromptToolAvailable(context, "execute_command") + ? " For example using the list_files tool is more effective than running a command like `ls` in the terminal." + : "" + return `# Tool Use Guidelines 1. Assess what information you already have and what information you need to proceed with the task. -2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like \`ls\` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information.${listFilesExample} It's critical that you think about each available tool and use the one that best fits the current step in the task. 3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.` diff --git a/src/core/prompts/sections/tool-use.ts b/src/core/prompts/sections/tool-use.ts index a3def86c07..ff23b23e2f 100644 --- a/src/core/prompts/sections/tool-use.ts +++ b/src/core/prompts/sections/tool-use.ts @@ -1,4 +1,10 @@ -export function getSharedToolUseSection(): string { +import type { SystemPromptContext } from "../types" + +export function getSharedToolUseSection(context?: SystemPromptContext): string { + if (context && context.availableToolNames.size === 0) { + return "" + } + return `==== TOOL USE diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 93f4a52846..a92122afe1 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -2,7 +2,15 @@ import * as vscode from "vscode" import { type ModeConfig, type PromptComponent, type CustomModePrompts, type TodoItem } from "@roo-code/types" -import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes" +import { + Mode, + modes, + defaultModeSlug, + getModeBySlug, + getModeConfig, + getGroupName, + getModeSelection, +} from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" @@ -11,7 +19,7 @@ import { McpHub } from "../../services/mcp/McpHub" import { CodeIndexManager } from "../../services/code-index/manager" import { SkillsManager } from "../../services/skills/SkillsManager" -import type { SystemPromptSettings } from "./types" +import type { SystemPromptContext, SystemPromptSettings } from "./types" import { getRulesSection, getSystemInfoSection, @@ -23,6 +31,7 @@ import { addCustomInstructions, markdownFormattingSection, getSkillsSection, + getBuiltInModeInstructions, } from "./sections" // Helper function to get prompt component, filtering out empty objects @@ -55,14 +64,37 @@ async function generatePrompt( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + promptContext?: SystemPromptContext, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") } // Get the full mode config to ensure we have the role definition (used for groups, etc.) - const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] + const builtInMode = modes.find((candidate) => candidate.slug === mode) + const modeConfig = getModeConfig(mode, customModeConfigs) const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs) + const editGroup = modeConfig.groups.find((groupEntry) => getGroupName(groupEntry) === "edit") + const editFileRestriction = + Array.isArray(editGroup) && editGroup[1].fileRegex + ? { + fileRegex: editGroup[1].fileRegex, + description: editGroup[1].description, + } + : undefined + const effectivePromptContext = promptContext + ? { + availableToolNames: promptContext.availableToolNames, + editFileRestriction, + } + : undefined + const hasUserAuthoredModeInstructions = + customModeConfigs?.some((customMode) => customMode.slug === mode) === true || + (Boolean(promptComponent?.customInstructions) && + promptComponent?.customInstructions !== builtInMode?.customInstructions) + const effectiveBaseInstructions = hasUserAuthoredModeInstructions + ? baseInstructions + : getBuiltInModeInstructions(mode, baseInstructions, effectivePromptContext) // Check if MCP functionality should be included const hasMcpGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") @@ -86,7 +118,9 @@ async function generatePrompt( const [modesSection, skillsSection] = await Promise.all([ getModesSection(context), - getSkillsSection(skillsManager, mode as string), + effectivePromptContext?.availableToolNames.has("skill") === false + ? Promise.resolve("") + : getSkillsSection(skillsManager, mode as string), ]) // Tools catalog is not included in the system prompt. @@ -94,11 +128,11 @@ async function generatePrompt( const basePrompt = `${roleDefinition} -${markdownFormattingSection()} +${markdownFormattingSection(effectivePromptContext)} -${getSharedToolUseSection()}${toolsCatalog} +${getSharedToolUseSection(effectivePromptContext)}${toolsCatalog} - ${getToolUseGuidelinesSection()} + ${getToolUseGuidelinesSection(effectivePromptContext)} ${ // Forward the hub only when the mode actually exposes the MCP group, and pass the per-mode @@ -107,18 +141,18 @@ ${ // the capability text consistent with the tools exposed in mixed cases (e.g. one allowed + // one disallowed server), preventing the section from advertising MCP based on a disallowed // server. `shouldIncludeMcp` is still used to short-circuit when no allowed server exists. - getCapabilitiesSection(cwd, hasMcpGroup ? mcpHub : undefined, allowedMcpServers) + getCapabilitiesSection(cwd, hasMcpGroup ? mcpHub : undefined, allowedMcpServers, effectivePromptContext) } ${modesSection} ${skillsSection ? `\n${skillsSection}` : ""} -${getRulesSection(cwd, settings)} +${getRulesSection(cwd, settings, effectivePromptContext)} -${getSystemInfoSection(cwd)} +${getSystemInfoSection(cwd, effectivePromptContext)} -${getObjectiveSection()} +${getObjectiveSection(effectivePromptContext)} -${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { +${await addCustomInstructions(effectiveBaseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), rooIgnoreInstructions, settings, @@ -144,6 +178,7 @@ export const SYSTEM_PROMPT = async ( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + promptContext?: SystemPromptContext, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -172,5 +207,6 @@ export const SYSTEM_PROMPT = async ( todoList, modelId, skillsManager, + promptContext, ) } diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index bc3cd0a360..38bc33fd70 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -2,7 +2,7 @@ import type OpenAI from "openai" -import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { filterMcpToolsForMode, filterNativeToolsForMode } from "../filter-tools-for-mode" function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { return { @@ -227,3 +227,47 @@ describe("filterNativeToolsForMode - access_mcp_resource allowlist", () => { }) }) }) + +describe("filterMcpToolsForMode", () => { + it("drops MCP tools when the mode does not allow MCP", () => { + const result = filterMcpToolsForMode([makeTool("mcp--server--search")], "orchestrator", undefined, undefined) + + expect(result).toEqual([]) + }) + + it("drops MCP tools excluded by the model", () => { + const result = filterMcpToolsForMode( + [makeTool("mcp--server--allowed"), makeTool("mcp--server--excluded")], + "code", + undefined, + undefined, + { + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: false, + excludedTools: ["mcp--server--excluded"], + }, + }, + ) + + expect(result.map((tool) => ("function" in tool ? tool.function?.name : undefined))).toEqual([ + "mcp--server--allowed", + ]) + }) + + it("drops malformed and explicitly disabled MCP tools", () => { + // Provider input is validated at runtime, so exercise the guard with a tool + // that is deliberately missing the statically required function payload. + const malformedTool = { type: "function" } as OpenAI.Chat.ChatCompletionTool + const result = filterMcpToolsForMode( + [malformedTool, makeTool("mcp--server--allowed"), makeTool("mcp--server--blocked")], + "code", + undefined, + undefined, + { disabledTools: ["mcp--server--blocked"] }, + ) + + expect(result).toHaveLength(1) + expect("function" in result[0] ? result[0].function?.name : undefined).toBe("mcp--server--allowed") + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..de95acac6e 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" import { getModeBySlug, getToolsForMode } from "../../../shared/modes" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES, isRequiredToolForMode } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" @@ -305,6 +305,14 @@ export function filterNativeToolsForMode( } } + // The task loop cannot safely ask for required input or finish without its lifecycle tools. + // Orchestrator likewise cannot perform its core workflow without new_task. + for (const toolName of allToolsForMode) { + if (isRequiredToolForMode(toolName, modeSlug)) { + allowedToolNames.add(resolveToolAlias(toolName)) + } + } + // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources. // When the mode restricts MCP servers via allowedMcpServers, only resources from allowed // servers count — otherwise a restricted mode could still read resources from disallowed servers. @@ -449,6 +457,7 @@ export function getAvailableToolsInGroup( * @param mode - Current mode slug * @param customModes - Custom mode configurations * @param experiments - Experiment flags + * @param settings - Request-specific disabled tools and model exclusions * @returns Filtered array of MCP tools if use_mcp_tool is allowed, empty array otherwise */ export function filterMcpToolsForMode( @@ -456,6 +465,7 @@ export function filterMcpToolsForMode( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, + settings?: { disabledTools?: string[]; modelInfo?: ModelInfo }, ): OpenAI.Chat.ChatCompletionTool[] { const modeSlug = mode ?? defaultModeSlug @@ -469,5 +479,23 @@ export function filterMcpToolsForMode( experiments ?? {}, ) - return isMcpAllowed ? mcpTools : [] + if (!isMcpAllowed) { + return [] + } + + const excludedToolNames = new Set( + [...(settings?.disabledTools ?? []), ...(settings?.modelInfo?.excludedTools ?? [])].map(resolveToolAlias), + ) + + if (excludedToolNames.has("use_mcp_tool")) { + return [] + } + + return mcpTools.filter((tool) => { + if (!("function" in tool) || !tool.function) { + return false + } + + return !excludedToolNames.has(resolveToolAlias(tool.function.name)) + }) } diff --git a/src/core/prompts/types.ts b/src/core/prompts/types.ts index a4c17c3a6e..a16f43b6f1 100644 --- a/src/core/prompts/types.ts +++ b/src/core/prompts/types.ts @@ -10,3 +10,30 @@ export interface SystemPromptSettings { /** When true, model should hide vendor/company identity in responses */ isStealthModel?: boolean } + +/** Request-scoped inputs used to keep system-owned guidance aligned with available tools. */ +export interface SystemPromptContext { + availableToolNames: ReadonlySet + editFileRestriction?: { + fileRegex: string + description?: string + } +} + +export const FILE_EDIT_TOOL_NAMES = [ + "write_to_file", + "apply_diff", + "edit", + "search_replace", + "edit_file", + "apply_patch", +] as const + +/** Legacy section callers omit context and retain the existing all-tools wording. */ +export function isPromptToolAvailable(context: SystemPromptContext | undefined, toolName: string): boolean { + return context?.availableToolNames.has(toolName) ?? true +} + +export function hasAnyPromptTool(context: SystemPromptContext | undefined, toolNames: readonly string[]): boolean { + return toolNames.some((toolName) => isPromptToolAvailable(context, toolName)) +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 37281a9010..a072f3c15b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -9,7 +9,6 @@ import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" import debounce from "lodash.debounce" import delay from "delay" import pWaitFor from "p-wait-for" @@ -97,7 +96,7 @@ import { getTaskDirectoryPath } from "../../utils/storage" // prompts import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" -import { buildNativeToolsArrayWithRestrictions } from "./build-tools" +import { buildNativeToolsArrayWithRestrictions, type BuildToolsResult } from "./build-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" @@ -194,6 +193,23 @@ export interface TaskOptions extends CreateTaskOptions { diffFuzzyThreshold?: number } +type TaskProviderState = Awaited> + +interface ResolvedPromptTools { + state: TaskProviderState + mode: string + mcpHub?: McpHub + toolsResult: BuildToolsResult +} + +export interface CurrentRequestToolPolicy { + effectiveToolNames: ReadonlySet + mode: string + customModes?: TaskProviderState["customModes"] + experiments?: TaskProviderState["experiments"] + allowedMcpServers?: string[] +} + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -205,6 +221,7 @@ export class Task extends EventEmitter implements TaskLike { readonly metadata: TaskMetadata todoList?: TodoItem[] + private currentRequestToolPolicy?: CurrentRequestToolPolicy readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined @@ -792,6 +809,11 @@ export class Task extends EventEmitter implements TaskLike { return this._taskMode || defaultModeSlug } + /** Tool policy used to generate and validate the currently streaming request. */ + public getCurrentRequestToolPolicy(): CurrentRequestToolPolicy | undefined { + return this.currentRequestToolPolicy + } + /** * Get the task mode synchronously. This should only be used when you're certain * that the mode has already been initialized (e.g., after waitForModeInitialization). @@ -1708,35 +1730,16 @@ export class Task extends EventEmitter implements TaskLike { // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() - const systemPrompt = await this.getSystemPrompt() - - // Get condensing configuration - const state = await this.providerRef.deref()?.getState() - const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE - // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration + await this.safeEnsureModelFetched() + const resolvedPromptTools = await this.resolvePromptTools() + const systemPrompt = await this.getSystemPrompt(resolvedPromptTools) + const { state, mode, toolsResult } = resolvedPromptTools + const customCondensingPrompt = state.customSupportPrompts?.CONDENSE const { contextTokens: prevContextTokens } = this.getTokenUsage() - // Build tools for condensing metadata (same tools used for normal API calls) - const provider = this.providerRef.deref() - let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] - if (provider) { - const modelInfo = this.api.getModel().info - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - allTools = toolsResult.tools - } + // Reuse the exact policy that generated the condensing system prompt. + const allTools = toolsResult.tools // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { @@ -1756,7 +1759,7 @@ export class Task extends EventEmitter implements TaskLike { : {}), } // Generate environment details to include in the condensed summary - const environmentDetails = await getEnvironmentDetails(this, true) + const environmentDetails = await getEnvironmentDetails(this, true, toolsResult.effectiveToolNames) const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext") @@ -2655,7 +2658,13 @@ export class Task extends EventEmitter implements TaskLike { // Add environment details to the existing last user message (which contains the tool_result) // This avoids creating a new user message which would cause consecutive user messages - const environmentDetails = await getEnvironmentDetails(this, true) + await this.safeEnsureModelFetched() + const resolvedPromptTools = await this.resolvePromptTools() + const environmentDetails = await getEnvironmentDetails( + this, + true, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) let lastUserMsgIndex = -1 for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { if (this.apiConversationHistory[i].role === "user") { @@ -2852,7 +2861,16 @@ export class Task extends EventEmitter implements TaskLike { } } - const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) + await this.safeEnsureModelFetched() + const supportsAllowedFunctionNames = this.apiConfiguration?.apiProvider === providerIdentifiers.gemini + const resolvedPromptTools = await this.resolvePromptTools({ + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + }) + const environmentDetails = await getEnvironmentDetails( + this, + currentIncludeFileDetails, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) // Remove any existing environment_details blocks before adding fresh ones. // This prevents duplicate environment details when resuming tasks, @@ -3005,8 +3023,6 @@ export class Task extends EventEmitter implements TaskLike { await this.diffViewProvider.reset() - await this.safeEnsureModelFetched() - // Cache model info once per API request to avoid repeated calls during streaming // This is especially important for tools and background usage collection this.cachedStreamingModel = this.api.getModel() @@ -3016,7 +3032,10 @@ export class Task extends EventEmitter implements TaskLike { // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). - const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) + const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { + skipProviderRateLimit: true, + resolvedPromptTools, + }) let assistantMessage = "" let reasoningMessage = "" const pendingGroundingSources: GroundingSource[] = [] @@ -4014,76 +4033,96 @@ export class Task extends EventEmitter implements TaskLike { return false } - private async getSystemPrompt(): Promise { - const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} - let mcpHub: McpHub | undefined - if (mcpEnabled ?? true) { - const provider = this.providerRef.deref() + private async getMcpHubForPrompt(state: TaskProviderState): Promise { + if (!(state.mcpEnabled ?? true)) { + return undefined + } - if (!provider) { - throw new Error("Provider reference lost during view transition") - } + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider reference lost during view transition") + } - // Wait for MCP hub initialization through McpServerManager - mcpHub = await McpServerManager.getInstance(provider.context, provider) + const mcpHub = await McpServerManager.getInstance(provider.context, provider) + if (!mcpHub) { + throw new Error("Failed to get MCP hub from server manager") + } - if (!mcpHub) { - throw new Error("Failed to get MCP hub from server manager") - } + await pWaitFor(() => !mcpHub.isConnecting, { timeout: 10_000 }).catch(() => { + console.error("MCP servers failed to connect in time") + }) - // Wait for MCP servers to be connected before generating system prompt - await pWaitFor(() => !mcpHub!.isConnecting, { timeout: 10_000 }).catch(() => { - console.error("MCP servers failed to connect in time") - }) - } + return mcpHub + } - const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() + private async resolvePromptTools(options?: { + state?: TaskProviderState + mode?: string + includeAllToolsWithRestrictions?: boolean + }): Promise { + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available") + } - const state = await this.providerRef.deref()?.getState() + const state = options?.state ?? (await provider.getState()) + const mode = options?.mode ?? (await this.getTaskMode()) + const mcpHub = await this.getMcpHubForPrompt(state) + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: this.cwd, + mode, + customModes: state.customModes, + experiments: state.experiments, + apiConfiguration: this.apiConfiguration, + disabledTools: state.disabledTools, + modelInfo: this.api.getModel().info, + mcpEnabled: state.mcpEnabled, + includeAllToolsWithRestrictions: options?.includeAllToolsWithRestrictions, + }) - const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = - state ?? {} - // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration + return { state, mode, mcpHub, toolsResult } + } - return await (async () => { - const provider = this.providerRef.deref() + private async getSystemPrompt(resolved?: ResolvedPromptTools): Promise { + const promptTools = resolved ?? (await this.resolvePromptTools()) + const { state, mode, mcpHub, toolsResult } = promptTools + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available") + } - if (!provider) { - throw new Error("Provider not available") - } + const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = + state + const modelInfo = this.api.getModel().info - const modelInfo = this.api.getModel().info - - return SYSTEM_PROMPT( - provider.context, - this.cwd, - false, - mcpHub, - this.diffStrategy, - mode ?? defaultModeSlug, - customModePrompts, - customModes, - customInstructions, - experiments, - language, - rooIgnoreInstructions, - { - todoListEnabled: apiConfiguration?.todoListEnabled ?? true, - useAgentRules: - vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, - enableSubfolderRules: enableSubfolderRules ?? false, - newTaskRequireTodos: vscode.workspace - .getConfiguration(Package.name) - .get("newTaskRequireTodos", false), - isStealthModel: modelInfo?.isStealthModel, - }, - undefined, // todoList - this.api.getModel().id, - provider.getSkillsManager(), - ) - })() + return SYSTEM_PROMPT( + provider.context, + this.cwd, + false, + mcpHub, + this.diffStrategy, + mode ?? defaultModeSlug, + customModePrompts, + customModes, + customInstructions, + experiments, + language, + this.rooIgnoreController?.getInstructions(), + { + todoListEnabled: this.apiConfiguration?.todoListEnabled ?? true, + useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, + enableSubfolderRules: enableSubfolderRules ?? false, + newTaskRequireTodos: vscode.workspace + .getConfiguration(Package.name) + .get("newTaskRequireTodos", false), + isStealthModel: modelInfo?.isStealthModel, + }, + undefined, // todoList + this.api.getModel().id, + provider.getSkillsManager(), + { availableToolNames: toolsResult.effectiveToolNames }, + ) } private getCurrentProfileId(state: any): string { @@ -4114,7 +4153,6 @@ export class Task extends EventEmitter implements TaskLike { const { profileThresholds = {} } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() - const apiConfiguration = this.apiConfiguration const { contextTokens } = this.getTokenUsage() await this.safeEnsureModelFetched() @@ -4143,23 +4181,8 @@ export class Task extends EventEmitter implements TaskLike { // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) - // Build tools for condensing metadata (same tools used for normal API calls) - const provider = this.providerRef.deref() - let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] - if (provider) { - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - allTools = toolsResult.tools - } + const resolvedPromptTools = await this.resolvePromptTools({ state, mode }) + const allTools = resolvedPromptTools.toolsResult.tools // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { @@ -4181,7 +4204,11 @@ export class Task extends EventEmitter implements TaskLike { try { // Generate environment details to include in the condensed summary - const environmentDetails = await getEnvironmentDetails(this, true) + const environmentDetails = await getEnvironmentDetails( + this, + true, + resolvedPromptTools.toolsResult.effectiveToolNames, + ) // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ @@ -4192,7 +4219,7 @@ export class Task extends EventEmitter implements TaskLike { apiHandler: this.api, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, - systemPrompt: await this.getSystemPrompt(), + systemPrompt: await this.getSystemPrompt(resolvedPromptTools), taskId: this.taskId, profileThresholds, currentProfileId, @@ -4282,9 +4309,9 @@ export class Task extends EventEmitter implements TaskLike { public async *attemptApiRequest( retryAttempt: number = 0, - options: { skipProviderRateLimit?: boolean } = {}, + options: { skipProviderRateLimit?: boolean; resolvedPromptTools?: ResolvedPromptTools } = {}, ): ApiStream { - const state = await this.providerRef.deref()?.getState() + const state = options.resolvedPromptTools?.state ?? (await this.providerRef.deref()?.getState()) const { autoApprovalEnabled, @@ -4294,7 +4321,7 @@ export class Task extends EventEmitter implements TaskLike { profileThresholds = {}, } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. - const mode = await this.getTaskMode() + const mode = options.resolvedPromptTools?.mode ?? (await this.getTaskMode()) const apiConfiguration = this.apiConfiguration // Get condensing configuration for automatic triggers. @@ -4313,13 +4340,31 @@ export class Task extends EventEmitter implements TaskLike { // in the caller. this.rateLimitClock.recordRequest() - const systemPrompt = await this.getSystemPrompt() + if (!options.resolvedPromptTools) { + await this.safeEnsureModelFetched() + } + const modelInfo = this.api.getModel().info + const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini + const resolvedPromptTools = + options.resolvedPromptTools ?? + (await this.resolvePromptTools({ + state, + mode, + includeAllToolsWithRestrictions: supportsAllowedFunctionNames, + })) + const resolvedState = resolvedPromptTools.state + const allowedMcpServers = getModeBySlug(mode, resolvedState.customModes)?.allowedMcpServers + this.currentRequestToolPolicy = { + effectiveToolNames: new Set(resolvedPromptTools.toolsResult.effectiveToolNames), + mode, + customModes: resolvedState.customModes, + experiments: resolvedState.experiments ? { ...resolvedState.experiments } : undefined, + allowedMcpServers: allowedMcpServers ? [...allowedMcpServers] : undefined, + } + const systemPrompt = await this.getSystemPrompt(resolvedPromptTools) const { contextTokens } = this.getTokenUsage() if (contextTokens) { - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info - const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, model: modelInfo, @@ -4366,26 +4411,10 @@ export class Task extends EventEmitter implements TaskLike { ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) } - // Build tools for condensing metadata (same tools used for normal API calls) - // This ensures the condensing API call includes tool definitions for providers that need them - let contextMgmtTools: import("openai").default.Chat.ChatCompletionTool[] = [] - { - const provider = this.providerRef.deref() - if (provider) { - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: false, - }) - contextMgmtTools = toolsResult.tools - } - } + // Reuse the main request's declarations and logical restrictions so the + // condensing prompt and metadata cannot drift while settings change. + const { tools: contextMgmtTools, allowedFunctionNames: contextMgmtAllowedFunctionNames } = + resolvedPromptTools.toolsResult // Build metadata with tools and taskId for the condensing API call const contextMgmtMetadata: ApiHandlerCreateMessageMetadata = { @@ -4401,6 +4430,9 @@ export class Task extends EventEmitter implements TaskLike { tools: contextMgmtTools, tool_choice: "auto", parallelToolCalls: true, + ...(contextMgmtAllowedFunctionNames + ? { allowedFunctionNames: contextMgmtAllowedFunctionNames } + : {}), } : {}), } @@ -4409,7 +4441,7 @@ export class Task extends EventEmitter implements TaskLike { // getEnvironmentDetails(this, true) triggers a recursive workspace listing which // adds overhead - avoid this for the common case where context is below threshold. const contextMgmtEnvironmentDetails = contextManagementWillRun - ? await getEnvironmentDetails(this, true) + ? await getEnvironmentDetails(this, true, resolvedPromptTools.toolsResult.effectiveToolNames) : undefined // Get files read by Roo for code folding - only when context management will run @@ -4519,43 +4551,11 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } - // Whether we include tools is determined by whether we have any tools to send. - const modelInfo = this.api.getModel().info - // Build complete tools array: native tools + dynamic MCP tools // When includeAllToolsWithRestrictions is true, returns all tools but provides - // allowedFunctionNames for providers (like Gemini) that need to see all tool - // definitions in history while restricting callable tools for the current mode. - // Only Gemini currently supports this - other providers filter tools normally. - let allTools: OpenAI.Chat.ChatCompletionTool[] = [] - let allowedFunctionNames: string[] | undefined - - // Gemini requires all tool definitions to be present for history compatibility, - // but uses allowedFunctionNames to restrict which tools can be called. - // Other providers (Anthropic, OpenAI, etc.) don't support this feature yet, - // so they continue to receive only the filtered tools for the current mode. - const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini - - { - const provider = this.providerRef.deref() - if (!provider) { - throw new Error("Provider reference lost during tool building") - } - - const toolsResult = await buildNativeToolsArrayWithRestrictions({ - provider, - cwd: this.cwd, - mode, - customModes: state?.customModes, - experiments: state?.experiments, - apiConfiguration, - disabledTools: state?.disabledTools, - modelInfo, - includeAllToolsWithRestrictions: supportsAllowedFunctionNames, - }) - allTools = toolsResult.tools - allowedFunctionNames = toolsResult.allowedFunctionNames - } + // allowedFunctionNames for providers (like Gemini) that need all definitions + // for history compatibility. Runtime validation enforces the same logical set. + const { tools: allTools, allowedFunctionNames } = resolvedPromptTools.toolsResult const shouldIncludeTools = allTools.length > 0 @@ -4574,8 +4574,7 @@ export class Task extends EventEmitter implements TaskLike { tools: allTools, tool_choice: "auto", parallelToolCalls: true, - // When mode restricts tools, provide allowedFunctionNames so providers - // like Gemini can see all tools in history but only call allowed ones + // Keep logical allowed names alongside compatibility declarations. ...(allowedFunctionNames ? { allowedFunctionNames } : {}), } : {}), diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..0866a998f1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6,6 +6,7 @@ import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" import type { Mock } from "vitest" +import pWaitFor from "p-wait-for" import { providerIdentifiers, @@ -14,6 +15,7 @@ import { type ProviderSettings, type ModelInfo, type TaskLike, + type ModeConfig, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -21,15 +23,35 @@ import { Task } from "../Task" import { SYSTEM_PROMPT } from "../../prompts/system" import { createRateLimitClock } from "../RateLimitClock" import { summarizeConversation } from "../../condense" +import { getEnvironmentDetails } from "../../environment/getEnvironmentDetails" import { ClineProvider } from "../../webview/ClineProvider" import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" +import { McpServerManager } from "../../../services/mcp/McpServerManager" +import { defaultModeSlug } from "../../../shared/modes" + +type TestPromptTools = { + state: ProviderState + mode?: string + mcpHub: undefined + toolsResult: { + tools: unknown[] + effectiveToolNames: Set + allowedFunctionNames?: string[] + } +} type TaskTestAccess = { - getSystemPrompt: () => Promise + getSystemPrompt: (resolved?: TestPromptTools) => Promise + getMcpHubForPrompt: (state: ProviderState) => Promise + resolvePromptTools: (options?: { + state?: ProviderState + mode?: string + includeAllToolsWithRestrictions?: boolean + }) => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise @@ -40,6 +62,7 @@ type TaskTestAccess = { saveClineMessages: () => Promise safeEnsureModelFetched: () => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise + saveApiConversationHistory: () => Promise } type TaskAskResult = Awaited> @@ -578,9 +601,35 @@ describe("Cline", () => { await getTaskTestAccess(task).getSystemPrompt() const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) - const [, , , , , mode, , , , , , , settings] = systemPromptCall + const [, , , , , mode, , , , , , , settings, , , , promptContext] = systemPromptCall expect(mode).toBe("architect") expect(settings).toMatchObject({ todoListEnabled: true }) + expect(promptContext?.availableToolNames).not.toContain("execute_command") + }) + + it("passes disabled tools through the effective prompt context", async () => { + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + mode: "code", + mcpEnabled: false, + disabledTools: ["execute_command"], + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await getTaskTestAccess(task).getSystemPrompt() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptContext = systemPromptCall[16] + expect(promptContext?.availableToolNames).not.toContain("execute_command") + expect(promptContext?.availableToolNames).toContain("read_file") }) it("uses the task mode when manually condensing after focused state changes", async () => { @@ -599,12 +648,21 @@ describe("Cline", () => { mode: "code", mcpEnabled: false, } as unknown as ProviderState) - vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await task.condenseContext() const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptToolNames = systemPromptCall[16]?.availableToolNames + const metadataToolNames = new Set( + (options.metadata?.tools ?? []).flatMap((tool) => + "function" in tool && tool.function ? [tool.function.name] : [], + ), + ) expect(options.metadata?.mode).toBe("architect") + expect(metadataToolNames).toEqual(promptToolNames) + expect(vi.mocked(getEnvironmentDetails)).toHaveBeenCalledWith(task, true, promptToolNames) }) it("uses the task mode in request metadata when focused provider state differs", async () => { @@ -642,6 +700,241 @@ describe("Cline", () => { const metadata = requireDefined(createMessage.mock.calls[0])[2] expect(metadata?.mode).toBe("ask") }) + + it("shares one effective tool policy across prompt, API metadata, and runtime validation", async () => { + const providerState = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ + ...providerState, + mode: "code", + mcpEnabled: false, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + disabledTools: ["execute_command"], + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + const stream = (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })() + const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest().next() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + const promptToolNames = systemPromptCall[16]?.availableToolNames + const metadata = requireDefined(createMessage.mock.calls[0])[2] + const apiToolNames = new Set( + (metadata?.tools ?? []).flatMap((tool) => + "function" in tool && tool.function ? [tool.function.name] : [], + ), + ) + const requestPolicy = requireDefined(task.getCurrentRequestToolPolicy()) + + expect(promptToolNames).toEqual(requestPolicy.effectiveToolNames) + expect(apiToolNames).toEqual(requestPolicy.effectiveToolNames) + expect(requestPolicy.effectiveToolNames).not.toContain("execute_command") + }) + }) + + describe("effective prompt tool resolution", () => { + it("refreshes model and tool policy before resuming after delegation", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + task.apiConversationHistory = [ + { + role: "user", + content: [ + { type: "text", text: "delegation result" }, + { type: "text", text: "stale" }, + ], + ts: Date.now(), + }, + ] + const state = await mockProvider.getState() + const effectiveToolNames = new Set(["read_file"]) + const taskAccess = getTaskTestAccess(task) + const ensureSpy = vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "resolvePromptTools").mockResolvedValue({ + state, + mode: "code", + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames }, + }) + vi.mocked(getEnvironmentDetails).mockResolvedValueOnce("fresh") + const saveSpy = vi.spyOn(taskAccess, "saveApiConversationHistory").mockResolvedValue(true) + const loopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + + await task.resumeAfterDelegation() + + expect(ensureSpy).toHaveBeenCalledOnce() + expect(getEnvironmentDetails).toHaveBeenCalledWith(task, true, effectiveToolNames) + expect(task.apiConversationHistory[0].content).toEqual([ + { type: "text", text: "delegation result" }, + { type: "text", text: "fresh" }, + ]) + expect(saveSpy).toHaveBeenCalledOnce() + expect(loopSpy).toHaveBeenCalledWith([]) + }) + + it("reports a lost provider while resolving tools", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect(getTaskTestAccess(task).resolvePromptTools()).rejects.toThrow("Provider not available") + }) + + it("reports a lost provider while building the system prompt", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = await mockProvider.getState() + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect( + getTaskTestAccess(task).getSystemPrompt({ + state, + mode: "code", + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames: new Set() }, + }), + ).rejects.toThrow("Provider not available") + }) + + it("uses the default mode when a resolved prompt policy has no mode", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = await mockProvider.getState() + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await getTaskTestAccess(task).getSystemPrompt({ + state, + mode: undefined, + mcpHub: undefined, + toolsResult: { tools: [], effectiveToolNames: new Set() }, + }) + + expect(requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1))[5]).toBe(defaultModeSlug) + }) + + it("reports a lost provider before loading the MCP hub", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + Object.defineProperty(task, "providerRef", { value: { deref: () => undefined } }) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).rejects.toThrow( + "Provider reference lost during view transition", + ) + }) + + it("reports a missing MCP hub from the server manager", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + const getInstanceSpy = vi.spyOn(McpServerManager, "getInstance").mockResolvedValueOnce(undefined as never) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).rejects.toThrow( + "Failed to get MCP hub from server manager", + ) + getInstanceSpy.mockRestore() + }) + + it("returns the MCP hub after logging a connection timeout", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const state = { ...(await mockProvider.getState()), mcpEnabled: true } + const mcpHub = { isConnecting: true } + const getInstanceSpy = vi.spyOn(McpServerManager, "getInstance").mockResolvedValueOnce(mcpHub as never) + vi.mocked(pWaitFor).mockRejectedValueOnce(new Error("timeout")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + await expect(getTaskTestAccess(task).getMcpHubForPrompt(state)).resolves.toBe(mcpHub) + expect(errorSpy).toHaveBeenCalledWith("MCP servers failed to connect in time") + errorSpy.mockRestore() + getInstanceSpy.mockRestore() + }) + + it("reuses a resolved policy and snapshots its MCP allowlist for the request", async () => { + const mode = "restricted-mcp" + const customModes: ModeConfig[] = [ + { + slug: mode, + name: "Restricted MCP", + roleDefinition: "Restricted MCP role", + groups: ["read", "mcp"], + allowedMcpServers: ["allowed-server"], + }, + ] + const state = { + ...(await mockProvider.getState()), + mode, + mcpEnabled: false, + autoApprovalEnabled: true, + requestDelaySeconds: 0, + customModes, + } + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const resolvedPromptTools = await taskAccess.resolvePromptTools({ state, mode }) + const ensureSpy = vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task.api, "createMessage").mockReturnValue( + (async function* () { + yield { type: "text", text: "response" } as ApiStreamChunk + })(), + ) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + await task.attemptApiRequest(0, { resolvedPromptTools } as never).next() + + expect(ensureSpy).not.toHaveBeenCalled() + expect(task.getCurrentRequestToolPolicy()?.allowedMcpServers).toEqual(["allowed-server"]) + }) }) describe("sayAndCreateMissingParamError", () => { @@ -2502,11 +2795,19 @@ describe("Cline", () => { id: requireDefined(mockApiConfig.apiModelId), info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo, }) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 190000, + }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration, autoApprovalEnabled: true, + autoCondenseContext: true, + autoCondenseContextPercent: 80, requestDelaySeconds: 0, }) const mockStream = (async function* () { @@ -2532,6 +2833,10 @@ describe("Cline", () => { expect(tools.length).toBeGreaterThan(0) expect(allowedFunctionNames.length).toBeGreaterThan(0) expect(allowedFunctionNames.every((name) => toolNames.includes(name))).toBe(true) + expect(summarizeConversation).toHaveBeenCalled() + const [contextOptions] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + expect(contextOptions.metadata?.tools).toEqual(tools) + expect(contextOptions.metadata?.allowedFunctionNames).toEqual(allowedFunctionNames) }) it("should invoke abort on currentRequestAbortController during first-chunk wait", async () => { @@ -3209,7 +3514,11 @@ describe("Cline", () => { mode: undefined, }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + let observedEnvironmentToolNames: unknown + let observedPolicyToolNames: unknown + vi.spyOn(task, "attemptApiRequest").mockImplementation((_retryAttempt, options) => { + observedEnvironmentToolNames = vi.mocked(getEnvironmentDetails).mock.calls.at(-1)?.[2] + observedPolicyToolNames = options?.resolvedPromptTools?.toolsResult.effectiveToolNames throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) @@ -3239,6 +3548,8 @@ describe("Cline", () => { const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }], false) expect(result).toBe(true) + expect(observedPolicyToolNames).toBeDefined() + expect(observedEnvironmentToolNames).toEqual(observedPolicyToolNames) expect(safeSpy).toHaveBeenCalled() expect(ensureModelFetched).toHaveBeenCalled() expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts new file mode 100644 index 0000000000..071fb87dbe --- /dev/null +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -0,0 +1,189 @@ +import type * as vscode from "vscode" + +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import type { McpHub } from "../../../services/mcp/McpHub" +import type { ClineProvider } from "../../webview/ClineProvider" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/manager", () => ({ + CodeIndexManager: { + getInstance: () => ({ + isFeatureEnabled: false, + isFeatureConfigured: false, + isInitialized: false, + }), + }, +})) + +const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, +} + +function createMcpHub(withCapabilities: boolean): McpHub { + return { + getServers: () => + withCapabilities + ? [ + { + name: "test-server", + resources: [{ uri: "test://resource", name: "Test Resource" }], + tools: [ + { + name: "test-tool", + description: "Test tool", + inputSchema: { type: "object", properties: {} }, + enabledForPrompt: true, + }, + ], + }, + ] + : [], + // The builder only reads server metadata from this test double. + } as unknown as McpHub +} + +function createProvider(mcpHub: McpHub): Pick { + return { + context: {} as vscode.ExtensionContext, + getMcpHub: () => mcpHub, + } +} + +describe("buildNativeToolsArrayWithRestrictions", () => { + it.each([ + { mode: "code", hasCommand: true, hasRead: true, hasEdit: true }, + { mode: "debug", hasCommand: true, hasRead: true, hasEdit: true }, + { mode: "architect", hasCommand: false, hasRead: true, hasEdit: true }, + { mode: "ask", hasCommand: false, hasRead: true, hasEdit: false }, + { mode: "orchestrator", hasCommand: false, hasRead: false, hasEdit: false }, + ])("resolves the built-in $mode mode tool policy", async ({ mode, hasCommand, hasRead, hasEdit }) => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode, + customModes: undefined, + experiments: {}, + apiConfiguration, + }) + + expect(result.effectiveToolNames.has("execute_command")).toBe(hasCommand) + expect(result.effectiveToolNames.has("read_file")).toBe(hasRead) + expect(result.effectiveToolNames.has("list_files")).toBe(hasRead) + expect(result.effectiveToolNames.has("write_to_file")).toBe(hasEdit) + }) + + it("returns canonical names for the request's logical tool set", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["execute_command"], + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: true, + includedTools: ["search_and_replace"], + }, + }) + + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.effectiveToolNames).toContain("edit") + expect(result.effectiveToolNames).not.toContain("search_and_replace") + }) + + it("removes model-excluded tools from logical availability", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + modelInfo: { + contextWindow: 200_000, + supportsPromptCache: true, + excludedTools: ["execute_command"], + }, + }) + + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.effectiveToolNames).toContain("read_file") + }) + + it("separates Gemini compatibility definitions from logical availability", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(false)), + cwd: "/test/path", + mode: "architect", + customModes: undefined, + experiments: {}, + apiConfiguration, + includeAllToolsWithRestrictions: true, + }) + const sentToolNames = result.tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : [])) + + expect(sentToolNames).toContain("execute_command") + expect(result.effectiveToolNames).not.toContain("execute_command") + expect(result.allowedFunctionNames).not.toContain("execute_command") + }) + + it("keeps lifecycle and active-mode essential tools available", async () => { + const provider = createProvider(createMcpHub(false)) + const codeResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["ask_followup_question", "attempt_completion"], + }) + const orchestratorResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "orchestrator", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["new_task"], + }) + + expect(codeResult.effectiveToolNames).toContain("ask_followup_question") + expect(codeResult.effectiveToolNames).toContain("attempt_completion") + expect(orchestratorResult.effectiveToolNames).toContain("new_task") + }) + + it("excludes MCP operations when MCP is globally disabled", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(true)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + mcpEnabled: false, + }) + + expect(result.effectiveToolNames).not.toContain("access_mcp_resource") + expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) + }) + + it("excludes dynamic MCP tools when use_mcp_tool is disabled", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider: createProvider(createMcpHub(true)), + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration, + disabledTools: ["use_mcp_tool"], + mcpEnabled: true, + }) + + expect(result.effectiveToolNames).toContain("access_mcp_resource") + expect(Array.from(result.effectiveToolNames).some((name) => name.startsWith("mcp--"))).toBe(false) + }) +}) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..020fce28b1 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -17,7 +17,7 @@ import { } from "../prompts/tools/filter-tools-for-mode" interface BuildToolsOptions { - provider: ClineProvider + provider: Pick cwd: string mode: string | undefined customModes: ModeConfig[] | undefined @@ -25,6 +25,8 @@ interface BuildToolsOptions { apiConfiguration: ProviderSettings | undefined disabledTools?: string[] modelInfo?: ModelInfo + /** Whether MCP tools and resources are enabled globally for this request. */ + mcpEnabled?: boolean /** * If true, returns all tools without mode filtering, but also includes * the list of allowed tool names for use with allowedFunctionNames. @@ -34,13 +36,15 @@ interface BuildToolsOptions { includeAllToolsWithRestrictions?: boolean } -interface BuildToolsResult { +export interface BuildToolsResult { /** * The tools to pass to the model. * If includeAllToolsWithRestrictions is true, this includes ALL tools. * Otherwise, it includes only mode-filtered tools. */ tools: OpenAI.Chat.ChatCompletionTool[] + /** Canonical names of tools that are logically available for this request. */ + effectiveToolNames: ReadonlySet /** * The names of tools that are allowed to be called based on mode restrictions. * Only populated when includeAllToolsWithRestrictions is true. @@ -90,10 +94,12 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO apiConfiguration, disabledTools, modelInfo, + mcpEnabled, includeAllToolsWithRestrictions, } = options const mcpHub = provider.getMcpHub() + const effectiveMcpHub = mcpEnabled === false ? undefined : mcpHub // Get CodeIndexManager for feature checking. const { CodeIndexManager } = await import("../../services/code-index/manager") @@ -128,13 +134,14 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO experiments, codeIndexManager, filterSettings, - mcpHub, + effectiveMcpHub, allowedMcpServers, ) // Filter MCP tools based on mode restrictions. - const mcpTools = getMcpServerTools(mcpHub, allowedMcpServers) - const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments) + const allMcpTools = getMcpServerTools(mcpHub, allowedMcpServers) + const mcpTools = mcpEnabled === false ? [] : allMcpTools + const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments, filterSettings) // Add custom tools if they are available and the experiment is enabled. let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = [] @@ -151,12 +158,13 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO // Combine filtered tools (for backward compatibility and for allowedFunctionNames) const filteredTools = [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools] + const effectiveToolNames = new Set(filteredTools.map((tool) => resolveToolAlias(getToolName(tool)))) // If includeAllToolsWithRestrictions is true, return ALL tools but provide // allowed names based on mode filtering if (includeAllToolsWithRestrictions) { // Combine ALL tools (unfiltered native + all MCP + custom) - const allTools = [...nativeTools, ...mcpTools, ...nativeCustomTools] + const allTools = [...nativeTools, ...allMcpTools, ...nativeCustomTools] // Extract names of tools that are allowed based on mode filtering. // Resolve any alias names to canonical names to ensure consistency with allTools @@ -167,11 +175,13 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO return { tools: allTools, allowedFunctionNames, + effectiveToolNames, } } // Default behavior: return only filtered tools return { tools: filteredTools, + effectiveToolNames, } } diff --git a/src/core/tools/__tests__/mcpServerRestriction.spec.ts b/src/core/tools/__tests__/mcpServerRestriction.spec.ts index 6455e1e4b3..a0d4d4731e 100644 --- a/src/core/tools/__tests__/mcpServerRestriction.spec.ts +++ b/src/core/tools/__tests__/mcpServerRestriction.spec.ts @@ -1,6 +1,6 @@ // npx vitest run core/tools/__tests__/mcpServerRestriction.spec.ts -import type { Task } from "../../task/Task" +import type { CurrentRequestToolPolicy, Task } from "../../task/Task" import { isMcpServerAllowed, getAllowedMcpServersForTask, ensureMcpServerAllowed } from "../mcpServerRestriction" vi.mock("../../../shared/modes", async (importOriginal) => { @@ -16,7 +16,7 @@ import { getModeBySlug } from "../../../shared/modes" const toolError = (error: string) => `ERR:${error}` -function makeTask(state: any): Task { +function makeTask(state: any, requestPolicy?: CurrentRequestToolPolicy): Task { return { providerRef: { deref: () => ({ @@ -26,6 +26,7 @@ function makeTask(state: any): Task { consecutiveMistakeCount: 0, didToolFailInCurrentTurn: false, recordToolError: vi.fn(), + ...(requestPolicy ? { getCurrentRequestToolPolicy: () => requestPolicy } : {}), } as unknown as Task } @@ -64,6 +65,22 @@ describe("getAllowedMcpServersForTask", () => { await expect(getAllowedMcpServersForTask(task)).resolves.toEqual(["srv-a"]) }) + it("prefers the request policy over the live focused mode", async () => { + const task = makeTask( + { mode: "code", customModes: [] }, + { + effectiveToolNames: new Set(["access_mcp_resource"]), + mode: "architect", + customModes: [], + experiments: {}, + allowedMcpServers: ["request-server"], + }, + ) + + await expect(getAllowedMcpServersForTask(task)).resolves.toEqual(["request-server"]) + expect(getModeBySlug).not.toHaveBeenCalled() + }) + it("returns undefined when the mode does not restrict servers", async () => { vi.mocked(getModeBySlug).mockReturnValue({ slug: "code", diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 9e4a8bbd0c..816d90c846 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -136,6 +136,11 @@ describe("mode-validator", () => { }) describe("tool requirements", () => { + it("disables every non-required tool when requirements are false", () => { + expect(isToolAllowedForMode("read_file", codeMode, [], false)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], false)).toBe(true) + }) + it("respects tool requirements when provided", () => { const requirements = { apply_diff: false } expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) @@ -160,13 +165,18 @@ describe("mode-validator", () => { expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) }) - it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => { - // Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still - // be blockable via toolRequirements / disabledTools - const requirements = { switch_mode: false, new_task: false, attempt_completion: false } + it("keeps lifecycle tools available while allowing optional control tools to be disabled", () => { + const requirements = { + switch_mode: false, + new_task: false, + attempt_completion: false, + ask_followup_question: false, + } expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) - expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(true) + expect(isToolAllowedForMode("ask_followup_question", codeMode, [], requirements)).toBe(true) + expect(isToolAllowedForMode("new_task", "orchestrator", [], requirements)).toBe(true) }) }) }) diff --git a/src/core/tools/mcpServerRestriction.ts b/src/core/tools/mcpServerRestriction.ts index aa88125733..e5c3118870 100644 --- a/src/core/tools/mcpServerRestriction.ts +++ b/src/core/tools/mcpServerRestriction.ts @@ -32,6 +32,11 @@ export function isMcpServerAllowed(serverName: string, allowedMcpServers?: strin * @returns The mode's `allowedMcpServers` allowlist, or `undefined` when unrestricted. */ export async function getAllowedMcpServersForTask(task: Task): Promise { + const requestPolicy = task.getCurrentRequestToolPolicy?.() + if (requestPolicy) { + return requestPolicy.allowedMcpServers + } + const provider = task.providerRef.deref() // Be defensive: provider may be gone, or `getState` may be unavailable (e.g. in tests). diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 243a170ed9..62f47c6569 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -4,7 +4,7 @@ import { customToolRegistry } from "@roo-code/core" import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes" import { EXPERIMENT_IDS } from "../../shared/experiments" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES, isRequiredToolForMode } from "../../shared/tools" /** * Checks if a tool name is a valid, known tool. @@ -33,7 +33,7 @@ export function validateToolUse( toolName: ToolName, mode: Mode, customModes?: ModeConfig[], - toolRequirements?: Record, + toolRequirements?: Record | false, toolParams?: Record, experiments?: Record, includedTools?: string[], @@ -121,7 +121,7 @@ export function isToolAllowedForMode( tool: string, modeSlug: string, customModes: ModeConfig[], - toolRequirements?: Record, + toolRequirements?: Record | false, toolParams?: Record, // All tool parameters experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) @@ -130,19 +130,20 @@ export function isToolAllowedForMode( const resolvedTool = TOOL_ALIASES[tool] ?? tool const resolvedIncludedTools = includedTools?.map((t) => TOOL_ALIASES[t] ?? t) - // Check tool requirements first — explicit disabling takes priority over everything, - // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently - // at both the filtering layer and the execution-time validation layer. - if (toolRequirements && typeof toolRequirements === "object") { - if ( - (tool in toolRequirements && !toolRequirements[tool]) || - (resolvedTool in toolRequirements && !toolRequirements[resolvedTool]) - ) { + // Explicit disabling takes priority over ordinary always-available tools. Lifecycle + // and active-mode essentials remain available because the task loop has no fallback. + if (!isRequiredToolForMode(resolvedTool, modeSlug)) { + if (toolRequirements && typeof toolRequirements === "object") { + if ( + (tool in toolRequirements && !toolRequirements[tool]) || + (resolvedTool in toolRequirements && !toolRequirements[resolvedTool]) + ) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all non-required tools are disabled return false } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false } // Always allow these tools (unless explicitly disabled above) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ad6ea143a8..9ec4182a4b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -33,6 +33,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" +import { buildNativeToolsArrayWithRestrictions } from "../../task/build-tools" // Mock setup must come before imports. vi.mock("../../prompts/sections/custom-instructions") @@ -356,10 +357,19 @@ vi.mock("../../prompts/system", () => ({ codeMode: "code", })) +vi.mock("../../task/build-tools", () => ({ + buildNativeToolsArrayWithRestrictions: vi.fn().mockResolvedValue({ + tools: [], + effectiveToolNames: new Set(["read_file", "execute_command"]), + }), +})) + vi.mock("../../../api", () => ({ buildApiHandler: vi.fn().mockReturnValue({ + ensureModelFetched: vi.fn().mockResolvedValue(undefined), getModel: vi.fn().mockReturnValue({ id: "claude-3-sonnet", + info: {}, }), }), })) @@ -2172,6 +2182,98 @@ describe("ClineProvider", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.get_system_prompt") }) + test("uses the effective disabled-tool policy for preview guidance", async () => { + const providerState = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + }, + mcpEnabled: false, + mode: "code", + disabledTools: ["execute_command"], + experiments: experimentDefault, + }) + vi.mocked(buildNativeToolsArrayWithRestrictions).mockResolvedValueOnce({ + tools: [], + effectiveToolNames: new Set(["read_file"]), + }) + const { SYSTEM_PROMPT } = await import("../../prompts/system") + vi.mocked(SYSTEM_PROMPT).mockClear() + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + disabledTools: ["execute_command"], + mcpEnabled: false, + mode: "code", + }), + ) + const systemPromptCall = vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1) + const promptContext = systemPromptCall?.[16] + expect(promptContext?.availableToolNames).not.toContain("execute_command") + expect(promptContext?.availableToolNames).toContain("read_file") + }) + + test("uses cached model policy when full model metadata fetch fails", async () => { + const { buildApiHandler } = await import("../../../api") + const fallbackHandler = { + ensureModelFetched: vi.fn().mockRejectedValue(new Error("fetch failed")), + createMessage: vi.fn(), + countTokens: vi.fn(), + getModel: vi.fn().mockReturnValue({ + id: "cached-model", + info: { + contextWindow: 128_000, + supportsPromptCache: false, + excludedTools: ["execute_command"], + }, + }), + } as ReturnType + vi.mocked(buildApiHandler).mockReturnValueOnce(fallbackHandler) + + const providerState = await provider.getState() + vi.spyOn(provider, "getState").mockResolvedValue({ + ...providerState, + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + mcpEnabled: false, + mode: "code", + experiments: experimentDefault, + }) + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ + modelInfo: expect.objectContaining({ excludedTools: ["execute_command"] }), + }), + ) + }) + + test("generates a preview when the temporary API handler cannot be created", async () => { + const { buildApiHandler } = await import("../../../api") + const error = new Error("handler creation failed") + vi.mocked(buildApiHandler).mockImplementationOnce(() => { + throw error + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + const handler = getMessageHandler() + await handler({ type: "getSystemPrompt", mode: "code" }) + + expect(errorSpy).toHaveBeenCalledWith("Error reading model info for system prompt preview:", error) + expect(buildNativeToolsArrayWithRestrictions).toHaveBeenCalledWith( + expect.objectContaining({ modelInfo: undefined }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "systemPrompt", text: "mocked system prompt" }), + ) + errorSpy.mockRestore() + }) + test("uses code mode custom instructions", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d..0dc304860e 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import type { ModelInfo } from "@roo-code/types" import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" @@ -6,6 +7,7 @@ import { buildApiHandler } from "../../api" import { SYSTEM_PROMPT } from "../prompts/system" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { Package } from "../../shared/package" +import { buildNativeToolsArrayWithRestrictions } from "../task/build-tools" import { ClineProvider } from "./ClineProvider" @@ -18,6 +20,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web experiments, language, enableSubfolderRules, + disabledTools, } = await provider.getState() const diffStrategy = new MultiSearchReplaceDiffStrategy() @@ -31,19 +34,37 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web // Create a temporary API handler to check model info for stealth mode. // This avoids relying on an active Cline instance which might not exist during preview. - let modelInfo: { isStealthModel?: boolean } | undefined + let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) + try { + await tempApiHandler.ensureModelFetched?.() + } catch (error) { + console.error("Error fetching full model info for system prompt preview:", error) + } modelInfo = tempApiHandler.getModel().info } catch (error) { - console.error("Error fetching model info for system prompt preview:", error) + console.error("Error reading model info for system prompt preview:", error) } + const toolsResult = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd, + mode, + customModes, + experiments, + apiConfiguration, + disabledTools, + modelInfo, + mcpEnabled, + includeAllToolsWithRestrictions: false, + }) + const systemPrompt = await SYSTEM_PROMPT( provider.context, cwd, false, // supportsComputerUse — browser removed - mcpEnabled ? provider.getMcpHub() : undefined, + (mcpEnabled ?? true) ? provider.getMcpHub() : undefined, diffStrategy, mode, customModePrompts, @@ -64,6 +85,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + { availableToolNames: toolsResult.effectiveToolNames }, ) return systemPrompt diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..282b4049d7 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -325,6 +325,17 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "skill", ] as const +/** Tools required by the task lifecycle or by a built-in mode's core workflow. */ +export function isRequiredToolForMode(toolName: string, mode: string): boolean { + const canonicalToolName = TOOL_ALIASES[toolName] ?? toolName + + return ( + canonicalToolName === "ask_followup_question" || + canonicalToolName === "attempt_completion" || + (mode === "orchestrator" && canonicalToolName === "new_task") + ) +} + /** * Central registry of tool aliases. * Maps alias name -> canonical tool name.