212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
|
|
import { queryModelWithoutStreaming } from '../../services/api/claude.js'
|
|
import type { ToolUseContext } from '../../Tool.js'
|
|
import type { Message } from '../../types/message.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 { safeParseJSON } from '../json.js'
|
|
import { createUserMessage, extractTextContent } from '../messages.js'
|
|
import { getSmallFastModel } from '../model/model.js'
|
|
import type { PromptHook } from '../settings/types.js'
|
|
import { asSystemPrompt } from '../systemPromptType.js'
|
|
import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js'
|
|
|
|
/**
|
|
* Execute a prompt-based hook using an LLM
|
|
*/
|
|
export async function execPromptHook(
|
|
hook: PromptHook,
|
|
hookName: string,
|
|
hookEvent: HookEvent,
|
|
jsonInput: string,
|
|
signal: AbortSignal,
|
|
toolUseContext: ToolUseContext,
|
|
messages?: Message[],
|
|
toolUseID?: string,
|
|
): Promise<HookResult> {
|
|
// Use provided toolUseID or generate a new one
|
|
const effectiveToolUseID = toolUseID || `hook-${randomUUID()}`
|
|
try {
|
|
// Replace $ARGUMENTS with the JSON input
|
|
const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput)
|
|
logForDebugging(
|
|
`Hooks: Processing prompt 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 })
|
|
|
|
// Prepend conversation history if provided
|
|
const messagesToQuery =
|
|
messages && messages.length > 0
|
|
? [...messages, userMessage]
|
|
: [userMessage]
|
|
|
|
logForDebugging(
|
|
`Hooks: Querying model with ${messagesToQuery.length} messages`,
|
|
)
|
|
|
|
// Query the model with Haiku
|
|
const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000
|
|
|
|
// Combined signal: aborts if either the hook signal or timeout triggers
|
|
const { signal: combinedSignal, cleanup: cleanupSignal } =
|
|
createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs })
|
|
|
|
try {
|
|
const response = await queryModelWithoutStreaming({
|
|
messages: messagesToQuery,
|
|
systemPrompt: asSystemPrompt([
|
|
`You are evaluating a hook in Claude Code.
|
|
|
|
Your response must be a JSON object matching one of the following schemas:
|
|
1. If the condition is met, return: {"ok": true}
|
|
2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`,
|
|
]),
|
|
thinkingConfig: { type: 'disabled' as const },
|
|
tools: toolUseContext.options.tools,
|
|
signal: combinedSignal,
|
|
options: {
|
|
async getToolPermissionContext() {
|
|
const appState = toolUseContext.getAppState()
|
|
return appState.toolPermissionContext
|
|
},
|
|
model: hook.model ?? getSmallFastModel(),
|
|
toolChoice: undefined,
|
|
isNonInteractiveSession: true,
|
|
hasAppendSystemPrompt: false,
|
|
agents: [],
|
|
querySource: 'hook_prompt',
|
|
mcpTools: [],
|
|
agentId: toolUseContext.agentId,
|
|
outputFormat: {
|
|
type: 'json_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
ok: { type: 'boolean' },
|
|
reason: { type: 'string' },
|
|
},
|
|
required: ['ok'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
cleanupSignal()
|
|
|
|
// Extract text content from response
|
|
const content = extractTextContent(response.message.content)
|
|
|
|
// Update response length for spinner display
|
|
toolUseContext.setResponseLength(length => length + content.length)
|
|
|
|
const fullResponse = content.trim()
|
|
logForDebugging(`Hooks: Model response: ${fullResponse}`)
|
|
|
|
const json = safeParseJSON(fullResponse)
|
|
if (!json) {
|
|
logForDebugging(
|
|
`Hooks: error parsing response as JSON: ${fullResponse}`,
|
|
)
|
|
return {
|
|
hook,
|
|
outcome: 'non_blocking_error',
|
|
message: createAttachmentMessage({
|
|
type: 'hook_non_blocking_error',
|
|
hookName,
|
|
toolUseID: effectiveToolUseID,
|
|
hookEvent,
|
|
stderr: 'JSON validation failed',
|
|
stdout: fullResponse,
|
|
exitCode: 1,
|
|
}),
|
|
}
|
|
}
|
|
|
|
const parsed = hookResponseSchema().safeParse(json)
|
|
if (!parsed.success) {
|
|
logForDebugging(
|
|
`Hooks: model response does not conform to expected schema: ${parsed.error.message}`,
|
|
)
|
|
return {
|
|
hook,
|
|
outcome: 'non_blocking_error',
|
|
message: createAttachmentMessage({
|
|
type: 'hook_non_blocking_error',
|
|
hookName,
|
|
toolUseID: effectiveToolUseID,
|
|
hookEvent,
|
|
stderr: `Schema validation failed: ${parsed.error.message}`,
|
|
stdout: fullResponse,
|
|
exitCode: 1,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// Failed to meet condition
|
|
if (!parsed.data.ok) {
|
|
logForDebugging(
|
|
`Hooks: Prompt hook condition was not met: ${parsed.data.reason}`,
|
|
)
|
|
return {
|
|
hook,
|
|
outcome: 'blocking',
|
|
blockingError: {
|
|
blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`,
|
|
command: hook.prompt,
|
|
},
|
|
preventContinuation: true,
|
|
stopReason: parsed.data.reason,
|
|
}
|
|
}
|
|
|
|
// Condition was met
|
|
logForDebugging(`Hooks: Prompt hook condition was met`)
|
|
return {
|
|
hook,
|
|
outcome: 'success',
|
|
message: createAttachmentMessage({
|
|
type: 'hook_success',
|
|
hookName,
|
|
toolUseID: effectiveToolUseID,
|
|
hookEvent,
|
|
content: '',
|
|
}),
|
|
}
|
|
} catch (error) {
|
|
cleanupSignal()
|
|
|
|
if (combinedSignal.aborted) {
|
|
return {
|
|
hook,
|
|
outcome: 'cancelled',
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error) {
|
|
const errorMsg = errorMessage(error)
|
|
logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`)
|
|
return {
|
|
hook,
|
|
outcome: 'non_blocking_error',
|
|
message: createAttachmentMessage({
|
|
type: 'hook_non_blocking_error',
|
|
hookName,
|
|
toolUseID: effectiveToolUseID,
|
|
hookEvent,
|
|
stderr: `Error executing prompt hook: ${errorMsg}`,
|
|
stdout: '',
|
|
exitCode: 1,
|
|
}),
|
|
}
|
|
}
|
|
}
|