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:
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>> = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
70
packages/workers/test/executor-role.test.ts
Executable file
70
packages/workers/test/executor-role.test.ts
Executable 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' }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user