From b6c8d8ddf445d4c85628daee0c8ede91f4ced8c6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 19 Feb 2026 08:55:41 -0800 Subject: [PATCH] fix(native-agent): recover textual tool_use JSON calls --- src/backends/native/agent.test.ts | 38 ++++++++ src/backends/native/agent.ts | 157 +++++++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 14 deletions(-) diff --git a/src/backends/native/agent.test.ts b/src/backends/native/agent.test.ts index ab7bc10..c2e85d1 100644 --- a/src/backends/native/agent.test.ts +++ b/src/backends/native/agent.test.ts @@ -337,6 +337,44 @@ describe('NativeAgent tool loop', () => { expect(history[history.length - 1]).toEqual({ role: 'assistant', content: response }); }); + it('recovers and executes valid textual tool_use JSON for registered tools', async () => { + let callCount = 0; + const mockClient: ModelClient = { + chat: vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { + content: 'Running tool now: {"type":"tool_use","id":"call_123","name":"test_echo","input":{"text":"hello"}}', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + }; + } + + return { + content: 'The tool returned: hello', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + }; + }), + }; + + const registry = new ToolRegistry(); + registry.register(echoTool); + const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); + const executor = new ToolExecutor(registry, hooks); + + const agent = new NativeAgent({ + modelClient: mockClient, + systemPrompt: 'You are helpful.', + toolRegistry: registry, + toolExecutor: executor, + }); + + const response = await agent.process('echo hello'); + expect(response).toBe('The tool returned: hello'); + expect(mockClient.chat).toHaveBeenCalledTimes(2); + }); + it('works without tools (backward compatible)', async () => { const mockClient: ModelClient = { chat: vi.fn().mockResolvedValue({ diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index d6e3638..464f2e8 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -1,4 +1,4 @@ -import type { ModelClient, Message, ChatRequest, ChatResponse, TokenUsage } from '../../models/types.js'; +import type { ModelClient, Message, ChatRequest, ChatResponse, ModelToolCall, TokenUsage } from '../../models/types.js'; import type { ModelRouter, ModelTier } from '../../models/router.js'; import type { Session } from '../../session/index.js'; import type { ToolRegistry } from '../../tools/registry.js'; @@ -56,6 +56,12 @@ interface PseudoToolUse { id?: string; } +interface ExtractedTextToolCall { + toolCall: ModelToolCall; + start: number; + end: number; +} + export class NativeAgent { private static readonly EMPTY_RESPONSE_FALLBACK = 'I could not generate a response for that. Please try again.'; @@ -237,11 +243,22 @@ export class NativeAgent { this._totalUsage.outputTokens += response.usage.outputTokens; this._callCount++; - // If the model didn't request tool use, we're done. - // Check both 'tool_use' (Anthropic) and 'tool_calls' (OpenAI-compatible) stop reasons, - // but always require actual toolCalls to be present. - const wantsToolUse = (response.stopReason === 'tool_use' || response.stopReason === 'tool_calls') - && response.toolCalls && response.toolCalls.length > 0; + // Some backends emit tool_use JSON as plain text rather than structured tool metadata. + // Recover those calls when possible so the loop can continue safely. + let toolCalls = response.toolCalls ?? []; + let assistantTextContent = response.content; + if (toolCalls.length === 0) { + const extracted = this.extractToolCallsFromText(response.content); + if (extracted && extracted.toolCalls.length > 0) { + const executableCalls = extracted.toolCalls.filter(tc => Boolean(toolRegistry.getByApiName(tc.name))); + if (executableCalls.length > 0) { + toolCalls = executableCalls; + assistantTextContent = extracted.remainingText; + } + } + } + + const wantsToolUse = toolCalls.length > 0; if (!wantsToolUse) { const pseudoToolUse = this.extractPseudoToolUse(response.content); const baseContent = pseudoToolUse @@ -256,12 +273,6 @@ export class NativeAgent { return finalContent; } - // Safe to assert non-null — wantsToolUse guarantees toolCalls exists and is non-empty - const toolCalls = response.toolCalls; - if (!toolCalls || toolCalls.length === 0) { - continue; - } - // Check for repeated tool calls — build a fingerprint from tool names + args const fingerprint = toolCalls .map(tc => `${tc.name}:${JSON.stringify(tc.args)}`) @@ -287,8 +298,8 @@ export class NativeAgent { // Build the assistant message with tool_use content blocks const assistantContent: unknown[] = []; - if (response.content) { - assistantContent.push({ type: 'text', text: response.content }); + if (assistantTextContent) { + assistantContent.push({ type: 'text', text: assistantTextContent }); } for (const tc of toolCalls) { assistantContent.push({ @@ -584,6 +595,124 @@ export class NativeAgent { }; } + private extractToolCallsFromText(content: string): { toolCalls: ModelToolCall[]; remainingText: string } | null { + if (!content || content.indexOf('{') === -1) { + return null; + } + + const extracted: ExtractedTextToolCall[] = []; + for (let i = 0; i < content.length; i++) { + if (content[i] !== '{') { + continue; + } + const end = this.findJsonObjectEnd(content, i); + if (end === -1) { + continue; + } + const candidate = content.slice(i, end + 1); + const parsedCall = this.parseTextToolUse(candidate, extracted.length + 1); + if (parsedCall) { + extracted.push({ + toolCall: parsedCall, + start: i, + end: end + 1, + }); + } + i = end; + } + + if (extracted.length === 0) { + return null; + } + + let remainingText = ''; + let cursor = 0; + for (const item of extracted) { + remainingText += content.slice(cursor, item.start); + cursor = item.end; + } + remainingText += content.slice(cursor); + remainingText = remainingText.trim(); + + return { + toolCalls: extracted.map(e => e.toolCall), + remainingText, + }; + } + + private parseTextToolUse(candidate: string, ordinal: number): ModelToolCall | null { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const obj = parsed as Record; + if (obj.type !== 'tool_use') { + return null; + } + if (typeof obj.name !== 'string' || obj.name.trim().length === 0) { + return null; + } + + const id = typeof obj.id === 'string' && obj.id.trim().length > 0 + ? obj.id + : `text_tool_call_${ordinal}`; + + return { + id, + name: obj.name, + args: obj.input ?? {}, + }; + } + + private findJsonObjectEnd(content: string, start: number): number { + let depth = 0; + let inString = false; + let escaping = false; + + for (let i = start; i < content.length; i++) { + const ch = content[i]; + + if (inString) { + if (escaping) { + escaping = false; + continue; + } + if (ch === '\\') { + escaping = true; + continue; + } + if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + depth++; + continue; + } + if (ch === '}') { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; + } + private buildPseudoToolUseWarning(rawContent: string, pseudo: PseudoToolUse): string { const toolName = pseudo.name ?? 'unknown'; const toolId = pseudo.id ?? 'unknown';