source dump of claude code
at main 339 lines 12 kB view raw
1import { randomUUID } from 'crypto' 2import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' 3import { query } from '../../query.js' 4import { logEvent } from '../../services/analytics/index.js' 5import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' 6import type { ToolUseContext } from '../../Tool.js' 7import { type Tool, toolMatchesName } from '../../Tool.js' 8import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' 9import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' 10import { asAgentId } from '../../types/ids.js' 11import type { Message } from '../../types/message.js' 12import { createAbortController } from '../abortController.js' 13import { createAttachmentMessage } from '../attachments.js' 14import { createCombinedAbortSignal } from '../combinedAbortSignal.js' 15import { logForDebugging } from '../debug.js' 16import { errorMessage } from '../errors.js' 17import type { HookResult } from '../hooks.js' 18import { createUserMessage, handleMessageFromStream } from '../messages.js' 19import { getSmallFastModel } from '../model/model.js' 20import { hasPermissionsToUseTool } from '../permissions/permissions.js' 21import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' 22import type { AgentHook } from '../settings/types.js' 23import { jsonStringify } from '../slowOperations.js' 24import { asSystemPrompt } from '../systemPromptType.js' 25import { 26 addArgumentsToPrompt, 27 createStructuredOutputTool, 28 hookResponseSchema, 29 registerStructuredOutputEnforcement, 30} from './hookHelpers.js' 31import { clearSessionHooks } from './sessionHooks.js' 32 33/** 34 * Execute an agent-based hook using a multi-turn LLM query 35 */ 36export async function execAgentHook( 37 hook: AgentHook, 38 hookName: string, 39 hookEvent: HookEvent, 40 jsonInput: string, 41 signal: AbortSignal, 42 toolUseContext: ToolUseContext, 43 toolUseID: string | undefined, 44 // Kept for signature stability with the other exec*Hook functions. 45 // Was used by hook.prompt(messages) before the .transform() was removed 46 // (CC-79) — the only consumer of that was ExitPlanModeV2Tool's 47 // programmatic construction, since refactored into VerifyPlanExecutionTool. 48 _messages: Message[], 49 agentName?: string, 50): Promise<HookResult> { 51 const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` 52 53 // Get transcript path from context 54 const transcriptPath = toolUseContext.agentId 55 ? getAgentTranscriptPath(toolUseContext.agentId) 56 : getTranscriptPath() 57 const hookStartTime = Date.now() 58 try { 59 // Replace $ARGUMENTS with the JSON input 60 const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) 61 logForDebugging( 62 `Hooks: Processing agent hook with prompt: ${processedPrompt}`, 63 ) 64 65 // Create user message directly - no need for processUserInput which would 66 // trigger UserPromptSubmit hooks and cause infinite recursion 67 const userMessage = createUserMessage({ content: processedPrompt }) 68 const agentMessages = [userMessage] 69 70 logForDebugging( 71 `Hooks: Starting agent query with ${agentMessages.length} messages`, 72 ) 73 74 // Setup timeout and combine with parent signal 75 const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 76 const hookAbortController = createAbortController() 77 78 // Combine parent signal with timeout, and have it abort our controller 79 const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = 80 createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) 81 const onParentTimeout = () => hookAbortController.abort() 82 parentTimeoutSignal.addEventListener('abort', onParentTimeout) 83 84 // Combined signal is just our controller's signal now 85 const combinedSignal = hookAbortController.signal 86 87 try { 88 // Create StructuredOutput tool with our schema 89 const structuredOutputTool = createStructuredOutputTool() 90 91 // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas 92 // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) 93 const filteredTools = toolUseContext.options.tools.filter( 94 tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), 95 ) 96 97 // Use all available tools plus our structured output tool 98 // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents 99 // or entering plan mode, and filter out duplicate StructuredOutput tools 100 const tools: Tool[] = [ 101 ...filteredTools.filter( 102 tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), 103 ), 104 structuredOutputTool, 105 ] 106 107 const systemPrompt = asSystemPrompt([ 108 `You are verifying a stop condition in Claude Code. Your task is to verify that the agent completed the given plan. The conversation transcript is available at: ${transcriptPath}\nYou can read this file to analyze the conversation history if needed. 109 110Use the available tools to inspect the codebase and verify the condition. 111Use as few steps as possible - be efficient and direct. 112 113When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: 114- ok: true if the condition is met 115- ok: false with reason if the condition is not met`, 116 ]) 117 118 const model = hook.model ?? getSmallFastModel() 119 const MAX_AGENT_TURNS = 50 120 121 // Create unique agentId for this hook agent 122 const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) 123 124 // Create a modified toolUseContext for the agent 125 const agentToolUseContext: ToolUseContext = { 126 ...toolUseContext, 127 agentId: hookAgentId, 128 abortController: hookAbortController, 129 options: { 130 ...toolUseContext.options, 131 tools, 132 mainLoopModel: model, 133 isNonInteractiveSession: true, 134 thinkingConfig: { type: 'disabled' as const }, 135 }, 136 setInProgressToolUseIDs: () => {}, 137 getAppState() { 138 const appState = toolUseContext.getAppState() 139 // Add session rule to allow reading transcript file 140 const existingSessionRules = 141 appState.toolPermissionContext.alwaysAllowRules.session ?? [] 142 return { 143 ...appState, 144 toolPermissionContext: { 145 ...appState.toolPermissionContext, 146 mode: 'dontAsk' as const, 147 alwaysAllowRules: { 148 ...appState.toolPermissionContext.alwaysAllowRules, 149 session: [...existingSessionRules, `Read(/${transcriptPath})`], 150 }, 151 }, 152 } 153 }, 154 } 155 156 // Register a session-level stop hook to enforce structured output 157 registerStructuredOutputEnforcement( 158 toolUseContext.setAppState, 159 hookAgentId, 160 ) 161 162 let structuredOutputResult: { ok: boolean; reason?: string } | null = null 163 let turnCount = 0 164 let hitMaxTurns = false 165 166 // Use query() for multi-turn execution 167 for await (const message of query({ 168 messages: agentMessages, 169 systemPrompt, 170 userContext: {}, 171 systemContext: {}, 172 canUseTool: hasPermissionsToUseTool, 173 toolUseContext: agentToolUseContext, 174 querySource: 'hook_agent', 175 })) { 176 // Process stream events to update response length in the spinner 177 handleMessageFromStream( 178 message, 179 () => {}, // onMessage - we handle messages below 180 newContent => 181 toolUseContext.setResponseLength( 182 length => length + newContent.length, 183 ), 184 toolUseContext.setStreamMode ?? (() => {}), 185 () => {}, // onStreamingToolUses - not needed for hooks 186 ) 187 188 // Skip streaming events for further processing 189 if ( 190 message.type === 'stream_event' || 191 message.type === 'stream_request_start' 192 ) { 193 continue 194 } 195 196 // Count assistant turns 197 if (message.type === 'assistant') { 198 turnCount++ 199 200 // Check if we've hit the turn limit 201 if (turnCount >= MAX_AGENT_TURNS) { 202 hitMaxTurns = true 203 logForDebugging( 204 `Hooks: Agent turn ${turnCount} hit max turns, aborting`, 205 ) 206 hookAbortController.abort() 207 break 208 } 209 } 210 211 // Check for structured output in attachments 212 if ( 213 message.type === 'attachment' && 214 message.attachment.type === 'structured_output' 215 ) { 216 const parsed = hookResponseSchema().safeParse(message.attachment.data) 217 if (parsed.success) { 218 structuredOutputResult = parsed.data 219 logForDebugging( 220 `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, 221 ) 222 // Got structured output, abort and exit 223 hookAbortController.abort() 224 break 225 } 226 } 227 } 228 229 parentTimeoutSignal.removeEventListener('abort', onParentTimeout) 230 cleanupCombinedSignal() 231 232 // Clean up the session hook we registered for this agent 233 clearSessionHooks(toolUseContext.setAppState, hookAgentId) 234 235 // Check if we got a result 236 if (!structuredOutputResult) { 237 // If we hit max turns, just log and return cancelled (no UI message) 238 if (hitMaxTurns) { 239 logForDebugging( 240 `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, 241 ) 242 logEvent('tengu_agent_stop_hook_max_turns', { 243 durationMs: Date.now() - hookStartTime, 244 turnCount, 245 agentName: 246 agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 247 }) 248 return { 249 hook, 250 outcome: 'cancelled', 251 } 252 } 253 254 // For other cases (e.g., agent finished without calling structured output tool), 255 // just log and return cancelled (don't show error to user) 256 logForDebugging(`Hooks: Agent hook did not return structured output`) 257 logEvent('tengu_agent_stop_hook_error', { 258 durationMs: Date.now() - hookStartTime, 259 turnCount, 260 errorType: 1, // 1 = no structured output 261 agentName: 262 agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 263 }) 264 return { 265 hook, 266 outcome: 'cancelled', 267 } 268 } 269 270 // Return result based on structured output 271 if (!structuredOutputResult.ok) { 272 logForDebugging( 273 `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, 274 ) 275 return { 276 hook, 277 outcome: 'blocking', 278 blockingError: { 279 blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, 280 command: hook.prompt, 281 }, 282 } 283 } 284 285 // Condition was met 286 logForDebugging(`Hooks: Agent hook condition was met`) 287 logEvent('tengu_agent_stop_hook_success', { 288 durationMs: Date.now() - hookStartTime, 289 turnCount, 290 agentName: 291 agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 292 }) 293 return { 294 hook, 295 outcome: 'success', 296 message: createAttachmentMessage({ 297 type: 'hook_success', 298 hookName, 299 toolUseID: effectiveToolUseID, 300 hookEvent, 301 content: '', 302 }), 303 } 304 } catch (error) { 305 parentTimeoutSignal.removeEventListener('abort', onParentTimeout) 306 cleanupCombinedSignal() 307 308 if (combinedSignal.aborted) { 309 return { 310 hook, 311 outcome: 'cancelled', 312 } 313 } 314 throw error 315 } 316 } catch (error) { 317 const errorMsg = errorMessage(error) 318 logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) 319 logEvent('tengu_agent_stop_hook_error', { 320 durationMs: Date.now() - hookStartTime, 321 errorType: 2, // 2 = general error 322 agentName: 323 agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 324 }) 325 return { 326 hook, 327 outcome: 'non_blocking_error', 328 message: createAttachmentMessage({ 329 type: 'hook_non_blocking_error', 330 hookName, 331 toolUseID: effectiveToolUseID, 332 hookEvent, 333 stderr: `Error executing agent hook: ${errorMsg}`, 334 stdout: '', 335 exitCode: 1, 336 }), 337 } 338 } 339}