| |
| |
| |
|
|
| import type { |
| ProviderAdapter, |
| ToolExecutor, |
| ContextManager, |
| ProgressReporter, |
| CostTracker, |
| AgentLoopConfig, |
| AgentLoopResult, |
| ParsedResponse, |
| ToolResult, |
| ToolCall, |
| ToolExecContext, |
| ContentBlock, |
| } from './types'; |
|
|
| |
|
|
| |
| const HARMONY_TOKEN_RE = /<\|[^|]*\|>/; |
|
|
| |
| |
| |
| |
| function detectMalformedToolCalls(content: string): boolean { |
| if (!content) return false; |
|
|
| const patterns = [ |
| /```(?:shell|bash|sh)\s*\n[\s\S]*?\n```/, |
| /^(?:bash|shell)\s*\{\s*["']?(?:command|cmd)["']?\s*:/m, |
| /^(?:bash|shell)\s*\[\s*["']/m, |
| /```json\s*\n\s*\{\s*["']?(?:command|cmd)["']?\s*:/, |
| ]; |
|
|
| const hasPattern = patterns.some(p => p.test(content)); |
| if (!hasPattern) return false; |
|
|
| const trimmed = content.trim(); |
| if (trimmed.length < 200) return true; |
|
|
| const endsWithToolPattern = /(?:bash|shell)\s*\{\s*["']?(?:command|cmd)["']?\s*:.*\}\s*$/.test(trimmed) || |
| /```(?:shell|bash|sh)\s*\n[\s\S]*?\n```\s*$/.test(trimmed); |
| return endsWithToolPattern; |
| } |
|
|
| |
| |
| |
| function extractToolCallsFromText(content: string): ToolCall[] | undefined { |
| if (!content) return undefined; |
|
|
| const commands: string[] = []; |
| let match; |
|
|
| |
| const bashBlockRe = /```(?:bash|shell|sh)\s*\n([\s\S]*?)\n```/g; |
| while ((match = bashBlockRe.exec(content)) !== null) { |
| const block = match[1].trim(); |
| if (block) commands.push(block); |
| } |
|
|
| |
| const toolCodeRe = /```tool_code\s*\n([\s\S]*?)\n```/g; |
| while ((match = toolCodeRe.exec(content)) !== null) { |
| const block = match[1].trim(); |
| const runCmdMatch = block.match(/(?:bash|shell)\.run_command\(["']([\s\S]*?)["']\)/); |
| if (runCmdMatch) { |
| commands.push(runCmdMatch[1].replace(/\\"/g, '"')); |
| } |
| } |
|
|
| |
| const toolJsonRe = /(?:bash|shell)\s*\(?\s*\{\s*["']?(?:command|cmd)["']?\s*:\s*["']([\s\S]*?)["']\s*\}\s*\)?/g; |
| while ((match = toolJsonRe.exec(content)) !== null) { |
| if (match[1].trim()) commands.push(match[1].trim()); |
| } |
|
|
| if (commands.length === 0) return undefined; |
|
|
| return commands.map((cmd, i) => ({ |
| id: `text-tool-${Date.now()}-${i}`, |
| type: 'function' as const, |
| function: { |
| name: 'bash', |
| arguments: JSON.stringify({ command: cmd }), |
| }, |
| })); |
| } |
|
|
| |
| |
| |
| function getToolCallSignature(toolCall: ToolCall): string { |
| const toolName = toolCall.function?.name || 'unknown'; |
| try { |
| const args = JSON.parse(toolCall.function.arguments); |
| if (toolName === 'bash' || toolName === 'shell') { |
| const rawCmd = args.command ?? args.cmd; |
| const cmd = Array.isArray(rawCmd) |
| ? rawCmd.join(' ') |
| : String(rawCmd || ''); |
| return `${toolName}:${cmd}`; |
| } |
| return `${toolName}:${toolCall.function.arguments}`; |
| } catch { |
| return `${toolName}:${toolCall.function.arguments}`; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function detectRepeatingPattern(signatures: string[], threshold: number): number | null { |
| const len = signatures.length; |
| for (let cycleLen = 2; cycleLen <= 4; cycleLen++) { |
| if (len < cycleLen * threshold) continue; |
| const checkLen = cycleLen * threshold; |
| const tail = signatures.slice(len - checkLen); |
| const cycle = tail.slice(0, cycleLen); |
| let isRepeating = true; |
| for (let i = cycleLen; i < checkLen; i++) { |
| if (tail[i] !== cycle[i % cycleLen]) { |
| isRepeating = false; |
| break; |
| } |
| } |
| if (isRepeating) return cycleLen; |
| } |
| return null; |
| } |
|
|
| |
|
|
| const MALFORMED_TOOL_CALL_ERROR = `⛔ CRITICAL ERROR: You wrote a tool call as TEXT instead of invoking it. |
| |
| This is WRONG - you wrote text like: |
| bash{"command": "..."} |
| \`\`\`bash |
| command |
| \`\`\` |
| |
| This is RIGHT - invoke tools directly via function calling: |
| Call bash tool with parameter command="your command" |
| |
| You MUST use function calling. DO NOT write tool syntax as text. |
| STOP writing text. START invoking tools. Try again NOW.`; |
|
|
| const MALFORMED_TOOL_CALL_PERSISTENT_REMINDER = ` |
| |
| ⚠️ REMINDER: You have been writing tool calls as text instead of invoking them. |
| EVERY time you want to use a tool, you MUST invoke it via function calling. |
| DO NOT write bash{"command":...} as text - INVOKE the tools directly.`; |
|
|
| const wrongToolNameError = (name: string) => |
| `⛔ "${name}" is not a tool. The ONLY tool is \`bash\` — run every command through it: bash({ command: "${name} ..." }). Re-issue your last action as a single bash tool call.`; |
|
|
| const NUDGE_MESSAGE = 'Before finishing, run the status command:\n status --task "..." --done "..." --remaining "..." --complete'; |
|
|
| const TOOL_ERROR_RETRY_MESSAGE = 'Your previous command failed (likely a streaming issue). Continue your work — retry writing the file. If the file is large, split it into multiple smaller cat commands.'; |
|
|
| const REASONING_ONLY_RETRY_MESSAGE = 'Your previous response contained only reasoning — no tool call and no user-visible text reached the conversation. Do not stop after thinking: invoke the bash tool via function calling to act (e.g. command="ls /"), or reply with your answer as plain text.'; |
|
|
| const MAX_MALFORMED_RETRIES = 2; |
| const MAX_REASONING_ONLY_RETRIES = 2; |
|
|
| |
| |
| |
| const RESULT_DEDUP_MIN_CHARS = 500; |
|
|
| const RESULT_DEDUP_MARKER = '(Output identical to a previous tool result above — not repeated to save context.)'; |
|
|
| const MALFORMED_THRESHOLD_FOR_REMINDER = 3; |
| const PATTERN_REPEAT_THRESHOLD = 2; |
| const PATTERN_WINDOW_SIZE = 8; |
|
|
| |
| |
| |
| |
| |
| |
| function harnessMessage(text: string): string { |
| return `<automated_reminder>\n${text}\n</automated_reminder>`; |
| } |
|
|
| |
|
|
| interface StatusResult { |
| task: string; |
| done: string; |
| remaining: string; |
| complete: boolean; |
| hasExplicitFlag: boolean; |
| } |
|
|
| |
|
|
| export class AgentLoop { |
| private stopped = false; |
| private abortController = new AbortController(); |
| private turnCount = 0; |
| private toolCallCount = 0; |
| private nudgeCount = 0; |
| private malformedToolCallRetries = 0; |
| private totalMalformedToolCalls = 0; |
| private reasoningOnlyRetries = 0; |
| private lastToolCallSignature: string | null = null; |
| private duplicateToolCallCount = 0; |
| private recentToolSignatures: string[] = []; |
| private lastIterationHadToolError = false; |
| private lastStatusResult: StatusResult | null = null; |
| |
| |
| |
| private lastPromptTokens = 0; |
|
|
| private config: AgentLoopConfig; |
| private provider: ProviderAdapter; |
| private executor: ToolExecutor; |
| private context: ContextManager; |
| private progress: ProgressReporter; |
| private cost: CostTracker; |
|
|
| constructor(deps: { |
| config: AgentLoopConfig; |
| provider: ProviderAdapter; |
| executor: ToolExecutor; |
| context: ContextManager; |
| progress: ProgressReporter; |
| cost: CostTracker; |
| }) { |
| this.config = deps.config; |
| this.provider = deps.provider; |
| this.executor = deps.executor; |
| this.context = deps.context; |
| this.progress = deps.progress; |
| this.cost = deps.cost; |
| } |
|
|
| stop(): void { |
| this.stopped = true; |
| this.abortController.abort(); |
| } |
|
|
| async run(userPrompt: string | ContentBlock[]): Promise<AgentLoopResult> { |
| this.context.addUserMessage(userPrompt); |
| this.cost.resetTurn(); |
|
|
| let exitReason = ''; |
|
|
| for (let iteration = 0; iteration < this.config.maxIterations; iteration++) { |
| if (this.stopped) { |
| exitReason = 'stopped'; |
| this.progress.onEvent('stopped', { reason: 'user' }); |
| break; |
| } |
|
|
| this.progress.onEvent('iteration', { |
| current: iteration + 1, |
| max: this.config.maxIterations, |
| agent: this.config.agentType, |
| }); |
| this.progress.onEvent('waiting', {}); |
|
|
| |
| let response: ParsedResponse; |
| try { |
| response = await this.provider.call({ |
| messages: this.context.getSanitizedMessages(), |
| tools: this.executor.getDefinitions(this.config.agentType), |
| signal: this.abortController.signal, |
| }); |
| } catch (error) { |
| if (this.stopped) { |
| exitReason = 'stopped'; |
| this.progress.onEvent('stopped', { reason: 'user' }); |
| break; |
| } |
| if (this.config.onPausableError && error instanceof Error) { |
| const action = await this.config.onPausableError(error); |
| if (action === 'stop') { |
| exitReason = 'error_stop'; |
| break; |
| } |
| |
| this.context.addUserMessage(harnessMessage(`⚠️ ${error.message}\n\nPlease try a different approach.`)); |
| continue; |
| } |
| throw error; |
| } |
|
|
| this.turnCount++; |
|
|
| |
| if (response.usage) { |
| this.cost.record(response.usage, this.provider.getProvider(), this.provider.getModel()); |
| if (response.usage.promptTokens) this.lastPromptTokens = response.usage.promptTokens; |
| } |
|
|
| |
| |
| |
| if (response.invalidToolName) { |
| this.malformedToolCallRetries++; |
| this.totalMalformedToolCalls++; |
| if (this.malformedToolCallRetries <= MAX_MALFORMED_RETRIES) { |
| |
| if (response.reasoningDetails?.length || (response.content && response.content.trim())) { |
| this.context.addAssistantTurn({ content: response.content, reasoningDetails: response.reasoningDetails }); |
| } |
| this.context.addUserMessage(harnessMessage(wrongToolNameError(response.invalidToolName))); |
| this.progress.onEvent('malformed_tool_call', { |
| retry: this.malformedToolCallRetries, |
| maxRetries: MAX_MALFORMED_RETRIES, |
| totalFailures: this.totalMalformedToolCalls, |
| invalidToolName: response.invalidToolName, |
| }); |
| continue; |
| } |
| |
| |
| } |
|
|
| |
| if (response.toolCalls && response.toolCalls.length > 0) { |
| response.toolCalls = response.toolCalls.filter(tc => { |
| const rawName = tc.function?.name || ''; |
| return !HARMONY_TOKEN_RE.test(rawName); |
| }); |
| if (response.toolCalls.length === 0) { |
| response.toolCalls = undefined; |
| } |
| } |
|
|
| |
| if (!this.provider.supportsTools() && response.content && (!response.toolCalls || response.toolCalls.length === 0)) { |
| const extracted = extractToolCallsFromText(response.content); |
| if (extracted && extracted.length > 0) { |
| response.toolCalls = extracted; |
| } |
| } |
|
|
| |
| if (this.provider.supportsTools() && response.content && (!response.toolCalls || response.toolCalls.length === 0)) { |
| if (detectMalformedToolCalls(response.content)) { |
| this.malformedToolCallRetries++; |
| this.totalMalformedToolCalls++; |
|
|
| if (this.malformedToolCallRetries <= MAX_MALFORMED_RETRIES) { |
| this.context.addAssistantTurn({ content: response.content }); |
| let errorMessage = MALFORMED_TOOL_CALL_ERROR; |
| if (this.totalMalformedToolCalls >= MALFORMED_THRESHOLD_FOR_REMINDER) { |
| errorMessage += MALFORMED_TOOL_CALL_PERSISTENT_REMINDER; |
| } |
| this.context.addUserMessage(harnessMessage(errorMessage)); |
| this.progress.onEvent('malformed_tool_call', { |
| retry: this.malformedToolCallRetries, |
| maxRetries: MAX_MALFORMED_RETRIES, |
| totalFailures: this.totalMalformedToolCalls, |
| }); |
| continue; |
| } |
| |
| } |
| } else if (response.toolCalls && response.toolCalls.length > 0) { |
| this.malformedToolCallRetries = 0; |
| this.reasoningOnlyRetries = 0; |
| } |
|
|
| |
| if (!response.toolCalls || response.toolCalls.length === 0) { |
| const hasContent = !!(response.content && response.content.trim()); |
| const hasReasoning = !!response.reasoningDetails?.length; |
|
|
| |
| if (this.config.agentType === 'explore' || this.config.agentType === 'plan' || this.config.agentType === 'setup') { |
| if (hasContent) { |
| this.context.addAssistantTurn({ content: response.content, reasoningDetails: response.reasoningDetails }); |
| } |
| exitReason = 'agent_type_exit'; |
| break; |
| } |
|
|
| |
| |
| |
| if (hasContent || hasReasoning) { |
| this.context.addAssistantTurn({ content: response.content, reasoningDetails: response.reasoningDetails }); |
| } |
|
|
| |
| if (this.lastStatusResult) { |
| if (this.lastStatusResult.complete) { |
| const gateResult = await this.runCompletionGate(); |
| if (gateResult) { |
| this.context.addUserMessage(harnessMessage(gateResult)); |
| this.lastStatusResult = null; |
| continue; |
| } |
| exitReason = 'status_complete'; |
| this.progress.onEvent('exit_reason', { reason: 'status_complete', iteration }); |
| break; |
| } else if (this.lastStatusResult.hasExplicitFlag) { |
| |
| this.lastStatusResult = null; |
| this.nudgeCount = 0; |
| continue; |
| } else { |
| |
| const rem = this.lastStatusResult.remaining.trim().toLowerCase(); |
| if (!rem || rem === 'none' || rem === 'n/a' || rem === 'nothing') { |
| const gateResult = await this.runCompletionGate(); |
| if (gateResult) { |
| this.context.addUserMessage(harnessMessage(gateResult)); |
| this.lastStatusResult = null; |
| continue; |
| } |
| exitReason = 'status_remaining_empty'; |
| this.progress.onEvent('exit_reason', { reason: 'status_remaining_empty', iteration }); |
| break; |
| } else { |
| this.lastStatusResult = null; |
| this.nudgeCount = 0; |
| continue; |
| } |
| } |
| } |
|
|
| |
| |
| if (this.config.agentType === 'interview' && hasContent) { |
| exitReason = 'awaiting_user'; |
| this.progress.onEvent('exit_reason', { reason: 'awaiting_user', iteration }); |
| break; |
| } |
|
|
| |
| if (this.lastIterationHadToolError && !hasContent) { |
| this.lastIterationHadToolError = false; |
| this.context.addUserMessage(harnessMessage(TOOL_ERROR_RETRY_MESSAGE)); |
| this.progress.onEvent('tool_error_retry', { iteration }); |
| continue; |
| } |
|
|
| |
| |
| |
| if (!hasContent && hasReasoning && this.reasoningOnlyRetries < MAX_REASONING_ONLY_RETRIES) { |
| this.reasoningOnlyRetries++; |
| this.progress.onEvent('reasoning_only_retry', { |
| attempt: this.reasoningOnlyRetries, |
| max: MAX_REASONING_ONLY_RETRIES, |
| iteration, |
| }); |
| this.context.addUserMessage(harnessMessage(REASONING_ONLY_RETRY_MESSAGE)); |
| continue; |
| } |
|
|
| |
| if (this.nudgeCount < this.config.maxNudges) { |
| this.nudgeCount++; |
| this.progress.onEvent('nudge', { attempt: this.nudgeCount, max: this.config.maxNudges }); |
| this.context.addUserMessage(harnessMessage(NUDGE_MESSAGE)); |
| continue; |
| } |
|
|
| |
| exitReason = 'nudge_exhaustion'; |
| this.progress.onEvent('exit_reason', { reason: 'nudge_exhaustion', nudges: this.config.maxNudges, iteration }); |
| break; |
| } |
|
|
| |
| const { results: toolResults, terminated } = await this.executeToolCalls(response.toolCalls); |
|
|
| |
| |
| |
| this.context.addAssistantTurn(response); |
| this.context.addToolResults(toolResults); |
|
|
| |
| if (terminated) { |
| exitReason = 'loop_detected'; |
| break; |
| } |
|
|
| |
| this.lastIterationHadToolError = toolResults.some(r => !r.success); |
|
|
| |
| const promptTokens = this.lastPromptTokens || this.context.getTokenEstimate(); |
| if (this.context.needsCompaction(promptTokens)) { |
| const preTokens = promptTokens; |
| const compactionUsage = await this.context.compact(this.provider, { signal: this.abortController.signal }); |
| if (compactionUsage) { |
| this.cost.record(compactionUsage, this.provider.getProvider(), this.provider.getModel()); |
| } |
| const postEstimate = this.context.getTokenEstimate(); |
| this.progress.onEvent('compaction', { preCompactTokens: preTokens, postCompactEstimate: postEstimate }); |
| } |
|
|
| |
| let shouldBreak = false; |
| for (const result of toolResults) { |
| if (!result.signals) continue; |
|
|
| if (result.signals.statusComplete) { |
| this.lastStatusResult = result.signals.statusResult as StatusResult || { |
| task: '', done: '', remaining: 'none', complete: true, hasExplicitFlag: true, |
| }; |
| } |
| if (result.signals.statusResult) { |
| this.lastStatusResult = result.signals.statusResult as StatusResult; |
| } |
| if (result.signals.setupComplete) { |
| exitReason = 'setup_complete'; |
| shouldBreak = true; |
| } |
| if (result.signals.awaitingUser) { |
| exitReason = 'awaiting_user'; |
| shouldBreak = true; |
| } |
| } |
|
|
| if (shouldBreak) break; |
|
|
| |
| if (this.lastStatusResult?.complete) { |
| const gateResult = await this.runCompletionGate(); |
| if (gateResult) { |
| this.context.addUserMessage(harnessMessage(gateResult)); |
| this.lastStatusResult = null; |
| continue; |
| } |
| exitReason = 'status_complete_post_tool'; |
| this.progress.onEvent('exit_reason', { reason: 'status_complete_post_tool', iteration }); |
| break; |
| } |
| } |
|
|
| |
| if (!exitReason) { |
| exitReason = 'max_iterations'; |
| this.progress.onEvent('exit_reason', { reason: 'max_iterations', maxIterations: this.config.maxIterations }); |
| } |
|
|
| const success = exitReason === 'status_complete' || |
| exitReason === 'status_complete_post_tool' || |
| exitReason === 'status_remaining_empty' || |
| exitReason === 'agent_type_exit' || |
| exitReason === 'setup_complete' || |
| exitReason === 'awaiting_user'; |
|
|
| const summary = this.buildSummary(exitReason); |
|
|
| return { |
| success, |
| summary, |
| exitReason, |
| totalCost: this.cost.getTotalCost(), |
| totalUsage: this.cost.getTotalUsage(), |
| toolCount: this.toolCallCount, |
| turnCount: this.turnCount, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| private async executeToolCalls(toolCalls: ToolCall[]): Promise<{ results: ToolResult[]; terminated: boolean }> { |
| const results: ToolResult[] = []; |
| const execContext: ToolExecContext = { |
| agentType: this.config.agentType, |
| isReadOnly: this.config.isReadOnly, |
| turnId: this.turnCount, |
| }; |
|
|
| for (const toolCall of toolCalls) { |
| if (this.stopped) break; |
|
|
| |
| const currentSignature = getToolCallSignature(toolCall); |
|
|
| if (this.lastToolCallSignature === currentSignature) { |
| this.duplicateToolCallCount++; |
|
|
| |
| results.push({ |
| tool_call_id: toolCall.id, |
| content: `Loop detected: Duplicate tool call #${this.duplicateToolCallCount}. Please try a different approach.`, |
| success: false, |
| }); |
|
|
| this.progress.onEvent('tool_status', { |
| toolIndex: results.length - 1, |
| status: 'failed', |
| error: `Loop detected - duplicate tool call #${this.duplicateToolCallCount}`, |
| }); |
|
|
| |
| if (this.duplicateToolCallCount >= this.config.maxDuplicateToolCalls) { |
| this.progress.onEvent('exit_reason', { reason: 'loop_detected', duplicates: this.duplicateToolCallCount }); |
| return { results, terminated: true }; |
| } |
|
|
| continue; |
| } |
|
|
| |
| this.duplicateToolCallCount = 0; |
| this.lastToolCallSignature = currentSignature; |
|
|
| |
| this.recentToolSignatures.push(currentSignature); |
| if (this.recentToolSignatures.length > PATTERN_WINDOW_SIZE) { |
| this.recentToolSignatures.shift(); |
| } |
| if (this.recentToolSignatures.length === PATTERN_WINDOW_SIZE) { |
| const repeating = detectRepeatingPattern(this.recentToolSignatures, PATTERN_REPEAT_THRESHOLD); |
| if (repeating) { |
| this.progress.onEvent('exit_reason', { reason: 'pattern_detected', cycleLength: repeating }); |
| return { results, terminated: true }; |
| } |
| } |
|
|
| |
| try { |
| const result = await this.executor.execute(toolCall, execContext); |
| results.push(this.dedupRepeatedResult(result, results)); |
| this.toolCallCount++; |
| } catch (error) { |
| const errorMessage = error instanceof Error ? error.message : String(error); |
| results.push({ |
| tool_call_id: toolCall.id, |
| content: `Error: ${errorMessage}`, |
| success: false, |
| }); |
| this.progress.onEvent('tool_status', { |
| toolIndex: results.length - 1, |
| toolName: toolCall.function?.name, |
| status: 'failed', |
| error: errorMessage, |
| }); |
| } |
| } |
|
|
| return { results, terminated: false }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private dedupRepeatedResult(result: ToolResult, currentBatch: ToolResult[]): ToolResult { |
| if (!result.success || result.content.length < RESULT_DEDUP_MIN_CHARS) return result; |
|
|
| const seenInContext = this.context.getMessages().some( |
| m => m.role === 'tool' && typeof m.content === 'string' && m.content === result.content |
| ); |
| const seenInBatch = currentBatch.some(r => r.content === result.content); |
| if (!seenInContext && !seenInBatch) return result; |
|
|
| return { ...result, content: RESULT_DEDUP_MARKER }; |
| } |
|
|
| |
| |
| |
| |
| private async runCompletionGate(): Promise<string | null> { |
| if (!this.config.completionGate) return null; |
| return await this.config.completionGate(); |
| } |
|
|
| private buildSummary(exitReason: string): string { |
| switch (exitReason) { |
| case 'status_complete': |
| case 'status_complete_post_tool': |
| return 'Completed successfully (status --complete)'; |
| case 'status_remaining_empty': |
| return 'Completed (no remaining work)'; |
| case 'agent_type_exit': |
| return `Completed (${this.config.agentType} agent finished)`; |
| case 'setup_complete': |
| return 'Setup complete'; |
| case 'awaiting_user': |
| return 'Paused awaiting user input'; |
| case 'stopped': |
| return 'Stopped by user'; |
| case 'error_stop': |
| return 'Stopped due to error'; |
| case 'nudge_exhaustion': |
| return `Exited after ${this.config.maxNudges} nudge attempts without status`; |
| case 'loop_detected': |
| return 'Terminated due to tool call loop detection'; |
| case 'max_iterations': |
| return `Reached maximum iterations (${this.config.maxIterations})`; |
| default: |
| return `Exited: ${exitReason}`; |
| } |
| } |
| } |
|
|
| |
| export { detectMalformedToolCalls, extractToolCallsFromText, getToolCallSignature, detectRepeatingPattern }; |
|
|