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 1212d20e0..8b6c82fd3 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 @@ -66,6 +66,17 @@ vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); vi.mock("@/lib/ai/coach/types", () => ({ coachChatRequestSchema: { safeParse: () => ({ success: false }) }, })); +// v1.20.0 (F1) — the route statically imports the tool barrel; this suite +// never reaches tool code (it asserts the 403 module-gate short-circuit), so a +// thin stub keeps the import graph satisfied without pulling the real schemas. +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [], + MAX_ROUNDS: 2, + buildCoachDataInventory: vi.fn(), + renderDataInventory: vi.fn(), + buildToolModeAddendum: vi.fn(), + runCoachToolLoop: vi.fn(), +})); vi.mock("@/lib/ai/coach/persistence", () => ({ appendMessage: vi.fn(), createConversation: vi.fn(), diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index cf29f225b..ef6332dde 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -58,8 +58,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({ runRawCompletionWithFallback, })); vi.mock("@/lib/ai/provider", () => ({ + // v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy + // snapshot-stuffing fallback path (the behaviour it was written to pin). The + // tool-retrieval path is covered by route-tool-mode.test.ts. resolveProviderChain: vi.fn(async () => [ - { providerType: "admin-openai", instance: {} }, + { providerType: "admin-openai", instance: { supportsTools: false } }, ]), resolveProvider: 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 new file mode 100644 index 000000000..d8b95d743 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.20.0 (F1) — Coach tool-retrieval routing contract. + * + * - When every provider in the chain supports tools, the route runs the tool + * loop with a base context (DATA INVENTORY, NOT the snapshot figures), and + * persists the tool trace onto provenance. + * - When any provider lacks tool support, the route falls back to the legacy + * snapshot-stuffing single completion verbatim. + * - The inbound gates (rate limit, refusal, consent, budget reservation) all + * fire BEFORE the loop in both modes. + */ + +const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}'; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number) => ({ data: null, error, status }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) }, + coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) }, + }, +})); + +const { checkRateLimit } = vi.hoisted(() => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); + +const { runRawCompletionWithFallback } = vi.hoisted(() => ({ + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback, +})); + +const { resolveProviderChain } = vi.hoisted(() => ({ + resolveProviderChain: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider: vi.fn(), +})); + +const { assertConsentForChain } = vi.hoisted(() => ({ + assertConsentForChain: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/ai-budgets", () => ({ + AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } }, +})); + +vi.mock("@/lib/ai/coach/types", async () => { + const actual = await vi.importActual( + "@/lib/ai/coach/types", + ); + return actual; +}); + +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(async () => ({ id: "m1" })), + createConversation: vi.fn(async () => ({ id: "c1" })), + fetchConversationWithMessages: vi.fn(), + listConversations: vi.fn(), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ + storeDeterministicFacts: vi.fn(async () => undefined), +})); + +const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({ + reserveBudget: vi.fn< + () => Promise<{ + allowed: boolean; + reserved: number; + totalAfter?: number; + }> + >(async () => ({ allowed: true, reserved: 3000 })), + reconcileSpend: vi.fn(async () => undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-06-21"), + reserveBudget, + reconcileSpend, +})); + +const { detectRefusal } = vi.hoisted(() => ({ + detectRefusal: vi.fn(() => ({ refuse: false })), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal })); +vi.mock("@/lib/ai/coach/outbound-guard", () => ({ + screenCoachReply: vi.fn(() => ({ block: false })), + coachOutboundFallback: vi.fn(() => "fallback"), +})); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(() => "SYSTEM"), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: vi.fn(async () => ({ + snapshotJson: SNAPSHOT_JSON, + sections: { bloodPressure: { aggregate: { mean: 128 } } }, + provenance: { windows: ["last30days"], metrics: ["bp"] }, + referenceGrounding: "REFERENCE RANGES", + })), +})); + +// 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 }], + })), + })); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [{ name: "get_metric_series" }], + MAX_ROUNDS: 2, + buildCoachDataInventory, + renderDataInventory, + buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"), + runCoachToolLoop, +})); + +vi.mock("@/lib/ai/coach/keyvalues", () => ({ + parseKeyValuesSentinel: vi.fn(() => ({ + prose: "Your BP is steady.", + keyValues: [], + malformed: false, + malformedEntries: [], + })), +})); +vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ + parseSuggestReminder: vi.fn(() => ({ prose: "Your BP is steady." })), +})); +vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ + parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })), + DEFAULT_REMINDER_SUGGESTION_PREFS: {}, +})); + +const { appendMessage } = await import("@/lib/ai/coach/persistence"); + +vi.mock("@/lib/sse/create-stream", () => ({ + createSseStream: ( + producer: (c: { + signal: { aborted: boolean }; + enqueue: () => void; + }) => void | Promise, + ) => { + void Promise.resolve( + producer({ signal: { aborted: true }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("coach chat — tool-mode routing (F1)", () => { + beforeEach(() => { + vi.clearAllMocks(); + reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 }); + detectRefusal.mockReturnValue({ refuse: false }); + checkRateLimit.mockResolvedValue({ allowed: true }); + assertConsentForChain.mockResolvedValue(undefined); + }); + + it("runs the tool loop (not the snapshot path) when the chain supports tools", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on + ]); + await post(chatReq({ message: "How is my BP?" })); + + expect(runCoachToolLoop).toHaveBeenCalledTimes(1); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // The base context carries the inventory, NOT the snapshot figures. + const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as { + messages: Array<{ content: string }>; + }; + const userContent = loopArgs.messages[0].content; + expect(userContent).toContain("DATA INVENTORY"); + expect(userContent).not.toContain(SNAPSHOT_JSON); + }); + + it("reserves the multi-round budget in tool mode", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + await post(chatReq({ message: "How is my BP?" })); + // maxTokens (1500) × MAX_ROUNDS (2). + expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21"); + }); + + it("persists the tool trace onto provenance", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + await post(chatReq({ message: "How is my BP?" })); + const calls = (appendMessage as ReturnType).mock.calls; + const assistantCall = calls.find( + (c) => (c[0] as { role: string }).role === "assistant", + ); + expect( + (assistantCall?.[0] as { metricSource: { toolCalls?: unknown } }) + .metricSource.toolCalls, + ).toEqual([{ name: "get_metric_series", present: true }]); + }); + + it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + { providerType: "local", instance: { supportsTools: false } }, + ]); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" }, + workingProvider: { providerType: "anthropic" }, + }); + await post(chatReq({ message: "How is my BP?" })); + + expect(runCoachToolLoop).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + // Single-round budget reservation in the fallback path. + expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21"); + // The legacy path ships the snapshot figures in the user turn. + const params = ( + (runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as { + params: { messages: Array<{ content: string }> }; + } + ).params; + expect(params.messages[0].content).toContain(SNAPSHOT_JSON); + }); + + it("fires the inbound gates BEFORE the loop (consent throws ⇒ no loop)", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + assertConsentForChain.mockRejectedValue(new Error("consent.ai.required")); + await expect(post(chatReq({ message: "How is my BP?" }))).rejects.toThrow( + "consent.ai.required", + ); + expect(runCoachToolLoop).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + }); + + it("refuses over-budget before entering the loop", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + reserveBudget.mockResolvedValue({ + allowed: false, + reserved: 3000, + totalAfter: 99999, + }); + await post(chatReq({ message: "How is my BP?" })); + expect(runCoachToolLoop).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index f04d9c7f1..aedc94be7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -73,6 +73,16 @@ import { import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { + COACH_TOOL_DEFS, + buildCoachDataInventory, + renderDataInventory, + buildToolModeAddendum, + runCoachToolLoop, + MAX_ROUNDS, + type CoachToolTrace, +} from "@/lib/ai/coach/tools"; +import type { AiMessage } from "@/lib/ai/types"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; @@ -419,6 +429,36 @@ The user's message answers this clarifying question from their self-context ques React briefly and personally to the answer; do not repeat the question and do not ask it again. ` : ""; + + // ── Provider chain ────────────────────────────────────────── + // v1.20.0 (F1) — resolved BEFORE the prompt is built so we know whether to + // run the tool-based retrieval path or the legacy snapshot-stuffing path. + const chain = await resolveProviderChain(userId); + if (chain.length === 0) { + const legacy = await resolveProvider(userId); + if (legacy.type === "none") { + annotate({ + action: { name: "insights.coach.noProvider" }, + }); + return streamProviderError({ code: "coach.provider.none" }); + } + chain.push({ providerType: "admin-openai", instance: legacy }); + } + + // v1.12.1 — consent gate before any server-managed external egress. When + // the chain could egress via the operator's global key, require an active + // `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth + // chains are the user's own egress and stay ungated. Throws + // ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`. + await assertConsentForChain({ userId, chain, surface: "coach" }); + + // v1.20.0 (F1) — tool mode is on only when EVERY provider in the chain + // supports tool-calling, so whichever hop the fallback runner lands on can + // still serve tools. A chain that includes a no-tools provider (local / + // Ollama) falls back to the legacy snapshot-stuffing path verbatim, exactly + // as before — the snapshot builder stays alive as the no-tools floor. + const toolMode = chain.every((c) => c.instance.supportsTools !== false); + // v1.18.6 (W7) — citation-aware reference-range grounding for the metrics // present in this snapshot. Deterministic + brand-free; framed as general // guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully @@ -447,26 +487,6 @@ ${transcript} Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; - // ── Provider chain ────────────────────────────────────────── - const chain = await resolveProviderChain(userId); - if (chain.length === 0) { - const legacy = await resolveProvider(userId); - if (legacy.type === "none") { - annotate({ - action: { name: "insights.coach.noProvider" }, - }); - return streamProviderError({ code: "coach.provider.none" }); - } - chain.push({ providerType: "admin-openai", instance: legacy }); - } - - // v1.12.1 — consent gate before any server-managed external egress. When - // the chain could egress via the operator's global key, require an active - // `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth - // chains are the user's own egress and stay ungated. Throws - // ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`. - await assertConsentForChain({ userId, chain, surface: "coach" }); - // v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before // the provider call. The reservation increments the day's total by the // per-call ceiling (`maxTokens`) in one upsert and returns the new total; @@ -474,10 +494,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // Over-cap → 429 refusal frame, reservation already refunded. The actual // token count is reconciled against this reservation after the call, // including on empty / sentinel replies (their tokens were still burned). + // + // v1.20.0 (F1) — the tool loop makes up to MAX_ROUNDS provider round-trips, + // so reserve the per-call ceiling × the round count up front and reconcile + // the SUMMED actual tokens afterwards. The atomic reserve/reconcile + // primitives are unchanged; only the reserved amount scales. const reqDateKey = buildDateKey(); const reservation = await reserveBudget( userId, - AI_BUDGETS.coach.maxTokens, + toolMode + ? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS + : AI_BUDGETS.coach.maxTokens, reqDateKey, ); if (!reservation.allowed) { @@ -490,23 +517,62 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; let result: CompletionResult; let workingProviderType: string; + let toolTrace: CoachToolTrace[] = []; + let totalTokensSpent: number; try { - const fallback = await runRawCompletionWithFallback({ - userId, - providers: chain, - // v1.20.0 — the Coach still builds one assembled user turn (the - // transcript-flattening preserves the once-per-conversation snapshot - // trick + grounding exactly), so it ships as a single user message. The - // stable persona/grounding rides `system` and is now cache-eligible. - params: singleUserTurn({ - system: systemPrompt, - user: userPrompt, + if (toolMode) { + // v1.20.0 (F1) — base context: the full system prompt + a tool-mode + // grounding addendum, with the tiny DATA INVENTORY manifest and the + // transcript on the user turn. The figures are NOT in the prompt — the + // model pulls only what it needs via the retrieval tools. The inventory + // build reuses the snapshot we already computed (60s LRU), so the tools + // that fire this turn share its reads. + const inventory = await buildCoachDataInventory(userId, effectiveScope); + const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`; + const messages: AiMessage[] = [ + { + role: "user", + content: `${renderDataInventory(inventory)}${guidedBlock} + +CONVERSATION +${transcript} + +Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`, + }, + ]; + const loop = await runCoachToolLoop({ + userId, + providers: chain, + system: toolSystem, + messages, + tools: COACH_TOOL_DEFS, temperature: AI_BUDGETS.coach.temperature, maxTokens: AI_BUDGETS.coach.maxTokens, - }), - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; + fallbackWindow: effectiveScope?.window, + }); + result = loop.result; + workingProviderType = loop.workingProviderType; + toolTrace = loop.toolTrace; + totalTokensSpent = loop.totalTokens; + } else { + const fallback = await runRawCompletionWithFallback({ + userId, + providers: chain, + // v1.20.0 — the no-tools path still builds one assembled user turn (the + // transcript-flattening preserves the once-per-conversation snapshot + // trick + grounding exactly), so it ships as a single user message. The + // stable persona/grounding rides `system` and is now cache-eligible. + params: singleUserTurn({ + system: systemPrompt, + user: userPrompt, + temperature: AI_BUDGETS.coach.temperature, + maxTokens: AI_BUDGETS.coach.maxTokens, + }), + }); + result = fallback.result; + workingProviderType = fallback.workingProvider.providerType; + totalTokensSpent = result.tokensUsed ?? 0; + } } catch (err) { // The provider chain failed outright — no tokens were billed, so refund // the full reservation before surfacing the error frame. @@ -555,10 +621,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // against the actual count NOW, before any empty/sentinel short-circuit, so // an empty or sentinel-only reply still records its burned cost (the old // post-hoc `recordSpend` ran only on the happy path, undercounting these). + // v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round + // (the loop accumulates them); the no-tools path sums to the single call. await reconcileSpend( userId, reservation.reserved, - result.tokensUsed ?? 0, + totalTokensSpent, reqDateKey, ).catch(() => { // Ledger reconcile is best-effort; a failure leaves the conservative @@ -681,6 +749,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; ? { keyValues: sentinel.keyValues } : {}), ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}), + // v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran + + // whether each found data) so a reload can show "what I looked at" and the + // audit can replay grounding. Metadata only. + ...(toolTrace.length > 0 + ? { + toolCalls: toolTrace.map((t) => ({ + name: t.name, + present: t.present, + })), + } + : {}), }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose @@ -720,7 +799,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // v1.18.9 — persist the per-turn token count + model so the quiet // token footer survives a conversation reload. The live turn paints // from the `done.usage` SSE frame below; reloads read these columns. - tokensUsed: result.tokensUsed ?? null, + // v1.20.0 (F1) — the summed cost across every tool round, so the footer + // reflects the true turn cost on the tool path too. + tokensUsed: totalTokensSpent || null, model: result.model ?? null, }); @@ -733,14 +814,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; action: { name: "insights.coach.replied" }, meta: { provider: workingProviderType, - tokens: result.tokensUsed ?? null, + // v1.20.0 (F1) — summed tokens across every tool round (the loop) or the + // single call (no-tools path), so the dashboards see the true turn cost. + tokens: totalTokensSpent, promptVersion: PROMPT_VERSION, conversationId: workingConversationId, historyTurns: window.length, + // v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how + // many tools it fetched, so the dashboards can correlate the token delta + // with the new path vs the legacy snapshot path. + toolMode, + toolsCalled: toolTrace.length, // v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the - // expensive prefix) vs the cheap pointer. Lets the dashboards confirm - // the once-per-conversation grounding cut the tokens-per-message. - snapshotSent: includeFullSnapshot, + // expensive prefix) vs the cheap pointer. On the tool path the snapshot + // never rides the prompt, so this is the legacy-path signal only. + snapshotSent: !toolMode && includeFullSnapshot, promptChars: userPrompt.length, // v1.7.0 — count of provenance metrics the snapshot surfaced // this turn (a proxy for cluster breadth) so the dashboards can @@ -793,7 +881,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; conversationId: workingConversationId, messageId: assistantMessage.id, usage: { - totalTokens: result.tokensUsed ?? null, + // v1.20.0 (F1) — summed across every tool round. + totalTokens: totalTokensSpent || null, model: result.model ?? null, }, }), diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts new file mode 100644 index 000000000..9b7e96c87 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -0,0 +1,197 @@ +/** + * v1.20.0 (F1) — Coach retrieval tool executor: dispatch, grounding, and the + * gate-preservation contract. The executor must NEVER throw and must return a + * structured `{ present: false }` for an absent domain — the hallucination + * audit asserts the model can always tell "no data" from "data". + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; + +const buildCoachSnapshot = + vi.fn<(userId: string, scope?: unknown) => Promise>(); + +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: (userId: string, scope?: unknown) => + buildCoachSnapshot(userId, scope), +})); + +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; + +function snapshot( + sections: Record, + referenceGrounding: string | null = null, +): CoachSnapshotResult { + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { windows: [], metrics: [] }, + referenceGrounding, + }; +} + +describe("executeCoachTool", () => { + beforeEach(() => { + buildCoachSnapshot.mockReset(); + }); + + it("returns the matching section for get_metric_series when present", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot( + { bloodPressure: { aggregate: { avgSys30: 128 } } }, + "REFERENCE GROUNDING\nBP systolic 128 …", + ), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp" }), + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ + metric: "bp", + section: { aggregate: { avgSys30: 128 } }, + }); + expect(result.grounding).toContain("REFERENCE GROUNDING"); + }); + + it("returns { present: false } (never throws) when the section is absent", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "hrv" }), + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data"); + expect(result.data).toBeUndefined(); + }); + + it("points get_metric_series(glucose) at the dedicated tool", async () => { + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "glucose" }), + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("use_get_glucose_panel"); + // Must not even read the snapshot for a wrong-tool call. + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + }); + + it("rejects invalid arguments with a grounded miss, not a throw", async () => { + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "not_a_metric" }), + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("invalid_arguments"); + }); + + it("rejects malformed JSON arguments with a grounded miss", async () => { + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: "{not json", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("invalid_arguments"); + }); + + it("rejects an unknown tool name without throwing", async () => { + const result = await executeCoachTool({ + userId: "u1", + name: "drop_table_users", + rawArguments: "{}", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("unknown_tool"); + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + }); + + it("degrades a snapshot-builder throw to { present: false }", async () => { + buildCoachSnapshot.mockRejectedValue(new Error("db down")); + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: "{}", + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("retrieval_failed"); + }); + + it("combines sleep nights + rhythm sections", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ + sleep: { timeline: { recent: [] } }, + sleepRhythm: { sleepDebt: 120, chronotype: "intermediate" }, + }), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_sleep", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ + nights: { timeline: { recent: [] } }, + rhythm: { sleepDebt: 120 }, + }); + }); + + it("surfaces glp1 from weeklyContext on get_medication_compliance", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ + compliance: { rate: 0.92 }, + weeklyContext: { glp1: { drug: "—", dose: "—" } }, + }), + ); + const result = await executeCoachTool({ + userId: "u1", + name: "get_medication_compliance", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + expect(result.data).toMatchObject({ + compliance: { rate: 0.92 }, + glp1: { drug: "—" }, + }); + }); + + it("filters labs by analyte and reports analyte_not_found", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ + labs: { recent: [{ name: "LDL", value: "120" }] }, + }), + ); + const hit = await executeCoachTool({ + userId: "u1", + name: "get_labs", + rawArguments: JSON.stringify({ analyte: "ldl" }), + }); + expect(hit.present).toBe(true); + expect(hit.data).toMatchObject({ recent: [{ name: "LDL" }] }); + + const miss = await executeCoachTool({ + userId: "u1", + name: "get_labs", + rawArguments: JSON.stringify({ analyte: "glucose" }), + }); + expect(miss.present).toBe(false); + expect(miss.reason).toBe("analyte_not_found"); + }); + + it("never passes userId as a tool argument (read from session only)", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({})); + // A crafted argument trying to smuggle a userId must be rejected by the + // strict schema, never forwarded to the read. + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "bp", userId: "victim" }), + }); + expect(result.present).toBe(false); + expect(result.reason).toBe("invalid_arguments"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts new file mode 100644 index 000000000..8387dfd08 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -0,0 +1,100 @@ +/** + * v1.20.0 (F1) — the DATA INVENTORY manifest: it must mark present/absent per + * domain from the snapshot's sections, carry restMode + cycleEnabled, and + * render a compact, brand-free, grounding-honest block. The model reads this to + * know what is fetchable so it never invents a metric. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; + +const buildCoachSnapshot = vi.fn<() => Promise>(); +const isCycleAvailableForUser = vi.fn<() => Promise>(); + +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: () => buildCoachSnapshot(), +})); +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: () => isCycleAvailableForUser(), +})); + +import { + buildCoachDataInventory, + renderDataInventory, +} from "@/lib/ai/coach/tools/inventory"; + +function snapshot( + sections: Record, + counts?: Record, +): CoachSnapshotResult { + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { + windows: [], + metrics: [], + ...(counts ? { counts: counts as never } : {}), + }, + referenceGrounding: null, + }; +} + +describe("buildCoachDataInventory", () => { + beforeEach(() => { + buildCoachSnapshot.mockReset(); + isCycleAvailableForUser.mockReset(); + isCycleAvailableForUser.mockResolvedValue(false); + }); + + it("marks present domains and carries counts + window", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot( + { + bloodPressure: { aggregate: {} }, + glucose: { byContext: {} }, + scope: { window: "last7days" }, + }, + { bp: 42, glucose: 100 }, + ), + ); + const inv = await buildCoachDataInventory("u1", { window: "last7days" }); + expect(inv.window).toBe("last7days"); + const bp = inv.entries.find((e) => e.metric === "bp"); + expect(bp).toMatchObject({ present: true, count: 42 }); + const glucose = inv.entries.find((e) => e.tool === "get_glucose_panel"); + expect(glucose).toMatchObject({ present: true, count: 100 }); + const sleep = inv.entries.find((e) => e.tool === "get_sleep"); + expect(sleep?.present).toBe(false); + }); + + it("reports restMode + cycleEnabled", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ illness: { restMode: true } }), + ); + isCycleAvailableForUser.mockResolvedValue(true); + const inv = await buildCoachDataInventory("u1", undefined); + expect(inv.restMode).toBe(true); + expect(inv.cycleEnabled).toBe(true); + }); +}); + +describe("renderDataInventory", () => { + it("renders a grounding-honest, brand-free block", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot( + { bloodPressure: {}, scope: { window: "last30days" } }, + { bp: 5 }, + ), + ); + isCycleAvailableForUser.mockResolvedValue(false); + const inv = await buildCoachDataInventory("u1", undefined); + const text = renderDataInventory(inv); + expect(text).toContain("DATA INVENTORY"); + expect(text).toContain("Never cite a figure you did not fetch"); + expect(text).toContain("blood pressure: present → get_metric_series"); + // brand-free: no vendor names. + expect(text.toLowerCase()).not.toContain("withings"); + expect(text.toLowerCase()).not.toContain("oura"); + expect(text.toLowerCase()).not.toContain("whoop"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts new file mode 100644 index 000000000..7e70c3499 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -0,0 +1,198 @@ +/** + * v1.20.0 (F1) — bounded Coach retrieval loop: tool dispatch round-trip, the + * max-rounds cap with a forced-final answer, parallel tool execution, and the + * summed-token accounting the budget reconcile depends on. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +const executeCoachTool = vi.fn<() => Promise>(); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: () => executeCoachTool(), +})); + +// The loop calls the real fallback runner; stub it to a deterministic +// per-round script so we exercise the loop control flow, not the chain. +const runRawCompletionWithFallback = vi.fn(); +vi.mock("@/lib/ai/provider-runner", () => ({ + runRawCompletionWithFallback: (args: unknown) => + runRawCompletionWithFallback(args), +})); + +import { runCoachToolLoop, HARD_CAP } from "@/lib/ai/coach/tools/loop"; +import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; + +function completion(opts: { + content: string; + tokensUsed?: number; + toolCalls?: Array<{ id: string; name: string; arguments: string }>; + finishReason?: "stop" | "tool_calls" | "length"; +}) { + return { + result: { + content: opts.content, + tokensUsed: opts.tokensUsed ?? 10, + model: "mock", + providerType: "anthropic" as const, + ...(opts.toolCalls ? { toolCalls: opts.toolCalls } : {}), + finishReason: opts.finishReason, + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + }; +} + +const baseArgs = { + userId: "u1", + providers: [], + system: "sys", + messages: [{ role: "user" as const, content: "how is my bp?" }], + tools: COACH_TOOL_DEFS, +}; + +describe("runCoachToolLoop", () => { + beforeEach(() => { + executeCoachTool.mockReset(); + runRawCompletionWithFallback.mockReset(); + }); + + it("runs one tool round then streams the final prose (happy path)", async () => { + executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + tokensUsed: 30, + finishReason: "tool_calls", + toolCalls: [ + { + id: "c1", + name: "get_metric_series", + arguments: '{"metric":"bp"}', + }, + ], + }), + ) + .mockResolvedValueOnce( + completion({ content: "Your BP looks steady.", tokensUsed: 20 }), + ); + + const out = await runCoachToolLoop(baseArgs); + + expect(out.rounds).toBe(2); + expect(out.result.content).toBe("Your BP looks steady."); + expect(out.totalTokens).toBe(50); // summed across both rounds + expect(out.toolTrace).toEqual([ + { name: "get_metric_series", present: true }, + ]); + // The second call must forbid tools? No — round 2 still offers them + // (round <= MAX_ROUNDS). It simply chose to answer. + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2); + }); + + it("answers immediately when the model emits no tool calls", async () => { + runRawCompletionWithFallback.mockResolvedValueOnce( + completion({ content: "Here's what I can help with.", tokensUsed: 12 }), + ); + const out = await runCoachToolLoop(baseArgs); + expect(out.rounds).toBe(1); + expect(out.toolTrace).toHaveLength(0); + expect(executeCoachTool).not.toHaveBeenCalled(); + }); + + it("executes parallel tool calls in one round", async () => { + executeCoachTool.mockResolvedValue({ present: true }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [ + { + id: "a", + name: "get_metric_series", + arguments: '{"metric":"bp"}', + }, + { id: "b", name: "get_sleep", arguments: "{}" }, + ], + }), + ) + .mockResolvedValueOnce(completion({ content: "Combined view." })); + + const out = await runCoachToolLoop(baseArgs); + expect(executeCoachTool).toHaveBeenCalledTimes(2); + expect(out.toolTrace).toHaveLength(2); + expect(out.result.content).toBe("Combined view."); + }); + + it("forces a final answer at the hard cap (no infinite loop)", async () => { + executeCoachTool.mockResolvedValue({ present: false }); + // The model keeps asking for tools every round. The loop must still + // terminate: the last allowed round offers no tools (toolChoice none) so + // the model is forced to produce prose. + runRawCompletionWithFallback.mockImplementation( + (args: { params: { toolChoice?: string } }) => { + if (args.params.toolChoice === "none") { + return Promise.resolve( + completion({ content: "Final forced answer." }), + ); + } + return Promise.resolve( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [ + { + id: "x", + name: "get_metric_series", + arguments: '{"metric":"bp"}', + }, + ], + }), + ); + }, + ); + + const out = await runCoachToolLoop(baseArgs); + expect(out.rounds).toBe(HARD_CAP); + expect(out.result.content).toBe("Final forced answer."); + // The final round must have been called with toolChoice "none". + const lastCall = + runRawCompletionWithFallback.mock.calls[ + runRawCompletionWithFallback.mock.calls.length - 1 + ][0]; + expect(lastCall.params.toolChoice).toBe("none"); + }); + + it("appends assistant(toolCalls) + tool turns to the message array", async () => { + executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } }); + let secondCallMessages: unknown; + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [{ id: "c1", name: "get_sleep", arguments: "{}" }], + }), + ) + .mockImplementationOnce((args: { params: { messages: unknown } }) => { + secondCallMessages = args.params.messages; + return Promise.resolve(completion({ content: "done" })); + }); + + await runCoachToolLoop(baseArgs); + + const msgs = secondCallMessages as Array<{ + role: string; + toolCallId?: string; + toolCalls?: unknown[]; + }>; + // user, assistant(toolCalls), tool + expect(msgs).toHaveLength(3); + expect(msgs[1].role).toBe("assistant"); + expect(msgs[1].toolCalls).toHaveLength(1); + expect(msgs[2].role).toBe("tool"); + expect(msgs[2].toolCallId).toBe("c1"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-source-keys.test.ts b/src/lib/ai/coach/__tests__/tools-source-keys.test.ts new file mode 100644 index 000000000..2cead9979 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-source-keys.test.ts @@ -0,0 +1,31 @@ +/** + * v1.20.0 (F1) — every CoachScopeSource must either resolve to a snapshot + * section key (so get_metric_series can fetch it) or be one of the + * dedicated-tool sources. A new source added to the scope enum without a key + * here would otherwise fall through to a fabricated "no data" answer for a + * metric the user actually has — this guard fails the build first. + */ +import { describe, expect, it } from "vitest"; + +import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; +import { + COACH_SOURCE_SNAPSHOT_KEY, + METRIC_SERIES_EXCLUDED_SOURCES, +} from "@/lib/ai/coach/tools/source-keys"; + +describe("COACH_SOURCE_SNAPSHOT_KEY", () => { + it("covers every CoachScopeSource (key map or dedicated tool)", () => { + const uncovered = coachScopeSourceSchema.options.filter( + (source) => + COACH_SOURCE_SNAPSHOT_KEY[source] === undefined && + !METRIC_SERIES_EXCLUDED_SOURCES.has(source), + ); + expect(uncovered).toEqual([]); + }); + + it("never maps an excluded (dedicated-tool) source to a series key", () => { + for (const source of METRIC_SERIES_EXCLUDED_SOURCES) { + expect(COACH_SOURCE_SNAPSHOT_KEY[source]).toBeUndefined(); + } + }); +}); diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts new file mode 100644 index 000000000..4fa65c033 --- /dev/null +++ b/src/lib/ai/coach/tools/definitions.ts @@ -0,0 +1,208 @@ +/** + * v1.20.0 (F1) — Coach retrieval tool catalogue. + * + * Replaces snapshot-stuffing with on-demand retrieval: the model is handed a + * tiny DATA INVENTORY in the base context and pulls only the domains it needs + * via these tools. Each tool is a THIN, READ-ONLY wrapper over the existing + * server-authoritative snapshot builder (`buildCoachSnapshot`) — same numbers, + * same module/cycle gates, same I/O. No tool ever accepts `userId` (it is + * narrowed from the session in the executor), and every tool is read-only, so + * there is no new mutation or egress surface. + * + * Six tools ship in v1.20.0 (the research's recommended slice): + * 1. get_metric_series — BP / weight / pulse + the ~38 additive series + * 2. get_glucose_panel — per-context daily means + the 30-day clinical panel + * 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype) + * 4. get_medication_compliance — dose-weighted compliance + GLP-1 + * 5. get_labs — latest reading per biomarker (12 months) + * 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites + * + * cycle / correlation / workouts are deferred to a later release. + */ +import { z } from "zod/v4"; + +import type { AiToolDef } from "@/lib/ai/types"; +import { + coachScopeSourceSchema, + coachScopeWindowSchema, +} from "@/lib/ai/coach/types"; + +/** The closed set of tool names F1 ships. */ +export const COACH_TOOL_NAMES = [ + "get_metric_series", + "get_glucose_panel", + "get_sleep", + "get_medication_compliance", + "get_labs", + "get_illness_recovery", +] as const; + +export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; + +export function isCoachToolName(name: string): name is CoachToolName { + return (COACH_TOOL_NAMES as readonly string[]).includes(name); +} + +// ── Per-tool argument schemas ──────────────────────────────────────── +// Closed enums + optional windows only — no free-text, no host, no id. The +// executor `safeParse`s the model's raw JSON arguments against these before it +// touches the snapshot builder, so a malformed / adversarial argument blob can +// never widen scope or reach an un-validated read. + +export const getMetricSeriesArgsSchema = z + .object({ + metric: coachScopeSourceSchema, + window: coachScopeWindowSchema.optional(), + }) + .strict(); + +export const getGlucosePanelArgsSchema = z + .object({ + window: coachScopeWindowSchema.optional(), + }) + .strict(); + +export const getSleepArgsSchema = z + .object({ + window: coachScopeWindowSchema.optional(), + }) + .strict(); + +export const getMedicationComplianceArgsSchema = z + .object({ + window: coachScopeWindowSchema.optional(), + }) + .strict(); + +export const getLabsArgsSchema = z + .object({ + /** Optional single-analyte filter (free-text biomarker name). */ + analyte: z.string().min(1).max(80).optional(), + }) + .strict(); + +export const getIllnessRecoveryArgsSchema = z.object({}).strict(); + +/** + * JSON-Schema parameter shapes handed to the provider. Kept hand-written + * (rather than generated from Zod) so the wire description the model reads is + * compact and stable — the byte-stable tool block keeps the cached prefix + * intact across turns. + */ +const WINDOW_ENUM = [ + "last7days", + "last30days", + "last90days", + "lastYear", + "allTime", +]; + +/** + * The tool definitions offered to the model. Descriptions are deliberately + * terse and brand-free; they tell the model WHEN to reach for each tool and + * that an absent domain returns `{ present: false }` rather than an error. + */ +export const COACH_TOOL_DEFS: AiToolDef[] = [ + { + name: "get_metric_series", + description: + "Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } when the user has no data for that metric.", + parameters: { + type: "object", + additionalProperties: false, + required: ["metric"], + properties: { + metric: { + type: "string", + enum: coachScopeSourceSchema.options, + description: "The metric to fetch.", + }, + window: { + type: "string", + enum: WINDOW_ENUM, + description: "Analysis window. Defaults to the user's scope window.", + }, + }, + }, + }, + { + name: "get_glucose_panel", + description: + "Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } when the user logs no glucose.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: { + window: { + type: "string", + enum: WINDOW_ENUM, + description: + "Window for the per-context means. The clinical panel is always the fixed trailing 30 days.", + }, + }, + }, + }, + { + name: "get_sleep", + description: + "Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } when no sleep is tracked.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: { + window: { + type: "string", + enum: WINDOW_ENUM, + description: "Analysis window. Defaults to the user's scope window.", + }, + }, + }, + }, + { + name: "get_medication_compliance", + description: + "Fetch the user's medication compliance: the dose-weighted adherence rate plus a recent timeline, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: { + window: { + type: "string", + enum: WINDOW_ENUM, + description: "Analysis window. Defaults to the user's scope window.", + }, + }, + }, + }, + { + name: "get_labs", + description: + "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } when no labs are on file.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: { + analyte: { + type: "string", + description: + "Optional single biomarker name to filter to (e.g. 'LDL').", + }, + }, + }, + }, + { + name: "get_illness_recovery", + description: + "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses, and the recovery / strain composites. Returns { present: false } when there is nothing to report.", + parameters: { + type: "object", + additionalProperties: false, + required: [], + properties: {}, + }, + }, +]; diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts new file mode 100644 index 000000000..75026d815 --- /dev/null +++ b/src/lib/ai/coach/tools/executor.ts @@ -0,0 +1,359 @@ +/** + * v1.20.0 (F1) — Coach retrieval tool executor. + * + * Dispatches a single tool call from the model: validates the raw JSON + * arguments against the tool's Zod schema, runs the existing + * server-authoritative snapshot builder scoped to just the requested domain, + * and slices the matching section(s) out of the structured result. + * + * GROUNDING CONTRACTS (the hallucination audit hammers these): + * - Every tool returns a structured `{ present: boolean }`. When the domain + * carries no data — or the user opted out of the owning module — the result + * is `{ present: false, reason }`. It NEVER throws and NEVER returns an + * ambiguous `[]`, so the model can always tell "no data" from "data". + * - Module / cycle gates are enforced AT THE BUILDER (`buildCoachSnapshot` + * applies `MODULE_EXCLUDED_SOURCES` + the cycle gate before any row is + * read), so an opted-out domain is structurally unfetchable here — the + * section simply never appears and the executor reports `present: false`. + * - `userId` is the session-narrowed id passed by the route, never a tool + * argument. Tools are read-only (snapshot reads only), so there is no + * mutation or egress surface. + * + * The builder result is memoised by the 60s snapshot LRU keyed on + * `(userId, window, sources)`, so several tool calls in one turn (and the + * base-context inventory build) share the underlying reads. + */ +import { 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 { + getMetricSeriesArgsSchema, + getGlucosePanelArgsSchema, + getSleepArgsSchema, + getMedicationComplianceArgsSchema, + getLabsArgsSchema, + getIllnessRecoveryArgsSchema, + isCoachToolName, + type CoachToolName, +} from "./definitions"; +import { + COACH_SOURCE_SNAPSHOT_KEY, + METRIC_SERIES_EXCLUDED_SOURCES, +} from "./source-keys"; + +/** 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; +} + +/** What the route persists onto provenance: which tools ran, did data exist. */ +export interface CoachToolTrace { + name: string; + present: boolean; +} + +function pickSection( + sections: Record, + key: string, +): unknown | undefined { + const value = sections[key]; + if (value === undefined || value === null) return undefined; + return value; +} + +/** + * 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. + */ +function scopeFor( + sources: CoachScope["sources"], + window: CoachScopeWindow | undefined, + fallbackWindow: CoachScopeWindow | undefined, +): CoachScope { + return { + sources, + window: window ?? fallbackWindow, + }; +} + +/** + * Execute one tool call. `rawArguments` is the model's raw JSON-string + * arguments (parsed + validated here, never trusted blindly). Returns a + * grounded `CoachToolResult` — a validation failure or unknown tool resolves + * to `{ present: false }`, never a throw, so a single bad call can't break the + * turn. + */ +export async function executeCoachTool(args: { + userId: string; + name: string; + rawArguments: string; + /** The conversation's effective window, used when a call omits `window`. */ + fallbackWindow?: CoachScopeWindow; +}): Promise { + const { userId, name, rawArguments, fallbackWindow } = args; + + if (!isCoachToolName(name)) { + annotate({ + action: { name: "coach.tool.unknown" }, + meta: { tool: name.slice(0, 48) }, + }); + return { present: false, reason: "unknown_tool" }; + } + + let parsedArgs: unknown; + try { + parsedArgs = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments); + } catch { + annotate({ + action: { name: "coach.tool.bad_arguments" }, + meta: { tool: name, reason: "invalid_json" }, + }); + return { present: false, reason: "invalid_arguments" }; + } + + try { + const result = await dispatch(name, userId, parsedArgs, fallbackWindow); + annotate({ + action: { name: "coach.tool.executed" }, + meta: { tool: name, present: result.present }, + }); + return result; + } catch (err) { + // A read failure must degrade to a grounded "no data" rather than break + // the loop — the model then says it could not retrieve the metric. + annotate({ + action: { name: "coach.tool.error" }, + meta: { + tool: name, + reason: err instanceof Error ? err.name : "unknown", + }, + }); + return { present: false, reason: "retrieval_failed" }; + } +} + +async function dispatch( + name: CoachToolName, + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { + switch (name) { + case "get_metric_series": + return getMetricSeries(userId, rawArgs, fallbackWindow); + case "get_glucose_panel": + return getGlucosePanel(userId, rawArgs, fallbackWindow); + case "get_sleep": + return getSleep(userId, rawArgs, fallbackWindow); + case "get_medication_compliance": + return getMedicationCompliance(userId, rawArgs, fallbackWindow); + case "get_labs": + return getLabs(userId, rawArgs); + case "get_illness_recovery": + return getIllnessRecovery(userId); + } +} + +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" }; +} + +async function getMetricSeries( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, +): 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" }; + } + + const snapshot = await buildCoachSnapshot( + userId, + scopeFor([metric], window, fallbackWindow), + ); + const section = pickSection(snapshot.sections, sectionKey); + if (section === undefined) { + return { present: false, reason: "no_data" }; + } + return { + present: true, + data: { metric, section }, + grounding: snapshot.referenceGrounding ?? undefined, + }; +} + +async function getGlucosePanel( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | 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), + ); + const section = pickSection(snapshot.sections, "glucose"); + if (section === undefined) return { present: false, reason: "no_data" }; + return { + present: true, + data: section, + grounding: snapshot.referenceGrounding ?? undefined, + }; +} + +async function getSleep( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | 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), + ); + const nights = pickSection(snapshot.sections, "sleep"); + const rhythm = pickSection(snapshot.sections, "sleepRhythm"); + if (nights === undefined && rhythm === undefined) { + return { present: false, reason: "no_data" }; + } + return { + present: true, + data: { + ...(nights !== undefined ? { nights } : {}), + ...(rhythm !== undefined ? { rhythm } : {}), + }, + grounding: snapshot.referenceGrounding ?? undefined, + }; +} + +async function getMedicationCompliance( + userId: string, + rawArgs: unknown, + fallbackWindow: CoachScopeWindow | undefined, +): Promise { + const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs); + if (!parsed.success) { + return badArgs("get_medication_compliance", parsed.error); + } + const snapshot = await buildCoachSnapshot( + userId, + scopeFor(["compliance"], parsed.data.window, fallbackWindow), + ); + 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 { present: false, reason: "no_data" }; + } + return { + present: true, + data: { + ...(compliance !== undefined ? { compliance } : {}), + ...(glp1 !== undefined ? { glp1 } : {}), + }, + }; +} + +async function getLabs( + userId: string, + rawArgs: unknown, +): 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. + const snapshot = await buildCoachSnapshot(userId, { sources: [] }); + const labs = pickSection(snapshot.sections, "labs") as + | { recent?: Array<{ name?: string; analyte?: string }> } + | undefined; + if (labs === undefined) return { present: false, reason: "no_data" }; + + const analyte = parsed.data.analyte?.trim().toLowerCase(); + if (analyte && Array.isArray(labs.recent)) { + const filtered = labs.recent.filter((entry) => { + const haystack = + `${entry.name ?? ""} ${entry.analyte ?? ""}`.toLowerCase(); + return haystack.includes(analyte); + }); + if (filtered.length === 0) { + return { present: false, reason: "analyte_not_found" }; + } + return { present: true, data: { recent: filtered } }; + } + return { present: true, data: labs }; +} + +async function getIllnessRecovery(userId: string): 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. + const snapshot = await buildCoachSnapshot(userId, { + sources: ["hrv", "resting_hr", "vo2_max"], + }); + const illness = pickSection(snapshot.sections, "illness"); + const derived = pickSection(snapshot.sections, "derived"); + const dayStrain = pickSection(snapshot.sections, "dayStrain"); + const trajectory = pickSection(snapshot.sections, "trajectory"); + if ( + illness === undefined && + derived === undefined && + dayStrain === undefined && + trajectory === undefined + ) { + return { present: false, reason: "no_data" }; + } + return { + present: true, + data: { + ...(illness !== undefined ? { illness } : {}), + ...(derived !== undefined ? { derived } : {}), + ...(dayStrain !== undefined ? { dayStrain } : {}), + ...(trajectory !== undefined ? { trajectory } : {}), + }, + }; +} diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts new file mode 100644 index 000000000..9f4858a2e --- /dev/null +++ b/src/lib/ai/coach/tools/index.ts @@ -0,0 +1,32 @@ +/** + * v1.20.0 (F1) — Coach retrieval-tool barrel. + * + * The on-demand retrieval slice that replaces snapshot-stuffing: a tiny DATA + * INVENTORY in the base context, six read-only tools the model calls to fetch + * figures, and a bounded loop that executes them. The legacy snapshot path + * stays alive as the no-tools fallback (per-provider `supportsTools`). + */ +export { + COACH_TOOL_DEFS, + COACH_TOOL_NAMES, + isCoachToolName, + type CoachToolName, +} from "./definitions"; +export { + executeCoachTool, + type CoachToolResult, + type CoachToolTrace, +} from "./executor"; +export { + buildCoachDataInventory, + renderDataInventory, + type CoachDataInventory, + type InventoryEntry, +} from "./inventory"; +export { buildToolModeAddendum } from "./system-addendum"; +export { + runCoachToolLoop, + MAX_ROUNDS, + HARD_CAP, + type CoachToolLoopResult, +} from "./loop"; diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts new file mode 100644 index 000000000..7e4e93bb8 --- /dev/null +++ b/src/lib/ai/coach/tools/inventory.ts @@ -0,0 +1,203 @@ +/** + * v1.20.0 (F1) — Coach DATA INVENTORY manifest. + * + * The tiny, always-sent manifest that replaces snapshot-stuffing in the base + * context. It tells the model WHAT data exists (so it never invents a metric) + * and WHICH tool to call to fetch it — without shipping the figures themselves. + * + * Built deterministically from a single `buildCoachSnapshot` over the user's + * effective scope: the snapshot already resolves the module / cycle gates and + * computes presence + counts, so a domain the user opted out of is structurally + * absent from the manifest (it will report `present: false`). The build is + * memoised by the 60s snapshot LRU, so the tools that fire on the same turn + * re-use these reads rather than paying for them twice. + * + * Cost: the manifest is ~0.3–0.5k tokens vs the ~6–15k-token snapshot JSON it + * replaces — the figures move out of the prompt and into on-demand tool results. + */ +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import type { CoachProvenanceMetric, CoachScope } from "@/lib/ai/coach/types"; + +/** One row of the inventory: a domain + whether the user has data for it. */ +export interface InventoryEntry { + /** The tool that fetches this domain. */ + tool: string; + /** Stable domain label the model reads (e.g. "blood pressure", "glucose"). */ + domain: string; + present: boolean; + /** Sample count, when the snapshot reported one. */ + count?: number; + /** For get_metric_series rows: the `metric` argument to pass. */ + metric?: string; +} + +export interface CoachDataInventory { + entries: InventoryEntry[]; + /** Illness rest-mode flag, so the safety framing is right before any call. */ + restMode: boolean; + /** Whether cycle tracking is available (the get_cycle tool is deferred). */ + cycleEnabled: boolean; + /** The window the inventory was built against. */ + window: string; +} + +/** + * Map a snapshot section key → an inventory row. The per-metric series tool + * carries the `metric` argument; the dedicated-tool domains name their tool. + */ +const METRIC_SERIES_DOMAINS: Array<{ + sectionKey: string; + metric: string; + domain: string; + provenance: CoachProvenanceMetric; +}> = [ + { + sectionKey: "bloodPressure", + metric: "bp", + domain: "blood pressure", + provenance: "bp", + }, + { + sectionKey: "weight", + metric: "weight", + domain: "weight", + provenance: "weight", + }, + { + sectionKey: "pulse", + metric: "pulse", + domain: "pulse", + provenance: "pulse", + }, + { sectionKey: "mood", metric: "mood", domain: "mood", provenance: "mood" }, + { + sectionKey: "heartRateVariability", + metric: "hrv", + domain: "heart-rate variability", + provenance: "hrv", + }, + { + sectionKey: "restingHeartRate", + metric: "resting_hr", + domain: "resting heart rate", + provenance: "resting_hr", + }, + { + sectionKey: "steps", + metric: "steps", + domain: "steps", + provenance: "steps", + }, + { + sectionKey: "vo2Max", + metric: "vo2_max", + domain: "VO2 max", + provenance: "vo2_max", + }, +]; + +export async function buildCoachDataInventory( + userId: string, + scope: CoachScope | undefined, +): Promise { + const [snapshot, cycleEnabled] = await Promise.all([ + buildCoachSnapshot(userId, scope), + isCycleAvailableForUser(userId), + ]); + const sections = snapshot.sections; + const counts = snapshot.provenance.counts ?? {}; + const has = (key: string): boolean => + sections[key] !== undefined && sections[key] !== null; + + const entries: InventoryEntry[] = []; + + // get_metric_series domains — one row per metric the snapshot surfaced. + for (const m of METRIC_SERIES_DOMAINS) { + const present = has(m.sectionKey); + entries.push({ + tool: "get_metric_series", + metric: m.metric, + domain: m.domain, + present, + ...(present && typeof counts[m.provenance] === "number" + ? { count: counts[m.provenance] } + : {}), + }); + } + + // Dedicated-tool domains. + entries.push({ + tool: "get_glucose_panel", + domain: "glucose", + present: has("glucose"), + ...(typeof counts.glucose === "number" ? { count: counts.glucose } : {}), + }); + entries.push({ + tool: "get_sleep", + domain: "sleep", + present: has("sleep") || has("sleepRhythm"), + ...(typeof counts.sleep === "number" ? { count: counts.sleep } : {}), + }); + entries.push({ + tool: "get_medication_compliance", + domain: "medication compliance", + present: has("compliance") || has("weeklyContext"), + ...(typeof counts.compliance === "number" + ? { count: counts.compliance } + : {}), + }); + entries.push({ + tool: "get_labs", + domain: "lab results", + present: has("labs"), + }); + entries.push({ + tool: "get_illness_recovery", + domain: "illness & recovery", + present: + has("illness") || has("derived") || has("dayStrain") || has("trajectory"), + }); + + // restMode rides the illness section. + const illness = sections.illness as { restMode?: boolean } | undefined; + const restMode = illness?.restMode === true; + + const scopeBlock = sections.scope as { window?: string } | undefined; + + return { + entries, + restMode, + cycleEnabled, + window: scopeBlock?.window ?? scope?.window ?? "last30days", + }; +} + +/** + * Render the inventory as the compact text block that rides the base context. + * Brand-free, deterministic, and stable across turns so the prompt-cache prefix + * stays intact. Each line tells the model the domain, whether data exists, and + * the tool + argument to call. + */ +export function renderDataInventory(inventory: CoachDataInventory): string { + const lines: string[] = []; + lines.push("DATA INVENTORY"); + lines.push( + "This lists which of the user's data exists and how to fetch it. Call the named tool ONLY for a domain marked present. Never cite a figure you did not fetch with a tool this turn; when a tool returns present:false, say plainly you have no data for it and pivot.", + ); + lines.push(`Window: ${inventory.window}.`); + if (inventory.restMode) { + lines.push( + "The user is currently in REST MODE (recovering from illness) — frame any activity guidance gently.", + ); + } + for (const e of inventory.entries) { + const arg = e.metric ? ` (metric:"${e.metric}")` : ""; + lines.push( + `- ${e.domain}: ${e.present ? "present" : "absent"} → ${e.tool}${arg}${ + e.present && typeof e.count === "number" ? ` [~${e.count} samples]` : "" + }`, + ); + } + return lines.join("\n"); +} diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts new file mode 100644 index 000000000..216c29b96 --- /dev/null +++ b/src/lib/ai/coach/tools/loop.ts @@ -0,0 +1,156 @@ +/** + * v1.20.0 (F1) — bounded Coach retrieval loop. + * + * Runs the agentic call → tool-result → answer cycle on top of the existing + * `runRawCompletionWithFallback` chain. Each round is a NON-streaming + * completion (the chain returns a whole body); the route fake-tokenises the + * 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. + * - Tool calls within a round run in parallel (`Promise.all`). + * + * Token accounting: the caller reserves `maxTokens * MAX_ROUNDS` up front and + * reconciles the SUMMED `tokensUsed` this function returns. The atomic + * reserve/reconcile primitives are unchanged. + * + * Provider fallback: when the working provider does not support tools + * (`supportsTools === false`) or returns no tool calls, the loop degrades to a + * single completion — the caller falls back to the snapshot path BEFORE + * entering this loop, so this is the safety floor, not the primary route. + */ +import { annotate } from "@/lib/logging/context"; +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 { executeCoachTool, type CoachToolTrace } from "./executor"; + +export const MAX_ROUNDS = 2; +/** Absolute ceiling; the final allowed round forces prose (toolChoice none). */ +export const HARD_CAP = 3; + +export interface CoachToolLoopResult { + /** The final completion (prose). */ + result: CompletionResult; + /** The working provider's type tag. */ + workingProviderType: string; + /** Summed tokens across every round (for budget reconcile). */ + totalTokens: number; + /** Number of model round-trips made. */ + rounds: number; + /** Which tools ran + whether each found data (persisted onto provenance). */ + toolTrace: CoachToolTrace[]; +} + +export async function runCoachToolLoop(args: { + userId: string; + providers: ProviderChainResolved[]; + system: string; + /** The conversation messages (history + the new user turn). */ + messages: AiMessage[]; + tools: AiToolDef[]; + temperature?: number; + maxTokens?: number; + fallbackWindow?: CoachScopeWindow; + ledger?: ProviderHealthLedger; +}): Promise { + const { + userId, + providers, + system, + tools, + temperature, + maxTokens, + fallbackWindow, + ledger, + } = args; + + const messages: AiMessage[] = [...args.messages]; + let totalTokens = 0; + let rounds = 0; + let workingProviderType = ""; + const toolTrace: CoachToolTrace[] = []; + + // Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool + // calls so the model must answer. + for (let round = 1; round <= HARD_CAP; round += 1) { + rounds = round; + const isForcedFinal = round >= HARD_CAP; + const offerTools = round <= MAX_ROUNDS && !isForcedFinal; + + const fallback = await runRawCompletionWithFallback({ + userId, + providers, + ledger, + params: { + system, + messages, + temperature, + maxTokens, + ...(offerTools + ? { tools, toolChoice: "auto" as const } + : { toolChoice: "none" as const }), + }, + }); + const result = fallback.result; + workingProviderType = fallback.workingProvider.providerType; + totalTokens += result.tokensUsed ?? 0; + + const calls = result.toolCalls ?? []; + const wantsTools = + offerTools && result.finishReason === "tool_calls" && calls.length > 0; + + if (!wantsTools) { + // The model produced prose (or a no-tools provider returned empty + // toolCalls) — we are done. + annotate({ + action: { name: "coach.tool.rounds" }, + meta: { + rounds, + tools: toolTrace.length, + forcedFinal: isForcedFinal, + }, + }); + return { result, workingProviderType, totalTokens, rounds, toolTrace }; + } + + // Append the assistant turn that requested the tools, then execute them in + // parallel and append one tool-result turn per call. + messages.push({ + role: "assistant", + content: result.content ?? "", + toolCalls: calls, + }); + + const results = await Promise.all( + calls.map(async (call) => { + const toolResult = await executeCoachTool({ + userId, + name: call.name, + rawArguments: call.arguments, + fallbackWindow, + }); + toolTrace.push({ name: call.name, present: toolResult.present }); + return { call, toolResult }; + }), + ); + + for (const { call, toolResult } of results) { + messages.push({ + role: "tool", + toolCallId: call.id, + content: JSON.stringify(toolResult), + }); + } + } + + // Unreachable: the forced-final round (round === HARD_CAP) always returns + // above because `offerTools` is false, so `wantsTools` is false. Kept as a + // defensive guard. + throw new Error("coach tool loop exceeded hard cap without a final answer"); +} diff --git a/src/lib/ai/coach/tools/source-keys.ts b/src/lib/ai/coach/tools/source-keys.ts new file mode 100644 index 000000000..d0d821347 --- /dev/null +++ b/src/lib/ai/coach/tools/source-keys.ts @@ -0,0 +1,75 @@ +/** + * v1.20.0 (F1) — CoachScopeSource → snapshot section key map. + * + * `get_metric_series` fetches a single metric by re-running the snapshot + * builder scoped to that one source, then reads the matching section out of the + * structured `CoachSnapshotResult.sections`. The builder names each section by + * a camelCase key (e.g. `heartRateVariability`); this map mirrors the + * `valueBlocks` table in `snapshot.ts` plus the three core clinical blocks + * (bloodPressure / weight / pulse) and the mood block. + * + * `glucose` and `workouts` are intentionally absent: they have dedicated tools + * / branches (glucose carries the clinical panel; workouts is deferred). A + * `get_metric_series` call for one of them resolves to `null` here and the + * executor returns `{ present: false }` with a pointer to the right tool. + * + * The `source-keys.test.ts` guard asserts every `coachScopeSourceSchema` value + * either resolves here or is explicitly one of the dedicated-tool sources, so a + * future source addition cannot silently fall through to a fabricated answer. + */ +import type { CoachScopeSource } from "@/lib/ai/coach/types"; + +/** Sources that `get_metric_series` does NOT serve (they have own tools). */ +export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet = + new Set(["glucose", "workouts", "compliance"]); + +/** source → the snapshot section key its single-metric block lands under. */ +export const COACH_SOURCE_SNAPSHOT_KEY: Readonly< + Partial> +> = { + // ── core clinical ── + bp: "bloodPressure", + weight: "weight", + pulse: "pulse", + mood: "mood", + // ── cardio ── + hrv: "heartRateVariability", + resting_hr: "restingHeartRate", + walking_hr: "walkingHeartRateAverage", + respiratory_rate: "respiratoryRate", + spo2: "oxygenSaturation", + pulse_wave_velocity: "pulseWaveVelocity", + vascular_age: "vascularAge", + // ── body composition ── + body_fat: "bodyFat", + fat_mass: "fatMass", + fat_free_mass: "fatFreeMass", + muscle_mass: "muscleMass", + lean_body_mass: "leanBodyMass", + bone_mass: "boneMass", + total_body_water: "totalBodyWater", + bmi: "bodyMassIndex", + visceral_fat: "visceralFat", + // ── activity ── + steps: "steps", + active_energy: "activeEnergy", + flights: "flightsClimbed", + distance: "walkingRunningDistance", + vo2_max: "vo2Max", + // ── mobility & gait ── + walking_steadiness: "walkingSteadiness", + walking_asymmetry: "walkingAsymmetry", + walking_double_support: "walkingDoubleSupport", + walking_step_length: "walkingStepLength", + walking_speed: "walkingSpeed", + // ── environment / exposure ── + audio_env: "audioExposureEnvironment", + audio_headphone: "audioExposureHeadphone", + audio_event: "audioExposureEvent", + daylight: "timeInDaylight", + skin_temp: "skinTemperature", + // ── sleep (also has the dedicated get_sleep tool, but the raw duration + // series is reachable here too) ── + sleep: "sleep", + body_temp: "bodyTemperature", +}; diff --git a/src/lib/ai/coach/tools/system-addendum.ts b/src/lib/ai/coach/tools/system-addendum.ts new file mode 100644 index 000000000..775d07650 --- /dev/null +++ b/src/lib/ai/coach/tools/system-addendum.ts @@ -0,0 +1,41 @@ +/** + * v1.20.0 (F1) — tool-mode system-prompt addendum. + * + * Appended to the stable Coach system prompt ONLY when retrieval tools are + * offered this turn (a tool-capable provider). It reframes the grounding + * contract from "cite the SNAPSHOT" to "cite ONLY this turn's tool results", + * and pins the empty-result rule the hallucination audit checks. + * + * Kept as a separate appended block (rather than editing the giant bilingual + * base prompt) so the base prompt stays byte-identical — preserving the + * provider prompt-cache prefix and the system-prompt contract-parity tests — + * while the tool grounding rules ride a small, stable suffix. + * + * When tools are NOT offered (local / no-tools provider) this block is omitted + * and the legacy snapshot-stuffing path runs with the unchanged prompt. + */ +import type { Locale } from "@/lib/i18n/config"; + +const EN = `TOOL-BASED RETRIEVAL (this conversation) + +You have read-only retrieval tools and a DATA INVENTORY listing what the user has logged. The figures are NOT in this prompt — fetch them. + +1. GROUNDING (overrides ground rule 7 for this turn): ground EVERY number you cite in a TOOL RESULT you received THIS turn. Never cite a figure you did not just fetch, never recall a number from earlier turns as if it were fresh, and never invent one. +2. Call a tool ONLY for a domain the DATA INVENTORY marks "present". Call several tools in one step when a question spans metrics — they run in parallel. +3. When a tool returns { present: false } or an empty result, say plainly that you have no data for that metric and pivot — do NOT infer, estimate, or fabricate a value. +4. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool. +5. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`; + +const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung) + +Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, was der Nutzer erfasst hat. Die Zahlen stehen NICHT in diesem Prompt — rufe sie ab. + +1. VERANKERUNG (ersetzt Grundregel 7 für diesen Zug): verankere JEDE genannte Zahl in einem TOOL-ERGEBNIS, das du in DIESEM Zug erhalten hast. Nenne keine Zahl, die du nicht gerade abgerufen hast, gib keine Zahl aus früheren Zügen als frisch aus und erfinde keine. +2. Rufe ein Tool NUR für eine Domäne auf, die das DATA INVENTORY als „present" markiert. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel. +3. Wenn ein Tool { present: false } oder ein leeres Ergebnis liefert, sage klar, dass dazu keine Daten vorliegen, und lenke um — leite nichts ab, schätze nichts und erfinde keinen Wert. +4. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf. +5. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`; + +export function buildToolModeAddendum(locale: Locale): string { + return locale === "de" ? DE : EN; +}