From 7e7685a19460b4911e6fd2cc8b912750ccfc55ae Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 23:01:52 -0800 Subject: [PATCH] fix(agent): recover when action intent has no tool call --- docs/plans/state.json | 15 +++++- src/backends/native/agent.test.ts | 83 +++++++++++++++++++++++++++++++ src/backends/native/agent.ts | 26 ++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 86cc8f9..bc3fdbf 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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" diff --git a/src/backends/native/agent.test.ts b/src/backends/native/agent.test.ts index bacebce..e53e3df 100644 --- a/src/backends/native/agent.test.ts +++ b/src/backends/native/agent.test.ts @@ -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({ diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index 972071d..e1e3128 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -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 { const runSignal = this._runAbortController?.signal; const requestSignal = request.signal;