feat: improve agent loop resilience — same-tool nudging and error handling
- agent.ts: track consecutive calls to the same tool (ignoring args) and inject a nudge after 4 repeats telling the model to summarize and respond, preventing local models from endlessly retrying searches with slight query variations - agent.ts: wrap the entire tool loop iteration in try-catch so model/network errors don't crash the daemon — returns a descriptive error message instead - Tests for both: nudge triggers after 4 same-tool calls, error recovery persists to history
This commit is contained in:
+116
-79
@@ -150,96 +150,133 @@ export class NativeAgent {
|
||||
const maxConsecutiveRepeats = 3;
|
||||
let lastToolResults: string[] = [];
|
||||
|
||||
// Track consecutive calls to the same tool (even with different args).
|
||||
// Local models often call the same tool with slight query variations.
|
||||
let lastToolName: string | undefined;
|
||||
let sameToolStreak = 0;
|
||||
const maxSameToolStreak = 4; // nudge after 4 calls to the same tool
|
||||
let nudged = false;
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Build request — cast loopMessages to Message[] because the underlying
|
||||
// model client will pass them through to the API which accepts structured content.
|
||||
const request = {
|
||||
messages: loopMessages as unknown as Message[],
|
||||
system: effectiveSystem,
|
||||
tools,
|
||||
...(this._thinking ? { thinking: true } : {}),
|
||||
};
|
||||
try {
|
||||
// Build request — cast loopMessages to Message[] because the underlying
|
||||
// model client will pass them through to the API which accepts structured content.
|
||||
const request = {
|
||||
messages: loopMessages as unknown as Message[],
|
||||
system: effectiveSystem,
|
||||
tools,
|
||||
...(this._thinking ? { thinking: true } : {}),
|
||||
};
|
||||
|
||||
const response = await this.chatWithRouter(request);
|
||||
const response = await this.chatWithRouter(request);
|
||||
|
||||
this._totalUsage.inputTokens += response.usage.inputTokens;
|
||||
this._totalUsage.outputTokens += response.usage.outputTokens;
|
||||
this._callCount++;
|
||||
this._totalUsage.inputTokens += response.usage.inputTokens;
|
||||
this._totalUsage.outputTokens += response.usage.outputTokens;
|
||||
this._callCount++;
|
||||
|
||||
// If the model didn't request tool use, we're done
|
||||
if (response.stopReason !== 'tool_use' || !response.toolCalls?.length) {
|
||||
let finalContent = response.content;
|
||||
if (response.thinkingContent) {
|
||||
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${response.content}`;
|
||||
// If the model didn't request tool use, we're done
|
||||
if (response.stopReason !== 'tool_use' || !response.toolCalls?.length) {
|
||||
let finalContent = response.content;
|
||||
if (response.thinkingContent) {
|
||||
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${response.content}`;
|
||||
}
|
||||
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
||||
this.addToHistory(assistantMsg);
|
||||
return finalContent;
|
||||
}
|
||||
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
||||
this.addToHistory(assistantMsg);
|
||||
return finalContent;
|
||||
}
|
||||
|
||||
// Check for repeated tool calls — build a fingerprint from tool names + args
|
||||
const fingerprint = response.toolCalls
|
||||
.map(tc => `${tc.name}:${JSON.stringify(tc.args)}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
// Check for repeated tool calls — build a fingerprint from tool names + args
|
||||
const fingerprint = response.toolCalls
|
||||
.map(tc => `${tc.name}:${JSON.stringify(tc.args)}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
if (fingerprint === lastFingerprint) {
|
||||
consecutiveRepeats++;
|
||||
} else {
|
||||
consecutiveRepeats = 1;
|
||||
lastFingerprint = fingerprint;
|
||||
}
|
||||
|
||||
// Build the assistant message with tool_use content blocks
|
||||
const assistantContent: unknown[] = [];
|
||||
if (response.content) {
|
||||
assistantContent.push({ type: 'text', text: response.content });
|
||||
}
|
||||
for (const tc of response.toolCalls) {
|
||||
assistantContent.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: tc.args,
|
||||
});
|
||||
}
|
||||
loopMessages.push({ role: 'assistant', content: assistantContent });
|
||||
|
||||
// Execute each tool call and collect results
|
||||
const toolResultBlocks: unknown[] = [];
|
||||
lastToolResults = [];
|
||||
for (const tc of response.toolCalls) {
|
||||
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name;
|
||||
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
|
||||
|
||||
const result = await this.toolExecutor!.execute(internalName, tc.args, this._toolPolicyContext);
|
||||
|
||||
this.onToolUse?.({ type: 'end', tool: internalName, result });
|
||||
|
||||
const resultContent = result.success ? result.output : (result.error ?? 'Unknown error');
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tc.id,
|
||||
content: resultContent,
|
||||
is_error: !result.success,
|
||||
});
|
||||
if (result.success && result.output) {
|
||||
lastToolResults.push(result.output);
|
||||
if (fingerprint === lastFingerprint) {
|
||||
consecutiveRepeats++;
|
||||
} else {
|
||||
consecutiveRepeats = 1;
|
||||
lastFingerprint = fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool results as a user message
|
||||
loopMessages.push({ role: 'user', content: toolResultBlocks });
|
||||
// Track consecutive calls to the same tool (by name, ignoring args)
|
||||
const toolNames = response.toolCalls.map(tc => tc.name).sort().join(',');
|
||||
if (toolNames === lastToolName) {
|
||||
sameToolStreak++;
|
||||
} else {
|
||||
sameToolStreak = 1;
|
||||
lastToolName = toolNames;
|
||||
nudged = false;
|
||||
}
|
||||
|
||||
// Break out if the model is stuck in a repeated tool call loop
|
||||
if (consecutiveRepeats >= maxConsecutiveRepeats) {
|
||||
const toolOutput = lastToolResults.length > 0
|
||||
? lastToolResults.join('\n\n')
|
||||
: 'No results available.';
|
||||
const breakMsg = `Tool loop detected (same tool called ${consecutiveRepeats} times). Returning last results:\n\n${toolOutput}`;
|
||||
const assistantMsg: Message = { role: 'assistant', content: breakMsg };
|
||||
// Build the assistant message with tool_use content blocks
|
||||
const assistantContent: unknown[] = [];
|
||||
if (response.content) {
|
||||
assistantContent.push({ type: 'text', text: response.content });
|
||||
}
|
||||
for (const tc of response.toolCalls) {
|
||||
assistantContent.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
input: tc.args,
|
||||
});
|
||||
}
|
||||
loopMessages.push({ role: 'assistant', content: assistantContent });
|
||||
|
||||
// Execute each tool call and collect results
|
||||
const toolResultBlocks: unknown[] = [];
|
||||
lastToolResults = [];
|
||||
for (const tc of response.toolCalls) {
|
||||
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name;
|
||||
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
|
||||
|
||||
const result = await this.toolExecutor!.execute(internalName, tc.args, this._toolPolicyContext);
|
||||
|
||||
this.onToolUse?.({ type: 'end', tool: internalName, result });
|
||||
|
||||
const resultContent = result.success ? result.output : (result.error ?? 'Unknown error');
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: tc.id,
|
||||
content: resultContent,
|
||||
is_error: !result.success,
|
||||
});
|
||||
if (result.success && result.output) {
|
||||
lastToolResults.push(result.output);
|
||||
}
|
||||
}
|
||||
|
||||
// If the same tool has been called too many times, append a nudge
|
||||
// telling the model to use what it has. This combats local models
|
||||
// that endlessly retry searches with slight query variations.
|
||||
if (sameToolStreak >= maxSameToolStreak && !nudged) {
|
||||
nudged = true;
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: '__system',
|
||||
content: `You have called this tool ${sameToolStreak} times in a row. You have enough information — do NOT call it again. Summarize what you have found and respond to the user now.`,
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool results as a user message
|
||||
loopMessages.push({ role: 'user', content: toolResultBlocks });
|
||||
|
||||
// Break out if the model is stuck in a repeated tool call loop
|
||||
if (consecutiveRepeats >= maxConsecutiveRepeats) {
|
||||
const toolOutput = lastToolResults.length > 0
|
||||
? lastToolResults.join('\n\n')
|
||||
: 'No results available.';
|
||||
const breakMsg = `Tool loop detected (same tool called ${consecutiveRepeats} times). Returning last results:\n\n${toolOutput}`;
|
||||
const assistantMsg: Message = { role: 'assistant', content: breakMsg };
|
||||
this.addToHistory(assistantMsg);
|
||||
return breakMsg;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error in tool loop (iteration ${iteration + 1}): ${error instanceof Error ? error.message : String(error)}`;
|
||||
const assistantMsg: Message = { role: 'assistant', content: errorMsg };
|
||||
this.addToHistory(assistantMsg);
|
||||
return breakMsg;
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user