fix(ai): treat Anthropic tool_use blocks as authoritative for finishReason

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 08:12:11 +02:00
1 parent 7b0dcbf560
commit b6f0ad61cb
2 files changed
+63 -6

No files matched your search

@@ -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");
});
});
+14 -6
View File
@@ -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,