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
* 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<ExecutorResult> {
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<string>()
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) {
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 {
const result = await this.runtime.call_tool(tc.name, tc.args)
const tool_output = result.type === 'error'
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<string, unknown> }
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 (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' })
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}` })
}
}
}
// 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: `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) {
messages.push({ role: 'user', content: `Tool ${tc.name} error: ${e.message}` })
} 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
}
}
// Auto-complete for simple write-only tasks
const allWrites = unique.every(tc => tc.name === 'fs.write')
if (allWrites && changes.length > 0) {
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
}
// 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: []
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.' })
}
}
// 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<string, unknown> }> {
const calls: Array<{ name: string; args: Record<string, unknown> }> = []
private parse_actions(text: string): Array<
{ 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
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<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
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
actions.push({ type: 'code_block', filename, content: match[4].trim() })
}
// ── 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 {
// Positional args: extract quoted or unquoted values
const positional = args_str.match(/"[^"]*"|'[^']*'|[^,]+/g) || []
// Map positional args for known tools
const posMap: Record<string, string[]> = {
if (ch === '"' || ch === "'") { inString = true; stringChar = ch }
else if (ch === '(') depth++
else if (ch === ')') depth--
}
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.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'],
'cpp.detect': ['project_root'],
'cpp.build': ['target'],
'cpp.test': ['filter'],
}
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
const keys = argMap[tname] || args.map((_, k) => `arg${k}`)
const toolArgs: Record<string, unknown> = {}
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 })
}
}
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)) {
// ── 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()
// Parse function calls inside the block
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 JSON
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 || {} })
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)
}
}
} catch { /* skip */ }
}
return calls
// If nothing parsed, it's just text
if (actions.length === 0) actions.push({ type: 'text' })
return actions
}
}