mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user