feat(core): support dynamic variable substitution in system prompt override (#17042)

This commit is contained in:
N. Taylor Mullen
2026-01-21 09:43:50 -08:00
committed by GitHub
parent 80069b4a78
commit acbef4cd31
3 changed files with 230 additions and 22 deletions

View File

@@ -56,6 +56,38 @@ error with: `missing system prompt file '<path>'`.
When `GEMINI_SYSTEM_MD` is active, the CLI shows a `|⌐■_■|` indicator in the UI
to signal custom systemprompt 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

View File

@@ -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('<available_skills>');
expect(prompt).toContain('<name>test-skill</name>');
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}.');
});
});

View File

@@ -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.name}</name>
<description>${skill.description}</description>
<location>${skill.location}</location>
</skill>`,
)
.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.
<available_skills>
${skillsXml}
</available_skills>
`;
}
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:
</state_snapshot>
`.trim();
}
function getSkillsPrompt(skills: SkillDefinition[]): string {
if (skills.length === 0) {
return '';
}
const skillsXml = skills
.map(
(skill) => ` <skill>
<name>${skill.name}</name>
<description>${skill.description}</description>
<location>${skill.location}</location>
</skill>`,
)
.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.
<available_skills>
${skillsXml}
</available_skills>
`;
}
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;
}