Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Large diffs are not rendered by default.

124 changes: 99 additions & 25 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, boolean>, 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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -438,16 +495,19 @@ 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. ` +
`This usually means the model streamed invalid or incomplete arguments and the call could not be finalized.`

cline.consecutiveMistakeCount++
try {
cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage)
cline.recordToolError(
toTelemetryToolName(block.name, false, effectiveExperiments),
errorMessage,
)
} catch {
// Best-effort only
}
Expand Down Expand Up @@ -599,29 +659,41 @@ 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<string, boolean>, tool: string) => {
acc[tool] = false
const resolvedToolName = resolveToolAlias(tool)
acc[resolvedToolName] = false
return acc
},
{} as Record<string, boolean>,
) ?? {}
const unavailableTools = requestPolicy
? []
: [
...(disabledTools ?? []),
...(modelInfo?.info?.excludedTools ?? []),
...(state?.mcpEnabled === false ? ["use_mcp_tool", "access_mcp_resource"] : []),
]
const toolRequirements = unavailableTools.reduce((acc: Record<string, boolean>, tool: string) => {
acc[tool] = false
acc[resolveToolAlias(tool)] = false
return acc
}, {})
const canonicalToolName = resolveToolAlias(block.name)
const isAvailableInRequestPolicy =
requestPolicy?.effectiveToolNames.has(canonicalToolName) ||
(canonicalToolName === "use_mcp_tool" &&
Array.from(requestPolicy?.effectiveToolNames ?? []).some(isMcpTool))
if (requestPolicy && !isAvailableInRequestPolicy) {
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) {
Expand All @@ -643,7 +715,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,
)

Expand All @@ -653,7 +725,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)

Expand Down Expand Up @@ -904,7 +976,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 {
Expand All @@ -924,7 +998,7 @@ export async function presentAssistantMessage(cline: Task) {
}

const result = await customTool.execute(customToolArgs, {
mode: mode ?? defaultModeSlug,
mode: effectiveMode,
task: cline,
})

Expand Down
37 changes: 37 additions & 0 deletions src/core/environment/__tests__/getEnvironmentDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ describe("getEnvironmentDetails", () => {
false,
mockCline.rooIgnoreController,
false,
undefined,
true,
)
})

Expand All @@ -187,13 +189,35 @@ 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)
expect(result).toContain("Desktop files not shown automatically")
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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 16 additions & 4 deletions src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
) {
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()
Expand Down Expand Up @@ -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)
Expand All @@ -251,6 +261,8 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
didHitLimit,
cline.rooIgnoreController,
showRooIgnoredFiles,
undefined,
canListFiles,
)

details += result
Expand All @@ -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 `<environment_details>\n${details.trim()}\n${reminderSection}\n</environment_details>`
}
18 changes: 18 additions & 0 deletions src/core/prompts/__tests__/responses-rooignore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading
Loading