diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index c7fe5fc4ba..b1ff43e3fd 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -56,6 +56,38 @@ error with: `missing system prompt file ''`. When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|⌐■_■|` indicator in the UI to signal custom system‑prompt mode. +## Variable Substitution + +When using a custom system prompt file, you can use the following variables to +dynamically include built-in content: + +- `${AgentSkills}`: Injects a complete section (including header) of all + available agent skills. +- `${SubAgents}`: Injects a complete section (including header) of available + sub-agents. +- `${AvailableTools}`: Injects a bulleted list of all currently enabled tool + names. +- Tool Name Variables: Injects the actual name of a tool using the pattern: + `${toolName}_ToolName` (e.g., `${write_file_ToolName}`, + `${run_shell_command_ToolName}`). + + This pattern is generated dynamically for all available tools. + +### Example + +```markdown +# Custom System Prompt + +You are a helpful assistant. ${AgentSkills} +${SubAgents} + +## Tooling + +The following tools are available to you: ${AvailableTools} + +You can use ${write_file_ToolName} to save logs. +``` + ## Export the default prompt (recommended) Before overriding, export the current default prompt so you can review required diff --git a/packages/core/src/core/prompts-substitution.test.ts b/packages/core/src/core/prompts-substitution.test.ts new file mode 100644 index 0000000000..d56d9c54b0 --- /dev/null +++ b/packages/core/src/core/prompts-substitution.test.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getCoreSystemPrompt } from './prompts.js'; +import fs from 'node:fs'; +import type { Config } from '../config/config.js'; +import * as toolNames from '../tools/tool-names.js'; + +vi.mock('node:fs'); +vi.mock('../utils/gitUtils', () => ({ + isGitRepository: vi.fn().mockReturnValue(false), +})); + +describe('Core System Prompt Substitution', () => { + let mockConfig: Config; + beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('GEMINI_SYSTEM_MD', 'true'); + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue({ + getAllToolNames: vi + .fn() + .mockReturnValue([ + toolNames.WRITE_FILE_TOOL_NAME, + toolNames.READ_FILE_TOOL_NAME, + ]), + }), + getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), + }, + isInteractive: vi.fn().mockReturnValue(true), + isInteractiveShellEnabled: vi.fn().mockReturnValue(true), + isAgentsEnabled: vi.fn().mockReturnValue(false), + getModel: vi.fn().mockReturnValue('auto'), + getActiveModel: vi.fn().mockReturnValue('gemini-1.5-pro'), + getPreviewFeatures: vi.fn().mockReturnValue(false), + getAgentRegistry: vi.fn().mockReturnValue({ + getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), + }), + getSkillManager: vi.fn().mockReturnValue({ + getSkills: vi.fn().mockReturnValue([]), + }), + } as unknown as Config; + }); + + it('should substitute ${AgentSkills} in custom system prompt', () => { + const skills = [ + { + name: 'test-skill', + description: 'A test skill description', + location: '/path/to/test-skill/SKILL.md', + body: 'Skill content', + }, + ]; + vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue(skills); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'Skills go here: ${AgentSkills}', + ); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('Skills go here:'); + expect(prompt).toContain(''); + expect(prompt).toContain('test-skill'); + expect(prompt).not.toContain('${AgentSkills}'); + }); + + it('should substitute ${SubAgents} in custom system prompt', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('Agents: ${SubAgents}'); + vi.mocked( + mockConfig.getAgentRegistry().getDirectoryContext, + ).mockReturnValue('Actual Agent Directory'); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('Agents: Actual Agent Directory'); + expect(prompt).not.toContain('${SubAgents}'); + }); + + it('should substitute ${AvailableTools} in custom system prompt', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('Tools:\n${AvailableTools}'); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain( + `Tools:\n- ${toolNames.WRITE_FILE_TOOL_NAME}\n- ${toolNames.READ_FILE_TOOL_NAME}`, + ); + expect(prompt).not.toContain('${AvailableTools}'); + }); + + it('should substitute tool names using the ${toolName}_ToolName pattern', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'Use ${write_file_ToolName} and ${read_file_ToolName}.', + ); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain( + `Use ${toolNames.WRITE_FILE_TOOL_NAME} and ${toolNames.READ_FILE_TOOL_NAME}.`, + ); + expect(prompt).not.toContain('${write_file_ToolName}'); + expect(prompt).not.toContain('${read_file_ToolName}'); + }); + + it('should not substitute old patterns', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '${WriteFileToolName} and ${WRITE_FILE_TOOL_NAME}', + ); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toBe('${WriteFileToolName} and ${WRITE_FILE_TOOL_NAME}'); + }); + + it('should not substitute disabled tool names', () => { + vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('Use ${write_file_ToolName}.'); + + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toBe('Use ${write_file_ToolName}.'); + }); +}); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 99a42df1b0..ce385bb05c 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -26,6 +26,7 @@ import { GEMINI_DIR, homedir } from '../utils/paths.js'; import { debugLogger } from '../utils/debugLogger.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import { resolveModel, isPreviewModel } from '../config/models.js'; +import type { SkillDefinition } from '../skills/skillLoader.js'; export function resolvePathFromEnv(envVar?: string): { isSwitch: boolean; @@ -132,32 +133,12 @@ export function getCoreSystemPrompt( const interactiveMode = interactiveOverride ?? config.isInteractive(); const skills = config.getSkillManager().getSkills(); - let skillsPrompt = ''; - if (skills.length > 0) { - const skillsXml = skills - .map( - (skill) => ` - ${skill.name} - ${skill.description} - ${skill.location} - `, - ) - .join('\n'); - - skillsPrompt = ` -# Available Agent Skills - -You have access to the following specialized skills. To activate a skill and receive its detailed instructions, you can call the \`${ACTIVATE_SKILL_TOOL_NAME}\` tool with the skill's name. - - -${skillsXml} - -`; - } + const skillsPrompt = getSkillsPrompt(skills); let basePrompt: string; if (systemMdEnabled) { basePrompt = fs.readFileSync(systemMdPath, 'utf8'); + basePrompt = applySubstitutions(basePrompt, config, skillsPrompt); } else { const promptConfig = { preamble: `You are ${interactiveMode ? 'an interactive ' : 'a non-interactive '}CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.`, @@ -497,3 +478,64 @@ The structure MUST be as follows: `.trim(); } + +function getSkillsPrompt(skills: SkillDefinition[]): string { + if (skills.length === 0) { + return ''; + } + + const skillsXml = skills + .map( + (skill) => ` + ${skill.name} + ${skill.description} + ${skill.location} + `, + ) + .join('\n'); + + return ` +# Available Agent Skills + +You have access to the following specialized skills. To activate a skill and receive its detailed instructions, you can call the \`${ACTIVATE_SKILL_TOOL_NAME}\` tool with the skill's name. + + +${skillsXml} + +`; +} + +function applySubstitutions( + prompt: string, + config: Config, + skillsPrompt: string, +): string { + let result = prompt; + + // Substitute skills and agents + result = result.replace(/\${AgentSkills}/g, skillsPrompt); + result = result.replace( + /\${SubAgents}/g, + config.getAgentRegistry().getDirectoryContext(), + ); + + // Substitute available tools list + const toolRegistry = config.getToolRegistry(); + const allToolNames = toolRegistry.getAllToolNames(); + const availableToolsList = + allToolNames.length > 0 + ? allToolNames.map((name) => `- ${name}`).join('\n') + : 'No tools are currently available.'; + result = result.replace(/\${AvailableTools}/g, availableToolsList); + + // Substitute tool names + for (const toolName of allToolNames) { + const varName = `${toolName}_ToolName`; + result = result.replace( + new RegExp(`\\\${\\b${varName}\\b}`, 'g'), + toolName, + ); + } + + return result; +}