mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge Coach tool-based retrieval into release/v1.20.0
Replace the Coach snapshot-stuffing with a small base context plus a data inventory and six on-demand retrieval tools (metric series, glucose, sleep, medication compliance, labs, illness recovery) the model calls as needed, then answers. Cuts first-turn input ~35-65% while preserving grounding and every gate; tools return a structured present:false rather than throwing, so the model cites only what it fetched. Falls back to the snapshot path when a provider lacks tool support.
This commit is contained in:
commit
5c2d3f12cb
18 files changed
+2094
-43
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/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>,
|
||||
) => {
|
||||
void Promise.resolve(
|
||||
producer({ signal: { aborted: true }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): 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<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<CoachSnapshotResult>>();
|
||||
|
||||
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<string, unknown>,
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<CoachSnapshotResult>>();
|
||||
const isCycleAvailableForUser = vi.fn<() => Promise<boolean>>();
|
||||
|
||||
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<string, unknown>,
|
||||
counts?: Record<string, number>,
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<CoachToolResult>>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,16 @@ import type {
|
||||
|
||||
export interface CoachSnapshotResult {
|
||||
snapshotJson: string;
|
||||
/**
|
||||
* v1.20.0 (F1) — the structured, post-degrade snapshot record `snapshotJson`
|
||||
* is serialised from. Keyed by domain block (`bloodPressure`, `weight`,
|
||||
* `pulse`, `mood`, `compliance`, `glucose`, `sleep`, `sleepRhythm`, `labs`,
|
||||
* `illness`, `derived`, `dayStrain`, `trajectory`, `weeklyContext`, …) plus a
|
||||
* `scope` block. The coach tool executor reads a single domain block out of
|
||||
* this so an on-demand retrieval tool returns exactly the numbers the legacy
|
||||
* snapshot-stuffing path would have shown — identical builder, gates, and I/O.
|
||||
*/
|
||||
sections: Record<string, unknown>;
|
||||
/**
|
||||
* Provenance built from snapshot keys actually present. Stays in
|
||||
* sync with the SNAPSHOT block so the source-chip row mirrors what
|
||||
@@ -2352,6 +2362,13 @@ async function buildCoachSnapshotImpl(
|
||||
|
||||
return {
|
||||
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
|
||||
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
|
||||
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
|
||||
// executor slices a single domain block out of this so a retrieval tool
|
||||
// returns exactly the numbers the legacy snapshot path would have shown —
|
||||
// same builder, same gates, same I/O. `snapshotJson` is this record
|
||||
// serialised; exposing the record avoids re-parsing it.
|
||||
sections: compactSnapshot,
|
||||
provenance: {
|
||||
windows: Array.from(windows),
|
||||
metrics: Array.from(metrics),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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<string, unknown>,
|
||||
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<CoachToolResult> {
|
||||
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<CoachToolResult> {
|
||||
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<CoachToolResult> {
|
||||
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
|
||||
const { metric, window } = parsed.data;
|
||||
|
||||
// Glucose / workouts / compliance have dedicated tools (or are deferred);
|
||||
// refuse to answer them here so the model is pointed at the right tool
|
||||
// rather than getting a fabricated-shape miss.
|
||||
if (METRIC_SERIES_EXCLUDED_SOURCES.has(metric)) {
|
||||
return {
|
||||
present: false,
|
||||
reason:
|
||||
metric === "glucose"
|
||||
? "use_get_glucose_panel"
|
||||
: metric === "compliance"
|
||||
? "use_get_medication_compliance"
|
||||
: "unsupported_metric",
|
||||
};
|
||||
}
|
||||
|
||||
const sectionKey = COACH_SOURCE_SNAPSHOT_KEY[metric];
|
||||
if (!sectionKey) {
|
||||
return { present: false, reason: "unsupported_metric" };
|
||||
}
|
||||
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor([metric], window, fallbackWindow),
|
||||
);
|
||||
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<CoachToolResult> {
|
||||
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["glucose"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
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<CoachToolResult> {
|
||||
const parsed = getSleepArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_sleep", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["sleep"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
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<CoachToolResult> {
|
||||
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return badArgs("get_medication_compliance", parsed.error);
|
||||
}
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["compliance"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
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<CoachToolResult> {
|
||||
const parsed = getLabsArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_labs", parsed.error);
|
||||
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
|
||||
// so a minimal scope still surfaces them. 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<CoachToolResult> {
|
||||
// Validate the (empty) args shape for consistency; an empty object always
|
||||
// passes.
|
||||
getIllnessRecoveryArgsSchema.parse({});
|
||||
// Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max
|
||||
// sources; request them so the derived / dayStrain / trajectory blocks can
|
||||
// build when the module is on. Illness rides the snapshot unconditionally.
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<CoachDataInventory> {
|
||||
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");
|
||||
}
|
||||
@@ -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<CoachToolLoopResult> {
|
||||
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");
|
||||
}
|
||||
@@ -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<CoachScopeSource> =
|
||||
new Set<CoachScopeSource>(["glucose", "workouts", "compliance"]);
|
||||
|
||||
/** source → the snapshot section key its single-metric block lands under. */
|
||||
export const COACH_SOURCE_SNAPSHOT_KEY: Readonly<
|
||||
Partial<Record<CoachScopeSource, string>>
|
||||
> = {
|
||||
// ── 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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -338,6 +338,15 @@ export interface CoachProvenance {
|
||||
* on a conversation reload. Absent on turns that carry no suggestion.
|
||||
*/
|
||||
suggestion?: CoachSuggestion;
|
||||
/**
|
||||
* v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the
|
||||
* Coach called and whether each found data (`present`). Metadata only — no
|
||||
* raw values beyond what `keyValues` already persists. Lets a conversation
|
||||
* reload show "what I looked at" and lets the hallucination audit replay
|
||||
* grounding. Absent on the legacy snapshot path + on turns that called no
|
||||
* tools.
|
||||
*/
|
||||
toolCalls?: ReadonlyArray<{ name: string; present: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -277,6 +277,17 @@ const coachProvenanceSchema = z
|
||||
.describe(
|
||||
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
|
||||
),
|
||||
toolCalls: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
present: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachProvenance",
|
||||
|
||||
Reference in new issue
Block a user