From c9673883b1518cfe8bd6f2d30433412953240b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 18:20:35 +0200 Subject: [PATCH] fix(ai): run gateway tool calls whatever finish reason comes beside them The Coach loop runs a round's tool calls only when the round finished with "tool_calls". The Anthropic and Codex clients already derive that from the calls themselves; the OpenAI-compatible client copied the server's finish_reason, and vLLM, Ollama and LiteLLM can report "stop" beside a parsed tool call. The loop then took the round as the answer, the call never ran, and what the person saw was whatever text came with it. A reply that carries tool calls now reports "tool_calls", as on the other wires. Refs #1276 --- .../__tests__/tools-loop-always-reasoning-gateway.test.ts | 7 +++++++ src/lib/ai/openai-client.ts | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts b/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts index b5071cc0c..9b7a56d34 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts @@ -312,6 +312,13 @@ describe("#1276: a gateway model that always reasons", () => { }, ); + it("runs a tool call the server reports with finish_reason stop", async () => { + toolFinish = "stop"; + const out = await turn("medium"); + expect(executeCoachTool).toHaveBeenCalledTimes(1); + expect(out.result.content).toBe(ANSWER); + }); + it("fails as reasoning_only, never with the thought, when no answer ever comes", async () => { parser = "raw"; reasoningWords = 20_000; diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 4f94b2035..20dce6fea 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -554,7 +554,12 @@ export class OpenAIClient implements AIProvider { model: this.config.model, providerType: this.type, ...(toolCalls ? { toolCalls } : {}), - finishReason: mapFinishReason(choice?.finish_reason), + // #1276 — the tool calls themselves decide, as on the Anthropic and + // Codex wires: vLLM, Ollama and LiteLLM can put `stop` beside them, + // and the loop runs calls only on `tool_calls`. + finishReason: toolCalls + ? "tool_calls" + : mapFinishReason(choice?.finish_reason), ...(params.reasoning ? { reasoning: {