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:
Marc-André Bombeck committed 2026-06-26 13:32:42 +02:00
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,
);
+5
View File
@@ -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",
});
});
});
+79 -3
View File
@@ -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 }),
);
});
});
+101 -34
View File
@@ -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 };
+9
View File
@@ -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,
};
}
+29 -8
View File
@@ -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