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
+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 { 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<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 {
const toolName = pseudo.name ?? 'unknown';
const toolId = pseudo.id ?? 'unknown';