Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 9b1c850

Browse files
feat(gemini): add allowedFunctionNames support to prevent mode switch errors (#10708)
Co-authored-by: Roo Code <roomote@roocode.com>
1 parent d74bad9 commit 9b1c850

6 files changed

Lines changed: 321 additions & 7 deletions

File tree

src/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ export interface ApiHandlerCreateMessageMetadata {
9494
* Only applies when toolProtocol is "native".
9595
*/
9696
parallelToolCalls?: boolean
97+
/**
98+
* Optional array of tool names that the model is allowed to call.
99+
* When provided, all tool definitions are passed to the model (so it can reference
100+
* historical tool calls), but only the specified tools can actually be invoked.
101+
* This is used when switching modes to prevent model errors from missing tool
102+
* definitions while still restricting callable tools to the current mode's permissions.
103+
* Only applies to providers that support function calling restrictions (e.g., Gemini).
104+
*/
105+
allowedFunctionNames?: string[]
97106
}
98107

99108
export interface ApiHandler {

src/api/providers/__tests__/gemini-handler.spec.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { t } from "i18next"
2+
import { FunctionCallingConfigMode } from "@google/genai"
23

34
import { GeminiHandler } from "../gemini"
45
import type { ApiHandlerOptions } from "../../../shared/api"
@@ -141,4 +142,152 @@ describe("GeminiHandler backend support", () => {
141142
}).rejects.toThrow(t("common:errors.gemini.generate_stream", { error: "API rate limit exceeded" }))
142143
})
143144
})
145+
146+
describe("allowedFunctionNames support", () => {
147+
const testTools = [
148+
{
149+
type: "function" as const,
150+
function: {
151+
name: "read_file",
152+
description: "Read a file",
153+
parameters: { type: "object", properties: {} },
154+
},
155+
},
156+
{
157+
type: "function" as const,
158+
function: {
159+
name: "write_to_file",
160+
description: "Write to a file",
161+
parameters: { type: "object", properties: {} },
162+
},
163+
},
164+
{
165+
type: "function" as const,
166+
function: {
167+
name: "execute_command",
168+
description: "Execute a command",
169+
parameters: { type: "object", properties: {} },
170+
},
171+
},
172+
]
173+
174+
it("should pass allowedFunctionNames to toolConfig when provided", async () => {
175+
const options = {
176+
apiProvider: "gemini",
177+
} as ApiHandlerOptions
178+
const handler = new GeminiHandler(options)
179+
const stub = vi.fn().mockReturnValue((async function* () {})())
180+
// @ts-ignore access private client
181+
handler["client"].models.generateContentStream = stub
182+
183+
await handler
184+
.createMessage("test", [] as any, {
185+
taskId: "test-task",
186+
tools: testTools,
187+
allowedFunctionNames: ["read_file", "write_to_file"],
188+
})
189+
.next()
190+
191+
const config = stub.mock.calls[0][0].config
192+
expect(config.toolConfig).toEqual({
193+
functionCallingConfig: {
194+
mode: FunctionCallingConfigMode.ANY,
195+
allowedFunctionNames: ["read_file", "write_to_file"],
196+
},
197+
})
198+
})
199+
200+
it("should include all tools but restrict callable functions via allowedFunctionNames", async () => {
201+
const options = {
202+
apiProvider: "gemini",
203+
} as ApiHandlerOptions
204+
const handler = new GeminiHandler(options)
205+
const stub = vi.fn().mockReturnValue((async function* () {})())
206+
// @ts-ignore access private client
207+
handler["client"].models.generateContentStream = stub
208+
209+
await handler
210+
.createMessage("test", [] as any, {
211+
taskId: "test-task",
212+
tools: testTools,
213+
allowedFunctionNames: ["read_file"],
214+
})
215+
.next()
216+
217+
const config = stub.mock.calls[0][0].config
218+
// All tools should be passed to the model
219+
expect(config.tools[0].functionDeclarations).toHaveLength(3)
220+
// But only read_file should be allowed to be called
221+
expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toEqual(["read_file"])
222+
})
223+
224+
it("should take precedence over tool_choice when allowedFunctionNames is provided", async () => {
225+
const options = {
226+
apiProvider: "gemini",
227+
} as ApiHandlerOptions
228+
const handler = new GeminiHandler(options)
229+
const stub = vi.fn().mockReturnValue((async function* () {})())
230+
// @ts-ignore access private client
231+
handler["client"].models.generateContentStream = stub
232+
233+
await handler
234+
.createMessage("test", [] as any, {
235+
taskId: "test-task",
236+
tools: testTools,
237+
tool_choice: "auto",
238+
allowedFunctionNames: ["read_file"],
239+
})
240+
.next()
241+
242+
const config = stub.mock.calls[0][0].config
243+
// allowedFunctionNames should take precedence - mode should be ANY, not AUTO
244+
expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.ANY)
245+
expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toEqual(["read_file"])
246+
})
247+
248+
it("should fall back to tool_choice when allowedFunctionNames is empty", async () => {
249+
const options = {
250+
apiProvider: "gemini",
251+
} as ApiHandlerOptions
252+
const handler = new GeminiHandler(options)
253+
const stub = vi.fn().mockReturnValue((async function* () {})())
254+
// @ts-ignore access private client
255+
handler["client"].models.generateContentStream = stub
256+
257+
await handler
258+
.createMessage("test", [] as any, {
259+
taskId: "test-task",
260+
tools: testTools,
261+
tool_choice: "auto",
262+
allowedFunctionNames: [],
263+
})
264+
.next()
265+
266+
const config = stub.mock.calls[0][0].config
267+
// Empty allowedFunctionNames should fall back to tool_choice behavior
268+
expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.AUTO)
269+
expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toBeUndefined()
270+
})
271+
272+
it("should not set toolConfig when allowedFunctionNames is undefined and no tool_choice", async () => {
273+
const options = {
274+
apiProvider: "gemini",
275+
} as ApiHandlerOptions
276+
const handler = new GeminiHandler(options)
277+
const stub = vi.fn().mockReturnValue((async function* () {})())
278+
// @ts-ignore access private client
279+
handler["client"].models.generateContentStream = stub
280+
281+
await handler
282+
.createMessage("test", [] as any, {
283+
taskId: "test-task",
284+
tools: testTools,
285+
})
286+
.next()
287+
288+
const config = stub.mock.calls[0][0].config
289+
// No toolConfig should be set when neither allowedFunctionNames nor tool_choice is provided
290+
expect(config.toolConfig).toBeUndefined()
291+
})
292+
})
144293
})

