fix: 主线 B3/B4 结构化工具调用与完成前验证

- 打通 Worker → WorkerManager → Provider 的 tools 传递链路,ProviderManager/adapter
  返回结构化 tool_calls 给 WorkerRuntime
- OpenAI-compatible/Anthropic adapter 发送工具 schema,并解析 provider 返回的
  tool_calls/tool_use;OpenAI 工具名使用 fs.write ↔ fs__write 双向映射
- 修复独立复审发现的 OpenAI 协议隐患:assistant tool_use blocks 必须转换为
  assistant.tool_calls,后续 role=tool 消息的 tool_call_id 必须匹配前一轮
  tool_calls[].id;不再把 tool_use JSON 字符串化为普通文本
- ExecutorRole 优先消费原生 tool_calls,回灌 canonical tool_result block;移除
  fs.write(...)/shell.run(...) 函数调用正则解析,只保留严格 JSON tool_call
  fallback 与 filename code block 兼容
- DONE 前执行 verification-before-completion:任务要求 build/compile/run/test/编译/
  运行/测试时必须实际 shell.run 验证,失败不 checkpoint、不返回 completed
- fs.write 覆盖已有文件也强制 read-before-write,补齐 Claude Code 文件状态纪律
- 新增 packages/workers/test/executor-role.test.ts 行为测试:原生 tool_calls 执行、
  verification 失败不得 completed

真实验收:
- TSC=0
- bun test packages/workers/test/executor-role.test.ts: 2 pass / 0 fail
- OpenAI converter 探针确认 assistant.tool_calls 与 role=tool 的 tool_call_id 匹配
- 真实 GLM Worker C++ 编译运行任务通过,worker verification 记录实际命令:
  c++ hello.cpp -o /tmp/aircoding-verify && /tmp/aircoding-verify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-08 15:01:16 +08:00
parent bac285d412
commit e383d5f6a7
8 changed files with 384 additions and 158 deletions

View File

@@ -16,6 +16,11 @@ export interface ExecutorResult {
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
@@ -36,25 +41,20 @@ export class ExecutorRole {
role: 'system',
content: `You are an AI coding assistant. Complete coding tasks by writing code files.
You can write files by outputting code blocks with a language tag that includes the filename:
Use structured tool calls whenever possible. Available tools include fs.read, fs.write, fs.edit, fs.list, shell.run, cpp.detect, cpp.build, and cpp.test.
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
\`\`\`
\`\`\`cmake:CMakeLists.txt
# CMake code here
\`\`\`
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`
After all required files are written and required verification has passed, write a line containing exactly: DONE`
},
{
role: 'user',
@@ -81,8 +81,8 @@ After completing ALL required files, write: DONE`
.replace(/<\|assistant\|>/g, '')
.trim()
// Parse ALL actions from the response
const actions = this.parse_actions(text)
// 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 => {
@@ -97,7 +97,7 @@ After completing ALL required files, write: DONE`
let allSucceeded = true
if (hadActions) {
messages.push({ role: 'assistant', content: text })
messages.push({ role: 'assistant', content: this.assistant_content_for_actions(text, actions) })
for (const action of actions) {
if (action.type === 'code_block') {
@@ -116,7 +116,7 @@ After completing ALL required files, write: DONE`
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> }
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'
@@ -125,10 +125,16 @@ After completing ALL required files, write: DONE`
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)}` })
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: `Tool ${name} error: ${e.message}` })
messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: id, content: `Tool ${name} error: ${e.message}`, is_error: true }]
})
}
}
}
@@ -139,11 +145,16 @@ After completing ALL required files, write: DONE`
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: { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` },
verification,
evidence_refs: []
}
}
@@ -163,17 +174,22 @@ After completing ALL required files, write: DONE`
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: { passed: true, output: `${changes.length} files created` },
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.' })
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.' })
}
}
@@ -200,24 +216,88 @@ After completing ALL required files, write: DONE`
.some(line => line === 'DONE' || line === 'TASK_COMPLETE')
}
/**
* Parse ALL actions from LLM response: code blocks and tool calls.
*/
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> = []
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 || {},
})
}
// ── 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] || ''
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
}
// Infer filename from language
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',
@@ -233,109 +313,51 @@ After completing ALL required files, write: DONE`
actions.push({ type: 'code_block', filename, content: match[4] })
}
// ── 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']
const textWithoutBlocks = text.replace(/```(?:\w+)?[\s\S]*?```/g, '')
actions.push(...this.extract_json_tool_calls(textWithoutBlocks))
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--
}
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'],
'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<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 })
}
}
// ── 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)
}
}
}
// If nothing parsed, it's just text
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
}
}