From a2d7aa0339d18b7026c51730414e3a665d7e3754 Mon Sep 17 00:00:00 2001 From: AirCoding Date: Fri, 5 Jun 2026 14:54:34 +0800 Subject: [PATCH] feat: ExecutorRole supports complex multi-file tasks with natural LLM output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of execution loop: - Parse natural ```lang:filename code blocks (no custom format needed) - Manual tool call parser handles content with embedded quotes/parens - Multi-turn: always ask LLM "more files needed?" after each tool execution - Support 15 turns for complex tasks (C++ program with multiple files) - Removed premature auto-complete (was returning after first write) Verified: "create C++ terminal AI that reads @ commands, calls LLM, generates shell commands with user y/n confirmation" → generated main.cpp (3037B) + CMakeLists.txt (484B), professional quality. tsc: 0 errors. E2E: 13/13. Complex task: 4/4 passed. Co-Authored-By: Claude Sonnet 4.6 --- packages/workers/src/roles/ExecutorRole.ts | 372 ++++++++++++--------- 1 file changed, 223 insertions(+), 149 deletions(-) diff --git a/packages/workers/src/roles/ExecutorRole.ts b/packages/workers/src/roles/ExecutorRole.ts index 4419278..cff23e9 100755 --- a/packages/workers/src/roles/ExecutorRole.ts +++ b/packages/workers/src/roles/ExecutorRole.ts @@ -1,6 +1,7 @@ /** * 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 */ @@ -17,7 +18,7 @@ export interface ExecutorResult { export class ExecutorRole { private runtime: WorkerRuntime - private max_turns: number = 10 + private max_turns: number = 15 constructor(runtime: WorkerRuntime) { this.runtime = runtime @@ -26,98 +27,139 @@ export class ExecutorRole { async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise { this.runtime.emit('task.attempt.started', { task_id: task_spec.id }) - // Use model from env or task_spec, fall back to glm-5.1 - const model = (task_spec as any).model - || process.env.AIRCODING_MODEL - || 'glm-5.1' + 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 executor. You MUST use tools to complete tasks. + content: `You are an AI coding assistant. Complete coding tasks by writing code files. -IMPORTANT: When you need to read or write a file, you MUST output a tool call in this EXACT format: -\`\`\`tool_call -fs.write("path/to/file", "content here") +You can write files by outputting code blocks with a language tag that includes the filename: +\`\`\`cpp:src/main.cpp +// C++ code here \`\`\` -NEVER describe what you will do — DO IT. Output the tool_call block directly. -Available tools: fs.write(path, content), fs.read(path), shell.run(command) +\`\`\`cmake:CMakeLists.txt +# CMake code here +\`\`\` -When the task is complete, write exactly: TASK_COMPLETE` +Or any language: python, javascript, txt, etc. +The filename goes after the language tag, separated by colon. + +You can also call tools directly: +fs.read("path/to/file") — read a file +fs.write("path/to/file", "content") — write a file +shell.run("command") — run a shell command +fs.list("dir") — list a directory + +After completing ALL required files, write: 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')}` + 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' }> = [] - let verification: { passed: boolean; output: string } | undefined while (turn < this.max_turns) { turn++ this.runtime.heartbeat() - // Call LLM const llm_response = await this.runtime.call_llm({ messages, model, - max_tokens: 4096, - temperature: 0.1 + max_tokens: 8192, + temperature: 0.2 }) - // Strip GLM reasoning tags and clean response - let response_text = (llm_response.content || '') + let text = (llm_response.content || '') .replace(/<\/?think>/g, '') .replace(/<\|assistant\|>/g, '') .trim() - // Debug: show response snippet - process.stderr.write(`[EXEC T${turn}] ${response_text.slice(0, 80).replace(/\n/g, ' ')}...\n`) + // Parse ALL actions from the response + const actions = this.parse_actions(text) - // Parse tool calls from LLM response FIRST - const tool_calls = this.parse_tool_calls(response_text) - // Deduplicate - const seen = new Set() - const unique = tool_calls.filter(tc => { - const key = `${tc.name}:${JSON.stringify(tc.args)}` - if (seen.has(key)) return false - seen.add(key) - return true - }) + // 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 tool calls BEFORE checking for completion - if (unique.length > 0) { - messages.push({ role: 'assistant', content: response_text }) + // Execute all actions + const hadActions = actions.some(a => a.type !== 'text') + let allSucceeded = true - for (const tc of unique) { - try { - const result = await this.runtime.call_tool(tc.name, tc.args) - const tool_output = result.type === 'error' - ? `Error: ${JSON.stringify(result.content)}` - : JSON.stringify(result.content) + if (hadActions) { + messages.push({ role: 'assistant', content: text }) - if (tc.name === 'fs.write' && tc.args.path) { - changes.push({ file: tc.args.path as string, type: 'create' }) - } else if (tc.name === 'fs.edit' && tc.args.path) { - changes.push({ file: tc.args.path as string, type: 'edit' }) + 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 { name, args } = action as { 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: `Tool ${name}(${args.path || ''}): ${output.slice(0, 500)}` }) + } catch (e: any) { + allSucceeded = false + messages.push({ role: 'user', content: `Tool ${name} error: ${e.message}` }) } - - messages.push({ - role: 'user', - content: `Tool ${tc.name} result: ${tool_output}` - }) - } catch (e: any) { - messages.push({ role: 'user', content: `Tool ${tc.name} error: ${e.message}` }) } } - // Auto-complete for simple write-only tasks - const allWrites = unique.every(tc => tc.name === 'fs.write') - if (allWrites && changes.length > 0) { + // After executing, check if LLM indicated completion + if (text.includes('DONE') || text.includes('TASK_COMPLETE')) { + await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) + return { + status: 'completed', + changes, + verification: { passed: allSucceeded, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` }, + 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 (text.includes('DONE') || text.includes('TASK_COMPLETE')) { + 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 + } + await this.runtime.checkpoint('task_completed', { task_id: task_spec.id }) return { status: 'completed', changes, @@ -126,34 +168,14 @@ When the task is complete, write exactly: TASK_COMPLETE` } } - messages.push({ - role: 'user', - content: 'Tools executed. If task is complete, respond "TASK_COMPLETE". Otherwise continue.' - }) - continue + messages.push({ role: 'assistant', content: text }) + messages.push({ role: 'user', content: 'Please CREATE the files. Use code blocks with filename tags or fs.write() tool calls. When done creating ALL files, respond DONE.' }) } - - // Check for completion signal (no tools to execute) - if (response_text.includes('TASK_COMPLETE')) { - const summary = response_text.split('TASK_COMPLETE')[1]?.trim() || 'Task completed' - await this.runtime.checkpoint('task_completed', { task_id: task_spec.id, summary }) - return { - status: 'completed', - changes, - verification, - evidence_refs: [] - } - } - - // No tool calls and no TASK_COMPLETE - messages.push({ role: 'assistant', content: response_text }) - messages.push({ role: 'user', content: 'Please use tool_call blocks to take action. When done, respond TASK_COMPLETE.' }) } - // Max turns reached return { status: 'blocked', - error: `Task exceeded ${this.max_turns} turns without completion`, + error: `Task exceeded ${this.max_turns} turns (${changes.length} files created)`, changes, evidence_refs: [] } @@ -163,94 +185,146 @@ When the task is complete, write exactly: TASK_COMPLETE` task_id: task_spec.id, error: error instanceof Error ? error.message : String(error) }) - - return { - status: 'blocked', - error: error instanceof Error ? error.message : String(error) - } + return { status: 'blocked', error: error instanceof Error ? error.message : String(error) } } } /** - * Parse tool calls from LLM response text. - * Supports JSON tool_call format and function-call markdown blocks. + * Parse ALL actions from LLM response: code blocks and tool calls. */ - private parse_tool_calls(text: string): Array<{ name: string; args: Record }> { - const calls: Array<{ name: string; args: Record }> = [] + private parse_actions(text: string): Array< + { type: 'text' } | + { type: 'code_block'; filename: string; content: string } | + { type: 'tool_call'; name: string; args: Record } + > { + const actions: Array = [] - // Pattern 1: JSON tool_call blocks - const json_pattern = /\{[\s\n]*"tool_call"[\s\n]*:[\s\n]*\{[^}]+\}[\s\n]*\}/g - for (const match of text.match(json_pattern) || []) { - try { - const parsed = JSON.parse(match) - if (parsed.tool_call) { - calls.push({ name: parsed.tool_call.name, args: parsed.tool_call.args || {} }) + // ── Pattern 1: Code blocks with filename tags ── + // ```cpp:src/main.cpp or ```cpp:main.cpp or ```cpp main.cpp + const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g + for (const match of text.matchAll(codeBlockRe)) { + const lang = match[1] + let filename = match[2] || match[3] || '' + + // Infer filename from language + 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', } - } catch { /* skip invalid JSON */ } + filename = extMap[lang] || `${lang}_output.${lang === 'cmake' ? 'txt' : lang}` + } + + actions.push({ type: 'code_block', filename, content: match[4].trim() }) } - // Pattern 2: function(name, args) format — positional or named - const func_pattern = /(\w+)\.(\w+)\(([^)]*)\)/g - for (const match of text.matchAll(func_pattern)) { - const [_, namespace, func, args_str] = match - const name = `${namespace}.${func}` - const args: Record = {} + // ── Pattern 2: Explicit tool calls ── + // Manual parser for tool_name("arg1", "arg2") to handle content with quotes + const toolNames = ['fs.write', 'fs.read', 'fs.edit', 'fs.list', 'fs.stat', + 'shell.run', 'git.status', 'git.diff', 'git.commit', 'git.branch', + 'project.scan', 'project.context', 'cpp.detect', 'cpp.build', 'cpp.test'] - if (args_str) { - // Try named args first: key:value pairs - const named = args_str.match(/(\w+)\s*:\s*("[^"]*"|'[^']*'|[^,]+)/g) - if (named && named.length > 0) { - for (const pair of named) { - const [key, ...value_parts] = pair.split(':') - const value = value_parts.join(':').trim().replace(/^["']|["']$/g, '') - args[key.trim()] = value + for (const tname of toolNames) { + let searchFrom = 0 + while (true) { + const idx = text.indexOf(`${tname}(`, searchFrom) + if (idx < 0) break + + // Find the argument list: count parens and handle quotes + const argsStart = idx + tname.length + 1 // skip "(" + let depth = 1 + let i = argsStart + let inString = false + let stringChar = '' + + while (i < text.length && depth > 0) { + const ch = text[i] + if (inString) { + if (ch === '\\') { i += 2; continue } + if (ch === stringChar) inString = false + } else { + if (ch === '"' || ch === "'") { inString = true; stringChar = ch } + else if (ch === '(') depth++ + else if (ch === ')') depth-- } - } else { - // Positional args: extract quoted or unquoted values - const positional = args_str.match(/"[^"]*"|'[^']*'|[^,]+/g) || [] - // Map positional args for known tools - const posMap: Record = { - 'fs.write': ['path', 'content'], - 'fs.read': ['path'], - 'fs.edit': ['path', 'old_str', 'new_str'], - 'fs.list': ['path'], - 'fs.stat': ['path'], - 'shell.run': ['command'], - 'git.status': [], - 'git.commit': ['message'], - 'project.scan': ['root'], + i++ + } + + const argsStr = text.slice(argsStart, i - 1).trim() + searchFrom = i + + // Parse arguments: split by top-level commas + const args: string[] = [] + let cur = '' + let inStr = false + let strCh = '' + for (let j = 0; j < argsStr.length; j++) { + const ch = argsStr[j] + if (inStr) { + if (ch === '\\') { cur += ch + (argsStr[j+1] || ''); j++; continue } + if (ch === strCh) inStr = false + cur += ch + } else { + if (ch === '"' || ch === "'") { inStr = true; strCh = ch; cur += ch } + else if (ch === ',') { args.push(cur.trim()); cur = '' } + else cur += ch } - const keys = posMap[name] || positional.map((_, i) => `arg${i}`) - positional.forEach((v, i) => { - const clean = v.trim().replace(/^["']|["']$/g, '') - args[keys[i] || `arg${i}`] = clean - }) + } + if (cur.trim()) args.push(cur.trim()) + + // Map to tool-specific arg names + const argMap: Record = { + 'fs.write': ['path', 'content'], + 'fs.read': ['path'], + 'fs.edit': ['path', 'old_str', 'new_str'], + 'fs.list': ['path'], + 'fs.stat': ['path'], + 'shell.run': ['command'], + 'project.scan': ['root'], + 'cpp.detect': ['project_root'], + 'cpp.build': ['target'], + 'cpp.test': ['filter'], + } + const keys = argMap[tname] || args.map((_, k) => `arg${k}`) + const toolArgs: Record = {} + args.forEach((v, k) => { + // Strip surrounding quotes + let clean = v.trim() + if ((clean.startsWith('"') && clean.endsWith('"')) || + (clean.startsWith("'") && clean.endsWith("'"))) { + clean = clean.slice(1, -1) + } + toolArgs[keys[k] || `arg${k}`] = clean + }) + + actions.push({ type: 'tool_call', name: tname, args: toolArgs }) + } + } + + // ── Pattern 3: ```tool_call blocks (explicit tool JSON) ── + const tcallRe = /```(?:tool_call|tool|json)\s*\n?([\s\S]*?)```/g + for (const match of text.matchAll(tcallRe)) { + const inner = match[1].trim() + // Try JSON + try { + const parsed = JSON.parse(inner) + if (parsed.tool) actions.push({ type: 'tool_call', name: parsed.tool, args: parsed.args || {} }) + } catch { + // Try function call + const subCalls = this.parse_actions(inner) + for (const sc of subCalls) { + if (sc.type !== 'text') actions.push(sc) } } - calls.push({ name, args }) } - // Pattern 4: ```tool_call code blocks with function calls - const tcall_pattern = /```tool_call\s*\n?([\s\S]*?)```/g - for (const match of text.matchAll(tcall_pattern)) { - const inner = match[1].trim() - // Parse function calls inside the block - const inner_calls = this.parse_tool_calls(inner) - for (const ic of inner_calls) calls.push(ic) - } + // If nothing parsed, it's just text + if (actions.length === 0) actions.push({ type: 'text' }) - // Pattern 3: ```tool_call JSON blocks - const block_pattern = /```(?:json)?\s*\n?\{[\s\n]*"tool"[\s\n]*:[\s\n]*"[^"]+"[\s\n]*,[\s\n]*"args"[\s\n]*:[\s\n]*\{[^}]*\}[\s\n]*\}[\s\n]*```/g - for (const match of text.match(block_pattern) || []) { - try { - const json_str = match.replace(/```(?:json)?\s*\n?/g, '').replace(/```/g, '').trim() - const parsed = JSON.parse(json_str) - if (parsed.tool) { - calls.push({ name: parsed.tool, args: parsed.args || {} }) - } - } catch { /* skip */ } - } - - return calls + return actions } } \ No newline at end of file