import { feature } from 'bun:bundle' import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' import uniqBy from 'lodash-es/uniqBy.js' import { dirname } from 'path' import { getProjectRoot } from 'src/bootstrap/state.js' import { builtInCommandNames, findCommand, getCommands, type PromptCommand, } from 'src/commands.js' import type { Tool, ToolCallProgress, ToolResult, ToolUseContext, ValidationResult, } from 'src/Tool.js' import { buildTool, type ToolDef } from 'src/Tool.js' import type { Command } from 'src/types/command.js' import type { AssistantMessage, AttachmentMessage, Message, SystemMessage, UserMessage, } from 'src/types/message.js' import { logForDebugging } from 'src/utils/debug.js' import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js' import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js' import { isOfficialMarketplaceName, parsePluginIdentifier, } from 'src/utils/plugins/pluginIdentifier.js' import { buildPluginCommandTelemetryFields } from 'src/utils/telemetry/pluginTelemetry.js' import { z } from 'zod/v4' import { addInvokedSkill, clearInvokedSkillsForAgent, getSessionId, } from '../../bootstrap/state.js' import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent, } from '../../services/analytics/index.js' import { getAgentContext } from '../../utils/agentContext.js' import { errorMessage } from '../../utils/errors.js' import { extractResultText, prepareForkedCommandContext, } from '../../utils/forkedAgent.js' import { parseFrontmatter } from '../../utils/frontmatterParser.js' import { lazySchema } from '../../utils/lazySchema.js' import { createUserMessage, normalizeMessages } from '../../utils/messages.js' import type { ModelAlias } from '../../utils/model/aliases.js' import { resolveSkillModelOverride } from '../../utils/model/model.js' import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js' import { createAgentId } from '../../utils/uuid.js' import { runAgent } from '../AgentTool/runAgent.js' import { getToolUseIDFromParentMessage, tagMessagesWithToolUseID, } from '../utils.js' import { SKILL_TOOL_NAME } from './constants.js' import { getPrompt } from './prompt.js' import { renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, } from './UI.js' /** * Gets all commands including MCP skills/prompts from AppState. * SkillTool needs this because getCommands() only returns local/bundled skills. */ async function getAllCommands(context: ToolUseContext): Promise { // Only include MCP skills (loadedFrom === 'mcp'), not plain MCP prompts. // Before this filter, the model could invoke MCP prompts via SkillTool // if it guessed the mcp__server__prompt name — they weren't discoverable // but were technically reachable. const mcpSkills = context .getAppState() .mcp.commands.filter( cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp', ) if (mcpSkills.length === 0) return getCommands(getProjectRoot()) const localCommands = await getCommands(getProjectRoot()) return uniqBy([...localCommands, ...mcpSkills], 'name') } // Re-export Progress from centralized types to break import cycles export type { SkillToolProgress as Progress } from '../../types/tools.js' import type { SkillToolProgress as Progress } from '../../types/tools.js' // Conditional require for remote skill modules — static imports here would // pull in akiBackend.ts (via remoteSkillLoader → akiBackend), which has // module-level memoize()/lazySchema() consts that survive tree-shaking as // side-effecting initializers. All usages are inside // feature('EXPERIMENTAL_SKILL_SEARCH') guards, so remoteSkillModules is // non-null at every call site. /* eslint-disable @typescript-eslint/no-require-imports */ const remoteSkillModules = feature('EXPERIMENTAL_SKILL_SEARCH') ? { ...(require('../../services/skillSearch/remoteSkillState.js') as typeof import('../../services/skillSearch/remoteSkillState.js')), ...(require('../../services/skillSearch/remoteSkillLoader.js') as typeof import('../../services/skillSearch/remoteSkillLoader.js')), ...(require('../../services/skillSearch/telemetry.js') as typeof import('../../services/skillSearch/telemetry.js')), ...(require('../../services/skillSearch/featureCheck.js') as typeof import('../../services/skillSearch/featureCheck.js')), } : null /* eslint-enable @typescript-eslint/no-require-imports */ /** * Executes a skill in a forked sub-agent context. * This runs the skill prompt in an isolated agent with its own token budget. */ async function executeForkedSkill( command: Command & { type: 'prompt' }, commandName: string, args: string | undefined, context: ToolUseContext, canUseTool: CanUseToolFn, parentMessage: AssistantMessage, onProgress?: ToolCallProgress, ): Promise> { const startTime = Date.now() const agentId = createAgentId() const isBuiltIn = builtInCommandNames().has(commandName) const isOfficialSkill = isOfficialMarketplaceSkill(command) const isBundled = command.source === 'bundled' const forkedSanitizedName = isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' const wasDiscoveredField = feature('EXPERIMENTAL_SKILL_SEARCH') && remoteSkillModules!.isSkillSearchEnabled() ? { was_discovered: context.discoveredSkillNames?.has(commandName) ?? false, } : {} const pluginMarketplace = command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace : undefined const queryDepth = context.queryTracking?.depth ?? 0 const parentAgentId = getAgentContext()?.agentId logEvent('tengu_skill_tool_invocation', { command_name: forkedSanitizedName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // _PROTO_skill_name routes to the privileged skill_name BQ column // (unredacted, all users); command_name stays in additional_metadata as // the redacted variant for general-access dashboards. _PROTO_skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, execution_context: 'fork' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, invocation_trigger: (queryDepth > 0 ? 'nested-skill' : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, query_depth: queryDepth, ...(parentAgentId && { parent_agent_id: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...wasDiscoveredField, ...(process.env.USER_TYPE === 'ant' && { skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, skill_source: command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(command.loadedFrom && { skill_loaded_from: command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(command.kind && { skill_kind: command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }), ...(command.pluginInfo && { // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns // (unredacted, all users); plugin_name/plugin_repository stay in // additional_metadata as redacted variants. _PROTO_plugin_name: command.pluginInfo.pluginManifest .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(pluginMarketplace && { _PROTO_marketplace_name: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), plugin_name: (isOfficialSkill ? command.pluginInfo.pluginManifest.name : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, plugin_repository: (isOfficialSkill ? command.pluginInfo.repository : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginCommandTelemetryFields(command.pluginInfo), }), }) const { modifiedGetAppState, baseAgent, promptMessages, skillContent } = await prepareForkedCommandContext(command, args || '', context) // Merge skill's effort into the agent definition so runAgent applies it const agentDefinition = command.effort !== undefined ? { ...baseAgent, effort: command.effort } : baseAgent // Collect messages from the forked agent const agentMessages: Message[] = [] logForDebugging( `SkillTool executing forked skill ${commandName} with agent ${agentDefinition.agentType}`, ) try { // Run the sub-agent for await (const message of runAgent({ agentDefinition, promptMessages, toolUseContext: { ...context, getAppState: modifiedGetAppState, }, canUseTool, isAsync: false, querySource: 'agent:custom', model: command.model as ModelAlias | undefined, availableTools: context.options.tools, override: { agentId }, })) { agentMessages.push(message) // Report progress for tool uses (like AgentTool does) if ( (message.type === 'assistant' || message.type === 'user') && onProgress ) { const normalizedNew = normalizeMessages([message]) for (const m of normalizedNew) { const hasToolContent = m.message.content.some( c => c.type === 'tool_use' || c.type === 'tool_result', ) if (hasToolContent) { onProgress({ toolUseID: `skill_${parentMessage.message.id}`, data: { message: m, type: 'skill_progress', prompt: skillContent, agentId, }, }) } } } } const resultText = extractResultText( agentMessages, 'Skill execution completed', ) // Release message memory after extracting result agentMessages.length = 0 const durationMs = Date.now() - startTime logForDebugging( `SkillTool forked skill ${commandName} completed in ${durationMs}ms`, ) return { data: { success: true, commandName, status: 'forked', agentId, result: resultText, }, } } finally { // Release skill content from invokedSkills state clearInvokedSkillsForAgent(agentId) } } export const inputSchema = lazySchema(() => z.object({ skill: z .string() .describe('The skill name. E.g., "commit", "review-pr", or "pdf"'), args: z.string().optional().describe('Optional arguments for the skill'), }), ) type InputSchema = ReturnType export const outputSchema = lazySchema(() => { // Output schema for inline skills (default) const inlineOutputSchema = z.object({ success: z.boolean().describe('Whether the skill is valid'), commandName: z.string().describe('The name of the skill'), allowedTools: z .array(z.string()) .optional() .describe('Tools allowed by this skill'), model: z.string().optional().describe('Model override if specified'), status: z.literal('inline').optional().describe('Execution status'), }) // Output schema for forked skills const forkedOutputSchema = z.object({ success: z.boolean().describe('Whether the skill completed successfully'), commandName: z.string().describe('The name of the skill'), status: z.literal('forked').describe('Execution status'), agentId: z .string() .describe('The ID of the sub-agent that executed the skill'), result: z.string().describe('The result from the forked skill execution'), }) return z.union([inlineOutputSchema, forkedOutputSchema]) }) type OutputSchema = ReturnType export type Output = z.input export const SkillTool: Tool = buildTool({ name: SKILL_TOOL_NAME, searchHint: 'invoke a slash-command skill', maxResultSizeChars: 100_000, get inputSchema(): InputSchema { return inputSchema() }, get outputSchema(): OutputSchema { return outputSchema() }, description: async ({ skill }) => `Execute skill: ${skill}`, prompt: async () => getPrompt(getProjectRoot()), // Only one skill/command should run at a time, since the tool expands the // command into a full prompt that Claude must process before continuing. // Skill-coach needs the skill name to avoid false-positive "you could have // used skill X" suggestions when X was actually invoked. Backseat classifies // downstream tool calls from the expanded prompt, not this wrapper, so the // name alone is sufficient — it just records that the skill fired. toAutoClassifierInput: ({ skill }) => skill ?? '', async validateInput({ skill }, context): Promise { // Skills are just skill names, no arguments const trimmed = skill.trim() if (!trimmed) { return { result: false, message: `Invalid skill format: ${skill}`, errorCode: 1, } } // Remove leading slash if present (for compatibility) const hasLeadingSlash = trimmed.startsWith('/') if (hasLeadingSlash) { logEvent('tengu_skill_tool_slash_prefix', {}) } const normalizedCommandName = hasLeadingSlash ? trimmed.substring(1) : trimmed // Remote canonical skill handling (ant-only experimental). Intercept // `_canonical_` names before local command lookup since remote // skills are not in the local command registry. if ( feature('EXPERIMENTAL_SKILL_SEARCH') && process.env.USER_TYPE === 'ant' ) { const slug = remoteSkillModules!.stripCanonicalPrefix( normalizedCommandName, ) if (slug !== null) { const meta = remoteSkillModules!.getDiscoveredRemoteSkill(slug) if (!meta) { return { result: false, message: `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, errorCode: 6, } } // Discovered remote skill — valid. Loading happens in call(). return { result: true } } } // Get available commands (including MCP skills) const commands = await getAllCommands(context) // Check if command exists const foundCommand = findCommand(normalizedCommandName, commands) if (!foundCommand) { return { result: false, message: `Unknown skill: ${normalizedCommandName}`, errorCode: 2, } } // Check if command has model invocation disabled if (foundCommand.disableModelInvocation) { return { result: false, message: `Skill ${normalizedCommandName} cannot be used with ${SKILL_TOOL_NAME} tool due to disable-model-invocation`, errorCode: 4, } } // Check if command is a prompt-based command if (foundCommand.type !== 'prompt') { return { result: false, message: `Skill ${normalizedCommandName} is not a prompt-based skill`, errorCode: 5, } } return { result: true } }, async checkPermissions( { skill, args }, context, ): Promise { // Skills are just skill names, no arguments const trimmed = skill.trim() // Remove leading slash if present (for compatibility) const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed const appState = context.getAppState() const permissionContext = appState.toolPermissionContext // Look up the command object to pass as metadata const commands = await getAllCommands(context) const commandObj = findCommand(commandName, commands) // Helper function to check if a rule matches the skill // Normalizes both inputs by stripping leading slashes for consistent matching const ruleMatches = (ruleContent: string): boolean => { // Normalize rule content by stripping leading slash const normalizedRule = ruleContent.startsWith('/') ? ruleContent.substring(1) : ruleContent // Check exact match (using normalized commandName) if (normalizedRule === commandName) { return true } // Check prefix match (e.g., "review:*" matches "review-pr 123") if (normalizedRule.endsWith(':*')) { const prefix = normalizedRule.slice(0, -2) // Remove ':*' return commandName.startsWith(prefix) } return false } // Check for deny rules const denyRules = getRuleByContentsForTool( permissionContext, SkillTool as Tool, 'deny', ) for (const [ruleContent, rule] of denyRules.entries()) { if (ruleMatches(ruleContent)) { return { behavior: 'deny', message: `Skill execution blocked by permission rules`, decisionReason: { type: 'rule', rule, }, } } } // Remote canonical skills are ant-only experimental — auto-grant. // Placed AFTER the deny loop so a user-configured Skill(_canonical_:*) // deny rule is honored (same pattern as safe-properties auto-allow below). // The skill content itself is canonical/curated, not user-authored. if ( feature('EXPERIMENTAL_SKILL_SEARCH') && process.env.USER_TYPE === 'ant' ) { const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) if (slug !== null) { return { behavior: 'allow', updatedInput: { skill, args }, decisionReason: undefined, } } } // Check for allow rules const allowRules = getRuleByContentsForTool( permissionContext, SkillTool as Tool, 'allow', ) for (const [ruleContent, rule] of allowRules.entries()) { if (ruleMatches(ruleContent)) { return { behavior: 'allow', updatedInput: { skill, args }, decisionReason: { type: 'rule', rule, }, } } } // Auto-allow skills that only use safe properties. // This is an allowlist: if a skill has any property NOT in this set with a // meaningful value, it requires permission. This ensures new properties added // in the future default to requiring permission. if ( commandObj?.type === 'prompt' && skillHasOnlySafeProperties(commandObj) ) { return { behavior: 'allow', updatedInput: { skill, args }, decisionReason: undefined, } } // Prepare suggestions for exact skill and prefix // Use normalized commandName (without leading slash) for consistent rules const suggestions = [ // Exact skill suggestion { type: 'addRules' as const, rules: [ { toolName: SKILL_TOOL_NAME, ruleContent: commandName, }, ], behavior: 'allow' as const, destination: 'localSettings' as const, }, // Prefix suggestion to allow any args { type: 'addRules' as const, rules: [ { toolName: SKILL_TOOL_NAME, ruleContent: `${commandName}:*`, }, ], behavior: 'allow' as const, destination: 'localSettings' as const, }, ] // Default behavior: ask user for permission return { behavior: 'ask', message: `Execute skill: ${commandName}`, decisionReason: undefined, suggestions, updatedInput: { skill, args }, metadata: commandObj ? { command: commandObj } : undefined, } }, async call( { skill, args }, context, canUseTool, parentMessage, onProgress?, ): Promise> { // At this point, validateInput has already confirmed: // - Skill format is valid // - Skill exists // - Skill can be loaded // - Skill doesn't have disableModelInvocation // - Skill is a prompt-based skill // Skills are just names, with optional arguments const trimmed = skill.trim() // Remove leading slash if present (for compatibility) const commandName = trimmed.startsWith('/') ? trimmed.substring(1) : trimmed // Remote canonical skill execution (ant-only experimental). Intercepts // `_canonical_` before local command lookup — loads SKILL.md from // AKI/GCS (with local cache), injects content directly as a user message. // Remote skills are declarative markdown so no slash-command expansion // (no !command substitution, no $ARGUMENTS interpolation) is needed. if ( feature('EXPERIMENTAL_SKILL_SEARCH') && process.env.USER_TYPE === 'ant' ) { const slug = remoteSkillModules!.stripCanonicalPrefix(commandName) if (slug !== null) { return executeRemoteSkill(slug, commandName, parentMessage, context) } } const commands = await getAllCommands(context) const command = findCommand(commandName, commands) // Track skill usage for ranking recordSkillUsage(commandName) // Check if skill should run as a forked sub-agent if (command?.type === 'prompt' && command.context === 'fork') { return executeForkedSkill( command, commandName, args, context, canUseTool, parentMessage, onProgress, ) } // Process the skill with optional args const { processPromptSlashCommand } = await import( 'src/utils/processUserInput/processSlashCommand.js' ) const processedCommand = await processPromptSlashCommand( commandName, args || '', // Pass args if provided commands, context, ) if (!processedCommand.shouldQuery) { throw new Error('Command processing failed') } // Extract metadata from the command const allowedTools = processedCommand.allowedTools || [] const model = processedCommand.model const effort = command?.type === 'prompt' ? command.effort : undefined const isBuiltIn = builtInCommandNames().has(commandName) const isBundled = command?.type === 'prompt' && command.source === 'bundled' const isOfficialSkill = command?.type === 'prompt' && isOfficialMarketplaceSkill(command) const sanitizedCommandName = isBuiltIn || isBundled || isOfficialSkill ? commandName : 'custom' const wasDiscoveredField = feature('EXPERIMENTAL_SKILL_SEARCH') && remoteSkillModules!.isSkillSearchEnabled() ? { was_discovered: context.discoveredSkillNames?.has(commandName) ?? false, } : {} const pluginMarketplace = command?.type === 'prompt' && command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace : undefined const queryDepth = context.queryTracking?.depth ?? 0 const parentAgentId = getAgentContext()?.agentId logEvent('tengu_skill_tool_invocation', { command_name: sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // _PROTO_skill_name routes to the privileged skill_name BQ column // (unredacted, all users); command_name stays in additional_metadata as // the redacted variant for general-access dashboards. _PROTO_skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, execution_context: 'inline' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, invocation_trigger: (queryDepth > 0 ? 'nested-skill' : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, query_depth: queryDepth, ...(parentAgentId && { parent_agent_id: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...wasDiscoveredField, ...(process.env.USER_TYPE === 'ant' && { skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...(command?.type === 'prompt' && { skill_source: command.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(command?.loadedFrom && { skill_loaded_from: command.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), ...(command?.kind && { skill_kind: command.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }), ...(command?.type === 'prompt' && command.pluginInfo && { _PROTO_plugin_name: command.pluginInfo.pluginManifest .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(pluginMarketplace && { _PROTO_marketplace_name: pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), plugin_name: (isOfficialSkill ? command.pluginInfo.pluginManifest.name : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, plugin_repository: (isOfficialSkill ? command.pluginInfo.repository : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginCommandTelemetryFields(command.pluginInfo), }), }) // Get the tool use ID from the parent message for linking newMessages const toolUseID = getToolUseIDFromParentMessage( parentMessage, SKILL_TOOL_NAME, ) // Tag user messages with sourceToolUseID so they stay transient until this tool resolves const newMessages = tagMessagesWithToolUseID( processedCommand.messages.filter( (m): m is UserMessage | AttachmentMessage | SystemMessage => { if (m.type === 'progress') { return false } // Filter out command-message since SkillTool handles display if (m.type === 'user' && 'message' in m) { const content = m.message.content if ( typeof content === 'string' && content.includes(`<${COMMAND_MESSAGE_TAG}>`) ) { return false } } return true }, ), toolUseID, ) logForDebugging( `SkillTool returning ${newMessages.length} newMessages for skill ${commandName}`, ) // Note: addInvokedSkill and registerSkillHooks are called inside // processPromptSlashCommand (via getMessagesForPromptSlashCommand), so // calling them again here would double-register hooks and rebuild // skillContent redundantly. // Return success with newMessages and contextModifier return { data: { success: true, commandName, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, model, }, newMessages, contextModifier(ctx) { let modifiedContext = ctx // Update allowed tools if specified if (allowedTools.length > 0) { // Capture the current getAppState to chain modifications properly const previousGetAppState = modifiedContext.getAppState modifiedContext = { ...modifiedContext, getAppState() { // Use the previous getAppState, not the closure's context.getAppState, // to properly chain context modifications const appState = previousGetAppState() return { ...appState, toolPermissionContext: { ...appState.toolPermissionContext, alwaysAllowRules: { ...appState.toolPermissionContext.alwaysAllowRules, command: [ ...new Set([ ...(appState.toolPermissionContext.alwaysAllowRules .command || []), ...allowedTools, ]), ], }, }, } }, } } // Carry [1m] suffix over — otherwise a skill with `model: opus` on an // opus[1m] session drops the effective window to 200K and trips autocompact. if (model) { modifiedContext = { ...modifiedContext, options: { ...modifiedContext.options, mainLoopModel: resolveSkillModelOverride( model, ctx.options.mainLoopModel, ), }, } } // Override effort level if skill specifies one if (effort !== undefined) { const previousGetAppState = modifiedContext.getAppState modifiedContext = { ...modifiedContext, getAppState() { const appState = previousGetAppState() return { ...appState, effortValue: effort, } }, } } return modifiedContext }, } }, mapToolResultToToolResultBlockParam( result: Output, toolUseID: string, ): ToolResultBlockParam { // Handle forked skill result if ('status' in result && result.status === 'forked') { return { type: 'tool_result' as const, tool_use_id: toolUseID, content: `Skill "${result.commandName}" completed (forked execution).\n\nResult:\n${result.result}`, } } // Inline skill result (default) return { type: 'tool_result' as const, tool_use_id: toolUseID, content: `Launching skill: ${result.commandName}`, } }, renderToolResultMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseRejectedMessage, renderToolUseErrorMessage, } satisfies ToolDef) // Allowlist of PromptCommand property keys that are safe and don't require permission. // If a skill has any property NOT in this set with a meaningful value, it requires // permission. This ensures new properties added to PromptCommand in the future // default to requiring permission until explicitly reviewed and added here. const SAFE_SKILL_PROPERTIES = new Set([ // PromptCommand properties 'type', 'progressMessage', 'contentLength', 'argNames', 'model', 'effort', 'source', 'pluginInfo', 'disableNonInteractive', 'skillRoot', 'context', 'agent', 'getPromptForCommand', 'frontmatterKeys', // CommandBase properties 'name', 'description', 'hasUserSpecifiedDescription', 'isEnabled', 'isHidden', 'aliases', 'isMcp', 'argumentHint', 'whenToUse', 'paths', 'version', 'disableModelInvocation', 'userInvocable', 'loadedFrom', 'immediate', 'userFacingName', ]) function skillHasOnlySafeProperties(command: Command): boolean { for (const key of Object.keys(command)) { if (SAFE_SKILL_PROPERTIES.has(key)) { continue } // Property not in safe allowlist - check if it has a meaningful value const value = (command as Record)[key] if (value === undefined || value === null) { continue } if (Array.isArray(value) && value.length === 0) { continue } if ( typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ) { continue } return false } return true } function isOfficialMarketplaceSkill(command: PromptCommand): boolean { if (command.source !== 'plugin' || !command.pluginInfo?.repository) { return false } return isOfficialMarketplaceName( parsePluginIdentifier(command.pluginInfo.repository).marketplace, ) } /** * Extract URL scheme for telemetry. Defaults to 'gs' for unrecognized schemes * since the AKI backend is the only production path and the loader throws on * unknown schemes before we reach telemetry anyway. */ function extractUrlScheme(url: string): 'gs' | 'http' | 'https' | 's3' { if (url.startsWith('gs://')) return 'gs' if (url.startsWith('https://')) return 'https' if (url.startsWith('http://')) return 'http' if (url.startsWith('s3://')) return 's3' return 'gs' } /** * Load a remote canonical skill and inject its SKILL.md content into the * conversation. Unlike local skills (which go through processPromptSlashCommand * for !command / $ARGUMENTS expansion), remote skills are declarative markdown * — we wrap the content directly in a user message. * * The skill is also registered with addInvokedSkill so it survives compaction * (same as local skills). * * Only called from within a feature('EXPERIMENTAL_SKILL_SEARCH') guard in * call() — remoteSkillModules is non-null here. */ async function executeRemoteSkill( slug: string, commandName: string, parentMessage: AssistantMessage, context: ToolUseContext, ): Promise> { const { getDiscoveredRemoteSkill, loadRemoteSkill, logRemoteSkillLoaded } = remoteSkillModules! // validateInput already confirmed this slug is in session state, but we // re-fetch here to get the URL. If it's somehow gone (e.g., state cleared // mid-session), fail with a clear error rather than crashing. const meta = getDiscoveredRemoteSkill(slug) if (!meta) { throw new Error( `Remote skill ${slug} was not discovered in this session. Use DiscoverSkills to find remote skills first.`, ) } const urlScheme = extractUrlScheme(meta.url) let loadResult try { loadResult = await loadRemoteSkill(slug, meta.url) } catch (e) { const msg = errorMessage(e) logRemoteSkillLoaded({ slug, cacheHit: false, latencyMs: 0, urlScheme, error: msg, }) throw new Error(`Failed to load remote skill ${slug}: ${msg}`) } const { cacheHit, latencyMs, skillPath, content, fileCount, totalBytes, fetchMethod, } = loadResult logRemoteSkillLoaded({ slug, cacheHit, latencyMs, urlScheme, fileCount, totalBytes, fetchMethod, }) // Remote skills are always model-discovered (never in static skill_listing), // so was_discovered is always true. is_remote lets BQ queries separate // remote from local invocations without joining on skill name prefixes. const queryDepth = context.queryTracking?.depth ?? 0 const parentAgentId = getAgentContext()?.agentId logEvent('tengu_skill_tool_invocation', { command_name: 'remote_skill' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // _PROTO_skill_name routes to the privileged skill_name BQ column // (unredacted, all users); command_name stays in additional_metadata as // the redacted variant. _PROTO_skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, execution_context: 'remote' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, invocation_trigger: (queryDepth > 0 ? 'nested-skill' : 'claude-proactive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, query_depth: queryDepth, ...(parentAgentId && { parent_agent_id: parentAgentId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), was_discovered: true, is_remote: true, remote_cache_hit: cacheHit, remote_load_latency_ms: latencyMs, ...(process.env.USER_TYPE === 'ant' && { skill_name: commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, remote_slug: slug as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), }) recordSkillUsage(commandName) logForDebugging( `SkillTool loaded remote skill ${slug} (cacheHit=${cacheHit}, ${latencyMs}ms, ${content.length} chars)`, ) // Strip YAML frontmatter (---\nname: x\n---) before prepending the header // (matches loadSkillsDir.ts:333). parseFrontmatter returns the original // content unchanged if no frontmatter is present. const { content: bodyContent } = parseFrontmatter(content, skillPath) // Inject base directory header + ${CLAUDE_SKILL_DIR}/${CLAUDE_SESSION_ID} // substitution (matches loadSkillsDir.ts) so the model can resolve relative // refs like ./schemas/foo.json against the cache dir. const skillDir = dirname(skillPath) const normalizedDir = process.platform === 'win32' ? skillDir.replace(/\\/g, '/') : skillDir let finalContent = `Base directory for this skill: ${normalizedDir}\n\n${bodyContent}` finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, normalizedDir) finalContent = finalContent.replace( /\$\{CLAUDE_SESSION_ID\}/g, getSessionId(), ) // Register with compaction-preservation state. Use the cached file path so // post-compact restoration knows where the content came from. Must use // finalContent (not raw content) so the base directory header and // ${CLAUDE_SKILL_DIR} substitutions survive compaction — matches how local // skills store their already-transformed content via processSlashCommand. addInvokedSkill( commandName, skillPath, finalContent, getAgentContext()?.agentId ?? null, ) // Direct injection — wrap SKILL.md content in a meta user message. Matches // the shape of what processPromptSlashCommand produces for simple skills. const toolUseID = getToolUseIDFromParentMessage( parentMessage, SKILL_TOOL_NAME, ) return { data: { success: true, commandName, status: 'inline' }, newMessages: tagMessagesWithToolUseID( [createUserMessage({ content: finalContent, isMeta: true })], toolUseID, ), } }