refactor(coach): run each Coach tool from a module of its own under tools/run

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 10:54:19 +02:00
1 parent e375a0e0ba
commit faeebfc63a
38 files changed
+1514 -1324

No files matched your search

@@ -17,6 +17,12 @@ import { join } from "node:path";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
readSource,
readSourceFolder,
requireFloor,
} from "@/__tests__/helpers/source-files";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
const buildCoachSnapshot =
@@ -348,12 +354,24 @@ describe("the reported case: a metric whose whole history is outside the window"
describe("no empty window read can bypass the probe again", () => {
it("the executor returns no hand-written no_data for a missing section", () => {
const source = readFileSync(
join(process.cwd(), "src/lib/ai/coach/tools/executor.ts"),
"utf8",
);
// The dispatcher plus one module per tool under `tools/run/`.
const sources = [
[
"executor.ts",
readSource("src/lib/ai/coach/tools/executor.ts", [
"async function dispatchRead(",
]),
],
...readSourceFolder("src/lib/ai/coach/tools/run", { floor: 15 }),
];
const source = sources.map(([, text]) => text).join("\n");
// Every miss goes through `emptyRead(...)`. A hand-written literal is how
// the defect got in: four sites, one reason, two meanings.
requireFloor(
"emptyRead calls in the tool executors",
[...source.matchAll(/\bemptyRead\(/g)],
9,
);
expect(source).not.toMatch(
/return \{ present: false, reason: "no_data" \}/,
);
@@ -26,6 +26,8 @@ import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readSource } from "@/__tests__/helpers/source-files";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
import type {
@@ -187,10 +189,8 @@ vi.mock("@/lib/ai/coach/persistence", () => ({
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
admittedPriorResults,
executeCoachTool,
} from "@/lib/ai/coach/tools/executor";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder";
import {
COACH_TOOL_DEFS,
@@ -426,7 +426,10 @@ function callArguments(raw: string, fn: string): string[] {
describe("the source hands the limit on", () => {
it("passes the limit in every dispatch branch of the executor", () => {
const source = readFileSync(join(COACH_DIR, "tools/executor.ts"), "utf8");
const source = readSource("src/lib/ai/coach/tools/executor.ts", [
"async function dispatchRead(",
/case\s+"get_/,
]);
const dispatch = source.slice(
source.indexOf("async function dispatchRead("),
);
@@ -7,7 +7,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
@@ -5,7 +5,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
@@ -16,7 +16,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
@@ -17,7 +17,7 @@
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
@@ -5,7 +5,7 @@
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
@@ -5,7 +5,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
const executeCoachTool = vi.fn<(args: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
@@ -44,11 +44,9 @@ vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a),
}));
import {
admittedPriorResults,
executeCoachTool,
type CoachToolTurnContext,
} from "@/lib/ai/coach/tools/executor";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
import type { CoachToolTurnContext } from "@/lib/ai/coach/tools/run/types";
import { createResultRefAllocator } from "../refs";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding";
@@ -5,7 +5,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
import type { CoachResultTable } from "@/lib/ai/coach/types";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import type { Locale } from "@/lib/i18n/config";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
import { MAX_RESULTS_PER_TURN, isTurnResultRef } from "./refs";
+2 -5
View File
@@ -35,11 +35,8 @@ import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
import { projectCompare } from "@/lib/ai/coach/results/projections";
import { coachResultGranularitySchema } from "./definitions";
import {
executeCoachTool,
type CoachToolResult,
type CoachToolTurnContext,
} from "./executor";
import { executeCoachTool } from "./executor";
import type { CoachToolResult, CoachToolTurnContext } from "./run/types";
export const COMPARE_SERIES_TOOL_NAME = "compare_series";
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -7,7 +7,7 @@
* stays alive as the no-tools fallback (per-provider `supportsTools`).
*/
export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions";
export type { CoachToolTrace } from "./executor";
export type { CoachToolTrace } from "./run/types";
export {
buildCoachDataInventory,
renderDataInventory,
+6 -6
View File
@@ -65,12 +65,12 @@ import {
} 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 { executeCoachTool } from "./executor";
import type {
CoachToolResult,
CoachToolTrace,
CoachToolTurnContext,
} from "./run/types";
import {
COMPARE_SERIES_TOOL_NAME,
compareSeriesArgsSchema,
+1 -1
View File
@@ -15,7 +15,7 @@
*
* Pure: no database, no provider.
*/
import type { CoachToolResult } from "./executor";
import type { CoachToolResult } from "./run/types";
/** Misses that tell the model nothing new about the record. */
const EMPTY_REASONS: ReadonlySet<string> = new Set([
@@ -0,0 +1,42 @@
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getCorrelationsArgsSchema } from "../definitions";
import { readCoachCorrelations } from "../correlations-read";
import { badArgs } from "./shared";
import type { CoachToolResult } from "./types";
export async function getCorrelations(
userId: string,
rawArgs: unknown,
reach: CoachHistoryReach,
): 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);
// The scan reads 180 days, or the lookback limit when that is shorter.
const result = await readCoachCorrelations(userId, locale, { reach });
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,
},
};
}
+60
View File
@@ -0,0 +1,60 @@
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type { CoachScope } from "@/lib/ai/coach/types";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import {
isBounded,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { getCycleArgsSchema } from "../definitions";
import {
OUTSIDE_REACH_REASON,
cycleRecorded,
subjectForTool,
} from "../availability";
import { badArgs, emptyRead, pickSection } from "./shared";
import type { CoachToolResult } from "./types";
export async function getCycle(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getCycleArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_cycle", parsed.error);
// The cycle block predicts from every logged cycle, so the snapshot leaves
// it out under any lookback limit. Say why, rather than that it is absent.
if (isBounded(reach)) {
return (await isCycleAvailableForUser(userId)) &&
(await cycleRecorded(userId))
? { present: false, reason: OUTSIDE_REACH_REASON }
: { present: false, reason: "no_data" };
}
// 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).
//
// 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: [] },
{ reach },
);
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,
reach,
);
}
return { present: true, data: cycle };
}
+31
View File
@@ -0,0 +1,31 @@
import { readCoachExclusions } from "@/lib/ai/coach/history-reach-read";
import { fenceUserText } from "@/lib/ai/coach/data-fence";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getDayArgsSchema } from "../definitions";
import { readDayForTool } from "../day-read";
import { badArgs } from "./shared";
import type { CoachToolResult } from "./types";
/**
* #613: one local day across the record, projected for a model:
* never the person's life events or notes. A day older than the lookback
* limit is refused before anything is read.
*/
export async function getDay(
userId: string,
rawArgs: unknown,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getDayArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_day", parsed.error);
const result = await readDayForTool({
userId,
date: parsed.data.date,
reach,
// The person's Coach exclusions hold here as in every other read.
loadExcluded: () => readCoachExclusions(userId),
text: fenceUserText,
});
if (result.present) return { present: true, data: result.data };
return { present: false, reason: result.reason };
}
@@ -0,0 +1,33 @@
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getEnvironmentArgsSchema } from "../definitions";
import { readEnvironmentForTool } from "../environment-read";
import { badArgs } from "./shared";
import type { CoachToolResult } from "./types";
/**
* #615: the stored days' weather and air quality, without any
* location. Module-gated inside the reader (off answers `module_disabled`
* without reading a row) and clamped to the lookback limit.
*/
export async function getEnvironment(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getEnvironmentArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_environment", parsed.error);
const result = await readEnvironmentForTool({
userId,
window: parsed.data.window ?? fallbackWindow ?? "last30days",
reach,
});
if (result.present) return { present: true, data: result.data };
return {
present: false,
reason: result.reason,
...(result.searchedWindow ? { searchedWindow: result.searchedWindow } : {}),
...(result.available ? { available: result.available } : {}),
};
}
@@ -0,0 +1,47 @@
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getGlucosePanelArgsSchema } from "../definitions";
import { subjectForTool } from "../availability";
import {
badArgs,
emptyRead,
limitWindow,
pickSection,
readToolSnapshot,
} from "./shared";
import type { CoachToolResult } from "./types";
export async function getGlucosePanel(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
const snapshot = await readToolSnapshot(
userId,
["glucose"],
["glucose"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
);
const section = pickSection(snapshot.sections, "glucose");
if (section === undefined) {
return emptyRead(
userId,
"glucose",
subjectForTool("get_glucose_panel"),
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
present: true,
data: section,
grounding: snapshot.referenceGrounding ?? undefined,
};
}
@@ -0,0 +1,90 @@
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import {
isBounded,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { getIllnessRecoveryArgsSchema } from "../definitions";
import {
OUTSIDE_REACH_REASON,
illnessBeyondReach,
subjectForTool,
} from "../availability";
import { emptyRead, limitWindow, pickSection, scopeFor } from "./shared";
import type { CoachToolResult } from "./types";
export async function getIllnessRecovery(
userId: string,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): 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.
//
// 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.
//
// 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,
reach,
),
{ reach },
);
const illness = pickSection(snapshot.sections, "illness");
const derived = pickSection(snapshot.sections, "derived");
const dayStrain = pickSection(snapshot.sections, "dayStrain");
const trajectory = pickSection(snapshot.sections, "trajectory");
// 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, undefined, reach);
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, unless
// a lookback limit is what hid the episodes.
if (isBounded(reach) && (await illnessBeyondReach(userId, reach))) {
return { present: false, reason: OUTSIDE_REACH_REASON };
}
return emptyRead(
userId,
"illness_recovery",
subjectForTool("get_illness_recovery"),
limitWindow(fallbackWindow, reach),
reach,
);
}
return {
present: true,
data: {
...(illness !== undefined ? { illness } : {}),
...(illnessScores !== null ? { illnessScores } : {}),
...(derived !== undefined ? { derived } : {}),
...(dayStrain !== undefined ? { dayStrain } : {}),
...(trajectory !== undefined ? { trajectory } : {}),
},
};
}
+63
View File
@@ -0,0 +1,63 @@
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getLabsArgsSchema } from "../definitions";
import { subjectForTool } from "../availability";
import { badArgs, emptyRead, pickSection, scopeFor } from "./shared";
import type { CoachToolResult } from "./types";
export async function getLabs(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): 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.
//
// The labs block is intentionally window-AGNOSTIC: the latest
// reading per biomarker with its summarised history, over the whole record
// or the user's lookback limit (#1263). 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.
//
// 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, reach),
{ reach },
);
const labs = pickSection(snapshot.sections, "labs") as
{ recent?: Array<{ name?: string; analyte?: string }> } | undefined;
if (labs === undefined) {
// The labs read is window-AGNOSTIC: it searched the whole record, or up to
// the lookback limit when there is one. A panel older than the limit must
// not read as "no labs on file", so the limit is the searched window.
return emptyRead(
userId,
"labs",
subjectForTool("get_labs"),
reach.window,
reach,
);
}
const analyte = parsed.data.analyte?.trim().toLowerCase();
if (analyte) {
// #1263 — a named read carries each matching marker's history over the
// whole record (or the limit), summarised. Read on its own rather than
// out of the snapshot, whose labs block holds the latest values only.
const named = await buildLabsSnapshotBlock(userId, new Date(), reach, {
analyte,
});
if (!named) return { present: false, reason: "analyte_not_found" };
return { present: true, data: named };
}
return { present: true, data: labs };
}
@@ -0,0 +1,55 @@
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getMedicationComplianceArgsSchema } from "../definitions";
import { subjectForTool } from "../availability";
import {
badArgs,
emptyRead,
limitWindow,
pickSection,
readToolSnapshot,
} from "./shared";
import type { CoachToolResult } from "./types";
export async function getMedicationCompliance(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
if (!parsed.success) {
return badArgs("get_medication_compliance", parsed.error);
}
const snapshot = await readToolSnapshot(
userId,
["compliance"],
["compliance", "weeklyContext"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
);
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"),
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
present: true,
data: {
...(compliance !== undefined ? { compliance } : {}),
...(glp1 !== undefined ? { glp1 } : {}),
},
};
}
@@ -0,0 +1,150 @@
import { annotate } from "@/lib/logging/context";
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getMetricSeriesArgsSchema } from "../definitions";
import {
COACH_SOURCE_SNAPSHOT_KEY,
METRIC_SERIES_EXCLUDED_SOURCES,
} from "../source-keys";
import { subjectForTool } from "../availability";
import {
badArgs,
emptyRead,
limitWindow,
pickSection,
readToolSnapshot,
} from "./shared";
import type { CoachToolResult } from "./types";
export async function getMetricSeries(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
sourceSnapshot: boolean,
): 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" };
}
// An MCP read needs one section and nothing else, so it is built from that
// source's own rows: the same section, without the context blocks a Coach
// turn carries. See `source-snapshot.ts`. Only MCP asks for it, and MCP
// reads without the Coach's lookback limit, so it takes no reach.
const snapshot = sourceSnapshot
? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow)
: await readToolSnapshot(
userId,
[metric],
[sectionKey],
window,
fallbackWindow,
sharedScope,
reach,
);
const section = pickSection(snapshot.sections, sectionKey);
if (section === undefined) {
return emptyRead(
userId,
metric,
subjectForTool("get_metric_series", metric),
limitWindow(window ?? fallbackWindow, reach),
reach,
);
}
if (sourceSnapshot) {
// MCP: the section exactly as it always was, without the figure check
// below: an MCP client reads `present` by its own contract (absence is
// "not recorded"), and its output stays byte for byte what it was.
return {
present: true,
data: { metric, section },
grounding: snapshot.referenceGrounding ?? undefined,
};
}
// A block without a single figure (a bare unit, an `omitted` marker) is
// not an answer: "present" with nothing in it is what the model then
// repeats to the person. Say the read failed instead.
if (!carriesFigures(section)) {
annotate({
action: { name: "coach.tool.section_without_values" },
meta: { tool: "get_metric_series", metric },
});
return { present: false, reason: "retrieval_failed" };
}
// Outside MCP the read is a Coach snapshot, which counts what it read.
const counts = (snapshot as Partial<CoachSnapshotResult>).provenance
?.counts as Record<string, number | undefined> | undefined;
const readings = counts?.[metric];
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
const condensed = isRecord(section) && Array.isArray(section.condensed);
const long = effectiveWindow === "allTime" || effectiveWindow === "lastYear";
return {
present: true,
data: {
metric,
section,
...(typeof readings === "number" ? { readings } : {}),
...(long || condensed ? { coverage: SERIES_COVERAGE_NOTE } : {}),
},
grounding: snapshot.referenceGrounding ?? undefined,
};
}
/**
* What a long or condensed series read carries, and where the rest is. Rides
* the result so the model switches to the table tool on its own instead of
* asking the person whether it should.
*/
const SERIES_COVERAGE_NOTE =
"Daily values for the newest 14 days, weekly means before that, back at most 12 months; " +
"timeline.coarse (when present) adds monthly and yearly means for older history. " +
"A 'condensed' list names what was cut to fit. A 'summary', when present, is taken from the series' daily values over the whole read ('from' to 'to', 'days'): " +
"the first and last day, the lowest and highest day, the mean of the days, and 'change' = 'latestWeekMean' minus 'firstWeekMean' (the latest 7 calendar days against the first 7). " +
"For the full history or a finer table, call get_metric_table yourself in this answer (window allTime, granularity month or week); do not ask the person first.";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Fields that describe a block rather than measure anything: the freshness
* stamp (`asOf.daysAgo` is a number, but no reading), and the scope echo. A
* block trimmed to `{ unit, asOf }` holds no figure.
*/
const METADATA_FIELDS: ReadonlySet<string> = new Set(["asOf", "scope"]);
/** Whether a payload holds at least one finite measured number anywhere. */
function carriesFigures(value: unknown): boolean {
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.some(carriesFigures);
if (isRecord(value)) {
return Object.entries(value).some(
([key, child]) => !METADATA_FIELDS.has(key) && carriesFigures(child),
);
}
return false;
}
@@ -0,0 +1,136 @@
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type {
CoachResultTable,
CoachScope,
CoachScopeWindow,
} from "@/lib/ai/coach/types";
import {
METRIC_TABLE_EXCLUDED_SOURCES,
compareWithCurrent,
readMetricTable,
summariseTable,
} from "@/lib/ai/coach/results/metric-table-tool";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
import {
clampWindow,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { getMetricTableArgsSchema } from "../definitions";
import { OUTSIDE_REACH_REASON, subjectForTool } from "../availability";
import { scopeAdmits, tableRangeWithinReach } from "./prior-results";
import { badArgs, emptyRead, limitWindow } from "./shared";
import type { CoachToolResult, CoachToolTurnContext } from "./types";
export async function getMetricTable(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getMetricTableArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_metric_table", parsed.error);
const { metric, granularity } = parsed.data;
// All time becomes the lookback limit; a narrower window stays.
const window = clampWindow(
parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW,
reach,
);
const period =
window === "allTime" ? "current" : (parsed.data.period ?? "current");
const excluded = METRIC_TABLE_EXCLUDED_SOURCES[metric];
if (excluded) return { present: false, reason: excluded };
// An earlier period is read only when all of it lies inside the limit: the
// previous window or the same window a year ago, cut short, would be a
// different comparison under the same name.
if (
period !== "current" &&
!tableRangeWithinReach(window, period, reach, turn?.now)
) {
return {
present: false,
reason: OUTSIDE_REACH_REASON,
searchedWindow: window,
};
}
// 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: limitWindow(fallbackWindow, reach),
},
{ reach },
);
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",
units: gate.units,
now: turn?.now,
});
if (!table) {
return emptyRead(
userId,
metric,
subjectForTool("get_metric_table", metric),
window,
reach,
);
}
// 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",
units: gate.units,
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 eighth 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,
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getSleepArgsSchema } from "../definitions";
import { subjectForTool } from "../availability";
import {
badArgs,
emptyRead,
limitWindow,
pickSection,
readToolSnapshot,
} from "./shared";
import type { CoachToolResult } from "./types";
export async function getSleep(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getSleepArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_sleep", parsed.error);
const snapshot = await readToolSnapshot(
userId,
["sleep"],
["sleep", "sleepRhythm"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
);
const nights = pickSection(snapshot.sections, "sleep");
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
if (nights === undefined && rhythm === undefined) {
return emptyRead(
userId,
"sleep",
subjectForTool("get_sleep"),
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return {
present: true,
data: {
...(nights !== undefined ? { nights } : {}),
...(rhythm !== undefined ? { rhythm } : {}),
},
grounding: snapshot.referenceGrounding ?? undefined,
};
}
@@ -0,0 +1,45 @@
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { getWorkoutsArgsSchema } from "../definitions";
import { subjectForTool } from "../availability";
import {
badArgs,
emptyRead,
limitWindow,
pickSection,
readToolSnapshot,
} from "./shared";
import type { CoachToolResult } from "./types";
export async function getWorkouts(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): 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 readToolSnapshot(
userId,
["workouts"],
["workouts"],
parsed.data.window,
fallbackWindow,
sharedScope,
reach,
);
const workouts = pickSection(snapshot.sections, "workouts");
if (workouts === undefined) {
return emptyRead(
userId,
"workouts",
subjectForTool("get_workouts"),
limitWindow(parsed.data.window ?? fallbackWindow, reach),
reach,
);
}
return { present: true, data: workouts };
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Which earlier result tables a turn may name and show again, under the
* same gates and lookback limit a fresh read passes.
*/
import { resolveModuleMap } from "@/lib/modules/gate";
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import type {
CoachResultPeriod,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
CoachStepDomain,
} from "@/lib/ai/coach/types";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import { resolveTableRange } from "@/lib/ai/coach/results/metric-table-tool";
import type { PriorResultTurn } from "@/lib/ai/coach/results/refs";
import {
exceedsReach,
isBounded,
withinReach,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
/**
* 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.
*
* The lookback limit applies the same way: a table whose range reaches past
* it (stored before the limit was set or narrowed) is not listed, because
* its line hands the model the window and the row count of history the
* Coach may no longer read. `show_result` refuses it on the same test.
*/
export async function admittedPriorResults(args: {
userId: string;
prefs: Pick<CoachPrefs, "excludeMetrics">;
scope: CoachScope | undefined;
reach: CoachHistoryReach;
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) &&
tableRangeWithinReach(
meta.source.window,
meta.source.period ?? "current",
args.reach,
),
),
}))
.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);
}
/**
* True when a table's whole range lies inside the lookback limit. An earlier
* period needs twice its window (previous) or a year more (a year ago), so a
* range either fits with room to spare or does not fit at all; the UTC day
* edges are close enough.
*/
export function tableRangeWithinReach(
window: CoachScopeWindow,
period: CoachResultPeriod,
reach: CoachHistoryReach,
now: Date = new Date(),
): boolean {
if (!isBounded(reach)) return true;
if (exceedsReach(window, reach)) return false;
const range = resolveTableRange({ window, period, timeZone: "UTC", now });
return withinReach(range.from, reach, now);
}
@@ -0,0 +1,119 @@
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import type { CoachResultTable, CoachScopeWindow } from "@/lib/ai/coach/types";
import { readMetricTable } from "@/lib/ai/coach/results/metric-table-tool";
import {
projectCompliance,
projectLabs,
projectWorkouts,
} from "@/lib/ai/coach/results/projections";
import { COACH_RESULT_TITLE_KEYS } from "@/lib/ai/coach/dialog-keys";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
import {
clampWindow,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import type { CoachToolName } from "../definitions";
import type { CoachToolResult, CoachToolTurnContext } from "./types";
/**
* 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.
*/
export async function withProjectedTable(
name: CoachToolName,
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
result: CoachToolResult,
turn: CoachToolTurnContext,
reach: CoachHistoryReach,
): 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 = clampWindow(
argWindow ?? fallbackWindow ?? DEFAULT_WINDOW,
reach,
);
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",
// Sleep is read in minutes for every reader; no preference applies.
units: DEFAULT_UNIT_PREFERENCES,
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;
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* What every Coach tool executor shares: the snapshot it slices its block
* out of, the window under the lookback limit, and the two grounded misses
* (bad arguments, an empty window read).
*/
import type { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
import {
clampWindow,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import {
resolveEmptyRead,
type CoachAvailabilitySubject,
} from "../availability";
import type { CoachToolResult } from "./types";
export function pickSection(
sections: Record<string, unknown>,
key: string,
): unknown | undefined {
const value = sections[key];
if (value === undefined || value === null) return undefined;
return value;
}
/**
* A tool's window under the lookback limit: whatever the call asks for, it
* reads no further back than the limit. `undefined` stays undefined (the
* builder default, 30 days, is inside every limit but the 7-day one, and the
* builder clamps that itself).
*/
export function limitWindow(
window: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): CoachScopeWindow | undefined {
return window === undefined ? undefined : clampWindow(window, reach);
}
/**
* Resolve the scope a tool reads its snapshot under.
*
* 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.
*/
export function scopeFor(
sources: CoachScope["sources"],
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
): CoachScope {
const effectiveWindow = limitWindow(window ?? fallbackWindow, reach);
if (sharedScope && sharedScope.window === effectiveWindow) {
// Same window as the inventory's full-source build → reuse its cache entry.
return sharedScope;
}
return {
sources,
window: effectiveWindow,
};
}
/**
* The snapshot a tool slices its block(s) out of.
*
* Reads under `scopeFor` first, which lands on the turn's shared full-source
* build when the windows match. That build is cut to the prompt budget for a
* prompt this read never sends: on an account with many synced series the
* budget pass collapses whole blocks to an `{ omitted }` marker, and slicing
* the marker out answered `present: true` with no figure in it. So when a
* block this read needs was cut there, it is read again from its own sources
* at the same window, where it is the block that was asked for.
*/
export async function readToolSnapshot(
userId: string,
sources: CoachScope["sources"],
sectionKeys: ReadonlyArray<string>,
window: CoachScopeWindow | undefined,
fallbackWindow: CoachScopeWindow | undefined,
sharedScope: CoachScope | undefined,
reach: CoachHistoryReach,
) {
const scope = scopeFor(sources, window, fallbackWindow, sharedScope, reach);
if (scope !== sharedScope) {
return buildCoachSnapshot(userId, scope, {
reach,
condenseRequested: true,
});
}
const snapshot = await buildCoachSnapshot(userId, scope, { reach });
const cut = sectionKeys.filter((key) =>
snapshot.degradedBlocks?.includes(key),
);
if (cut.length === 0) return snapshot;
annotate({
action: { name: "coach.tool.shared_block_cut" },
meta: { blocks: cut, window: scope.window ?? DEFAULT_WINDOW },
});
return buildCoachSnapshot(
userId,
{ sources, window: scope.window },
{ reach, condenseRequested: true },
);
}
export 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.
*/
export function emptyRead(
userId: string,
domain: string,
subject: CoachAvailabilitySubject | null,
searchedWindow: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach });
}
+180
View File
@@ -0,0 +1,180 @@
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import { resolveModuleMap } from "@/lib/modules/gate";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import type { CoachResultTable, CoachScope } from "@/lib/ai/coach/types";
import { summariseTable } from "@/lib/ai/coach/results/metric-table-tool";
import {
formatPriorResultRef,
resolvePriorResultRef,
} 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 type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
import { showResultArgsSchema, SHOW_RESULT_TOOL_NAME } from "../definitions";
import { OUTSIDE_REACH_REASON } from "../availability";
import {
isCoachScopeSource,
scopeAdmits,
tableRangeWithinReach,
} from "./prior-results";
import { badArgs, limitWindow } from "./shared";
import type { CoachToolResult, CoachToolTurnContext } from "./types";
/**
* 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(
shown: CoachResultTable,
view: "table" | "chart" | undefined,
locale: Locale,
): CoachResultTable {
// The view is this showing's alone, never the stored copy's.
const { view: _stored, ...table } = shown;
if (view === "table") {
// The table shows first; the chart stays for the toggle and the chip
// back to it.
const chart = deriveChartSpec(table);
return chart
? { ...table, chart, chartKind: chart.kind, view: "table" }
: { ...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.
*/
export async function showResult(
userId: string,
rawArgs: unknown,
sharedScope: CoachScope | undefined,
turn: CoachToolTurnContext | undefined,
reach: CoachHistoryReach,
): 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" };
}
// A stored table whose range reaches past the lookback limit set since is
// not shown again: the limit covers what the Coach shows as much as what
// it reads.
const { window: storedWindow, period: storedPeriod } = target.meta.source;
if (
storedWindow !== undefined &&
!tableRangeWithinReach(
storedWindow,
storedPeriod ?? "current",
reach,
turn.now,
)
) {
return { present: false, reason: OUTSIDE_REACH_REASON };
}
// 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: limitWindow(target.meta.source.window, reach),
},
{ reach },
);
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,
};
}
+71
View File
@@ -0,0 +1,71 @@
/** The shapes the Coach tool executor answers with and is handed. */
import type { Locale } from "@/lib/i18n/config";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import type {
PriorResultTurn,
ResultRefAllocator,
} from "@/lib/ai/coach/results/refs";
import type { CoachDomainAvailability } from "../availability";
/** 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;
/**
* The name of the table this call produced (`r1`..`r8`), 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;
/**
* 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;
}
/**
* 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;
/**
* 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>;
}
@@ -19,7 +19,7 @@ vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn() }));
vi.mock("@/lib/ai/coach/memory/contract", () => ({
buildMemoryContextBlock: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/tools/executor", async (importOriginal) => ({
vi.mock("@/lib/ai/coach/tools/run/prior-results", async (importOriginal) => ({
...(await importOriginal<object>()),
admittedPriorResults: vi.fn(async () => []),
}));
+13 -12
View File
@@ -7,11 +7,11 @@
* results (seeded, so a failure reproduces) and checks each step against the
* full set of strings the catalog can render.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readSource } from "@/__tests__/helpers/source-files";
import { locales, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -32,7 +32,7 @@ import {
SHOW_RESULT_TOOL_NAME,
parseCoachToolArgs,
} from "@/lib/ai/coach/tools/definitions";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
@@ -745,15 +745,16 @@ describe("snapshotStep", () => {
* executor, and the executor turns every failure into a result.
*/
describe("the tool loop settles every call it starts (structural)", () => {
const root = path.resolve(__dirname, "../../../../../..");
const loop = readFileSync(
path.join(root, "src/lib/ai/coach/tools/loop.ts"),
"utf8",
);
const executor = readFileSync(
path.join(root, "src/lib/ai/coach/tools/executor.ts"),
"utf8",
);
const loop = readSource("src/lib/ai/coach/tools/loop.ts", [
"onCallStart?.(",
"onCallSettled?.(",
]);
// The entry point and its dispatch live in `executor.ts`; the per-tool
// executors it dispatches to are in `tools/run/`.
const executor = readSource("src/lib/ai/coach/tools/executor.ts", [
"export async function executeCoachTool(",
"async function dispatch(",
]);
it("fires onCallStart and onCallSettled exactly once each per call", () => {
expect(loop.match(/onCallStart\?\.\(/g)).toHaveLength(1);
+1 -1
View File
@@ -31,7 +31,7 @@ import {
runCoachToolLoop,
type CoachToolTrace,
} from "@/lib/ai/coach/tools";
import { admittedPriorResults } from "@/lib/ai/coach/tools/executor";
import { admittedPriorResults } from "@/lib/ai/coach/tools/run/prior-results";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
import {
COMPARE_SERIES_TOOL_DEF,
+1 -1
View File
@@ -43,7 +43,7 @@ import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/executor";
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/run/prior-results";
import { createSseStream } from "@/lib/sse/create-stream";
import {
+1 -1
View File
@@ -30,7 +30,7 @@ import {
coachDomainLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
import {
SHOW_RESULT_TOOL_NAME,
isCoachToolName,