| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- 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<HookResult> {
- 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 as any).attachment.type === 'structured_output'
- ) {
- const parsed = hookResponseSchema().safeParse((message as any).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,
- }),
- }
- }
- }
|