Download src/utils/hooks/execAgentHook.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/hooks/execAgentHook.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/hooks/execAgentHook.ts
-
curl -L -o execAgentHook.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/hooks/execAgentHook.ts
12.5 kB
| 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.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, | |
| }), | |
| } | |
| } | |
| } | |