From d7ecbb072f76fc4a28ac2ea1f288d1f155884162 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 9 Apr 2026 17:39:16 -0700 Subject: [PATCH] feat: implement experimental dynamic tools documentation-only pattern --- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 10 + packages/core/src/agents/local-executor.ts | 79 +++++++- packages/core/src/config/config.ts | 12 ++ packages/core/src/core/client.ts | 24 ++- packages/core/src/core/geminiChat.ts | 31 ++- packages/core/src/core/turn.ts | 88 ++++++++- packages/core/src/prompts/promptProvider.ts | 12 ++ packages/core/src/prompts/snippets.ts | 3 + .../core/src/services/chatRecordingService.ts | 14 +- packages/core/src/utils/dynamicToolsUtils.ts | 184 ++++++++++++++++++ packages/test-utils/src/test-rig.ts | 3 +- 12 files changed, 430 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/utils/dynamicToolsUtils.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4e7e1db6f2..01e8fd3dd9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -990,6 +990,7 @@ export async function loadCliConfig( disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, experimentalMemoryManager: settings.experimental?.memoryManager, + experimentalDynamicTools: settings.experimental?.dynamicTools, contextManagement, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 076978b203..39f073e8e0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2207,6 +2207,16 @@ const SETTINGS_SCHEMA = { 'Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.', showInDialog: true, }, + dynamicTools: { + type: 'boolean', + label: 'Dynamic Tools Documentation', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable documentation-injected dynamic tools experiment. This hides native tools and exposes a single execute() function.', + showInDialog: true, + }, }, }, extensions: { diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index f257018b1b..b47c843735 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -83,6 +83,12 @@ import { COMPLETE_TASK_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME, } from '../tools/definitions/base-declarations.js'; +import { + getDynamicToolsDocumentation, + EXECUTE_FUNCTION_DECLARATION, + unwrapExecuteArgs, + wrapAsExecute, +} from '../utils/dynamicToolsUtils.js'; /** A callback function to report on agent activity. */ export type ActivityCallback = (activity: SubagentActivityEvent) => void; @@ -1074,7 +1080,58 @@ export class LocalAgentExecutor { for (const [index, functionCall] of functionCalls.entries()) { const callId = functionCall.id ?? `${promptId}-${index}`; - const { args, error: parseError } = this.parseToolArguments(functionCall); + let { args, error: parseError } = this.parseToolArguments(functionCall); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + let effectiveToolName = functionCall.name as string; + let effectiveArgs = args; + let originalRequestName: string | undefined; + let originalRequestArgs: Record | undefined; + + // Handle experimental dynamic tools + if ( + !parseError && + this.context.config.getExperimentalDynamicTools() + ) { + if (functionCall.name === 'execute') { + const unwrapped = unwrapExecuteArgs(functionCall.args); + if (unwrapped) { + debugLogger.log( + `[LocalAgentExecutor] Unwrapping execute call for tool: ${unwrapped.name}`, + ); + effectiveToolName = unwrapped.name; + effectiveArgs = unwrapped.args; + args = unwrapped.args; + originalRequestName = 'execute'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + originalRequestArgs = functionCall.args as Record; + } else { + parseError = + 'Invalid execute arguments. Expected {name: string, args: object}'; + } + } else if (functionCall.name) { + // Model called a tool directly despite documentation. + // Recovery: Wrap it as an execute call internally. + debugLogger.log( + `[LocalAgentExecutor] Model called tool '${functionCall.name}' directly. Wrapping as 'execute' for experiment compatibility.`, + ); + + const wrapped = wrapAsExecute(functionCall.name, args); + const unwrapped = unwrapExecuteArgs(wrapped.args); // Still unwrap to get resilient parameter mapping + + if (unwrapped) { + // IMPORTANT: Rewrite the original functionCall so history recording sees it as "execute" + functionCall.name = wrapped.name; + functionCall.args = wrapped.args; + + effectiveToolName = unwrapped.name; + effectiveArgs = unwrapped.args; + args = unwrapped.args; + originalRequestName = 'execute'; + originalRequestArgs = wrapped.args as Record; + } + } + } if (parseError) { debugLogger.warn(`[LocalAgentExecutor] ${parseError}`); @@ -1097,8 +1154,7 @@ export class LocalAgentExecutor { continue; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const toolName = functionCall.name as string; + const toolName = effectiveToolName; let displayName = toolName; let description: string | undefined = undefined; @@ -1129,7 +1185,7 @@ export class LocalAgentExecutor { syncResults.set(callId, { functionResponse: { - name: toolName, + name: functionCall.name, id: callId, response: { error }, }, @@ -1149,7 +1205,9 @@ export class LocalAgentExecutor { toolRequests.push({ callId, name: toolName, - args, + args: effectiveArgs, + originalRequestName, + originalRequestArgs, isClientInitiated: false, // These are coming from the subagent (the "model") prompt_id: promptId, }); @@ -1285,6 +1343,9 @@ export class LocalAgentExecutor { * Prepares the list of tool function declarations to be sent to the model. */ private prepareToolsList(): FunctionDeclaration[] { + if (this.context.config.getExperimentalDynamicTools()) { + return [EXECUTE_FUNCTION_DECLARATION]; + } const toolsList: FunctionDeclaration[] = []; const { toolConfig } = this.definition; @@ -1341,6 +1402,14 @@ export class LocalAgentExecutor { const dirContext = await getDirectoryContextString(this.context.config); finalPrompt += `\n\n# Environment Context\n${dirContext}`; + if (this.context.config.getExperimentalDynamicTools()) { + const toolDeclarations = this.toolRegistry.getFunctionDeclarations( + this.definition.modelConfig.model, + ); + const toolDocumentation = getDynamicToolsDocumentation(toolDeclarations); + finalPrompt += `\n\n${toolDocumentation}`; + } + // Append standard rules for non-interactive execution. finalPrompt += ` Important Rules: diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2ef6fc26a8..b66319fca7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -699,6 +699,7 @@ export interface ConfigParameters { experimentalJitContext?: boolean; autoDistillation?: boolean; experimentalMemoryManager?: boolean; + experimentalDynamicTools?: boolean; experimentalAgentHistoryTruncation?: boolean; experimentalAgentHistoryTruncationThreshold?: number; experimentalAgentHistoryRetainedMessages?: number; @@ -941,6 +942,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly adminSkillsEnabled: boolean; private readonly experimentalJitContext: boolean; private readonly experimentalMemoryManager: boolean; + private readonly experimentalDynamicTools: boolean; private readonly memoryBoundaryMarkers: readonly string[]; private readonly topicUpdateNarration: boolean; private readonly disableLLMCorrection: boolean; @@ -1152,6 +1154,12 @@ export class Config implements McpContext, AgentLoopContext { this.experimentalJitContext = params.experimentalJitContext ?? false; this.experimentalMemoryManager = params.experimentalMemoryManager ?? false; + this.experimentalDynamicTools = + params.experimentalDynamicTools === true || + process.env['GEMINI_CLI_EXP_DYNAMIC_TOOLS'] === 'true'; + if (this.experimentalDynamicTools) { + debugLogger.log('[Config] Experimental Dynamic Tools enabled.'); + } this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git']; this.contextManagement = { enabled: params.contextManagement?.enabled ?? false, @@ -2426,6 +2434,10 @@ export class Config implements McpContext, AgentLoopContext { return this.experimentalMemoryManager; } + getExperimentalDynamicTools(): boolean { + return this.experimentalDynamicTools; + } + getContextManagementConfig(): ContextManagementConfig { return this.contextManagement; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 491758049d..c892cbe5f3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -27,6 +27,7 @@ import { import type { Config } from '../config/config.js'; import { type AgentLoopContext } from '../config/agent-loop-context.js'; import { getCoreSystemPrompt } from './prompts.js'; +import { EXECUTE_FUNCTION_DECLARATION } from '../utils/dynamicToolsUtils.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { reportError } from '../utils/errorReporting.js'; import { GeminiChat } from './geminiChat.js'; @@ -301,12 +302,20 @@ export class GeminiClient { } this.lastUsedModelId = modelId; - const toolRegistry = this.context.toolRegistry; - const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId); - const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; + const tools = this.getChatTools(modelId); this.getChat().setTools(tools); } + private getChatTools(modelId?: string): Tool[] { + const toolRegistry = this.context.toolRegistry; + if (this.config.getExperimentalDynamicTools()) { + debugLogger.log('[GeminiClient] Experimental Dynamic Tools enabled.'); + return [{ functionDeclarations: [EXECUTE_FUNCTION_DECLARATION] }]; + } + const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId); + return [{ functionDeclarations: toolDeclarations }]; + } + async resetChat(): Promise { this.chat = await this.startChat(); this.updateTelemetryTokenCount(); @@ -369,9 +378,7 @@ export class GeminiClient { this.hasFailedCompressionAttempt = false; this.lastUsedModelId = undefined; - const toolRegistry = this.context.toolRegistry; - const toolDeclarations = toolRegistry.getFunctionDeclarations(); - const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; + const tools = this.getChatTools(); const history = await getInitialChatHistory(this.config, extraHistory); @@ -386,10 +393,7 @@ export class GeminiClient { resumedSessionData, async (modelId: string) => { this.lastUsedModelId = modelId; - const toolRegistry = this.context.toolRegistry; - const toolDeclarations = - toolRegistry.getFunctionDeclarations(modelId); - return [{ functionDeclarations: toolDeclarations }]; + return this.getChatTools(modelId); }, ); } catch (error) { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index b0efc9e1e4..c5246af714 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -49,12 +49,14 @@ import { isFunctionResponse } from '../utils/messageInspectors.js'; import { partListUnionToString } from './geminiRequest.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; +import { wrapAsExecute } from '../utils/dynamicToolsUtils.js'; import { applyModelSelection, createAvailabilityContextProvider, } from '../availability/policyHelpers.js'; import { coreEvents } from '../utils/events.js'; import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { debugLogger } from '../utils/debugLogger.js'; export enum StreamEventType { /** A regular content chunk from the API. */ @@ -892,11 +894,30 @@ export class GeminiChat { if (isValidResponse(chunk)) { const content = chunk.candidates?.[0]?.content; if (content?.parts) { - if (content.parts.some((part) => part.thought)) { - // Record thoughts - hasThoughts = true; - this.recordThoughtFromContent(content); + for (const part of content.parts) { + if (part.thought) { + // Record thoughts + hasThoughts = true; + this.recordThoughtFromContent(content); + } + if ( + part.functionCall && + part.functionCall.name && + part.functionCall.name !== 'execute' && + this.context.config.getExperimentalDynamicTools() + ) { + debugLogger.log( + `[GeminiChat] Rewriting hallucinated tool call '${part.functionCall.name}' to 'execute' wrapper.`, + ); + const wrapped = wrapAsExecute( + part.functionCall.name, + part.functionCall.args, + ); + part.functionCall.name = wrapped.name; + part.functionCall.args = wrapped.args; + } } + if (content.parts.some((part) => part.functionCall)) { hasToolCall = true; } @@ -1048,6 +1069,8 @@ export class GeminiChat { resultDisplay, description: 'invocation' in call ? call.invocation?.getDescription() : undefined, + originalRequestName: call.request.originalRequestName, + originalRequestArgs: call.request.originalRequestArgs, }; }); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 9c0e536c48..8163338380 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -29,6 +29,11 @@ import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { getCitations } from '../utils/generateContentResponseUtilities.js'; import { LlmRole } from '../telemetry/types.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { + unwrapExecuteArgs, + wrapAsExecute, +} from '../utils/dynamicToolsUtils.js'; import { type ToolCallRequestInfo, @@ -237,6 +242,7 @@ export type ServerGeminiStreamEvent = // A turn manages the agentic loop turn within the server context. export class Turn { private callCounter = 0; + private handledCallIds = new Set(); readonly pendingToolCalls: ToolCallRequestInfo[] = []; private debugResponses: GenerateContentResponse[] = []; @@ -409,7 +415,74 @@ export class Turn { ): ServerGeminiStreamEvent | null { const name = fnCall.name || 'undefined_tool_name'; const args = fnCall.args || {}; - const callId = fnCall.id ?? `${name}_${Date.now()}_${this.callCounter++}`; + const callId = fnCall.id || `${name}_${Date.now()}_${this.callCounter++}`; + + if (this.handledCallIds.has(callId)) { + return null; + } + this.handledCallIds.add(callId); + + // Handle experimental dynamic tools + if ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + (this.chat as any).context.config.getExperimentalDynamicTools() + ) { + if (name === 'execute') { + const unwrapped = unwrapExecuteArgs(fnCall.args); + if (unwrapped) { + debugLogger.log( + `[Turn] Unwrapping execute call for tool: ${unwrapped.name}`, + ); + + const toolCallRequest: ToolCallRequestInfo = { + callId, + name: unwrapped.name, + args: unwrapped.args, + originalRequestName: 'execute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion + originalRequestArgs: fnCall.args as any, + isClientInitiated: false, + prompt_id: this.prompt_id, + traceId, + }; + + this.pendingToolCalls.push(toolCallRequest); + return { + type: GeminiEventType.ToolCallRequest, + value: toolCallRequest, + }; + } + } else if (name !== 'undefined_tool_name') { + // Model called a tool directly despite documentation. + // It should have been wrapped by GeminiChat.makeApiCallAndProcessStream, + // but as a failsafe we wrap it here too. + debugLogger.log( + `[Turn] Model called tool '${name}' directly. Recovery wrapping as 'execute'.`, + ); + + const unwrapped = unwrapExecuteArgs(wrapAsExecute(name, args).args); + + if (unwrapped) { + const toolCallRequest: ToolCallRequestInfo = { + callId, + name: unwrapped.name, + args: unwrapped.args, + originalRequestName: 'execute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion + originalRequestArgs: wrapAsExecute(name, args).args as any, + isClientInitiated: false, + prompt_id: this.prompt_id, + traceId, + }; + + this.pendingToolCalls.push(toolCallRequest); + return { + type: GeminiEventType.ToolCallRequest, + value: toolCallRequest, + }; + } + } + } const toolCallRequest: ToolCallRequestInfo = { callId, @@ -426,15 +499,6 @@ export class Turn { return { type: GeminiEventType.ToolCallRequest, value: toolCallRequest }; } - getDebugResponses(): GenerateContentResponse[] { - return this.debugResponses; - } - - /** - * Get the concatenated response text from all responses in this turn. - * This extracts and joins all text content from the model's responses. - * The result is cached since this is called multiple times per turn. - */ getResponseText(): string { if (this.cachedResponseText === undefined) { this.cachedResponseText = this.debugResponses @@ -444,4 +508,8 @@ export class Turn { } return this.cachedResponseText; } + + getDebugResponses(): GenerateContentResponse[] { + return this.debugResponses; + } } diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 36d08c7e74..4f63d9c98b 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -32,6 +32,9 @@ import { resolveModel, supportsModernFeatures } from '../config/models.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { getAllGeminiMdFilenames } from '../tools/memoryTool.js'; import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import { + getDynamicToolsDocumentation, +} from '../utils/dynamicToolsUtils.js'; /** * Orchestrates prompt generation by gathering context and building options. @@ -96,6 +99,13 @@ export class PromptProvider { .join('\n'); } + let dynamicToolsDocumentation: string | undefined; + if (context.config.getExperimentalDynamicTools()) { + dynamicToolsDocumentation = getDynamicToolsDocumentation( + context.toolRegistry.getFunctionDeclarations(), + ); + } + let basePrompt: string; // --- Template File Override --- @@ -141,6 +151,7 @@ export class PromptProvider { contextFilenames, topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(), })), + dynamicToolsDocumentation, subAgents: this.withSection( 'agentContexts', () => @@ -282,6 +293,7 @@ export class PromptProvider { ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; + return activeSnippets.getCompressionPrompt( context.config.getApprovedPlanPath(), ); diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index d75a6545e7..587fad26f1 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -43,6 +43,7 @@ import { DEFAULT_CONTEXT_FILENAME } from '../tools/memoryTool.js'; export interface SystemPromptOptions { preamble?: PreambleOptions; coreMandates?: CoreMandatesOptions; + dynamicToolsDocumentation?: string; subAgents?: SubAgentOptions[]; agentSkills?: AgentSkillOptions[]; hookContext?: boolean; @@ -127,6 +128,8 @@ ${renderPreamble(options.preamble)} ${renderCoreMandates(options.coreMandates)} +${options.dynamicToolsDocumentation ? `\n\n${options.dynamicToolsDocumentation}\n\n` : ''} + ${renderSubAgents(options.subAgents)} ${renderAgentSkills(options.agentSkills)} diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index c71519f858..7cd63999ce 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -72,6 +72,10 @@ export interface ToolCallRecord { description?: string; resultDisplay?: ToolResultDisplay; renderOutputAsMarkdown?: boolean; + /** Experimental: original name of the tool requested by the model before unwrapping */ + originalRequestName?: string; + /** Experimental: original arguments of the tool requested by the model before unwrapping */ + originalRequestArgs?: Record; } /** @@ -108,6 +112,8 @@ export interface ConversationRecord { directories?: string[]; /** The kind of conversation (main agent or subagent) */ kind?: 'main' | 'subagent'; + /** Experimental: Whether dynamic tools documentation-injection was enabled */ + experimentalDynamicTools?: boolean; } /** @@ -235,6 +241,7 @@ export class ChatRecordingService { messages: [], directories, kind: this.kind, + experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), }); } @@ -487,7 +494,10 @@ export class ChatRecordingService { this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.cachedConversation = JSON.parse(this.cachedLastConvData); - if (!this.cachedConversation) { + if (this.cachedConversation) { + this.cachedConversation.experimentalDynamicTools = + this.context.config.getExperimentalDynamicTools(); + } else { // File is corrupt or contains "null". Fallback to an empty conversation. this.cachedConversation = { sessionId: this.sessionId, @@ -496,6 +506,7 @@ export class ChatRecordingService { lastUpdated: new Date().toISOString(), messages: [], kind: this.kind, + experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), }; } return this.cachedConversation; @@ -514,6 +525,7 @@ export class ChatRecordingService { lastUpdated: new Date().toISOString(), messages: [], kind: this.kind, + experimentalDynamicTools: this.context.config.getExperimentalDynamicTools(), }; return this.cachedConversation; } diff --git a/packages/core/src/utils/dynamicToolsUtils.ts b/packages/core/src/utils/dynamicToolsUtils.ts new file mode 100644 index 0000000000..33362ba19e --- /dev/null +++ b/packages/core/src/utils/dynamicToolsUtils.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FunctionDeclaration, Schema } from '@google/genai'; +import { Type } from '@google/genai'; +import { debugLogger } from './debugLogger.js'; + +/** + * Generates documentation for tools to be injected into the system prompt. + */ +export function getDynamicToolsDocumentation( + tools: FunctionDeclaration[], +): string { + let doc = + '# Tools\n\nYou have a variety of tools available to you that can be called via the `execute` function. ALWAYS use `execute` for all tool calls. Additional tools may be loaded dynamically as you continue to work.\n\n## Initial Tools\n\n'; + for (const tool of tools) { + if (!tool.name) continue; + const argsName = `${tool.name + .split(/_|-/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join('')}Args`; + doc += `\n`; + doc += `Description: ${tool.description ?? ''}\n\n`; + doc += `Usage: execute({name: "${tool.name}", args: ...})\n\n`; + if (tool.parameters) { + doc += 'Arguments:\n'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const properties = + ((tool.parameters.properties as unknown) as Record) || + {}; + for (const [name, propRaw] of Object.entries(properties)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const prop = propRaw as Schema; + const isRequired = tool.parameters.required?.includes(name); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typeStr = (prop.type as string) || 'any'; + doc += `- ${name} (${typeStr}${isRequired ? ', REQUIRED' : ', OPTIONAL'}): ${prop.description ?? ''}\n`; + } + doc += '\n```ts\n'; + doc += `interface ${argsName} ${schemaToTypeScript(tool.parameters)}\n`; + doc += '```\n'; + } + doc += '\n\n'; + } + return doc; +} + +/** + * Converts a JSON schema to a TypeScript interface-like string. + */ +export function schemaToTypeScript(schema: Schema, indent = ''): string { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const type = (schema.type as string) || 'any'; + if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + schema.type === (Type.OBJECT as unknown as string) || + schema.type === Type.OBJECT + ) { + let ts = '{\n'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const properties = + ((schema.properties as unknown) as Record) || {}; + for (const [name, propRaw] of Object.entries(properties)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const prop = propRaw as Schema; + const isRequired = schema.required?.includes(name); + if (prop.description) { + ts += `${indent} /** ${prop.description.replace(/\n/g, ' ')} */\n`; + } + const optional = isRequired ? '' : '?'; + ts += `${indent} ${name}${optional}: ${schemaToTypeScript( + prop, + indent + ' ', + )};${isRequired ? ' // REQUIRED' : ''}\n`; + } + ts += `${indent}}`; + return ts; + } else if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + schema.type === (Type.ARRAY as unknown as string) || + schema.type === Type.ARRAY + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const items = schema.items as Schema; + return `${schemaToTypeScript(items, indent)}[]`; + } else { + return type; + } +} + +/** + * The single `execute` function declaration used when dynamic tools experiment is active. + */ +export const EXECUTE_FUNCTION_DECLARATION: FunctionDeclaration = { + name: 'execute', + description: + 'Executes a tool by its name. Check the documentation in the system prompt for available tools and their arguments.', + parameters: { + type: Type.OBJECT, + properties: { + name: { + type: Type.STRING, + description: 'The name of the tool to execute.', + }, + args: { + type: Type.OBJECT, + description: 'The arguments for the tool.', + }, + }, + required: ['name', 'args'], + }, +}; + +/** + * Wraps a direct tool call into the `execute` pattern. + * This is used to recover when the model calls a tool directly despite instructions. + */ +export function wrapAsExecute( + name: string, + args: unknown, +): { + name: string; + args: { name: string; args: unknown }; +} { + return { + name: 'execute', + args: { + name, + args, + }, + }; +} + +/** + * Unwraps the arguments for an `execute` call, applying resilient parameter mapping + * to help the model succeed during the documentation-only tools experiment. + */ +export function unwrapExecuteArgs(args: unknown): { + name: string; + args: Record; +} | null { + if (typeof args !== 'object' || args === null) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion + const name = (args as any).name; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion + let innerArgs = (args as any).args; + + if (typeof name !== 'string' || !innerArgs || typeof innerArgs !== 'object') { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + innerArgs = { ...innerArgs }; + + // Resilient parameter mapping for common model hallucinations/priors + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + const anyArgs: any = innerArgs; + if (name === 'read_file' && anyArgs.path && !anyArgs.file_path) { + debugLogger.log(`[DynamicTools] Mapping path -> file_path for read_file: ${anyArgs.path}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + anyArgs.file_path = anyArgs.path; + } + if ( + (name === 'list_directory' || name === 'grep_search' || name === 'glob') && + anyArgs.path && + !anyArgs.dir_path + ) { + debugLogger.log(`[DynamicTools] Mapping path -> dir_path for ${name}: ${anyArgs.path}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + anyArgs.dir_path = anyArgs.path; + } + + return { + name, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + args: innerArgs as Record, + }; +} diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 734c1b9546..7455900c2c 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -659,7 +659,8 @@ export class TestRig { key !== 'GEMINI_DEBUG' && key !== 'GEMINI_CLI_TEST_VAR' && key !== 'GEMINI_CLI_INTEGRATION_TEST' && - !key.startsWith('GEMINI_CLI_ACTIVITY_LOG') + !key.startsWith('GEMINI_CLI_ACTIVITY_LOG') && + !key.startsWith('GEMINI_CLI_EXP_') ) { delete cleanEnv[key]; }