feat: ExecutorRole supports complex multi-file tasks with natural LLM output

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 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-05 14:54:34 +08:00
parent 2ef0af6a55
commit a2d7aa0339

View File

@@ -1,6 +1,7 @@
/** /**
* ExecutorRole - Implementation worker * ExecutorRole - Implementation worker
* Implements DD §8.4. Executes tasks using LLM→tool→LLM loop. * 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 * @module packages/workers/src/roles/ExecutorRole
*/ */
@@ -17,7 +18,7 @@ export interface ExecutorResult {
export class ExecutorRole { export class ExecutorRole {
private runtime: WorkerRuntime private runtime: WorkerRuntime
private max_turns: number = 10 private max_turns: number = 15
constructor(runtime: WorkerRuntime) { constructor(runtime: WorkerRuntime) {
this.runtime = runtime this.runtime = runtime
@@ -26,98 +27,139 @@ export class ExecutorRole {
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> { async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
this.runtime.emit('task.attempt.started', { task_id: task_spec.id }) 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 const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
|| process.env.AIRCODING_MODEL
|| 'glm-5.1'
try { try {
const messages: Array<{ role: string; content: unknown }> = [ const messages: Array<{ role: string; content: unknown }> = [
{ {
role: 'system', 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: You can write files by outputting code blocks with a language tag that includes the filename:
\`\`\`tool_call \`\`\`cpp:src/main.cpp
fs.write("path/to/file", "content here") // C++ code here
\`\`\` \`\`\`
NEVER describe what you will do — DO IT. Output the tool_call block directly. \`\`\`cmake:CMakeLists.txt
Available tools: fs.write(path, content), fs.read(path), shell.run(command) # 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', 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 let turn = 0
const changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> = [] const changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }> = []
let verification: { passed: boolean; output: string } | undefined
while (turn < this.max_turns) { while (turn < this.max_turns) {
turn++ turn++
this.runtime.heartbeat() this.runtime.heartbeat()
// Call LLM
const llm_response = await this.runtime.call_llm({ const llm_response = await this.runtime.call_llm({
messages, messages,
model, model,
max_tokens: 4096, max_tokens: 8192,
temperature: 0.1 temperature: 0.2
}) })
// Strip GLM reasoning tags and clean response let text = (llm_response.content || '')
let response_text = (llm_response.content || '')
.replace(/<\/?think>/g, '') .replace(/<\/?think>/g, '')
.replace(/<\|assistant\|>/g, '') .replace(/<\|assistant\|>/g, '')
.trim() .trim()
// Debug: show response snippet // Parse ALL actions from the response
process.stderr.write(`[EXEC T${turn}] ${response_text.slice(0, 80).replace(/\n/g, ' ')}...\n`) const actions = this.parse_actions(text)
// Parse tool calls from LLM response FIRST // Debug
const tool_calls = this.parse_tool_calls(response_text) const actionSummary = actions.map(a => {
// Deduplicate if (a.type === 'code_block') return `📄 ${(a as any).filename} (${(a as any).content.length}B)`
const seen = new Set<string>() if (a.type === 'tool_call') return `🔧 ${(a as any).name}`
const unique = tool_calls.filter(tc => { return `💬 text`
const key = `${tc.name}:${JSON.stringify(tc.args)}` }).join(', ')
if (seen.has(key)) return false process.stderr.write(`[EXEC T${turn}] ${actionSummary}\n`)
seen.add(key)
return true
})
// Execute tool calls BEFORE checking for completion // Execute all actions
if (unique.length > 0) { const hadActions = actions.some(a => a.type !== 'text')
messages.push({ role: 'assistant', content: response_text }) let allSucceeded = true
for (const tc of unique) { if (hadActions) {
messages.push({ role: 'assistant', content: text })
for (const action of actions) {
if (action.type === 'code_block') {
const { filename, content } = action as { filename: string; content: string }
try { try {
const result = await this.runtime.call_tool(tc.name, tc.args) const result = await this.runtime.call_tool('fs.write', { path: filename, content, create_dirs: true })
const tool_output = result.type === 'error' 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<string, unknown> }
try {
const result = await this.runtime.call_tool(name, args)
const output = result.type === 'error'
? `Error: ${JSON.stringify(result.content)}` ? `Error: ${JSON.stringify(result.content)}`
: JSON.stringify(result.content) : JSON.stringify(result.content)
if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' })
if (tc.name === 'fs.write' && tc.args.path) { if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' })
changes.push({ file: tc.args.path as string, type: 'create' }) if (result.type === 'error') allSucceeded = false
} else if (tc.name === 'fs.edit' && tc.args.path) { messages.push({ role: 'user', content: `Tool ${name}(${args.path || ''}): ${output.slice(0, 500)}` })
changes.push({ file: tc.args.path as string, type: 'edit' }) } catch (e: any) {
allSucceeded = false
messages.push({ role: 'user', content: `Tool ${name} error: ${e.message}` })
}
}
} }
// 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({ messages.push({
role: 'user', role: 'user',
content: `Tool ${tc.name} result: ${tool_output}` 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.'
}) })
} catch (e: any) { } else {
messages.push({ role: 'user', content: `Tool ${tc.name} error: ${e.message}` }) // 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 })
// Auto-complete for simple write-only tasks
const allWrites = unique.every(tc => tc.name === 'fs.write')
if (allWrites && changes.length > 0) {
return { return {
status: 'completed', status: 'completed',
changes, changes,
@@ -126,34 +168,14 @@ When the task is complete, write exactly: TASK_COMPLETE`
} }
} }
messages.push({ messages.push({ role: 'assistant', content: text })
role: 'user', 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.' })
content: 'Tools executed. If task is complete, respond "TASK_COMPLETE". Otherwise continue.'
})
continue
}
// 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 { return {
status: 'blocked', status: 'blocked',
error: `Task exceeded ${this.max_turns} turns without completion`, error: `Task exceeded ${this.max_turns} turns (${changes.length} files created)`,
changes, changes,
evidence_refs: [] evidence_refs: []
} }
@@ -163,94 +185,146 @@ When the task is complete, write exactly: TASK_COMPLETE`
task_id: task_spec.id, task_id: task_spec.id,
error: error instanceof Error ? error.message : String(error) 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. * Parse ALL actions from LLM response: code blocks and tool calls.
* Supports JSON tool_call format and function-call markdown blocks.
*/ */
private parse_tool_calls(text: string): Array<{ name: string; args: Record<string, unknown> }> { private parse_actions(text: string): Array<
const calls: Array<{ name: string; args: Record<string, unknown> }> = [] { type: 'text' } |
{ type: 'code_block'; filename: string; content: string } |
{ type: 'tool_call'; name: string; args: Record<string, unknown> }
> {
const actions: Array<any> = []
// Pattern 1: JSON tool_call blocks // ── Pattern 1: Code blocks with filename tags ──
const json_pattern = /\{[\s\n]*"tool_call"[\s\n]*:[\s\n]*\{[^}]+\}[\s\n]*\}/g // ```cpp:src/main.cpp or ```cpp:main.cpp or ```cpp main.cpp
for (const match of text.match(json_pattern) || []) { const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g
try { for (const match of text.matchAll(codeBlockRe)) {
const parsed = JSON.parse(match) const lang = match[1]
if (parsed.tool_call) { let filename = match[2] || match[3] || ''
calls.push({ name: parsed.tool_call.name, args: parsed.tool_call.args || {} })
// Infer filename from language
if (!filename || filename.length < 2) {
const extMap: Record<string, string> = {
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}`
} }
// Pattern 2: function(name, args) format — positional or named actions.push({ type: 'code_block', filename, content: match[4].trim() })
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<string, unknown> = {}
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
} }
// ── 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']
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 { } else {
// Positional args: extract quoted or unquoted values if (ch === '"' || ch === "'") { inString = true; stringChar = ch }
const positional = args_str.match(/"[^"]*"|'[^']*'|[^,]+/g) || [] else if (ch === '(') depth++
// Map positional args for known tools else if (ch === ')') depth--
const posMap: Record<string, string[]> = { }
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
}
}
if (cur.trim()) args.push(cur.trim())
// Map to tool-specific arg names
const argMap: Record<string, string[]> = {
'fs.write': ['path', 'content'], 'fs.write': ['path', 'content'],
'fs.read': ['path'], 'fs.read': ['path'],
'fs.edit': ['path', 'old_str', 'new_str'], 'fs.edit': ['path', 'old_str', 'new_str'],
'fs.list': ['path'], 'fs.list': ['path'],
'fs.stat': ['path'], 'fs.stat': ['path'],
'shell.run': ['command'], 'shell.run': ['command'],
'git.status': [],
'git.commit': ['message'],
'project.scan': ['root'], 'project.scan': ['root'],
'cpp.detect': ['project_root'],
'cpp.build': ['target'],
'cpp.test': ['filter'],
} }
const keys = posMap[name] || positional.map((_, i) => `arg${i}`) const keys = argMap[tname] || args.map((_, k) => `arg${k}`)
positional.forEach((v, i) => { const toolArgs: Record<string, unknown> = {}
const clean = v.trim().replace(/^["']|["']$/g, '') args.forEach((v, k) => {
args[keys[i] || `arg${i}`] = clean // 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 })
} }
} }
calls.push({ name, args })
}
// Pattern 4: ```tool_call code blocks with function calls // ── Pattern 3: ```tool_call blocks (explicit tool JSON) ──
const tcall_pattern = /```tool_call\s*\n?([\s\S]*?)```/g const tcallRe = /```(?:tool_call|tool|json)\s*\n?([\s\S]*?)```/g
for (const match of text.matchAll(tcall_pattern)) { for (const match of text.matchAll(tcallRe)) {
const inner = match[1].trim() const inner = match[1].trim()
// Parse function calls inside the block // Try JSON
const inner_calls = this.parse_tool_calls(inner)
for (const ic of inner_calls) calls.push(ic)
}
// 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 { try {
const json_str = match.replace(/```(?:json)?\s*\n?/g, '').replace(/```/g, '').trim() const parsed = JSON.parse(inner)
const parsed = JSON.parse(json_str) if (parsed.tool) actions.push({ type: 'tool_call', name: parsed.tool, args: parsed.args || {} })
if (parsed.tool) { } catch {
calls.push({ name: parsed.tool, args: parsed.args || {} }) // Try function call
const subCalls = this.parse_actions(inner)
for (const sc of subCalls) {
if (sc.type !== 'text') actions.push(sc)
}
} }
} catch { /* skip */ }
} }
return calls // If nothing parsed, it's just text
if (actions.length === 0) actions.push({ type: 'text' })
return actions
} }
} }