From 58f1bc871871a63d4c9b66652d691f1c944501f0 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 16 Dec 2025 14:15:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=AE=9E=E7=8E=B0=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=A8=A1=E6=9D=BF=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 prompt-template 模块,支持运行时变量替换 - 支持 ${variable}、${obj.prop}、${cond ? "a" : "b"} 语法 - AgentInfo 新增 promptTemplate 字段标记动态模板 - Plan Agent 提示词改用模板语法,支持动态工具名和计划文件路径 - AgentExecutor.buildSystemPrompt 集成模板渲染 - 新增 27 个单元测试验证模板功能 --- packages/core/src/agent/executor.ts | 12 +- packages/core/src/agent/index.ts | 25 ++ packages/core/src/agent/presets/plan.ts | 35 +- .../core/src/agent/prompt-template/index.ts | 95 +++++ .../src/agent/prompt-template/renderer.ts | 347 ++++++++++++++++++ .../agent/prompt-template/templates/index.ts | 9 + .../prompt-template/templates/plan-mode.ts | 113 ++++++ .../core/src/agent/prompt-template/types.ts | 119 ++++++ packages/core/src/agent/types.ts | 6 + .../tests/unit/agent/prompt-template.test.ts | 220 +++++++++++ 10 files changed, 968 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/agent/prompt-template/index.ts create mode 100644 packages/core/src/agent/prompt-template/renderer.ts create mode 100644 packages/core/src/agent/prompt-template/templates/index.ts create mode 100644 packages/core/src/agent/prompt-template/templates/plan-mode.ts create mode 100644 packages/core/src/agent/prompt-template/types.ts create mode 100644 packages/core/tests/unit/agent/prompt-template.test.ts diff --git a/packages/core/src/agent/executor.ts b/packages/core/src/agent/executor.ts index bba962b..d02853a 100644 --- a/packages/core/src/agent/executor.ts +++ b/packages/core/src/agent/executor.ts @@ -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; } diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 202938c..8b46302 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -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'; diff --git a/packages/core/src/agent/presets/plan.ts b/packages/core/src/agent/presets/plan.ts index 5910832..a579977 100644 --- a/packages/core/src/agent/presets/plan.ts +++ b/packages/core/src/agent/presets/plan.ts @@ -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 = { description: '计划模式,设计实现方案(只读探索,可写入计划文件)', mode: 'primary', - prompt: PLAN_PROMPT, + prompt: PLAN_PROMPT_TEMPLATE, + promptTemplate: true, // 启用动态模板渲染 tools: { enabled: [ // 文件操作,限制只能写入 plan 目录 diff --git a/packages/core/src/agent/prompt-template/index.ts b/packages/core/src/agent/prompt-template/index.ts new file mode 100644 index 0000000..d68d9f8 --- /dev/null +++ b/packages/core/src/agent/prompt-template/index.ts @@ -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); +} diff --git a/packages/core/src/agent/prompt-template/renderer.ts b/packages/core/src/agent/prompt-template/renderer.ts new file mode 100644 index 0000000..a7ec91a --- /dev/null +++ b/packages/core/src/agent/prompt-template/renderer.ts @@ -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, 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)[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, 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 { + 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, + }, + }); +} diff --git a/packages/core/src/agent/prompt-template/templates/index.ts b/packages/core/src/agent/prompt-template/templates/index.ts new file mode 100644 index 0000000..7f3f812 --- /dev/null +++ b/packages/core/src/agent/prompt-template/templates/index.ts @@ -0,0 +1,9 @@ +/** + * 提示词模板导出 + */ + +export { + PLAN_MODE_TEMPLATE, + PLAN_MODE_SUBAGENT_TEMPLATE, + PLAN_MODE_REMINDER_TEMPLATE, +} from './plan-mode.js'; diff --git a/packages/core/src/agent/prompt-template/templates/plan-mode.ts b/packages/core/src/agent/prompt-template/templates/plan-mode.ts new file mode 100644 index 0000000..a34fbe9 --- /dev/null +++ b/packages/core/src/agent/prompt-template/templates/plan-mode.ts @@ -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: ` +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. +`, +}; diff --git a/packages/core/src/agent/prompt-template/types.ts b/packages/core/src/agent/prompt-template/types.ts new file mode 100644 index 0000000..87f7486 --- /dev/null +++ b/packages/core/src/agent/prompt-template/types.ts @@ -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; +} + +/** + * 提示词模板定义 + */ +export interface PromptTemplate { + /** 模板名称 */ + name: string; + /** 模板描述 */ + description?: string; + /** 模板内容(支持变量插值) */ + template: string; + /** 所需变量列表(用于验证) */ + requiredVariables?: string[]; +} + +/** + * 模板渲染选项 + */ +export interface RenderOptions { + /** 遇到未定义变量时是否抛出错误 */ + throwOnUndefined?: boolean; + /** 未定义变量的默认值 */ + undefinedValue?: string; + /** 是否启用调试日志 */ + debug?: boolean; +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 85a48a2..aece14d 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -106,6 +106,12 @@ export interface AgentInfo { mode: AgentMode; /** 自定义 System Prompt */ prompt?: string; + /** + * 是否将 prompt 作为模板处理 + * 如果为 true,prompt 中的 ${variable} 语法会在运行时动态渲染 + * 支持:${variable}, ${obj.prop}, ${condition ? "true" : "false"} + */ + promptTemplate?: boolean; /** 模型配置 */ model?: AgentModelConfig; /** 工具配置 */ diff --git a/packages/core/tests/unit/agent/prompt-template.test.ts b/packages/core/tests/unit/agent/prompt-template.test.ts new file mode 100644 index 0000000..33faed9 --- /dev/null +++ b/packages/core/tests/unit/agent/prompt-template.test.ts @@ -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'); + }); + }); +});