diff --git a/packages/llm/src/ProviderManager.ts b/packages/llm/src/ProviderManager.ts index 1b05d2d..f09b932 100755 --- a/packages/llm/src/ProviderManager.ts +++ b/packages/llm/src/ProviderManager.ts @@ -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 }> }> { // 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 }> = [] 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; arguments?: Record } + 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 } } /** diff --git a/packages/llm/src/adapters/AnthropicAdapter.ts b/packages/llm/src/adapters/AnthropicAdapter.ts index 44a08f5..7345586 100755 --- a/packages/llm/src/adapters/AnthropicAdapter.ts +++ b/packages/llm/src/adapters/AnthropicAdapter.ts @@ -41,6 +41,7 @@ interface AnthropicApiRequest { top_p?: number system?: string stream?: boolean + tools?: Array<{ name: string; description: string; input_schema: Record }> } 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) || {} } } } } 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 }> }> { 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) || {} })) 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 }> | undefined { + if (!tools?.length) return undefined + return tools.map(t => { + const tool = t as { name: string; description?: string; input_schema?: Record } + 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> }> { return messages.map(m => { const blocks: Array> = [] diff --git a/packages/llm/src/adapters/OpenAICompatibleAdapter.ts b/packages/llm/src/adapters/OpenAICompatibleAdapter.ts index c0987fa..7285df6 100755 --- a/packages/llm/src/adapters/OpenAICompatibleAdapter.ts +++ b/packages/llm/src/adapters/OpenAICompatibleAdapter.ts @@ -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 = {} + 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 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 }> }> { 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 = {} + 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> { - 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> { + 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> | undefined { + if (!tools?.length) return undefined + return tools.map(t => { + const tool = t as { name: string; description?: string; input_schema?: Record } + 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 + 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> { - 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 { diff --git a/packages/runtime/src/tools/fs/index.ts b/packages/runtime/src/tools/fs/index.ts index a9b295b..738b4c4 100755 --- a/packages/runtime/src/tools/fs/index.ts +++ b/packages/runtime/src/tools/fs/index.ts @@ -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) }) diff --git a/packages/runtime/src/workers/WorkerManager.ts b/packages/runtime/src/workers/WorkerManager.ts index 0102e3b..b381eb1 100755 --- a/packages/runtime/src/workers/WorkerManager.ts +++ b/packages/runtime/src/workers/WorkerManager.ts @@ -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', { diff --git a/packages/workers/src/WorkerRuntime.ts b/packages/workers/src/WorkerRuntime.ts index 357915b..6dfb788 100755 --- a/packages/workers/src/WorkerRuntime.ts +++ b/packages/workers/src/WorkerRuntime.ts @@ -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 }> + tool_calls?: Array<{ id?: string; name: string; arguments: Record }> } export class WorkerRuntime { diff --git a/packages/workers/src/roles/ExecutorRole.ts b/packages/workers/src/roles/ExecutorRole.ts index 2dbf4cb..8c5e37a 100755 --- a/packages/workers/src/roles/ExecutorRole.ts +++ b/packages/workers/src/roles/ExecutorRole.ts @@ -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 } + 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 } + const { id, name, args } = action as { id: string; name: string; args: Record } 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 } - > { - const actions: Array = [] + 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 }> = [] + ): 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 = { 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 = { - '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 = {} - 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 | null { + try { + const parsed = JSON.parse(raw) as { id?: string; tool?: string; name?: string; args?: Record; arguments?: Record } + 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 => 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> = [] + 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 + } } \ No newline at end of file diff --git a/packages/workers/test/executor-role.test.ts b/packages/workers/test/executor-role.test.ts new file mode 100755 index 0000000..51070f3 --- /dev/null +++ b/packages/workers/test/executor-role.test.ts @@ -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 }> = [] + 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) { + 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) { + 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' }]) + }) +})