mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-17 15:23:08 -07:00
feat: implement experimental dynamic tools documentation-only pattern
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
} 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<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parseError) {
|
||||
debugLogger.warn(`[LocalAgentExecutor] ${parseError}`);
|
||||
@@ -1097,8 +1154,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
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<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
syncResults.set(callId, {
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
name: functionCall.name,
|
||||
id: callId,
|
||||
response: { error },
|
||||
},
|
||||
@@ -1149,7 +1205,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
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<TOutput extends z.ZodTypeAny> {
|
||||
* 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<TOutput extends z.ZodTypeAny> {
|
||||
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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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<tools>\n${options.dynamicToolsDocumentation}\n</tools>\n` : ''}
|
||||
|
||||
${renderSubAgents(options.subAgents)}
|
||||
|
||||
${renderAgentSkills(options.agentSkills)}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 += `<tool name="${tool.name}">\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<string, unknown>) ||
|
||||
{};
|
||||
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 += '</tool>\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<string, unknown>) || {};
|
||||
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 <tools> 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<string, unknown>;
|
||||
} | 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<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user