File size: 8,221 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | 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'
// Skill listing gets 1% of the context window (in characters)
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
// Per-entry hard cap. The listing is for discovery only — the Skill tool loads
// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
// tokens without improving match rate. Applies to all entries, including bundled,
// since the cap is generous enough to preserve the core use case.
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 {
// Debug: log if userFacingName differs from cmd.name for plugin skills
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)
// Try full descriptions first
const fullEntries = commands.map(cmd => ({
cmd,
full: formatCommandDescription(cmd),
}))
// join('\n') produces N-1 newlines for N entries
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')
}
// Partition into bundled (never truncated) and rest
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)
}
}
// Compute space used by bundled skills (full descriptions, always preserved)
const bundledChars = fullEntries.reduce(
(sum, e, i) =>
bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,
0,
)
const remainingBudget = budget - bundledChars
// Calculate max description length for non-bundled commands
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) {
// Extreme case: non-bundled go names-only, bundled keep descriptions
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')
}
// Truncate non-bundled descriptions to fit within budget
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,
// Count of bundled skills included in this prompt (excludes skills with disableModelInvocation)
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) => {
// Bundled skills always get full descriptions
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,
}
}
// Returns the commands included in the SkillTool prompt.
// All commands are always included (descriptions may be truncated to fit budget).
// Used by analyzeContext to count skill 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 zeros rather than throwing - let caller decide how to handle
return {
totalSkills: 0,
includedSkills: 0,
}
}
}
|