/ utils / hooks / execAgentHook.ts
execAgentHook.ts
  1  import { randomUUID } from 'crypto'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import { query } from '../../query.js'
  4  import { logEvent } from '../../services/analytics/index.js'
  5  import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js'
  6  import type { ToolUseContext } from '../../Tool.js'
  7  import { type Tool, toolMatchesName } from '../../Tool.js'
  8  import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js'
  9  import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js'
 10  import { asAgentId } from '../../types/ids.js'
 11  import type { Message } from '../../types/message.js'
 12  import { createAbortController } from '../abortController.js'
 13  import { createAttachmentMessage } from '../attachments.js'
 14  import { createCombinedAbortSignal } from '../combinedAbortSignal.js'
 15  import { logForDebugging } from '../debug.js'
 16  import { errorMessage } from '../errors.js'
 17  import type { HookResult } from '../hooks.js'
 18  import { createUserMessage, handleMessageFromStream } from '../messages.js'
 19  import { getSmallFastModel } from '../model/model.js'
 20  import { hasPermissionsToUseTool } from '../permissions/permissions.js'
 21  import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js'
 22  import type { AgentHook } from '../settings/types.js'
 23  import { jsonStringify } from '../slowOperations.js'
 24  import { asSystemPrompt } from '../systemPromptType.js'
 25  import {
 26    addArgumentsToPrompt,
 27    createStructuredOutputTool,
 28    hookResponseSchema,
 29    registerStructuredOutputEnforcement,
 30  } from './hookHelpers.js'
 31  import { clearSessionHooks } from './sessionHooks.js'
 32  
 33  /**
 34   * Execute an agent-based hook using a multi-turn LLM query
 35   */
 36  export 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  
110  Use the available tools to inspect the codebase and verify the condition.
111  Use as few steps as possible - be efficient and direct.
112  
113  When 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  }