chore: initialize recovered claude workspace
This commit is contained in:
339
src/utils/hooks/execAgentHook.ts
Normal file
339
src/utils/hooks/execAgentHook.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user