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

Commit 958444a

Browse files
committed
feat: support Gemini 3 built-in and custom tool combinations (tool context circulation)
Enable combining server-side built-in tools (Google Search, Code Execution) with client-side function declarations for Gemini 3+ models. This enables "tool context circulation" where the model can invoke its built-in tools server-side and seamlessly pass that context into Roo Code local tools. Changes: - Add isGemini3Model helper to detect Gemini 3+ models - Include googleSearch as a built-in tool alongside function declarations for Gemini 3 models - Handle executableCode and codeExecutionResult parts in streaming response - Store server-side tool parts on handler for conversation history round-tripping via getServerSideToolParts() - Update gemini-format.ts to convert server-side tool content blocks back to Gemini Part format - Update Task.ts addToApiConversationHistory to persist server-side tool parts in the conversation history - Add comprehensive tests for all new functionality Closes #11966
1 parent 92a1be4 commit 958444a

6 files changed

Lines changed: 448 additions & 20 deletions

File tree

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import { GeminiHandler } from "../gemini"
55
import type { ApiHandlerOptions } from "../../../shared/api"
66

77
describe("GeminiHandler backend support", () => {
8-
it("createMessage uses function declarations (URL context and grounding are only for completePrompt)", async () => {
9-
// URL context and grounding are mutually exclusive with function declarations
10-
// in Gemini API, so createMessage only uses function declarations.
11-
// URL context/grounding are only added in completePrompt.
8+
it("createMessage uses function declarations and googleSearch for Gemini 3 models", async () => {
9+
// Gemini 3+ models support combining built-in tools (Google Search) with
10+
// function declarations in a single generation (tool context circulation).
1211
const options = {
1312
apiProvider: "gemini",
1413
enableUrlContext: true,
@@ -20,9 +19,9 @@ describe("GeminiHandler backend support", () => {
2019
handler["client"].models.generateContentStream = stub
2120
await handler.createMessage("instr", [] as any).next()
2221
const config = stub.mock.calls[0][0].config
23-
// createMessage always uses function declarations only
24-
// (tools are always present from ALWAYS_AVAILABLE_TOOLS)
25-
expect(config.tools).toEqual([{ functionDeclarations: expect.any(Array) }])
22+
// Default model is gemini-3.1-pro-preview, a Gemini 3 model,
23+
// so tools should include both function declarations and googleSearch.
24+
expect(config.tools).toEqual([{ functionDeclarations: expect.any(Array) }, { googleSearch: {} }])
2625
})
2726

2827
it("completePrompt passes config overrides without tools when URL context and grounding disabled", async () => {

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

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,206 @@ describe("GeminiHandler", () => {
257257
})
258258
})
259259

260+
describe("Gemini 3 tool context circulation", () => {
261+
const systemPrompt = "You are a helpful assistant"
262+
const mockMessages: Anthropic.Messages.MessageParam[] = [
263+
{ role: "user", content: "Search the web for the latest API docs" },
264+
]
265+
266+
it("should include googleSearch tool for Gemini 3 models", async () => {
267+
const gemini3Handler = new GeminiHandler({
268+
apiKey: "test-key",
269+
apiModelId: "gemini-3-pro-preview",
270+
geminiApiKey: "test-key",
271+
})
272+
273+
const mockGenerateContentStream = vitest.fn().mockResolvedValue({
274+
[Symbol.asyncIterator]: async function* () {
275+
yield { text: "Hello" }
276+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
277+
},
278+
})
279+
280+
gemini3Handler["client"] = {
281+
models: {
282+
generateContentStream: mockGenerateContentStream,
283+
generateContent: vitest.fn(),
284+
},
285+
} as any
286+
287+
const stream = gemini3Handler.createMessage(systemPrompt, mockMessages)
288+
for await (const _chunk of stream) {
289+
// consume
290+
}
291+
292+
const callArgs = mockGenerateContentStream.mock.calls[0][0]
293+
const tools = callArgs.config.tools
294+
expect(tools).toHaveLength(2)
295+
expect(tools[0]).toHaveProperty("functionDeclarations")
296+
expect(tools[1]).toEqual({ googleSearch: {} })
297+
})
298+
299+
it("should NOT include googleSearch tool for pre-Gemini 3 models", async () => {
300+
// The default handler uses geminiDefaultModelId which is gemini-3.1-pro-preview
301+
// Let's create one with a 2.5 model
302+
const gemini25Handler = new GeminiHandler({
303+
apiKey: "test-key",
304+
apiModelId: "gemini-2.5-pro",
305+
geminiApiKey: "test-key",
306+
})
307+
308+
const mockGenerateContentStream = vitest.fn().mockResolvedValue({
309+
[Symbol.asyncIterator]: async function* () {
310+
yield { text: "Hello" }
311+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
312+
},
313+
})
314+
315+
gemini25Handler["client"] = {
316+
models: {
317+
generateContentStream: mockGenerateContentStream,
318+
generateContent: vitest.fn(),
319+
},
320+
} as any
321+
322+
const stream = gemini25Handler.createMessage(systemPrompt, mockMessages)
323+
for await (const _chunk of stream) {
324+
// consume
325+
}
326+
327+
const callArgs = mockGenerateContentStream.mock.calls[0][0]
328+
const tools = callArgs.config.tools
329+
expect(tools).toHaveLength(1)
330+
expect(tools[0]).toHaveProperty("functionDeclarations")
331+
})
332+
333+
it("should handle executableCode parts in streaming response", async () => {
334+
const gemini3Handler = new GeminiHandler({
335+
apiKey: "test-key",
336+
apiModelId: "gemini-3-pro-preview",
337+
geminiApiKey: "test-key",
338+
})
339+
340+
const mockGenerateContentStream = vitest.fn().mockResolvedValue({
341+
[Symbol.asyncIterator]: async function* () {
342+
yield {
343+
candidates: [
344+
{
345+
content: {
346+
parts: [
347+
{
348+
executableCode: {
349+
code: 'print("hello")',
350+
language: "python",
351+
},
352+
},
353+
],
354+
},
355+
},
356+
],
357+
}
358+
yield {
359+
candidates: [
360+
{
361+
content: {
362+
parts: [
363+
{
364+
codeExecutionResult: {
365+
output: "hello",
366+
outcome: "OUTCOME_OK",
367+
},
368+
},
369+
],
370+
},
371+
},
372+
],
373+
}
374+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
375+
},
376+
})
377+
378+
gemini3Handler["client"] = {
379+
models: {
380+
generateContentStream: mockGenerateContentStream,
381+
generateContent: vitest.fn(),
382+
},
383+
} as any
384+
385+
const stream = gemini3Handler.createMessage(systemPrompt, mockMessages)
386+
const chunks = []
387+
for await (const chunk of stream) {
388+
chunks.push(chunk)
389+
}
390+
391+
// Should yield text chunks for executableCode and codeExecutionResult
392+
const textChunks = chunks.filter((c) => c.type === "text")
393+
expect(textChunks.length).toBe(2)
394+
expect(textChunks[0].text).toContain('print("hello")')
395+
expect(textChunks[1].text).toContain("hello")
396+
397+
// Should store server-side tool parts for history round-tripping
398+
const storedParts = gemini3Handler.getServerSideToolParts()
399+
expect(storedParts).toHaveLength(2)
400+
expect(storedParts![0].type).toBe("executableCode")
401+
expect(storedParts![0].data).toEqual({ code: 'print("hello")', language: "python" })
402+
expect(storedParts![1].type).toBe("codeExecutionResult")
403+
expect(storedParts![1].data).toEqual({ output: "hello", outcome: "OUTCOME_OK" })
404+
})
405+
406+
it("should reset server-side tool parts between requests", async () => {
407+
const gemini3Handler = new GeminiHandler({
408+
apiKey: "test-key",
409+
apiModelId: "gemini-3-pro-preview",
410+
geminiApiKey: "test-key",
411+
})
412+
413+
const mockGenerateContentStream = vitest.fn()
414+
415+
gemini3Handler["client"] = {
416+
models: {
417+
generateContentStream: mockGenerateContentStream,
418+
generateContent: vitest.fn(),
419+
},
420+
} as any
421+
422+
// First request: has server-side tool parts
423+
mockGenerateContentStream.mockResolvedValueOnce({
424+
[Symbol.asyncIterator]: async function* () {
425+
yield {
426+
candidates: [
427+
{
428+
content: {
429+
parts: [{ executableCode: { code: "x = 1", language: "python" } }],
430+
},
431+
},
432+
],
433+
}
434+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
435+
},
436+
})
437+
438+
let stream = gemini3Handler.createMessage(systemPrompt, mockMessages)
439+
for await (const _chunk of stream) {
440+
// consume
441+
}
442+
expect(gemini3Handler.getServerSideToolParts()).toHaveLength(1)
443+
444+
// Second request: no server-side tool parts
445+
mockGenerateContentStream.mockResolvedValueOnce({
446+
[Symbol.asyncIterator]: async function* () {
447+
yield { text: "plain text" }
448+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
449+
},
450+
})
451+
452+
stream = gemini3Handler.createMessage(systemPrompt, mockMessages)
453+
for await (const _chunk of stream) {
454+
// consume
455+
}
456+
expect(gemini3Handler.getServerSideToolParts()).toBeUndefined()
457+
})
458+
})
459+
260460
describe("error telemetry", () => {
261461
const mockMessages: Anthropic.Messages.MessageParam[] = [
262462
{

src/api/providers/gemini.ts

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ import { getModelParams } from "../transform/model-params"
2929
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
3030
import { BaseProvider } from "./base-provider"
3131

32+
/**
33+
* Represents a server-side tool part returned by Gemini 3 when built-in tools
34+
* (Google Search, Code Execution, URL Context) are combined with custom
35+
* function declarations. These parts must be preserved and round-tripped in
36+
* conversation history for the model to maintain context.
37+
*/
38+
export type ServerSideToolPart = {
39+
type: "serverSideToolCall" | "serverSideToolResponse" | "executableCode" | "codeExecutionResult"
40+
/** Raw part data from the Gemini API response, preserved for round-tripping. */
41+
data: Record<string, unknown>
42+
}
43+
44+
/**
45+
* Returns true if the model ID corresponds to a Gemini 3+ model that supports
46+
* combining server-side built-in tools (Google Search, URL Context, Code
47+
* Execution) with client-side function declarations in a single generation.
48+
*/
49+
function isGemini3Model(modelId: string): boolean {
50+
return /^gemini-3/.test(modelId)
51+
}
52+
3253
type GeminiHandlerOptions = ApiHandlerOptions & {
3354
isVertex?: boolean
3455
}
@@ -39,6 +60,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
3960
private client: GoogleGenAI
4061
private lastThoughtSignature?: string
4162
private lastResponseId?: string
63+
private lastServerSideToolParts?: ServerSideToolPart[]
4264
private readonly providerName = "Gemini"
4365

4466
constructor({ isVertex, ...options }: GeminiHandlerOptions) {
@@ -80,6 +102,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
80102
// Reset per-request metadata that we persist into apiConversationHistory.
81103
this.lastThoughtSignature = undefined
82104
this.lastResponseId = undefined
105+
this.lastServerSideToolParts = undefined
83106

84107
// For hybrid/budget reasoning models (e.g. Gemini 2.5 Pro), respect user-configured
85108
// modelMaxTokens so the ThinkingBudget slider can control the cap. For effort-only or
@@ -129,18 +152,30 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
129152
.flat()
130153

131154
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS).
132-
// Google built-in tools (Grounding, URL Context) are mutually exclusive
133-
// with function declarations in the Gemini API, so we always use
134-
// function declarations when tools are provided.
135-
const tools: GenerateContentConfig["tools"] = [
136-
{
137-
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
138-
name: (tool as any).function.name,
139-
description: (tool as any).function.description,
140-
parametersJsonSchema: (tool as any).function.parameters,
141-
})),
142-
},
143-
]
155+
// For pre-Gemini 3 models, Google built-in tools (Grounding, URL Context)
156+
// are mutually exclusive with function declarations.
157+
// For Gemini 3+, we can combine them, enabling "tool context circulation"
158+
// where the model can use both server-side built-in tools and client-side
159+
// function declarations in a single generation.
160+
const isGemini3 = isGemini3Model(model)
161+
162+
const functionDeclarationsTool = {
163+
functionDeclarations: (metadata?.tools ?? []).map((tool) => ({
164+
name: (tool as any).function.name,
165+
description: (tool as any).function.description,
166+
parametersJsonSchema: (tool as any).function.parameters,
167+
})),
168+
}
169+
170+
const tools: GenerateContentConfig["tools"] = isGemini3
171+
? [
172+
functionDeclarationsTool,
173+
// Enable Google Search as a built-in tool alongside custom function declarations.
174+
// The model can invoke this server-side, and the results will be circulated back
175+
// as context for subsequent turns.
176+
{ googleSearch: {} },
177+
]
178+
: [functionDeclarationsTool]
144179

145180
// Determine temperature respecting model capabilities and defaults:
146181
// - If supportsTemperature is explicitly false, ignore user overrides
@@ -235,6 +270,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
235270
text?: string
236271
thoughtSignature?: string
237272
functionCall?: { name: string; args: Record<string, unknown> }
273+
executableCode?: { code: string; language?: string }
274+
codeExecutionResult?: { output: string; outcome?: string }
238275
}>) {
239276
// Capture thought signatures so they can be persisted into API history.
240277
const thoughtSignature = part.thoughtSignature
@@ -277,6 +314,37 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
277314
}
278315

279316
toolCallCounter++
317+
} else if (part.executableCode) {
318+
// Server-side code execution part (Gemini 3 built-in tool).
319+
// Surface the code to the user as informational text and
320+
// store the raw part for round-tripping in conversation history.
321+
hasContent = true
322+
const lang = part.executableCode.language ?? "python"
323+
yield {
324+
type: "text",
325+
text: `\n\`\`\`${lang}\n${part.executableCode.code}\n\`\`\`\n`,
326+
}
327+
if (!this.lastServerSideToolParts) {
328+
this.lastServerSideToolParts = []
329+
}
330+
this.lastServerSideToolParts.push({
331+
type: "executableCode",
332+
data: part.executableCode as unknown as Record<string, unknown>,
333+
})
334+
} else if (part.codeExecutionResult) {
335+
// Server-side code execution result (Gemini 3 built-in tool).
336+
hasContent = true
337+
yield {
338+
type: "text",
339+
text: `\n**Code Execution Result:**\n\`\`\`\n${part.codeExecutionResult.output}\n\`\`\`\n`,
340+
}
341+
if (!this.lastServerSideToolParts) {
342+
this.lastServerSideToolParts = []
343+
}
344+
this.lastServerSideToolParts.push({
345+
type: "codeExecutionResult",
346+
data: part.codeExecutionResult as unknown as Record<string, unknown>,
347+
})
280348
} else {
281349
// This is regular content
282350
if (part.text) {
@@ -463,6 +531,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
463531
return this.lastResponseId
464532
}
465533

534+
public getServerSideToolParts(): ServerSideToolPart[] | undefined {
535+
return this.lastServerSideToolParts
536+
}
537+
466538
public calculateCost({
467539
info,
468540
inputTokens,

0 commit comments

Comments
 (0)