From b6f0ad61cb97f3f52284861fad1fa5b6e2f153ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 08:12:11 +0200 Subject: [PATCH] fix(ai): treat Anthropic tool_use blocks as authoritative for finishReason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic client derived `finishReason` solely from `stop_reason`. The Coach tool loop continues only when `finishReason === "tool_calls"`, so if Anthropic ever returned tool_use blocks under a different (or absent) stop_reason, the reason resolved to undefined, the loop dropped the tool request, and the empty tool_use-only reply surfaced as the final answer — the requested data was never fetched. Set `finishReason` to "tool_calls" whenever tool_use blocks are present, clamped ahead of the stop_reason map, so the presence of a tool request is authoritative regardless of the reported stop reason. --- src/lib/ai/__tests__/anthropic-client.test.ts | 49 +++++++++++++++++++ src/lib/ai/anthropic-client.ts | 20 +++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts index e31fa0d85..fe637eabe 100644 --- a/src/lib/ai/__tests__/anthropic-client.test.ts +++ b/src/lib/ai/__tests__/anthropic-client.test.ts @@ -337,4 +337,53 @@ describe("AnthropicClient", () => { expect(result.finishReason).toBe("tool_calls"); expect(result.cachedInputTokens).toBe(40); }); + + it("derives finishReason from tool_use blocks even on an unexpected stop_reason (M-2)", async () => { + // Anthropic normally pairs tool_use blocks with stop_reason "tool_use". + // If it ever returns the blocks under a different (or absent) stop_reason, + // the loop must still see finishReason "tool_calls" so it executes the tool + // round instead of surfacing the empty tool_use-only reply as the answer. + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + content: [ + { + type: "tool_use", + id: "toolu_9", + name: "fetch_glucose", + input: { window: "last30days" }, + }, + ], + // Deliberately NOT "tool_use" — the pre-fix code derived finishReason + // from this field alone and would have returned undefined here. + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 4 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new AnthropicClient({ + apiKey: "sk-ant-x", + model: "claude-sonnet-4-6", + }); + + const result = await client.generateCompletion( + singleUserTurn({ + system: "s", + user: "u", + tools: [ + { + name: "fetch_glucose", + description: "Fetch glucose readings", + parameters: { type: "object", properties: {} }, + }, + ], + toolChoice: "auto", + }), + ); + + expect(result.toolCalls).toHaveLength(1); + expect(result.finishReason).toBe("tool_calls"); + }); }); diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index afb25ef37..97162de03 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -335,14 +335,22 @@ export class AnthropicClient implements AIProvider { const tokensUsed = inputTokens + outputTokens || null; const cachedInputTokens = json.usage?.cache_read_input_tokens ?? null; + // The presence of tool_use blocks is authoritative for the tool-loop gate: + // F1's loop continues only when `finishReason === "tool_calls"`. Anthropic + // normally pairs tool_use blocks with `stop_reason: "tool_use"`, but if it + // ever returns the blocks under a different (or absent) stop_reason, deriving + // the reason from `stop_reason` alone would drop the tool request and the + // loop would surface the empty tool_use-only reply as the final answer. const finishReason: CompletionResult["finishReason"] = - json.stop_reason === "tool_use" + toolCalls && toolCalls.length > 0 ? "tool_calls" - : json.stop_reason === "max_tokens" - ? "length" - : json.stop_reason === "end_turn" - ? "stop" - : undefined; + : json.stop_reason === "tool_use" + ? "tool_calls" + : json.stop_reason === "max_tokens" + ? "length" + : json.stop_reason === "end_turn" + ? "stop" + : undefined; return { content,