src/api/providers/gemini.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,19 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
172172
...(tools.length > 0 ? { tools } : {}),
173173
}
174174

175-
if (metadata?.tool_choice) {
175+
// Handle allowedFunctionNames for mode-restricted tool access.
176+
// When provided, all tool definitions are passed to the model (so it can reference
177+
// historical tool calls in conversation), but only the specified tools can be invoked.
178+
// This takes precedence over tool_choice to ensure mode restrictions are honored.
179+
if (metadata?.allowedFunctionNames && metadata.allowedFunctionNames.length > 0) {
180+
config.toolConfig = {
181+
functionCallingConfig: {
182+
// Use ANY mode to allow calling any of the allowed functions
183+
mode: FunctionCallingConfigMode.ANY,
184+
allowedFunctionNames: metadata.allowedFunctionNames,
185+
},
186+
}
187+
} else if (metadata?.tool_choice) {
176188
const choice = metadata.tool_choice
177189
let mode: FunctionCallingConfigMode
178190
let allowedFunctionNames: string[] | undefined

src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest"
22
import type OpenAI from "openai"
33
import type { ModeConfig, ModelInfo } from "@roo-code/types"
4-
import { filterNativeToolsForMode, filterMcpToolsForMode, applyModelToolCustomization } from "../filter-tools-for-mode"
4+
import {
5+
filterNativeToolsForMode,
6+
filterMcpToolsForMode,
7+
applyModelToolCustomization,
8+
resolveToolAlias,
9+
} from "../filter-tools-for-mode"
510
import * as toolsModule from "../../../../shared/tools"
611

712
describe("filterNativeToolsForMode", () => {
@@ -859,3 +864,49 @@ describe("filterMcpToolsForMode", () => {
859864
})
860865
})
861866
})
867+
868+
describe("resolveToolAlias", () => {
869+
it("should resolve known alias to canonical name", () => {
870+
// write_file is an alias for write_to_file (defined in TOOL_ALIASES)
871+
expect(resolveToolAlias("write_file")).toBe("write_to_file")
872+
})
873+
874+
it("should return canonical name unchanged", () => {
875+
expect(resolveToolAlias("write_to_file")).toBe("write_to_file")
876+
expect(resolveToolAlias("read_file")).toBe("read_file")
877+
expect(resolveToolAlias("apply_diff")).toBe("apply_diff")
878+
})
879+
880+
it("should return unknown tool names unchanged", () => {
881+
expect(resolveToolAlias("unknown_tool")).toBe("unknown_tool")
882+
expect(resolveToolAlias("custom_tool_xyz")).toBe("custom_tool_xyz")
883+
})
884+
885+
it("should ensure allowedFunctionNames are consistent with functionDeclarations", () => {
886+
// This test documents the fix for the Gemini allowedFunctionNames issue.
887+
// When tools are renamed via aliasRenames, the alias names must be resolved
888+
// back to canonical names for allowedFunctionNames to match functionDeclarations.
889+
//
890+
// Example scenario:
891+
// - Model specifies includedTools: ["write_file"] (an alias)
892+
// - filterNativeToolsForMode returns tool with name "write_file"
893+
// - But allTools (functionDeclarations) contains "write_to_file" (canonical)
894+
// - If allowedFunctionNames contains "write_file", Gemini will error
895+
// - Resolving aliases ensures consistency: resolveToolAlias("write_file") -> "write_to_file"
896+
897+
const aliasToolName = "write_file"
898+
const canonicalToolName = "write_to_file"
899+
900+
// Simulate extracting name from a filtered tool that was renamed to alias
901+
const extractedName = aliasToolName
902+
903+
// Before the fix: allowedFunctionNames would contain alias name
904+
// This would cause Gemini to error because "write_file" doesn't exist in functionDeclarations
905+
906+
// After the fix: we resolve to canonical name
907+
const resolvedName = resolveToolAlias(extractedName)
908+
909+
// The resolved name matches what's in functionDeclarations (canonical names)
910+
expect(resolvedName).toBe(canonicalToolName)
911+
})
912+
})

