fix(agent): recover when action intent has no tool call

This commit is contained in:
William Valentin
2026-02-22 23:01:52 -08:00
parent 50c4f3de57
commit 7e7685a194
3 changed files with 123 additions and 1 deletions
+14 -1
View File
@@ -3,6 +3,18 @@
"updated_at": "2026-02-23",
"description": "Tracks the status of all Flynn plans and implementation phases",
"plans": {
"toolloop-action-intent-recovery": {
"status": "completed",
"date": "2026-02-23",
"updated": "2026-02-23",
"summary": "Hardened NativeAgent tool-loop behavior when models narrate imminent actions without emitting tool calls. Added a one-time in-loop nudge for action-intent text with missing tool calls, so Flynn re-prompts the model to either execute the tool now or return an explicit blocker in the same turn.",
"files_modified": [
"src/backends/native/agent.ts",
"src/backends/native/agent.test.ts",
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/backends/native/agent.test.ts + pnpm typecheck passing"
},
"gmail-filter-full-scope-auth": {
"status": "completed",
"date": "2026-02-23",
@@ -6287,7 +6299,7 @@
}
},
"overall_progress": {
"total_test_count": 1958,
"total_test_count": 1960,
"all_tests_passing": true,
"p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)",
@@ -6307,6 +6319,7 @@
"dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling",
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
"gmail_filter_creation": "completed — gmail.filter.create tool added with criteria/action validation; gmail-auth now requests full Gmail scope (https://mail.google.com/) for complete filter permissions",
"toolloop_action_intent_recovery": "completed — when a model claims it will execute a tool but emits no tool call, NativeAgent now issues one internal nudge and continues the same turn to execute tools or produce a concrete blocker",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback, plus 2026-02-23 arg hydration hardening, tool.args_rewritten audit metric, transient fetch retry/timeout hardening, localhost->127.0.0.1 fallback for transcription endpoint connectivity, and whisper docker-compose entrypoint arg fix for port 18801",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
"next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas"
+83
View File
@@ -958,6 +958,89 @@ describe('NativeAgent tool loop', () => {
expect(seenCommands[0]).toBe('grep -r "createCouncilRunTool" /home/will/lab/flynn/src --include="*.ts" | head -20');
});
it('nudges once when model claims it will act but emits no tool call, then recovers', async () => {
let callCount = 0;
const mockClient: ModelClient = {
chat: vi.fn().mockImplementation((request: ChatRequest) => {
callCount++;
if (callCount === 1) {
return {
content: 'I am going to create that filter now. Proceeding.',
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: 'test.echo', args: { text: 'filter-created' } }],
};
}
return {
content: 'Filter created.',
stopReason: 'end_turn',
usage: { inputTokens: 12, outputTokens: 6 },
};
}),
};
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('Create the filter');
expect(response).toBe('Filter created.');
expect(mockClient.chat).toHaveBeenCalledTimes(3);
});
it('returns follow-up text after one missing-tool nudge when still no tool call', async () => {
let callCount = 0;
const mockClient: ModelClient = {
chat: vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return {
content: 'I will run the lookup now.',
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
};
}
return {
content: 'Blocked: tool unavailable in current mode.',
stopReason: 'end_turn',
usage: { inputTokens: 11, outputTokens: 6 },
};
}),
};
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('Do the lookup');
expect(response).toBe('Blocked: tool unavailable in current mode.');
expect(mockClient.chat).toHaveBeenCalledTimes(2);
});
it('works without tools (backward compatible)', async () => {
const mockClient: ModelClient = {
chat: vi.fn().mockResolvedValue({
+26
View File
@@ -259,6 +259,7 @@ export class NativeAgent {
let sameToolStreak = 0;
const maxSameToolStreak = 4; // nudge after 4 calls to the same tool
let nudged = false;
let actionIntentNudged = false;
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
try {
@@ -305,6 +306,16 @@ export class NativeAgent {
const wantsToolUse = toolCalls.length > 0;
if (!wantsToolUse) {
const pseudoToolUse = this.extractPseudoToolUse(response.content);
if (this.shouldNudgeForMissingToolCall(response.content, pseudoToolUse) && !actionIntentNudged) {
actionIntentNudged = true;
const normalized = this.normalizeAssistantContent(response.content);
loopMessages.push({ role: 'assistant', content: normalized });
loopMessages.push({
role: 'user',
content: 'You said you would perform an action now, but no tool call was emitted. If a tool is needed, call it now. If blocked, explain the exact blocker.',
});
continue;
}
const baseContent = pseudoToolUse
? this.buildPseudoToolUseWarning(response.content, pseudoToolUse)
: this.normalizeAssistantContent(response.content);
@@ -472,6 +483,21 @@ export class NativeAgent {
return warningMsg;
}
private shouldNudgeForMissingToolCall(content: string, pseudoToolUse: PseudoToolUse | null): 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)) {
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);
}
private async chatWithRouter(request: ChatRequest): Promise<ChatResponse> {
const runSignal = this._runAbortController?.signal;
const requestSignal = request.signal;