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