/ tools / SkillTool / prompt.ts
prompt.ts
  1  import { memoize } from 'lodash-es'
  2  import type { Command } from 'src/commands.js'
  3  import {
  4    getCommandName,
  5    getSkillToolCommands,
  6    getSlashCommandToolSkills,
  7  } from 'src/commands.js'
  8  import { COMMAND_NAME_TAG } from '../../constants/xml.js'
  9  import { stringWidth } from '../../ink/stringWidth.js'
 10  import {
 11    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 12    logEvent,
 13  } from '../../services/analytics/index.js'
 14  import { count } from '../../utils/array.js'
 15  import { logForDebugging } from '../../utils/debug.js'
 16  import { toError } from '../../utils/errors.js'
 17  import { truncate } from '../../utils/format.js'
 18  import { logError } from '../../utils/log.js'
 19  
 20  // Skill listing gets 1% of the context window (in characters)
 21  export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
 22  export const CHARS_PER_TOKEN = 4
 23  export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4
 24  
 25  // Per-entry hard cap. The listing is for discovery only — the Skill tool loads
 26  // full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
 27  // tokens without improving match rate. Applies to all entries, including bundled,
 28  // since the cap is generous enough to preserve the core use case.
 29  export const MAX_LISTING_DESC_CHARS = 250
 30  
 31  export function getCharBudget(contextWindowTokens?: number): number {
 32    if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
 33      return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
 34    }
 35    if (contextWindowTokens) {
 36      return Math.floor(
 37        contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT,
 38      )
 39    }
 40    return DEFAULT_CHAR_BUDGET
 41  }
 42  
 43  function getCommandDescription(cmd: Command): string {
 44    const desc = cmd.whenToUse
 45      ? `${cmd.description} - ${cmd.whenToUse}`
 46      : cmd.description
 47    return desc.length > MAX_LISTING_DESC_CHARS
 48      ? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026'
 49      : desc
 50  }
 51  
 52  function formatCommandDescription(cmd: Command): string {
 53    // Debug: log if userFacingName differs from cmd.name for plugin skills
 54    const displayName = getCommandName(cmd)
 55    if (
 56      cmd.name !== displayName &&
 57      cmd.type === 'prompt' &&
 58      cmd.source === 'plugin'
 59    ) {
 60      logForDebugging(
 61        `Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`,
 62      )
 63    }
 64  
 65    return `- ${cmd.name}: ${getCommandDescription(cmd)}`
 66  }
 67  
 68  const MIN_DESC_LENGTH = 20
 69  
 70  export function formatCommandsWithinBudget(
 71    commands: Command[],
 72    contextWindowTokens?: number,
 73  ): string {
 74    if (commands.length === 0) return ''
 75  
 76    const budget = getCharBudget(contextWindowTokens)
 77  
 78    // Try full descriptions first
 79    const fullEntries = commands.map(cmd => ({
 80      cmd,
 81      full: formatCommandDescription(cmd),
 82    }))
 83    // join('\n') produces N-1 newlines for N entries
 84    const fullTotal =
 85      fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) +
 86      (fullEntries.length - 1)
 87  
 88    if (fullTotal <= budget) {
 89      return fullEntries.map(e => e.full).join('\n')
 90    }
 91  
 92    // Partition into bundled (never truncated) and rest
 93    const bundledIndices = new Set<number>()
 94    const restCommands: Command[] = []
 95    for (let i = 0; i < commands.length; i++) {
 96      const cmd = commands[i]!
 97      if (cmd.type === 'prompt' && cmd.source === 'bundled') {
 98        bundledIndices.add(i)
 99      } else {
100        restCommands.push(cmd)
101      }
102    }
103  
104    // Compute space used by bundled skills (full descriptions, always preserved)
105    const bundledChars = fullEntries.reduce(
106      (sum, e, i) =>
107        bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,
108      0,
109    )
110    const remainingBudget = budget - bundledChars
111  
112    // Calculate max description length for non-bundled commands
113    if (restCommands.length === 0) {
114      return fullEntries.map(e => e.full).join('\n')
115    }
116  
117    const restNameOverhead =
118      restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) +
119      (restCommands.length - 1)
120    const availableForDescs = remainingBudget - restNameOverhead
121    const maxDescLen = Math.floor(availableForDescs / restCommands.length)
122  
123    if (maxDescLen < MIN_DESC_LENGTH) {
124      // Extreme case: non-bundled go names-only, bundled keep descriptions
125      if (process.env.USER_TYPE === 'ant') {
126        logEvent('tengu_skill_descriptions_truncated', {
127          skill_count: commands.length,
128          budget,
129          full_total: fullTotal,
130          truncation_mode:
131            'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
132          max_desc_length: maxDescLen,
133          bundled_count: bundledIndices.size,
134          bundled_chars: bundledChars,
135        })
136      }
137      return commands
138        .map((cmd, i) =>
139          bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
140        )
141        .join('\n')
142    }
143  
144    // Truncate non-bundled descriptions to fit within budget
145    const truncatedCount = count(
146      restCommands,
147      cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen,
148    )
149    if (process.env.USER_TYPE === 'ant') {
150      logEvent('tengu_skill_descriptions_truncated', {
151        skill_count: commands.length,
152        budget,
153        full_total: fullTotal,
154        truncation_mode:
155          'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
156        max_desc_length: maxDescLen,
157        truncated_count: truncatedCount,
158        // Count of bundled skills included in this prompt (excludes skills with disableModelInvocation)
159        bundled_count: bundledIndices.size,
160        bundled_chars: bundledChars,
161      })
162    }
163    return commands
164      .map((cmd, i) => {
165        // Bundled skills always get full descriptions
166        if (bundledIndices.has(i)) return fullEntries[i]!.full
167        const description = getCommandDescription(cmd)
168        return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
169      })
170      .join('\n')
171  }
172  
173  export const getPrompt = memoize(async (_cwd: string): Promise<string> => {
174    return `Execute a skill within the main conversation
175  
176  When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.
177  
178  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.
179  
180  How to invoke:
181  - Use this tool with the skill name and optional arguments
182  - Examples:
183    - \`skill: "pdf"\` - invoke the pdf skill
184    - \`skill: "commit", args: "-m 'Fix bug'"\` - invoke with arguments
185    - \`skill: "review-pr", args: "123"\` - invoke with arguments
186    - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name
187  
188  Important:
189  - Available skills are listed in system-reminder messages in the conversation
190  - 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
191  - NEVER mention a skill without actually calling this tool
192  - Do not invoke a skill that is already running
193  - Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
194  - 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
195  `
196  })
197  
198  export async function getSkillToolInfo(cwd: string): Promise<{
199    totalCommands: number
200    includedCommands: number
201  }> {
202    const agentCommands = await getSkillToolCommands(cwd)
203  
204    return {
205      totalCommands: agentCommands.length,
206      includedCommands: agentCommands.length,
207    }
208  }
209  
210  // Returns the commands included in the SkillTool prompt.
211  // All commands are always included (descriptions may be truncated to fit budget).
212  // Used by analyzeContext to count skill tokens.
213  export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> {
214    return getSkillToolCommands(cwd)
215  }
216  
217  export function clearPromptCache(): void {
218    getPrompt.cache?.clear?.()
219  }
220  
221  export async function getSkillInfo(cwd: string): Promise<{
222    totalSkills: number
223    includedSkills: number
224  }> {
225    try {
226      const skills = await getSlashCommandToolSkills(cwd)
227  
228      return {
229        totalSkills: skills.length,
230        includedSkills: skills.length,
231      }
232    } catch (error) {
233      logError(toError(error))
234  
235      // Return zeros rather than throwing - let caller decide how to handle
236      return {
237        totalSkills: 0,
238        includedSkills: 0,
239      }
240    }
241  }