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 {