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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user