import { Message as APIAssistantMessage, MessageParam, ToolUseBlock, } from '@anthropic-ai/sdk/resources/index.mjs' import type { UUID } from './types/common' import type { Tool, ToolUseContext } from './Tool' import { messagePairValidForBinaryFeedback, shouldUseBinaryFeedback, } from './components/binary-feedback/utils.js' import { CanUseToolFn } from './hooks/useCanUseTool' import { formatSystemPromptWithContext, queryLLM, queryModel, } from './services/claude.js' import { emitReminderEvent } from './services/systemReminder' import { logEvent } from './services/statsig' import { all } from './utils/generators' import { logError } from './utils/log' import { debug as debugLogger, markPhase, getCurrentRequest, logUserFriendly, } from './utils/debugLogger' import { getModelManager } from './utils/model.js' import { createAssistantMessage, createProgressMessage, createToolResultStopMessage, createUserMessage, FullToolUseResult, INTERRUPT_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE, NormalizedMessage, normalizeMessagesForAPI, } from './utils/messages.js' import { createToolExecutionController } from './utils/toolExecutionController' import { BashTool } from './tools/BashTool/BashTool' import { getCwd } from './utils/state' import { checkAutoCompact } from './utils/autoCompactCore' // Extended ToolUseContext for query functions interface ExtendedToolUseContext extends ToolUseContext { abortController: AbortController options: { commands: any[] forkNumber: number messageLogName: string tools: Tool[] verbose: boolean safeMode: boolean maxThinkingTokens: number isKodingRequest?: boolean model?: string | import('./utils/config').ModelPointerType } readFileTimestamps: { [filename: string]: number } setToolJSX: (jsx: any) => void requestId?: string } export type Response = { costUSD: number; response: string } export type UserMessage = { message: MessageParam type: 'user' uuid: UUID toolUseResult?: FullToolUseResult options?: { isKodingRequest?: boolean kodingContext?: string isCustomCommand?: boolean commandName?: string commandArgs?: string } } export type AssistantMessage = { costUSD: number durationMs: number message: APIAssistantMessage type: 'assistant' uuid: UUID isApiErrorMessage?: boolean responseId?: string // For GPT-5 Responses API state management } export type BinaryFeedbackResult = | { message: AssistantMessage | null; shouldSkipPermissionCheck: false } | { message: AssistantMessage; shouldSkipPermissionCheck: true } export type ProgressMessage = { content: AssistantMessage normalizedMessages: NormalizedMessage[] siblingToolUseIDs: Set tools: Tool[] toolUseID: string type: 'progress' uuid: UUID } // Each array item is either a single message or a message-and-response pair export type Message = UserMessage | AssistantMessage | ProgressMessage const MAX_TOOL_USE_CONCURRENCY = 10 // Returns a message if we got one, or `null` if the user cancelled async function queryWithBinaryFeedback( toolUseContext: ExtendedToolUseContext, getAssistantResponse: () => Promise, getBinaryFeedbackResponse?: ( m1: AssistantMessage, m2: AssistantMessage, ) => Promise, ): Promise { if ( process.env.USER_TYPE !== 'ant' || !getBinaryFeedbackResponse || !(await shouldUseBinaryFeedback()) ) { const assistantMessage = await getAssistantResponse() if (toolUseContext.abortController.signal.aborted) { return { message: null, shouldSkipPermissionCheck: false } } return { message: assistantMessage, shouldSkipPermissionCheck: false } } const [m1, m2] = await Promise.all([ getAssistantResponse(), getAssistantResponse(), ]) if (toolUseContext.abortController.signal.aborted) { return { message: null, shouldSkipPermissionCheck: false } } if (m2.isApiErrorMessage) { // If m2 is an error, we might as well return m1, even if it's also an error -- // the UI will display it as an error as it would in the non-feedback path. return { message: m1, shouldSkipPermissionCheck: false } } if (m1.isApiErrorMessage) { return { message: m2, shouldSkipPermissionCheck: false } } if (!messagePairValidForBinaryFeedback(m1, m2)) { return { message: m1, shouldSkipPermissionCheck: false } } return await getBinaryFeedbackResponse(m1, m2) } /** * The rules of thinking are lengthy and fortuitous. They require plenty of thinking * of most long duration and deep meditation for a wizard to wrap one's noggin around. * * The rules follow: * 1. A message that contains a thinking or redacted_thinking block must be part of a query whose max_thinking_length > 0 * 2. A thinking block may not be the last message in a block * 3. Thinking blocks must be preserved for the duration of an assistant trajectory (a single turn, or if that turn includes a tool_use block then also its subsequent tool_result and the following assistant message) * * Heed these rules well, young wizard. For they are the rules of thinking, and * the rules of thinking are the rules of the universe. If ye does not heed these * rules, ye will be punished with an entire day of debugging and hair pulling. */ export async function* query( messages: Message[], systemPrompt: string[], context: { [k: string]: string }, canUseTool: CanUseToolFn, toolUseContext: ExtendedToolUseContext, getBinaryFeedbackResponse?: ( m1: AssistantMessage, m2: AssistantMessage, ) => Promise, ): AsyncGenerator { const currentRequest = getCurrentRequest() markPhase('QUERY_INIT') // Auto-compact check const { messages: processedMessages, wasCompacted } = await checkAutoCompact( messages, toolUseContext, ) if (wasCompacted) { messages = processedMessages } markPhase('SYSTEM_PROMPT_BUILD') const { systemPrompt: fullSystemPrompt, reminders } = formatSystemPromptWithContext(systemPrompt, context, toolUseContext.agentId) // Emit session startup event emitReminderEvent('session:startup', { agentId: toolUseContext.agentId, messages: messages.length, timestamp: Date.now(), }) // Inject reminders into the latest user message if (reminders && messages.length > 0) { // Find the last user message for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg?.type === 'user') { const lastUserMessage = msg as UserMessage messages[i] = { ...lastUserMessage, message: { ...lastUserMessage.message, content: typeof lastUserMessage.message.content === 'string' ? reminders + lastUserMessage.message.content : [ ...(Array.isArray(lastUserMessage.message.content) ? lastUserMessage.message.content : []), { type: 'text', text: reminders }, ], }, } break } } } markPhase('LLM_PREPARATION') function getAssistantResponse() { return queryLLM( normalizeMessagesForAPI(messages), fullSystemPrompt, toolUseContext.options.maxThinkingTokens, toolUseContext.options.tools, toolUseContext.abortController.signal, { safeMode: toolUseContext.options.safeMode ?? false, model: toolUseContext.options.model || 'main', prependCLISysprompt: true, toolUseContext: toolUseContext, }, ) } const result = await queryWithBinaryFeedback( toolUseContext, getAssistantResponse, getBinaryFeedbackResponse, ) // If request was cancelled, return immediately with interrupt message if (toolUseContext.abortController.signal.aborted) { yield createAssistantMessage(INTERRUPT_MESSAGE) return } if (result.message === null) { yield createAssistantMessage(INTERRUPT_MESSAGE) return } const assistantMessage = result.message const shouldSkipPermissionCheck = result.shouldSkipPermissionCheck yield assistantMessage // @see https://docs.anthropic.com/en/docs/build-with-claude/tool-use // Note: stop_reason === 'tool_use' is unreliable -- it's not always set correctly const toolUseMessages = assistantMessage.message.content.filter( _ => _.type === 'tool_use', ) // If there's no more tool use, we're done if (!toolUseMessages.length) { return } const toolResults: UserMessage[] = [] // Simple concurrency check like original system const canRunConcurrently = toolUseMessages.every(msg => toolUseContext.options.tools.find(t => t.name === msg.name)?.isReadOnly(), ) if (canRunConcurrently) { for await (const message of runToolsConcurrently( toolUseMessages, assistantMessage, canUseTool, toolUseContext, shouldSkipPermissionCheck, )) { yield message // progress messages are not sent to the server, so don't need to be accumulated for the next turn if (message.type === 'user') { toolResults.push(message) } } } else { for await (const message of runToolsSerially( toolUseMessages, assistantMessage, canUseTool, toolUseContext, shouldSkipPermissionCheck, )) { yield message // progress messages are not sent to the server, so don't need to be accumulated for the next turn if (message.type === 'user') { toolResults.push(message) } } } if (toolUseContext.abortController.signal.aborted) { yield createAssistantMessage(INTERRUPT_MESSAGE_FOR_TOOL_USE) return } // Sort toolResults to match the order of toolUseMessages const orderedToolResults = toolResults.sort((a, b) => { const aIndex = toolUseMessages.findIndex( tu => tu.id === (a.message.content[0] as ToolUseBlock).id, ) const bIndex = toolUseMessages.findIndex( tu => tu.id === (b.message.content[0] as ToolUseBlock).id, ) return aIndex - bIndex }) // Recursive query try { yield* await query( [...messages, assistantMessage, ...orderedToolResults], systemPrompt, context, canUseTool, toolUseContext, getBinaryFeedbackResponse, ) } catch (error) { // Re-throw the error to maintain the original behavior throw error } } async function* runToolsConcurrently( toolUseMessages: ToolUseBlock[], assistantMessage: AssistantMessage, canUseTool: CanUseToolFn, toolUseContext: ExtendedToolUseContext, shouldSkipPermissionCheck?: boolean, ): AsyncGenerator { yield* all( toolUseMessages.map(toolUse => runToolUse( toolUse, new Set(toolUseMessages.map(_ => _.id)), assistantMessage, canUseTool, toolUseContext, shouldSkipPermissionCheck, ), ), MAX_TOOL_USE_CONCURRENCY, ) } async function* runToolsSerially( toolUseMessages: ToolUseBlock[], assistantMessage: AssistantMessage, canUseTool: CanUseToolFn, toolUseContext: ExtendedToolUseContext, shouldSkipPermissionCheck?: boolean, ): AsyncGenerator { for (const toolUse of toolUseMessages) { yield* runToolUse( toolUse, new Set(toolUseMessages.map(_ => _.id)), assistantMessage, canUseTool, toolUseContext, shouldSkipPermissionCheck, ) } } export async function* runToolUse( toolUse: ToolUseBlock, siblingToolUseIDs: Set, assistantMessage: AssistantMessage, canUseTool: CanUseToolFn, toolUseContext: ExtendedToolUseContext, shouldSkipPermissionCheck?: boolean, ): AsyncGenerator { const currentRequest = getCurrentRequest() // 🔍 Debug: ć·„ć…·è°ƒç”šćŒ€ć§‹ debugLogger.flow('TOOL_USE_START', { toolName: toolUse.name, toolUseID: toolUse.id, inputSize: JSON.stringify(toolUse.input).length, siblingToolCount: siblingToolUseIDs.size, shouldSkipPermissionCheck: !!shouldSkipPermissionCheck, requestId: currentRequest?.id, }) logUserFriendly( 'TOOL_EXECUTION', { toolName: toolUse.name, action: 'Starting', target: toolUse.input ? Object.keys(toolUse.input).join(', ') : '', }, currentRequest?.id, ) logEvent('tengu_tool_use_start', { toolName: toolUse.name, toolUseID: toolUse.id, }) const toolName = toolUse.name const tool = toolUseContext.options.tools.find(t => t.name === toolName) // Check if the tool exists if (!tool) { debugLogger.error('TOOL_NOT_FOUND', { requestedTool: toolName, availableTools: toolUseContext.options.tools.map(t => t.name), toolUseID: toolUse.id, requestId: currentRequest?.id, }) logEvent('tengu_tool_use_error', { error: `No such tool available: ${toolName}`, messageID: assistantMessage.message.id, toolName, toolUseID: toolUse.id, }) yield createUserMessage([ { type: 'tool_result', content: `Error: No such tool available: ${toolName}`, is_error: true, tool_use_id: toolUse.id, }, ]) return } const toolInput = toolUse.input as { [key: string]: string } debugLogger.flow('TOOL_VALIDATION_START', { toolName: tool.name, toolUseID: toolUse.id, inputKeys: Object.keys(toolInput), requestId: currentRequest?.id, }) try { // 🔧 Check for cancellation before starting tool execution if (toolUseContext.abortController.signal.aborted) { debugLogger.flow('TOOL_USE_CANCELLED_BEFORE_START', { toolName: tool.name, toolUseID: toolUse.id, abortReason: 'AbortController signal', requestId: currentRequest?.id, }) logEvent('tengu_tool_use_cancelled', { toolName: tool.name, toolUseID: toolUse.id, }) const message = createUserMessage([ createToolResultStopMessage(toolUse.id), ]) yield message return } // Track if any progress messages were yielded let hasProgressMessages = false for await (const message of checkPermissionsAndCallTool( tool, toolUse.id, siblingToolUseIDs, toolInput, toolUseContext, canUseTool, assistantMessage, shouldSkipPermissionCheck, )) { // 🔧 Check for cancellation during tool execution if (toolUseContext.abortController.signal.aborted) { debugLogger.flow('TOOL_USE_CANCELLED_DURING_EXECUTION', { toolName: tool.name, toolUseID: toolUse.id, hasProgressMessages, abortReason: 'AbortController signal during execution', requestId: currentRequest?.id, }) // If we yielded progress messages but got cancelled, yield a cancellation result if (hasProgressMessages && message.type === 'progress') { yield message // yield the last progress message first } // Always yield a tool result message for cancellation to clear UI state const cancelMessage = createUserMessage([ createToolResultStopMessage(toolUse.id), ]) yield cancelMessage return } if (message.type === 'progress') { hasProgressMessages = true } yield message } } catch (e) { logError(e) // 🔧 Even on error, ensure we yield a tool result to clear UI state const errorMessage = createUserMessage([ { type: 'tool_result', content: `Tool execution failed: ${e instanceof Error ? e.message : String(e)}`, is_error: true, tool_use_id: toolUse.id, }, ]) yield errorMessage } } // TODO: Generalize this to all tools export function normalizeToolInput( tool: Tool, input: { [key: string]: boolean | string | number }, ): { [key: string]: boolean | string | number } { switch (tool) { case BashTool: { const { command, timeout } = BashTool.inputSchema.parse(input) // already validated upstream, won't throw return { command: command.replace(`cd ${getCwd()} && `, ''), ...(timeout ? { timeout } : {}), } } default: return input } } async function* checkPermissionsAndCallTool( tool: Tool, toolUseID: string, siblingToolUseIDs: Set, input: { [key: string]: boolean | string | number }, context: ToolUseContext, canUseTool: CanUseToolFn, assistantMessage: AssistantMessage, shouldSkipPermissionCheck?: boolean, ): AsyncGenerator { // Validate input types with zod // (surprisingly, the model is not great at generating valid input) const isValidInput = tool.inputSchema.safeParse(input) if (!isValidInput.success) { // Create a more helpful error message for common cases let errorMessage = `InputValidationError: ${isValidInput.error.message}` // Special handling for the "View" tool (FileReadTool) being called with empty parameters if (tool.name === 'View' && Object.keys(input).length === 0) { errorMessage = `Error: The View tool requires a 'file_path' parameter to specify which file to read. Please provide the absolute path to the file you want to view. For example: {"file_path": "/path/to/file.txt"}` } logEvent('tengu_tool_use_error', { error: errorMessage, messageID: assistantMessage.message.id, toolName: tool.name, toolInput: JSON.stringify(input).slice(0, 200), }) yield createUserMessage([ { type: 'tool_result', content: errorMessage, is_error: true, tool_use_id: toolUseID, }, ]) return } const normalizedInput = normalizeToolInput(tool, input) // Validate input values. Each tool has its own validation logic const isValidCall = await tool.validateInput?.( normalizedInput as never, context, ) if (isValidCall?.result === false) { logEvent('tengu_tool_use_error', { error: isValidCall?.message.slice(0, 2000), messageID: assistantMessage.message.id, toolName: tool.name, toolInput: JSON.stringify(input).slice(0, 200), ...(isValidCall?.meta ?? {}), }) yield createUserMessage([ { type: 'tool_result', content: isValidCall!.message, is_error: true, tool_use_id: toolUseID, }, ]) return } // Check whether we have permission to use the tool, // and ask the user for permission if we don't const permissionResult = shouldSkipPermissionCheck ? ({ result: true } as const) : await canUseTool(tool, normalizedInput, context, assistantMessage) if (permissionResult.result === false) { yield createUserMessage([ { type: 'tool_result', content: permissionResult.message, is_error: true, tool_use_id: toolUseID, }, ]) return } // Call the tool try { const generator = tool.call(normalizedInput as never, context) for await (const result of generator) { switch (result.type) { case 'result': logEvent('tengu_tool_use_success', { messageID: assistantMessage.message.id, toolName: tool.name, }) yield createUserMessage( [ { type: 'tool_result', content: result.resultForAssistant || String(result.data), tool_use_id: toolUseID, }, ], { data: result.data, resultForAssistant: result.resultForAssistant || String(result.data), }, ) return case 'progress': logEvent('tengu_tool_use_progress', { messageID: assistantMessage.message.id, toolName: tool.name, }) yield createProgressMessage( toolUseID, siblingToolUseIDs, result.content, result.normalizedMessages || [], result.tools || [], ) break } } } catch (error) { const content = formatError(error) logError(error) logEvent('tengu_tool_use_error', { error: content.slice(0, 2000), messageID: assistantMessage.message.id, toolName: tool.name, toolInput: JSON.stringify(input).slice(0, 1000), }) yield createUserMessage([ { type: 'tool_result', content, is_error: true, tool_use_id: toolUseID, }, ]) } } function formatError(error: unknown): string { if (!(error instanceof Error)) { return String(error) } const parts = [error.message] if ('stderr' in error && typeof error.stderr === 'string') { parts.push(error.stderr) } if ('stdout' in error && typeof error.stdout === 'string') { parts.push(error.stdout) } const fullMessage = parts.filter(Boolean).join('\n') if (fullMessage.length <= 10000) { return fullMessage } const halfLength = 5000 const start = fullMessage.slice(0, halfLength) const end = fullMessage.slice(-halfLength) return `${start}\n\n... [${fullMessage.length - 10000} characters truncated] ...\n\n${end}` }