Files
HealthLog/src/lib/ai/coach/tools/executor.ts
T

1097 lines
38 KiB
TypeScript

/**
* v1.20.0 (F1) — Coach retrieval tool executor.
*
* Dispatches a single tool call from the model: validates the raw JSON
* arguments against the tool's Zod schema, runs the existing
* server-authoritative snapshot builder scoped to just the requested domain,
* and slices the matching section(s) out of the structured result.
*
* GROUNDING CONTRACTS (the hallucination audit hammers these):
* - Every tool returns a structured `{ present: boolean }`. When the domain
* carries no data — or the user opted out of the owning module — the result
* is `{ present: false, reason }`. It NEVER throws and NEVER returns an
* ambiguous `[]`, so the model can always tell "no data" from "data".
* - An empty WINDOW read is never reported as absence on its own. Every
* `present: false` that comes from a missing snapshot section routes through
* `resolveEmptyRead`, which probes whether the record holds rows outside the
* window and returns `outside_window` (with the range + a bounded aggregate)
* rather than `no_data`. One reason code for "never recorded" and "recorded,
* outside the window I searched" is what made the Coach deny data the app
* was displaying on the next screen.
* - Module / cycle gates are enforced AT THE BUILDER (`buildCoachSnapshot`
* applies `MODULE_EXCLUDED_SOURCES` + the cycle gate before any row is
* read), so an opted-out domain is structurally unfetchable here — the
* section simply never appears and the executor reports `present: false`.
* - `userId` is the session-narrowed id passed by the route, never a tool
* argument. Tools are read-only (snapshot reads only), so there is no
* mutation or egress surface.
*
* The builder result is memoised by the 60s snapshot LRU keyed on
* `(userId, window, sources)`.
*
* v1.21.0 (D5-1) — ONE snapshot per turn. The route hands every tool the SAME
* `sharedScope` it built the DATA INVENTORY against (the full source set + the
* conversation window). A full-source build already contains every section, so
* each tool reads under that shared scope key and slices its own section out —
* landing the 60s LRU hit the inventory primed rather than rebuilding a
* distinct single-source snapshot per tool (the D5-1 N-builds-per-turn cost).
* A tool that overrides the window to something OTHER than the shared window
* still gets a correct, distinct build (the rare case); the common path
* collapses to one build.
*/
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveModuleMap } from "@/lib/modules/gate";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import type {
CoachResultTable,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import {
METRIC_TABLE_EXCLUDED_SOURCES,
compareWithCurrent,
readMetricTable,
summariseTable,
} from "@/lib/ai/coach/results/metric-table-tool";
import {
projectCompliance,
projectLabs,
projectWorkouts,
} from "@/lib/ai/coach/results/projections";
import {
formatPriorResultRef,
resolvePriorResultRef,
type PriorResultTurn,
type ResultRefAllocator,
} from "@/lib/ai/coach/results/refs";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
import {
COACH_RESULT_COLUMN_KEYS,
COACH_RESULT_TITLE_KEYS,
COACH_RESULT_UI_KEYS,
coachDomainLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
buildDistributionTable,
deriveChartSpec,
} from "@/lib/ai/coach/results/chart-spec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveIntlLocale } from "@/lib/format-locale";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
import {
getMetricSeriesArgsSchema,
getGlucosePanelArgsSchema,
getSleepArgsSchema,
getMedicationComplianceArgsSchema,
getLabsArgsSchema,
getIllnessRecoveryArgsSchema,
getWorkoutsArgsSchema,
getCycleArgsSchema,
getCorrelationsArgsSchema,
getMetricTableArgsSchema,
isCoachToolName,
showResultArgsSchema,
SHOW_RESULT_TOOL_NAME,
type CoachToolName,
} from "./definitions";
import {
COACH_SOURCE_SNAPSHOT_KEY,
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
import { readCoachCorrelations } from "./correlations-read";
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import {
resolveEmptyRead,
subjectForTool,
type CoachAvailabilitySubject,
type CoachDomainAvailability,
} from "./availability";
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
export interface CoachToolResult {
present: boolean;
/** Short machine reason on a `present: false` result (no PII). */
reason?: string;
/** The domain payload on a `present: true` result (the snapshot section). */
data?: unknown;
/**
* Optional citation-coupled reference grounding for the fetched metric —
* published population bands + the user's placement, general guidance only.
*/
grounding?: string;
/** The window an empty read searched, so a miss describes its own scope. */
searchedWindow?: string;
/**
* On a `present: false` result whose reason is `outside_window` /
* `unavailable_in_scope`: what the record holds for this domain over its whole
* history — count, date range, and a bounded per-series aggregate. These are
* server-computed figures the model MAY cite; they ride the authoritative set
* for the prose number-verifier exactly like a present result's payload.
*/
available?: CoachDomainAvailability;
/**
* v1.39.4 — the name of the table this call produced (`r1`..`r6`), so the
* model can mark the prose that relies on it with `result:rN`. Only in a
* chat turn; absent when no table was made.
*/
resultRef?: string;
/**
* v1.39.4 — the full table, for the person. NEVER serialised to the model:
* the loop strips it before the tool-result turn, and the model reads the
* compact summary in `data` instead. Only in a chat turn.
*/
table?: CoachResultTable;
}
/**
* v1.39.4 — what a chat turn hands the executor so a call can produce a
* table and name it, and `show_result` can find an earlier one. Absent on
* every other caller (MCP), which then gets the plain result.
*/
export interface CoachToolTurnContext {
/** The conversation the turn belongs to, already narrowed to its owner. */
conversationId: string;
locale: Locale;
/** The earlier tables of THIS conversation, as the context named them. */
priorResults: ReadonlyArray<PriorResultTurn>;
refs: ResultRefAllocator;
now?: Date;
}
/** What the route persists onto provenance: which tools ran, did data exist. */
export interface CoachToolTrace {
name: string;
present: boolean;
/**
* v1.39.4 — the call's arguments as their schema validated them; absent
* when they did not validate. Turn-internal (steps, method, chips): never
* persisted, the provenance keeps `{ name, present }` only.
*/
args?: Record<string, unknown>;
}
function pickSection(
sections: Record<string, unknown>,
key: string,
): unknown | undefined {
const value = sections[key];
if (value === undefined || value === null) return undefined;
return value;
}
/**
* Resolve the scope a tool reads its snapshot under.
*
* v1.21.0 (D5-1) — when the route handed us a `sharedScope` (the full-source
* inventory build for this turn) AND the tool's effective window matches the
* shared window, read under the SHARED scope key so the per-tool read lands the
* 60s LRU hit the inventory already primed (the section it needs is present in
* the full-source build). Otherwise — no shared scope, or a window override —
* fall back to the tight per-source scope (a correct, distinct build).
*
* The window default chain is unchanged: `args.window` wins, else the
* conversation's `fallbackWindow`, else the builder default.
*/
function scopeFor(
sources: CoachScope["sources"],
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): CoachScope {
const effectiveWindow = window ?? fallbackWindow;
if (sharedScope && sharedScope.window === effectiveWindow) {
// Same window as the inventory's full-source build → reuse its cache entry.
return sharedScope;
}
return {
sources,
window: effectiveWindow,
};
}
/**
* Execute one tool call. `rawArguments` is the model's raw JSON-string
* arguments (parsed + validated here, never trusted blindly). Returns a
* grounded `CoachToolResult` — a validation failure or unknown tool resolves
* to `{ present: false }`, never a throw, so a single bad call can't break the
* turn.
*/
export async function executeCoachTool(args: {
userId: string;
name: string;
rawArguments: string;
/** The conversation's effective window, used when a call omits `window`. */
fallbackWindow?: CoachScopeWindow;
/**
* v1.21.0 (D5-1) — the turn's shared full-source snapshot scope (the same one
* the DATA INVENTORY was built against). When present and the tool's window
* matches, the tool reads under this scope key so it lands the inventory's
* already-built cache entry instead of rebuilding a single-source snapshot.
*/
sharedScope?: CoachScope;
/** v1.39.4 — the chat turn, when the call runs inside one. */
turn?: CoachToolTurnContext;
}): Promise<CoachToolResult> {
const { userId, name, rawArguments, fallbackWindow, sharedScope, turn } =
args;
if (!isCoachToolName(name) && name !== SHOW_RESULT_TOOL_NAME) {
annotate({
action: { name: "coach.tool.unknown" },
meta: { tool: name.slice(0, 48) },
});
return { present: false, reason: "unknown_tool" };
}
let parsedArgs: unknown;
try {
parsedArgs = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
} catch {
annotate({
action: { name: "coach.tool.bad_arguments" },
meta: { tool: name, reason: "invalid_json" },
});
return { present: false, reason: "invalid_arguments" };
}
try {
const result =
name === SHOW_RESULT_TOOL_NAME
? await showResult(userId, parsedArgs, sharedScope, turn)
: await dispatch(
name as CoachToolName,
userId,
parsedArgs,
fallbackWindow,
sharedScope,
turn,
);
annotate({
action: { name: "coach.tool.executed" },
meta: { tool: name, present: result.present },
});
return result;
} catch (err) {
// A read failure must degrade to a grounded "no data" rather than break
// the loop — the model then says it could not retrieve the metric.
annotate({
action: { name: "coach.tool.error" },
meta: {
tool: name,
reason: err instanceof Error ? err.name : "unknown",
},
});
return { present: false, reason: "retrieval_failed" };
}
}
async function dispatch(
name: CoachToolName,
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
): Promise<CoachToolResult> {
const result = await dispatchRead(
name,
userId,
rawArgs,
fallbackWindow,
sharedScope,
turn,
);
if (!turn || !result.present || result.table) return result;
return withProjectedTable(
name,
userId,
rawArgs,
fallbackWindow,
result,
turn,
);
}
async function dispatchRead(
name: CoachToolName,
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
): Promise<CoachToolResult> {
switch (name) {
case "get_metric_series":
return getMetricSeries(userId, rawArgs, fallbackWindow, sharedScope);
case "get_glucose_panel":
return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope);
case "get_sleep":
return getSleep(userId, rawArgs, fallbackWindow, sharedScope);
case "get_medication_compliance":
return getMedicationCompliance(
userId,
rawArgs,
fallbackWindow,
sharedScope,
);
case "get_labs":
return getLabs(userId, rawArgs, fallbackWindow, sharedScope);
case "get_illness_recovery":
return getIllnessRecovery(userId, fallbackWindow, sharedScope);
case "get_workouts":
return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope);
case "get_cycle":
return getCycle(userId, rawArgs, sharedScope);
case "get_correlations":
return getCorrelations(userId, rawArgs);
case "get_metric_table":
return getMetricTable(userId, rawArgs, fallbackWindow, sharedScope, turn);
}
}
function badArgs(name: string, error: z.ZodError): CoachToolResult {
annotate({
action: { name: "coach.tool.bad_arguments" },
meta: { tool: name, reason: "schema", issues: error.issues.length },
});
return { present: false, reason: "invalid_arguments" };
}
/**
* The one exit for "the window read produced no section". Never returns a bare
* `no_data` without the probe having confirmed the record is empty — see
* `availability.ts` for the four states it can resolve to.
*/
function emptyRead(
userId: string,
domain: string,
subject: CoachAvailabilitySubject | null,
searchedWindow: CoachScopeWindow | undefined,
): Promise<CoachToolResult> {
return resolveEmptyRead({ userId, domain, subject, searchedWindow });
}
async function getMetricSeries(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
const { metric, window } = parsed.data;
// Glucose / workouts / compliance have dedicated tools (or are deferred);
// refuse to answer them here so the model is pointed at the right tool
// rather than getting a fabricated-shape miss.
if (METRIC_SERIES_EXCLUDED_SOURCES.has(metric)) {
return {
present: false,
reason:
metric === "glucose"
? "use_get_glucose_panel"
: metric === "compliance"
? "use_get_medication_compliance"
: "unsupported_metric",
};
}
const sectionKey = COACH_SOURCE_SNAPSHOT_KEY[metric];
if (!sectionKey) {
return { present: false, reason: "unsupported_metric" };
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([metric], window, fallbackWindow, sharedScope),
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
return emptyRead(
userId,
metric,
subjectForTool("get_metric_series", metric),
window ?? fallbackWindow,
);
}
return {
present: true,
data: { metric, section },
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getGlucosePanel(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope),
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) {
return emptyRead(
userId,
"glucose",
subjectForTool("get_glucose_panel"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
data: section,
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getSleep(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getSleepArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_sleep", parsed.error);
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope),
);
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
if (nights === undefined && rhythm === undefined) {
return emptyRead(
userId,
"sleep",
subjectForTool("get_sleep"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
data: {
...(nights !== undefined ? { nights } : {}),
...(rhythm !== undefined ? { rhythm } : {}),
},
grounding: snapshot.referenceGrounding ?? undefined,
};
}
async function getMedicationCompliance(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
if (!parsed.success) {
return badArgs("get_medication_compliance", parsed.error);
}
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["compliance"], parsed.data.window, fallbackWindow, sharedScope),
);
const compliance = pickSection(snapshot.sections, "compliance");
// GLP-1 context rides the `weeklyContext` block.
const weeklyContext = pickSection(snapshot.sections, "weeklyContext") as
{ glp1?: unknown } | undefined;
const glp1 = weeklyContext?.glp1;
if (compliance === undefined && glp1 === undefined) {
return emptyRead(
userId,
"compliance",
subjectForTool("get_medication_compliance"),
parsed.data.window ?? fallbackWindow,
);
}
return {
present: true,
data: {
...(compliance !== undefined ? { compliance } : {}),
...(glp1 !== undefined ? { glp1 } : {}),
},
};
}
async function getLabs(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getLabsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_labs", parsed.error);
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
// so a minimal scope still surfaces them.
//
// v1.21.0 (A5-F4) — the labs block itself is a "latest reading per biomarker
// over the last 12 months" snapshot and is intentionally window-AGNOSTIC
// (the read cutoff is fixed). We still thread the conversation's window onto
// the scope so the snapshot's `scope` block reports the right horizon for the
// turn; it does not move the labs read.
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read
// lands the inventory's cache entry; fall back to a tight empty-source scope
// (labs ride unconditionally) when no shared scope or a window mismatch.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor([], fallbackWindow, fallbackWindow, sharedScope),
);
const labs = pickSection(snapshot.sections, "labs") as
{ recent?: Array<{ name?: string; analyte?: string }> } | undefined;
if (labs === undefined) {
// The labs read is window-AGNOSTIC: a fixed trailing-12-month cutoff. So the
// window it "searched" is a year regardless of the conversation's scope —
// a panel from 2023 is outside it and must not read as "no labs on file".
return emptyRead(userId, "labs", subjectForTool("get_labs"), "lastYear");
}
const analyte = parsed.data.analyte?.trim().toLowerCase();
if (analyte && Array.isArray(labs.recent)) {
const filtered = labs.recent.filter((entry) => {
const haystack =
`${entry.name ?? ""} ${entry.analyte ?? ""}`.toLowerCase();
return haystack.includes(analyte);
});
if (filtered.length === 0) {
return { present: false, reason: "analyte_not_found" };
}
return { present: true, data: { recent: filtered } };
}
return { present: true, data: labs };
}
async function getIllnessRecovery(
userId: string,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
// Validate the (empty) args shape for consistency; an empty object always
// passes.
getIllnessRecoveryArgsSchema.parse({});
// Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max
// sources; request them so the derived / dayStrain / trajectory blocks can
// build when the module is on. Illness rides the snapshot unconditionally.
//
// v1.21.0 (A5-F4) — honour the conversation's window so the recovery/strain/
// trajectory composites are computed over the horizon the caller asked for
// rather than the builder default.
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope (which already
// carries hrv/resting_hr/vo2_max + illness + derived) so this read lands the
// inventory's cache entry; the narrow scope is the window-mismatch fallback.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(
["hrv", "resting_hr", "vo2_max"],
fallbackWindow,
fallbackWindow,
sharedScope,
),
);
const illness = pickSection(snapshot.sections, "illness");
const derived = pickSection(snapshot.sections, "derived");
const dayStrain = pickSection(snapshot.sections, "dayStrain");
const trajectory = pickSection(snapshot.sections, "trajectory");
// v1.21.0 (NEW-B B-2) — the computed illness retrospective the card shows
// (recovery-gap, gap-driver, nadir, pre-onset, red flags) for the most
// relevant episode. Read-only, coverage-gated (null when the engine
// withholds), and the SAME engine the card + the red-flag notifier run, so
// the Coach restates the numbers the user sees rather than the composite.
const illnessScores = await buildIllnessScores(userId);
if (
illness === undefined &&
derived === undefined &&
dayStrain === undefined &&
trajectory === undefined &&
illnessScores === null
) {
// No row-backed subject: illness episodes + the recovery composites are
// computed / module-gated, not a window slice of one table, so there is
// nothing an availability probe could establish. Honest absence.
return emptyRead(
userId,
"illness_recovery",
subjectForTool("get_illness_recovery"),
fallbackWindow,
);
}
return {
present: true,
data: {
...(illness !== undefined ? { illness } : {}),
...(illnessScores !== null ? { illnessScores } : {}),
...(derived !== undefined ? { derived } : {}),
...(dayStrain !== undefined ? { dayStrain } : {}),
...(trajectory !== undefined ? { trajectory } : {}),
},
};
}
async function getWorkouts(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getWorkoutsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_workouts", parsed.error);
// The workouts block builds when the `workouts` cluster is active AND the
// user has workout rows in the window. Scope the read to that single source.
const snapshot = await buildCoachSnapshot(
userId,
scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope),
);
const workouts = pickSection(snapshot.sections, "workouts");
if (workouts === undefined) {
return emptyRead(
userId,
"workouts",
subjectForTool("get_workouts"),
parsed.data.window ?? fallbackWindow,
);
}
return { present: true, data: workouts };
}
async function getCycle(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
): Promise<CoachToolResult> {
const parsed = getCycleArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_cycle", parsed.error);
// The cycle block is gated INSIDE the builder by `isCycleAvailableForUser`
// (the per-user toggle AND the operator switch), independent of `sources` —
// so a minimal scope still surfaces it when the account tracks cycles, and a
// non-cycle account structurally produces no block (→ present:false).
//
// v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read
// lands the inventory's cache entry; the empty-source scope is the fallback
// when no shared scope is threaded.
const snapshot = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [] },
);
const cycle = pickSection(snapshot.sections, "cycle");
if (cycle === undefined) {
// No row-backed subject: the cycle block is gated by the per-user toggle
// AND the operator switch inside the builder, so an absent block is a gate
// verdict rather than a window artefact.
return emptyRead(
userId,
"cycle",
subjectForTool("get_cycle"),
sharedScope?.window,
);
}
return { present: true, data: cycle };
}
async function getCorrelations(
userId: string,
rawArgs: unknown,
): Promise<CoachToolResult> {
const parsed = getCorrelationsArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_correlations", parsed.error);
// Reads the deterministic FDR discovery + coincident-deviation flag; returns
// a clean `{ present: false }` when too little paired data exists.
//
// No availability probe here, and no window either: a discovered pattern is
// not a slice of one table — it needs two channels paired over overlapping
// days, and the reader already reports WHY nothing survived (its own reason
// code). "No pattern survived the correction" is an honest absence of a
// finding, not an absence of data, and nothing older would change it.
// The reader's language, from their stored preference — the executor is
// reached from the chat loop and from MCP, neither of which carries a request
// of its own. The notes below are finished sentences; some MCP clients show
// them to the person verbatim rather than letting the model re-word them.
const locale = await resolveLocaleForUser(userId);
const result = await readCoachCorrelations(userId, locale);
if (!result.present) {
return { present: false, reason: result.reason ?? "no_pattern" };
}
return {
present: true,
data: {
...(result.drivers ? { drivers: result.drivers } : {}),
...(result.coincident ? { coincident: result.coincident } : {}),
pairsTested: result.pairsTested,
windowDays: result.windowDays,
},
};
}
// ── v1.39.4: result tables ──────────────────────────────────────────────
/**
* True when the snapshot builder admitted `metric` for this account: the
* resolved scope it pins on every snapshot is the source set left after the
* module switches and the person's own exclusions. The table tool reads
* rows the builder does not, so it asks the builder's gate first rather
* than keeping a second copy of it.
*/
export function scopeAdmits(
sections: Record<string, unknown>,
metric: CoachScopeSource,
): boolean {
const scope = sections.scope as { sources?: unknown } | undefined;
return Array.isArray(scope?.sources) && scope.sources.includes(metric);
}
/**
* The earlier tables a turn may name and show again: those whose metric the
* same gate would read now. With a conversation scope that is the scope's
* sources less the person's exclusions and switched-off modules; without
* one, every metric not excluded. The exclusion is the snapshot's own
* (`coachExclusions`, `admitCoachSources`), so a metric the person excluded
* after a table was stored is neither listed for the model nor sent to it.
* Tables of other domains (labs) answer to their module when read.
*/
export async function admittedPriorResults(args: {
userId: string;
prefs: Pick<CoachPrefs, "excludeMetrics">;
scope: CoachScope | undefined;
prior: readonly PriorResultTurn[];
}): Promise<PriorResultTurn[]> {
if (args.prior.length === 0) return [];
const excluded = coachExclusions(
args.prefs,
await resolveModuleMap(args.userId),
);
const scoped =
args.scope?.sources && args.scope.sources.length > 0
? admitCoachSources(args.scope.sources, excluded)
: null;
const admits = (domain: CoachStepDomain) =>
!isCoachScopeSource(domain) ||
(scoped
? scoped.has(domain)
: admitCoachSources([domain], excluded).size > 0);
return args.prior
.map((turn) => ({
...turn,
results: turn.results.filter((meta) => admits(meta.source.domain)),
}))
.filter((turn) => turn.results.length > 0);
}
/** True for a step domain the snapshot scope gates (a metric, not labs). */
export function isCoachScopeSource(
domain: CoachStepDomain,
): domain is CoachScopeSource {
return Object.hasOwn(COACH_SOURCE_MEASUREMENT_TYPES, domain);
}
async function getMetricTable(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
): Promise<CoachToolResult> {
const parsed = getMetricTableArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_table", parsed.error);
const { metric, granularity } = parsed.data;
const window = parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW;
const period =
window === "allTime" ? "current" : (parsed.data.period ?? "current");
const excluded = METRIC_TABLE_EXCLUDED_SOURCES[metric];
if (excluded) return { present: false, reason: excluded };
// Same gate as every other read: a module switched off, or a metric the
// person excluded from the Coach, is not read here either.
const gate = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [metric], window: fallbackWindow },
);
if (!scopeAdmits(gate.sections, metric)) {
return {
present: false,
reason: "unavailable_in_scope",
searchedWindow: window,
};
}
const timeZone = await resolveUserTimezone(userId);
const table = await readMetricTable({
userId,
metric,
window,
period,
granularity,
timeZone,
locale: turn?.locale ?? "en",
ref: "r0",
now: turn?.now,
});
if (!table) {
return emptyRead(
userId,
metric,
subjectForTool("get_metric_table", metric),
window,
);
}
// An earlier window is read to be compared: read the current one too and
// hand the model both, and the change between them, in one summary.
let comparison: Record<string, unknown> | null = null;
if (period !== "current") {
const current = await readMetricTable({
userId,
metric,
window,
period: "current",
granularity,
timeZone,
locale: turn?.locale ?? "en",
ref: "r0",
now: turn?.now,
});
comparison = current ? compareWithCurrent(table, current) : null;
}
const summarise = (t: CoachResultTable) => ({
...summariseTable(t),
...(comparison ? { comparison } : {}),
});
const ref = turn?.refs.next() ?? null;
if (!ref) {
// Outside a chat turn, or past the sixth table: the model still gets
// the figures, there is just no table to name.
return { present: true, data: summarise(table) };
}
const named = { ...table, ref };
return {
present: true,
resultRef: ref,
data: summarise(named),
table: named,
};
}
/**
* A table shown again, with the chart its `view` asks for. No view: the chart
* the server picks for any table. `table`: no chart. `chart`: a day table
* becomes how often each range came up (a histogram); any other table, or a
* day table with too few values, keeps the chart the server picks. The model
* then reads the summary of what is shown.
*/
function withResultView(
table: CoachResultTable,
view: "table" | "chart" | undefined,
locale: Locale,
): CoachResultTable {
if (view === "table") return { ...table, chart: null, chartKind: null };
if (view === "chart") {
const { t } = getServerTranslator(locale);
const distribution = buildDistributionTable(table, {
localeTag: resolveIntlLocale(locale),
title: t(COACH_RESULT_TITLE_KEYS.distribution, {
metric: t(coachDomainLabelKey(table.source.domain)),
}),
range: t(COACH_RESULT_COLUMN_KEYS.range),
count: t(COACH_RESULT_COLUMN_KEYS.count),
bin: (from, to, unit) =>
t(COACH_RESULT_UI_KEYS.histogramBin, { from, to, unit }),
});
if (distribution) return distribution;
}
const chart = deriveChartSpec(table);
return { ...table, chart, chartKind: chart?.kind ?? null };
}
/**
* `show_result` — an earlier table of THIS conversation, shown again. The
* name resolves only against the tables the turn's own conversation holds
* (`turn.priorResults`), and the values come through the owner- and
* conversation-narrowed read, so a name from anywhere else is an unknown
* result, never a lookup.
*/
async function showResult(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
): Promise<CoachToolResult> {
const parsed = showResultArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs(SHOW_RESULT_TOOL_NAME, parsed.error);
const target = turn
? resolvePriorResultRef(parsed.data.ref, turn.priorResults)
: null;
if (!turn || !target) {
annotate({
action: { name: "coach.result.unknown_ref" },
meta: { inTurn: turn !== undefined },
});
return { present: false, reason: "unknown_result" };
}
// The same gate a fresh read passes, before anything is decrypted: a
// metric the person has since excluded from the Coach, or left out of
// this conversation's scope, is not sent to the model again either.
const domain = target.meta.source.domain;
if (isCoachScopeSource(domain)) {
const gate = await buildCoachSnapshot(
userId,
sharedScope ?? { sources: [domain], window: target.meta.source.window },
);
if (!scopeAdmits(gate.sections, domain)) {
return { present: false, reason: "unavailable_in_scope" };
}
}
const modules = await resolveModuleMap(userId);
const entries = await readMessageResults(
userId,
turn.conversationId,
target.messageId,
(domain) => isCoachDomainWithheld(domain, modules),
);
const entry = entries?.find((candidate) => candidate.ref === target.ref);
if (!entry) return { present: false, reason: "unknown_result" };
if ("withheld" in entry) {
return {
present: false,
reason:
entry.withheld === "module_disabled"
? "module_disabled"
: "result_unavailable",
};
}
const ref = turn.refs.next();
if (!ref) return { present: false, reason: "result_limit" };
const table = withResultView(
{
...entry,
ref,
displayed: false,
reusedFrom: { messageId: target.messageId, ref: target.ref },
},
parsed.data.view,
turn.locale,
);
return {
present: true,
resultRef: ref,
data: {
shownAgain: formatPriorResultRef(
turn.priorResults.find((p) => p.messageId === target.messageId)
?.turnIndex ?? 0,
target.ref,
),
...summariseTable(table),
},
table,
};
}
/**
* The table an older tool's present result projects to, named with the
* turn's next ref. A projection that fails leaves the answer as it was: the
* table is a view of the result, never a condition for it.
*/
async function withProjectedTable(
name: CoachToolName,
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
result: CoachToolResult,
turn: CoachToolTurnContext,
): Promise<CoachToolResult> {
const argWindow =
rawArgs !== null &&
typeof rawArgs === "object" &&
typeof (rawArgs as { window?: unknown }).window === "string"
? ((rawArgs as { window: CoachScopeWindow }).window as CoachScopeWindow)
: undefined;
const window = argWindow ?? fallbackWindow ?? DEFAULT_WINDOW;
try {
let table: CoachResultTable | null = null;
switch (name) {
case "get_workouts":
table = projectWorkouts(result.data, {
ref: "r0",
locale: turn.locale,
window,
timeZone: "UTC",
});
break;
case "get_medication_compliance":
table = projectCompliance(result.data, {
ref: "r0",
locale: turn.locale,
window,
timeZone: "UTC",
});
break;
case "get_labs":
table = projectLabs(result.data, {
ref: "r0",
locale: turn.locale,
window,
timeZone: await resolveUserTimezone(userId),
});
break;
case "get_sleep": {
const read = await readMetricTable({
userId,
metric: "sleep",
window,
period: "current",
granularity: "day",
timeZone: await resolveUserTimezone(userId),
locale: turn.locale,
ref: "r0",
now: turn.now,
});
table =
read && read.source.granularity === "day"
? {
...read,
source: { ...read.source, tool: "get_sleep" },
titleKey: COACH_RESULT_TITLE_KEYS.sleepByNight,
title: getServerTranslator(turn.locale).t(
COACH_RESULT_TITLE_KEYS.sleepByNight,
),
}
: read && {
...read,
source: { ...read.source, tool: "get_sleep" },
};
break;
}
default:
return result;
}
if (!table) return result;
const ref = turn.refs.next();
if (!ref) return result;
return { ...result, resultRef: ref, table: { ...table, ref } };
} catch (err) {
annotate({
action: { name: "coach.result.projection_failed" },
meta: {
tool: name,
reason: err instanceof Error ? err.name : "unknown",
},
});
return result;
}
}