fix(native-agent): recover textual tool_use JSON calls
This commit is contained in:
+143
-14
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user