/** * OswsToolExecutor - Handles individual tool call execution with signal extraction. * * Responsibilities: * - Sanitize tool names (strip harmony tokens) * - Validate tool access per agent type * - Dispatch to toolRegistry * - Extract signals from results (statusComplete, statusResult, setupComplete, awaitingUser) * - Emit progress events (tool_status, tool_result) * - Provide onAfterExecute hook for facade checkpoint logic */ import type { ToolExecutor, ToolCall, ToolResult, ToolExecContext, ToolDef, ProgressReporter } from './core/types'; import { toolRegistry, ToolExecutionContext } from './tool-registry'; import { Agent } from './agent'; import type { ApprovalRequest, ApprovalOutcome, PermissionMode, GateDecision } from './permissions'; const HARMONY_TOKEN_STRIP_RE = /<\|[^|]*\|>/g; // Matches `status` whether standalone or chained (e.g. `build && status --complete`), // so completion is detected in compound commands. const STATUS_SEGMENT_RE = /(?:^|&&|\|\||;|\|)\s*status\b/i; export interface OswsToolExecutorConfig { projectId: string; progress: ProgressReporter; getAgent: () => Agent; chatMode: boolean; abortSignal: AbortSignal; generateImage?: (prompt: string, opts: { aspectRatio?: string; imageSize?: string }) => Promise<{ base64: string; mimeType: string }>; onApprovalNeeded?: (req: ApprovalRequest) => Promise; permissionMode?: PermissionMode; permissionOverrides?: Record; } export class OswsToolExecutor implements ToolExecutor { onAfterExecute?: (toolCall: ToolCall, result: ToolResult, durationMs: number) => Promise; constructor(private config: OswsToolExecutorConfig) {} getDefinitions(agentType: string): ToolDef[] { const agent = this.config.getAgent(); const defs = toolRegistry.getDefinitions(agent.tools, agentType); return defs.map(d => ({ name: d.name, description: d.description, parameters: d.parameters as Record, })); } async execute(toolCall: ToolCall, context: ToolExecContext): Promise { const startedAt = Date.now(); // 1. Sanitize tool name (strip <|...|> harmony tokens) const rawName = toolCall.function?.name; const toolId = rawName?.replace(HARMONY_TOKEN_STRIP_RE, '').trim(); if (!toolId) { return { tool_call_id: toolCall.id, content: 'Error: Tool call has no function name. Available tools: bash.', success: false, }; } // 2. Validate tool access const agent = this.config.getAgent(); if (!agent.hasTool(toolId)) { const errorMsg = this.buildToolAccessError(toolId, agent.type); this.config.progress.onEvent('tool_status', { toolCallId: toolCall.id, toolName: toolId, status: 'failed', args: toolCall.function?.arguments, }); return { tool_call_id: toolCall.id, content: errorMsg, success: false }; } // 3. Emit executing status this.config.progress.onEvent('tool_status', { toolCallId: toolCall.id, toolName: toolId, status: 'executing', args: toolCall.function.arguments, }); // 4. Dispatch to registry with signal/ask/setup detection let setupComplete = false; let awaitingUser = false; const execContext: ToolExecutionContext = { agentType: context.agentType, isReadOnly: context.isReadOnly || this.config.chatMode, writeScope: agent.writeScope, onProgress: (event, data) => { if (event === 'ask') awaitingUser = true; if (event === 'project_ready') setupComplete = true; this.config.progress.onEvent(event, data); }, generateImage: this.config.generateImage, onApprovalNeeded: this.config.onApprovalNeeded, permissionMode: this.config.permissionMode, permissionOverrides: this.config.permissionOverrides, }; try { const resultContent = await Promise.race([ toolRegistry.execute(toolCall, this.config.projectId, execContext), new Promise((_, reject) => { if (this.config.abortSignal.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } this.config.abortSignal.addEventListener( 'abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true } ); }), ]); const isError = resultContent.startsWith('Error:'); const signals = this.extractSignals(toolCall, resultContent, setupComplete, awaitingUser); const result: ToolResult = { tool_call_id: toolCall.id, content: resultContent, success: !isError, ...(Object.keys(signals).length > 0 && { signals }), }; this.config.progress.onEvent('tool_status', { toolCallId: toolCall.id, toolName: toolId, status: isError ? 'failed' : 'completed', result: resultContent, ...(isError && { error: resultContent }), }); this.config.progress.onEvent('tool_result', { toolCallId: toolCall.id, result: resultContent }); if (this.onAfterExecute) await this.onAfterExecute(toolCall, result, Date.now() - startedAt); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const result: ToolResult = { tool_call_id: toolCall.id, content: `Error: ${errorMessage}`, success: false, }; this.config.progress.onEvent('tool_status', { toolCallId: toolCall.id, toolName: toolId, status: 'failed', error: errorMessage, }); if (this.onAfterExecute) await this.onAfterExecute(toolCall, result, Date.now() - startedAt); return result; } } /** * Extract signals from a tool call result. Inspects the command and output * to detect status completions, setup events, and user-awaiting states. */ private extractSignals( toolCall: ToolCall, output: string, setupComplete: boolean, awaitingUser: boolean ): Record { const signals: Record = {}; if (setupComplete) signals.setupComplete = true; if (awaitingUser) signals.awaitingUser = true; // Parse the command from tool call arguments let cmd = ''; try { const args = JSON.parse(toolCall.function.arguments); cmd = typeof (args.command ?? args.cmd) === 'string' ? (args.command ?? args.cmd) : ''; } catch { return signals; } // Detect status commands (also when chained after another command) if (STATUS_SEGMENT_RE.test(cmd)) { // Simple complete detection: command contains "complete" flag but not "--incomplete" if (/--complete\b/i.test(cmd) && !/--incomplete\b/i.test(cmd)) { signals.statusComplete = true; } // Also detect "status complete ..." shorthand if (/^\s*status\s+complete\b/i.test(cmd)) { signals.statusComplete = true; } // Full status result extraction const statusResult = this.extractStatusResult(cmd, output); if (statusResult) { signals.statusResult = statusResult; if (statusResult.complete) signals.statusComplete = true; } } return signals; } /** * Extract structured status result from a shell command or its output. * Detects: `status --task "..." --done "..." --remaining "..." --complete` */ private extractStatusResult( cmd: string, output: string ): { task: string; done: string; remaining: string; complete: boolean; hasExplicitFlag: boolean } | null { // If the output contains an error, the status command failed - don't trust command-level parsing const hasError = output && /^Error:\s/im.test(output); // 1. Check shell output for Remaining:/Complete: lines (from cli-shell status handler) // Prefer output over command parsing — the shell parsed the arguments // properly (escaped quotes etc.), the command regexes below cannot. // Task:/Done: lines only exist in the legacy full-echo format. if (output) { const taskLine = output.match(/^Task:\s*(.+)/im); const doneLine = output.match(/^Done:\s*(.+)/im); const remainingLine = output.match(/^Remaining:\s*(.*)/im); const completeLine = output.match(/^Complete:\s*(yes|no)/im); if (completeLine) { return { task: taskLine ? taskLine[1].trim() : '', done: doneLine ? doneLine[1].trim() : '', remaining: remainingLine ? remainingLine[1].trim() : 'none', complete: completeLine[1].toLowerCase() === 'yes', hasExplicitFlag: true, }; } } // 2. Fallback: check the command itself for `status --task ... --done ... --remaining ...` // Skip if the output had errors - the command may have been malformed if (!hasError && STATUS_SEGMENT_RE.test(cmd)) { const taskMatch = cmd.match(/--task\s+"([^"]*)"/) || cmd.match(/--task\s+'([^']*)'/) || cmd.match(/--task\s+(\S+)/); const doneMatch = cmd.match(/--done\s+"([^"]*)"/) || cmd.match(/--done\s+'([^']*)'/) || cmd.match(/--done\s+(\S+)/); const remainingMatch = cmd.match(/--remaining\s+"([^"]*)"/) || cmd.match(/--remaining\s+'([^']*)'/) || cmd.match(/--remaining\s+(\S+)/); const hasComplete = /--complete\b/.test(cmd); const hasIncomplete = /--incomplete\b/.test(cmd); if (taskMatch && doneMatch) { return { task: taskMatch[1], done: doneMatch[1], remaining: remainingMatch ? remainingMatch[1] : 'none', complete: hasComplete && !hasIncomplete, hasExplicitFlag: hasComplete || hasIncomplete, }; } } return null; } /** * Build a helpful error message when a tool is not accessible to the current agent. * Guides the model to use the correct tool invocation pattern. */ private buildToolAccessError(toolId: string, agentType: string): string { const knownBashCommands = new Set([ 'ls', 'tree', 'cat', 'head', 'tail', 'rg', 'grep', 'find', 'mkdir', 'touch', 'rm', 'mv', 'cp', 'echo', 'sed', 'ss', 'wc', 'sort', 'uniq', 'tr', 'curl', 'sqlite3', 'python', 'python3', 'lua', 'preview', 'build', 'status', 'agent', 'delegate', 'runtime', 'ask', 'generate-image', ]); const setupOnlyCommands = new Set(['brief', 'spec', 'propose-create']); const isSetupCommand = setupOnlyCommands.has(toolId); const isBashCommand = knownBashCommands.has(toolId) || (isSetupCommand && agentType === 'setup'); if (toolId === 'ss') { return `Error: "ss" is not a tool — it is a bash command. Call it via the bash tool:\n\n bash({ command: "ss /file << 'EOF'\\nsearch text\\n=======\\nreplacement text\\nEOF" })`; } if (isBashCommand) { return `Error: "${toolId}" is not a tool — it is a bash command. Use the bash tool to run it:\n\n bash({ command: "${toolId} ..." })`; } const commandList = agentType === 'setup' ? 'brief, spec, ask, propose-create' : 'ls, tree, cat, head, tail, rg, grep, find, mkdir, touch, rm, mv, cp, echo, sed, ss, wc, sort, uniq, tr, curl, sqlite3, python, python3, lua, preview, build, status'; return `Error: Unknown tool "${toolId}". Available tools: bash.\n\nThe bash tool supports these commands: ${commandList}`; } }