diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts index 6c92359fb..7b8e75db2 100644 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -71,9 +71,10 @@ vi.mock("@/lib/ai/coach/types", () => ({ // thin stub keeps the import graph satisfied without pulling the real schemas. vi.mock("@/lib/ai/coach/tools", () => ({ COACH_TOOL_DEFS: [], - MAX_ROUNDS: 2, + MAX_ROUNDS: 3, buildCoachDataInventory: vi.fn(), renderDataInventory: vi.fn(), + renderFocusHint: vi.fn(() => ""), buildToolModeAddendum: vi.fn(), runCoachToolLoop: vi.fn(), })); diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index c38816b39..e09dc97a2 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -138,31 +138,36 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({ })); // Tool-module surface — stub the inventory + loop so we assert routing only. -const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } = - vi.hoisted(() => ({ - buildCoachDataInventory: vi.fn(async () => ({ - entries: [], - restMode: false, - cycleEnabled: false, - window: "last30days", - })), - renderDataInventory: vi.fn( - () => "DATA INVENTORY\n- blood pressure: present", - ), - runCoachToolLoop: vi.fn(async () => ({ - result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" }, - workingProviderType: "anthropic", - totalTokens: 80, - rounds: 2, - toolTrace: [{ name: "get_metric_series", present: true }], - toolResults: [], - })), - })); -vi.mock("@/lib/ai/coach/tools", () => ({ - COACH_TOOL_DEFS: [{ name: "get_metric_series" }], - MAX_ROUNDS: 2, +const { buildCoachDataInventory, renderDataInventory, + renderFocusHint, + runCoachToolLoop, +} = vi.hoisted(() => ({ + buildCoachDataInventory: vi.fn(async () => ({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: ["bp", "hrv"], window: "last30days" }, + })), + renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"), + renderFocusHint: vi.fn(() => ""), + runCoachToolLoop: vi.fn(async () => ({ + result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" }, + workingProviderType: "anthropic", + totalTokens: 80, + rounds: 2, + toolTrace: [{ name: "get_metric_series", present: true }], + toolResults: [], + })), +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 3, + buildCoachDataInventory, + renderDataInventory, + renderFocusHint, buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"), runCoachToolLoop, })); @@ -246,11 +251,11 @@ describe("coach chat — tool-mode routing (F1)", () => { { providerType: "anthropic", instance: {} }, ]); await post(chatReq({ message: "How is my BP?" })); - // maxTokens (1500) × MAX_ROUNDS (2). The 4th arg is the provider-aware + // maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware // daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain. expect(reserveBudget).toHaveBeenCalledWith( "u1", - 3000, + 4500, "2026-06-21", 2_000_000, ); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 59183ba6f..512af8e14 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -575,6 +575,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, fallbackWindow: effectiveScope?.window, + // v1.21.0 (D5-1) — share the inventory's full-source snapshot across + // every tool so the turn builds ONE snapshot, not one per tool. The + // probe scope is the exact scope the inventory was built against, so the + // per-tool reads land its 60s LRU entry. + sharedScope: inventory.probeScope, // v1.20.1 — thread the abort signal so a mid-generation disconnect tears // down the per-round provider calls instead of paying the full cost. signal: request.signal, diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index e4edce158..c3c847a80 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -299,3 +299,82 @@ describe("executeCoachTool", () => { expect(result.reason).toBe("invalid_arguments"); }); }); + +// v1.21.0 (D5-1) — ONE snapshot per turn. When the route threads the shared +// full-source scope (the inventory's probe scope) and the tool's window +// matches, the tool must read under the SHARED scope key so every per-tool read +// lands the inventory's already-built 60s LRU entry — rather than rebuilding a +// distinct single-source snapshot per tool. +describe("executeCoachTool — shared snapshot scope (D5-1)", () => { + beforeEach(() => { + buildCoachSnapshot.mockReset(); + }); + + const sharedScope = { + sources: ["bp", "hrv", "sleep", "glucose"] as never, + window: "last30days" as const, + }; + + it("reads under the shared scope when the tool window matches", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ bloodPressure: { aggregate: { avgSys30: 128 } } }), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp" }), + fallbackWindow: "last30days", + sharedScope, + }); + expect(result.present).toBe(true); + // The read used the SHARED scope object, not a narrow single-source scope — + // so a second tool with the same shared scope hits the same cache key. + expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", sharedScope); + }); + + it("two tools in a turn read under the SAME scope key (one build)", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ + bloodPressure: { aggregate: {} }, + heartRateVariability: { aggregate: {} }, + }), + ); + await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp" }), + fallbackWindow: "last30days", + sharedScope, + }); + await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "hrv" }), + fallbackWindow: "last30days", + sharedScope, + }); + // Both reads used the identical shared scope object → identical cache key. + const scopes = buildCoachSnapshot.mock.calls.map((c) => c[1]); + expect(scopes).toHaveLength(2); + expect(scopes[0]).toBe(sharedScope); + expect(scopes[1]).toBe(sharedScope); + }); + + it("falls back to a narrow scope when the tool overrides the window", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ bloodPressure: { aggregate: {} } }), + ); + await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + // The model overrides the window → a distinct, correct build. + rawArguments: JSON.stringify({ metric: "bp", window: "last7days" }), + fallbackWindow: "last30days", + sharedScope, + }); + expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", { + sources: ["bp"], + window: "last7days", + }); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 3c4586224..91cf0fbe6 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -7,9 +7,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; -const executeCoachTool = vi.fn<() => Promise>(); +const executeCoachTool = vi.fn<(args?: unknown) => Promise>(); vi.mock("@/lib/ai/coach/tools/executor", () => ({ - executeCoachTool: () => executeCoachTool(), + executeCoachTool: (args: unknown) => executeCoachTool(args), })); // The loop calls the real fallback runner; stub it to a deterministic @@ -20,7 +20,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({ runRawCompletionWithFallback(args), })); -import { runCoachToolLoop, HARD_CAP } from "@/lib/ai/coach/tools/loop"; +import { + runCoachToolLoop, + HARD_CAP, + MAX_ROUNDS, +} from "@/lib/ai/coach/tools/loop"; import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; function completion(opts: { @@ -199,3 +203,75 @@ describe("runCoachToolLoop", () => { expect(msgs[2].toolCallId).toBe("c1"); }); }); + +// v1.21.0 (D5) — the round cap is 3 (sequential cross-metric "why" chains are +// no longer starved on a 2-round ceiling), and the absolute ceiling tracks one +// above so the final allowed round still forces prose. +describe("round bounds (D5)", () => { + it("MAX_ROUNDS is 3 and HARD_CAP tracks one above", () => { + expect(MAX_ROUNDS).toBe(3); + expect(HARD_CAP).toBe(MAX_ROUNDS + 1); + }); + + it("allows up to three tool-fetch rounds before forcing prose", async () => { + executeCoachTool.mockResolvedValue({ present: false }); + let toolRounds = 0; + runRawCompletionWithFallback.mockImplementation( + (args: { params: { toolChoice?: string } }) => { + if (args.params.toolChoice === "none") { + return Promise.resolve(completion({ content: "Forced final." })); + } + toolRounds += 1; + return Promise.resolve( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [ + { + id: "c", + name: "get_metric_series", + arguments: '{"metric":"bp"}', + }, + ], + }), + ); + }, + ); + const out = await runCoachToolLoop(baseArgs); + // Three rounds offered tools, the fourth forced prose. + expect(toolRounds).toBe(MAX_ROUNDS); + expect(out.rounds).toBe(HARD_CAP); + expect(out.result.content).toBe("Forced final."); + }); +}); + +// v1.21.0 (D5-1) — the loop threads the turn's shared snapshot scope to every +// tool so each per-tool read lands one cache key (one snapshot build per turn). +describe("shared snapshot scope threading (D5-1)", () => { + it("passes sharedScope through to executeCoachTool", async () => { + executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } }); + const shared = { + sources: ["bp", "hrv"] as never, + window: "last30days" as const, + }; + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [ + { + id: "c1", + name: "get_metric_series", + arguments: '{"metric":"bp"}', + }, + ], + }), + ) + .mockResolvedValueOnce(completion({ content: "done" })); + await runCoachToolLoop({ ...baseArgs, sharedScope: shared }); + expect(executeCoachTool).toHaveBeenCalledWith( + expect.objectContaining({ sharedScope: shared }), + ); + }); +}); diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 60baa2ba1..1d33e9726 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -20,10 +20,17 @@ * mutation or egress surface. * * The builder result is memoised by the 60s snapshot LRU keyed on - * `(userId, window, sources)`. Each tool scopes to a different source set, so - * tool calls for distinct domains do NOT share a cache entry; sharing happens - * only when the SAME scope recurs (a repeated call, or a turn that re-enters - * the same domain) within the 60s window. + * `(userId, window, sources)`. + * + * v1.21.0 (D5-1) — ONE snapshot per turn. The route hands every tool the SAME + * `sharedScope` it built the DATA INVENTORY against (the full source set + the + * conversation window). A full-source build already contains every section, so + * each tool reads under that shared scope key and slices its own section out — + * landing the 60s LRU hit the inventory primed rather than rebuilding a + * distinct single-source snapshot per tool (the D5-1 N-builds-per-turn cost). + * A tool that overrides the window to something OTHER than the shared window + * still gets a correct, distinct build (the rare case); the common path + * collapses to one build. */ import { z } from "zod/v4"; @@ -79,18 +86,32 @@ function pickSection( } /** - * Resolve `args.window` into a scope. Falls back to the conversation's - * effective window so a tool call that omits `window` matches the user's - * chosen analysis window rather than the builder default. + * Resolve the scope a tool reads its snapshot under. + * + * v1.21.0 (D5-1) — when the route handed us a `sharedScope` (the full-source + * inventory build for this turn) AND the tool's effective window matches the + * shared window, read under the SHARED scope key so the per-tool read lands the + * 60s LRU hit the inventory already primed (the section it needs is present in + * the full-source build). Otherwise — no shared scope, or a window override — + * fall back to the tight per-source scope (a correct, distinct build). + * + * The window default chain is unchanged: `args.window` wins, else the + * conversation's `fallbackWindow`, else the builder default. */ function scopeFor( sources: CoachScope["sources"], window: CoachScopeWindow | undefined, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): CoachScope { + const effectiveWindow = window ?? fallbackWindow; + if (sharedScope && sharedScope.window === effectiveWindow) { + // Same window as the inventory's full-source build → reuse its cache entry. + return sharedScope; + } return { sources, - window: window ?? fallbackWindow, + window: effectiveWindow, }; } @@ -107,8 +128,15 @@ export async function executeCoachTool(args: { rawArguments: string; /** The conversation's effective window, used when a call omits `window`. */ fallbackWindow?: CoachScopeWindow; + /** + * v1.21.0 (D5-1) — the turn's shared full-source snapshot scope (the same one + * the DATA INVENTORY was built against). When present and the tool's window + * matches, the tool reads under this scope key so it lands the inventory's + * already-built cache entry instead of rebuilding a single-source snapshot. + */ + sharedScope?: CoachScope; }): Promise { - const { userId, name, rawArguments, fallbackWindow } = args; + const { userId, name, rawArguments, fallbackWindow, sharedScope } = args; if (!isCoachToolName(name)) { annotate({ @@ -130,7 +158,13 @@ export async function executeCoachTool(args: { } try { - const result = await dispatch(name, userId, parsedArgs, fallbackWindow); + const result = await dispatch( + name, + userId, + parsedArgs, + fallbackWindow, + sharedScope, + ); annotate({ action: { name: "coach.tool.executed" }, meta: { tool: name, present: result.present }, @@ -155,24 +189,30 @@ async function dispatch( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { switch (name) { case "get_metric_series": - return getMetricSeries(userId, rawArgs, fallbackWindow); + return getMetricSeries(userId, rawArgs, fallbackWindow, sharedScope); case "get_glucose_panel": - return getGlucosePanel(userId, rawArgs, fallbackWindow); + return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope); case "get_sleep": - return getSleep(userId, rawArgs, fallbackWindow); + return getSleep(userId, rawArgs, fallbackWindow, sharedScope); case "get_medication_compliance": - return getMedicationCompliance(userId, rawArgs, fallbackWindow); + return getMedicationCompliance( + userId, + rawArgs, + fallbackWindow, + sharedScope, + ); case "get_labs": - return getLabs(userId, rawArgs, fallbackWindow); + return getLabs(userId, rawArgs, fallbackWindow, sharedScope); case "get_illness_recovery": - return getIllnessRecovery(userId, fallbackWindow); + return getIllnessRecovery(userId, fallbackWindow, sharedScope); case "get_workouts": - return getWorkouts(userId, rawArgs, fallbackWindow); + return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope); case "get_cycle": - return getCycle(userId, rawArgs); + return getCycle(userId, rawArgs, sharedScope); case "get_correlations": return getCorrelations(userId, rawArgs); } @@ -190,6 +230,7 @@ async function getMetricSeries( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_metric_series", parsed.error); @@ -217,7 +258,7 @@ async function getMetricSeries( const snapshot = await buildCoachSnapshot( userId, - scopeFor([metric], window, fallbackWindow), + scopeFor([metric], window, fallbackWindow, sharedScope), ); const section = pickSection(snapshot.sections, sectionKey); if (section === undefined) { @@ -234,12 +275,13 @@ async function getGlucosePanel( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_glucose_panel", parsed.error); const snapshot = await buildCoachSnapshot( userId, - scopeFor(["glucose"], parsed.data.window, fallbackWindow), + scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope), ); const section = pickSection(snapshot.sections, "glucose"); if (section === undefined) return { present: false, reason: "no_data" }; @@ -254,12 +296,13 @@ async function getSleep( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getSleepArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_sleep", parsed.error); const snapshot = await buildCoachSnapshot( userId, - scopeFor(["sleep"], parsed.data.window, fallbackWindow), + scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope), ); const nights = pickSection(snapshot.sections, "sleep"); const rhythm = pickSection(snapshot.sections, "sleepRhythm"); @@ -280,6 +323,7 @@ async function getMedicationCompliance( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs); if (!parsed.success) { @@ -287,7 +331,7 @@ async function getMedicationCompliance( } const snapshot = await buildCoachSnapshot( userId, - scopeFor(["compliance"], parsed.data.window, fallbackWindow), + scopeFor(["compliance"], parsed.data.window, fallbackWindow, sharedScope), ); const compliance = pickSection(snapshot.sections, "compliance"); // GLP-1 context rides the `weeklyContext` block. @@ -311,22 +355,26 @@ async function getLabs( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): 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. Pass an empty source set to keep - // the read tight. + // so a minimal scope still surfaces them. // // v1.21.0 (A5-F4) — the labs block itself is a "latest reading per biomarker // over the last 12 months" snapshot and is intentionally window-AGNOSTIC // (the read cutoff is fixed). We still thread the conversation's window onto // the scope so the snapshot's `scope` block reports the right horizon for the // turn; it does not move the labs read. - const snapshot = await buildCoachSnapshot(userId, { - sources: [], - window: fallbackWindow, - }); + // + // v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read + // lands the inventory's cache entry; fall back to a tight empty-source scope + // (labs ride unconditionally) when no shared scope or a window mismatch. + const snapshot = await buildCoachSnapshot( + userId, + scopeFor([], fallbackWindow, fallbackWindow, sharedScope), + ); const labs = pickSection(snapshot.sections, "labs") as | { recent?: Array<{ name?: string; analyte?: string }> } | undefined; @@ -350,6 +398,7 @@ async function getLabs( async function getIllnessRecovery( userId: string, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { // Validate the (empty) args shape for consistency; an empty object always // passes. @@ -361,10 +410,19 @@ async function getIllnessRecovery( // v1.21.0 (A5-F4) — honour the conversation's window so the recovery/strain/ // trajectory composites are computed over the horizon the caller asked for // rather than the builder default. - const snapshot = await buildCoachSnapshot(userId, { - sources: ["hrv", "resting_hr", "vo2_max"], - window: fallbackWindow, - }); + // + // v1.21.0 (D5-1) — prefer the turn's shared full-source scope (which already + // carries hrv/resting_hr/vo2_max + illness + derived) so this read lands the + // inventory's cache entry; the narrow scope is the window-mismatch fallback. + const snapshot = await buildCoachSnapshot( + userId, + scopeFor( + ["hrv", "resting_hr", "vo2_max"], + fallbackWindow, + fallbackWindow, + sharedScope, + ), + ); const illness = pickSection(snapshot.sections, "illness"); const derived = pickSection(snapshot.sections, "derived"); const dayStrain = pickSection(snapshot.sections, "dayStrain"); @@ -392,6 +450,7 @@ async function getWorkouts( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getWorkoutsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_workouts", parsed.error); @@ -399,7 +458,7 @@ async function getWorkouts( // user has workout rows in the window. Scope the read to that single source. const snapshot = await buildCoachSnapshot( userId, - scopeFor(["workouts"], parsed.data.window, fallbackWindow), + scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope), ); const workouts = pickSection(snapshot.sections, "workouts"); if (workouts === undefined) return { present: false, reason: "no_data" }; @@ -409,6 +468,7 @@ async function getWorkouts( async function getCycle( userId: string, rawArgs: unknown, + sharedScope: CoachScope | undefined, ): Promise { const parsed = getCycleArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_cycle", parsed.error); @@ -416,7 +476,14 @@ async function getCycle( // (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). - const snapshot = await buildCoachSnapshot(userId, { sources: [] }); + // + // v1.21.0 (D5-1) — prefer the turn's shared full-source scope so this read + // lands the inventory's cache entry; the empty-source scope is the fallback + // when no shared scope is threaded. + const snapshot = await buildCoachSnapshot( + userId, + sharedScope ?? { sources: [] }, + ); const cycle = pickSection(snapshot.sections, "cycle"); if (cycle === undefined) return { present: false, reason: "no_data" }; return { present: true, data: cycle }; diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 651dca57b..ac8e147e9 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -50,6 +50,14 @@ export interface CoachDataInventory { cycleEnabled: boolean; /** The window the inventory was built against. */ window: string; + /** + * v1.21.0 (D5-1) — the exact full-source scope this inventory's snapshot was + * built against. The route threads it to the tool loop so every per-tool read + * lands the SAME 60s LRU entry (one snapshot build per turn, not N). Returned + * here so the route reuses the identical scope object rather than + * re-deriving it and risking a key mismatch. + */ + probeScope: CoachScope; } /** @@ -188,6 +196,7 @@ export async function buildCoachDataInventory( restMode, cycleEnabled, window: scopeBlock?.window ?? scope?.window ?? "last30days", + probeScope, }; } diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 852813e67..ba7b52be3 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -7,10 +7,11 @@ * FINAL prose for the visible stream exactly as before. * * Bounds (the loop can never run away): - * - MAX_ROUNDS = 2: one tool-fetch round + the answer round. - * - HARD_CAP = 3: an absolute ceiling; on the last allowed round the loop - * re-calls with `toolChoice:"none"` so the model is FORCED to produce prose - * even if it would otherwise keep asking for tools. + * - MAX_ROUNDS = 3 (v1.21.0 D5): up to three tool-fetch rounds before the + * forced answer, so a sequential cross-metric "why" chain isn't starved. + * - HARD_CAP = MAX_ROUNDS + 1: an absolute ceiling; on the last allowed round + * the loop re-calls with `toolChoice:"none"` so the model is FORCED to + * produce prose even if it would otherwise keep asking for tools. * - Tool calls within a round run in parallel (`Promise.all`). * * Token accounting: the caller reserves `maxTokens * MAX_ROUNDS` up front and @@ -27,16 +28,28 @@ import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types"; import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger"; -import type { CoachScopeWindow } from "@/lib/ai/coach/types"; +import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types"; import { executeCoachTool, type CoachToolResult, type CoachToolTrace, } from "./executor"; -export const MAX_ROUNDS = 2; -/** Absolute ceiling; the final allowed round forces prose (toolChoice none). */ -export const HARD_CAP = 3; +/** + * v1.21.0 (D5) — MAX_ROUNDS raised 2 → 3 so a sequential cross-metric "why" + * chain (recovery low → which driver → pull that metric's series) is not + * starved on the 2-round ceiling. The per-round token budget is unchanged; the + * caller reserves `maxTokens * MAX_ROUNDS` and reconciles the summed actual + * tokens, so the bound scales with this constant and stays explicit. HARD_CAP + * tracks one above so the final allowed round still forces prose. + */ +export const MAX_ROUNDS = 3; +/** + * Absolute ceiling; the final allowed round forces prose (toolChoice none). + * Tracks one above MAX_ROUNDS so rounds 1..MAX_ROUNDS may fetch tools and the + * final round is always a forced answer. + */ +export const HARD_CAP = MAX_ROUNDS + 1; export interface CoachToolLoopResult { /** The final completion (prose). */ @@ -74,6 +87,12 @@ export async function runCoachToolLoop(args: { temperature?: number; maxTokens?: number; fallbackWindow?: CoachScopeWindow; + /** + * v1.21.0 (D5-1) — the turn's shared full-source snapshot scope (the same one + * the DATA INVENTORY was built against). Threaded to every tool so they read + * under one cache key — one snapshot build per turn instead of N. + */ + sharedScope?: CoachScope; ledger?: ProviderHealthLedger; /** Aborts the per-round provider calls on client disconnect. */ signal?: AbortSignal; @@ -86,6 +105,7 @@ export async function runCoachToolLoop(args: { temperature, maxTokens, fallbackWindow, + sharedScope, ledger, signal, } = args; @@ -166,6 +186,7 @@ export async function runCoachToolLoop(args: { name: call.name, rawArguments: call.arguments, fallbackWindow, + sharedScope, }); toolTrace.push({ name: call.name, present: toolResult.present }); // v1.21.0 (P6) — retain the present results' payloads for the post-hoc