fix(native-agent): recover textual tool_use JSON calls

This commit is contained in:
William Valentin
2026-02-19 08:55:41 -08:00
parent dcbb4649a2
commit b6c8d8ddf4
2 changed files with 181 additions and 14 deletions
+38
View File
@@ -337,6 +337,44 @@ describe('NativeAgent tool loop', () => {
expect(history[history.length - 1]).toEqual({ role: 'assistant', content: response }); 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 () => { it('works without tools (backward compatible)', async () => {
const mockClient: ModelClient = { const mockClient: ModelClient = {
chat: vi.fn().mockResolvedValue({ chat: vi.fn().mockResolvedValue({
+143 -14
View File
@@ -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 { ModelRouter, ModelTier } from '../../models/router.js';
import type { Session } from '../../session/index.js'; import type { Session } from '../../session/index.js';
import type { ToolRegistry } from '../../tools/registry.js'; import type { ToolRegistry } from '../../tools/registry.js';
@@ -56,6 +56,12 @@ interface PseudoToolUse {
id?: string; id?: string;
} }
interface ExtractedTextToolCall {
toolCall: ModelToolCall;
start: number;
end: number;
}
export class NativeAgent { export class NativeAgent {
private static readonly EMPTY_RESPONSE_FALLBACK = private static readonly EMPTY_RESPONSE_FALLBACK =
'I could not generate a response for that. Please try again.'; '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._totalUsage.outputTokens += response.usage.outputTokens;
this._callCount++; this._callCount++;
// If the model didn't request tool use, we're done. // Some backends emit tool_use JSON as plain text rather than structured tool metadata.
// Check both 'tool_use' (Anthropic) and 'tool_calls' (OpenAI-compatible) stop reasons, // Recover those calls when possible so the loop can continue safely.
// but always require actual toolCalls to be present. let toolCalls = response.toolCalls ?? [];
const wantsToolUse = (response.stopReason === 'tool_use' || response.stopReason === 'tool_calls') let assistantTextContent = response.content;
&& response.toolCalls && response.toolCalls.length > 0; 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) { if (!wantsToolUse) {
const pseudoToolUse = this.extractPseudoToolUse(response.content); const pseudoToolUse = this.extractPseudoToolUse(response.content);
const baseContent = pseudoToolUse const baseContent = pseudoToolUse
@@ -256,12 +273,6 @@ export class NativeAgent {
return finalContent; 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 // Check for repeated tool calls — build a fingerprint from tool names + args
const fingerprint = toolCalls const fingerprint = toolCalls
.map(tc => `${tc.name}:${JSON.stringify(tc.args)}`) .map(tc => `${tc.name}:${JSON.stringify(tc.args)}`)
@@ -287,8 +298,8 @@ export class NativeAgent {
// Build the assistant message with tool_use content blocks // Build the assistant message with tool_use content blocks
const assistantContent: unknown[] = []; const assistantContent: unknown[] = [];
if (response.content) { if (assistantTextContent) {
assistantContent.push({ type: 'text', text: response.content }); assistantContent.push({ type: 'text', text: assistantTextContent });
} }
for (const tc of toolCalls) { for (const tc of toolCalls) {
assistantContent.push({ 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<string, unknown>;
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 { private buildPseudoToolUseWarning(rawContent: string, pseudo: PseudoToolUse): string {
const toolName = pseudo.name ?? 'unknown'; const toolName = pseudo.name ?? 'unknown';
const toolId = pseudo.id ?? 'unknown'; const toolId = pseudo.id ?? 'unknown';