diff --git a/packages/core/src/agent/executor.ts b/packages/core/src/agent/executor.ts index d02853a..f890b21 100644 --- a/packages/core/src/agent/executor.ts +++ b/packages/core/src/agent/executor.ts @@ -17,7 +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'; +import { renderPromptTemplate, createPlanContext } from '../template/index.js'; /** * Agent 执行器 @@ -298,7 +298,7 @@ export class AgentExecutor { workdir: process.cwd(), isSubagent: this.agentInfo.mode === 'subagent', }); - return renderTemplate(this.agentInfo.prompt, context); + return renderPromptTemplate(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 fd2a729..0711a0d 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -61,15 +61,18 @@ export { // System Prompt export { SystemPrompt } from './system-prompt.js'; -// Prompt Template +// Prompt Template (re-export from ../template/) export { renderTemplate, render, + renderPromptTemplate, + renderPrompt, createDefaultContext, createPlanContext, + createToolDescriptionContext, checkPlanFileExists, DEFAULT_TOOL_NAMES, -} from './prompt-template/index.js'; +} from '../template/index.js'; export type { PromptContext, @@ -79,4 +82,4 @@ export type { PlanModeContext, EnvContext, AgentContext, -} from './prompt-template/index.js'; +} from '../template/index.js'; diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index 248aca1..0508f01 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -21,8 +21,10 @@ import { agentRegistry, AgentExecutor, checkBashPermission, - renderTemplate, + renderPromptTemplate, createPlanContext, + createToolDescriptionContext, + type PromptContext, } from '../agent/index.js'; import { loadVisionConfig } from '../utils/config.js'; import { getProviderRegistry, resolveApiKey } from '../provider/index.js'; @@ -104,6 +106,9 @@ export class Agent { // 原始 system prompt(用于切换回 default 时恢复) private originalSystemPrompt: string; + // 工具描述渲染上下文缓存 + private toolDescriptionContext: PromptContext | null = null; + constructor(config: AgentConfig, compressionConfig?: Partial) { this.config = config; this.originalSystemPrompt = config.systemPrompt; @@ -244,6 +249,33 @@ export class Agent { return filteredTools; } + /** + * 获取或创建工具描述渲染上下文 + */ + private getToolDescriptionContext(): PromptContext { + if (!this.toolDescriptionContext) { + this.toolDescriptionContext = createToolDescriptionContext({ + agent: { + name: this.currentAgentMode?.name ?? 'default', + mode: this.currentAgentMode?.mode ?? 'primary', + isSubagent: this.currentAgentMode?.mode === 'subagent', + }, + }); + } + return this.toolDescriptionContext; + } + + /** + * 渲染工具描述中的模板变量 + */ + private renderToolDescription(description: string): string { + const context = this.getToolDescriptionContext(); + return renderPromptTemplate(description, context, { + throwOnUndefined: false, + undefinedValue: '', + }); + } + /** * 将工具转换为 Vercel AI SDK 的工具格式 */ @@ -254,9 +286,10 @@ export class Agent { for (const tool of availableTools) { const schema = buildZodSchema(tool.parameters); + const renderedDescription = this.renderToolDescription(tool.description); vercelTools[tool.name] = { - description: tool.description, + description: renderedDescription, inputSchema: schema, execute: async (params) => { const args = params as Record; @@ -755,6 +788,9 @@ export class Agent { * @param agent AgentInfo 对象或模式字符串 ('build'/'plan') */ setAgentMode(agent: AgentInfo | 'build' | 'plan' | null): void { + // 清除工具描述上下文缓存,下次使用时重新创建 + this.toolDescriptionContext = null; + // 如果是字符串模式,从 registry 获取预设 if (typeof agent === 'string') { const presetAgent = agentRegistry.get(agent); @@ -809,7 +845,7 @@ export class Agent { workdir: process.cwd(), isSubagent: agent.mode === 'subagent', }); - return renderTemplate(agent.prompt, context); + return renderPromptTemplate(agent.prompt, context); } return agent.prompt; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db511da..7dbd46e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -259,3 +259,31 @@ export { getCacheDir, getLogsDir, } from './constants/index.js'; + +// Template - 通用模板引擎 +export { + // 通用模板渲染 + renderTemplate, + render, + // Agent 特定函数 + renderPromptTemplate, + renderPrompt, + createDefaultContext, + createPlanContext, + createToolDescriptionContext, + checkPlanFileExists, + DEFAULT_TOOL_NAMES, +} from './template/index.js'; + +export type { + TemplateContext, + Template, + RenderOptions, + // Agent 特定类型 + PromptContext, + PromptTemplate, + ToolNameMapping, + PlanModeContext, + EnvContext, + AgentContext, +} from './template/index.js'; diff --git a/packages/core/src/agent/prompt-template/index.ts b/packages/core/src/template/index.ts similarity index 52% rename from packages/core/src/agent/prompt-template/index.ts rename to packages/core/src/template/index.ts index c692a10..a374d4b 100644 --- a/packages/core/src/agent/prompt-template/index.ts +++ b/packages/core/src/template/index.ts @@ -1,25 +1,29 @@ /** - * 提示词模板系统 + * 通用模板引擎 * - * 提供动态提示词生成能力,支持: + * 提供动态模板渲染能力,支持: * - ${variable} 变量替换 * - ${obj.prop} 嵌套属性访问 * - ${condition ? "trueValue" : "falseValue"} 条件表达式 * * @example * ```typescript - * import { renderTemplate, createPlanContext } from './prompt-template'; + * import { renderTemplate } from '@ai-assistant/core/template'; * - * const context = createPlanContext({ workdir: '/path/to/project' }); - * const prompt = renderTemplate(template, context); + * const context = { name: 'World', count: 42 }; + * const result = renderTemplate('Hello ${name}, count is ${count}', context); + * // => "Hello World, count is 42" * ``` */ // 类型导出 export type { + TemplateContext, + Template, + RenderOptions, + // Agent 特定类型 PromptContext, PromptTemplate, - RenderOptions, ToolNameMapping, PlanModeContext, EnvContext, @@ -28,10 +32,15 @@ export type { // 渲染器导出 export { + // 通用模板渲染 renderTemplate, render, + // Agent 特定函数 + renderPromptTemplate, + renderPrompt, createDefaultContext, createPlanContext, + createToolDescriptionContext, checkPlanFileExists, DEFAULT_TOOL_NAMES, } from './renderer.js'; diff --git a/packages/core/src/agent/prompt-template/renderer.ts b/packages/core/src/template/renderer.ts similarity index 73% rename from packages/core/src/agent/prompt-template/renderer.ts rename to packages/core/src/template/renderer.ts index 96dd24c..1dfc151 100644 --- a/packages/core/src/agent/prompt-template/renderer.ts +++ b/packages/core/src/template/renderer.ts @@ -1,5 +1,5 @@ /** - * 提示词模板渲染器 + * 通用模板渲染器 * * 支持的语法: * - ${variable} - 简单变量替换 @@ -7,7 +7,14 @@ * - ${condition ? "trueValue" : "falseValue"} - 条件表达式 */ -import type { PromptContext, PromptTemplate, RenderOptions, ToolNameMapping } from './types.js'; +import type { + TemplateContext, + Template, + RenderOptions, + PromptContext, + PromptTemplate, + ToolNameMapping, +} from './types.js'; import * as fs from 'fs'; import * as path from 'path'; @@ -32,68 +39,6 @@ function getNestedValue(obj: Record, pathStr: string): unknown 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); -} - /** * 查找三元运算符的各个部分 * 处理嵌套引号的情况 @@ -152,7 +97,74 @@ function findTernaryOperator( } /** - * 渲染提示词模板 + * 安全的表达式求值器 + * 支持简单的属性访问和三元运算符 + */ +function evaluateExpression( + expr: string, + context: TemplateContext, + 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 + let value = getNestedValue(context, expr); + + // 如果在主上下文中找不到,且是单层变量名,尝试直接访问 + // 这支持在 context 根级别定义的变量(如 GREP_TOOL_NAME) + if (value === undefined && !expr.includes('.')) { + value = context[expr]; + } + + if (value === undefined) { + if (options.throwOnUndefined) { + throw new Error(`Undefined variable: ${expr}`); + } + if (options.debug) { + console.warn(`[Template] Undefined variable: ${expr}`); + } + return options.undefinedValue ?? ''; + } + + // 布尔值转换 + if (typeof value === 'boolean') { + return value ? 'true' : ''; + } + + return String(value); +} + +/** + * 渲染模板 * * @param template 模板字符串 * @param context 上下文变量 @@ -161,7 +173,7 @@ function findTernaryOperator( */ export function renderTemplate( template: string, - context: PromptContext, + context: TemplateContext, options: RenderOptions = {} ): string { // 匹配 ${...} 模式,支持嵌套括号和字符串 @@ -214,7 +226,7 @@ export function renderTemplate( result.push(value); } catch (error) { if (options.debug) { - console.warn(`[PromptTemplate] Expression evaluation failed: ${expression}`, error); + console.warn(`[Template] Expression evaluation failed: ${expression}`, error); } result.push(`\${${expression}}`); // 保留原始文本 } @@ -227,14 +239,47 @@ export function renderTemplate( } /** - * 渲染 PromptTemplate 对象 + * 渲染 Template 对象 */ -export function render( +export function render(templateObj: Template, context: TemplateContext, options?: RenderOptions): string { + return renderTemplate(templateObj.template, context, options); +} + +// ============================================ +// Agent 特定函数(Prompt Template) +// ============================================ + +/** + * 渲染提示词模板(Agent 特定版本) + * 将 custom 中的变量合并到顶层,以支持直接引用(如 ${GREP_TOOL_NAME}) + */ +export function renderPromptTemplate( + template: string, + context: PromptContext, + options: RenderOptions = {} +): string { + // 将 custom 中的变量合并到顶层上下文,这样 ${GREP_TOOL_NAME} 可以直接访问 + const flattenedContext: Record = { + ...context, + ...context.custom, + }; + return renderTemplate(template, flattenedContext, options); +} + +/** + * 渲染提示词模板对象(Agent 特定版本) + */ +export function renderPrompt( promptTemplate: PromptTemplate, context: PromptContext, options?: RenderOptions ): string { - return renderTemplate(promptTemplate.template, context, options); + // 将 custom 中的变量合并到顶层上下文 + const flattenedContext: Record = { + ...context, + ...context.custom, + }; + return render({ ...promptTemplate }, flattenedContext, options); } /** @@ -345,3 +390,39 @@ export function createPlanContext(options: { }, }); } + +/** + * 将驼峰命名转换为大写下划线命名 + * 例如: askUserQuestion -> ASK_USER_QUESTION + */ +function camelToUpperSnake(str: string): string { + return str.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase(); +} + +/** + * 创建工具描述渲染上下文 + * + * 除了标准的 PromptContext 变量外,还会在 custom 中添加大写形式的工具名变量, + * 以兼容 Claude Code 官方提示词格式(如 ${GREP_TOOL_NAME})。 + * + * 支持的变量格式: + * - ${tools.grep} - 小写驼峰形式(项目内部风格) + * - ${GREP_TOOL_NAME} - 大写下划线形式(Claude Code 官方风格) + */ +export function createToolDescriptionContext(overrides?: Partial): PromptContext { + const base = createDefaultContext(overrides); + + // 将 tools 映射转为大写变量放入 custom + const toolVars: Record = {}; + for (const [key, value] of Object.entries(base.tools)) { + // glob -> GLOB_TOOL_NAME + // askUserQuestion -> ASK_USER_QUESTION_TOOL_NAME + const upperKey = camelToUpperSnake(key) + '_TOOL_NAME'; + toolVars[upperKey] = value; + } + + return { + ...base, + custom: { ...base.custom, ...toolVars }, + }; +} diff --git a/packages/core/src/agent/prompt-template/types.ts b/packages/core/src/template/types.ts similarity index 79% rename from packages/core/src/agent/prompt-template/types.ts rename to packages/core/src/template/types.ts index 87f7486..57aa39e 100644 --- a/packages/core/src/agent/prompt-template/types.ts +++ b/packages/core/src/template/types.ts @@ -1,9 +1,46 @@ /** - * 提示词模板系统 - 类型定义 + * 通用模板引擎 - 类型定义 * - * 支持动态变量替换和条件表达式的提示词模板系统 + * 支持动态变量替换和条件表达式的模板系统 */ +/** + * 模板上下文 - 运行时可用的变量 + * + * 支持任意嵌套的键值对结构 + */ +export type TemplateContext = Record; + +/** + * 模板定义 + */ +export interface Template { + /** 模板名称 */ + name: string; + /** 模板描述 */ + description?: string; + /** 模板内容(支持变量插值) */ + template: string; + /** 所需变量列表(用于验证) */ + requiredVariables?: string[]; +} + +/** + * 模板渲染选项 + */ +export interface RenderOptions { + /** 遇到未定义变量时是否抛出错误 */ + throwOnUndefined?: boolean; + /** 未定义变量的默认值 */ + undefinedValue?: string; + /** 是否启用调试日志 */ + debug?: boolean; +} + +// ============================================ +// Agent 特定类型(Prompt Template) +// ============================================ + /** * 工具名称映射 * 用于在模板中引用工具名称,便于重命名 @@ -93,27 +130,6 @@ export interface PromptContext { } /** - * 提示词模板定义 + * 提示词模板定义(继承自通用 Template) */ -export interface PromptTemplate { - /** 模板名称 */ - name: string; - /** 模板描述 */ - description?: string; - /** 模板内容(支持变量插值) */ - template: string; - /** 所需变量列表(用于验证) */ - requiredVariables?: string[]; -} - -/** - * 模板渲染选项 - */ -export interface RenderOptions { - /** 遇到未定义变量时是否抛出错误 */ - throwOnUndefined?: boolean; - /** 未定义变量的默认值 */ - undefinedValue?: string; - /** 是否启用调试日志 */ - debug?: boolean; -} +export interface PromptTemplate extends Template {} diff --git a/packages/core/tests/unit/agent/prompt-template.test.ts b/packages/core/tests/unit/agent/prompt-template.test.ts index 8f64fdf..79631a0 100644 --- a/packages/core/tests/unit/agent/prompt-template.test.ts +++ b/packages/core/tests/unit/agent/prompt-template.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { - renderTemplate, + renderPromptTemplate, createDefaultContext, createPlanContext, + createToolDescriptionContext, DEFAULT_TOOL_NAMES, -} from '../../../src/agent/prompt-template/index.js'; -import type { PromptContext } from '../../../src/agent/prompt-template/types.js'; +} from '../../../src/template/index.js'; +import type { PromptContext } from '../../../src/template/types.js'; + +// Alias for backward compatibility in tests +const renderTemplate = renderPromptTemplate; describe('Prompt Template System', () => { let context: PromptContext; @@ -178,4 +182,74 @@ describe('Prompt Template System', () => { expect(result).toBe('primary'); }); }); + + describe('createToolDescriptionContext', () => { + it('should create context with uppercase tool name variables', () => { + const ctx = createToolDescriptionContext(); + // Check that uppercase tool names are available in custom + expect(ctx.custom).toBeDefined(); + expect(ctx.custom!['GLOB_TOOL_NAME']).toBe('glob'); + expect(ctx.custom!['GREP_TOOL_NAME']).toBe('grep'); + expect(ctx.custom!['BASH_TOOL_NAME']).toBe('bash'); + expect(ctx.custom!['READ_TOOL_NAME']).toBe('read_file'); + expect(ctx.custom!['WRITE_TOOL_NAME']).toBe('write_file'); + expect(ctx.custom!['EDIT_TOOL_NAME']).toBe('edit_file'); + expect(ctx.custom!['TASK_TOOL_NAME']).toBe('task'); + }); + + it('should convert camelCase to UPPER_SNAKE_CASE correctly', () => { + const ctx = createToolDescriptionContext(); + // askUserQuestion -> ASK_USER_QUESTION_TOOL_NAME + expect(ctx.custom!['ASK_USER_QUESTION_TOOL_NAME']).toBe('ask_user_question'); + // exitPlanMode -> EXIT_PLAN_MODE_TOOL_NAME + expect(ctx.custom!['EXIT_PLAN_MODE_TOOL_NAME']).toBe('exit_plan_mode'); + // webSearch -> WEB_SEARCH_TOOL_NAME + expect(ctx.custom!['WEB_SEARCH_TOOL_NAME']).toBe('web_search'); + }); + + it('should support rendering with uppercase tool name variables', () => { + const ctx = createToolDescriptionContext(); + const template = 'Use ${GREP_TOOL_NAME} for search. Never use ${BASH_TOOL_NAME} grep.'; + const result = renderTemplate(template, ctx); + expect(result).toBe('Use grep for search. Never use bash grep.'); + }); + + it('should support both lowercase and uppercase variable styles', () => { + const ctx = createToolDescriptionContext(); + const template = 'Tools: ${tools.grep} or ${GREP_TOOL_NAME}'; + const result = renderTemplate(template, ctx); + expect(result).toBe('Tools: grep or grep'); + }); + + it('should preserve overrides', () => { + const ctx = createToolDescriptionContext({ + agent: { + name: 'custom-agent', + mode: 'subagent', + isSubagent: true, + }, + }); + expect(ctx.agent.name).toBe('custom-agent'); + expect(ctx.agent.mode).toBe('subagent'); + expect(ctx.agent.isSubagent).toBe(true); + // Uppercase tool names should still be available + expect(ctx.custom!['GREP_TOOL_NAME']).toBe('grep'); + }); + + it('should render Claude Code style tool description template', () => { + const ctx = createToolDescriptionContext(); + // Example from Claude Code official prompt + const template = `A powerful search tool built on ripgrep + +Usage: +- ALWAYS use \${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a \${BASH_TOOL_NAME} command. +- Use \${TASK_TOOL_NAME} tool for open-ended searches requiring multiple rounds`; + + const result = renderTemplate(template, ctx); + + expect(result).toContain('ALWAYS use grep for search tasks'); + expect(result).toContain('NEVER invoke `grep` or `rg` as a bash command'); + expect(result).toContain('Use task tool for open-ended searches'); + }); + }); });