Files
HealthLog/src/lib/ai/local-reasoning.ts
T
Marc-André Bombeck 7697ac8970 refactor(ai): move reasoning helpers and the Coach error resolver into their own files
The reasoning separation grew three files past their size ratchet. The Local
client's reasoning collector and the new separation helpers move to
local-reasoning.ts, the gateway client's thinking room and reply separation
become two private methods, and the Coach's error-code resolver moves out of
chat-bubble.tsx into coach-error-key.ts, still re-exported from
message-thread.tsx. Code motion apart from comments that said when a branch
was added rather than what it does. The baselines record the shorter files.

Refs #1276
2026-10-24 18:47:04 +02:00

125 lines
4.3 KiB
TypeScript

/**
* What the Local client does with the reasoning a server sends back: collect
* it for the caller that asked, and keep it out of the answer for every
* caller. Split out of `local-client.ts`; that client is its only importer.
*
* Local servers return the reasoning as `reasoning_content` (llama.cpp,
* LiteLLM) or `reasoning` (vLLM, Ollama, LM Studio), on the message and on
* each streamed delta, or inline in `content`.
* https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md,
* https://docs.vllm.ai/en/latest/features/reasoning_outputs.html,
* https://docs.ollama.com/api/openai-compatibility
*
* Server-only.
*/
import { emitSignal } from "@/lib/logging/signal";
import { answerOf, reasoningTitleOf } from "./reasoning/support";
import type { CompletionParams, CompletionResult } from "./types";
/**
* Collects what a call reasoned and reports it to `onReasoning`, for
* a caller that asked for reasoning; any other caller is told nothing.
*/
export class ReasoningCollector {
private text = "";
private titled = false;
private finished = false;
constructor(private readonly params: CompletionParams) {}
private get emit() {
return this.params.reasoning ? this.params.onReasoning : undefined;
}
/** A streamed piece of reasoning; the title goes out once it has closed. */
add(delta: string): void {
this.text += delta;
if (!this.titled && this.emit) {
const title = reasoningTitleOf(this.text);
if (title) {
this.titled = true;
this.emit({ kind: "title", text: title });
}
}
}
/** The reasoning is complete (the answer started, or the call ended). */
finish(): void {
if (this.finished) return;
this.finished = true;
const text = this.text.trim();
if (!text || !this.emit) return;
if (!this.titled) {
const title = reasoningTitleOf(text);
if (title) this.emit({ kind: "title", text: title });
}
this.emit({ kind: "text", text });
}
result(tokens: number | null): CompletionResult["reasoning"] {
this.finish();
if (!this.params.reasoning) return undefined;
this.emit?.({ kind: "done", text: "" });
const text = this.text.trim();
return { summary: text ? [text] : [], tokens };
}
}
/** The reasoning text a non-streamed message carries, if any. */
export function messageReasoning(message: unknown): string | null {
const m = (message ?? {}) as Record<string, unknown>;
const text = [m.reasoning_content, m.reasoning].find(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
return text ?? null;
}
/**
* #1276 — take the reasoning out of `content` (`answerOf`: an inline
* `<think>` block or only its closing tag, or unlabelled text that filled a
* ceiling sized for thinking) and hand it to `collector`. Returns the answer
* and whether anything was taken. For every caller: reasoning is never an
* answer, asked for or not.
*/
export function takeReasoning(
collector: ReasoningCollector,
content: string,
reply: {
fieldedReasoning: boolean;
finishReason: string | undefined;
reasoningRoom: boolean;
},
): { content: string; took: boolean } {
const separated = answerOf(content, { ...reply, hasToolCalls: false });
if (separated.reasoning) collector.add(separated.reasoning);
if (separated.unseparated) {
emitSignal({
action: "ai.local.reasoning_unseparated",
level: "warn",
meta: { provider: "local" },
});
}
return { content: separated.content, took: separated.content !== content };
}
/**
* The answer and the reasoning of a non-streamed message: the reasoning
* fields beside `content`, and whatever `takeReasoning` finds inside it.
*/
export function separateMessage(
params: CompletionParams,
message: { content?: string | null } | undefined,
finishReason: string | undefined,
reasoningRoom: boolean,
): { content: string; hadReasoning: boolean; collector: ReasoningCollector } {
const collector = new ReasoningCollector(params);
const fielded = messageReasoning(message);
if (fielded) collector.add(fielded);
const { content, took } = takeReasoning(collector, message?.content ?? "", {
fieldedReasoning: fielded !== null,
finishReason,
reasoningRoom,
});
return { content, hadReasoning: fielded !== null || took, collector };
}