/** * ExecutorRole - Implementation worker * Implements DD §8.4. Executes tasks using LLM→tool→LLM loop. * Accepts natural LLM output (code blocks, tool calls, direct writing). * * @module packages/workers/src/roles/ExecutorRole */ import { WorkerRuntime } from '../WorkerRuntime.js' export interface ExecutorResult { status: 'completed' | 'failed' | 'blocked' changes?: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> verification?: { passed: boolean; output: string } error?: string evidence_refs?: string[] } type ExecutorAction = | { type: 'text' } | { type: 'code_block'; filename: string; content: string } | { type: 'tool_call'; id: string; name: string; args: Record } export class ExecutorRole { private runtime: WorkerRuntime private max_turns: number = 15 constructor(runtime: WorkerRuntime) { this.runtime = runtime } async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise { this.runtime.checkpoint('task_attempt_started', { task_id: task_spec.id }) const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1' const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.' try { const messages: Array<{ role: string; content: unknown }> = [ { role: 'system', content: `You are an AI coding assistant. Complete coding tasks by writing code files. Use structured tool calls whenever possible. Available tools include: - fs.read, fs.write, fs.edit, fs.list — filesystem operations - shell.run — shell command execution - cpp.detect, cpp.configure, cpp.build, cpp.test, cpp.cppcheck, cpp.clangd — C++ toolchain If native tools are unavailable, output strict JSON tool calls only in this form: \`\`\`json {"tool":"fs.write","args":{"path":"src/main.cpp","content":"..."}} \`\`\` You may write new files by outputting code blocks with a language tag that includes the filename: \`\`\`cpp:src/main.cpp // C++ code here \`\`\` After all required files are written and required verification has passed, write a line containing exactly: DONE` }, { role: 'user', content: `Task: ${task_spec.title}\n\nDescription: ${task_spec.description}\n\nAcceptance criteria:\n${task_spec.acceptance_criteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}\n\nProject directory: ${projectRoot}` } ] let turn = 0 const changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> = [] while (turn < this.max_turns) { turn++ this.runtime.heartbeat() const llm_response = await this.runtime.call_llm({ messages, model, max_tokens: 8192, temperature: 0.2 }) let text = (llm_response.content || '') .replace(/<\/?think>/g, '') .replace(/<\|assistant\|>/g, '') .trim() // Parse structured actions from native tool_calls first, then strict JSON/code-block fallback const actions = this.parse_actions(text, llm_response.tool_calls || []) // Debug const actionSummary = actions.map(a => { if (a.type === 'code_block') return `📄 ${(a as any).filename} (${(a as any).content.length}B)` if (a.type === 'tool_call') return `🔧 ${(a as any).name}` return `💬 text` }).join(', ') process.stderr.write(`[EXEC T${turn}] ${actionSummary}\n`) // Execute all actions const hadActions = actions.some(a => a.type !== 'text') let allSucceeded = true if (hadActions) { messages.push({ role: 'assistant', content: this.assistant_content_for_actions(text, actions) }) for (const action of actions) { if (action.type === 'code_block') { const { filename, content } = action as { filename: string; content: string } try { const result = await this.runtime.call_tool('fs.write', { path: filename, content, create_dirs: true }) if (result.type === 'error') { allSucceeded = false messages.push({ role: 'user', content: `Failed to write ${filename}: ${JSON.stringify(result.content)}` }) } else { changes.push({ file: filename, type: 'create' }) messages.push({ role: 'user', content: `✅ ${filename} written (${content.length} bytes)` }) } } catch (e: any) { allSucceeded = false messages.push({ role: 'user', content: `Error writing ${filename}: ${e.message}` }) } } else if (action.type === 'tool_call') { const { id, name, args } = action as { id: string; name: string; args: Record } try { const result = await this.runtime.call_tool(name, args) const output = result.type === 'error' ? `Error: ${JSON.stringify(result.content)}` : JSON.stringify(result.content) if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' }) if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' }) if (result.type === 'error') allSucceeded = false messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: output.slice(0, 5000), is_error: result.type === 'error' }] }) } catch (e: any) { allSucceeded = false messages.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: `Tool ${name} error: ${e.message}`, is_error: true }] }) } } } if (this.is_done_signal(text)) { if (!allSucceeded) { messages.push({ role: 'assistant', content: text }) messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' }) continue } const verification = await this.verify_before_completion(task_spec, changes) if (!verification.passed) { messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` }) continue } await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) return { status: 'completed', changes, verification, evidence_refs: [] } } // ALWAYS ask the LLM: are you done or do you need to create more files? messages.push({ role: 'user', content: allSucceeded ? `Actions completed. ${changes.length} files written so far: ${changes.map(c => c.file).join(', ')}.\nIf the task needs MORE files, continue creating them.\nIf the task is COMPLETE (all required files created), respond with DONE.` : 'Some actions failed. Review the errors and retry. If all attempts exhausted, respond with DONE to finish with partial results.' }) } else { // No code blocks, no tool calls — LLM is just talking if (this.is_done_signal(text)) { if (changes.length === 0) { messages.push({ role: 'assistant', content: text }) messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' }) continue } const verification = await this.verify_before_completion(task_spec, changes) if (!verification.passed) { messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` }) continue } await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) return { status: 'completed', changes, verification, evidence_refs: [] } } messages.push({ role: 'assistant', content: text }) messages.push({ role: 'user', content: 'Please CREATE the files. Use native tools, strict JSON tool_call blocks, or code blocks with filename tags. When done creating ALL files and required verification passes, respond DONE.' }) } } return { status: 'blocked', error: `Task exceeded ${this.max_turns} turns (${changes.length} files created)`, changes, evidence_refs: [] } } catch (error) { return { status: 'blocked', error: error instanceof Error ? error.message : String(error) } } } private is_done_signal(text: string): boolean { return text .split(/\r?\n/) .map(line => line.trim()) .some(line => line === 'DONE' || line === 'TASK_COMPLETE') } private async verify_before_completion( task_spec: { acceptance_criteria: string[]; title: string; description: string }, changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> ): Promise<{ passed: boolean; output: string }> { if (!this.requires_executable_verification(task_spec)) { return { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` } } const command = this.verification_command(task_spec, changes) if (!command) { return { passed: false, output: 'Acceptance criteria require executable verification, but no verification command could be derived.' } } const result = await this.runtime.call_tool('shell.run', { command, timeout: 300000 }) const payload = result.content as { exit_code?: number; stdout?: string; stderr?: string; message?: string } if (result.type === 'error') { return { passed: false, output: `Verification command failed: ${command}\n${payload.stderr || payload.stdout || payload.message || JSON.stringify(payload)}`, } } return { passed: true, output: `Verification command passed: ${command}\n${payload.stdout || ''}`.trim(), } } private requires_executable_verification(task_spec: { acceptance_criteria: string[]; title: string; description: string }): boolean { const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase() return /\b(build|compile|run|test|cmake|make|pytest|npm test|bun test)\b|编译|构建|运行|测试/.test(text) } private verification_command( task_spec: { title: string; description: string; acceptance_criteria: string[] }, changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> ): string | null { const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase() const files = new Set(changes.map(c => c.file)) if (files.has('CMakeLists.txt') || text.includes('cmake')) { return 'cmake -S . -B build && cmake --build build' } if ([...files].some(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx'))) { const file = [...files].find(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx')) || 'main.cpp' return `c++ ${file} -o /tmp/aircoding-verify && /tmp/aircoding-verify` } if (files.has('package.json') || text.includes('npm test')) return 'npm test' if (text.includes('bun test')) return 'bun test' if ([...files].some(f => f.endsWith('.py')) && text.includes('test')) return 'python3 -m pytest' return null } /** * Parse structured actions from native tool calls and strict JSON fallback. */ private parse_actions( text: string, native_tool_calls: Array<{ id?: string; name: string; arguments: Record }> = [] ): ExecutorAction[] { const actions: ExecutorAction[] = [] for (const call of native_tool_calls) { actions.push({ type: 'tool_call', id: call.id || crypto.randomUUID(), name: call.name, args: call.arguments || {}, }) } const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g for (const match of text.matchAll(codeBlockRe)) { const lang = match[1] const inner = match[4].trim() if (lang === 'json' || lang === 'tool' || lang === 'tool_call') { const parsed = this.parse_json_tool_call(inner) if (parsed) actions.push(parsed) continue } let filename = match[2] || match[3] || '' if (!filename || filename.length < 2) { const extMap: Record = { cpp: 'main.cpp', c: 'main.c', h: 'header.h', hpp: 'header.hpp', cmake: 'CMakeLists.txt', python: 'script.py', py: 'script.py', js: 'script.js', ts: 'script.ts', json: 'config.json', yaml: 'config.yaml', yml: 'config.yml', toml: 'config.toml', md: 'README.md', txt: 'output.txt', sh: 'script.sh', bash: 'script.sh', Makefile: 'Makefile', } filename = extMap[lang] || `${lang}_output.${lang === 'cmake' ? 'txt' : lang}` } actions.push({ type: 'code_block', filename, content: match[4] }) } const textWithoutBlocks = text.replace(/```(?:\w+)?[\s\S]*?```/g, '') actions.push(...this.extract_json_tool_calls(textWithoutBlocks)) if (actions.length === 0) actions.push({ type: 'text' }) return actions } private parse_json_tool_call(raw: string): Extract | null { try { const parsed = JSON.parse(raw) as { id?: string; tool?: string; name?: string; args?: Record; arguments?: Record } const name = parsed.tool || parsed.name if (!name) return null return { type: 'tool_call', id: parsed.id || crypto.randomUUID(), name, args: parsed.args || parsed.arguments || {}, } } catch { return null } } private extract_json_tool_calls(text: string): ExecutorAction[] { const actions: ExecutorAction[] = [] for (const line of text.split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue const action = this.parse_json_tool_call(trimmed) if (action) actions.push(action) } return actions } private assistant_content_for_actions(text: string, actions: ExecutorAction[]): unknown { const toolUses = actions .filter((a): a is Extract => a.type === 'tool_call') .map(a => ({ type: 'tool_use', id: a.id, name: a.name, input: a.args })) if (toolUses.length === 0) return text const blocks: Array> = [] const cleanText = text.replace(/```(?:tool_call|tool|json)\s*\n?[\s\S]*?```/g, '').trim() if (cleanText) blocks.push({ type: 'text', text: cleanText }) blocks.push(...toolUses) return blocks } }