execAgentHook.ts 12 KB

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