| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|
| |
| |
| 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<ApprovalOutcome>; |
| permissionMode?: PermissionMode; |
| permissionOverrides?: Record<string, GateDecision>; |
| } |
|
|
| export class OswsToolExecutor implements ToolExecutor { |
| onAfterExecute?: (toolCall: ToolCall, result: ToolResult, durationMs: number) => Promise<void>; |
|
|
| 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<string, unknown>, |
| })); |
| } |
|
|
| async execute(toolCall: ToolCall, context: ToolExecContext): Promise<ToolResult> { |
| const startedAt = Date.now(); |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| |
| this.config.progress.onEvent('tool_status', { |
| toolCallId: toolCall.id, |
| toolName: toolId, |
| status: 'executing', |
| args: toolCall.function.arguments, |
| }); |
|
|
| |
| 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<string>((_, 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| private extractSignals( |
| toolCall: ToolCall, |
| output: string, |
| setupComplete: boolean, |
| awaitingUser: boolean |
| ): Record<string, unknown> { |
| const signals: Record<string, unknown> = {}; |
|
|
| if (setupComplete) signals.setupComplete = true; |
| if (awaitingUser) signals.awaitingUser = true; |
|
|
| |
| let cmd = ''; |
| try { |
| const args = JSON.parse(toolCall.function.arguments); |
| cmd = typeof (args.command ?? args.cmd) === 'string' ? (args.command ?? args.cmd) : ''; |
| } catch { |
| return signals; |
| } |
|
|
| |
| if (STATUS_SEGMENT_RE.test(cmd)) { |
| |
| if (/--complete\b/i.test(cmd) && !/--incomplete\b/i.test(cmd)) { |
| signals.statusComplete = true; |
| } |
|
|
| |
| if (/^\s*status\s+complete\b/i.test(cmd)) { |
| signals.statusComplete = true; |
| } |
|
|
| |
| const statusResult = this.extractStatusResult(cmd, output); |
| if (statusResult) { |
| signals.statusResult = statusResult; |
| if (statusResult.complete) signals.statusComplete = true; |
| } |
| } |
|
|
| return signals; |
| } |
|
|
| |
| |
| |
| |
| private extractStatusResult( |
| cmd: string, |
| output: string |
| ): { task: string; done: string; remaining: string; complete: boolean; hasExplicitFlag: boolean } | null { |
| |
| const hasError = output && /^Error:\s/im.test(output); |
|
|
| |
| |
| |
| |
| 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, |
| }; |
| } |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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}`; |
| } |
| } |
|
|