Files
HealthLog/src/lib/ai/coach/tools/loop.ts
T
Marc-André Bombeck 511eae8241 fix(jobs): report every caught failure in background jobs or say why not
A catch that drops what it caught is where the silent nightly failures
started. Every `catch` and `.catch()` under src/lib/jobs now rethrows,
fails the job, writes to a ledger an operator reads, counts into the run's
failure facts, hands the error back to its caller, or logs at warn under a
stable action. The enqueue helpers that swallowed a failed send (briefing,
status, narrative, morning digest, document index / summary / thumbnail,
workout insight, boot-time sweeps), the Coach nudge's provider fallback,
the workout and reaction-line cleanups, the advisory-lock release, the
backup attempt bookkeeping and the cycle reminder's dedupe reads now leave
a warn line instead of nothing or an info annotation.

`error-level-discipline-guard` holds the rule. The catches that stay
silent are listed with their reason (the pgboss readers' documented null on
a web-only deployment, the key-ring trial, the restore drill's per-account
result, an already-deleted Telegram message); a new silent catch fails, an
entry that no longer matches fails, and each entry is shown to be
load-bearing by removing it.

A Coach turn cut short by its token, time or round budget writes
`coach.turn.budget_stop` at warn.
2026-10-08 09:06:17 +02:00

634 lines
22 KiB
TypeScript