src/core/task/Task.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import { sanitizeToolUseId } from "../../utils/tool-id"
9595
// prompts
9696
import { formatResponse } from "../prompts/responses"
9797
import { SYSTEM_PROMPT } from "../prompts/system"
98-
import { buildNativeToolsArray } from "./build-tools"
98+
import { buildNativeToolsArrayWithRestrictions } from "./build-tools"
9999

100100
// core modules
101101
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
@@ -4091,15 +4091,27 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
40914091
const taskProtocol = this._taskToolProtocol ?? "xml"
40924092
const shouldIncludeTools = taskProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false)
40934093

4094-
// Build complete tools array: native tools + dynamic MCP tools, filtered by mode restrictions
4094+
// Build complete tools array: native tools + dynamic MCP tools
4095+
// When includeAllToolsWithRestrictions is true, returns all tools but provides
4096+
// allowedFunctionNames for providers (like Gemini) that need to see all tool
4097+
// definitions in history while restricting callable tools for the current mode.
4098+
// Only Gemini currently supports this - other providers filter tools normally.
40954099
let allTools: OpenAI.Chat.ChatCompletionTool[] = []
4100+
let allowedFunctionNames: string[] | undefined
4101+
4102+
// Gemini requires all tool definitions to be present for history compatibility,
4103+
// but uses allowedFunctionNames to restrict which tools can be called.
4104+
// Other providers (Anthropic, OpenAI, etc.) don't support this feature yet,
4105+
// so they continue to receive only the filtered tools for the current mode.
4106+
const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === "gemini"
4107+
40964108
if (shouldIncludeTools) {
40974109
const provider = this.providerRef.deref()
40984110
if (!provider) {
40994111
throw new Error("Provider reference lost during tool building")
41004112
}
41014113

4102-
allTools = await buildNativeToolsArray({
4114+
const toolsResult = await buildNativeToolsArrayWithRestrictions({
41034115
provider,
41044116
cwd: this.cwd,
41054117
mode,
@@ -4111,7 +4123,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41114123
browserToolEnabled: state?.browserToolEnabled ?? true,
41124124
modelInfo,
41134125
diffEnabled: this.diffEnabled,
4126+
includeAllToolsWithRestrictions: supportsAllowedFunctionNames,
41144127
})
4128+
allTools = toolsResult.tools
4129+
allowedFunctionNames = toolsResult.allowedFunctionNames
41154130
}
41164131

41174132
// Parallel tool calls are disabled - feature is on hold
@@ -4129,6 +4144,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41294144
tool_choice: "auto",
41304145
toolProtocol: taskProtocol,
41314146
parallelToolCalls: parallelToolCallsEnabled,
4147+
// When mode restricts tools, provide allowedFunctionNames so providers
4148+
// like Gemini can see all tools in history but only call allowed ones
4149+
...(allowedFunctionNames ? { allowedFunctionNames } : {}),
41324150
}
41334151
: {}),
41344152
}

0 commit comments

Comments
 (0)