import { randomUUID } from 'crypto' import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' import { query } from '../../query.js' import { logEvent } from '../../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' import type { ToolUseContext } from '../../Tool.js' import { type Tool, toolMatchesName } from '../../Tool.js' import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' import { asAgentId } from '../../types/ids.js' import type { Message } from '../../types/message.js' import { createAbortController } from '../abortController.js' import { createAttachmentMessage } from '../attachments.js' import { createCombinedAbortSignal } from '../combinedAbortSignal.js' import { logForDebugging } from '../debug.js' import { errorMessage } from '../errors.js' import type { HookResult } from '../hooks.js' import { createUserMessage, handleMessageFromStream } from '../messages.js' import { getSmallFastModel } from '../model/model.js' import { hasPermissionsToUseTool } from '../permissions/permissions.js' import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' import type { AgentHook } from '../settings/types.js' import { jsonStringify } from '../slowOperations.js' import { asSystemPrompt } from '../systemPromptType.js' import { addArgumentsToPrompt, createStructuredOutputTool, hookResponseSchema, registerStructuredOutputEnforcement, } from './hookHelpers.js' import { clearSessionHooks } from './sessionHooks.js' /** * Execute an agent-based hook using a multi-turn LLM query */ export async function execAgentHook( hook: AgentHook, hookName: string, hookEvent: HookEvent, jsonInput: string, signal: AbortSignal, toolUseContext: ToolUseContext, toolUseID: string | undefined, // Kept for signature stability with the other exec*Hook functions. // Was used by hook.prompt(messages) before the .transform() was removed // (CC-79) — the only consumer of that was ExitPlanModeV2Tool's // programmatic construction, since refactored into VerifyPlanExecutionTool. _messages: Message[], agentName?: string, ): Promise { const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` // Get transcript path from context const transcriptPath = toolUseContext.agentId ? getAgentTranscriptPath(toolUseContext.agentId) : getTranscriptPath() const hookStartTime = Date.now() try { // Replace $ARGUMENTS with the JSON input const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) logForDebugging( `Hooks: Processing agent hook with prompt: ${processedPrompt}`, ) // Create user message directly - no need for processUserInput which would // trigger UserPromptSubmit hooks and cause infinite recursion const userMessage = createUserMessage({ content: processedPrompt }) const agentMessages = [userMessage] logForDebugging( `Hooks: Starting agent query with ${agentMessages.length} messages`, ) // Setup timeout and combine with parent signal const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 const hookAbortController = createAbortController() // Combine parent signal with timeout, and have it abort our controller const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) const onParentTimeout = () => hookAbortController.abort() parentTimeoutSignal.addEventListener('abort', onParentTimeout) // Combined signal is just our controller's signal now const combinedSignal = hookAbortController.signal try { // Create StructuredOutput tool with our schema const structuredOutputTool = createStructuredOutputTool() // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) const filteredTools = toolUseContext.options.tools.filter( tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), ) // Use all available tools plus our structured output tool // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents // or entering plan mode, and filter out duplicate StructuredOutput tools const tools: Tool[] = [ ...filteredTools.filter( tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), ), structuredOutputTool, ] const systemPrompt = asSystemPrompt([ `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. Use the available tools to inspect the codebase and verify the condition. Use as few steps as possible - be efficient and direct. When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: - ok: true if the condition is met - ok: false with reason if the condition is not met`, ]) const model = hook.model ?? getSmallFastModel() const MAX_AGENT_TURNS = 50 // Create unique agentId for this hook agent const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) // Create a modified toolUseContext for the agent const agentToolUseContext: ToolUseContext = { ...toolUseContext, agentId: hookAgentId, abortController: hookAbortController, options: { ...toolUseContext.options, tools, mainLoopModel: model, isNonInteractiveSession: true, thinkingConfig: { type: 'disabled' as const }, }, setInProgressToolUseIDs: () => {}, getAppState() { const appState = toolUseContext.getAppState() // Add session rule to allow reading transcript file const existingSessionRules = appState.toolPermissionContext.alwaysAllowRules.session ?? [] return { ...appState, toolPermissionContext: { ...appState.toolPermissionContext, mode: 'dontAsk' as const, alwaysAllowRules: { ...appState.toolPermissionContext.alwaysAllowRules, session: [...existingSessionRules, `Read(/${transcriptPath})`], }, }, } }, } // Register a session-level stop hook to enforce structured output registerStructuredOutputEnforcement( toolUseContext.setAppState, hookAgentId, ) let structuredOutputResult: { ok: boolean; reason?: string } | null = null let turnCount = 0 let hitMaxTurns = false // Use query() for multi-turn execution for await (const message of query({ messages: agentMessages, systemPrompt, userContext: {}, systemContext: {}, canUseTool: hasPermissionsToUseTool, toolUseContext: agentToolUseContext, querySource: 'hook_agent', })) { // Process stream events to update response length in the spinner handleMessageFromStream( message, () => {}, // onMessage - we handle messages below newContent => toolUseContext.setResponseLength( length => length + newContent.length, ), toolUseContext.setStreamMode ?? (() => {}), () => {}, // onStreamingToolUses - not needed for hooks ) // Skip streaming events for further processing if ( message.type === 'stream_event' || message.type === 'stream_request_start' ) { continue } // Count assistant turns if (message.type === 'assistant') { turnCount++ // Check if we've hit the turn limit if (turnCount >= MAX_AGENT_TURNS) { hitMaxTurns = true logForDebugging( `Hooks: Agent turn ${turnCount} hit max turns, aborting`, ) hookAbortController.abort() break } } // Check for structured output in attachments if ( message.type === 'attachment' && message.attachment.type === 'structured_output' ) { const parsed = hookResponseSchema().safeParse(message.attachment.data) if (parsed.success) { structuredOutputResult = parsed.data logForDebugging( `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, ) // Got structured output, abort and exit hookAbortController.abort() break } } } parentTimeoutSignal.removeEventListener('abort', onParentTimeout) cleanupCombinedSignal() // Clean up the session hook we registered for this agent clearSessionHooks(toolUseContext.setAppState, hookAgentId) // Check if we got a result if (!structuredOutputResult) { // If we hit max turns, just log and return cancelled (no UI message) if (hitMaxTurns) { logForDebugging( `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, ) logEvent('tengu_agent_stop_hook_max_turns', { durationMs: Date.now() - hookStartTime, turnCount, agentName: agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { hook, outcome: 'cancelled', } } // For other cases (e.g., agent finished without calling structured output tool), // just log and return cancelled (don't show error to user) logForDebugging(`Hooks: Agent hook did not return structured output`) logEvent('tengu_agent_stop_hook_error', { durationMs: Date.now() - hookStartTime, turnCount, errorType: 1, // 1 = no structured output agentName: agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { hook, outcome: 'cancelled', } } // Return result based on structured output if (!structuredOutputResult.ok) { logForDebugging( `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, ) return { hook, outcome: 'blocking', blockingError: { blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, command: hook.prompt, }, } } // Condition was met logForDebugging(`Hooks: Agent hook condition was met`) logEvent('tengu_agent_stop_hook_success', { durationMs: Date.now() - hookStartTime, turnCount, agentName: agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { hook, outcome: 'success', message: createAttachmentMessage({ type: 'hook_success', hookName, toolUseID: effectiveToolUseID, hookEvent, content: '', }), } } catch (error) { parentTimeoutSignal.removeEventListener('abort', onParentTimeout) cleanupCombinedSignal() if (combinedSignal.aborted) { return { hook, outcome: 'cancelled', } } throw error } } catch (error) { const errorMsg = errorMessage(error) logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) logEvent('tengu_agent_stop_hook_error', { durationMs: Date.now() - hookStartTime, errorType: 2, // 2 = general error agentName: agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { hook, outcome: 'non_blocking_error', message: createAttachmentMessage({ type: 'hook_non_blocking_error', hookName, toolUseID: effectiveToolUseID, hookEvent, stderr: `Error executing agent hook: ${errorMsg}`, stdout: '', exitCode: 1, }), } } }