/**
* The Coach retrieval loop: model round → tool calls → results → the next
* round, until the model answers in prose or the turn's budget says the
* next round must be the answer.
*
* v1.41 — a budget instead of a fixed three rounds. A turn runs under the
* token, time and round limits of whoever pays for it (`turn-budget.ts`), a
* no-progress brake (`progress.ts`) and, round by round, the day's ledger
* (`spend`, reserved before each round and settled after it). Whichever of
* them stops the loop, the next round is the final one: sent with
* `toolChoice: "none"`, with one line telling the model why, and the room
* for it was held back from the start, so a turn always ends with an answer.
* The final round still carries the tool definitions: the history holds the
* earlier calls and their results, and Anthropic refuses a request with
* `tool_use` / `tool_result` blocks that defines no tools (400). `none` with
* the tools present is valid on every wire, thinking included, and keeps the
* cached tools prefix warm.
*
* Each round is a NON-streaming completion over the fallback chain; the
* clients read the provider's stream internally where it has one and report
* reasoning summaries as they arrive (`onReasoning`), which the trail shows
* live. The reasoning state a provider needs back between rounds rides the
* assistant message (`providerState`) and is never kept anywhere else.
*
* Tool calls within a round run in parallel. Beside the read-only catalogue
* the loop runs `compare_series` and the three dialog tools; neither is part
* of the executor, which the MCP surface shares. A call that repeats an
* earlier one of the turn is not run again: the model gets `duplicateOf`.
*
* A clarifying question ends the turn without a prose round: the question
* is the reply.
*/
import { coachPromptCacheKey } from "@/lib/ai/coach/prompt-cache-key";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { annotate } from "@/lib/logging/context";
import { emitSignal } from "@/lib/logging/signal";
import type { Locale } from "@/lib/i18n/config";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import type {
AiMessage,
AiToolCall,
AiToolDef,
CompletionResult,
} from "@/lib/ai/types";
import type { ReasoningLevel } from "@/lib/ai/reasoning/levels";
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
import type {
CoachClarification,
CoachMemoryNote,
CoachPlanProposal,
CoachScope,
CoachScopeWindow,
CoachStop,
CoachStopReason,
} from "@/lib/ai/coach/types";
import {
createNoopActivityRecorder,
type ActivityRecorder,
} from "@/lib/ai/coach/activity/contract";
import {
simpleActivityLabel,
stopActivityLabel,
} from "@/lib/ai/coach/activity/catalog";
import { screenCheckpoint } from "@/lib/ai/coach/activity/screen";
import { parseCoachToolArgs, type CoachToolName } from "./definitions";
import {
executeCoachTool,
type CoachToolResult,
type CoachToolTrace,
type CoachToolTurnContext,
} from "./executor";
import {
COMPARE_SERIES_TOOL_NAME,
compareSeriesArgsSchema,
runCompareSeries,
} from "./compare-series";
import {
ASK_CLARIFICATION_TOOL_NAME,
PROPOSE_PLAN_TOOL_NAME,
isDialogToolName,
runDialogTool,
type DialogToolContext,
} from "./dialog-tools";
import { callScope, callSignature, createProgressTracker } from "./progress";
import {
createTurnBudget,
estimateInputTokens,
type TurnBudget,
} from "./turn-budget";
/**
* The day's ledger, round by round (`turn/budget.ts`). Kept as callbacks so
* the loop stays free of the database.
*/
export interface RoundSpend {
/** Reserve the next round; false when the day's ceiling refuses it. */
reserveRound(estimate: number): Promise<boolean>;
/** Settle a finished round against what it reserved. */
settleRound(usage: {
tokens: number;
cachedTokens: number;
servedBy: ProviderChainType;
final: boolean;
}): Promise<void>;
}
/** What the trail is told about a call as it starts and as it settles. */
export interface LoopCallEvents {
/**
* Fired as each call starts. `index` counts calls across the whole turn
* from 0, so it is stable for a step id. A throwing callback is swallowed:
* progress reporting never breaks the loop.
*/
onCallStart?: (call: AiToolCall, index: number, round: number) => void;
/**
* Fired once for every call that started, when its result is in: found,
* missed, invalid or failed alike.
*/
onCallSettled?: (
call: AiToolCall,
result: CoachToolResult,
index: number,
round: number,
) => void;
/** Fired when every call of a round has settled. */
onRoundSettled?: (round: number) => void;
}
export interface CoachToolLoopResult {
/** The final completion (prose, or the question on a clarification). */
result: CompletionResult;
/** The working provider's type tag. */
workingProviderType: string;
/** Summed tokens across every round (for budget reconcile). */
totalTokens: number;
/** Summed cached-input tokens across every round. */
cachedTokens: number;
/** Number of model round-trips made. */
rounds: number;
/** Which tools ran + whether each found data (persisted onto provenance). */
toolTrace: CoachToolTrace[];
/**
* The structured payloads of every tool result this turn that DELIVERED
* figures, in call order, for the prose number-verifier: a present
* result's `data`, or the `available` block an out-of-window miss carries.
*/
toolResults: CoachToolResult[];
/**
* True when the answer was forced by the budget, the clock or the round
* cap: the "keep looking" chip continues it. A no-progress stop is
* forced too, but continuing it would only circle again.
*/
forcedFinal: boolean;
/** v1.41 — why the answer was forced, when it was. */
stop?: CoachStop;
/** v1.41 — the question, when the turn ended on one. */
clarification?: { question: string; clarification: CoachClarification };
/**
* v1.41 — questions the brake turned into assumptions, for the
* provenance. Absent when there were none.
*/
declinedClarifications?: CoachClarification[];
/** v1.41 — the fact this answer saved or proposes. */
memoryNote?: CoachMemoryNote;
/** v1.41 — the plan this answer proposes. */
planProposal?: CoachPlanProposal;
}
/** v1.39.4 — a tool call to run before the first model round. */
interface CoachSeedCall {
name: CoachToolName;
args: Record<string, unknown>;
}
/** The line the final round is sent with, by why it is final. */
const FINAL_ROUND_LINE: Readonly<Record<CoachStopReason, string>> = {
budget:
"FINAL ROUND: this turn's token budget is spent. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
time: "FINAL ROUND: this turn's time is up. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
cap: "FINAL ROUND: this turn has used its rounds. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
no_progress:
"FINAL ROUND: the last calls brought nothing new. Answer now from the results you already have; name what could not be found in one clause. No tool calls.",
};
/** Tokens of text, for the budget's estimate of what the next round adds. */
function textTokens(value: string): number {
return estimateInputTokens(value.length);
}
export async function runCoachToolLoop(args: {
userId: string;
providers: ProviderChainResolved[];
system: string;
/**
* v1.39.4 — the system prompt for the rounds after a call produced a
* table, when it differs (the table rules ride it).
*/
systemOnceTableShown?: string;
/** The conversation messages (history + the new user turn). */
messages: AiMessage[];
tools: AiToolDef[];
temperature?: number;
maxTokens?: number;
fallbackWindow?: CoachScopeWindow;
/** The turn's shared full-source snapshot scope. */
sharedScope?: CoachScope;
/** The person's Coach lookback limit; every tool call is clamped to it. */
reach: CoachHistoryReach;
ledger?: ProviderHealthLedger;
/** Aborts the per-round provider calls on client disconnect. */
signal?: AbortSignal;
/** Per-user upstream timeout (ms) for each round's provider call. */
timeoutMs?: number;
onCallStart?: LoopCallEvents["onCallStart"];
onCallSettled?: LoopCallEvents["onCallSettled"];
onRoundSettled?: LoopCallEvents["onRoundSettled"];
/** v1.39.4 — accepted, not read yet. */
seedCalls?: ReadonlyArray<CoachSeedCall>;
/** v1.39.4 — the chat turn the loop runs in. */
turn?: CoachToolTurnContext;
/**
* v1.41 — the turn's budget. Absent: the person's own limits, estimated
* from the prompt.
*/
budget?: TurnBudget;
/** v1.41 — the day's ledger, round by round. Absent: nothing is booked. */
spend?: RoundSpend;
/** v1.41 — the reasoning each round asks for, already resolved. */
reasoning?: { effort: ReasoningLevel; summaries: boolean };
/** v1.41 — the live trail. Absent: the no-op recorder. */
activity?: ActivityRecorder;
/** v1.41 — the locale the trail is labelled in. */
locale?: Locale;
/** v1.41 — what the dialog tools need. Absent: they are declined. */
dialog?: DialogToolContext;
/**
* v1.41 — a note the answer already carries before any round (a health
* fact the background found, offered once). It is the answer's one note:
* `remember_fact` is declined beside it.
*/
initialMemoryNote?: CoachMemoryNote;
/** v1.41 — the figures read so far, for screening a checkpoint. */
checkpointScreen?: { userMessage: string };
}): Promise<CoachToolLoopResult> {
const {
userId,
providers,
tools,
temperature,
maxTokens,
fallbackWindow,
sharedScope,
reach,
ledger,
signal,
timeoutMs,
onCallStart,
onCallSettled,
onRoundSettled,
turn,
spend,
reasoning,
dialog,
} = args;
const locale: Locale = args.locale ?? turn?.locale ?? "en";
const activity = args.activity ?? createNoopActivityRecorder();
const budget =
args.budget ??
createTurnBudget({
payer: "user",
effort: reasoning?.effort,
initialInputTokens: textTokens(
args.system +
args.messages
.map((m) => (typeof m.content === "string" ? m.content : ""))
.join(""),
),
});
const progress = createProgressTracker();
// v1.41.2 — one key for every round, so round two finds round one's prefix
// in the provider's prompt cache.
const cacheKey = coachPromptCacheKey(turn?.conversationId);
const messages: AiMessage[] = [...args.messages];
let system = args.system;
let totalTokens = 0;
let cachedTokens = 0;
let rounds = 0;
let workingProviderType = "";
const toolTrace: CoachToolTrace[] = [];
const toolResults: CoachToolResult[] = [];
const declinedClarifications: CoachClarification[] = [];
let memoryNote: CoachMemoryNote | undefined = args.initialMemoryNote;
let planProposal: CoachPlanProposal | undefined;
let callCount = 0;
let stopReason: CoachStopReason | null = null;
let stopSignalled = false;
let progressStalled = false;
for (let round = 1; ; round += 1) {
// ── Whether this round may still fetch ──────────────────────────
if (round > 1 && stopReason === null) {
stopReason = budget.check() ?? (progressStalled ? "no_progress" : null);
if (stopReason === null && spend) {
const admitted = await spend.reserveRound(budget.nextRoundEstimate());
if (!admitted) stopReason = "budget";
}
}
const isFinal = stopReason !== null;
if (stopReason) {
// v1.42 — a turn cut short by its budget is not a failure, but an
// operator who sees answers getting thin needs to count it. It used to
// be one `stop` meta key on the turn's info line.
if (!stopSignalled && stopReason !== "no_progress") {
stopSignalled = true;
emitSignal({
action: "coach.turn.budget_stop",
level: "warn",
meta: {
reason: stopReason,
round,
spent: budget.spent(),
elapsedMs: budget.elapsedMs(),
payer: budget.payer,
},
});
}
const label = stopActivityLabel(locale, stopReason);
const id = activity.start({
phase: "stop",
round,
...label,
stop: stopReason,
});
activity.finish(id, "done");
}
rounds = round;
const thinking = activity.start({
phase: "thinking",
round,
...simpleActivityLabel(locale, "thinking"),
});
const summaries: string[] = [];
const started = Date.now();
const fallback = await runRawCompletionWithFallback({
userId,
providers,
// The tool loop IS the Coach chat: a person is waiting on every round.
surface: "coach",
ledger,
params: {
system: stopReason
? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}`
: system,
messages,
temperature,
maxTokens,
signal,
timeoutMs,
...(reasoning
? {
reasoning,
onReasoning: (event) => {
if (event.kind === "title") {
activity.update(thinking, { title: event.text });
} else if (event.kind === "text" && event.text.trim()) {
summaries.push(event.text);
}
},
}
: {}),
tools,
toolChoice: isFinal ? ("none" as const) : ("auto" as const),
cacheKey,
},
});
const result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
totalTokens += result.tokensUsed ?? 0;
cachedTokens += result.cachedInputTokens ?? 0;
budget.endRound({
tokens: result.tokensUsed,
cachedTokens: result.cachedInputTokens,
durationMs: Date.now() - started,
});
if (spend) {
await spend.settleRound({
tokens: result.tokensUsed ?? 0,
cachedTokens: result.cachedInputTokens ?? 0,
servedBy: fallback.workingProvider.providerType as ProviderChainType,
final: isFinal,
});
}
// A client that reports summaries only on the result, not live.
const summaryText = (
summaries.length > 0 ? summaries : (result.reasoning?.summary ?? [])
).join("\n\n");
activity.finish(thinking, "done", summaryText ? { text: summaryText } : {});
const calls = result.toolCalls ?? [];
const wantsTools =
!isFinal && result.finishReason === "tool_calls" && calls.length > 0;
if (!wantsTools) {
return finish({ result });
}
// Assistant text beside the calls: the model's own checkpoint.
const checkpoint = screenCheckpoint(result.content, {
locale,
figures: () => toolResults.map((r) => r.data ?? r.available),
userMessage: args.checkpointScreen?.userMessage,
});
if (checkpoint) {
const id = activity.start({
phase: "checkpoint",
round,
...simpleActivityLabel(locale, "thinking"),
});
activity.finish(id, "done", { title: checkpoint });
}
messages.push({
role: "assistant",
content: result.content ?? "",
toolCalls: calls,
...(result.providerState ? { providerState: result.providerState } : {}),
});
let asked: CoachToolLoopResult["clarification"];
const settled = await Promise.all(
calls.map(async (call) => {
const index = callCount;
callCount += 1;
const signature = callSignature(call.name, call.arguments);
const earlier = progress.duplicateOf(call.id, signature);
if (earlier !== null) {
progress.record({ duplicate: true, scope: null, result: null });
annotate({
action: { name: "coach.tool.duplicate" },
meta: { tool: call.name.slice(0, 48), round },
});
return {
call,
content: {
present: false,
reason: "duplicate",
duplicateOf: earlier,
},
};
}
if (isDialogToolName(call.name)) {
const phase =
call.name === ASK_CLARIFICATION_TOOL_NAME
? "asking"
: call.name === PROPOSE_PLAN_TOOL_NAME
? "plan"
: "remember";
const id = activity.start({
phase,
round,
...simpleActivityLabel(locale, phase),
});
const outcome = dialog
? await runDialogTool({
name: call.name,
rawArguments: call.arguments,
round,
ctx: dialog,
noted: memoryNote !== undefined,
proposed: planProposal !== undefined,
})
: { kind: "none" as const, result: { declined: "unavailable" } };
if (outcome.kind === "ask") {
asked = {
question: outcome.question,
clarification: outcome.clarification,
};
} else if (outcome.kind === "memory") {
memoryNote = outcome.note;
} else if (outcome.kind === "plan") {
planProposal = outcome.proposal;
} else if (outcome.kind === "declined") {
declinedClarifications.push(outcome.clarification);
}
activity.finish(
id,
outcome.kind === "none" || outcome.kind === "declined"
? "empty"
: "done",
);
progress.record({ duplicate: false, scope: null, result: null });
return { call, content: outcome.result };
}
notify(() => onCallStart?.(call, index, round));
const toolResult =
call.name === COMPARE_SERIES_TOOL_NAME
? await runCompareSeries({
userId,
rawArguments: call.arguments,
fallbackWindow,
sharedScope,
reach,
...(turn ? { turn } : {}),
})
: await executeCoachTool({
userId,
name: call.name,
rawArguments: call.arguments,
fallbackWindow,
sharedScope,
reach,
...(turn ? { turn } : {}),
});
// The settled callback gets the table; the model never does. It
// reads the compact summary in `data`, and the verifier grades the
// prose against that same summary.
notify(() => onCallSettled?.(call, toolResult, index, round));
const { table, ...forModel } = toolResult;
if (table && args.systemOnceTableShown) {
system = args.systemOnceTableShown;
}
const validArgs =
call.name === COMPARE_SERIES_TOOL_NAME
? compareArgs(call.arguments)
: parseCoachToolArgs(call.name, call.arguments);
toolTrace.push({
name: call.name,
present: forModel.present,
...(validArgs ? { args: validArgs } : {}),
});
if (forModel.present || forModel.available !== undefined) {
toolResults.push(forModel);
}
progress.record({
duplicate: false,
scope: validArgs ? callScope(call.name, validArgs) : null,
result: forModel,
});
return { call, content: forModel };
}),
);
for (const { call, content } of settled) {
const serialised = JSON.stringify(content);
budget.addInput(textTokens(serialised));
messages.push({ role: "tool", toolCallId: call.id, content: serialised });
}
budget.addInput(textTokens(result.content ?? ""));
notify(() => onRoundSettled?.(round));
// A question ends the turn: it is the reply, with no prose round.
if (asked) {
return finish({
result: {
...result,
content: asked.question,
toolCalls: undefined,
// The provider state belongs to the next round, which a question
// ends; it never leaves the loop.
providerState: undefined,
},
clarification: asked,
});
}
progressStalled = progress.endRound();
}
function finish(end: {
result: CompletionResult;
clarification?: CoachToolLoopResult["clarification"];
}): CoachToolLoopResult {
const stop: CoachStop | undefined = stopReason
? { reason: stopReason, rounds }
: undefined;
annotate({
action: { name: "coach.tool.rounds" },
meta: {
rounds,
tools: toolTrace.length,
forcedFinal: stopReason !== null,
stop: stopReason ?? "answered",
clarification: end.clarification !== undefined,
spent: budget.spent(),
elapsedMs: budget.elapsedMs(),
payer: budget.payer,
},
});
return {
result: end.result,
workingProviderType,
totalTokens,
cachedTokens,
rounds,
toolTrace,
toolResults,
forcedFinal: stopReason !== null && stopReason !== "no_progress",
...(stop ? { stop } : {}),
...(end.clarification ? { clarification: end.clarification } : {}),
...(declinedClarifications.length > 0 ? { declinedClarifications } : {}),
...(memoryNote ? { memoryNote } : {}),
...(planProposal ? { planProposal } : {}),
};
}
}
/** A `compare_series` call's arguments as their schema validates them. */
function compareArgs(raw: string): Record<string, unknown> | undefined {
try {
const parsed = compareSeriesArgsSchema.safeParse(
raw.trim() === "" ? {} : JSON.parse(raw),
);
return parsed.success
? (parsed.data as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
/** Run a progress callback; a throw from it never reaches the loop. */
function notify(callback: () => void): void {
try {
callback();
} catch {
// Progress is best-effort; the answer is not.
}
}