File size: 7,606 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | import { z } from 'zod/v4'
import { logEvent } from '../../services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
import type { AssistantMessage, Message } from '../../types/message.js'
import { getGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import { errorMessage } from '../errors.js'
import { lazySchema } from '../lazySchema.js'
import { logError } from '../log.js'
import { getMainLoopModel } from '../model/model.js'
import { sideQuery } from '../sideQuery.js'
import { jsonStringify } from '../slowOperations.js'
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH'
// Map risk levels to numeric values for analytics
const RISK_LEVEL_NUMERIC: Record<RiskLevel, number> = {
LOW: 1,
MEDIUM: 2,
HIGH: 3,
}
// Error type codes for analytics
const ERROR_TYPE_PARSE = 1
const ERROR_TYPE_NETWORK = 2
const ERROR_TYPE_UNKNOWN = 3
export type PermissionExplanation = {
riskLevel: RiskLevel
explanation: string
reasoning: string
risk: string
}
type GenerateExplanationParams = {
toolName: string
toolInput: unknown
toolDescription?: string
messages?: Message[]
signal: AbortSignal
}
const SYSTEM_PROMPT = `Analyze shell commands and explain what they do, why you're running them, and potential risks.`
// Tool definition for forced structured output (no beta required)
const EXPLAIN_COMMAND_TOOL = {
name: 'explain_command',
description: 'Provide an explanation of a shell command',
input_schema: {
type: 'object' as const,
properties: {
explanation: {
type: 'string',
description: 'What this command does (1-2 sentences)',
},
reasoning: {
type: 'string',
description:
'Why YOU are running this command. Start with "I" - e.g. "I need to check the file contents"',
},
risk: {
type: 'string',
description: 'What could go wrong, under 15 words',
},
riskLevel: {
type: 'string',
enum: ['LOW', 'MEDIUM', 'HIGH'],
description:
'LOW (safe dev workflows), MEDIUM (recoverable changes), HIGH (dangerous/irreversible)',
},
},
required: ['explanation', 'reasoning', 'risk', 'riskLevel'],
},
}
// Zod schema for parsing and validating the response
const RiskAssessmentSchema = lazySchema(() =>
z.object({
riskLevel: z.enum(['LOW', 'MEDIUM', 'HIGH']),
explanation: z.string(),
reasoning: z.string(),
risk: z.string(),
}),
)
function formatToolInput(input: unknown): string {
if (typeof input === 'string') {
return input
}
try {
return jsonStringify(input, null, 2)
} catch {
return String(input)
}
}
/**
* Extract recent conversation context from messages for the explainer.
* Returns a summary of recent assistant messages to provide context
* for "why" this command is being run.
*/
function extractConversationContext(
messages: Message[],
maxChars = 1000,
): string {
// Get recent assistant messages (they contain Claude's reasoning)
const assistantMessages = messages
.filter((m): m is AssistantMessage => m.type === 'assistant')
.slice(-3) // Last 3 assistant messages
const contextParts: string[] = []
let totalChars = 0
for (const msg of assistantMessages.reverse()) {
// Extract text content from assistant message
const textBlocks = msg.message.content
.filter(c => c.type === 'text')
.map(c => ('text' in c ? c.text : ''))
.join(' ')
if (textBlocks && totalChars < maxChars) {
const remaining = maxChars - totalChars
const truncated =
textBlocks.length > remaining
? textBlocks.slice(0, remaining) + '...'
: textBlocks
contextParts.unshift(truncated)
totalChars += truncated.length
}
}
return contextParts.join('\n\n')
}
/**
* Check if the permission explainer feature is enabled.
* Enabled by default; users can opt out via config.
*/
export function isPermissionExplainerEnabled(): boolean {
return getGlobalConfig().permissionExplainerEnabled !== false
}
/**
* Generate a permission explanation using Haiku with structured output.
* Returns null if the feature is disabled, request is aborted, or an error occurs.
*/
export async function generatePermissionExplanation({
toolName,
toolInput,
toolDescription,
messages,
signal,
}: GenerateExplanationParams): Promise<PermissionExplanation | null> {
// Check if feature is enabled
if (!isPermissionExplainerEnabled()) {
return null
}
const startTime = Date.now()
try {
const formattedInput = formatToolInput(toolInput)
const conversationContext = messages?.length
? extractConversationContext(messages)
: ''
const userPrompt = `Tool: ${toolName}
${toolDescription ? `Description: ${toolDescription}\n` : ''}
Input:
${formattedInput}
${conversationContext ? `\nRecent conversation context:\n${conversationContext}` : ''}
Explain this command in context.`
const model = getMainLoopModel()
// Use sideQuery with forced tool choice for guaranteed structured output
const response = await sideQuery({
model,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
tools: [EXPLAIN_COMMAND_TOOL],
tool_choice: { type: 'tool', name: 'explain_command' },
signal,
querySource: 'permission_explainer',
})
const latencyMs = Date.now() - startTime
logForDebugging(
`Permission explainer: API returned in ${latencyMs}ms, stop_reason=${response.stop_reason}`,
)
// Extract structured data from tool use block
const toolUseBlock = response.content.find(c => c.type === 'tool_use')
if (toolUseBlock && toolUseBlock.type === 'tool_use') {
logForDebugging(
`Permission explainer: tool input: ${jsonStringify(toolUseBlock.input).slice(0, 500)}`,
)
const result = RiskAssessmentSchema().safeParse(toolUseBlock.input)
if (result.success) {
const explanation: PermissionExplanation = {
riskLevel: result.data.riskLevel,
explanation: result.data.explanation,
reasoning: result.data.reasoning,
risk: result.data.risk,
}
logEvent('tengu_permission_explainer_generated', {
tool_name: sanitizeToolNameForAnalytics(toolName),
risk_level: RISK_LEVEL_NUMERIC[explanation.riskLevel],
latency_ms: latencyMs,
})
logForDebugging(
`Permission explainer: ${explanation.riskLevel} risk for ${toolName} (${latencyMs}ms)`,
)
return explanation
}
}
// No valid JSON in response
logEvent('tengu_permission_explainer_error', {
tool_name: sanitizeToolNameForAnalytics(toolName),
error_type: ERROR_TYPE_PARSE,
latency_ms: latencyMs,
})
logForDebugging(`Permission explainer: no parsed output in response`)
return null
} catch (error) {
const latencyMs = Date.now() - startTime
// Don't log aborted requests as errors
if (signal.aborted) {
logForDebugging(`Permission explainer: request aborted for ${toolName}`)
return null
}
logForDebugging(`Permission explainer error: ${errorMessage(error)}`)
logError(error)
logEvent('tengu_permission_explainer_error', {
tool_name: sanitizeToolNameForAnalytics(toolName),
error_type:
error instanceof Error && error.name === 'AbortError'
? ERROR_TYPE_NETWORK
: ERROR_TYPE_UNKNOWN,
latency_ms: latencyMs,
})
return null
}
}
|