Files
claude-code-mirror/claude-code源码-中文注释/src/tools/SkillTool/prompt.ts
2026-04-03 13:01:19 +08:00

242 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memoize } from 'lodash-es'
import type { Command } from 'src/commands.js'
import {
getCommandName,
getSkillToolCommands,
getSlashCommandToolSkills,
} from 'src/commands.js'
import { COMMAND_NAME_TAG } from '../../constants/xml.js'
import { stringWidth } from '../../ink/stringWidth.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { count } from '../../utils/array.js'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { truncate } from '../../utils/format.js'
import { logError } from '../../utils/log.js'
// 技能列表获取上下文窗口的 1% 字符数
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // 后备值200k × 4 的 1%
// 每个条目的硬性上限。列表仅用于发现——技能工具在调用时加载
// 完整内容,因此冗长的 whenToUse 字符串会浪费 turn-1 cache_creation
// tokens 而不会提高匹配率。适用于所有条目,包括捆绑的,
// 因为上限足够宽松,可以保留核心用例。
export const MAX_LISTING_DESC_CHARS = 250
export function getCharBudget(contextWindowTokens?: number): number {
if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
}
if (contextWindowTokens) {
return Math.floor(
contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT,
)
}
return DEFAULT_CHAR_BUDGET
}
function getCommandDescription(cmd: Command): string {
const desc = cmd.whenToUse
? `${cmd.description} - ${cmd.whenToUse}`
: cmd.description
return desc.length > MAX_LISTING_DESC_CHARS
? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026'
: desc
}
function formatCommandDescription(cmd: Command): string {
// 调试:如果 userFacingName 与插件技能的 cmd.name 不同则记录
const displayName = getCommandName(cmd)
if (
cmd.name !== displayName &&
cmd.type === 'prompt' &&
cmd.source === 'plugin'
) {
logForDebugging(
`Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`,
)
}
return `- ${cmd.name}: ${getCommandDescription(cmd)}`
}
const MIN_DESC_LENGTH = 20
export function formatCommandsWithinBudget(
commands: Command[],
contextWindowTokens?: number,
): string {
if (commands.length === 0) return ''
const budget = getCharBudget(contextWindowTokens)
// 首先尝试完整描述
const fullEntries = commands.map(cmd => ({
cmd,
full: formatCommandDescription(cmd),
}))
// join('\n') 为 N 个条目生成 N-1 个换行符
const fullTotal =
fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) +
(fullEntries.length - 1)
if (fullTotal <= budget) {
return fullEntries.map(e => e.full).join('\n')
}
// 分为捆绑的(永不截断)和其他
const bundledIndices = new Set<number>()
const restCommands: Command[] = []
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
if (cmd.type === 'prompt' && cmd.source === 'bundled') {
bundledIndices.add(i)
} else {
restCommands.push(cmd)
}
}
// 计算捆绑技能使用的空间(完整描述,始终保留)
const bundledChars = fullEntries.reduce(
(sum, e, i) =>
bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,
0,
)
const remainingBudget = budget - bundledChars
// 计算非捆绑命令的最大描述长度
if (restCommands.length === 0) {
return fullEntries.map(e => e.full).join('\n')
}
const restNameOverhead =
restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) +
(restCommands.length - 1)
const availableForDescs = remainingBudget - restNameOverhead
const maxDescLen = Math.floor(availableForDescs / restCommands.length)
if (maxDescLen < MIN_DESC_LENGTH) {
// 极端情况:非捆绑显示名称,捆绑保留描述
if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_skill_descriptions_truncated', {
skill_count: commands.length,
budget,
full_total: fullTotal,
truncation_mode:
'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
max_desc_length: maxDescLen,
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) =>
bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
)
.join('\n')
}
// 截断非捆绑描述以适应预算
const truncatedCount = count(
restCommands,
cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen,
)
if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_skill_descriptions_truncated', {
skill_count: commands.length,
budget,
full_total: fullTotal,
truncation_mode:
'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
max_desc_length: maxDescLen,
truncated_count: truncatedCount,
// 此提示中包含的捆绑技能数量(排除 disableModelInvocation 的技能)
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) => {
// 捆绑技能始终获得完整描述
if (bundledIndices.has(i)) return fullEntries[i]!.full
const description = getCommandDescription(cmd)
return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
})
.join('\n')
}
export const getPrompt = memoize(async (_cwd: string): Promise<string> => {
return `Execute a skill within the main conversation
When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.
When users reference a "slash command" or "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke it.
How to invoke:
- Use this tool with the skill name and optional arguments
- Examples:
- \`skill: "pdf"\` - invoke the pdf skill
- \`skill: "commit", args: "-m 'Fix bug'\` - invoke with arguments
- \`skill: "review-pr", args: "123"\` - invoke with arguments
- \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name
Important:
- Available skills are listed in system-reminder messages in the conversation
- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
- NEVER mention a skill without actually calling this tool
- Do not invoke a skill that is already running
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
- If you see a <${COMMAND_NAME_TAG}> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again
`
})
export async function getSkillToolInfo(cwd: string): Promise<{
totalCommands: number
includedCommands: number
}> {
const agentCommands = await getSkillToolCommands(cwd)
return {
totalCommands: agentCommands.length,
includedCommands: agentCommands.length,
}
}
// 返回包含在 SkillTool 提示中的命令。
// 所有命令始终被包含(描述可能会被截断以适应预算)。
// 由 analyzeContext 用于计算技能 tokens。
export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> {
return getSkillToolCommands(cwd)
}
export function clearPromptCache(): void {
getPrompt.cache?.clear?.()
}
export async function getSkillInfo(cwd: string): Promise<{
totalSkills: number
includedSkills: number
}> {
try {
const skills = await getSlashCommandToolSkills(cwd)
return {
totalSkills: skills.length,
includedSkills: skills.length,
}
} catch (error) {
logError(toError(error))
// 返回零而不是抛出异常——让调用者决定如何处理
return {
totalSkills: 0,
includedSkills: 0,
}
}
}