1109 lines
37 KiB
TypeScript
1109 lines
37 KiB
TypeScript
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<Command[]> {
|
|
// 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<Progress>,
|
|
): Promise<ToolResult<Output>> {
|
|
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<typeof inputSchema>
|
|
|
|
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<typeof outputSchema>
|
|
|
|
export type Output = z.input<OutputSchema>
|
|
|
|
export const SkillTool: Tool<InputSchema, Output, Progress> = 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<ValidationResult> {
|
|
// 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_<slug>` 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<PermissionDecision> {
|
|
// 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<ToolResult<Output>> {
|
|
// 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_<slug>` 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<InputSchema, Output, Progress>)
|
|
|
|
// 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<string, unknown>)[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<ToolResult<Output>> {
|
|
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,
|
|
),
|
|
}
|
|
}
|