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

@@ -96,8 +96,8 @@ export class ProviderManager {
*/
async complete_text(
messages: unknown[],
options: { model?: string; max_tokens?: number; temperature?: number; system?: string } = {}
): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
options: { model?: string; max_tokens?: number; temperature?: number; system?: string; tools?: unknown[] } = {}
): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
// Auto-initialize if no adapter selected yet (cold start)
if (!this.current_adapter) {
this.select_model({ model: options.model || 'claude-haiku-4-5-20251001', provider: 'anthropic' })
@@ -114,6 +114,7 @@ export class ProviderManager {
model_id: model_id as ModelID,
canonical_format: 'anthropic',
messages: messages as any,
tools: options.tools as any,
max_output_tokens: options.max_tokens || 4096,
temperature: options.temperature,
system: options.system
@@ -121,6 +122,7 @@ export class ProviderManager {
let content = ''
let usage: { input_tokens: number; output_tokens: number } | undefined
const tool_calls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> = []
for await (const event of adapter.complete(input)) {
if (event.type === 'content_delta') {
@@ -130,6 +132,11 @@ export class ProviderManager {
} else if (payload.type === 'thinking_delta') {
// Accumulate thinking for reference but don't include in content
}
} else if (event.type === 'tool_use') {
const payload = event.payload as { id?: string; name?: string; input?: Record<string, unknown>; arguments?: Record<string, unknown> }
if (payload.name) {
tool_calls.push({ id: payload.id, name: payload.name, arguments: payload.input || payload.arguments || {} })
}
} else if (event.type === 'message_stop') {
const payload = event.payload as { usage?: { output_tokens: number } }
if (payload.usage) {
@@ -138,7 +145,7 @@ export class ProviderManager {
}
}
return { content, usage }
return { content, usage, tool_calls: tool_calls.length ? tool_calls : undefined }
}
/**

View File

@@ -41,6 +41,7 @@ interface AnthropicApiRequest {
top_p?: number
system?: string
stream?: boolean
tools?: Array<{ name: string; description: string; input_schema: Record<string, unknown> }>
}
export class AnthropicAdapter implements ProviderAdapter {
@@ -101,6 +102,7 @@ export class AnthropicAdapter implements ProviderAdapter {
temperature: input.temperature,
system: input.system as string | undefined,
stream: false,
tools: this.convert_tools(input.tools),
})
// Yield each content block as an event
@@ -110,6 +112,8 @@ export class AnthropicAdapter implements ProviderAdapter {
yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } }
} else if (block.type === 'thinking' && block.thinking) {
yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } }
} else if (block.type === 'tool_use' && block.name) {
yield { type: 'tool_use', payload: { id: block.id, name: block.name, input: (block.input as Record<string, unknown>) || {} } }
}
}
if (response.usage) {
@@ -126,19 +130,36 @@ export class AnthropicAdapter implements ProviderAdapter {
* Backward-compat: single-shot complete that returns string content.
* Used by MainAgent.classify_via_llm.
*/
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
const response = await this.make_request({
model: options.model || 'claude-haiku-4-5-20251001',
messages: this.convert_raw_messages(messages),
max_tokens: options.max_tokens || 1024,
tools: this.convert_tools(options.tools),
stream: false,
})
const tool_calls = response.content
.filter(b => b.type === 'tool_use' && b.name)
.map(b => ({ id: b.id, name: b.name!, arguments: (b.input as Record<string, unknown>) || {} }))
return {
content: this.extract_content(response),
usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined,
tool_calls: tool_calls.length ? tool_calls : undefined,
}
}
private convert_tools(tools?: unknown[]): Array<{ name: string; description: string; input_schema: Record<string, unknown> }> | undefined {
if (!tools?.length) return undefined
return tools.map(t => {
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
return {
name: tool.name,
description: tool.description || tool.name,
input_schema: tool.input_schema || { type: 'object', properties: {} },
}
})
}
private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> {
return messages.map(m => {
const blocks: Array<Record<string, unknown>> = []

View File

@@ -34,7 +34,7 @@ interface OpenAIApiResponse {
model: string
choices: Array<{
index: number
message?: { role: string; content: string; tool_calls?: unknown[] }
message?: { role: string; content: string | null; tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> }
delta?: { role?: string; content?: string }
finish_reason?: string
}>
@@ -96,6 +96,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
const response = await this.make_request({
model: String(input.model_id),
messages: this.convert_messages(input.messages as { role: string; content: unknown }[]),
tools: this.convert_tools(input.tools),
max_tokens: input.max_output_tokens ?? 4096,
temperature: input.temperature,
system: input.system as string | undefined,
@@ -108,6 +109,22 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
if (content) {
yield { type: 'content_delta', payload: { type: 'text_delta', text: content, index: choice.index } }
}
for (const call of choice.message?.tool_calls || []) {
let args: Record<string, unknown> = {}
try {
args = JSON.parse(call.function.arguments || '{}')
} catch {
args = { raw_arguments: call.function.arguments || '' }
}
yield {
type: 'tool_use',
payload: {
id: call.id,
name: this.from_openai_tool_name(call.function.name),
input: args,
}
}
}
}
if (response.usage) {
const stop = response.choices[0]?.finish_reason || 'stop'
@@ -121,10 +138,11 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
* Backward-compat: single-shot complete that returns string content.
* Used by callers expecting a Promise<string> result.
*/
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
const response = await this.make_request({
model: options.model || this.model,
messages: this.convert_raw_messages(messages),
tools: this.convert_tools(options.tools),
max_tokens: options.max_tokens || 1024,
stream: false,
})
@@ -133,27 +151,104 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
const content = choice?.message?.content
|| (choice?.message as any)?.reasoning
|| ''
const tool_calls = (choice?.message?.tool_calls || []).map(call => {
let args: Record<string, unknown> = {}
try {
args = JSON.parse(call.function.arguments || '{}')
} catch {
args = { raw_arguments: call.function.arguments || '' }
}
return { id: call.id, name: this.from_openai_tool_name(call.function.name), arguments: args }
})
return {
content,
usage: response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined,
tool_calls: tool_calls.length ? tool_calls : undefined,
}
}
private convert_messages(messages: Array<{ role: string; content: unknown }>): Array<Record<string, unknown>> {
return messages.map(m => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : String(m.content),
}))
return messages.flatMap(m => this.convert_one_message(m))
}
/**
* Convert a single canonical message to OpenAI wire format.
* Assistant tool_use blocks → assistant message with tool_calls.
* tool_result blocks → one role:tool message per result (OpenAI requires
* each tool result to reference its tool_call_id on its own message).
*/
private convert_one_message(m: { role: string; content: unknown }): Array<Record<string, unknown>> {
if (Array.isArray(m.content)) {
const toolResults = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_result') as any[]
if (toolResults.length > 0) {
return toolResults.map(tr => ({
role: 'tool',
tool_call_id: tr.tool_use_id,
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
}))
}
const toolUses = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_use') as any[]
if (toolUses.length > 0) {
const text = m.content
.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'text')
.map(c => String((c as any).text || ''))
.join('\n')
return [{
role: m.role,
content: text || null,
tool_calls: toolUses.map(tu => ({
id: tu.id,
type: 'function',
function: { name: this.to_openai_tool_name(tu.name), arguments: JSON.stringify(tu.input || {}) },
})),
}]
}
}
return [{ role: m.role, content: this.content_to_text(m.content) }]
}
private convert_tools(tools?: unknown[]): Array<Record<string, unknown>> | undefined {
if (!tools?.length) return undefined
return tools.map(t => {
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
return {
type: 'function',
function: {
name: this.to_openai_tool_name(tool.name),
description: tool.description || tool.name,
parameters: tool.input_schema || { type: 'object', properties: {} },
}
}
})
}
private to_openai_tool_name(name: string): string {
return name.replace(/\./g, '__')
}
private from_openai_tool_name(name: string): string {
return name.replace(/__/g, '.')
}
private content_to_text(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content.map(c => {
if (typeof c === 'string') return c
if (typeof c === 'object' && c !== null) {
const obj = c as Record<string, unknown>
if (obj.type === 'text') return String(obj.text || '')
return JSON.stringify(obj)
}
return String(c)
}).join('\n')
}
return String(content)
}
private convert_raw_messages(messages: unknown[]): Array<Record<string, unknown>> {
return messages.map(m => {
const obj = m as { role: string; content: unknown }
if (typeof obj.content === 'string') {
return { role: obj.role, content: obj.content }
}
return { role: obj.role, content: String(obj.content) }
})
return messages.flatMap(m => this.convert_one_message(m as { role: string; content: unknown }))
}
private capability_matrix(model_id: string): ProviderCapabilityMatrix {

View File

@@ -241,7 +241,16 @@ export function createFsExecutors(project_root: string) {
? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf-8')
if (existsSync(full_path)) {
const original = readFileSync(full_path, 'utf-8')
const read_check = check_file_read_state(full_path, original)
if (!read_check.allowed) {
return create_result(call.call_id, 'fs.write', 'error', { message: read_check.error })
}
}
writeFileSync(full_path, data)
update_file_state(full_path, data.toString('utf-8'))
return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length })
} catch (error) {
return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) })

View File

@@ -219,13 +219,15 @@ export class WorkerManager {
const response = await this.provider_manager.complete_text(messages, {
model: msg.payload.model as string,
max_tokens: msg.payload.max_tokens as number,
temperature: msg.payload.temperature as number
temperature: msg.payload.temperature as number,
tools: (msg.payload.tools as unknown[]) || this.tool_registry?.list?.() || []
})
this.send_to_worker(agent_id, 'llm.response', {
call_id,
content: response.content,
usage: response.usage
usage: response.usage,
tool_calls: response.tool_calls
})
} catch (e: any) {
this.send_to_worker(agent_id, 'llm.response', {

View File

@@ -35,7 +35,7 @@ export interface LLMRequest {
export interface LLMResponse {
content: string
usage?: { input_tokens: number; output_tokens: number }
tool_calls?: Array<{ name: string; arguments: Record<string, unknown> }>
tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }>
}
export class WorkerRuntime {

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
}
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test'
import { ExecutorRole } from '../src/roles/ExecutorRole.js'
class NativeToolRuntime {
turns = 0
calls: Array<{ name: string; args: Record<string, unknown> }> = []
emit() {}
heartbeat() {}
checkpoint() {}
async call_llm() {
this.turns++
if (this.turns === 1) {
return { content: '', tool_calls: [{ id: 'tu_1', name: 'fs.write', arguments: { path: 'a.txt', content: 'X' } }] }
}
return { content: 'DONE' }
}
async call_tool(name: string, args: Record<string, unknown>) {
this.calls.push({ name, args })
return { call_id: 'c', type: 'text' as const, content: { ok: true } }
}
}
class VerificationFailRuntime {
turns = 0
checkpointed = false
emit() {}
heartbeat() {}
checkpoint() { this.checkpointed = true }
async call_llm() {
this.turns++
if (this.turns === 1) {
return { content: '', tool_calls: [{ id: 'tu_main', name: 'fs.write', arguments: { path: 'main.cpp', content: 'int main(){return 0;}' } }] }
}
return { content: 'DONE' }
}
async call_tool(name: string, _args: Record<string, unknown>) {
if (name === 'fs.write') return { call_id: 'w', type: 'text' as const, content: { ok: true } }
if (name === 'shell.run') return { call_id: 's', type: 'error' as const, content: { exit_code: 1, stderr: 'compile failed' } }
return { call_id: 'x', type: 'error' as const, content: { message: 'unexpected tool' } }
}
}
describe('ExecutorRole structured tool execution', () => {
test('executes native tool_calls instead of regex text parsing', async () => {
const runtime = new NativeToolRuntime()
const result = await new ExecutorRole(runtime as any).run({
id: 't1',
title: 'create file',
description: 'create a file',
acceptance_criteria: ['file exists'],
})
expect(result.status).toBe('completed')
expect(runtime.calls).toEqual([{ name: 'fs.write', args: { path: 'a.txt', content: 'X' } }])
})
test('does not complete executable tasks when verification fails', async () => {
const runtime = new VerificationFailRuntime()
const result = await new ExecutorRole(runtime as any).run({
id: 't2',
title: 'compile cpp',
description: 'write and compile C++',
acceptance_criteria: ['must compile'],
})
expect(result.status).not.toBe('completed')
expect(runtime.checkpointed).toBe(false)
expect(result.changes).toEqual([{ file: 'main.cpp', type: 'create' }])
})
})