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