Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)
Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)
Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名
Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)
Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
362 lines
15 KiB
TypeScript
Executable File
362 lines
15 KiB
TypeScript
Executable File
/**
|
|
* 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<string, unknown> }
|
|
|
|
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<ExecutorResult> {
|
|
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<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 (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<string, unknown> }> = []
|
|
): 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<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',
|
|
}
|
|
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<ExecutorAction, { type: 'tool_call' }> | null {
|
|
try {
|
|
const parsed = JSON.parse(raw) as { id?: string; tool?: string; name?: string; args?: Record<string, unknown>; arguments?: Record<string, unknown> }
|
|
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<ExecutorAction, { type: 'tool_call' }> => 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<Record<string, unknown>> = []
|
|
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
|
|
}
|
|
} |