fix(agent): recover from fake tool execution claims

This commit is contained in:
William Valentin
2026-02-22 23:11:12 -08:00
parent 7e7685a194
commit 8411641061
3 changed files with 95 additions and 6 deletions
+58
View File
@@ -1041,6 +1041,64 @@ describe('NativeAgent tool loop', () => {
expect(mockClient.chat).toHaveBeenCalledTimes(2);
});
it('nudges when response claims a known tool failed without emitting any tool call', async () => {
let callCount = 0;
const mockClient: ModelClient = {
chat: vi.fn().mockImplementation((request: ChatRequest) => {
callCount++;
if (callCount === 1) {
return {
content: 'Still failing: gmail.filter.create returns Insufficient Permission.',
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
};
}
if (callCount === 2) {
expect(JSON.stringify(request.messages)).toContain('no tool call was emitted');
return {
content: '',
stopReason: 'tool_use',
usage: { inputTokens: 10, outputTokens: 5 },
toolCalls: [{ id: 'call_2', name: 'gmail_filter_create', args: { query: 'from:no-reply@gandi.net' } }],
};
}
return {
content: 'Created after retry.',
stopReason: 'end_turn',
usage: { inputTokens: 12, outputTokens: 6 },
};
}),
};
const gmailFilterTool: Tool = {
name: 'gmail.filter.create',
description: 'Create gmail filter',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
},
},
execute: async () => ({ success: true, output: 'ok' }),
};
const registry = new ToolRegistry();
registry.register(gmailFilterTool);
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('Create the Gmail filter');
expect(response).toBe('Created after retry.');
expect(mockClient.chat).toHaveBeenCalledTimes(3);
});
it('works without tools (backward compatible)', async () => {
const mockClient: ModelClient = {
chat: vi.fn().mockResolvedValue({
+23 -5
View File
@@ -221,6 +221,7 @@ export class NativeAgent {
throw new Error('Tool loop requires tool registry and executor');
}
const tools = toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext);
const availableToolNames = toolRegistry.filteredList(this._toolPolicyContext).map((tool) => tool.name);
// Track whether untrusted content (web/fetched/tool output) has been introduced
// during this run. Used to harden against prompt injection.
@@ -306,7 +307,7 @@ export class NativeAgent {
const wantsToolUse = toolCalls.length > 0;
if (!wantsToolUse) {
const pseudoToolUse = this.extractPseudoToolUse(response.content);
if (this.shouldNudgeForMissingToolCall(response.content, pseudoToolUse) && !actionIntentNudged) {
if (this.shouldNudgeForMissingToolCall(response.content, pseudoToolUse, availableToolNames) && !actionIntentNudged) {
actionIntentNudged = true;
const normalized = this.normalizeAssistantContent(response.content);
loopMessages.push({ role: 'assistant', content: normalized });
@@ -483,19 +484,36 @@ export class NativeAgent {
return warningMsg;
}
private shouldNudgeForMissingToolCall(content: string, pseudoToolUse: PseudoToolUse | null): boolean {
private shouldNudgeForMissingToolCall(
content: string,
pseudoToolUse: PseudoToolUse | null,
availableToolNames: string[],
): boolean {
if (!content || pseudoToolUse) {
return false;
}
const normalized = content.toLowerCase();
const intentRegex = /\b(i(?:'m| am)? going to|i(?:'ll| will)|let me|proceeding(?: now)?|i can(?: now)?)\b/;
if (!intentRegex.test(normalized)) {
const actionRegex = /\b(create|run|execute|call|use|check|fetch|search|read|write|send|retry|proceed|attempt|apply|delete|update|list)\b/;
if (intentRegex.test(normalized) && actionRegex.test(normalized)) {
return true;
}
// Also catch "execution claims" (success/failure statements) that mention a known tool
// without actually emitting any tool calls in the response payload.
const claimRegex = /\b(still failing|failed|failing|error|blocked|insufficient permission|returns?|returned|result|succeeded|success|completed|done|tried|attempted|executed|ran|called|used)\b/;
if (!claimRegex.test(normalized)) {
return false;
}
const actionRegex = /\b(create|run|execute|call|use|check|fetch|search|read|write|send|retry|proceed|attempt|apply|delete|update|list)\b/;
return actionRegex.test(normalized);
const mentionsKnownTool = availableToolNames.some((name) => {
const dotName = name.toLowerCase();
const underscoreName = dotName.replace(/\./g, '_');
return normalized.includes(dotName) || normalized.includes(underscoreName);
});
return mentionsKnownTool;
}
private async chatWithRouter(request: ChatRequest): Promise<ChatResponse> {