This pass rewrites comment-only ANT-ONLY markers to neutral internal-only language across the source tree without changing runtime strings, flags, commands, or protocol identifiers. The goal is to lower obvious internal prose leakage while keeping the diff mechanically safe and easy to review. Constraint: Phase B is limited to comments/prose only; runtime strings and user-facing labels remain deferred Rejected: Broad search-and-replace across strings and command descriptions | too risky for a prose-only pass Confidence: high Scope-risk: narrow Reversibility: clean Directive: Remaining ANT-ONLY hits are mostly runtime/user-facing strings and should be handled separately from comment cleanup Tested: bun run build Tested: bun run smoke Tested: bun run verify:privacy Tested: bun run test:provider Tested: bun run test:provider-recommendation Not-tested: Full repo typecheck (upstream baseline remains noisy) Co-authored-by: anandh8x <test@example.com>
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 (internal-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 internal-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 (internal-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,
|
|
),
|
|
}
|
|
}
|