mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
821 lines
31 KiB
TypeScript
821 lines
31 KiB
TypeScript
/**
|
|
* Run the model for one Coach turn: the tool-retrieval loop when every
|
|
* provider in the chain supports tools, otherwise the streaming no-tools
|
|
* completion over the full snapshot. A provider failure is classified into
|
|
* the structured `coach.*` code the stream answers with.
|
|
*/
|
|
import { coachPromptCacheKey } from "@/lib/ai/coach/prompt-cache-key";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import type { Locale } from "@/lib/i18n/config";
|
|
import { localeLanguageNames as LANGUAGE_NAMES } from "@/lib/i18n/config";
|
|
import {
|
|
AllProvidersFailedError,
|
|
runStreamingRawCompletionWithFallback,
|
|
} from "@/lib/ai/provider-runner";
|
|
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
|
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
|
|
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
|
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
|
import { appendMessage } from "@/lib/ai/coach/persistence";
|
|
import {
|
|
buildCoachToolRequest,
|
|
renderPriorResultRefs,
|
|
} from "@/lib/ai/coach/chat-request-builder";
|
|
import {
|
|
COACH_TOOL_DEFS,
|
|
buildCoachDataInventory,
|
|
renderDataInventory,
|
|
renderFocusHint,
|
|
buildToolModeAddendum,
|
|
parseCoachToolArgs,
|
|
runCoachToolLoop,
|
|
type CoachToolTrace,
|
|
} from "@/lib/ai/coach/tools";
|
|
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
|
|
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
|
import {
|
|
COMPARE_SERIES_TOOL_DEF,
|
|
COMPARE_SERIES_TOOL_NAME,
|
|
compareSeriesArgsSchema,
|
|
} from "@/lib/ai/coach/tools/compare-series";
|
|
import { DIALOG_TOOL_DEFS } from "@/lib/ai/coach/tools/dialog-tools";
|
|
import {
|
|
createTurnBudget,
|
|
estimateInputTokens,
|
|
type TurnPayer,
|
|
} from "@/lib/ai/coach/tools/turn-budget";
|
|
import type {
|
|
CoachClarification,
|
|
CoachMemoryNote,
|
|
CoachPlanProposal,
|
|
CoachResultTable,
|
|
CoachStep,
|
|
CoachStop,
|
|
} from "@/lib/ai/coach/types";
|
|
import type { AiToolCall } from "@/lib/ai/types";
|
|
import type { ActivityRecorder } from "@/lib/ai/coach/activity/contract";
|
|
import {
|
|
createActivityRecorder,
|
|
type TurnActivityRecorder,
|
|
} from "@/lib/ai/coach/activity/recorder";
|
|
import {
|
|
digestActivityLabel,
|
|
digestDoneActivityLabel,
|
|
fetchActivityLabel,
|
|
memoryActivityLabel,
|
|
simpleActivityLabel,
|
|
} from "@/lib/ai/coach/activity/catalog";
|
|
import { buildMemoryContextBlock } from "@/lib/ai/coach/memory/contract";
|
|
import {
|
|
projectResults,
|
|
type SettledToolCall,
|
|
} from "@/lib/ai/coach/results/project";
|
|
import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec";
|
|
import {
|
|
correlationPartners,
|
|
type CorrelationPair,
|
|
} from "@/lib/ai/coach/follow-ups/derive";
|
|
import {
|
|
createResultRefAllocator,
|
|
type PriorResultTurn,
|
|
} from "@/lib/ai/coach/results/refs";
|
|
|
|
import { buildDialogAddenda, buildTableRulesAddendum } from "./addenda";
|
|
import type { TurnLedger } from "./budget";
|
|
import type { TurnReasoning } from "./reasoning";
|
|
import type { TurnChain } from "./chain";
|
|
import type { TurnContext } from "./context";
|
|
import { classifyBubblingProviderError } from "./errors";
|
|
import { snapshotStep, toStep } from "./steps";
|
|
import type { TurnEmitter } from "./types";
|
|
|
|
export type ModelOutcome =
|
|
| {
|
|
ok: true;
|
|
result: CompletionResult;
|
|
/**
|
|
* v1.38.19 — typed as the chain's provider union, not a bare string:
|
|
* the budget reconcile attributes the turn's tokens to the cost owner of
|
|
* the hop named here.
|
|
*/
|
|
workingProviderType: ProviderChainType;
|
|
toolTrace: CoachToolTrace[];
|
|
/**
|
|
* v1.21.0 — the present tool-result payloads this turn, for the
|
|
* post-hoc prose number-verifier. Empty on the no-tools path.
|
|
*/
|
|
toolResultPayloads: unknown[];
|
|
/**
|
|
* v1.21.2 — no-tools/local-provider parity for the prose
|
|
* number-verifier. The tool path grades prose against the figures the
|
|
* tools returned; the no-tools path has no tools, so the authoritative
|
|
* set is the SNAPSHOT the model was actually shown this turn —
|
|
* `snapshot.sections`, the structured record `snapshotJson` is
|
|
* serialised from, which already carries the correlations-snapshot
|
|
* block. Populated only when the full figures were delivered this turn
|
|
* (`includeFullSnapshot`); on a cheap follow-up the block was not
|
|
* re-sent, so there is no fresh authoritative set to grade against.
|
|
*/
|
|
noToolsSnapshotPayloads: unknown[];
|
|
/**
|
|
* The DATA INVENTORY manifest (per-domain sample counts) that rode this
|
|
* turn's tool-mode prompt. The all-missed activation needs it as a
|
|
* WIDENER: the counts were in front of the model, so a plain count
|
|
* restatement ("you've logged 42 BP readings") must stay grounded even
|
|
* on a turn whose every tool missed. Never an ACTIVATOR on its own —
|
|
* see the v1.32.1 note below.
|
|
*/
|
|
inventoryPayloads: unknown[];
|
|
totalTokens: number;
|
|
/**
|
|
* v1.21.0 — cached-input tokens to subtract at reconcile
|
|
* (prompt-cached input the user did not re-pay for must not be billed
|
|
* to the daily meter).
|
|
*/
|
|
cachedTokens: number;
|
|
/**
|
|
* v1.39.4 — the steps this turn read, in first-seen order: the tool
|
|
* calls on the tool path, one snapshot step on the no-tools path.
|
|
* Already streamed as `step` frames; persisted on the provenance.
|
|
*/
|
|
steps: CoachStep[];
|
|
/**
|
|
* v1.39.4 — the tables this turn's tool results project to, with their
|
|
* charts. Not streamed here: they go out after the reply guards, and
|
|
* not at all on a blocked turn.
|
|
*/
|
|
results: CoachResultTable[];
|
|
/** v1.39.4 — the loop forced the answer at its round cap. */
|
|
forcedFinal: boolean;
|
|
/** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */
|
|
inventory: InventoryEntry[] | null;
|
|
/**
|
|
* v1.39.4 — the metric pairs a `get_correlations` call returned this
|
|
* turn, for the related-metric chip. Empty on the no-tools path.
|
|
*/
|
|
correlations: CorrelationPair[];
|
|
/** v1.41 — the turn's live trail. */
|
|
activity: TurnActivityRecorder;
|
|
/** v1.41 — why the answer was forced, when it was. */
|
|
stop?: CoachStop;
|
|
/** v1.41 — the question the turn ended on, asked through the tool. */
|
|
toolClarification: {
|
|
question: string;
|
|
clarification: CoachClarification;
|
|
} | null;
|
|
/** v1.41 — questions the brake turned into assumptions. */
|
|
declinedClarifications: CoachClarification[];
|
|
/** v1.41 — the fact the turn saved or proposes. */
|
|
memoryNote: CoachMemoryNote | null;
|
|
/** v1.41 — the plan the turn proposes. */
|
|
planProposal: CoachPlanProposal | null;
|
|
/** v1.41 — tables already went out as interim `result` frames. */
|
|
interimSent: boolean;
|
|
}
|
|
| { ok: false; code: string };
|
|
|
|
/**
|
|
* v1.39.4 — the turn's steps, upserted by id and streamed as they change.
|
|
* A frame is never written to a stream the client already closed.
|
|
*/
|
|
function stepRecorder(emitter: TurnEmitter): {
|
|
record(step: CoachStep | null): void;
|
|
list(): CoachStep[];
|
|
} {
|
|
const byId = new Map<string, CoachStep>();
|
|
return {
|
|
record(step) {
|
|
if (!step) return;
|
|
byId.set(step.id, step);
|
|
if (!emitter.aborted()) emitter.emit({ type: "step", step });
|
|
},
|
|
list: () => [...byId.values()],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.39.4 — a projected table with the chart the server chose for it. A
|
|
* table shown again already carries the chart its `show_result` view asked
|
|
* for, and keeps it.
|
|
*/
|
|
function withChart(table: CoachResultTable): CoachResultTable {
|
|
if (table.reusedFrom) return table;
|
|
const chart = deriveChartSpec(table);
|
|
return { ...table, chart, chartKind: chart?.kind ?? null };
|
|
}
|
|
|
|
/**
|
|
* The tables a settled turn shows. Built outside the provider's failure
|
|
* path: the model has already answered and its tokens are billed, so a
|
|
* projection defect must not refund them or drop the reply. It costs the
|
|
* turn its tables, which the annotation records, and nothing else.
|
|
*/
|
|
export function buildTurnResults(
|
|
calls: Parameters<typeof projectResults>[0]["calls"],
|
|
locale: Locale,
|
|
): CoachResultTable[] {
|
|
try {
|
|
return projectResults({ calls, locale }).map(withChart);
|
|
} catch (err) {
|
|
annotate({
|
|
action: { name: "coach.results.project_failed" },
|
|
meta: {
|
|
error: err instanceof Error ? err.name : "unknown",
|
|
calls: calls.length,
|
|
},
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** v1.39.4 — server-authored lines appended to a prompt block. */
|
|
function appendBlocks(base: string, blocks: string[]): string {
|
|
const extra = blocks.filter((block) => block.length > 0);
|
|
return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
|
|
}
|
|
|
|
/** The tools a tool-mode turn offers: the catalogue, comparisons, the dialog. */
|
|
export const COACH_TURN_TOOL_DEFS = [
|
|
...COACH_TOOL_DEFS,
|
|
COMPARE_SERIES_TOOL_DEF,
|
|
...DIALOG_TOOL_DEFS,
|
|
];
|
|
|
|
/** A call's validated arguments, `compare_series` included. */
|
|
function validatedArgs(call: AiToolCall): Record<string, unknown> | undefined {
|
|
if (call.name !== COMPARE_SERIES_TOOL_NAME) {
|
|
return parseCoachToolArgs(call.name, call.arguments);
|
|
}
|
|
try {
|
|
const parsed = compareSeriesArgsSchema.safeParse(
|
|
call.arguments.trim() === "" ? {} : JSON.parse(call.arguments),
|
|
);
|
|
return parsed.success
|
|
? (parsed.data as Record<string, unknown>)
|
|
: undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function runTurnModel(args: {
|
|
userId: string;
|
|
locale: Locale;
|
|
signal: AbortSignal;
|
|
conversationId: string;
|
|
ctx: TurnContext;
|
|
chain: TurnChain;
|
|
toolMode: boolean;
|
|
/** v1.41 — the day's ledger, booked round by round. */
|
|
ledger: TurnLedger;
|
|
/** The turn's frame channel; the live `step` and `activity` frames go out on it. */
|
|
emitter: TurnEmitter;
|
|
/**
|
|
* v1.39.4 — server-authored context lines for this turn (a resolved
|
|
* follow-up chip, an answered clarification). Empty on a plain turn,
|
|
* which then sends its prompt unchanged.
|
|
*/
|
|
turnHints: string[];
|
|
/**
|
|
* v1.39.4 — the tables earlier replies of this conversation hold, named
|
|
* for the context and for `show_result`. Empty on a new conversation.
|
|
*/
|
|
priorResults?: PriorResultTurn[];
|
|
/** v1.41 — the person's message, the only source of a remembered fact. */
|
|
message: string;
|
|
/** v1.41 — that message's stored id. */
|
|
userMessageId?: string;
|
|
/** v1.41 — the reasoning the turn asks for, already resolved. */
|
|
reasoning?: TurnReasoning;
|
|
/** v1.41 — who pays: fixes the turn's budget. */
|
|
payer: TurnPayer;
|
|
/** v1.41 — a window was set for this conversation by the client. */
|
|
conversationWindowSet: boolean;
|
|
}): Promise<ModelOutcome> {
|
|
const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args;
|
|
const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx;
|
|
const steps = stepRecorder(args.emitter);
|
|
// v1.41 — the figures read so far, which screen the trail's model text.
|
|
const figures: unknown[] = [];
|
|
const recorder = createActivityRecorder({
|
|
emit: (activity) => {
|
|
if (!args.emitter.aborted()) {
|
|
args.emitter.emit({ type: "activity", activity });
|
|
}
|
|
},
|
|
screen: {
|
|
locale,
|
|
figures: () => figures,
|
|
userMessage: args.message,
|
|
dosePlan: ctx.dosePlan,
|
|
medicationNames: ctx.medicationNames,
|
|
},
|
|
});
|
|
// A round's digest runs until the next entry opens: the next round's
|
|
// thinking, or the stop before it.
|
|
let digest: { id: string; count: number; areas: number } | null = null;
|
|
const closeDigest = () => {
|
|
if (!digest) return;
|
|
const { id, count, areas } = digest;
|
|
digest = null;
|
|
recorder.finish(id, "done", {
|
|
...digestDoneActivityLabel(locale, count, areas),
|
|
count,
|
|
});
|
|
};
|
|
const activity: ActivityRecorder = {
|
|
...recorder,
|
|
start: (entry) => {
|
|
closeDigest();
|
|
return recorder.start(entry);
|
|
},
|
|
};
|
|
try {
|
|
// v1.41 — what the Coach knows about the person, built only now: the
|
|
// capability was re-checked at the egress site before the model step.
|
|
const memory = await buildMemoryContextBlock({
|
|
userId,
|
|
conversationId,
|
|
locale,
|
|
// The block re-runs the wire egress check for exactly these.
|
|
providerTypes: chain.map((entry) => entry.providerType),
|
|
}).catch(() => null);
|
|
if (memory) {
|
|
recorder.setRecalled(memory.recalled);
|
|
}
|
|
if (memory && memory.factIds.length + memory.planIds.length > 0) {
|
|
const count = memory.factIds.length + memory.planIds.length;
|
|
const id = activity.start({
|
|
phase: "memory",
|
|
round: 1,
|
|
...memoryActivityLabel(locale, count),
|
|
count,
|
|
});
|
|
activity.finish(id, "done");
|
|
}
|
|
|
|
if (toolMode) {
|
|
// v1.20.0 — base context: the full system prompt + a tool-mode
|
|
// grounding addendum, with the tiny DATA INVENTORY manifest and the
|
|
// transcript on the user turn. The figures are NOT in the prompt — the
|
|
// model pulls only what it needs via the retrieval tools.
|
|
const inventory = await buildCoachDataInventory(
|
|
userId,
|
|
effectiveScope,
|
|
ctx.reach,
|
|
);
|
|
// Earlier tables whose metric the person has since excluded, or whose
|
|
// range lies beyond the lookback limit, are neither named for the
|
|
// model nor reachable through show_result.
|
|
const priorResults = await admittedPriorResults({
|
|
userId,
|
|
prefs: ctx.coachPrefs,
|
|
scope: inventory.probeScope,
|
|
reach: ctx.reach,
|
|
prior: args.priorResults ?? [],
|
|
});
|
|
// The recheck and follow-up rules are about tables: they ride the turn
|
|
// once the conversation holds one, or from the round after this turn
|
|
// produced its first. v1.43 (#1217) — like every other line that can
|
|
// change from one turn or round to the next (the opener hint, the name
|
|
// clause, a resolved follow-up), they travel on the user side, so the
|
|
// system prompt and the tools stay one cached prefix for the whole
|
|
// conversation.
|
|
const tableRules = buildTableRulesAddendum(locale);
|
|
const toolRequest = buildCoachToolRequest({
|
|
systemPrompt: ctx.systemPrompt,
|
|
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
|
|
buildDialogAddenda(locale, { tableRules: false }),
|
|
]),
|
|
focusHint: renderFocusHint(effectiveScope?.sources),
|
|
workoutEvidence,
|
|
dataInventory: renderDataInventory(inventory),
|
|
priorResults: renderPriorResultRefs(priorResults),
|
|
guidedBlock: turnContext.guidedBlock,
|
|
transcript: turnContext.transcript,
|
|
languageName: LANGUAGE_NAMES[locale],
|
|
turnNotes: [
|
|
...(priorResults.length > 0 ? [tableRules] : []),
|
|
ctx.turnNotes,
|
|
...args.turnHints,
|
|
]
|
|
.filter((block) => block.length > 0)
|
|
.join("\n\n"),
|
|
...(memory ? { memoryBlock: memory.text } : {}),
|
|
});
|
|
// v1.39.4 — every call this turn settled, by its turn-wide index, for
|
|
// the result tables. Only this turn's own calls ever reach them.
|
|
const settled: SettledToolCall[] = [];
|
|
// v1.41 — the trail entry of each call, and what each round read.
|
|
const fetches = new Map<number, { id: string; round: number }>();
|
|
const roundReads = new Map<
|
|
number,
|
|
Array<{ count: number; domain: string | undefined; done: boolean }>
|
|
>();
|
|
let interimSent = false;
|
|
const budget = createTurnBudget({
|
|
payer: args.payer,
|
|
effort: args.reasoning?.effort,
|
|
initialInputTokens: estimateInputTokens(
|
|
toolRequest.system.length +
|
|
toolRequest.messages
|
|
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
|
.join("").length,
|
|
),
|
|
});
|
|
const loop = await runCoachToolLoop({
|
|
userId,
|
|
providers: chain,
|
|
system: toolRequest.system,
|
|
...(priorResults.length > 0 ? {} : { tableRulesOnceShown: tableRules }),
|
|
messages: toolRequest.messages,
|
|
tools: COACH_TURN_TOOL_DEFS,
|
|
temperature: AI_BUDGETS.coach.temperature,
|
|
maxTokens: AI_BUDGETS.coach.maxTokens,
|
|
fallbackWindow: effectiveScope?.window,
|
|
// v1.21.0 — share the inventory's full-source snapshot across
|
|
// every tool so the turn builds ONE snapshot, not one per tool.
|
|
sharedScope: inventory.probeScope,
|
|
// The lookback limit: every tool call is clamped to it.
|
|
reach: ctx.reach,
|
|
// v1.20.1 — a mid-generation disconnect tears the round calls down.
|
|
signal,
|
|
// v1.22 (#89) — per-user response timeout for each round's call.
|
|
timeoutMs: ctx.aiResponseTimeoutMs,
|
|
// v1.39.4 — result tables: each call may produce one, named `r1`..
|
|
// in the order they settle.
|
|
turn: {
|
|
conversationId,
|
|
locale,
|
|
priorResults,
|
|
refs: createResultRefAllocator(),
|
|
},
|
|
budget,
|
|
spend: args.ledger,
|
|
...(args.reasoning ? { reasoning: args.reasoning } : {}),
|
|
activity,
|
|
locale,
|
|
...(memory?.pendingProposal
|
|
? { initialMemoryNote: memory.pendingProposal }
|
|
: {}),
|
|
dialog: {
|
|
userId,
|
|
conversationId,
|
|
locale,
|
|
userMessage: args.message,
|
|
...(args.userMessageId ? { userMessageId: args.userMessageId } : {}),
|
|
inventory: inventory.entries,
|
|
conversationWindowSet: args.conversationWindowSet,
|
|
dosePlan: ctx.dosePlan,
|
|
medicationNames: ctx.medicationNames,
|
|
},
|
|
checkpointScreen: { userMessage: args.message },
|
|
// v1.39.4 — live steps: a `running` step as each call starts, its
|
|
// final status as it settles. v1.41 — and a `fetch` trail entry.
|
|
onCallStart: (call, index, round) => {
|
|
const step = toStep({
|
|
call,
|
|
index,
|
|
parsedArgs: validatedArgs(call),
|
|
locale,
|
|
fallbackWindow: effectiveScope?.window,
|
|
});
|
|
steps.record(step);
|
|
const id = activity.start({
|
|
phase: "fetch",
|
|
round,
|
|
...fetchActivityLabel(locale, {
|
|
domain: step?.domain ?? "snapshot",
|
|
window: step?.window,
|
|
...(step && !step.window
|
|
? { fallback: { labelKey: step.labelKey, label: step.label } }
|
|
: {}),
|
|
}),
|
|
...(step ? { stepRef: step.id } : {}),
|
|
});
|
|
fetches.set(index, { id, round });
|
|
},
|
|
onCallSettled: (call, result, index, round) => {
|
|
const parsedArgs = validatedArgs(call);
|
|
const settledCall: SettledToolCall = {
|
|
name: call.name,
|
|
...(parsedArgs ? { args: parsedArgs } : {}),
|
|
result,
|
|
};
|
|
settled[index] = settledCall;
|
|
if (result.present || result.available !== undefined) {
|
|
figures.push(result.data ?? result.available);
|
|
}
|
|
const step = toStep({
|
|
call,
|
|
index,
|
|
parsedArgs,
|
|
result,
|
|
locale,
|
|
fallbackWindow: effectiveScope?.window,
|
|
});
|
|
steps.record(step);
|
|
const entry = fetches.get(index);
|
|
const status = step?.status ?? (result.present ? "done" : "empty");
|
|
if (entry) {
|
|
activity.finish(entry.id, status === "running" ? "done" : status, {
|
|
...(step?.count !== undefined ? { count: step.count } : {}),
|
|
});
|
|
}
|
|
const reads = roundReads.get(round) ?? [];
|
|
reads.push({
|
|
count: step?.count ?? 0,
|
|
domain: step?.domain,
|
|
done: status === "done",
|
|
});
|
|
roundReads.set(round, reads);
|
|
// v1.41 — a table goes out the moment it is read, as an interim
|
|
// result the answer later shows or files under "Data used".
|
|
if (result.table && !args.emitter.aborted()) {
|
|
const [table] = buildTurnResults([settledCall], locale);
|
|
if (table) {
|
|
args.emitter.emit({
|
|
type: "result",
|
|
result: table,
|
|
interim: true,
|
|
});
|
|
interimSent = true;
|
|
}
|
|
}
|
|
},
|
|
onRoundSettled: (round) => {
|
|
const reads = roundReads.get(round) ?? [];
|
|
if (reads.length === 0) return;
|
|
const count = reads.reduce((sum, read) => sum + read.count, 0);
|
|
const areas = new Set(
|
|
reads.filter((read) => read.done).map((read) => read.domain),
|
|
).size;
|
|
const id = activity.start({
|
|
phase: "digest",
|
|
round,
|
|
...digestActivityLabel(locale, count),
|
|
count,
|
|
});
|
|
digest = { id, count, areas };
|
|
},
|
|
});
|
|
closeDigest();
|
|
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
|
|
// delivered figures the model was told to ground against: a pinned
|
|
// workout-evidence block or a present tool result. The DATA INVENTORY
|
|
// manifest is NOT an activator; when the turn IS active, the inventory
|
|
// counts WIDEN the authoritative set so a plain count restatement stays
|
|
// grounded.
|
|
const presentToolPayloads = [
|
|
...(workoutEvidence === null ? [] : [workoutEvidence]),
|
|
// A miss carries no `data` but may carry `available` — the bounded
|
|
// out-of-window aggregate rule 3 lets the model cite. Ground it.
|
|
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
|
|
];
|
|
return {
|
|
ok: true,
|
|
result: loop.result,
|
|
// The loop reports the hop it landed on as a bare string; it is
|
|
// assigned from `workingProvider.providerType` one frame up.
|
|
workingProviderType: loop.workingProviderType as ProviderChainType,
|
|
toolTrace: loop.toolTrace,
|
|
toolResultPayloads:
|
|
presentToolPayloads.length > 0
|
|
? [...presentToolPayloads, inventory.entries]
|
|
: [],
|
|
noToolsSnapshotPayloads: [],
|
|
inventoryPayloads: [inventory.entries],
|
|
totalTokens: loop.totalTokens,
|
|
cachedTokens: loop.cachedTokens,
|
|
steps: steps.list(),
|
|
results: buildTurnResults(
|
|
settled.filter((call) => call !== undefined),
|
|
locale,
|
|
),
|
|
forcedFinal: loop.forcedFinal === true,
|
|
inventory: inventory.entries,
|
|
correlations: correlationPartners(
|
|
settled.filter((call) => call !== undefined),
|
|
),
|
|
activity: recorder,
|
|
...(loop.stop ? { stop: loop.stop } : {}),
|
|
toolClarification: loop.clarification ?? null,
|
|
declinedClarifications: loop.declinedClarifications ?? [],
|
|
memoryNote: loop.memoryNote ?? null,
|
|
planProposal: loop.planProposal ?? null,
|
|
interimSent,
|
|
};
|
|
}
|
|
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
|
|
// that includes a non-tool provider) runs through the STREAMING runner so
|
|
// the local client emits real tokens as they arrive and the per-idle-gap
|
|
// timeout governs. The assembled reply is returned in full so every guard
|
|
// still runs on the complete text.
|
|
let streamedDeltas = 0;
|
|
// v1.39.4 — the no-tools path reads the whole snapshot: one step.
|
|
const snapshotRead = snapshotStep({
|
|
metricCount: snapshot.provenance.metrics.length,
|
|
locale,
|
|
});
|
|
steps.record(snapshotRead);
|
|
if (snapshotRead) {
|
|
const id = activity.start({
|
|
phase: "fetch",
|
|
round: 1,
|
|
labelKey: snapshotRead.labelKey,
|
|
label: snapshotRead.label,
|
|
stepRef: snapshotRead.id,
|
|
...(snapshotRead.count !== undefined
|
|
? { count: snapshotRead.count }
|
|
: {}),
|
|
});
|
|
activity.finish(id, snapshotRead.status === "done" ? "done" : "empty");
|
|
}
|
|
const thinking = activity.start({
|
|
phase: "thinking",
|
|
round: 1,
|
|
...simpleActivityLabel(locale, "thinking"),
|
|
});
|
|
const summaries: string[] = [];
|
|
// v1.41 — the memory block rides the user turn here too, ahead of the
|
|
// snapshot, fenced like the rest of the person's own context. v1.43
|
|
// (#1217) — so do this turn's own lines (opener hint, name clause, a
|
|
// resolved follow-up), which used to ride the system prompt and rewrote
|
|
// its cached prefix on every turn.
|
|
const user = [
|
|
memory?.text,
|
|
ctx.turnNotes,
|
|
...args.turnHints,
|
|
ctx.userPrompt,
|
|
]
|
|
.filter((block): block is string => Boolean(block))
|
|
.join("\n\n");
|
|
const fallback = await runStreamingRawCompletionWithFallback({
|
|
surface: "coach",
|
|
userId,
|
|
providers: chain,
|
|
onDelta: () => {
|
|
streamedDeltas += 1;
|
|
},
|
|
params: {
|
|
...singleUserTurn({
|
|
system: ctx.systemPrompt,
|
|
user,
|
|
temperature: AI_BUDGETS.coach.temperature,
|
|
maxTokens: AI_BUDGETS.coach.maxTokens,
|
|
// v1.20.1 — tear the upstream call down on a client disconnect.
|
|
signal,
|
|
// v1.22 (#89) — per-idle-gap timeout for the streaming local call.
|
|
timeoutMs: ctx.aiResponseTimeoutMs,
|
|
}),
|
|
cacheKey: coachPromptCacheKey(conversationId),
|
|
...(args.reasoning
|
|
? {
|
|
reasoning: args.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);
|
|
}
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
annotate({
|
|
action: { name: "coach.stream.deltas" },
|
|
meta: { deltas: streamedDeltas },
|
|
});
|
|
const result = fallback.result;
|
|
await args.ledger.settleRound({
|
|
tokens: result.tokensUsed ?? 0,
|
|
cachedTokens: result.cachedInputTokens ?? 0,
|
|
servedBy: fallback.workingProvider.providerType,
|
|
final: false,
|
|
});
|
|
const summaryText = (
|
|
summaries.length > 0 ? summaries : (result.reasoning?.summary ?? [])
|
|
).join("\n\n");
|
|
activity.finish(thinking, "done", summaryText ? { text: summaryText } : {});
|
|
return {
|
|
ok: true,
|
|
result,
|
|
workingProviderType: fallback.workingProvider.providerType,
|
|
toolTrace: [],
|
|
toolResultPayloads: [],
|
|
// v1.21.2 — the no-tools path grades only when it delivered the
|
|
// full SNAPSHOT.
|
|
noToolsSnapshotPayloads: turnContext.includeFullSnapshot
|
|
? [
|
|
snapshot.sections,
|
|
...(workoutEvidence === null ? [] : [workoutEvidence]),
|
|
]
|
|
: [],
|
|
inventoryPayloads: [],
|
|
totalTokens: result.tokensUsed ?? 0,
|
|
cachedTokens: result.cachedInputTokens ?? 0,
|
|
steps: steps.list(),
|
|
results: [],
|
|
forcedFinal: false,
|
|
inventory: null,
|
|
correlations: [],
|
|
activity: recorder,
|
|
toolClarification: null,
|
|
declinedClarifications: [],
|
|
memoryNote: memory?.pendingProposal ?? null,
|
|
planProposal: null,
|
|
interimSent: false,
|
|
};
|
|
} catch (err) {
|
|
// The provider chain failed: every round that returned is already
|
|
// settled; give back what is still reserved before the error frame.
|
|
await args.ledger.close();
|
|
// #781 — the client walked away mid-generation. The request's abort
|
|
// signal is threaded into every provider call, so the teardown surfaces
|
|
// here as an abort-shaped failure with `request.signal` already flipped.
|
|
// Close the turn honestly instead of letting the user message dangle
|
|
// unanswered: persist an EMPTY assistant marker tagged
|
|
// `providerType: "cancelled"` (the same channel "refusal" and "nudge"
|
|
// already use for non-provider rows) so the thread shows the turn as
|
|
// interrupted and offers a retry on reload. The content is empty by
|
|
// construction — nothing guarded was produced, and anything the client
|
|
// ever SAW was persisted before the first token frame left, so no
|
|
// unguarded partial text is ever written. The reservation was refunded
|
|
// above; a retry pays exactly what a fresh turn pays.
|
|
if (signal.aborted) {
|
|
await appendMessage({
|
|
conversationId,
|
|
role: "assistant",
|
|
content: "",
|
|
providerType: "cancelled",
|
|
promptVersion: PROMPT_VERSION,
|
|
}).catch(() => {
|
|
// Marker persistence is best-effort — a failure leaves the
|
|
// dangling user turn, which is exactly the pre-#781 state.
|
|
});
|
|
annotate({
|
|
action: { name: "insights.coach.cancelled" },
|
|
meta: { conversationId },
|
|
});
|
|
return { ok: false, code: "coach.cancelled" };
|
|
}
|
|
if (err instanceof AllProvidersFailedError) {
|
|
annotate({
|
|
action: { name: "insights.coach.providerFailed" },
|
|
meta: {
|
|
attempts: err.attempts.length,
|
|
firstStatus: err.attempts[0]?.httpStatus ?? null,
|
|
credentialExpired: err.primaryCredentialExpired,
|
|
},
|
|
});
|
|
// v1.11.0 — when the user's PRIMARY provider failed with an
|
|
// auth-class status (401/403), the credential is dead, not the
|
|
// service. Surface a distinct `credential_expired` frame so the
|
|
// drawer can deep-link the user to reconnect rather than telling
|
|
// them to "try again later" — the gap that let an expired codex
|
|
// token silently kill all generation.
|
|
if (err.primaryCredentialExpired) {
|
|
return { ok: false, code: "coach.provider.credential_expired" };
|
|
}
|
|
// v1.4.25 — distinguish provider rate-limit (every attempt
|
|
// landed on 429) from generic unavailability. The drawer's
|
|
// error-decoder surfaces the rate-limit copy with a warning
|
|
// toast instead of the generic provider-down message, so the
|
|
// user understands the limit is transient.
|
|
const allRateLimited =
|
|
err.attempts.length > 0 &&
|
|
err.attempts.every((a) => a.httpStatus === 429);
|
|
return {
|
|
ok: false,
|
|
code: allRateLimited
|
|
? "coach.provider.rate_limited"
|
|
: "coach.provider.unavailable",
|
|
};
|
|
}
|
|
// v1.21.3 — defence in depth. The chain runner wraps every hard provider
|
|
// failure in `AllProvidersFailedError`, but a provider client can still
|
|
// throw a tagged wire error that reaches here un-wrapped (e.g. a Codex 400
|
|
// raised mid tool-loop on a path the chain runner did not catch). Such an
|
|
// error is a PROVIDER failure, not a server bug — surface the same graceful
|
|
// `coach.provider.*` frame the chain path uses rather than rethrowing into
|
|
// an HTTP 500 (the bug that took the live Coach down for codex users). Only
|
|
// a genuinely unexpected error (no upstream tag, no httpStatus) keeps the
|
|
// 500 + GlitchTip path so real defects stay visible.
|
|
const providerError = classifyBubblingProviderError(err);
|
|
if (providerError) {
|
|
annotate({
|
|
action: { name: "insights.coach.providerFailed" },
|
|
meta: {
|
|
attempts: 1,
|
|
firstStatus: providerError.httpStatus,
|
|
credentialExpired: providerError.code === "credential_expired",
|
|
unwrapped: true,
|
|
},
|
|
});
|
|
return { ok: false, code: `coach.provider.${providerError.code}` };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|