From faeebfc63a8c7175cd9e1fbcd27d28a6f6502824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:54:19 +0200 Subject: [PATCH] refactor(coach): run each Coach tool from a module of its own under tools/run --- .../coach-availability-window-honesty.test.ts | 26 +- .../coach-history-reach-guard.test.ts | 13 +- .../coach/__tests__/tools-loop-budget.test.ts | 2 +- .../__tests__/tools-loop-callbacks.test.ts | 2 +- .../tools-loop-final-round-wire.test.ts | 2 +- .../tools-loop-miss-payloads.test.ts | 2 +- src/lib/ai/coach/__tests__/tools-loop.test.ts | 2 +- .../coach/results/__tests__/compare.test.ts | 2 +- .../__tests__/executor-results.test.ts | 8 +- .../__tests__/loop-result-tables.test.ts | 2 +- src/lib/ai/coach/results/project.ts | 2 +- src/lib/ai/coach/tools/compare-series.ts | 7 +- src/lib/ai/coach/tools/executor.ts | 1293 +---------------- src/lib/ai/coach/tools/index.ts | 2 +- src/lib/ai/coach/tools/loop.ts | 12 +- src/lib/ai/coach/tools/progress.ts | 2 +- .../ai/coach/tools/run/get-correlations.ts | 42 + src/lib/ai/coach/tools/run/get-cycle.ts | 60 + src/lib/ai/coach/tools/run/get-day.ts | 31 + src/lib/ai/coach/tools/run/get-environment.ts | 33 + .../ai/coach/tools/run/get-glucose-panel.ts | 47 + .../coach/tools/run/get-illness-recovery.ts | 90 ++ src/lib/ai/coach/tools/run/get-labs.ts | 63 + .../tools/run/get-medication-compliance.ts | 55 + .../ai/coach/tools/run/get-metric-series.ts | 150 ++ .../ai/coach/tools/run/get-metric-table.ts | 136 ++ src/lib/ai/coach/tools/run/get-sleep.ts | 51 + src/lib/ai/coach/tools/run/get-workouts.ts | 45 + src/lib/ai/coach/tools/run/prior-results.ts | 114 ++ src/lib/ai/coach/tools/run/projected-table.ts | 119 ++ src/lib/ai/coach/tools/run/shared.ts | 139 ++ src/lib/ai/coach/tools/run/show-result.ts | 180 +++ src/lib/ai/coach/tools/run/types.ts | 71 + .../__tests__/model-stable-prefix.test.ts | 2 +- src/lib/ai/coach/turn/__tests__/steps.test.ts | 25 +- src/lib/ai/coach/turn/model.ts | 2 +- src/lib/ai/coach/turn/reuse-turn.ts | 2 +- src/lib/ai/coach/turn/steps.ts | 2 +- 38 files changed, 1514 insertions(+), 1324 deletions(-) create mode 100644 src/lib/ai/coach/tools/run/get-correlations.ts create mode 100644 src/lib/ai/coach/tools/run/get-cycle.ts create mode 100644 src/lib/ai/coach/tools/run/get-day.ts create mode 100644 src/lib/ai/coach/tools/run/get-environment.ts create mode 100644 src/lib/ai/coach/tools/run/get-glucose-panel.ts create mode 100644 src/lib/ai/coach/tools/run/get-illness-recovery.ts create mode 100644 src/lib/ai/coach/tools/run/get-labs.ts create mode 100644 src/lib/ai/coach/tools/run/get-medication-compliance.ts create mode 100644 src/lib/ai/coach/tools/run/get-metric-series.ts create mode 100644 src/lib/ai/coach/tools/run/get-metric-table.ts create mode 100644 src/lib/ai/coach/tools/run/get-sleep.ts create mode 100644 src/lib/ai/coach/tools/run/get-workouts.ts create mode 100644 src/lib/ai/coach/tools/run/prior-results.ts create mode 100644 src/lib/ai/coach/tools/run/projected-table.ts create mode 100644 src/lib/ai/coach/tools/run/shared.ts create mode 100644 src/lib/ai/coach/tools/run/show-result.ts create mode 100644 src/lib/ai/coach/tools/run/types.ts diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts index e34055329..d973dbba0 100644 --- a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -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" \}/, ); diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts index f9293bcaf..1236a489d 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -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("), ); diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts index c39c6f4b5..678e79f30 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts @@ -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>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ diff --git a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts index 530a66fea..0c09a5057 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts @@ -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>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ diff --git a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts index 9a7c287b5..c7f1e2abf 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts @@ -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(); diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts index d932c7faf..8cc8aa3ee 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -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>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 9afadd995..36509a431 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -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>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ diff --git a/src/lib/ai/coach/results/__tests__/compare.test.ts b/src/lib/ai/coach/results/__tests__/compare.test.ts index bea217f80..ed4a0f7aa 100644 --- a/src/lib/ai/coach/results/__tests__/compare.test.ts +++ b/src/lib/ai/coach/results/__tests__/compare.test.ts @@ -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>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index aca9923de..9ba93ed03 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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"; diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts index 1e392f059..02ad76e56 100644 --- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts +++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts @@ -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>(); diff --git a/src/lib/ai/coach/results/project.ts b/src/lib/ai/coach/results/project.ts index 0a0207e59..d551147c9 100644 --- a/src/lib/ai/coach/results/project.ts +++ b/src/lib/ai/coach/results/project.ts @@ -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"; diff --git a/src/lib/ai/coach/tools/compare-series.ts b/src/lib/ai/coach/tools/compare-series.ts index 569c16338..4e169fa83 100644 --- a/src/lib/ai/coach/tools/compare-series.ts +++ b/src/lib/ai/coach/tools/compare-series.ts @@ -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"; diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 1f6b201b1..d47706efd 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -38,272 +38,37 @@ * 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. + * + * This module is the entry point and the dispatch table. Each tool's + * executor lives in `run/.ts`, the result shapes in `run/types.ts`, + * and the snapshot plumbing they share in `run/shared.ts`. */ -import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -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 type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types"; import { - buildCoachSnapshot, - type CoachSnapshotResult, -} from "@/lib/ai/coach/snapshot"; -import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; -import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-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 { - CoachResultPeriod, - 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, - resolveTableRange, - 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, - getEnvironmentArgsSchema, - getDayArgsSchema, 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 { readEnvironmentForTool } from "./environment-read"; -import { readDayForTool } from "./day-read"; -import { readCoachExclusions } from "@/lib/ai/coach/history-reach-read"; -import { fenceUserText } from "@/lib/ai/coach/data-fence"; -import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; -import { - resolveEmptyRead, - subjectForTool, - type CoachAvailabilitySubject, - type CoachDomainAvailability, -} from "./availability"; -import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot"; -import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { UNBOUNDED_REACH, - clampWindow, - exceedsReach, - isBounded, - withinReach, type CoachHistoryReach, } from "@/lib/ai/coach/history-reach"; -import { - OUTSIDE_REACH_REASON, - cycleRecorded, - illnessBeyondReach, -} 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; - /** - * v1.39.4 — 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; - /** - * 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; - 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; -} - -function pickSection( - sections: Record, - 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). - */ -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. - * - * v1.21.0 — 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, - 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. - */ -async function readToolSnapshot( - userId: string, - sources: CoachScope["sources"], - sectionKeys: ReadonlyArray, - 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 }, - ); -} +import type { CoachToolResult, CoachToolTurnContext } from "./run/types"; +import { getMetricSeries } from "./run/get-metric-series"; +import { getGlucosePanel } from "./run/get-glucose-panel"; +import { getSleep } from "./run/get-sleep"; +import { getMedicationCompliance } from "./run/get-medication-compliance"; +import { getLabs } from "./run/get-labs"; +import { getIllnessRecovery } from "./run/get-illness-recovery"; +import { getWorkouts } from "./run/get-workouts"; +import { getCycle } from "./run/get-cycle"; +import { getCorrelations } from "./run/get-correlations"; +import { getMetricTable } from "./run/get-metric-table"; +import { getEnvironment } from "./run/get-environment"; +import { getDay } from "./run/get-day"; +import { showResult } from "./run/show-result"; +import { withProjectedTable } from "./run/projected-table"; /** * Execute one tool call. `rawArguments` is the model's raw JSON-string @@ -502,1021 +267,3 @@ async function dispatchRead( return getDay(userId, rawArgs, reach); } } - -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, - reach: CoachHistoryReach, -): Promise { - return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach }); -} - -async function getMetricSeries( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, - sourceSnapshot: boolean, -): Promise { - 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).provenance - ?.counts as Record | 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 { - 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 = 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; -} - -async function getGlucosePanel( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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, - }; -} - -async function getSleep( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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, - }; -} - -async function getMedicationCompliance( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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 } : {}), - }, - }; -} - -async function getLabs( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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 — 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. - // - // v1.21.0 — 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 }; -} - -async function getIllnessRecovery( - userId: string, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - // 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 — 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 — 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"); - // 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, 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 } : {}), - }, - }; -} - -async function getWorkouts( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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 }; -} - -async function getCycle( - userId: string, - rawArgs: unknown, - sharedScope: CoachScope | undefined, - reach: CoachHistoryReach, -): Promise { - 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). - // - // v1.21.0 — 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 }; -} - -async function getCorrelations( - userId: string, - rawArgs: unknown, - reach: CoachHistoryReach, -): Promise { - 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, - }, - }; -} - -/** - * v1.42 (#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. - */ -async function getDay( - userId: string, - rawArgs: unknown, - reach: CoachHistoryReach, -): Promise { - 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 }; -} - -/** - * v1.42 (#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. - */ -async function getEnvironment( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - reach: CoachHistoryReach, -): Promise { - 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 } : {}), - }; -} - -// ── 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, - 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; - scope: CoachScope | undefined; - reach: CoachHistoryReach; - prior: readonly PriorResultTurn[]; -}): Promise { - 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); -} - -async function getMetricTable( - userId: string, - rawArgs: unknown, - fallbackWindow: CoachScopeWindow | undefined, - sharedScope: CoachScope | undefined, - turn: CoachToolTurnContext | undefined, - reach: CoachHistoryReach, -): Promise { - 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 | 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, - }; -} - -/** - * 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. - */ -async function showResult( - userId: string, - rawArgs: unknown, - sharedScope: CoachScope | undefined, - turn: CoachToolTurnContext | undefined, - reach: CoachHistoryReach, -): Promise { - 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, - }; -} - -/** - * 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, - reach: CoachHistoryReach, -): Promise { - 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; - } -} diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts index 100f2e8a3..9ae6724f5 100644 --- a/src/lib/ai/coach/tools/index.ts +++ b/src/lib/ai/coach/tools/index.ts @@ -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, diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 5fe032389..5bfb5cc6a 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -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, diff --git a/src/lib/ai/coach/tools/progress.ts b/src/lib/ai/coach/tools/progress.ts index cfd3352e7..d4c63942b 100644 --- a/src/lib/ai/coach/tools/progress.ts +++ b/src/lib/ai/coach/tools/progress.ts @@ -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 = new Set([ diff --git a/src/lib/ai/coach/tools/run/get-correlations.ts b/src/lib/ai/coach/tools/run/get-correlations.ts new file mode 100644 index 000000000..77551a243 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-correlations.ts @@ -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 { + 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, + }, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-cycle.ts b/src/lib/ai/coach/tools/run/get-cycle.ts new file mode 100644 index 000000000..c9eb1fc78 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-cycle.ts @@ -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 { + 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 }; +} diff --git a/src/lib/ai/coach/tools/run/get-day.ts b/src/lib/ai/coach/tools/run/get-day.ts new file mode 100644 index 000000000..55f44ef56 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-day.ts @@ -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 { + 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 }; +} diff --git a/src/lib/ai/coach/tools/run/get-environment.ts b/src/lib/ai/coach/tools/run/get-environment.ts new file mode 100644 index 000000000..1aeedbc0c --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-environment.ts @@ -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 { + 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 } : {}), + }; +} diff --git a/src/lib/ai/coach/tools/run/get-glucose-panel.ts b/src/lib/ai/coach/tools/run/get-glucose-panel.ts new file mode 100644 index 000000000..05ea2e17a --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-glucose-panel.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-illness-recovery.ts b/src/lib/ai/coach/tools/run/get-illness-recovery.ts new file mode 100644 index 000000000..615660928 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-illness-recovery.ts @@ -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 { + // 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 } : {}), + }, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-labs.ts b/src/lib/ai/coach/tools/run/get-labs.ts new file mode 100644 index 000000000..14d39e0b6 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-labs.ts @@ -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 { + 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 }; +} diff --git a/src/lib/ai/coach/tools/run/get-medication-compliance.ts b/src/lib/ai/coach/tools/run/get-medication-compliance.ts new file mode 100644 index 000000000..c6a4fb3a0 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-medication-compliance.ts @@ -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 { + 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 } : {}), + }, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-metric-series.ts b/src/lib/ai/coach/tools/run/get-metric-series.ts new file mode 100644 index 000000000..6a12d1d18 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-metric-series.ts @@ -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 { + 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).provenance + ?.counts as Record | 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 { + 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 = 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; +} diff --git a/src/lib/ai/coach/tools/run/get-metric-table.ts b/src/lib/ai/coach/tools/run/get-metric-table.ts new file mode 100644 index 000000000..ade3b87d0 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-metric-table.ts @@ -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 { + 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 | 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, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-sleep.ts b/src/lib/ai/coach/tools/run/get-sleep.ts new file mode 100644 index 000000000..b038fe95c --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-sleep.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/ai/coach/tools/run/get-workouts.ts b/src/lib/ai/coach/tools/run/get-workouts.ts new file mode 100644 index 000000000..e55d9dc32 --- /dev/null +++ b/src/lib/ai/coach/tools/run/get-workouts.ts @@ -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 { + 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 }; +} diff --git a/src/lib/ai/coach/tools/run/prior-results.ts b/src/lib/ai/coach/tools/run/prior-results.ts new file mode 100644 index 000000000..08d324843 --- /dev/null +++ b/src/lib/ai/coach/tools/run/prior-results.ts @@ -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, + 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; + scope: CoachScope | undefined; + reach: CoachHistoryReach; + prior: readonly PriorResultTurn[]; +}): Promise { + 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); +} diff --git a/src/lib/ai/coach/tools/run/projected-table.ts b/src/lib/ai/coach/tools/run/projected-table.ts new file mode 100644 index 000000000..66c9b9d6f --- /dev/null +++ b/src/lib/ai/coach/tools/run/projected-table.ts @@ -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 { + 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; + } +} diff --git a/src/lib/ai/coach/tools/run/shared.ts b/src/lib/ai/coach/tools/run/shared.ts new file mode 100644 index 000000000..035dce514 --- /dev/null +++ b/src/lib/ai/coach/tools/run/shared.ts @@ -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, + 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, + 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 { + return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach }); +} diff --git a/src/lib/ai/coach/tools/run/show-result.ts b/src/lib/ai/coach/tools/run/show-result.ts new file mode 100644 index 000000000..926dc2285 --- /dev/null +++ b/src/lib/ai/coach/tools/run/show-result.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/ai/coach/tools/run/types.ts b/src/lib/ai/coach/tools/run/types.ts new file mode 100644 index 000000000..cfb7ecc89 --- /dev/null +++ b/src/lib/ai/coach/tools/run/types.ts @@ -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; + 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; +} diff --git a/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts index 1a90f4b14..998d38f47 100644 --- a/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts +++ b/src/lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts @@ -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()), admittedPriorResults: vi.fn(async () => []), })); diff --git a/src/lib/ai/coach/turn/__tests__/steps.test.ts b/src/lib/ai/coach/turn/__tests__/steps.test.ts index a679912ba..b5453ed75 100644 --- a/src/lib/ai/coach/turn/__tests__/steps.test.ts +++ b/src/lib/ai/coach/turn/__tests__/steps.test.ts @@ -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); diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 6bdd561b6..6450a50db 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -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, diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts index 69f7e5624..fe2a9c14f 100644 --- a/src/lib/ai/coach/turn/reuse-turn.ts +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -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 { diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index bbb9998c7..87aa40787 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -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,