feat(core): 实现动态提示词模板系统

- 新增 prompt-template 模块,支持运行时变量替换
- 支持 ${variable}、${obj.prop}、${cond ? "a" : "b"} 语法
- AgentInfo 新增 promptTemplate 字段标记动态模板
- Plan Agent 提示词改用模板语法,支持动态工具名和计划文件路径
- AgentExecutor.buildSystemPrompt 集成模板渲染
- 新增 27 个单元测试验证模板功能
This commit is contained in:
2025-12-16 14:15:10 +08:00
parent a32c83480d
commit 58f1bc8718
10 changed files with 968 additions and 13 deletions
+11 -1
View File
@@ -17,6 +17,7 @@ import type {
} from './types.js';
import { checkBashPermission, isPathInAllowedWritePaths } from './permission-merger.js';
import { getProviderRegistry } from '../provider/index.js';
import { renderTemplate, createPlanContext } from './prompt-template/index.js';
/**
* Agent 执行器
@@ -286,10 +287,19 @@ export class AgentExecutor {
/**
* 构建系统提示词
* 如果 Agent 启用了 promptTemplate,则动态渲染模板变量
*/
private buildSystemPrompt(): string {
// 如果 Agent 有自定义 prompt,使用它
// 如果 Agent 有自定义 prompt
if (this.agentInfo.prompt) {
// 如果启用了模板渲染,动态解析变量
if (this.agentInfo.promptTemplate) {
const context = createPlanContext({
workdir: process.cwd(),
isSubagent: this.agentInfo.mode === 'subagent',
});
return renderTemplate(this.agentInfo.prompt, context);
}
return this.agentInfo.prompt;
}
+25
View File
@@ -60,3 +60,28 @@ export {
// System Prompt
export { SystemPrompt } from './system-prompt.js';
// Prompt Template
export {
renderTemplate,
render,
createDefaultContext,
createPlanContext,
checkPlanFileExists,
DEFAULT_TOOL_NAMES,
generatePlanPrompt,
resolveAgentPrompt,
PLAN_MODE_TEMPLATE,
PLAN_MODE_SUBAGENT_TEMPLATE,
PLAN_MODE_REMINDER_TEMPLATE,
} from './prompt-template/index.js';
export type {
PromptContext,
PromptTemplate,
RenderOptions,
ToolNameMapping,
PlanModeContext,
EnvContext,
AgentContext,
} from './prompt-template/index.js';
+23 -12
View File
@@ -1,15 +1,17 @@
import type { AgentInfo } from '../types.js';
/**
* Plan Agent 专用提示词
* Plan Agent 专用提示词模板
*
* 变量映射
* - GLOB_TOOL_NAME -> glob
* - GREP_TOOL_NAME -> grep_content
* - READ_TOOL_NAME -> read_file
* - BASH_TOOL_NAME -> bash
* 使用 ${variable} 语法支持动态变量替换
* - ${tools.glob} -> glob
* - ${tools.grep} -> grep_content
* - ${tools.read} -> read_file
* - ${tools.bash} -> bash
* - ${plan.planFilePath} -> 计划文件路径
* - ${plan.planExists ? "..." : "..."} -> 条件渲染
*/
const PLAN_PROMPT = `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
const PLAN_PROMPT_TEMPLATE = `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
@@ -23,6 +25,11 @@ This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
## Plan File Info
\${plan.planExists ? "A plan file already exists at \${plan.planFilePath}. You can read it and make incremental edits using the \${tools.edit} tool." : "No plan file exists yet. You should create your plan at \${plan.planFilePath} using the \${tools.write} tool."}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
## Your Process
@@ -31,12 +38,12 @@ You will be provided with a set of requirements and optionally a perspective on
2. **Explore Thoroughly**:
- Read any files provided to you in the initial prompt
- Find existing patterns and conventions using glob, grep_content, and read_file
- Find existing patterns and conventions using \${tools.glob}, \${tools.grep}, and \${tools.read}
- Understand the current architecture
- Identify similar features as reference
- Trace through relevant code paths
- Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
- NEVER use bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
- Use \${tools.bash} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
- NEVER use \${tools.bash} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
3. **Design Solution**:
- Create implementation approach based on your assigned perspective
@@ -58,13 +65,16 @@ List 3-5 files most critical for implementing this plan:
- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]
- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.`;
Answer the user's query comprehensively, using the \${tools.askUserQuestion} tool if you need to ask clarifying questions.
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files (except the plan file at \${plan.planFilePath}).`;
/**
* 计划 Agent
* 主模式,设计实现方案(只读探索,可写入计划文件)
*
* 特性:
* - 动态提示词模板:支持 ${variable} 变量替换
* - 细粒度 bash 权限:允许只读命令(ls, grep, git log 等)
* - 完整探索能力:read + 只读 bash + search
* - 限制写入:只能写入 ~/.ai-terminal-assistant/plan/ 目录
@@ -72,7 +82,8 @@ REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or
export const planAgent: Omit<AgentInfo, 'name'> = {
description: '计划模式,设计实现方案(只读探索,可写入计划文件)',
mode: 'primary',
prompt: PLAN_PROMPT,
prompt: PLAN_PROMPT_TEMPLATE,
promptTemplate: true, // 启用动态模板渲染
tools: {
enabled: [
// 文件操作,限制只能写入 plan 目录
@@ -0,0 +1,95 @@
/**
* 提示词模板系统
*
* 提供动态提示词生成能力,支持:
* - ${variable} 变量替换
* - ${obj.prop} 嵌套属性访问
* - ${condition ? "trueValue" : "falseValue"} 条件表达式
*
* @example
* ```typescript
* import { renderTemplate, createPlanContext } from './prompt-template';
*
* const context = createPlanContext({ workdir: '/path/to/project' });
* const prompt = renderTemplate(PLAN_MODE_TEMPLATE.template, context);
* ```
*/
// 类型导出
export type {
PromptContext,
PromptTemplate,
RenderOptions,
ToolNameMapping,
PlanModeContext,
EnvContext,
AgentContext,
} from './types.js';
// 渲染器导出
export {
renderTemplate,
render,
createDefaultContext,
createPlanContext,
checkPlanFileExists,
DEFAULT_TOOL_NAMES,
} from './renderer.js';
// 模板导出
export {
PLAN_MODE_TEMPLATE,
PLAN_MODE_SUBAGENT_TEMPLATE,
PLAN_MODE_REMINDER_TEMPLATE,
} from './templates/index.js';
// 便捷函数
import { renderTemplate, createPlanContext } from './renderer.js';
import { PLAN_MODE_TEMPLATE, PLAN_MODE_SUBAGENT_TEMPLATE } from './templates/index.js';
/**
* 生成 Plan 模式提示词
*
* @param options 配置选项
* @returns 渲染后的提示词
*/
export function generatePlanPrompt(options: {
isSubagent?: boolean;
workdir?: string;
planFilePath?: string;
}): string {
const context = createPlanContext(options);
const template = options.isSubagent ? PLAN_MODE_SUBAGENT_TEMPLATE : PLAN_MODE_TEMPLATE;
return renderTemplate(template.template, context);
}
/**
* 动态解析 Agent 提示词
* 在运行时根据上下文生成最终提示词
*
* @param basePrompt 基础提示词(可能包含模板变量)
* @param options 上下文选项
* @returns 渲染后的提示词
*/
export function resolveAgentPrompt(
basePrompt: string,
options: {
workdir?: string;
planFilePath?: string;
isSubagent?: boolean;
isPlanMode?: boolean;
}
): string {
const context = createPlanContext({
workdir: options.workdir,
planFilePath: options.planFilePath,
isSubagent: options.isSubagent,
});
// 如果不是 Plan 模式,设置 plan.isActive = false
if (!options.isPlanMode) {
context.plan.isActive = false;
}
return renderTemplate(basePrompt, context);
}
@@ -0,0 +1,347 @@
/**
* 提示词模板渲染器
*
* 支持的语法:
* - ${variable} - 简单变量替换
* - ${obj.prop} - 嵌套属性访问
* - ${condition ? "trueValue" : "falseValue"} - 条件表达式
*/
import type { PromptContext, PromptTemplate, RenderOptions, ToolNameMapping } from './types.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* 获取嵌套属性值
*/
function getNestedValue(obj: Record<string, unknown>, pathStr: string): unknown {
const parts = pathStr.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current === 'object') {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return current;
}
/**
* 安全的表达式求值器
* 支持简单的属性访问和三元运算符
*/
function evaluateExpression(
expr: string,
context: PromptContext,
options: RenderOptions = {}
): string {
// 移除首尾空格
expr = expr.trim();
// 处理三元运算符: condition ? trueValue : falseValue
// 需要处理嵌套的情况,使用更精确的匹配
const ternaryMatch = findTernaryOperator(expr);
if (ternaryMatch) {
const { condition, trueValue, falseValue } = ternaryMatch;
const conditionResult = evaluateExpression(condition, context, options);
// 将结果转换为布尔值
const isTruthy =
conditionResult !== '' &&
conditionResult !== 'false' &&
conditionResult !== 'undefined' &&
conditionResult !== 'null' &&
conditionResult !== '0';
return isTruthy
? evaluateExpression(trueValue, context, options)
: evaluateExpression(falseValue, context, options);
}
// 处理字符串字面量: "string" 或 'string' 或 `string`
const stringMatch = expr.match(/^["'`]([\s\S]*)["'`]$/);
if (stringMatch) {
// 递归处理字符串中的变量插值
return renderTemplate(stringMatch[1], context, options);
}
// 处理布尔字面量
if (expr === 'true') return 'true';
if (expr === 'false') return '';
// 处理属性访问: obj.prop.subprop
const value = getNestedValue(context as unknown as Record<string, unknown>, expr);
if (value === undefined) {
if (options.throwOnUndefined) {
throw new Error(`Undefined variable: ${expr}`);
}
if (options.debug) {
console.warn(`[PromptTemplate] Undefined variable: ${expr}`);
}
return options.undefinedValue ?? '';
}
// 布尔值转换
if (typeof value === 'boolean') {
return value ? 'true' : '';
}
return String(value);
}
/**
* 查找三元运算符的各个部分
* 处理嵌套引号的情况
*/
function findTernaryOperator(
expr: string
): { condition: string; trueValue: string; falseValue: string } | null {
let depth = 0;
let inString: string | null = null;
let questionPos = -1;
let colonPos = -1;
for (let i = 0; i < expr.length; i++) {
const char = expr[i];
const prevChar = i > 0 ? expr[i - 1] : '';
// 处理字符串边界
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
if (inString === null) {
inString = char;
} else if (inString === char) {
inString = null;
}
continue;
}
// 如果在字符串中,跳过
if (inString !== null) continue;
// 处理括号深度
if (char === '(' || char === '[' || char === '{') {
depth++;
} else if (char === ')' || char === ']' || char === '}') {
depth--;
}
// 只在顶层寻找 ? 和 :
if (depth === 0) {
if (char === '?' && questionPos === -1) {
questionPos = i;
} else if (char === ':' && questionPos !== -1 && colonPos === -1) {
colonPos = i;
}
}
}
if (questionPos === -1 || colonPos === -1) {
return null;
}
return {
condition: expr.substring(0, questionPos).trim(),
trueValue: expr.substring(questionPos + 1, colonPos).trim(),
falseValue: expr.substring(colonPos + 1).trim(),
};
}
/**
* 渲染提示词模板
*
* @param template 模板字符串
* @param context 上下文变量
* @param options 渲染选项
* @returns 渲染后的字符串
*/
export function renderTemplate(
template: string,
context: PromptContext,
options: RenderOptions = {}
): string {
// 匹配 ${...} 模式,支持嵌套括号和字符串
const result: string[] = [];
let i = 0;
while (i < template.length) {
// 查找 ${
const start = template.indexOf('${', i);
if (start === -1) {
result.push(template.substring(i));
break;
}
// 添加 ${ 之前的文本
result.push(template.substring(i, start));
// 查找匹配的 }
let depth = 1;
let j = start + 2;
let inString: string | null = null;
while (j < template.length && depth > 0) {
const char = template[j];
const prevChar = j > 0 ? template[j - 1] : '';
// 处理字符串边界
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
if (inString === null) {
inString = char;
} else if (inString === char) {
inString = null;
}
} else if (inString === null) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth !== 0) {
// 未找到匹配的 },保留原文
result.push(template.substring(start, j));
} else {
// 提取表达式并求值
const expression = template.substring(start + 2, j - 1);
try {
const value = evaluateExpression(expression, context, options);
result.push(value);
} catch (error) {
if (options.debug) {
console.warn(`[PromptTemplate] Expression evaluation failed: ${expression}`, error);
}
result.push(`\${${expression}}`); // 保留原始文本
}
}
i = j;
}
return result.join('');
}
/**
* 渲染 PromptTemplate 对象
*/
export function render(
promptTemplate: PromptTemplate,
context: PromptContext,
options?: RenderOptions
): string {
return renderTemplate(promptTemplate.template, context, options);
}
/**
* 默认工具名称映射
*/
export const DEFAULT_TOOL_NAMES: ToolNameMapping = {
glob: 'glob',
grep: 'grep_content',
read: 'read_file',
write: 'write_file',
edit: 'edit_file',
bash: 'bash',
askUserQuestion: 'ask_user_question',
exitPlanMode: 'exit_plan_mode',
enterPlanMode: 'enter_plan_mode',
task: 'task',
todoRead: 'todoread',
todoWrite: 'todowrite',
webSearch: 'web_search',
webExtract: 'web_extract',
gitStatus: 'git_status',
gitDiff: 'git_diff',
gitLog: 'git_log',
gitBranch: 'git_branch',
repoMap: 'repo_map',
};
/**
* 创建默认的提示词上下文
*/
export function createDefaultContext(overrides?: Partial<PromptContext>): PromptContext {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const workdir = process.cwd();
return {
tools: {
...DEFAULT_TOOL_NAMES,
...overrides?.tools,
},
plan: {
isActive: false,
planExists: false,
planFilePath: path.join(homeDir, '.ai-terminal-assistant', 'plan', 'plan.md'),
allowedWritePaths: [path.join(homeDir, '.ai-terminal-assistant', 'plan', '*')],
...overrides?.plan,
},
env: {
workdir,
isGitRepo: fs.existsSync(path.join(workdir, '.git')),
platform: process.platform,
date: new Date().toISOString().split('T')[0],
homeDir,
...overrides?.env,
},
agent: {
name: 'build',
mode: 'primary',
isSubagent: false,
...overrides?.agent,
},
custom: overrides?.custom,
};
}
/**
* 检查计划文件是否存在
*/
export function checkPlanFileExists(planFilePath: string): boolean {
try {
fs.accessSync(planFilePath);
return true;
} catch {
return false;
}
}
/**
* 创建 Plan 模式上下文
*/
export function createPlanContext(options: {
planFilePath?: string;
workdir?: string;
isSubagent?: boolean;
}): PromptContext {
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const planPath =
options.planFilePath || path.join(homeDir, '.ai-terminal-assistant', 'plan', 'plan.md');
const workdir = options.workdir || process.cwd();
return createDefaultContext({
plan: {
isActive: true,
planExists: checkPlanFileExists(planPath),
planFilePath: planPath,
allowedWritePaths: [path.join(homeDir, '.ai-terminal-assistant', 'plan', '*')],
},
env: {
workdir,
isGitRepo: fs.existsSync(path.join(workdir, '.git')),
platform: process.platform,
date: new Date().toISOString().split('T')[0],
homeDir,
},
agent: {
name: 'plan',
mode: options.isSubagent ? 'subagent' : 'primary',
isSubagent: options.isSubagent || false,
},
});
}
@@ -0,0 +1,9 @@
/**
* 提示词模板导出
*/
export {
PLAN_MODE_TEMPLATE,
PLAN_MODE_SUBAGENT_TEMPLATE,
PLAN_MODE_REMINDER_TEMPLATE,
} from './plan-mode.js';
@@ -0,0 +1,113 @@
/**
* Plan 模式提示词模板
*
* 使用 ${variable} 语法支持动态变量替换
*/
import type { PromptTemplate } from '../types.js';
/**
* Plan 模式主提示词模板
*/
export const PLAN_MODE_TEMPLATE: PromptTemplate = {
name: 'plan-mode',
description: 'Plan 模式主提示词 - 用于代码探索和实现方案设计',
requiredVariables: ['tools', 'plan'],
template: `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
- Deleting files (no rm or deletion)
- Moving or copying files (no mv or cp)
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
## Plan File Info
\${plan.planExists ? "A plan file already exists at \${plan.planFilePath}. You can read it and make incremental edits using the \${tools.edit} tool." : "No plan file exists yet. You should create your plan at \${plan.planFilePath} using the \${tools.write} tool."}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
## Your Process
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
2. **Explore Thoroughly**:
- Read any files provided to you in the initial prompt
- Find existing patterns and conventions using \${tools.glob}, \${tools.grep}, and \${tools.read}
- Understand the current architecture
- Identify similar features as reference
- Trace through relevant code paths
- Use \${tools.bash} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
- NEVER use \${tools.bash} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
3. **Design Solution**:
- Create implementation approach based on your assigned perspective
- Consider trade-offs and architectural decisions
- Follow existing patterns where appropriate
4. **Detail the Plan**:
- Provide step-by-step implementation strategy
- Identify dependencies and sequencing
- Anticipate potential challenges
## Required Output
End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- path/to/file1.ts - [Brief reason: e.g., "Core logic to modify"]
- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]
- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]
Answer the user's query comprehensively, using the \${tools.askUserQuestion} tool if you need to ask clarifying questions.
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files (except the plan file at \${plan.planFilePath}).`,
};
/**
* Plan 模式子代理提示词模板(简化版)
* 用于作为子代理时的精简提示
*/
export const PLAN_MODE_SUBAGENT_TEMPLATE: PromptTemplate = {
name: 'plan-mode-subagent',
description: 'Plan 模式子代理提示词(简化版)',
requiredVariables: ['tools', 'plan'],
template: `Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
## Plan File Info
\${plan.planExists ? "A plan file already exists at \${plan.planFilePath}. You can read it and make incremental edits using the \${tools.edit} tool if you need to." : "No plan file exists yet. You should create your plan at \${plan.planFilePath} using the \${tools.write} tool if you need to."}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Available Tools
- Use \${tools.glob}, \${tools.grep}, and \${tools.read} for code exploration
- Use \${tools.bash} ONLY for read-only operations
- Use \${tools.askUserQuestion} to ask clarifying questions
Answer the user's query comprehensively, exploring the codebase as needed.
REMEMBER: You can ONLY explore and plan. Do NOT modify any files except the plan file.`,
};
/**
* Plan 模式系统提醒模板(用于注入到消息中)
*/
export const PLAN_MODE_REMINDER_TEMPLATE: PromptTemplate = {
name: 'plan-mode-reminder',
description: 'Plan 模式系统提醒',
template: `<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
## Plan File Info:
\${plan.planExists ? "A plan file already exists at \${plan.planFilePath}. You can read it and make incremental edits." : "No plan file exists yet. You should create your plan at \${plan.planFilePath}."}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
</system-reminder>`,
};
@@ -0,0 +1,119 @@
/**
* 提示词模板系统 - 类型定义
*
* 支持动态变量替换和条件表达式的提示词模板系统
*/
/**
* 工具名称映射
* 用于在模板中引用工具名称,便于重命名
*/
export interface ToolNameMapping {
glob: string;
grep: string;
read: string;
write: string;
edit: string;
bash: string;
askUserQuestion: string;
exitPlanMode: string;
enterPlanMode: string;
task: string;
todoRead: string;
todoWrite: string;
webSearch: string;
webExtract: string;
gitStatus: string;
gitDiff: string;
gitLog: string;
gitBranch: string;
repoMap: string;
}
/**
* Plan 模式上下文
*/
export interface PlanModeContext {
/** 是否处于 Plan 模式 */
isActive: boolean;
/** 计划文件是否存在 */
planExists: boolean;
/** 计划文件路径 */
planFilePath: string;
/** 允许写入的路径列表 */
allowedWritePaths: string[];
}
/**
* 环境信息上下文
*/
export interface EnvContext {
/** 工作目录 */
workdir: string;
/** 是否是 Git 仓库 */
isGitRepo: boolean;
/** 操作系统平台 */
platform: string;
/** 当前日期 */
date: string;
/** 主目录 */
homeDir: string;
}
/**
* Agent 信息上下文
*/
export interface AgentContext {
/** Agent 名称 */
name: string;
/** Agent 模式 */
mode: 'primary' | 'subagent' | 'all' | 'internal';
/** 是否是子代理 */
isSubagent: boolean;
}
/**
* 提示词模板上下文 - 运行时可用的变量
*/
export interface PromptContext {
/** 工具名称映射 */
tools: ToolNameMapping;
/** Plan 模式相关 */
plan: PlanModeContext;
/** 环境信息 */
env: EnvContext;
/** Agent 信息 */
agent: AgentContext;
/** 自定义扩展变量 */
custom?: Record<string, unknown>;
}
/**
* 提示词模板定义
*/
export interface PromptTemplate {
/** 模板名称 */
name: string;
/** 模板描述 */
description?: string;
/** 模板内容(支持变量插值) */
template: string;
/** 所需变量列表(用于验证) */
requiredVariables?: string[];
}
/**
* 模板渲染选项
*/
export interface RenderOptions {
/** 遇到未定义变量时是否抛出错误 */
throwOnUndefined?: boolean;
/** 未定义变量的默认值 */
undefinedValue?: string;
/** 是否启用调试日志 */
debug?: boolean;
}
+6
View File
@@ -106,6 +106,12 @@ export interface AgentInfo {
mode: AgentMode;
/** 自定义 System Prompt */
prompt?: string;
/**
* 是否将 prompt 作为模板处理
* 如果为 trueprompt 中的 ${variable} 语法会在运行时动态渲染
* 支持:${variable}, ${obj.prop}, ${condition ? "true" : "false"}
*/
promptTemplate?: boolean;
/** 模型配置 */
model?: AgentModelConfig;
/** 工具配置 */
@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
renderTemplate,
createDefaultContext,
createPlanContext,
DEFAULT_TOOL_NAMES,
generatePlanPrompt,
resolveAgentPrompt,
} from '../../../src/agent/prompt-template/index.js';
import type { PromptContext } from '../../../src/agent/prompt-template/types.js';
describe('Prompt Template System', () => {
let context: PromptContext;
beforeEach(() => {
context = createDefaultContext({
plan: {
isActive: true,
planExists: false,
planFilePath: '/tmp/test-plan.md',
allowedWritePaths: ['/tmp/*'],
},
env: {
workdir: '/test/project',
isGitRepo: true,
platform: 'darwin',
date: '2024-01-01',
homeDir: '/home/test',
},
agent: {
name: 'test-agent',
mode: 'primary',
isSubagent: false,
},
});
});
describe('renderTemplate', () => {
it('should replace simple variables', () => {
const template = 'Use ${tools.glob} to find files';
const result = renderTemplate(template, context);
expect(result).toBe('Use glob to find files');
});
it('should replace nested properties', () => {
const template = 'Plan file: ${plan.planFilePath}';
const result = renderTemplate(template, context);
expect(result).toBe('Plan file: /tmp/test-plan.md');
});
it('should handle multiple variables in one template', () => {
const template = 'Use ${tools.glob}, ${tools.grep}, and ${tools.read}';
const result = renderTemplate(template, context);
expect(result).toBe('Use glob, grep_content, and read_file');
});
it('should handle ternary expressions - false condition', () => {
const template = '${plan.planExists ? "exists" : "not exists"}';
const result = renderTemplate(template, context);
expect(result).toBe('not exists');
});
it('should handle ternary expressions - true condition', () => {
context.plan.planExists = true;
const template = '${plan.planExists ? "exists" : "not exists"}';
const result = renderTemplate(template, context);
expect(result).toBe('exists');
});
it('should handle nested variables in ternary expressions', () => {
const template =
'${plan.planExists ? "File at ${plan.planFilePath}" : "Create at ${plan.planFilePath}"}';
const result = renderTemplate(template, context);
expect(result).toBe('Create at /tmp/test-plan.md');
});
it('should handle boolean values', () => {
const template = 'Is git repo: ${env.isGitRepo}';
const result = renderTemplate(template, context);
expect(result).toBe('Is git repo: true');
});
it('should handle undefined variables gracefully', () => {
const template = 'Value: ${custom.undefined}';
const result = renderTemplate(template, context);
expect(result).toBe('Value: ');
});
it('should preserve text without variables', () => {
const template = 'No variables here';
const result = renderTemplate(template, context);
expect(result).toBe('No variables here');
});
it('should handle complex nested ternary with tool references', () => {
context.plan.planExists = true;
const template =
'${plan.planExists ? "Edit with ${tools.edit}" : "Write with ${tools.write}"}';
const result = renderTemplate(template, context);
expect(result).toBe('Edit with edit_file');
});
});
describe('createDefaultContext', () => {
it('should create context with default tool names', () => {
const ctx = createDefaultContext();
expect(ctx.tools.glob).toBe('glob');
expect(ctx.tools.grep).toBe('grep_content');
expect(ctx.tools.read).toBe('read_file');
expect(ctx.tools.write).toBe('write_file');
expect(ctx.tools.bash).toBe('bash');
});
it('should allow overriding tool names', () => {
const ctx = createDefaultContext({
tools: {
...DEFAULT_TOOL_NAMES,
glob: 'custom_glob',
},
});
expect(ctx.tools.glob).toBe('custom_glob');
expect(ctx.tools.grep).toBe('grep_content'); // Others unchanged
});
it('should set default plan values', () => {
const ctx = createDefaultContext();
expect(ctx.plan.isActive).toBe(false);
expect(ctx.plan.planExists).toBe(false);
});
});
describe('createPlanContext', () => {
it('should create context with plan mode active', () => {
const ctx = createPlanContext({});
expect(ctx.plan.isActive).toBe(true);
expect(ctx.agent.name).toBe('plan');
});
it('should set isSubagent correctly', () => {
const ctx = createPlanContext({ isSubagent: true });
expect(ctx.agent.isSubagent).toBe(true);
expect(ctx.agent.mode).toBe('subagent');
});
it('should use custom workdir', () => {
const ctx = createPlanContext({ workdir: '/custom/path' });
expect(ctx.env.workdir).toBe('/custom/path');
});
it('should use custom plan file path', () => {
const ctx = createPlanContext({ planFilePath: '/custom/plan.md' });
expect(ctx.plan.planFilePath).toBe('/custom/plan.md');
});
});
describe('generatePlanPrompt', () => {
it('should generate prompt with tool names resolved', () => {
const prompt = generatePlanPrompt({});
expect(prompt).toContain('glob');
expect(prompt).toContain('grep_content');
expect(prompt).toContain('read_file');
});
it('should include plan file path', () => {
const prompt = generatePlanPrompt({ planFilePath: '/test/plan.md' });
expect(prompt).toContain('/test/plan.md');
});
it('should generate shorter prompt for subagent', () => {
const mainPrompt = generatePlanPrompt({ isSubagent: false });
const subPrompt = generatePlanPrompt({ isSubagent: true });
expect(subPrompt.length).toBeLessThan(mainPrompt.length);
});
});
describe('resolveAgentPrompt', () => {
it('should resolve template variables in prompt', () => {
const basePrompt = 'Use ${tools.glob} to search';
const resolved = resolveAgentPrompt(basePrompt, {});
expect(resolved).toBe('Use glob to search');
});
it('should handle isPlanMode option', () => {
const basePrompt = '${plan.isActive ? "Plan mode" : "Normal mode"}';
const planResolved = resolveAgentPrompt(basePrompt, { isPlanMode: true });
expect(planResolved).toBe('Plan mode');
const normalResolved = resolveAgentPrompt(basePrompt, { isPlanMode: false });
expect(normalResolved).toBe('Normal mode');
});
});
describe('Edge Cases', () => {
it('should handle empty template', () => {
const result = renderTemplate('', context);
expect(result).toBe('');
});
it('should handle template with only variable', () => {
const result = renderTemplate('${tools.bash}', context);
expect(result).toBe('bash');
});
it('should handle unclosed variable syntax', () => {
const result = renderTemplate('${tools.bash', context);
expect(result).toBe('${tools.bash');
});
it('should handle escaped-like patterns (no real escaping)', () => {
// Note: This system doesn't support escaping, so $$ or similar won't work
const result = renderTemplate('Price: $100', context);
expect(result).toBe('Price: $100');
});
it('should handle deeply nested properties', () => {
const result = renderTemplate('${agent.mode}', context);
expect(result).toBe('primary');
});
});
});