mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(coach): run the turn on a budget with a live trail, questions and comparisons
Replace the fixed three tool rounds with a budget fixed by who pays:
120k tokens, 150 s and 12 rounds on the person's own plan, key or local
model, 40k, 90 s and 6 rounds when the operator pays. Cached input counts
at a tenth. The final answer is reserved from the start, so a turn whose
budget, clock or round cap runs out answers from what it read. A
no-progress brake stops a turn that repeats calls or reads nothing new,
and a repeated call is never run again.
The day's ledger is booked round by round: round one and the final
reserve when the turn starts, each further round right before it runs,
every round settled as it returns, the unused reserve given back at the
end. A round the daily ceiling refuses ends the turn with an answer.
Every phase goes out live as an activity frame (thinking, fetching,
summarising, asking, remembering, planning, the stop, the answer), with
screened reasoning titles and summaries; the structure rides the
provenance and the text is sealed in trail_encrypted, served by
GET /api/insights/chat/{id}/messages/{messageId}/trail. Tables go out as
interim results as soon as they are read.
Add ask_clarification with a server brake, remember_fact and
propose_plan over the memory contract, model-free decision turns,
compare_series with the compare chart, eight tables per message,
assumptions with change-assumption chips, a reasoning level per turn and
at most two concurrent turns per person.
This commit is contained in:
1 parent
210becb28b
commit
cf68055cb2
59 files changed
+5477
-614
No files matched your search
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.41 — the trail route: owner-narrowed, 404 for anything else, and the
|
||||
* model-written text only while the Coach's text may be shown.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const { requireAuth, readMessageTrail, aiCapabilityToServe } = vi.hoisted(
|
||||
() => ({
|
||||
requireAuth: vi.fn(),
|
||||
readMessageTrail: vi.fn(),
|
||||
aiCapabilityToServe: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ApiHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
|
||||
handler,
|
||||
requireAuth,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
const TRAIL = {
|
||||
entries: [{ id: "a1", title: "Weighing the two weeks", text: "Looked at sleep first." }],
|
||||
};
|
||||
|
||||
function call(id = "c1", messageId = "m1") {
|
||||
return GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`,
|
||||
),
|
||||
{ params: Promise.resolve({ id, messageId }) },
|
||||
) as Promise<Response>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
|
||||
it("reads under the caller's own id and the path ids only", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
const response = await call("c1", "m1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1");
|
||||
// Requires a session or a full-access token, like every Coach read.
|
||||
expect(requireAuth).toHaveBeenCalledWith();
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toEqual(TRAIL);
|
||||
expect(body.data.ai.available).toBe(true);
|
||||
});
|
||||
|
||||
it("answers 404 for a message the caller does not own", async () => {
|
||||
readMessageTrail.mockResolvedValue(null);
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
expect(aiCapabilityToServe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves no model text while the Coach's text may not be shown", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: TRAIL });
|
||||
aiCapabilityToServe.mockResolvedValue({
|
||||
available: false,
|
||||
reason: "disabled_by_user",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
expect(body.data.ai.available).toBe(false);
|
||||
expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
|
||||
});
|
||||
|
||||
it("answers a message without a trail with null", async () => {
|
||||
readMessageTrail.mockResolvedValue({ trail: null });
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.trail).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the model-written
|
||||
* text of one assistant message's trail (v1.41): the screened reasoning
|
||||
* titles and summaries and checkpoint sentences by trail entry, decrypted for
|
||||
* their owner. The structure of the trail (phases, statuses, counts) is
|
||||
* plaintext on the message's `metricSource.activity`; only the text is here,
|
||||
* read lazily so a long thread does not carry every trail.
|
||||
*
|
||||
* Narrowed exactly like `…/results`: the message must sit in a conversation
|
||||
* the caller owns, and a foreign or unknown id is 404, never 403. A session
|
||||
* or a full-access token reads it; a narrow token is refused by
|
||||
* `requireAuth()` like on every Coach read.
|
||||
*
|
||||
* A mixed read: the text is model-written, so it is served only while the
|
||||
* Coach's text may be shown for the record (`aiCapabilityToServe`). When it
|
||||
* may not, the answer is 200 with `trail: null` and the `ai` state beside it,
|
||||
* and the client keeps the trail's catalog labels.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { readMessageTrail } from "@/lib/ai/coach/persistence";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string; messageId: string }>;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
const userId = auth.user.id;
|
||||
const { id, messageId } = await ctx.params;
|
||||
if (!id || !messageId) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const read = await readMessageTrail(userId, id, messageId);
|
||||
if (!read) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const ai = await aiCapabilityToServe(userId, "coach");
|
||||
const trail = ai.available ? read.trail : null;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach.trail.read" },
|
||||
meta: {
|
||||
conversationId: id,
|
||||
entries: trail?.entries.length ?? 0,
|
||||
served: ai.available,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ trail, ai });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
+1393
-108
File diff suppressed because it is too large.
Load diff
@@ -473,7 +473,10 @@ export const modules = {
|
||||
labResult: { aggregate: async () => EMPTY_AGGREGATE },
|
||||
},
|
||||
}),
|
||||
rateLimit: () => ({ checkRateLimit: m.checkRateLimit }),
|
||||
rateLimit: () => ({
|
||||
checkRateLimit: m.checkRateLimit,
|
||||
refundRateLimit: async () => {},
|
||||
}),
|
||||
serverLocale: () => ({
|
||||
resolveServerLocale: async (args: { override?: string }) =>
|
||||
args.override ?? "en",
|
||||
|
||||
@@ -373,7 +373,26 @@ export async function runCoachToolLoop(args: Record<string, unknown>) {
|
||||
timeoutMs: args.timeoutMs,
|
||||
signal: args.signal instanceof AbortSignal,
|
||||
});
|
||||
return m.runCoachToolLoop(args);
|
||||
const out = await m.runCoachToolLoop(args);
|
||||
// The real loop settles each round on the turn's ledger as it returns;
|
||||
// the scripted loop settles its whole cost as one round.
|
||||
const spend = args.spend as
|
||||
| {
|
||||
settleRound(usage: {
|
||||
tokens: number;
|
||||
cachedTokens: number;
|
||||
servedBy: string;
|
||||
final: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
await spend?.settleRound({
|
||||
tokens: out.totalTokens,
|
||||
cachedTokens: out.cachedTokens,
|
||||
servedBy: out.workingProviderType,
|
||||
final: false,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runStreamingRawCompletionWithFallback(
|
||||
|
||||
@@ -65,7 +65,10 @@ vi.mock("@/lib/db", () => ({
|
||||
coachMessage: { findMany: h.m.coachMessageFindMany },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: h.m.checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: h.m.resolveServerLocale,
|
||||
}));
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -69,7 +69,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -53,7 +53,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -38,7 +38,10 @@ vi.mock("@/lib/api-response", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -70,7 +70,10 @@ vi.mock("@/lib/db", () => ({
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit,
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
@@ -316,21 +319,25 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
expect(args.messages[0].content).toContain("2400");
|
||||
});
|
||||
|
||||
it("reserves the multi-round budget in tool mode", async () => {
|
||||
it("reserves round one and the final answer up front in tool mode", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
// 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.
|
||||
// v1.41 — the first round (its input estimate plus a round's answer and
|
||||
// the default medium thinking budget) and the final answer's reserve, in
|
||||
// one reservation. The 4th arg is the provider-aware daily cap —
|
||||
// the user-plan ceiling for this BYOK chain.
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
4500,
|
||||
expect.any(Number),
|
||||
"2026-06-21",
|
||||
2_000_000,
|
||||
"user",
|
||||
"coach",
|
||||
);
|
||||
const reserved = reserveBudget.mock.calls[0][1] as number;
|
||||
expect(reserved).toBeGreaterThanOrEqual(600 + 4_096 + 1_200);
|
||||
});
|
||||
|
||||
it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
refundRateLimit: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -63,6 +63,19 @@ async function coachCapabilityRefusal(): Promise<Response | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — at most this many Coach turns of one person at once. A budgeted
|
||||
* turn can run for minutes; three tabs firing at the same time would run
|
||||
* three of them against one daily budget and one provider account.
|
||||
*/
|
||||
const COACH_CONCURRENT_TURNS = 2;
|
||||
/**
|
||||
* How long a turn's slot is held at most: a turn's wall time (150 s) plus
|
||||
* the streaming of its reply. A slot whose release never arrived (a crashed
|
||||
* process) frees itself when the window ends.
|
||||
*/
|
||||
const COACH_TURN_SLOT_MS = 180_000;
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// The `coach` capability, right after auth and before the rate limit and
|
||||
@@ -111,6 +124,8 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
} = parsed.data;
|
||||
|
||||
// Per-user request-rate ceiling layered in front of the daily budget
|
||||
@@ -162,6 +177,29 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.41 — the person's turn slot. The counter is the rate-limit row's own
|
||||
// (an atomic upsert), given back when the turn is over; a refused slot is
|
||||
// given back at once, since the refused attempt never ran.
|
||||
const slotKey = `coach-turn-active:${userId}`;
|
||||
const slot = await checkRateLimit(
|
||||
slotKey,
|
||||
COACH_CONCURRENT_TURNS,
|
||||
COACH_TURN_SLOT_MS,
|
||||
);
|
||||
const releaseSlot = () => {
|
||||
void refundRateLimit(slotKey).catch(() => {
|
||||
// The window frees the slot on its own.
|
||||
});
|
||||
};
|
||||
if (!slot.allowed) {
|
||||
releaseSlot();
|
||||
annotate({
|
||||
action: { name: "insights.coach.concurrent-limited" },
|
||||
meta: { limit: COACH_CONCURRENT_TURNS },
|
||||
});
|
||||
return apiError("Too many Coach requests, please wait a moment", 429);
|
||||
}
|
||||
|
||||
// Conversation, context, chain, egress re-check, budget, and the streamed
|
||||
// reply: `src/lib/ai/coach/turn/`.
|
||||
return runCoachTurn({
|
||||
@@ -175,6 +213,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
workoutId,
|
||||
followUp,
|
||||
clarification,
|
||||
memoryDecision,
|
||||
planDecision,
|
||||
releaseSlot,
|
||||
recheckCapability: coachCapabilityRefusal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
runRawCompletionWithFallback(args),
|
||||
}));
|
||||
|
||||
import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
|
||||
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
|
||||
import { createTurnBudget } from "@/lib/ai/coach/tools/turn-budget";
|
||||
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
|
||||
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
||||
|
||||
@@ -138,17 +139,35 @@ describe("runCoachToolLoop — progress callbacks", () => {
|
||||
});
|
||||
|
||||
it("reports a forced answer at the round cap", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: false });
|
||||
executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } });
|
||||
const metrics = ["bp", "weight", "pulse", "hrv", "steps"];
|
||||
let round_ = 0;
|
||||
runRawCompletionWithFallback.mockImplementation(
|
||||
(args: { params: { toolChoice?: string } }) =>
|
||||
Promise.resolve(
|
||||
(args: { params: { toolChoice?: string } }) => {
|
||||
round_ += 1;
|
||||
return Promise.resolve(
|
||||
args.params.toolChoice === "none"
|
||||
? round("forced")
|
||||
: round("", [{ id: "x", name: "get_sleep", arguments: "{}" }]),
|
||||
),
|
||||
: round("", [
|
||||
{
|
||||
id: `x${round_}`,
|
||||
name: "get_metric_series",
|
||||
arguments: JSON.stringify({ metric: metrics[round_] }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
expect(out.rounds).toBe(HARD_CAP);
|
||||
const out = await runCoachToolLoop({
|
||||
...baseArgs,
|
||||
budget: createTurnBudget({
|
||||
payer: "user",
|
||||
initialInputTokens: 10,
|
||||
limits: { tokens: 1e9, wallMs: 1e9, maxRounds: 4 },
|
||||
}),
|
||||
});
|
||||
expect(out.rounds).toBe(4);
|
||||
expect(out.stop).toEqual({ reason: "cap", rounds: 4 });
|
||||
expect(out.forcedFinal).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,8 @@ vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
runRawCompletionWithFallback(args),
|
||||
}));
|
||||
|
||||
import {
|
||||
runCoachToolLoop,
|
||||
HARD_CAP,
|
||||
MAX_ROUNDS,
|
||||
} from "@/lib/ai/coach/tools/loop";
|
||||
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
|
||||
import { createTurnBudget } from "@/lib/ai/coach/tools/turn-budget";
|
||||
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
|
||||
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
|
||||
|
||||
@@ -135,11 +132,13 @@ describe("runCoachToolLoop", () => {
|
||||
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.
|
||||
it("forces a final answer at the round cap (no infinite loop)", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } });
|
||||
// The model asks for a new read every round. The loop must still
|
||||
// terminate: the round the cap allows last offers no tools
|
||||
// (toolChoice none), so the model is forced to produce prose.
|
||||
const metrics = ["bp", "weight", "pulse", "hrv", "steps", "sleep"];
|
||||
let n = 0;
|
||||
runRawCompletionWithFallback.mockImplementation(
|
||||
(args: { params: { toolChoice?: string } }) => {
|
||||
if (args.params.toolChoice === "none") {
|
||||
@@ -147,15 +146,16 @@ describe("runCoachToolLoop", () => {
|
||||
completion({ content: "Final forced answer." }),
|
||||
);
|
||||
}
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
completion({
|
||||
content: "",
|
||||
finishReason: "tool_calls",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "x",
|
||||
id: `x${n}`,
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp"}',
|
||||
arguments: JSON.stringify({ metric: metrics[n] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -163,15 +163,24 @@ describe("runCoachToolLoop", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
expect(out.rounds).toBe(HARD_CAP);
|
||||
const out = await runCoachToolLoop({
|
||||
...baseArgs,
|
||||
budget: createTurnBudget({
|
||||
payer: "operator",
|
||||
initialInputTokens: 10,
|
||||
limits: { tokens: 1e9, wallMs: 1e9, maxRounds: 6 },
|
||||
}),
|
||||
});
|
||||
expect(out.rounds).toBe(6);
|
||||
expect(out.result.content).toBe("Final forced answer.");
|
||||
expect(out.stop).toEqual({ reason: "cap", rounds: 6 });
|
||||
// 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");
|
||||
expect(lastCall.params.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends assistant(toolCalls) + tool turns to the message array", async () => {
|
||||
@@ -206,47 +215,6 @@ describe("runCoachToolLoop", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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)", () => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* v1.41 — the labels of a turn's trail, rendered on the server in the
|
||||
* request locale.
|
||||
*
|
||||
* Every label is catalog text with server-chosen values: a domain and a
|
||||
* window from their closed enums, a count the server took. Model text never
|
||||
* reaches a label; it rides `title` and `text` on the entry, after
|
||||
* screening (`screen.ts`).
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type {
|
||||
CoachScopeWindow,
|
||||
CoachStepDomain,
|
||||
CoachStopReason,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachDomainLabelKey,
|
||||
coachWindowLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
|
||||
import {
|
||||
COACH_ACTIVITY_KEYS,
|
||||
COACH_ACTIVITY_STOP_KEYS,
|
||||
activityAreasKey,
|
||||
activityDigestDoneKey,
|
||||
activityDigestKey,
|
||||
activityMemoryKey,
|
||||
} from "./contract";
|
||||
|
||||
export interface ActivityLabel {
|
||||
labelKey: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function render(
|
||||
locale: Locale,
|
||||
labelKey: string,
|
||||
values?: Record<string, string | number>,
|
||||
): ActivityLabel {
|
||||
return { labelKey, label: getServerTranslator(locale).t(labelKey, values) };
|
||||
}
|
||||
|
||||
type SimplePhase = keyof typeof COACH_ACTIVITY_KEYS;
|
||||
|
||||
/** The labels with no value: thinking, remembering, planning, asking, answering. */
|
||||
export function simpleActivityLabel(
|
||||
locale: Locale,
|
||||
phase: Exclude<SimplePhase, "fetching">,
|
||||
): ActivityLabel {
|
||||
return render(locale, COACH_ACTIVITY_KEYS[phase]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fetching weight, last 30 days…". A read without a window (the cycle, the
|
||||
* patterns between metrics) has no true window to name, so it is labelled
|
||||
* with the step's own catalog label instead, passed as `fallback`.
|
||||
*/
|
||||
export function fetchActivityLabel(
|
||||
locale: Locale,
|
||||
args: {
|
||||
domain: CoachStepDomain;
|
||||
window?: CoachScopeWindow;
|
||||
fallback?: ActivityLabel;
|
||||
},
|
||||
): ActivityLabel {
|
||||
const { t } = getServerTranslator(locale);
|
||||
if (!args.window) {
|
||||
return (
|
||||
args.fallback ??
|
||||
render(locale, COACH_ACTIVITY_KEYS.fetching, {
|
||||
domain: t(coachDomainLabelKey(args.domain)),
|
||||
window: t(coachWindowLabelKey("allTime")),
|
||||
})
|
||||
);
|
||||
}
|
||||
return render(locale, COACH_ACTIVITY_KEYS.fetching, {
|
||||
domain: t(coachDomainLabelKey(args.domain)),
|
||||
window: t(coachWindowLabelKey(args.window)),
|
||||
});
|
||||
}
|
||||
|
||||
/** "Summarising 412 readings…", while a round's results are folded in. */
|
||||
export function digestActivityLabel(
|
||||
locale: Locale,
|
||||
count: number,
|
||||
): ActivityLabel {
|
||||
return render(locale, activityDigestKey(count, locale), { count });
|
||||
}
|
||||
|
||||
/** "412 readings from 2 areas", once the round has settled. */
|
||||
export function digestDoneActivityLabel(
|
||||
locale: Locale,
|
||||
count: number,
|
||||
areas: number,
|
||||
): ActivityLabel {
|
||||
const { t } = getServerTranslator(locale);
|
||||
return render(locale, activityDigestDoneKey(count, locale), {
|
||||
count,
|
||||
areas: t(activityAreasKey(areas, locale), { count: areas }),
|
||||
});
|
||||
}
|
||||
|
||||
/** "Recalls 3 things you told it". */
|
||||
export function memoryActivityLabel(
|
||||
locale: Locale,
|
||||
count: number,
|
||||
): ActivityLabel {
|
||||
return render(locale, activityMemoryKey(count, locale), { count });
|
||||
}
|
||||
|
||||
/** "Time limit reached, answering with what it has". */
|
||||
export function stopActivityLabel(
|
||||
locale: Locale,
|
||||
reason: CoachStopReason,
|
||||
): ActivityLabel {
|
||||
return render(locale, COACH_ACTIVITY_STOP_KEYS[reason]);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* v1.41 — the live trail of one Coach turn: the recorder the loop reports
|
||||
* to (implements `contract.ts`).
|
||||
*
|
||||
* Each entry is opened before its phase starts, updated while it runs and
|
||||
* closed with its status and duration. Every change goes out at once as an
|
||||
* `activity` frame, upserted by id on the client, so the status line moves
|
||||
* while the turn runs. Model text (a reasoning title, a summary, a
|
||||
* checkpoint sentence) is screened here before it goes anywhere; text that
|
||||
* fails keeps the entry on its catalog label.
|
||||
*
|
||||
* At the end the recorder hands back the plaintext metadata for
|
||||
* `metricSource.activity` and the model text for `trail_encrypted`, held to
|
||||
* `TRAIL_MAX_BYTES` by dropping the oldest texts first.
|
||||
*/
|
||||
import type {
|
||||
CoachActivity,
|
||||
CoachActivityMeta,
|
||||
CoachStepStatus,
|
||||
CoachTrail,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
TRAIL_MAX_BYTES,
|
||||
type ActivityPatch,
|
||||
type ActivityRecorder,
|
||||
type ActivityStart,
|
||||
} from "./contract";
|
||||
import {
|
||||
screenActivityText,
|
||||
screenActivityTitle,
|
||||
type ActivityScreenContext,
|
||||
} from "./screen";
|
||||
|
||||
interface Entry {
|
||||
meta: CoachActivityMeta;
|
||||
title?: string;
|
||||
text?: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** The recorder a chat turn holds: the contract plus the trail's extras. */
|
||||
export interface TurnActivityRecorder extends ActivityRecorder {
|
||||
/** The facts recalled into the turn, owner-only, for the `memory` entry. */
|
||||
setRecalled(facts: readonly string[]): void;
|
||||
/** The fact proposal's text, which a `memoryDecision` reads back. */
|
||||
setProposal(proposal: NonNullable<CoachTrail["proposal"]>): void;
|
||||
/** Whether any entry has been opened. */
|
||||
started(): boolean;
|
||||
}
|
||||
|
||||
function bytes(value: unknown): number {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
export function createActivityRecorder(args: {
|
||||
/** Sends one frame; a throw is swallowed (the trail never breaks a turn). */
|
||||
emit: (activity: CoachActivity) => void;
|
||||
screen: ActivityScreenContext;
|
||||
now?: () => number;
|
||||
}): TurnActivityRecorder {
|
||||
const now = args.now ?? Date.now;
|
||||
const entries = new Map<string, Entry>();
|
||||
let recalled: string[] = [];
|
||||
let proposal: CoachTrail["proposal"];
|
||||
|
||||
const send = (entry: Entry) => {
|
||||
try {
|
||||
args.emit({
|
||||
...entry.meta,
|
||||
...(entry.title ? { title: entry.title } : {}),
|
||||
...(entry.text ? { text: entry.text } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Progress is best-effort; the answer is not.
|
||||
}
|
||||
};
|
||||
|
||||
const apply = (entry: Entry, patch: ActivityPatch | undefined) => {
|
||||
if (!patch) return;
|
||||
if (patch.labelKey !== undefined && patch.label !== undefined) {
|
||||
entry.meta.labelKey = patch.labelKey;
|
||||
entry.meta.label = patch.label;
|
||||
}
|
||||
if (patch.count !== undefined && Number.isSafeInteger(patch.count)) {
|
||||
entry.meta.count = Math.max(0, patch.count);
|
||||
}
|
||||
if (patch.title !== undefined) {
|
||||
const title = screenActivityTitle(patch.title, args.screen);
|
||||
if (title) entry.title = title;
|
||||
}
|
||||
if (patch.text !== undefined) {
|
||||
const text = screenActivityText(patch.text, args.screen);
|
||||
if (text) entry.text = text;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(start: ActivityStart) {
|
||||
if (entries.size >= ACTIVITY_MAX_ENTRIES) return "";
|
||||
const id = `a${entries.size + 1}`;
|
||||
const entry: Entry = {
|
||||
meta: {
|
||||
id,
|
||||
phase: start.phase,
|
||||
status: "running",
|
||||
round: Math.max(1, Math.floor(start.round)),
|
||||
labelKey: start.labelKey,
|
||||
label: start.label,
|
||||
...(start.stepRef ? { stepRef: start.stepRef } : {}),
|
||||
...(start.count !== undefined && Number.isSafeInteger(start.count)
|
||||
? { count: Math.max(0, start.count) }
|
||||
: {}),
|
||||
...(start.stop ? { stop: start.stop } : {}),
|
||||
},
|
||||
startedAt: now(),
|
||||
};
|
||||
entries.set(id, entry);
|
||||
send(entry);
|
||||
return id;
|
||||
},
|
||||
update(id, patch) {
|
||||
const entry = entries.get(id);
|
||||
if (!entry || entry.meta.status !== "running") return;
|
||||
apply(entry, patch);
|
||||
send(entry);
|
||||
},
|
||||
finish(id, status: Exclude<CoachStepStatus, "running">, patch) {
|
||||
const entry = entries.get(id);
|
||||
if (!entry || entry.meta.status !== "running") return;
|
||||
apply(entry, patch);
|
||||
entry.meta.status = status;
|
||||
entry.meta.durationMs = Math.max(0, Math.round(now() - entry.startedAt));
|
||||
send(entry);
|
||||
},
|
||||
meta: () => [...entries.values()].map((entry) => ({ ...entry.meta })),
|
||||
trail() {
|
||||
const withText = [...entries.values()].filter(
|
||||
(entry) => entry.title || entry.text,
|
||||
);
|
||||
if (withText.length === 0 && recalled.length === 0 && !proposal) {
|
||||
return null;
|
||||
}
|
||||
const trail: CoachTrail = {
|
||||
entries: withText.map((entry) => ({
|
||||
id: entry.meta.id,
|
||||
...(entry.title ? { title: entry.title } : {}),
|
||||
...(entry.text ? { text: entry.text } : {}),
|
||||
})),
|
||||
...(recalled.length > 0 ? { recalled: [...recalled] } : {}),
|
||||
...(proposal ? { proposal } : {}),
|
||||
};
|
||||
// Over the ceiling: the oldest texts go first, then the oldest titles,
|
||||
// then the recalled facts. The proposal is what a tap reads back, so
|
||||
// it stays.
|
||||
for (const field of ["text", "title"] as const) {
|
||||
for (const entry of trail.entries) {
|
||||
if (bytes(trail) <= TRAIL_MAX_BYTES) return trail;
|
||||
delete entry[field];
|
||||
}
|
||||
}
|
||||
trail.entries = trail.entries.filter((e) => e.title || e.text);
|
||||
if (bytes(trail) > TRAIL_MAX_BYTES) delete trail.recalled;
|
||||
return trail;
|
||||
},
|
||||
setRecalled(facts) {
|
||||
recalled = facts.filter((fact) => fact.length > 0).slice(0, 12);
|
||||
},
|
||||
setProposal(next) {
|
||||
proposal = next;
|
||||
},
|
||||
started: () => entries.size > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.41 — the screen model text passes before it rides a trail entry.
|
||||
*
|
||||
* A reasoning title, a reasoning summary and a checkpoint sentence are model
|
||||
* text shown to the person, so each goes through what a reply goes through:
|
||||
*
|
||||
* - the outbound screen (`screenCoachReply`): a dose instruction, a
|
||||
* diagnosis or a risk score drops the text;
|
||||
* - the refusal detector: an instruction smuggled into the text drops it;
|
||||
* - the number check: every figure must be one the turn read, checked
|
||||
* against the same grounding ledger the reply is checked against, but
|
||||
* strictly — a figure that does not reconcile drops the whole text rather
|
||||
* than being elided, because a half-sentence in the trail helps nobody;
|
||||
* - length: a title is cut to 80 characters at a word, a text to 400.
|
||||
*
|
||||
* Dropped text leaves the entry with its catalog label alone. Nothing here
|
||||
* throws: a screen that cannot decide drops the text.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
|
||||
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
||||
import { buildGroundingLedger } from "@/lib/ai/coach/grounding-ledger";
|
||||
import { findUnverifiedCoachNumbersInLedger } from "@/lib/ai/coach/coach-prose-grounding";
|
||||
|
||||
import { ACTIVITY_TEXT_MAX_CHARS, ACTIVITY_TITLE_MAX_CHARS } from "./contract";
|
||||
|
||||
export interface ActivityScreenContext {
|
||||
locale: Locale;
|
||||
/**
|
||||
* The figures the turn has read so far (the present tool payloads). A
|
||||
* getter: the set grows with every round, and a text is checked against
|
||||
* what was read by the time it arrived.
|
||||
*/
|
||||
figures: () => readonly unknown[];
|
||||
/** The person's own message: figures they stated may be repeated. */
|
||||
userMessage?: string;
|
||||
/** Scheduled doses, which the dose screen lets through. */
|
||||
scheduleDoses?: readonly number[];
|
||||
}
|
||||
|
||||
/** Markdown emphasis, headings and list marks, which the trail never renders. */
|
||||
function plain(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*|__|`/g, "")
|
||||
.replace(/^#+\s*/gm, "")
|
||||
.replace(/^\s*[-*•]\s+/gm, "")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Cut at the last word boundary that fits, with an ellipsis. */
|
||||
function cut(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
const head = text.slice(0, max - 1);
|
||||
const space = head.lastIndexOf(" ");
|
||||
return `${(space > max / 2 ? head.slice(0, space) : head).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function passes(text: string, ctx: ActivityScreenContext): boolean {
|
||||
try {
|
||||
if (screenCoachReply(text, ctx.locale, ctx.scheduleDoses).block) {
|
||||
return false;
|
||||
}
|
||||
if (detectRefusal({ message: text, locale: ctx.locale }).refuse) {
|
||||
return false;
|
||||
}
|
||||
if (/\d/.test(text)) {
|
||||
const ledger = buildGroundingLedger({
|
||||
toolPayloads: ctx.figures(),
|
||||
priorUserMessages: ctx.userMessage ? [ctx.userMessage] : [],
|
||||
scheduleDoses: ctx.scheduleDoses,
|
||||
});
|
||||
const unverified = findUnverifiedCoachNumbersInLedger(
|
||||
text,
|
||||
ledger,
|
||||
ctx.locale,
|
||||
{ gradeAgainstEmptyLedger: true },
|
||||
);
|
||||
if (unverified.length > 0) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reasoning title or a checkpoint sentence: the first line, plain, at most
|
||||
* 80 characters. Null when nothing is left or the screen drops it.
|
||||
*/
|
||||
export function screenActivityTitle(
|
||||
raw: string | null | undefined,
|
||||
ctx: ActivityScreenContext,
|
||||
): string | null {
|
||||
if (!raw) return null;
|
||||
const firstLine = plain(raw)
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine) return null;
|
||||
const title = cut(firstLine, ACTIVITY_TITLE_MAX_CHARS);
|
||||
return passes(title, ctx) ? title : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reasoning summary for one round: plain, paragraphs kept, at most 400
|
||||
* characters. Null when nothing is left or the screen drops it.
|
||||
*/
|
||||
export function screenActivityText(
|
||||
raw: string | null | undefined,
|
||||
ctx: ActivityScreenContext,
|
||||
): string | null {
|
||||
if (!raw) return null;
|
||||
const text = cut(
|
||||
plain(raw)
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.replace(/\s*\n\s*/g, " ").trim())
|
||||
.filter((p) => p.length > 0)
|
||||
.join("\n\n"),
|
||||
ACTIVITY_TEXT_MAX_CHARS,
|
||||
);
|
||||
if (!text) return null;
|
||||
return passes(text, ctx) ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first sentence of a checkpoint (assistant text that came beside tool
|
||||
* calls), screened like a title.
|
||||
*/
|
||||
export function screenCheckpoint(
|
||||
raw: string | null | undefined,
|
||||
ctx: ActivityScreenContext,
|
||||
): string | null {
|
||||
if (!raw) return null;
|
||||
const sentence = plain(raw).split(/(?<=[.!?])\s+/)[0] ?? "";
|
||||
return screenActivityTitle(sentence, ctx);
|
||||
}
|
||||
@@ -295,11 +295,18 @@ export async function reserveBudget(
|
||||
owner: BudgetCostOwner,
|
||||
surface: BudgetSurface,
|
||||
db: BudgetExecutor = prisma,
|
||||
/**
|
||||
* v1.41 — `false` for a further reservation inside a request that already
|
||||
* counted its message: a Coach turn reserves round by round, and each
|
||||
* round is not a message of its own.
|
||||
*/
|
||||
opts: { countMessage?: boolean } = {},
|
||||
): Promise<ReserveBudgetResult> {
|
||||
const reserved =
|
||||
Number.isFinite(estimatedTokens) && estimatedTokens > 0
|
||||
? Math.floor(estimatedTokens)
|
||||
: 0;
|
||||
const messageBump = opts.countMessage === false ? 0 : 1;
|
||||
// v1.38.19 — a reservation is booked to `operator_tokens` only when
|
||||
// the chain's primary is operator-funded. The counter moves inside the SAME
|
||||
// statement as the total, so two concurrent requests can never observe one
|
||||
@@ -314,11 +321,11 @@ export async function reserveBudget(
|
||||
{ total_tokens: number; operator_tokens: number }[]
|
||||
>`
|
||||
INSERT INTO coach_usage (id, user_id, date_key, total_tokens, operator_tokens, message_count, created_at, updated_at)
|
||||
VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, 1, NOW(), NOW())
|
||||
VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, NOW(), NOW())
|
||||
ON CONFLICT (user_id, date_key) DO UPDATE SET
|
||||
total_tokens = coach_usage.total_tokens + ${reserved},
|
||||
operator_tokens = coach_usage.operator_tokens + ${operatorReserved},
|
||||
message_count = coach_usage.message_count + 1,
|
||||
message_count = coach_usage.message_count + ${messageBump},
|
||||
updated_at = NOW()
|
||||
RETURNING total_tokens, operator_tokens
|
||||
`;
|
||||
@@ -349,7 +356,14 @@ export async function reserveBudget(
|
||||
if (limit !== null) {
|
||||
// Already over before this request — refund the reservation + the
|
||||
// message-count bump and refuse.
|
||||
await refundReservation(userId, reserved, operatorReserved, dateKey, db);
|
||||
await refundReservation(
|
||||
userId,
|
||||
reserved,
|
||||
operatorReserved,
|
||||
dateKey,
|
||||
db,
|
||||
messageBump,
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
reserved,
|
||||
@@ -451,12 +465,13 @@ async function refundReservation(
|
||||
operatorReserved: number,
|
||||
dateKey: string,
|
||||
db: BudgetExecutor = prisma,
|
||||
messageBump = 1,
|
||||
): Promise<void> {
|
||||
await db.$executeRaw`
|
||||
UPDATE coach_usage
|
||||
SET total_tokens = GREATEST(0, total_tokens - ${reserved}),
|
||||
operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}),
|
||||
message_count = GREATEST(0, message_count - 1),
|
||||
message_count = GREATEST(0, message_count - ${messageBump}),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = ${userId} AND date_key = ${dateKey}
|
||||
`;
|
||||
|
||||
+345
-1
@@ -29,19 +29,26 @@ import {
|
||||
type LatestMessage,
|
||||
type LatestMessagesLoader,
|
||||
} from "@/lib/ai/coach/latest-messages";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
type CoachAssumption,
|
||||
type CoachClarification,
|
||||
type CoachClarificationChoice,
|
||||
type CoachClarificationKind,
|
||||
type CoachComparisonBasis,
|
||||
type CoachScopeSource,
|
||||
type CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { coachClarificationSchema } from "@/lib/ai/coach/stream-events";
|
||||
import {
|
||||
COACH_FOLLOW_UP_KEYS,
|
||||
clarifyWindowLabelKey,
|
||||
coachDomainLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
@@ -183,7 +190,14 @@ function extractBlock(prose: string): {
|
||||
}
|
||||
|
||||
function isKind(value: string | null): value is ClarifyKind {
|
||||
return value === "metric" || value === "window" || value === "context";
|
||||
return (
|
||||
value === "metric" ||
|
||||
value === "window" ||
|
||||
value === "comparison" ||
|
||||
value === "goal" ||
|
||||
value === "anchor" ||
|
||||
value === "context"
|
||||
);
|
||||
}
|
||||
|
||||
function metricChoices(
|
||||
@@ -349,6 +363,14 @@ function clarifiedLine(choice: CoachClarificationChoice): string {
|
||||
const parts: string[] = [];
|
||||
if (choice.value.metric) parts.push(`metric=${choice.value.metric}`);
|
||||
if (choice.value.window) parts.push(`window=${choice.value.window}`);
|
||||
if (choice.value.comparison) {
|
||||
parts.push(`comparison=${choice.value.comparison}`);
|
||||
}
|
||||
// A goal or an anchor is an id the model never saw; the server-rendered
|
||||
// label says what it stands for.
|
||||
if (choice.value.goal || choice.value.anchor) {
|
||||
parts.push(`"${choice.label}"`);
|
||||
}
|
||||
return `CLARIFIED: the person answered your clarifying question by choosing ${parts.join(" ")}. Answer the original question with exactly this; do not ask again.`;
|
||||
}
|
||||
|
||||
@@ -402,3 +424,325 @@ export async function resolveClarificationAnswer(args: {
|
||||
});
|
||||
return choice ? clarifiedLine(choice) : FREE_TEXT_LINE;
|
||||
}
|
||||
|
||||
// ── The ask_clarification tool (v1.41) ──────────────────────────────────
|
||||
|
||||
/** A clarifying question asked through the tool, at most this long. */
|
||||
export const CLARIFY_TOOL_QUESTION_MAX = 200;
|
||||
|
||||
/** At most one question in this many turns of a conversation. */
|
||||
export const CLARIFY_TURN_SPACING = 6;
|
||||
/** At most this many questions a day, across every conversation. */
|
||||
export const CLARIFY_DAILY_LIMIT = 3;
|
||||
|
||||
/** The catalog of comparison bases, each with its reply label. */
|
||||
const COMPARISON_LABEL_KEY: Readonly<Record<CoachComparisonBasis, string>> = {
|
||||
previous_period: COACH_FOLLOW_UP_KEYS.previous_period,
|
||||
year_ago: COACH_FOLLOW_UP_KEYS.year_ago,
|
||||
// Pending until integration copies it into the bundles.
|
||||
baseline_90d: "coach.clarify.comparison.baseline90d",
|
||||
};
|
||||
|
||||
/** A pending key: "Since {date}", an illness episode as an anchor. */
|
||||
const ANCHOR_ILLNESS_KEY = "coach.clarify.anchor.illness";
|
||||
|
||||
/** A choice the server built from the record: an id and a catalog label. */
|
||||
export interface ClarifyRecordChoice {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** What the model passed to `ask_clarification`, after its schema. */
|
||||
export interface ClarifyToolCall {
|
||||
kind: CoachClarificationKind;
|
||||
question: string;
|
||||
choices?: string[];
|
||||
assumption?: string;
|
||||
}
|
||||
|
||||
export type ClarifyToolOutcome =
|
||||
| { ok: true; question: string; clarification: CoachClarification }
|
||||
| { ok: false; reason: ClarifyDropReason | "no_record_choices" };
|
||||
|
||||
function comparisonChoices(
|
||||
tokens: readonly string[],
|
||||
locale: Locale,
|
||||
): CoachClarificationChoice[] {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const seen = new Set<CoachComparisonBasis>();
|
||||
const choices: CoachClarificationChoice[] = [];
|
||||
for (const token of tokens) {
|
||||
const basis = normaliseToken(token) as CoachComparisonBasis;
|
||||
if (!Object.hasOwn(COMPARISON_LABEL_KEY, basis) || seen.has(basis)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(basis);
|
||||
const labelKey = COMPARISON_LABEL_KEY[basis];
|
||||
choices.push({
|
||||
id: `c${choices.length + 1}`,
|
||||
labelKey,
|
||||
label: t(labelKey),
|
||||
value: { comparison: basis },
|
||||
});
|
||||
if (choices.length === CLARIFY_MAX_CHOICES) break;
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
function recordChoices(
|
||||
candidates: readonly ClarifyRecordChoice[],
|
||||
field: "goal" | "anchor",
|
||||
): CoachClarificationChoice[] {
|
||||
return candidates.slice(0, CLARIFY_MAX_CHOICES).map((candidate, index) => ({
|
||||
id: `c${index + 1}`,
|
||||
labelKey: candidate.labelKey,
|
||||
label: candidate.label,
|
||||
value: { [field]: candidate.id },
|
||||
}));
|
||||
}
|
||||
|
||||
/** The assumed choice first, ids renumbered `c1`... */
|
||||
function assumedFirst(
|
||||
choices: CoachClarificationChoice[],
|
||||
assumption: string | undefined,
|
||||
): CoachClarificationChoice[] {
|
||||
if (choices.length === 0) return choices;
|
||||
const token = assumption ? normaliseToken(assumption) : null;
|
||||
const matches = (choice: CoachClarificationChoice): boolean => {
|
||||
if (!token) return false;
|
||||
const v = choice.value;
|
||||
return (
|
||||
v.metric === toSource(token) ||
|
||||
v.window === WINDOW_BY_TOKEN.get(token) ||
|
||||
v.comparison === token ||
|
||||
v.goal === assumption ||
|
||||
v.anchor === assumption
|
||||
);
|
||||
};
|
||||
const index = Math.max(0, choices.findIndex(matches));
|
||||
const ordered = [choices[index], ...choices.filter((_, i) => i !== index)];
|
||||
return ordered.map((choice, i) => ({ ...choice, id: `c${i + 1}` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an `ask_clarification` call into the question and its choices.
|
||||
* The same rules as the sentinel: catalog labels only, metrics the record
|
||||
* holds, a screened question that is a question. `goal` and `anchor`
|
||||
* choices are never the model's: the server lists them from the record, and
|
||||
* without two of them there is nothing to ask.
|
||||
*/
|
||||
export function buildClarificationFromTool(args: {
|
||||
call: ClarifyToolCall;
|
||||
inventory: InventoryEntry[] | null;
|
||||
locale: Locale;
|
||||
goals?: readonly ClarifyRecordChoice[];
|
||||
anchors?: readonly ClarifyRecordChoice[];
|
||||
}): ClarifyToolOutcome {
|
||||
const { call, locale } = args;
|
||||
const kind = call.kind;
|
||||
const drop = (reason: ClarifyDropReason | "no_record_choices") => {
|
||||
annotate({
|
||||
action: { name: "coach.clarification.dropped" },
|
||||
meta: { reason, kind, via: "tool" },
|
||||
});
|
||||
return { ok: false as const, reason };
|
||||
};
|
||||
const question = call.question.replace(/\s+/g, " ").trim();
|
||||
if (!question || !hasQuestionMark(question)) return drop("no_question");
|
||||
if (question.length > CLARIFY_TOOL_QUESTION_MAX) {
|
||||
return drop("question_too_long");
|
||||
}
|
||||
if (
|
||||
screenCoachReply(question, locale).block ||
|
||||
detectRefusal({ message: question, locale }).refuse
|
||||
) {
|
||||
return drop("screened");
|
||||
}
|
||||
const tokens = call.choices ?? [];
|
||||
let choices: CoachClarificationChoice[] = [];
|
||||
switch (kind) {
|
||||
case "metric":
|
||||
if (!args.inventory) return drop("no_inventory");
|
||||
choices = metricChoices(
|
||||
tokens,
|
||||
presentSources(args.inventory),
|
||||
locale,
|
||||
);
|
||||
if (choices.length < 2) return drop("too_few_metrics");
|
||||
break;
|
||||
case "window":
|
||||
choices = windowChoices(tokens, locale);
|
||||
if (choices.length < 2) return drop("no_window_choices");
|
||||
break;
|
||||
case "comparison":
|
||||
choices = comparisonChoices(tokens, locale);
|
||||
if (choices.length < 2) return drop("malformed");
|
||||
break;
|
||||
case "goal":
|
||||
choices = recordChoices(args.goals ?? [], "goal");
|
||||
if (choices.length < 2) return drop("no_record_choices");
|
||||
break;
|
||||
case "anchor":
|
||||
choices = recordChoices(args.anchors ?? [], "anchor");
|
||||
if (choices.length < 2) return drop("no_record_choices");
|
||||
break;
|
||||
case "context":
|
||||
break;
|
||||
}
|
||||
choices = assumedFirst(choices, call.assumption);
|
||||
annotate({
|
||||
action: { name: "coach.clarification.offered" },
|
||||
meta: { kind, choices: choices.length, via: "tool" },
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
question,
|
||||
clarification: {
|
||||
kind,
|
||||
choices,
|
||||
freeText: true,
|
||||
...(choices.length > 0 ? { assumption: "c1" } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The assumption a question stands for when it is not asked (declined by
|
||||
* the brake): its assumed choice, with the others as alternatives. Only the
|
||||
* kinds the answer can carry an assumption line for.
|
||||
*/
|
||||
export function assumptionFromClarification(
|
||||
clarification: CoachClarification,
|
||||
): CoachAssumption | null {
|
||||
if (
|
||||
clarification.kind !== "metric" &&
|
||||
clarification.kind !== "window" &&
|
||||
clarification.kind !== "comparison"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [assumed, ...rest] = clarification.choices;
|
||||
if (!assumed) return null;
|
||||
const option = (choice: CoachClarificationChoice) => ({
|
||||
labelKey: choice.labelKey,
|
||||
label: choice.label,
|
||||
value: choice.value,
|
||||
});
|
||||
return {
|
||||
kind: clarification.kind,
|
||||
value: option(assumed),
|
||||
alternatives: rest.slice(0, 3).map(option),
|
||||
};
|
||||
}
|
||||
|
||||
/** `BLOOD_PRESSURE` → `bp`, `WEIGHT` → `weight`; null when there is no match. */
|
||||
function planMetricSource(metric: string): CoachScopeSource | null {
|
||||
const token = metric.trim().toLowerCase();
|
||||
if (token === "blood_pressure") return "bp";
|
||||
return SCOPE_SOURCES.has(token) ? (token as CoachScopeSource) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The choices a `goal` or `anchor` question may offer, from the record:
|
||||
* the person's active plans by the metric each moves, and the illness
|
||||
* episodes that began most recently. Labels are catalog text and a date;
|
||||
* no plan or episode text reaches them.
|
||||
*/
|
||||
export async function loadClarifyRecordChoices(args: {
|
||||
userId: string;
|
||||
kind: "goal" | "anchor";
|
||||
locale: Locale;
|
||||
}): Promise<ClarifyRecordChoice[]> {
|
||||
const { t } = getServerTranslator(args.locale);
|
||||
try {
|
||||
if (args.kind === "goal") {
|
||||
const plans = await prisma.coachPlan.findMany({
|
||||
where: { userId: args.userId, status: "active", deletedAt: null },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 8,
|
||||
select: { id: true, metric: true },
|
||||
});
|
||||
const seen = new Set<CoachScopeSource>();
|
||||
const out: ClarifyRecordChoice[] = [];
|
||||
for (const plan of plans) {
|
||||
const source = planMetricSource(plan.metric);
|
||||
if (!source || seen.has(source)) continue;
|
||||
seen.add(source);
|
||||
const labelKey = coachDomainLabelKey(source);
|
||||
out.push({ id: plan.id, labelKey, label: t(labelKey) });
|
||||
}
|
||||
return out.slice(0, CLARIFY_MAX_CHOICES);
|
||||
}
|
||||
if (!(await isModuleEnabled(args.userId, "illness"))) return [];
|
||||
const episodes = await prisma.illnessEpisode.findMany({
|
||||
where: { userId: args.userId, deletedAt: null },
|
||||
orderBy: { onsetAt: "desc" },
|
||||
take: 3,
|
||||
select: { id: true, onsetAt: true },
|
||||
});
|
||||
const format = new Intl.DateTimeFormat(resolveIntlLocale(args.locale), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return episodes.map((episode) => ({
|
||||
id: episode.id,
|
||||
labelKey: ANCHOR_ILLNESS_KEY,
|
||||
label: t(ANCHOR_ILLNESS_KEY, { date: format.format(episode.onsetAt) }),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The brake on questions, checked before one is asked: never two in a row,
|
||||
* at most one in `CLARIFY_TURN_SPACING` turns of a conversation, and at most
|
||||
* `CLARIFY_DAILY_LIMIT` a day across all of them. Over it, the question
|
||||
* becomes an assumption and the turn goes on. Unverifiable counts as over:
|
||||
* not asking is the safe side.
|
||||
*/
|
||||
export async function clarificationAllowed(args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
now?: Date;
|
||||
}): Promise<boolean> {
|
||||
const now = args.now ?? new Date();
|
||||
try {
|
||||
const recent = await prisma.coachMessage.findMany({
|
||||
where: {
|
||||
conversationId: args.conversationId,
|
||||
conversation: { userId: args.userId },
|
||||
role: "assistant",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: CLARIFY_TURN_SPACING - 1,
|
||||
select: { metricSourceJson: true, providerType: true },
|
||||
});
|
||||
if (
|
||||
recent.some(
|
||||
(m) =>
|
||||
m.providerType !== "cancelled" &&
|
||||
storedClarification(m.metricSourceJson) !== null,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const dayStart = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
const today = await prisma.coachMessage.count({
|
||||
where: {
|
||||
conversation: { userId: args.userId },
|
||||
role: "assistant",
|
||||
createdAt: { gte: dayStart },
|
||||
metricSourceJson: { contains: '"clarification":' },
|
||||
},
|
||||
});
|
||||
return today < CLARIFY_DAILY_LIMIT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
CoachMethod,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStreamEvent,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { COACH_FOLLOW_UP_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import { POST } from "@/app/api/insights/chat/route";
|
||||
@@ -125,6 +126,20 @@ const call = (name: string, args: Record<string, unknown> = {}) => ({
|
||||
args,
|
||||
});
|
||||
|
||||
/** Ten distinct reads: with `get_sleep` first, eleven fetching rounds. */
|
||||
const FORCED_READS: ReadonlyArray<[string, string]> = [
|
||||
["pulse", "last7days"],
|
||||
["pulse", "last30days"],
|
||||
["pulse", "last90days"],
|
||||
["pulse", "lastYear"],
|
||||
["bp", "last7days"],
|
||||
["bp", "last30days"],
|
||||
["bp", "last90days"],
|
||||
["bp", "lastYear"],
|
||||
["sleep", "last90days"],
|
||||
["sleep", "lastYear"],
|
||||
];
|
||||
|
||||
function script(name: string, lang: Lang): Round[] {
|
||||
switch (name) {
|
||||
case "show-as-chart":
|
||||
@@ -185,18 +200,14 @@ function script(name: string, lang: Lang): Round[] {
|
||||
case "as-chart-chip":
|
||||
return [];
|
||||
case "forced-final":
|
||||
// v1.41 — a new read every round, never a repeat, until the round cap
|
||||
// of the person's own plan (twelve, the forced answer included) makes
|
||||
// the twelfth round the answer.
|
||||
return [
|
||||
{ calls: [call("get_sleep")] },
|
||||
{
|
||||
calls: [
|
||||
call("get_metric_table", { metric: "pulse", window: "last30days" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
calls: [
|
||||
call("get_metric_table", { metric: "bp", window: "last30days" }),
|
||||
],
|
||||
},
|
||||
...FORCED_READS.map(([metric, window]) => ({
|
||||
calls: [call("get_metric_table", { metric, window })],
|
||||
})),
|
||||
{ text: PROSE.forced[lang] },
|
||||
];
|
||||
case "fenced-title":
|
||||
@@ -343,18 +354,36 @@ function resultsOf(frames: Run["frames"]): CoachResultTable[] {
|
||||
}
|
||||
|
||||
/** The order the wire promises: steps, tokens, provenance, then the rest. */
|
||||
/**
|
||||
* The promised frame order. The live frames share one rank: `step` and
|
||||
* `activity` interleave while the turn runs, and a table read mid-turn goes
|
||||
* out at once as an interim `result`.
|
||||
*/
|
||||
const FRAME_ORDER = [
|
||||
"step",
|
||||
"live",
|
||||
"token",
|
||||
"provenance",
|
||||
"result",
|
||||
"suggestion",
|
||||
"suggestedAction",
|
||||
"memoryNote",
|
||||
"planProposal",
|
||||
"clarification",
|
||||
"followUps",
|
||||
"done",
|
||||
];
|
||||
|
||||
function frameRank(frame: CoachStreamEvent): number {
|
||||
if (
|
||||
frame.type === "step" ||
|
||||
frame.type === "activity" ||
|
||||
(frame.type === "result" && frame.interim === true)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return FRAME_ORDER.indexOf(frame.type);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(h.NOW);
|
||||
h.resetDialog();
|
||||
@@ -386,7 +415,7 @@ describe.each(COACH_SCENARIOS.map((s) => [s.id, s] as const))(
|
||||
expect(evaluateScenario(scenario, observation)).toEqual([]);
|
||||
|
||||
// The frames arrive in the promised order.
|
||||
const order = frames.map((f) => FRAME_ORDER.indexOf(f.type));
|
||||
const order = frames.map(frameRank);
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
|
||||
@@ -672,9 +701,7 @@ describe.each(["de", "en"] as const)("in %s", (lang) => {
|
||||
const scenario = byName("forced-final", lang);
|
||||
const { observation, frames } = await run(scenario);
|
||||
expect(providerCalls.map((c) => c.toolChoice)).toEqual([
|
||||
"auto",
|
||||
"auto",
|
||||
"auto",
|
||||
...Array.from({ length: 11 }, () => "auto"),
|
||||
"none",
|
||||
]);
|
||||
const provenance = framesOf<{ metricSource: { forcedFinal?: boolean } }>(
|
||||
|
||||
@@ -34,7 +34,10 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { COACH_FOLLOW_UP_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type {
|
||||
CoachAssumption,
|
||||
CoachFollowUp,
|
||||
CoachResultMeta,
|
||||
CoachScopeSource,
|
||||
@@ -401,3 +404,33 @@ export async function readFollowUpHistory(args: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Changing an assumption (v1.41) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The chips that change an assumption the answer made instead of asking:
|
||||
* one per alternative, "Instead: last 90 days". They lead the chips, since
|
||||
* they answer the same question differently, and the reply's chip cap still
|
||||
* applies. The label is catalog text with the alternative's own catalog
|
||||
* label; the value is read back from the stored chip when it is tapped.
|
||||
*/
|
||||
export function assumptionFollowUps(args: {
|
||||
assumptions: readonly CoachAssumption[];
|
||||
prefs: CoachPrefs;
|
||||
locale: Locale;
|
||||
}): CoachFollowUp[] {
|
||||
if (!followUpChipsEnabled(args.prefs)) return [];
|
||||
const { t } = getServerTranslator(args.locale);
|
||||
const labelKey = COACH_FOLLOW_UP_KEYS.change_assumption;
|
||||
return args.assumptions.flatMap((assumption) =>
|
||||
assumption.alternatives.map((alternative) => ({
|
||||
id: "f0",
|
||||
kind: "change_assumption" as const,
|
||||
labelKey,
|
||||
label: t(labelKey, { value: alternative.label }),
|
||||
reuse: false,
|
||||
origin: "server" as const,
|
||||
assumption: { kind: assumption.kind, value: alternative.value },
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -129,9 +129,20 @@ export function followUpContextHint(
|
||||
case "continue":
|
||||
// Answered with its own context lines (`continue.ts`).
|
||||
return null;
|
||||
case "change_assumption":
|
||||
// v1.41 — the contract is in place; no chip of this kind is offered yet.
|
||||
return null;
|
||||
case "change_assumption": {
|
||||
// v1.41 — the answer assumed a metric, a window or a comparison; the
|
||||
// person picked another. Every value is a catalog token read from the
|
||||
// stored chip.
|
||||
const value = followUp.assumption?.value;
|
||||
if (!value) return null;
|
||||
const picked = [
|
||||
value.metric ? `metric=${value.metric}` : null,
|
||||
value.window ? `window=${value.window}` : null,
|
||||
value.comparison ? `comparison=${value.comparison}` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
if (picked.length === 0) return null;
|
||||
return `${lead} Your last answer assumed a ${followUp.assumption?.kind}; the person chose ${picked.join(" ")} instead. Answer their previous question again with exactly this, and do not ask about it.${from}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,30 @@ import type { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
|
||||
import {
|
||||
coachActivityMetaSchema,
|
||||
coachAssumptionSchema,
|
||||
coachClarificationSchema,
|
||||
coachFollowUpSchema,
|
||||
coachMemoryNoteMetaSchema,
|
||||
coachMethodSchema,
|
||||
coachPlanProposalMetaSchema,
|
||||
coachResultMetaSchema,
|
||||
coachResultTableSchema,
|
||||
coachStepSchema,
|
||||
coachStopSchema,
|
||||
coachTrailSchema,
|
||||
} from "./stream-events";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
TRAIL_MAX_BYTES,
|
||||
} from "./activity/contract";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
|
||||
import { isRedundantViewChip } from "./follow-ups/view-chip";
|
||||
import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/refs";
|
||||
import {
|
||||
MAX_RESULTS_PER_TURN,
|
||||
RESULTS_MAX_BYTES,
|
||||
fitResultsToStorage,
|
||||
} from "./results/refs";
|
||||
import {
|
||||
isCheckupIntervalId,
|
||||
isSuggestedActionType,
|
||||
@@ -47,6 +61,7 @@ import type {
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
CoachSuggestion,
|
||||
CoachTrail,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@@ -76,8 +91,8 @@ export function summariseTitle(input: string): string {
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */
|
||||
const MAX_RESULTS_PER_MESSAGE = 6;
|
||||
/** At most this many tables per message (`r1`..`r8`, v1.41). */
|
||||
const MAX_RESULTS_PER_MESSAGE = MAX_RESULTS_PER_TURN;
|
||||
|
||||
function provenanceToJson(provenance: CoachProvenance | null): string | null {
|
||||
if (!provenance) return null;
|
||||
@@ -205,9 +220,16 @@ function restoreEach<T>(
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/** The live steps a turn persisted (at most 12 per turn). */
|
||||
/** One field of the provenance against its wire schema, or undefined. */
|
||||
function restoreOne<T>(raw: unknown, schema: z.ZodType<T>): T | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
const parsed = schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/** The live steps a turn persisted (at most 48 per turn since v1.41). */
|
||||
function restoreSteps(raw: unknown): CoachStep[] | undefined {
|
||||
return restoreEach(raw, coachStepSchema, 12);
|
||||
return restoreEach(raw, coachStepSchema, 48);
|
||||
}
|
||||
|
||||
/** The method line; dropped whole when it does not parse. */
|
||||
@@ -216,7 +238,7 @@ function restoreMethod(raw: unknown): CoachMethod | undefined {
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/** The tables' metadata (at most 6 per message). No values live here. */
|
||||
/** The tables' metadata (at most 8 per message). No values live here. */
|
||||
function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined {
|
||||
return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE);
|
||||
}
|
||||
@@ -331,6 +353,24 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
);
|
||||
const clarification = restoreClarification(parsed.clarification);
|
||||
const forcedFinal = parsed.forcedFinal === true;
|
||||
// v1.41 — the trail's metadata, the stop, the assumptions, the fact
|
||||
// note and the plan proposal, each held to its wire schema.
|
||||
const activity = restoreEach(
|
||||
parsed.activity,
|
||||
coachActivityMetaSchema,
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
);
|
||||
const stop = restoreOne(parsed.stop, coachStopSchema);
|
||||
const assumptions = restoreEach(
|
||||
parsed.assumptions,
|
||||
coachAssumptionSchema,
|
||||
2,
|
||||
);
|
||||
const memoryNote = restoreOne(parsed.memoryNote, coachMemoryNoteMetaSchema);
|
||||
const planProposal = restoreOne(
|
||||
parsed.planProposal,
|
||||
coachPlanProposalMetaSchema,
|
||||
);
|
||||
const continuationOf =
|
||||
typeof parsed.continuationOf === "string" &&
|
||||
parsed.continuationOf.length > 0 &&
|
||||
@@ -354,6 +394,11 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
...(clarification ? { clarification } : {}),
|
||||
...(forcedFinal ? { forcedFinal: true as const } : {}),
|
||||
...(continuationOf ? { continuationOf } : {}),
|
||||
...(activity ? { activity } : {}),
|
||||
...(stop ? { stop } : {}),
|
||||
...(assumptions ? { assumptions } : {}),
|
||||
...(memoryNote ? { memoryNote } : {}),
|
||||
...(planProposal ? { planProposal } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -396,10 +441,43 @@ export interface AppendMessageParams {
|
||||
* or empty on every turn without a table.
|
||||
*/
|
||||
results?: CoachResultTable[];
|
||||
/**
|
||||
* v1.41 — the model text of the turn's trail and the facts it touched.
|
||||
* Encrypted into `trailEncrypted`; the structure rides
|
||||
* `metricSource.activity`. Omitted on every turn without one.
|
||||
*/
|
||||
trail?: CoachTrail | null;
|
||||
}
|
||||
|
||||
export { RESULTS_MAX_BYTES };
|
||||
|
||||
/**
|
||||
* Serialise a turn's trail for the ciphertext column, or null when there is
|
||||
* none. Over `TRAIL_MAX_BYTES` (the recorder already holds it under): the
|
||||
* texts go first, then the titles, and a trail that still does not fit is
|
||||
* not stored at all rather than cut mid-entry.
|
||||
*/
|
||||
function trailToBytes(
|
||||
trail: CoachTrail | null | undefined,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (!trail) return null;
|
||||
const parsed = coachTrailSchema.safeParse(trail);
|
||||
if (!parsed.success) return null;
|
||||
const copy: CoachTrail = {
|
||||
...parsed.data,
|
||||
entries: parsed.data.entries.map((entry) => ({ ...entry })),
|
||||
};
|
||||
const size = () => new TextEncoder().encode(JSON.stringify(copy)).byteLength;
|
||||
for (const field of ["text", "title"] as const) {
|
||||
for (const entry of copy.entries) {
|
||||
if (size() <= TRAIL_MAX_BYTES) break;
|
||||
delete entry[field];
|
||||
}
|
||||
}
|
||||
if (size() > TRAIL_MAX_BYTES) return null;
|
||||
return encryptToBytes(JSON.stringify(copy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a turn's tables for the ciphertext column, or null when there are
|
||||
* none. The turn already fitted them (`fitResultsToStorage`, before it
|
||||
@@ -534,6 +612,7 @@ export async function appendMessage(
|
||||
tokensUsed: params.tokensUsed ?? null,
|
||||
model: params.model ?? null,
|
||||
resultsEncrypted: resultsToBytes(params.results),
|
||||
trailEncrypted: trailToBytes(params.trail),
|
||||
},
|
||||
});
|
||||
await tx.coachConversation.update({
|
||||
@@ -676,7 +755,8 @@ export async function fetchConversationWithMessages(
|
||||
take: CONVERSATION_MESSAGE_DETAIL_CAP,
|
||||
// v1.39.4 — the tables are read lazily, one message at a time, through
|
||||
// `readMessageResults`; the detail read never loads their ciphertext.
|
||||
omit: { resultsEncrypted: true },
|
||||
// v1.41 — and the trail, read the same way (`readMessageTrail`).
|
||||
omit: { resultsEncrypted: true, trailEncrypted: true },
|
||||
},
|
||||
// v1.29.x (S7) — the LIVE attachment set (join → document label columns
|
||||
// only; the encrypted body is untouched), ordered by attach time. Always
|
||||
@@ -794,6 +874,42 @@ export async function readMessageResults(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — the stored trail text of one assistant message, for its owner.
|
||||
*
|
||||
* Narrowed like `readMessageResults`: the message must sit in a
|
||||
* conversation `userId` owns; a foreign or missing id is `null` (the route
|
||||
* answers 404). A message with no trail, or one whose ciphertext does not
|
||||
* decrypt or parse, reads as `{ trail: null }`: fail closed, never a
|
||||
* partial trail.
|
||||
*/
|
||||
export async function readMessageTrail(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
): Promise<{ trail: CoachTrail | null } | null> {
|
||||
const row = await prisma.coachMessage.findFirst({
|
||||
where: {
|
||||
id: messageId,
|
||||
conversationId,
|
||||
conversation: { userId },
|
||||
},
|
||||
select: { trailEncrypted: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
if (!row.trailEncrypted || row.trailEncrypted.byteLength === 0) {
|
||||
return { trail: null };
|
||||
}
|
||||
try {
|
||||
const parsed = coachTrailSchema.safeParse(
|
||||
JSON.parse(decryptFromBytes(row.trailEncrypted)),
|
||||
);
|
||||
return { trail: parsed.success ? parsed.data : null };
|
||||
} catch {
|
||||
return { trail: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt and parse a message's tables by ref; null when unreadable. */
|
||||
function decryptResultTables(
|
||||
bytes: Uint8Array | null,
|
||||
|
||||
@@ -301,9 +301,9 @@ describe("get_metric_table", () => {
|
||||
expect(result).toEqual({ present: false, reason: "invalid_arguments" });
|
||||
});
|
||||
|
||||
it("stops naming tables after the sixth", async () => {
|
||||
it("stops naming tables after the eighth", async () => {
|
||||
const refs = createResultRefAllocator();
|
||||
for (let i = 0; i < 6; i += 1) refs.next();
|
||||
for (let i = 0; i < 8; i += 1) refs.next();
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_table",
|
||||
|
||||
@@ -35,10 +35,21 @@ function meta(ref: string): CoachResultMeta {
|
||||
}
|
||||
|
||||
describe("createResultRefAllocator", () => {
|
||||
it("hands out r1..r6 and then nothing", () => {
|
||||
it("hands out r1..r8 and then nothing", () => {
|
||||
const refs = createResultRefAllocator();
|
||||
const issued = Array.from({ length: 8 }, () => refs.next());
|
||||
expect(issued).toEqual(["r1", "r2", "r3", "r4", "r5", "r6", null, null]);
|
||||
const issued = Array.from({ length: 10 }, () => refs.next());
|
||||
expect(issued).toEqual([
|
||||
"r1",
|
||||
"r2",
|
||||
"r3",
|
||||
"r4",
|
||||
"r5",
|
||||
"r6",
|
||||
"r7",
|
||||
"r8",
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +90,7 @@ describe("prior result names", () => {
|
||||
it("formats and parses m<k>.r<n>", () => {
|
||||
expect(formatPriorResultRef(3, "r2")).toBe("m3.r2");
|
||||
expect(parsePriorResultRef("m3.r2")).toEqual({ turnIndex: 3, ref: "r2" });
|
||||
expect(parsePriorResultRef("m3.r7")).toBeNull();
|
||||
expect(parsePriorResultRef("m3.r9")).toBeNull();
|
||||
expect(parsePriorResultRef("m0.r1")).toBeNull();
|
||||
expect(parsePriorResultRef("x3.r1")).toBeNull();
|
||||
expect(parsePriorResultRef("m3.r1; drop")).toBeNull();
|
||||
@@ -111,7 +122,7 @@ describe("stripResultRefs", () => {
|
||||
expect(out.prose).not.toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("is case-insensitive and ignores refs past r6", () => {
|
||||
it("is case-insensitive and ignores refs past r8", () => {
|
||||
const out = stripResultRefs("See RESULT:R3 and result:r9.");
|
||||
expect(out.referenced).toEqual(["r3"]);
|
||||
expect(out.prose).toBe("See and.");
|
||||
|
||||
@@ -208,7 +208,9 @@ function categorySpec(table: ChartInput): CoachChartSpec | null {
|
||||
* non-negative whole numbers.
|
||||
* 4. A distribution keeps the histogram it was built with
|
||||
* (`buildDistributionTable`); its bins cannot be read back from the rows.
|
||||
* 5. Anything else (a list of lab results): none.
|
||||
* 5. A comparison (v1.41, `compare_series`) keeps the `compare` chart it was
|
||||
* built with, while its columns still hold what the chart names.
|
||||
* 6. Anything else (a list of lab results): none.
|
||||
*/
|
||||
export function deriveChartSpec(table: ChartInput): CoachChartSpec | null {
|
||||
if (
|
||||
@@ -217,6 +219,7 @@ export function deriveChartSpec(table: ChartInput): CoachChartSpec | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (table.chart?.kind === "compare") return compareSpec(table, table.chart);
|
||||
switch (table.shape) {
|
||||
case "timeSeries":
|
||||
return timeSeriesSpec(table);
|
||||
@@ -229,6 +232,32 @@ export function deriveChartSpec(table: ChartInput): CoachChartSpec | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparison's chart, kept when the columns it names are there: the shared
|
||||
* period column and two numeric ones. Two axes only for two metrics.
|
||||
*/
|
||||
function compareSpec(
|
||||
table: ChartInput,
|
||||
spec: Extract<CoachChartSpec, { kind: "compare" }>,
|
||||
): CoachChartSpec | null {
|
||||
const x = table.columns.find((column) => column.key === spec.x);
|
||||
const a = table.columns.find((column) => column.key === spec.a);
|
||||
const b = table.columns.find((column) => column.key === spec.b);
|
||||
if (x?.kind !== "period" || a?.kind !== "number" || b?.kind !== "number") {
|
||||
return null;
|
||||
}
|
||||
const ia = columnIndex(table, spec.a);
|
||||
const ib = columnIndex(table, spec.b);
|
||||
const paired = table.rows.filter(
|
||||
(row) => isNumericCell(row[ia]) && isNumericCell(row[ib]),
|
||||
).length;
|
||||
if (paired < CHART_MIN_ROWS) return null;
|
||||
return {
|
||||
...spec,
|
||||
axes: spec.mode === "metrics" && spec.axes === 2 ? 2 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Histogram ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistogramBin {
|
||||
|
||||
@@ -15,13 +15,17 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { shiftDateKey, userDayKey, weekdayOfDateKey } from "@/lib/tz/format";
|
||||
import type {
|
||||
CoachResultCell,
|
||||
CoachResultColumn,
|
||||
CoachResultPeriod,
|
||||
CoachResultTable,
|
||||
CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_CHART_COMPARE_KEYS,
|
||||
COACH_RESULT_COLUMN_KEYS,
|
||||
COACH_RESULT_TITLE_KEYS,
|
||||
coachDomainLabelKey,
|
||||
coachPeriodLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
|
||||
export interface ProjectionContext {
|
||||
@@ -278,3 +282,165 @@ export function projectCompliance(
|
||||
chart: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Comparisons (v1.41) ─────────────────────────────────────────────────
|
||||
|
||||
/** The value columns of a comparison table. */
|
||||
export const COMPARE_COLUMN_A = "a";
|
||||
export const COMPARE_COLUMN_B = "b";
|
||||
|
||||
/** A comparison keeps at most this many rows, like every table. */
|
||||
const RESULT_COMPARE_MAX_ROWS = 400;
|
||||
|
||||
/** The first value column of a metric table: what a comparison draws. */
|
||||
function valueColumn(
|
||||
table: CoachResultTable,
|
||||
): { column: CoachResultColumn; index: number } | null {
|
||||
let index = table.columns.findIndex((column) => column.kind === "number");
|
||||
if (index < 0) {
|
||||
index = table.columns.findIndex(
|
||||
(column) => column.kind === "count" && column.key !== "readings",
|
||||
);
|
||||
}
|
||||
return index < 0 ? null : { column: table.columns[index], index };
|
||||
}
|
||||
|
||||
function periodIndex(table: CoachResultTable): number {
|
||||
return table.columns.findIndex((column) => column.kind === "period");
|
||||
}
|
||||
|
||||
function numericOrNull(cell: CoachResultCell | undefined): number | null {
|
||||
return typeof cell === "number" && Number.isFinite(cell) ? cell : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two series of one comparison as one table, `period | a | b`, with the
|
||||
* `compare` chart:
|
||||
*
|
||||
* - `periods`: the same metric over the current window (`a`) and an earlier
|
||||
* one (`b`, the period before or a year earlier), overlaid position by
|
||||
* position. The rows carry the current window's period keys; the earlier
|
||||
* value of each row is the one at the same place in its own window. One
|
||||
* axis: it is one metric in one unit.
|
||||
* - `metrics`: two metrics over the same window, joined on their period
|
||||
* keys. Two axes when their units differ, one when they match.
|
||||
*
|
||||
* Pure: both tables are ones this turn read, and every value is copied from
|
||||
* them unchanged. Null when either has no value column or the comparison
|
||||
* holds fewer than two rows with a value on both sides.
|
||||
*/
|
||||
export function projectCompare(args: {
|
||||
mode: "periods" | "metrics";
|
||||
a: CoachResultTable;
|
||||
b: CoachResultTable;
|
||||
/** The earlier window of a `periods` comparison. */
|
||||
basis?: Exclude<CoachResultPeriod, "current">;
|
||||
ref: string;
|
||||
locale: Locale;
|
||||
}): CoachResultTable | null {
|
||||
const { mode, a, b, locale } = args;
|
||||
const { t } = getServerTranslator(locale);
|
||||
const valueA = valueColumn(a);
|
||||
const valueB = valueColumn(b);
|
||||
const xA = periodIndex(a);
|
||||
const xB = periodIndex(b);
|
||||
if (!valueA || !valueB || xA < 0 || xB < 0) return null;
|
||||
|
||||
let rows: CoachResultCell[][];
|
||||
if (mode === "periods") {
|
||||
rows = a.rows.map((row, i) => [
|
||||
row[xA] ?? null,
|
||||
numericOrNull(row[valueA.index]),
|
||||
numericOrNull(b.rows[i]?.[valueB.index]),
|
||||
]);
|
||||
} else {
|
||||
const byKey = new Map<string, [number | null, number | null]>();
|
||||
for (const row of a.rows) {
|
||||
const key = row[xA];
|
||||
if (typeof key !== "string") continue;
|
||||
byKey.set(key, [numericOrNull(row[valueA.index]), null]);
|
||||
}
|
||||
for (const row of b.rows) {
|
||||
const key = row[xB];
|
||||
if (typeof key !== "string") continue;
|
||||
const entry = byKey.get(key) ?? [null, null];
|
||||
entry[1] = numericOrNull(row[valueB.index]);
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
rows = [...byKey.entries()]
|
||||
.sort(([k1], [k2]) => (k1 < k2 ? -1 : k1 > k2 ? 1 : 0))
|
||||
.map(([key, [va, vb]]) => [key, va, vb]);
|
||||
}
|
||||
const paired = rows.filter((row) => row[1] !== null && row[2] !== null);
|
||||
if (paired.length < 2) return null;
|
||||
|
||||
const domainA = a.source.domain;
|
||||
const domainB = b.source.domain;
|
||||
const basis = args.basis ?? "previous";
|
||||
const labelA =
|
||||
mode === "periods"
|
||||
? coachPeriodLabelKey("current")
|
||||
: coachDomainLabelKey(domainA);
|
||||
const labelB =
|
||||
mode === "periods" ? coachPeriodLabelKey(basis) : coachDomainLabelKey(domainB);
|
||||
const columns: CoachResultColumn[] = [
|
||||
{ ...a.columns[xA] },
|
||||
{
|
||||
key: COMPARE_COLUMN_A,
|
||||
kind: "number",
|
||||
labelKey: labelA,
|
||||
label: t(labelA),
|
||||
...(valueA.column.unit ? { unit: valueA.column.unit } : {}),
|
||||
...(valueA.column.decimals !== undefined
|
||||
? { decimals: valueA.column.decimals }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
key: COMPARE_COLUMN_B,
|
||||
kind: "number",
|
||||
labelKey: labelB,
|
||||
label: t(labelB),
|
||||
...(valueB.column.unit ? { unit: valueB.column.unit } : {}),
|
||||
...(valueB.column.decimals !== undefined
|
||||
? { decimals: valueB.column.decimals }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
const sameUnit = (valueA.column.unit ?? "") === (valueB.column.unit ?? "");
|
||||
const titleKey = COACH_CHART_COMPARE_KEYS.periods;
|
||||
const title =
|
||||
mode === "periods"
|
||||
? t(titleKey, { a: a.title, b: t(coachPeriodLabelKey(basis)) })
|
||||
: t(titleKey, {
|
||||
a: t(coachDomainLabelKey(domainA)),
|
||||
b: t(coachDomainLabelKey(domainB)),
|
||||
});
|
||||
const trimmed = rows.slice(-RESULT_COMPARE_MAX_ROWS);
|
||||
return {
|
||||
ref: args.ref,
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: domainA,
|
||||
window: a.source.window,
|
||||
period: "current",
|
||||
...(a.source.granularity ? { granularity: a.source.granularity } : {}),
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey,
|
||||
title,
|
||||
rowCount: rows.length,
|
||||
chartKind: "compare",
|
||||
displayed: false,
|
||||
columns,
|
||||
rows: trimmed,
|
||||
truncated: trimmed.length < rows.length,
|
||||
chart: {
|
||||
kind: "compare",
|
||||
mode,
|
||||
x: columns[0].key,
|
||||
a: COMPARE_COLUMN_A,
|
||||
b: COMPARE_COLUMN_B,
|
||||
axes: mode === "periods" || sameUnit ? 1 : 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Result references: the names a table goes by, inside one turn and across
|
||||
* the turns of one conversation.
|
||||
*
|
||||
* - `r<n>` — a table of the current message, `r1`..`r6`, handed out as the
|
||||
* - `r<n>` — a table of the current message, `r1`..`r8`, handed out as the
|
||||
* tool that produces it settles, so the model reads its own table's name
|
||||
* in the tool result it cites from.
|
||||
* - `m<k>.r<n>` — table `r<n>` of an earlier assistant message of the same
|
||||
@@ -19,19 +19,23 @@
|
||||
*/
|
||||
import type { CoachResultMeta, CoachResultTable } from "@/lib/ai/coach/types";
|
||||
|
||||
/** At most this many tables per message (`r1`..`r6`). */
|
||||
export const MAX_RESULTS_PER_TURN = 6;
|
||||
/**
|
||||
* At most this many tables per message (`r1`..`r8`). v1.41 — eight, up from
|
||||
* six: a budgeted turn reads over more rounds. A ninth table call still
|
||||
* gives the model its summary; there is just no table to name.
|
||||
*/
|
||||
export const MAX_RESULTS_PER_TURN = 8;
|
||||
|
||||
const TURN_REF = /^r([1-9]\d?)$/;
|
||||
const PRIOR_REF = /^m([1-9]\d{0,3})\.(r[1-9]\d?)$/;
|
||||
|
||||
/** True for a table name of the current message (`r1`..`r6`). */
|
||||
/** True for a table name of the current message (`r1`..`r8`). */
|
||||
export function isTurnResultRef(ref: string): boolean {
|
||||
const match = TURN_REF.exec(ref);
|
||||
return match !== null && Number(match[1]) <= MAX_RESULTS_PER_TURN;
|
||||
}
|
||||
|
||||
/** Hands out `r1`..`r6` in order, then null: a turn keeps at most six. */
|
||||
/** Hands out `r1`..`r8` in order, then null: a turn keeps at most eight. */
|
||||
export interface ResultRefAllocator {
|
||||
next(): string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* v1.41 — `compare_series`: two series of the record on one chart.
|
||||
*
|
||||
* Two kinds of comparison, each one call instead of two reads the model
|
||||
* would have to line up itself:
|
||||
*
|
||||
* - `periods`: one metric over the current window and an earlier one (the
|
||||
* period before, or the same window a year earlier), overlaid;
|
||||
* - `metrics`: two metrics over the same window side by side, on two axes
|
||||
* when their units differ.
|
||||
*
|
||||
* Both sides are read through the metric table tool itself
|
||||
* (`get_metric_table` in the executor), so every gate holds exactly as for
|
||||
* a single table: the modules, the metrics the person excluded from the
|
||||
* Coach, the lookback limit, the reasons a read comes back empty. The two
|
||||
* tables are read under a private name allocator and joined into one table
|
||||
* that takes the turn's next name; the model gets both summaries, and for
|
||||
* `periods` the change the server computed between them.
|
||||
*
|
||||
* Kept out of the executor and its tool catalogue on purpose: the MCP
|
||||
* surface runs the executor and is not part of this release.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AiToolDef } from "@/lib/ai/types";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
type CoachScope,
|
||||
type CoachScopeWindow,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createResultRefAllocator } from "@/lib/ai/coach/results/refs";
|
||||
import { projectCompare } from "@/lib/ai/coach/results/projections";
|
||||
|
||||
import { coachResultGranularitySchema } from "./definitions";
|
||||
import {
|
||||
executeCoachTool,
|
||||
type CoachToolResult,
|
||||
type CoachToolTurnContext,
|
||||
} from "./executor";
|
||||
|
||||
export const COMPARE_SERIES_TOOL_NAME = "compare_series";
|
||||
|
||||
export const compareSeriesArgsSchema = z
|
||||
.object({
|
||||
mode: z.enum(["periods", "metrics"]),
|
||||
metric: coachScopeSourceSchema,
|
||||
metricB: coachScopeSourceSchema.optional(),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
basis: z.enum(["previous", "yearAgo"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CompareSeriesArgs = z.infer<typeof compareSeriesArgsSchema>;
|
||||
|
||||
export const COMPARE_SERIES_TOOL_DEF: AiToolDef = {
|
||||
name: COMPARE_SERIES_TOOL_NAME,
|
||||
description:
|
||||
"Compare two series on one chart. mode=periods: ONE metric over the current window against the period before (basis=previous, default) or the same window a year earlier (basis=yearAgo), overlaid; it also returns the change the server computed. mode=metrics: TWO metrics (metric and metricB) over the same window side by side, for 'does my sleep go with my resting pulse?' questions; a pattern between two metrics is an association, never a cause. Use it for every comparison question instead of two separate reads. Returns both summaries and the table's name (resultRef); the person sees the comparison chart under your answer.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["mode", "metric"],
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["periods", "metrics"] },
|
||||
metric: {
|
||||
type: "string",
|
||||
enum: coachScopeSourceSchema.options,
|
||||
description: "The metric, or the first of two.",
|
||||
},
|
||||
metricB: {
|
||||
type: "string",
|
||||
enum: coachScopeSourceSchema.options,
|
||||
description: "mode=metrics only: the second metric.",
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
enum: coachScopeWindowSchema.options,
|
||||
description:
|
||||
"Analysis window, cut to the user's lookback limit like every window. allTime cannot be compared with an earlier period.",
|
||||
},
|
||||
granularity: {
|
||||
type: "string",
|
||||
enum: coachResultGranularitySchema.options,
|
||||
description: "One row per day, week or month; defaults by window.",
|
||||
},
|
||||
basis: {
|
||||
type: "string",
|
||||
enum: ["previous", "yearAgo"],
|
||||
description: "mode=periods only: what the current window is compared with.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function tableArgs(args: {
|
||||
metric: string;
|
||||
window: CoachScopeWindow | undefined;
|
||||
granularity: string | undefined;
|
||||
period: "current" | "previous" | "yearAgo";
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
metric: args.metric,
|
||||
...(args.window ? { window: args.window } : {}),
|
||||
...(args.granularity ? { granularity: args.granularity } : {}),
|
||||
period: args.period,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one `compare_series` call. Never throws; a call that does not validate
|
||||
* is `invalid_arguments`, a side that holds nothing reports that side's own
|
||||
* reason, and a comparison with too few shared points still gives the model
|
||||
* both summaries, with no chart.
|
||||
*/
|
||||
export async function runCompareSeries(args: {
|
||||
userId: string;
|
||||
rawArguments: string;
|
||||
fallbackWindow?: CoachScopeWindow;
|
||||
sharedScope?: CoachScope;
|
||||
reach: CoachHistoryReach;
|
||||
turn?: CoachToolTurnContext;
|
||||
}): Promise<CoachToolResult> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = args.rawArguments.trim() === "" ? {} : JSON.parse(args.rawArguments);
|
||||
} catch {
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
const parsed = compareSeriesArgsSchema.safeParse(raw);
|
||||
if (!parsed.success) return { present: false, reason: "invalid_arguments" };
|
||||
const call = parsed.data;
|
||||
if (call.mode === "metrics" && (!call.metricB || call.metricB === call.metric)) {
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
if (call.mode === "periods" && call.window === "allTime") {
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
|
||||
// Both sides are read as tables under a name allocator of their own: the
|
||||
// turn names only the joined table.
|
||||
const sideTurn: CoachToolTurnContext | undefined = args.turn
|
||||
? { ...args.turn, refs: createResultRefAllocator() }
|
||||
: undefined;
|
||||
const read = (metric: string, period: "current" | "previous" | "yearAgo") =>
|
||||
executeCoachTool({
|
||||
userId: args.userId,
|
||||
name: "get_metric_table",
|
||||
rawArguments: tableArgs({
|
||||
metric,
|
||||
window: call.window,
|
||||
granularity: call.granularity,
|
||||
period,
|
||||
}),
|
||||
fallbackWindow: args.fallbackWindow,
|
||||
sharedScope: args.sharedScope,
|
||||
reach: args.reach,
|
||||
...(sideTurn ? { turn: sideTurn } : {}),
|
||||
});
|
||||
|
||||
const basis = call.basis ?? "previous";
|
||||
const [first, second] =
|
||||
call.mode === "periods"
|
||||
? await Promise.all([read(call.metric, "current"), read(call.metric, basis)])
|
||||
: await Promise.all([
|
||||
read(call.metric, "current"),
|
||||
read(call.metricB as string, "current"),
|
||||
]);
|
||||
|
||||
if (!first.present) return first;
|
||||
const data: Record<string, unknown> = {
|
||||
mode: call.mode,
|
||||
...(call.mode === "periods" ? { basis } : {}),
|
||||
a: first.data,
|
||||
b: second.present
|
||||
? second.data
|
||||
: { present: false, reason: second.reason ?? "no_data" },
|
||||
};
|
||||
const joined =
|
||||
second.present && first.table && second.table && args.turn
|
||||
? projectCompare({
|
||||
mode: call.mode,
|
||||
a: first.table,
|
||||
b: second.table,
|
||||
...(call.mode === "periods" ? { basis } : {}),
|
||||
ref: "r0",
|
||||
locale: args.turn.locale,
|
||||
})
|
||||
: null;
|
||||
if (!joined || !args.turn) {
|
||||
annotate({
|
||||
action: { name: "coach.compare.no_chart" },
|
||||
meta: { mode: call.mode, bothSides: second.present },
|
||||
});
|
||||
return { present: true, data };
|
||||
}
|
||||
const ref = args.turn.refs.next();
|
||||
if (!ref) return { present: true, data };
|
||||
annotate({
|
||||
action: { name: "coach.compare.built" },
|
||||
meta: { mode: call.mode, rows: joined.rows.length, axes: joined.chart?.kind === "compare" ? joined.chart.axes : 1 },
|
||||
});
|
||||
return {
|
||||
present: true,
|
||||
resultRef: ref,
|
||||
data: { ...data, resultRef: ref },
|
||||
table: { ...joined, ref },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* v1.41 — the three dialog tools of a Coach turn: `ask_clarification`,
|
||||
* `remember_fact` and `propose_plan`.
|
||||
*
|
||||
* None of them reads the record. `ask_clarification` ends the turn with a
|
||||
* question instead of an answer, when the answer visibly depends on a choice
|
||||
* the conversation does not settle; the server decides whether the question
|
||||
* may be asked (the brake in `clarify.ts`) and builds its choices from
|
||||
* catalogs and the record, never from the model's words. `remember_fact` and
|
||||
* `propose_plan` go through the memory contract (`memory/contract.ts`),
|
||||
* which decides what is saved, what is only proposed and what is declined.
|
||||
*
|
||||
* Every call answers the model with a small JSON result, as a data tool
|
||||
* does, so a declined question or memory never breaks the turn: the model
|
||||
* reads why and goes on.
|
||||
*
|
||||
* Kept out of `executeCoachTool` on purpose: the MCP surface runs the
|
||||
* executor, and none of these may ever run there.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { AiToolDef } from "@/lib/ai/types";
|
||||
import {
|
||||
COACH_MEMORY_CATEGORIES,
|
||||
type CoachClarification,
|
||||
type CoachClarificationKind,
|
||||
type CoachMemoryNote,
|
||||
type CoachPlanProposal,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
PLAN_REVIEW_DAYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
proposePlanFromTool,
|
||||
rememberFactFromTool,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
import {
|
||||
CLARIFY_MAX_CHOICES,
|
||||
CLARIFY_TOOL_QUESTION_MAX,
|
||||
buildClarificationFromTool,
|
||||
clarificationAllowed,
|
||||
loadClarifyRecordChoices,
|
||||
} from "@/lib/ai/coach/clarify";
|
||||
import type { InventoryEntry } from "./inventory";
|
||||
|
||||
export const ASK_CLARIFICATION_TOOL_NAME = "ask_clarification";
|
||||
export const REMEMBER_FACT_TOOL_NAME = "remember_fact";
|
||||
export const PROPOSE_PLAN_TOOL_NAME = "propose_plan";
|
||||
|
||||
export const DIALOG_TOOL_NAMES = [
|
||||
ASK_CLARIFICATION_TOOL_NAME,
|
||||
REMEMBER_FACT_TOOL_NAME,
|
||||
PROPOSE_PLAN_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
export type DialogToolName = (typeof DIALOG_TOOL_NAMES)[number];
|
||||
|
||||
export function isDialogToolName(name: string): name is DialogToolName {
|
||||
return (DIALOG_TOOL_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** A question may be asked in the first two rounds: look first, then ask. */
|
||||
export const CLARIFY_LAST_ROUND = 2;
|
||||
|
||||
const CLARIFY_KINDS = [
|
||||
"metric",
|
||||
"window",
|
||||
"comparison",
|
||||
"goal",
|
||||
"anchor",
|
||||
"context",
|
||||
] as const satisfies readonly CoachClarificationKind[];
|
||||
|
||||
export const askClarificationArgsSchema = z
|
||||
.object({
|
||||
kind: z.enum(CLARIFY_KINDS),
|
||||
question: z.string().min(1).max(CLARIFY_TOOL_QUESTION_MAX),
|
||||
choices: z
|
||||
.array(z.string().min(1).max(64))
|
||||
.max(CLARIFY_MAX_CHOICES)
|
||||
.optional(),
|
||||
assumption: z.string().min(1).max(64).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const rememberFactArgsSchema = z
|
||||
.object({
|
||||
category: z.enum(COACH_MEMORY_CATEGORIES),
|
||||
fact: z.string().min(1).max(REMEMBER_FACT_MAX_CHARS),
|
||||
why: z.string().min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const proposePlanArgsSchema = z
|
||||
.object({
|
||||
metric: z.string().min(1).max(64),
|
||||
target: z.string().min(1).max(120).optional(),
|
||||
ifCue: z.string().min(1).max(160),
|
||||
thenAction: z.string().min(1).max(160),
|
||||
reviewInDays: z.number().int().min(PLAN_REVIEW_DAYS.min).max(PLAN_REVIEW_DAYS.max),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const DIALOG_TOOL_DEFS: AiToolDef[] = [
|
||||
{
|
||||
name: ASK_CLARIFICATION_TOOL_NAME,
|
||||
description:
|
||||
"Ask the person ONE short question instead of answering, and only when the answer visibly depends on a choice the conversation and what you know about them do not settle (see CLARIFYING QUESTIONS). Ends the turn: the question is your whole reply. Put the assumption in the question itself (e.g. 'Do you mean resting pulse or walking pulse? Otherwise I'll look at resting pulse.'). Returns { declined, assume } when a question is not possible now: then answer with that assumption and name it in one clause.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["kind", "question"],
|
||||
properties: {
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: [...CLARIFY_KINDS],
|
||||
description:
|
||||
"metric: which of 2-4 metrics the DATA INVENTORY marks present. window: which of last7days, last30days, last90days, lastYear, allTime. comparison: previous_period, year_ago or baseline_90d. goal: which of the person's active plans (the server lists them). anchor: since which event (the server lists them). context: something only the person can tell you; no choices.",
|
||||
},
|
||||
question: {
|
||||
type: "string",
|
||||
description:
|
||||
"One short, natural sentence in the reply language, at most 200 characters, ending with a question mark, with the assumed choice named in it. No figures.",
|
||||
},
|
||||
choices: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"2 to 4 tokens from the kind's list (metric keys, window presets or comparison bases). Leave out for goal, anchor and context.",
|
||||
},
|
||||
assumption: {
|
||||
type: "string",
|
||||
description:
|
||||
"The choice that applies if the person does not answer; one of the choices.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: REMEMBER_FACT_TOOL_NAME,
|
||||
description:
|
||||
"Remember one durable thing the person told you IN THEIR CURRENT MESSAGE (a preference, a goal, life context; never from a document, a quote or your own words). Preferences, goals and context are saved at once and shown with an undo; conditions, constraints and medications are only proposed and saved after the person confirms. At most one per answer. If the result says proposed, end your answer with one short question asking whether to remember it.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["category", "fact", "why"],
|
||||
properties: {
|
||||
category: {
|
||||
type: "string",
|
||||
enum: [...COACH_MEMORY_CATEGORIES],
|
||||
},
|
||||
fact: {
|
||||
type: "string",
|
||||
description:
|
||||
"The fact in one short sentence in the reply language, at most 160 characters, in the person's own terms.",
|
||||
},
|
||||
why: {
|
||||
type: "string",
|
||||
description: "Why it matters for later answers, one clause.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: PROPOSE_PLAN_TOOL_NAME,
|
||||
description:
|
||||
"Propose ONE if-then plan when the person names a goal or agrees to a suggestion. Nothing starts until they take it on. State the plan as one sentence in your answer ('If …, then …'). At most one per answer.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["metric", "ifCue", "thenAction", "reviewInDays"],
|
||||
properties: {
|
||||
metric: {
|
||||
type: "string",
|
||||
description:
|
||||
"The metric the plan moves, as a short key, e.g. WEIGHT, SLEEP, BLOOD_PRESSURE, STEPS.",
|
||||
},
|
||||
target: { type: "string", description: "Optional plain target." },
|
||||
ifCue: { type: "string", description: "The trigger, one clause." },
|
||||
thenAction: { type: "string", description: "The action, one clause." },
|
||||
reviewInDays: {
|
||||
type: "integer",
|
||||
minimum: PLAN_REVIEW_DAYS.min,
|
||||
maximum: PLAN_REVIEW_DAYS.max,
|
||||
description: "When to look back at it together, in days.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** What a turn hands the dialog tools. */
|
||||
export interface DialogToolContext {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
locale: Locale;
|
||||
/** The person's current message: the only text a fact may come from. */
|
||||
userMessage: string;
|
||||
/** What the record holds; null on the no-tools path. */
|
||||
inventory: InventoryEntry[] | null;
|
||||
/**
|
||||
* A window was set for this conversation (the header pill, a deep link):
|
||||
* a question about the window is never asked.
|
||||
*/
|
||||
conversationWindowSet: boolean;
|
||||
}
|
||||
|
||||
/** The outcome of a dialog call: the model's result, and what it produced. */
|
||||
export type DialogToolOutcome =
|
||||
| {
|
||||
kind: "ask";
|
||||
result: Record<string, unknown>;
|
||||
question: string;
|
||||
clarification: CoachClarification;
|
||||
}
|
||||
| { kind: "memory"; result: Record<string, unknown>; note: CoachMemoryNote }
|
||||
| {
|
||||
kind: "plan";
|
||||
result: Record<string, unknown>;
|
||||
proposal: CoachPlanProposal;
|
||||
}
|
||||
| {
|
||||
/** A valid question the brake turned into an assumption. */
|
||||
kind: "declined";
|
||||
result: Record<string, unknown>;
|
||||
clarification: CoachClarification;
|
||||
}
|
||||
| { kind: "none"; result: Record<string, unknown> };
|
||||
|
||||
function parseJson(raw: string): unknown {
|
||||
try {
|
||||
return raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const invalid = { present: false, reason: "invalid_arguments" } as const;
|
||||
|
||||
/**
|
||||
* Run one dialog call. `round` is the loop round it came in; `noted` and
|
||||
* `proposed` say whether this answer already saved a fact or proposed a
|
||||
* plan (one each per answer). Never throws.
|
||||
*/
|
||||
export async function runDialogTool(args: {
|
||||
name: DialogToolName;
|
||||
rawArguments: string;
|
||||
round: number;
|
||||
ctx: DialogToolContext;
|
||||
noted: boolean;
|
||||
proposed: boolean;
|
||||
}): Promise<DialogToolOutcome> {
|
||||
const { name, ctx } = args;
|
||||
const raw = parseJson(args.rawArguments);
|
||||
try {
|
||||
switch (name) {
|
||||
case ASK_CLARIFICATION_TOOL_NAME: {
|
||||
const parsed = askClarificationArgsSchema.safeParse(raw);
|
||||
if (!parsed.success) return { kind: "none", result: invalid };
|
||||
const call = parsed.data;
|
||||
const assume = call.assumption ?? call.choices?.[0] ?? null;
|
||||
const decline = (reason: string): DialogToolOutcome => ({
|
||||
kind: "none",
|
||||
result: { declined: reason, ...(assume ? { assume } : {}) },
|
||||
});
|
||||
if (args.round > CLARIFY_LAST_ROUND) return decline("late");
|
||||
if (call.kind === "window" && ctx.conversationWindowSet) {
|
||||
return decline("window_set");
|
||||
}
|
||||
const records =
|
||||
call.kind === "goal" || call.kind === "anchor"
|
||||
? await loadClarifyRecordChoices({
|
||||
userId: ctx.userId,
|
||||
kind: call.kind,
|
||||
locale: ctx.locale,
|
||||
})
|
||||
: [];
|
||||
const built = buildClarificationFromTool({
|
||||
call,
|
||||
inventory: ctx.inventory,
|
||||
locale: ctx.locale,
|
||||
...(call.kind === "goal" ? { goals: records } : {}),
|
||||
...(call.kind === "anchor" ? { anchors: records } : {}),
|
||||
});
|
||||
if (!built.ok) return decline("invalid");
|
||||
if (
|
||||
!(await clarificationAllowed({
|
||||
userId: ctx.userId,
|
||||
conversationId: ctx.conversationId,
|
||||
}))
|
||||
) {
|
||||
return {
|
||||
kind: "declined",
|
||||
result: {
|
||||
declined: "rate",
|
||||
assume: built.clarification.choices[0]?.label ?? assume,
|
||||
},
|
||||
clarification: built.clarification,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "ask",
|
||||
result: { asked: true },
|
||||
question: built.question,
|
||||
clarification: built.clarification,
|
||||
};
|
||||
}
|
||||
case REMEMBER_FACT_TOOL_NAME: {
|
||||
const parsed = rememberFactArgsSchema.safeParse(raw);
|
||||
if (!parsed.success) return { kind: "none", result: invalid };
|
||||
if (args.noted) {
|
||||
return { kind: "none", result: { declined: "one_per_answer" } };
|
||||
}
|
||||
const outcome = await rememberFactFromTool({
|
||||
userId: ctx.userId,
|
||||
conversationId: ctx.conversationId,
|
||||
userMessage: ctx.userMessage,
|
||||
call: parsed.data,
|
||||
});
|
||||
if (outcome.kind === "declined") {
|
||||
return { kind: "none", result: { declined: outcome.reason } };
|
||||
}
|
||||
return {
|
||||
kind: "memory",
|
||||
result: { [outcome.kind]: true, category: outcome.note.category },
|
||||
note: outcome.note,
|
||||
};
|
||||
}
|
||||
case PROPOSE_PLAN_TOOL_NAME: {
|
||||
const parsed = proposePlanArgsSchema.safeParse(raw);
|
||||
if (!parsed.success) return { kind: "none", result: invalid };
|
||||
if (args.proposed) {
|
||||
return { kind: "none", result: { declined: "one_per_answer" } };
|
||||
}
|
||||
const outcome = await proposePlanFromTool({
|
||||
userId: ctx.userId,
|
||||
conversationId: ctx.conversationId,
|
||||
call: parsed.data,
|
||||
});
|
||||
if (outcome.kind === "declined") {
|
||||
return { kind: "none", result: { declined: outcome.reason } };
|
||||
}
|
||||
return {
|
||||
kind: "plan",
|
||||
result: {
|
||||
proposed: true,
|
||||
reviewInDays: outcome.proposal.reviewInDays,
|
||||
},
|
||||
proposal: outcome.proposal,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { kind: "none", result: { declined: "unavailable" } };
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ export interface CoachToolResult {
|
||||
*/
|
||||
available?: CoachDomainAvailability;
|
||||
/**
|
||||
* v1.39.4 — the name of the table this call produced (`r1`..`r6`), so the
|
||||
* v1.39.4 — the name of the table this call produced (`r1`..`r8`), so the
|
||||
* model can mark the prose that relies on it with `result:rN`. Only in a
|
||||
* chat turn; absent when no table was made.
|
||||
*/
|
||||
@@ -1202,7 +1202,7 @@ async function getMetricTable(
|
||||
});
|
||||
const ref = turn?.refs.next() ?? null;
|
||||
if (!ref) {
|
||||
// Outside a chat turn, or past the sixth table: the model still gets
|
||||
// Outside a chat turn, or past the eighth table: the model still gets
|
||||
// the figures, there is just no table to name.
|
||||
return { present: true, data: summarise(table) };
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ export {
|
||||
renderFocusHint,
|
||||
} from "./inventory";
|
||||
export { buildToolModeAddendum } from "./system-addendum";
|
||||
export { runCoachToolLoop, MAX_ROUNDS } from "./loop";
|
||||
export { runCoachToolLoop } from "./loop";
|
||||
export { TURN_LIMITS, createTurnBudget } from "./turn-budget";
|
||||
+420
-157
@@ -1,40 +1,62 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — bounded Coach retrieval loop.
|
||||
* The Coach retrieval loop: model round → tool calls → results → the next
|
||||
* round, until the model answers in prose or the turn's budget says the
|
||||
* next round must be the answer.
|
||||
*
|
||||
* 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.
|
||||
* v1.41 — a budget instead of a fixed three rounds. A turn runs under the
|
||||
* token, time and round limits of whoever pays for it (`turn-budget.ts`), a
|
||||
* no-progress brake (`progress.ts`) and, round by round, the day's ledger
|
||||
* (`spend`, reserved before each round and settled after it). Whichever of
|
||||
* them stops the loop, the next round is the final one: sent without tools,
|
||||
* with one line telling the model why, and the room for it was held back
|
||||
* from the start, so a turn always ends with an answer.
|
||||
*
|
||||
* Bounds (the loop can never run away):
|
||||
* - 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`).
|
||||
* Each round is a NON-streaming completion over the fallback chain; the
|
||||
* clients read the provider's stream internally where it has one and report
|
||||
* reasoning summaries as they arrive (`onReasoning`), which the trail shows
|
||||
* live. The reasoning state a provider needs back between rounds rides the
|
||||
* assistant message (`providerState`) and is never kept anywhere else.
|
||||
*
|
||||
* 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.
|
||||
* Tool calls within a round run in parallel. Beside the read-only catalogue
|
||||
* the loop runs `compare_series` and the three dialog tools; neither is part
|
||||
* of the executor, which the MCP surface shares. A call that repeats an
|
||||
* earlier one of the turn is not run again: the model gets `duplicateOf`.
|
||||
*
|
||||
* 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.
|
||||
* A clarifying question ends the turn without a prose round: the question
|
||||
* is the reply.
|
||||
*/
|
||||
import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import type {
|
||||
AiMessage,
|
||||
AiToolCall,
|
||||
AiToolDef,
|
||||
CompletionResult,
|
||||
} from "@/lib/ai/types";
|
||||
import type { ReasoningLevel } from "@/lib/ai/reasoning/levels";
|
||||
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
CoachScope,
|
||||
CoachScopeWindow,
|
||||
CoachStop,
|
||||
CoachStopReason,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
createNoopActivityRecorder,
|
||||
type ActivityRecorder,
|
||||
} from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
simpleActivityLabel,
|
||||
stopActivityLabel,
|
||||
} from "@/lib/ai/coach/activity/catalog";
|
||||
import { screenCheckpoint } from "@/lib/ai/coach/activity/screen";
|
||||
import { parseCoachToolArgs, type CoachToolName } from "./definitions";
|
||||
import {
|
||||
executeCoachTool,
|
||||
@@ -42,59 +64,101 @@ import {
|
||||
type CoachToolTrace,
|
||||
type CoachToolTurnContext,
|
||||
} from "./executor";
|
||||
import {
|
||||
COMPARE_SERIES_TOOL_NAME,
|
||||
compareSeriesArgsSchema,
|
||||
runCompareSeries,
|
||||
} from "./compare-series";
|
||||
import {
|
||||
ASK_CLARIFICATION_TOOL_NAME,
|
||||
PROPOSE_PLAN_TOOL_NAME,
|
||||
isDialogToolName,
|
||||
runDialogTool,
|
||||
type DialogToolContext,
|
||||
} from "./dialog-tools";
|
||||
import { callScope, callSignature, createProgressTracker } from "./progress";
|
||||
import {
|
||||
createTurnBudget,
|
||||
estimateInputTokens,
|
||||
type TurnBudget,
|
||||
} from "./turn-budget";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The day's ledger, round by round (`turn/budget.ts`). Kept as callbacks so
|
||||
* the loop stays free of the database.
|
||||
*/
|
||||
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 RoundSpend {
|
||||
/** Reserve the next round; false when the day's ceiling refuses it. */
|
||||
reserveRound(estimate: number): Promise<boolean>;
|
||||
/** Settle a finished round against what it reserved. */
|
||||
settleRound(usage: {
|
||||
tokens: number;
|
||||
cachedTokens: number;
|
||||
servedBy: ProviderChainType;
|
||||
final: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/** What the trail is told about a call as it starts and as it settles. */
|
||||
export interface LoopCallEvents {
|
||||
/**
|
||||
* Fired as each call starts. `index` counts calls across the whole turn
|
||||
* from 0, so it is stable for a step id. A throwing callback is swallowed:
|
||||
* progress reporting never breaks the loop.
|
||||
*/
|
||||
onCallStart?: (call: AiToolCall, index: number, round: number) => void;
|
||||
/**
|
||||
* Fired once for every call that started, when its result is in: found,
|
||||
* missed, invalid or failed alike.
|
||||
*/
|
||||
onCallSettled?: (
|
||||
call: AiToolCall,
|
||||
result: CoachToolResult,
|
||||
index: number,
|
||||
round: number,
|
||||
) => void;
|
||||
/** Fired when every call of a round has settled. */
|
||||
onRoundSettled?: (round: number) => void;
|
||||
}
|
||||
|
||||
export interface CoachToolLoopResult {
|
||||
/** The final completion (prose). */
|
||||
/** The final completion (prose, or the question on a clarification). */
|
||||
result: CompletionResult;
|
||||
/** The working provider's type tag. */
|
||||
workingProviderType: string;
|
||||
/** Summed tokens across every round (for budget reconcile). */
|
||||
totalTokens: number;
|
||||
/**
|
||||
* v1.21.0 (F3) — summed cached-input tokens across every round. Subtracted
|
||||
* from the charged amount at reconcile so prompt-cached input the user did
|
||||
* not re-pay for is not billed to their daily meter.
|
||||
*/
|
||||
/** Summed cached-input tokens across every round. */
|
||||
cachedTokens: number;
|
||||
/** Number of model round-trips made. */
|
||||
rounds: number;
|
||||
/** Which tools ran + whether each found data (persisted onto provenance). */
|
||||
toolTrace: CoachToolTrace[];
|
||||
/**
|
||||
* v1.21.0 (P6) — the structured payloads of every tool result this turn that
|
||||
* DELIVERED figures, in call order. The post-hoc prose number-verifier
|
||||
* extracts the numeric leaves from these to cross-check the figures the model
|
||||
* cited. Empty on a no-tools answer.
|
||||
*
|
||||
* "Delivered figures" is present results plus the availability payload an
|
||||
* `outside_window` miss carries: that payload is server-computed (count, date
|
||||
* range, per-series aggregate) and rule 3 tells the model it MAY cite it, so it
|
||||
* has to ride the authoritative set — otherwise the verifier would elide the
|
||||
* one honest sentence the fix exists to make sayable.
|
||||
* The structured payloads of every tool result this turn that DELIVERED
|
||||
* figures, in call order, for the prose number-verifier: a present
|
||||
* result's `data`, or the `available` block an out-of-window miss carries.
|
||||
*/
|
||||
toolResults: CoachToolResult[];
|
||||
/**
|
||||
* v1.39.4 — true when the loop reached `HARD_CAP`: the model asked for
|
||||
* tools in every round it was offered them, so the final answer was forced
|
||||
* with `toolChoice: "none"` rather than given freely.
|
||||
* True when the answer was forced by the budget, the clock or the round
|
||||
* cap: the "keep looking" chip continues it. A no-progress stop is
|
||||
* forced too, but continuing it would only circle again.
|
||||
*/
|
||||
forcedFinal: boolean;
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
/** v1.41 — the question, when the turn ended on one. */
|
||||
clarification?: { question: string; clarification: CoachClarification };
|
||||
/**
|
||||
* v1.41 — questions the brake turned into assumptions, for the
|
||||
* provenance. Absent when there were none.
|
||||
*/
|
||||
declinedClarifications?: CoachClarification[];
|
||||
/** v1.41 — the fact this answer saved or proposes. */
|
||||
memoryNote?: CoachMemoryNote;
|
||||
/** v1.41 — the plan this answer proposes. */
|
||||
planProposal?: CoachPlanProposal;
|
||||
}
|
||||
|
||||
/** v1.39.4 — a tool call to run before the first model round. */
|
||||
@@ -103,14 +167,28 @@ interface CoachSeedCall {
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The line the final round is sent with, by why it is final. */
|
||||
const FINAL_ROUND_LINE: Readonly<Record<CoachStopReason, string>> = {
|
||||
budget:
|
||||
"FINAL ROUND: this turn's token budget is spent. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
|
||||
time: "FINAL ROUND: this turn's time is up. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
|
||||
cap: "FINAL ROUND: this turn has used its rounds. Answer now from the results you already have; name what is still missing in one clause. No tool calls.",
|
||||
no_progress:
|
||||
"FINAL ROUND: the last calls brought nothing new. Answer now from the results you already have; name what could not be found in one clause. No tool calls.",
|
||||
};
|
||||
|
||||
/** Tokens of text, for the budget's estimate of what the next round adds. */
|
||||
function textTokens(value: string): number {
|
||||
return estimateInputTokens(value.length);
|
||||
}
|
||||
|
||||
export async function runCoachToolLoop(args: {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
system: string;
|
||||
/**
|
||||
* v1.39.4 — the system prompt for the rounds after a call produced a
|
||||
* table, when it differs (the table rules ride it). Absent: `system`
|
||||
* throughout.
|
||||
* table, when it differs (the table rules ride it).
|
||||
*/
|
||||
systemOnceTableShown?: string;
|
||||
/** The conversation messages (history + the new user turn). */
|
||||
@@ -119,51 +197,39 @@ 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.
|
||||
*/
|
||||
/** The turn's shared full-source snapshot scope. */
|
||||
sharedScope?: CoachScope;
|
||||
/**
|
||||
* The person's Coach lookback limit. Required: every tool call of a chat
|
||||
* turn is clamped to it, and a caller that forgot it would read past it.
|
||||
*/
|
||||
/** The person's Coach lookback limit; every tool call is clamped to it. */
|
||||
reach: CoachHistoryReach;
|
||||
ledger?: ProviderHealthLedger;
|
||||
/** Aborts the per-round provider calls on client disconnect. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* v1.22 (#89) — per-user upstream timeout (ms) for each tool-round provider
|
||||
* call. Forwarded to `CompletionParams.timeoutMs`; unset → the client's
|
||||
* shared default holds.
|
||||
*/
|
||||
/** Per-user upstream timeout (ms) for each round's provider call. */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* v1.39.4 — fired as each tool call starts. `index` counts calls across
|
||||
* the whole turn from 0, so it is stable for a step id. A throwing callback
|
||||
* is swallowed: progress reporting never breaks the loop.
|
||||
*/
|
||||
onCallStart?: (call: AiToolCall, index: number) => void;
|
||||
/**
|
||||
* v1.39.4 — fired once for every call that started, when its result is in:
|
||||
* found, missed, invalid or failed alike (`executeCoachTool` never throws).
|
||||
*/
|
||||
onCallSettled?: (
|
||||
call: AiToolCall,
|
||||
result: CoachToolResult,
|
||||
index: number,
|
||||
) => void;
|
||||
/**
|
||||
* v1.39.4 — calls to run before round one, reusing the loop's own
|
||||
* assistant-call and tool-result serialisation. Accepted, not read yet.
|
||||
*/
|
||||
onCallStart?: LoopCallEvents["onCallStart"];
|
||||
onCallSettled?: LoopCallEvents["onCallSettled"];
|
||||
onRoundSettled?: LoopCallEvents["onRoundSettled"];
|
||||
/** v1.39.4 — accepted, not read yet. */
|
||||
seedCalls?: ReadonlyArray<CoachSeedCall>;
|
||||
/**
|
||||
* v1.39.4 — the chat turn the loop runs in: lets a call produce a named
|
||||
* result table and `show_result` find an earlier one of the conversation.
|
||||
*/
|
||||
/** v1.39.4 — the chat turn the loop runs in. */
|
||||
turn?: CoachToolTurnContext;
|
||||
/**
|
||||
* v1.41 — the turn's budget. Absent: the person's own limits, estimated
|
||||
* from the prompt.
|
||||
*/
|
||||
budget?: TurnBudget;
|
||||
/** v1.41 — the day's ledger, round by round. Absent: nothing is booked. */
|
||||
spend?: RoundSpend;
|
||||
/** v1.41 — the reasoning each round asks for, already resolved. */
|
||||
reasoning?: { effort: ReasoningLevel; summaries: boolean };
|
||||
/** v1.41 — the live trail. Absent: the no-op recorder. */
|
||||
activity?: ActivityRecorder;
|
||||
/** v1.41 — the locale the trail is labelled in. */
|
||||
locale?: Locale;
|
||||
/** v1.41 — what the dialog tools need. Absent: they are declined. */
|
||||
dialog?: DialogToolContext;
|
||||
/** v1.41 — the figures read so far, for screening a checkpoint. */
|
||||
checkpointScreen?: { userMessage: string };
|
||||
}): Promise<CoachToolLoopResult> {
|
||||
const {
|
||||
userId,
|
||||
@@ -179,8 +245,27 @@ export async function runCoachToolLoop(args: {
|
||||
timeoutMs,
|
||||
onCallStart,
|
||||
onCallSettled,
|
||||
onRoundSettled,
|
||||
turn,
|
||||
spend,
|
||||
reasoning,
|
||||
dialog,
|
||||
} = args;
|
||||
const locale: Locale = args.locale ?? turn?.locale ?? "en";
|
||||
const activity = args.activity ?? createNoopActivityRecorder();
|
||||
const budget =
|
||||
args.budget ??
|
||||
createTurnBudget({
|
||||
payer: "user",
|
||||
effort: reasoning?.effort,
|
||||
initialInputTokens: textTokens(
|
||||
args.system +
|
||||
args.messages
|
||||
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
||||
.join(""),
|
||||
),
|
||||
});
|
||||
const progress = createProgressTracker();
|
||||
|
||||
const messages: AiMessage[] = [...args.messages];
|
||||
let system = args.system;
|
||||
@@ -190,15 +275,42 @@ export async function runCoachToolLoop(args: {
|
||||
let workingProviderType = "";
|
||||
const toolTrace: CoachToolTrace[] = [];
|
||||
const toolResults: CoachToolResult[] = [];
|
||||
const declinedClarifications: CoachClarification[] = [];
|
||||
let memoryNote: CoachMemoryNote | undefined;
|
||||
let planProposal: CoachPlanProposal | undefined;
|
||||
let callCount = 0;
|
||||
let stopReason: CoachStopReason | null = null;
|
||||
let progressStalled = false;
|
||||
|
||||
for (let round = 1; ; round += 1) {
|
||||
// ── Whether this round may still fetch ──────────────────────────
|
||||
if (round > 1 && stopReason === null) {
|
||||
stopReason = budget.check() ?? (progressStalled ? "no_progress" : null);
|
||||
if (stopReason === null && spend) {
|
||||
const admitted = await spend.reserveRound(budget.nextRoundEstimate());
|
||||
if (!admitted) stopReason = "budget";
|
||||
}
|
||||
}
|
||||
const isFinal = stopReason !== null;
|
||||
if (stopReason) {
|
||||
const label = stopActivityLabel(locale, stopReason);
|
||||
const id = activity.start({
|
||||
phase: "stop",
|
||||
round,
|
||||
...label,
|
||||
stop: stopReason,
|
||||
});
|
||||
activity.finish(id, "done");
|
||||
}
|
||||
|
||||
// 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 thinking = activity.start({
|
||||
phase: "thinking",
|
||||
round,
|
||||
...simpleActivityLabel(locale, "thinking"),
|
||||
});
|
||||
const summaries: string[] = [];
|
||||
const started = Date.now();
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers,
|
||||
@@ -206,109 +318,260 @@ export async function runCoachToolLoop(args: {
|
||||
surface: "coach",
|
||||
ledger,
|
||||
params: {
|
||||
system,
|
||||
system: stopReason ? `${system}\n\n${FINAL_ROUND_LINE[stopReason]}` : system,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens,
|
||||
signal,
|
||||
timeoutMs,
|
||||
...(offerTools
|
||||
? { tools, toolChoice: "auto" as const }
|
||||
: { toolChoice: "none" as const }),
|
||||
...(reasoning
|
||||
? {
|
||||
reasoning,
|
||||
onReasoning: (event) => {
|
||||
if (event.kind === "title") {
|
||||
activity.update(thinking, { title: event.text });
|
||||
} else if (event.kind === "text" && event.text.trim()) {
|
||||
summaries.push(event.text);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(isFinal
|
||||
? { toolChoice: "none" as const }
|
||||
: { tools, toolChoice: "auto" as const }),
|
||||
},
|
||||
});
|
||||
const result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
totalTokens += result.tokensUsed ?? 0;
|
||||
cachedTokens += result.cachedInputTokens ?? 0;
|
||||
budget.endRound({
|
||||
tokens: result.tokensUsed,
|
||||
cachedTokens: result.cachedInputTokens,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
if (spend) {
|
||||
await spend.settleRound({
|
||||
tokens: result.tokensUsed ?? 0,
|
||||
cachedTokens: result.cachedInputTokens ?? 0,
|
||||
servedBy: fallback.workingProvider.providerType as ProviderChainType,
|
||||
final: isFinal,
|
||||
});
|
||||
}
|
||||
// A client that reports summaries only on the result, not live.
|
||||
const summaryText = (
|
||||
summaries.length > 0 ? summaries : (result.reasoning?.summary ?? [])
|
||||
).join("\n\n");
|
||||
activity.finish(thinking, "done", summaryText ? { text: summaryText } : {});
|
||||
|
||||
const calls = result.toolCalls ?? [];
|
||||
const wantsTools =
|
||||
offerTools && result.finishReason === "tool_calls" && calls.length > 0;
|
||||
!isFinal && 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 finish({ result });
|
||||
}
|
||||
|
||||
// Assistant text beside the calls: the model's own checkpoint.
|
||||
const checkpoint = screenCheckpoint(result.content, {
|
||||
locale,
|
||||
figures: () => toolResults.map((r) => r.data ?? r.available),
|
||||
userMessage: args.checkpointScreen?.userMessage,
|
||||
});
|
||||
if (checkpoint) {
|
||||
const id = activity.start({
|
||||
phase: "checkpoint",
|
||||
round,
|
||||
...simpleActivityLabel(locale, "thinking"),
|
||||
});
|
||||
return {
|
||||
result,
|
||||
workingProviderType,
|
||||
totalTokens,
|
||||
cachedTokens,
|
||||
rounds,
|
||||
toolTrace,
|
||||
toolResults,
|
||||
forcedFinal: isForcedFinal,
|
||||
};
|
||||
activity.finish(id, "done", { title: checkpoint });
|
||||
}
|
||||
|
||||
// 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,
|
||||
...(result.providerState ? { providerState: result.providerState } : {}),
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
let asked: CoachToolLoopResult["clarification"];
|
||||
const settled = await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
const index = callCount;
|
||||
callCount += 1;
|
||||
notify(() => onCallStart?.(call, index));
|
||||
const settled = await executeCoachTool({
|
||||
userId,
|
||||
name: call.name,
|
||||
rawArguments: call.arguments,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
...(turn ? { turn } : {}),
|
||||
});
|
||||
// v1.39.4 — the settled callback gets the table; the model never
|
||||
// does. It reads the compact summary in `data`, and the verifier
|
||||
// grades the prose against that same summary.
|
||||
notify(() => onCallSettled?.(call, settled, index));
|
||||
const { table, ...toolResult } = settled;
|
||||
const signature = callSignature(call.name, call.arguments);
|
||||
const earlier = progress.duplicateOf(call.id, signature);
|
||||
if (earlier !== null) {
|
||||
progress.record({ duplicate: true, scope: null, result: null });
|
||||
annotate({
|
||||
action: { name: "coach.tool.duplicate" },
|
||||
meta: { tool: call.name.slice(0, 48), round },
|
||||
});
|
||||
return {
|
||||
call,
|
||||
content: { present: false, reason: "duplicate", duplicateOf: earlier },
|
||||
};
|
||||
}
|
||||
|
||||
if (isDialogToolName(call.name)) {
|
||||
const phase =
|
||||
call.name === ASK_CLARIFICATION_TOOL_NAME
|
||||
? "asking"
|
||||
: call.name === PROPOSE_PLAN_TOOL_NAME
|
||||
? "plan"
|
||||
: "remember";
|
||||
const id = activity.start({
|
||||
phase,
|
||||
round,
|
||||
...simpleActivityLabel(locale, phase),
|
||||
});
|
||||
const outcome = dialog
|
||||
? await runDialogTool({
|
||||
name: call.name,
|
||||
rawArguments: call.arguments,
|
||||
round,
|
||||
ctx: dialog,
|
||||
noted: memoryNote !== undefined,
|
||||
proposed: planProposal !== undefined,
|
||||
})
|
||||
: { kind: "none" as const, result: { declined: "unavailable" } };
|
||||
if (outcome.kind === "ask") {
|
||||
asked = {
|
||||
question: outcome.question,
|
||||
clarification: outcome.clarification,
|
||||
};
|
||||
} else if (outcome.kind === "memory") {
|
||||
memoryNote = outcome.note;
|
||||
} else if (outcome.kind === "plan") {
|
||||
planProposal = outcome.proposal;
|
||||
} else if (outcome.kind === "declined") {
|
||||
declinedClarifications.push(outcome.clarification);
|
||||
}
|
||||
activity.finish(
|
||||
id,
|
||||
outcome.kind === "none" || outcome.kind === "declined"
|
||||
? "empty"
|
||||
: "done",
|
||||
);
|
||||
progress.record({ duplicate: false, scope: null, result: null });
|
||||
return { call, content: outcome.result };
|
||||
}
|
||||
|
||||
notify(() => onCallStart?.(call, index, round));
|
||||
const toolResult =
|
||||
call.name === COMPARE_SERIES_TOOL_NAME
|
||||
? await runCompareSeries({
|
||||
userId,
|
||||
rawArguments: call.arguments,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
...(turn ? { turn } : {}),
|
||||
})
|
||||
: await executeCoachTool({
|
||||
userId,
|
||||
name: call.name,
|
||||
rawArguments: call.arguments,
|
||||
fallbackWindow,
|
||||
sharedScope,
|
||||
reach,
|
||||
...(turn ? { turn } : {}),
|
||||
});
|
||||
// The settled callback gets the table; the model never does. It
|
||||
// reads the compact summary in `data`, and the verifier grades the
|
||||
// prose against that same summary.
|
||||
notify(() => onCallSettled?.(call, toolResult, index, round));
|
||||
const { table, ...forModel } = toolResult;
|
||||
if (table && args.systemOnceTableShown) {
|
||||
system = args.systemOnceTableShown;
|
||||
}
|
||||
const validArgs = parseCoachToolArgs(call.name, call.arguments);
|
||||
const validArgs =
|
||||
call.name === COMPARE_SERIES_TOOL_NAME
|
||||
? compareArgs(call.arguments)
|
||||
: parseCoachToolArgs(call.name, call.arguments);
|
||||
toolTrace.push({
|
||||
name: call.name,
|
||||
present: toolResult.present,
|
||||
present: forModel.present,
|
||||
...(validArgs ? { args: validArgs } : {}),
|
||||
});
|
||||
// v1.21.0 (P6) — retain the payloads that carried figures for the
|
||||
// post-hoc prose number-verifier (the union of numeric leaves grounds
|
||||
// the figures the model may cite): a present result's `data`, or the
|
||||
// `available` block an out-of-window miss reports.
|
||||
if (toolResult.present || toolResult.available !== undefined) {
|
||||
toolResults.push(toolResult);
|
||||
if (forModel.present || forModel.available !== undefined) {
|
||||
toolResults.push(forModel);
|
||||
}
|
||||
return { call, toolResult };
|
||||
progress.record({
|
||||
duplicate: false,
|
||||
scope: validArgs ? callScope(call.name, validArgs) : null,
|
||||
result: forModel,
|
||||
});
|
||||
return { call, content: forModel };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { call, toolResult } of results) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
toolCallId: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
for (const { call, content } of settled) {
|
||||
const serialised = JSON.stringify(content);
|
||||
budget.addInput(textTokens(serialised));
|
||||
messages.push({ role: "tool", toolCallId: call.id, content: serialised });
|
||||
}
|
||||
budget.addInput(textTokens(result.content ?? ""));
|
||||
notify(() => onRoundSettled?.(round));
|
||||
|
||||
// A question ends the turn: it is the reply, with no prose round.
|
||||
if (asked) {
|
||||
return finish({
|
||||
result: { ...result, content: asked.question, toolCalls: undefined },
|
||||
clarification: asked,
|
||||
});
|
||||
}
|
||||
progressStalled = progress.endRound();
|
||||
}
|
||||
|
||||
// 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");
|
||||
function finish(end: {
|
||||
result: CompletionResult;
|
||||
clarification?: CoachToolLoopResult["clarification"];
|
||||
}): CoachToolLoopResult {
|
||||
const stop: CoachStop | undefined = stopReason
|
||||
? { reason: stopReason, rounds }
|
||||
: undefined;
|
||||
annotate({
|
||||
action: { name: "coach.tool.rounds" },
|
||||
meta: {
|
||||
rounds,
|
||||
tools: toolTrace.length,
|
||||
forcedFinal: stopReason !== null,
|
||||
stop: stopReason ?? "answered",
|
||||
clarification: end.clarification !== undefined,
|
||||
spent: budget.spent(),
|
||||
elapsedMs: budget.elapsedMs(),
|
||||
payer: budget.payer,
|
||||
},
|
||||
});
|
||||
return {
|
||||
result: end.result,
|
||||
workingProviderType,
|
||||
totalTokens,
|
||||
cachedTokens,
|
||||
rounds,
|
||||
toolTrace,
|
||||
toolResults,
|
||||
forcedFinal: stopReason !== null && stopReason !== "no_progress",
|
||||
...(stop ? { stop } : {}),
|
||||
...(end.clarification ? { clarification: end.clarification } : {}),
|
||||
...(declinedClarifications.length > 0 ? { declinedClarifications } : {}),
|
||||
...(memoryNote ? { memoryNote } : {}),
|
||||
...(planProposal ? { planProposal } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** A `compare_series` call's arguments as their schema validates them. */
|
||||
function compareArgs(raw: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed = compareSeriesArgsSchema.safeParse(
|
||||
raw.trim() === "" ? {} : JSON.parse(raw),
|
||||
);
|
||||
return parsed.success ? (parsed.data as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a progress callback; a throw from it never reaches the loop. */
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v1.41 — the no-progress brake of the Coach loop.
|
||||
*
|
||||
* A budget stops a turn that runs long; this stops one that runs in circles.
|
||||
* The next round becomes the final answer (`no_progress`) when
|
||||
*
|
||||
* - every call of the last round repeated an earlier call of the turn (the
|
||||
* same tool with the same arguments after normalising them);
|
||||
* - two rounds in a row brought back nothing but misses that say nothing
|
||||
* (`no_data`, a call that did not validate, a read that failed);
|
||||
* - three rounds in a row read no new domain, window and period.
|
||||
*
|
||||
* A repeated call is never run again: the model gets `{ duplicateOf }` with
|
||||
* the id of the call it repeats, and the result it already holds.
|
||||
*
|
||||
* Pure: no database, no provider.
|
||||
*/
|
||||
import type { CoachToolResult } from "./executor";
|
||||
|
||||
/** Misses that tell the model nothing new about the record. */
|
||||
const EMPTY_REASONS: ReadonlySet<string> = new Set([
|
||||
"no_data",
|
||||
"analyte_not_found",
|
||||
"invalid_arguments",
|
||||
"unknown_tool",
|
||||
"unsupported_metric",
|
||||
"use_get_glucose_panel",
|
||||
"use_get_medication_compliance",
|
||||
"use_get_workouts",
|
||||
"retrieval_failed",
|
||||
"no_data_unconfirmed",
|
||||
"duplicate",
|
||||
]);
|
||||
|
||||
/** Rounds of nothing but empty misses before the brake. */
|
||||
export const EMPTY_ROUNDS_LIMIT = 2;
|
||||
/** Rounds without a new domain × window × period before the brake. */
|
||||
export const STALE_ROUNDS_LIMIT = 3;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Sorted keys, so `{a,b}` and `{b,a}` are one call; strings trimmed and lower-cased. */
|
||||
function normalise(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(normalise);
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.filter((key) => value[key] !== undefined && value[key] !== null)
|
||||
.map((key) => [key, normalise(value[key])]),
|
||||
);
|
||||
}
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : value;
|
||||
}
|
||||
|
||||
/** The identity of a call: its name and its normalised arguments. */
|
||||
export function callSignature(name: string, rawArguments: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
|
||||
} catch {
|
||||
parsed = rawArguments.trim();
|
||||
}
|
||||
return `${name}:${JSON.stringify(normalise(parsed))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch of the record a call reads: domain × window × period. A
|
||||
* metric call's domain is its metric; any other tool's is its name.
|
||||
*/
|
||||
export function callScope(
|
||||
name: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
const pick = (key: string): string | undefined =>
|
||||
typeof args?.[key] === "string" ? (args[key] as string) : undefined;
|
||||
const domain = pick("metric") ?? name;
|
||||
const second = pick("metricB");
|
||||
return [
|
||||
second ? `${domain}+${second}` : domain,
|
||||
pick("window") ?? "default",
|
||||
pick("period") ?? pick("basis") ?? "current",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export interface ProgressTracker {
|
||||
/**
|
||||
* The id of an earlier call of the turn this one repeats, or null. A call
|
||||
* that is not a repeat is remembered under `callId`.
|
||||
*/
|
||||
duplicateOf(callId: string, signature: string): string | null;
|
||||
/** Records how one call of the current round came out. */
|
||||
record(args: {
|
||||
duplicate: boolean;
|
||||
scope: string | null;
|
||||
result: Pick<CoachToolResult, "present" | "reason"> | null;
|
||||
}): void;
|
||||
/** Closes the round; true when the next round must be the final one. */
|
||||
endRound(): boolean;
|
||||
}
|
||||
|
||||
export function createProgressTracker(): ProgressTracker {
|
||||
const signatures = new Map<string, string>();
|
||||
const scopes = new Set<string>();
|
||||
let emptyRounds = 0;
|
||||
let staleRounds = 0;
|
||||
// The current round.
|
||||
let calls = 0;
|
||||
let duplicates = 0;
|
||||
let informative = 0;
|
||||
let newScope = false;
|
||||
|
||||
return {
|
||||
duplicateOf(callId, signature) {
|
||||
const earlier = signatures.get(signature);
|
||||
if (earlier !== undefined) return earlier;
|
||||
signatures.set(signature, callId);
|
||||
return null;
|
||||
},
|
||||
record({ duplicate, scope, result }) {
|
||||
calls += 1;
|
||||
if (duplicate) duplicates += 1;
|
||||
// A call with no data result (a dialog tool) is neither empty nor news.
|
||||
if (
|
||||
result &&
|
||||
!duplicate &&
|
||||
(result.present || !EMPTY_REASONS.has(result.reason ?? ""))
|
||||
) {
|
||||
informative += 1;
|
||||
}
|
||||
if (!result) informative += 1;
|
||||
if (scope !== null && !duplicate && !scopes.has(scope)) {
|
||||
scopes.add(scope);
|
||||
newScope = true;
|
||||
}
|
||||
},
|
||||
endRound() {
|
||||
const allDuplicates = calls > 0 && duplicates === calls;
|
||||
emptyRounds = calls > 0 && informative === 0 ? emptyRounds + 1 : 0;
|
||||
staleRounds = newScope ? 0 : staleRounds + 1;
|
||||
calls = 0;
|
||||
duplicates = 0;
|
||||
informative = 0;
|
||||
newScope = false;
|
||||
return (
|
||||
allDuplicates ||
|
||||
emptyRounds >= EMPTY_ROUNDS_LIMIT ||
|
||||
staleRounds >= STALE_ROUNDS_LIMIT
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -35,7 +35,7 @@ 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 for any domain the DATA INVENTORY does not mark plainly "absent" — including one it marks OUTSIDE WINDOW or NOT IN SCOPE. Call several tools in one step when a question spans metrics — they run in parallel.
|
||||
2. Call a tool for any domain the DATA INVENTORY does not mark plainly "absent" — including one it marks OUTSIDE WINDOW or NOT IN SCOPE — unless the CLARIFYING QUESTIONS rule applies. Call several tools in one step when a question spans metrics — they run in parallel. For a comparison of two periods or of two metrics, call compare_series once instead of two separate reads.
|
||||
3. A { present: false } result is NOT automatically "no data". Read its "reason" (this overrides ground rule 3 for every reason except no_data):
|
||||
- "no_data": the record holds nothing for that metric. Acknowledge it in one short sentence and offer to think it through with what the user can tell you.
|
||||
- "outside_window": the readings EXIST and are older than the window that was searched. "available" carries the count, the first and last date, and a per-series mean/min/max. Say what the record holds and over which dates — never that the user has no data for it. Cite only figures from "available". If "available.reachableWithWindow" names a window, call the same tool again with that window to read the real series.
|
||||
@@ -46,14 +46,15 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha
|
||||
4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on.
|
||||
5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
|
||||
6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.
|
||||
7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" (when it has one) and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, and never tell them the numbers were not given to you.`;
|
||||
7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" (when it has one) and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, unless the CLARIFYING QUESTIONS rule applies, and never tell them the numbers were not given to you.
|
||||
8. You have several rounds: read, look at what came back, and fetch what is still missing in the next one. Never repeat a call you already made this turn; its result is above. If a round has to be your last, you are told so: then answer from what you have.`;
|
||||
|
||||
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 für jede Domäne auf, die das DATA INVENTORY nicht klar als „absent" markiert — auch für eine, die als OUTSIDE WINDOW oder NOT IN SCOPE markiert ist. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel.
|
||||
2. Rufe ein Tool für jede Domäne auf, die das DATA INVENTORY nicht klar als „absent" markiert — auch für eine, die als OUTSIDE WINDOW oder NOT IN SCOPE markiert ist —, außer die Regel CLARIFYING QUESTIONS greift. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel. Für einen Vergleich zweier Zeiträume oder zweier Metriken rufe einmal compare_series auf statt zweier einzelner Abfragen.
|
||||
3. Ein Ergebnis { present: false } bedeutet NICHT automatisch „keine Daten". Lies den „reason" (das ersetzt Grundregel 3 für jeden Grund außer no_data):
|
||||
- „no_data": zu dieser Metrik liegt nichts vor. Benenne das in einem kurzen Satz und biete an, es mit dem zu durchdenken, was der Nutzer dir erzählen kann.
|
||||
- „outside_window": die Messwerte EXISTIEREN und sind älter als das abgefragte Zeitfenster. „available" enthält die Anzahl, das erste und letzte Datum sowie Mittelwert/Minimum/Maximum je Serie. Sage, was vorliegt und über welchen Zeitraum — niemals, dass keine Daten vorliegen. Nenne nur Zahlen aus „available". Wenn „available.reachableWithWindow" ein Fenster nennt, rufe dasselbe Tool erneut mit diesem Fenster auf und lies die echte Serie.
|
||||
@@ -64,7 +65,8 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet,
|
||||
4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging.
|
||||
5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
|
||||
6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.
|
||||
7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" (falls vorhanden) und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, und sage nie, die Zahlen seien dir nicht mitgegeben worden.`;
|
||||
7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" (falls vorhanden) und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, außer die Regel CLARIFYING QUESTIONS greift, und sage nie, die Zahlen seien dir nicht mitgegeben worden.
|
||||
8. Du hast mehrere Runden: abrufen, ansehen, was zurückkam, und in der nächsten Runde holen, was noch fehlt. Wiederhole keinen Aufruf, den du in diesem Zug schon gemacht hast; sein Ergebnis steht oben. Muss eine Runde deine letzte sein, wird dir das gesagt: dann antworte mit dem, was du hast.`;
|
||||
|
||||
export function buildToolModeAddendum(locale: Locale): string {
|
||||
return locale === "de" ? DE : EN;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* v1.41 — the budget one Coach turn runs under: tokens, wall time and a hard
|
||||
* round cap, fixed by who pays for the turn.
|
||||
*
|
||||
* The loop asks `check()` before every tool round after the first. A reason
|
||||
* means the next round is the final one: it is sent without tools and the
|
||||
* answer is written from what was read so far. The checks run in the order
|
||||
* the plan fixes:
|
||||
*
|
||||
* 1. tokens: spent + the next round + the final answer would pass the
|
||||
* turn's token budget (`budget`);
|
||||
* 2. time: elapsed + a typical round + the final round would pass the wall
|
||||
* time (`time`);
|
||||
* 3. rounds: the next round is the last one the cap allows (`cap`).
|
||||
*
|
||||
* The final answer is reserved before anything else is spent: whichever
|
||||
* check stops the loop, there is always room for the answer, so a turn never
|
||||
* ends without one.
|
||||
*
|
||||
* Cached input counts at a tenth of its size. A provider that serves the
|
||||
* prompt prefix from its cache bills it at a fraction, and later rounds of a
|
||||
* turn resend almost the whole prompt; counting it whole would stop a long
|
||||
* turn for tokens nobody paid for.
|
||||
*
|
||||
* Pure: no database, no provider, no clock unless one is passed.
|
||||
*/
|
||||
import type { CoachStopReason } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
REASONING_THINKING_BUDGET,
|
||||
type ReasoningLevel,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
|
||||
/** Who pays for the turn; the same split the daily ledger keeps. */
|
||||
export type TurnPayer = "user" | "operator";
|
||||
|
||||
export interface TurnLimits {
|
||||
/** Tokens per turn, cached input weighted (`CACHED_INPUT_WEIGHT`). */
|
||||
tokens: number;
|
||||
/** Wall time per turn, in milliseconds. */
|
||||
wallMs: number;
|
||||
/** Rounds per turn, the final answer included. */
|
||||
maxRounds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed per payer, not configurable: the person's own plan, key or local
|
||||
* model gets room for a long "why" chain; a turn the operator pays for gets
|
||||
* a third of the tokens and half the rounds.
|
||||
*/
|
||||
export const TURN_LIMITS: Readonly<Record<TurnPayer, TurnLimits>> = {
|
||||
user: { tokens: 120_000, wallMs: 150_000, maxRounds: 12 },
|
||||
operator: { tokens: 40_000, wallMs: 90_000, maxRounds: 6 },
|
||||
};
|
||||
|
||||
/** The answer budget of one round, before any thinking budget. */
|
||||
export const ROUND_ANSWER_TOKENS = 600;
|
||||
|
||||
/** What the final answer is given on top of its input. */
|
||||
export const FINAL_ANSWER_TOKENS = 1_200;
|
||||
|
||||
/** Cached input counts at this share of its size. */
|
||||
export const CACHED_INPUT_WEIGHT = 0.1;
|
||||
|
||||
/** A typical round, before one has been measured. */
|
||||
const DEFAULT_ROUND_MS = 8_000;
|
||||
|
||||
/** A token count from a provider: a finite non-negative integer, else 0. */
|
||||
function tokens(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The output allowance of one round: the answer budget plus the thinking
|
||||
* budget of the level, where the level thinks at all.
|
||||
*/
|
||||
export function roundOutputAllowance(effort: ReasoningLevel | undefined): number {
|
||||
return (
|
||||
ROUND_ANSWER_TOKENS +
|
||||
(effort && effort !== "off" ? REASONING_THINKING_BUDGET[effort] : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** A round's tokens as the turn budget counts them. */
|
||||
export function weightedRoundTokens(usage: {
|
||||
tokens: number | null | undefined;
|
||||
cachedTokens: number | null | undefined;
|
||||
}): number {
|
||||
const gross = tokens(usage.tokens);
|
||||
const cached = Math.min(gross, tokens(usage.cachedTokens));
|
||||
return Math.ceil(gross - cached + cached * CACHED_INPUT_WEIGHT);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rough input estimate from prompt text: four characters a token. Only
|
||||
* the first round needs it; every later round is estimated from the one
|
||||
* before.
|
||||
*/
|
||||
export function estimateInputTokens(chars: number): number {
|
||||
return Math.ceil(Math.max(0, chars) / 4);
|
||||
}
|
||||
|
||||
export interface RoundUsage {
|
||||
tokens: number | null | undefined;
|
||||
cachedTokens: number | null | undefined;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface TurnBudget {
|
||||
readonly payer: TurnPayer;
|
||||
readonly limits: TurnLimits;
|
||||
/** Rounds completed so far. */
|
||||
rounds(): number;
|
||||
/** Weighted tokens spent so far. */
|
||||
spent(): number;
|
||||
elapsedMs(): number;
|
||||
/** Records a finished round. */
|
||||
endRound(usage: RoundUsage): void;
|
||||
/**
|
||||
* Text the next round sends that the last one did not (the assistant turn
|
||||
* and the tool results), in tokens. It is new, so it is never cached.
|
||||
*/
|
||||
addInput(tokenCount: number): void;
|
||||
/**
|
||||
* What the first round is expected to cost: its whole input, uncached, and
|
||||
* a round's output. What a turn reserves before anything runs.
|
||||
*/
|
||||
firstRoundEstimate(): number;
|
||||
/** What the next tool round is expected to cost, weighted. */
|
||||
nextRoundEstimate(): number;
|
||||
/** What the final answer is held back for, weighted. */
|
||||
finalReserve(): number;
|
||||
/**
|
||||
* Why the next round must be the final one, or null when it may still
|
||||
* fetch. Asked before every round after the first.
|
||||
*/
|
||||
check(): Exclude<CoachStopReason, "no_progress"> | null;
|
||||
}
|
||||
|
||||
export function createTurnBudget(args: {
|
||||
payer: TurnPayer;
|
||||
effort?: ReasoningLevel;
|
||||
/** The first round's input, estimated from the prompt. */
|
||||
initialInputTokens: number;
|
||||
/** Injected for tests. */
|
||||
now?: () => number;
|
||||
/** Overrides for tests. */
|
||||
limits?: TurnLimits;
|
||||
}): TurnBudget {
|
||||
const now = args.now ?? Date.now;
|
||||
const limits = args.limits ?? TURN_LIMITS[args.payer];
|
||||
const started = now();
|
||||
const output = roundOutputAllowance(args.effort);
|
||||
const durations: number[] = [];
|
||||
let spent = 0;
|
||||
let rounds = 0;
|
||||
// The prompt the last round sent, which the next one resends as its
|
||||
// prefix, and what was added to it since.
|
||||
let prefix = tokens(args.initialInputTokens);
|
||||
let added = 0;
|
||||
// The share of a resent prefix the turn is charged for. Optimistic until a
|
||||
// later round says otherwise: providers cache a resent prefix, and the
|
||||
// first round cannot show whether this one does.
|
||||
let prefixWeight = CACHED_INPUT_WEIGHT;
|
||||
|
||||
const median = (): number => {
|
||||
if (durations.length === 0) return DEFAULT_ROUND_MS;
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
};
|
||||
// What the last round really cost, weighted: a round resends the same
|
||||
// prefix, so it bounds the estimate from above whatever the cache did.
|
||||
let lastRound = Number.POSITIVE_INFINITY;
|
||||
const nextInput = (): number =>
|
||||
Math.min(lastRound, Math.ceil(prefix * prefixWeight)) + added;
|
||||
|
||||
const budget: TurnBudget = {
|
||||
payer: args.payer,
|
||||
limits,
|
||||
rounds: () => rounds,
|
||||
spent: () => spent,
|
||||
elapsedMs: () => now() - started,
|
||||
endRound(usage) {
|
||||
const gross = tokens(usage.tokens);
|
||||
const weighted = weightedRoundTokens(usage);
|
||||
spent += weighted;
|
||||
if (gross > 0) lastRound = weighted;
|
||||
rounds += 1;
|
||||
durations.push(Math.max(0, Math.round(usage.durationMs)));
|
||||
// From the second round on, a round's own cache share says how much
|
||||
// of a resent prefix this provider really charges.
|
||||
if (rounds >= 2 && gross > 0) {
|
||||
const cached = Math.min(gross, tokens(usage.cachedTokens));
|
||||
prefixWeight = Math.max(
|
||||
CACHED_INPUT_WEIGHT,
|
||||
1 - (cached / gross) * (1 - CACHED_INPUT_WEIGHT),
|
||||
);
|
||||
}
|
||||
prefix += added + output;
|
||||
added = 0;
|
||||
},
|
||||
addInput(tokenCount) {
|
||||
added += tokens(tokenCount);
|
||||
},
|
||||
firstRoundEstimate: () => tokens(args.initialInputTokens) + output,
|
||||
nextRoundEstimate: () => nextInput() + output,
|
||||
finalReserve: () => nextInput() + FINAL_ANSWER_TOKENS,
|
||||
check() {
|
||||
if (
|
||||
spent + budget.nextRoundEstimate() + budget.finalReserve() >
|
||||
limits.tokens
|
||||
) {
|
||||
return "budget";
|
||||
}
|
||||
// The next round and the final one, each about as long as a typical
|
||||
// round so far.
|
||||
if (budget.elapsedMs() + 2 * median() > limits.wallMs) return "time";
|
||||
// Rounds are counted with the final one: the round that would be the
|
||||
// last allowed must be the answer.
|
||||
if (rounds + 1 >= limits.maxRounds) return "cap";
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return budget;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ const m = vi.hoisted(() => ({
|
||||
assembleTurnContext: vi.fn(),
|
||||
resolveTurnChain: vi.fn(),
|
||||
reserveTurnBudget: vi.fn(),
|
||||
settleReservation: vi.fn(),
|
||||
runTurnModel: vi.fn(),
|
||||
guardReply: vi.fn(),
|
||||
surfaceCards: vi.fn(),
|
||||
@@ -41,7 +40,6 @@ vi.mock("../context", () => ({ assembleTurnContext: m.assembleTurnContext }));
|
||||
vi.mock("../chain", () => ({ resolveTurnChain: m.resolveTurnChain }));
|
||||
vi.mock("../budget", () => ({
|
||||
reserveTurnBudget: m.reserveTurnBudget,
|
||||
settleReservation: m.settleReservation,
|
||||
}));
|
||||
vi.mock("../model", () => ({ runTurnModel: m.runTurnModel }));
|
||||
vi.mock("../reply-guards", () => ({ guardReply: m.guardReply }));
|
||||
@@ -54,6 +52,7 @@ import type { CoachProvenance, CoachStreamEvent } from "@/lib/ai/coach/types";
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
|
||||
import { runCoachTurn } from "../pipeline";
|
||||
import { STUB_PROMPT_CONTEXT, modelExtras, stubLedger } from "./turn-test-kit";
|
||||
import type { TurnInput } from "../types";
|
||||
|
||||
const bytes = (text: string) => new TextEncoder().encode(text);
|
||||
@@ -85,12 +84,14 @@ async function frames(res: Response): Promise<CoachStreamEvent[]> {
|
||||
|
||||
function modelPath(forcedFinal: boolean) {
|
||||
m.assembleTurnContext.mockResolvedValue({
|
||||
...STUB_PROMPT_CONTEXT,
|
||||
coachPrefs: DEFAULT_COACH_PREFS,
|
||||
snapshot: { provenance: { windows: [], metrics: [] } },
|
||||
});
|
||||
m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, ledger: stubLedger() });
|
||||
m.runTurnModel.mockResolvedValue({
|
||||
...modelExtras(),
|
||||
ok: true,
|
||||
result: { content: "x", model: "gpt" },
|
||||
workingProviderType: "openai",
|
||||
|
||||
@@ -130,6 +130,9 @@ describe("assembleTurnDialog", () => {
|
||||
results: [TABLE],
|
||||
forcedFinal: false,
|
||||
inventory: null,
|
||||
declinedClarifications: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
} as unknown as Extract<ModelOutcome, { ok: true }>;
|
||||
const reply = (blocked: boolean): GuardedReply =>
|
||||
({
|
||||
@@ -217,6 +220,9 @@ describe("assembleTurnDialog", () => {
|
||||
method: null,
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
assumptions: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ const m = vi.hoisted(() => ({
|
||||
assembleTurnContext: vi.fn(),
|
||||
resolveTurnChain: vi.fn(),
|
||||
reserveTurnBudget: vi.fn(),
|
||||
settleReservation: vi.fn(),
|
||||
runTurnModel: vi.fn(),
|
||||
guardReply: vi.fn(),
|
||||
surfaceCards: vi.fn(),
|
||||
@@ -53,7 +52,6 @@ vi.mock("../context", () => ({ assembleTurnContext: m.assembleTurnContext }));
|
||||
vi.mock("../chain", () => ({ resolveTurnChain: m.resolveTurnChain }));
|
||||
vi.mock("../budget", () => ({
|
||||
reserveTurnBudget: m.reserveTurnBudget,
|
||||
settleReservation: m.settleReservation,
|
||||
}));
|
||||
vi.mock("../model", () => ({ runTurnModel: m.runTurnModel }));
|
||||
// The record reaches back years, so the history chips have something to
|
||||
@@ -76,6 +74,7 @@ import type {
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
|
||||
import { runCoachTurn } from "../pipeline";
|
||||
import { STUB_PROMPT_CONTEXT, modelExtras, stubLedger } from "./turn-test-kit";
|
||||
import type { TurnInput } from "../types";
|
||||
|
||||
const STORED: CoachResultTable = {
|
||||
@@ -180,13 +179,15 @@ function annotated(name: string) {
|
||||
/** The model path, enough of it to reach the frames. */
|
||||
function modelPath(prefs = DEFAULT_COACH_PREFS) {
|
||||
m.assembleTurnContext.mockResolvedValue({
|
||||
...STUB_PROMPT_CONTEXT,
|
||||
coachPrefs: prefs,
|
||||
snapshot: { provenance: { windows: [], metrics: [] } },
|
||||
});
|
||||
m.resolveTurnChain.mockResolvedValue({ ok: true, chain: [], toolMode: true });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, reservation: {} });
|
||||
m.reserveTurnBudget.mockResolvedValue({ ok: true, ledger: stubLedger() });
|
||||
const table = { ...STORED, chart: null, chartKind: null, displayed: true };
|
||||
m.runTurnModel.mockResolvedValue({
|
||||
...modelExtras(),
|
||||
ok: true,
|
||||
result: { content: "x", model: "gpt" },
|
||||
workingProviderType: "openai",
|
||||
|
||||
@@ -177,7 +177,7 @@ function fuzzResult(r: () => number): CoachToolResult | undefined {
|
||||
const roll = r();
|
||||
if (roll < 0.15) return undefined;
|
||||
if (roll < 0.55) {
|
||||
const ref = ["r1", "r6", "r7", "m1.r1", PLANTED[0]][Math.floor(r() * 5)];
|
||||
const ref = ["r1", "r8", "r9", "m1.r1", PLANTED[0]][Math.floor(r() * 5)];
|
||||
return {
|
||||
present: true,
|
||||
data: fuzzValue(r),
|
||||
@@ -254,7 +254,7 @@ function fuzzResult(r: () => number): CoachToolResult | undefined {
|
||||
|
||||
function assertClean(step: CoachStep, locale: Locale) {
|
||||
for (const key of Object.keys(step)) expect(STEP_KEYS.has(key)).toBe(true);
|
||||
expect(step.id).toMatch(/^s([1-9]|1[0-2])$/);
|
||||
expect(step.id).toMatch(/^s([1-9]|[1-3]\d|4[0-8])$/);
|
||||
expect(
|
||||
[...COACH_TOOL_NAMES, "snapshot", "show_result"].includes(step.tool),
|
||||
).toBe(true);
|
||||
@@ -275,7 +275,7 @@ function assertClean(step: CoachStep, locale: Locale) {
|
||||
}
|
||||
if (step.reason !== undefined) expect(REASONS.has(step.reason)).toBe(true);
|
||||
if (step.resultRef !== undefined) {
|
||||
expect(step.resultRef).toMatch(/^r[1-6]$/);
|
||||
expect(step.resultRef).toMatch(/^r[1-8]$/);
|
||||
}
|
||||
const wire = JSON.stringify(step);
|
||||
for (const planted of PLANTED) expect(wire).not.toContain(planted);
|
||||
@@ -462,7 +462,7 @@ describe("toStep — mapping", () => {
|
||||
parsedArgs: parsed,
|
||||
locale: "en",
|
||||
})?.id,
|
||||
).toBe("s12");
|
||||
).toBe("s48");
|
||||
expect(
|
||||
toStep({
|
||||
call: { id: "u", name: "get_everything", arguments: "{}" },
|
||||
@@ -572,7 +572,7 @@ describe("toStep — tables", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("past the sixth table the read still counts, with no table to name", () => {
|
||||
it("past the eighth table the read still counts, with no table to name", () => {
|
||||
const {
|
||||
table: _table,
|
||||
resultRef: _ref,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Stubs the turn pipeline's unit tests share: a ledger that books nothing,
|
||||
* the context fields the budget estimate reads, and the v1.41 fields of a
|
||||
* model outcome.
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { createActivityRecorder } from "@/lib/ai/coach/activity/recorder";
|
||||
|
||||
/** A turn ledger that books nothing and records its calls. */
|
||||
export function stubLedger() {
|
||||
return {
|
||||
reservation: { reserved: 0, owner: "user" as const, dateKey: "2026-10-06" },
|
||||
reserveRound: vi.fn(async () => true),
|
||||
settleRound: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The context fields the first-round estimate reads. */
|
||||
export const STUB_PROMPT_CONTEXT = {
|
||||
systemPrompt: "",
|
||||
turnContext: { transcript: "" },
|
||||
} as const;
|
||||
|
||||
/** The v1.41 fields of a successful model outcome, all empty. */
|
||||
export function modelExtras() {
|
||||
return {
|
||||
activity: createActivityRecorder({
|
||||
emit: () => {},
|
||||
screen: { locale: "en", figures: () => [] },
|
||||
}),
|
||||
toolClarification: null,
|
||||
declinedClarifications: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
interimSent: false,
|
||||
};
|
||||
}
|
||||
@@ -25,8 +25,17 @@ describe("clarifyAddendum", () => {
|
||||
});
|
||||
|
||||
it("stays short: it rides every tool-mode round", () => {
|
||||
// About 250 tokens at four characters a token.
|
||||
expect(clarifyAddendum("en").length).toBeLessThan(1_000);
|
||||
// About 450 tokens at four characters a token: the v1.41 triggers and
|
||||
// the tool rules on top of the sentinel fallback.
|
||||
expect(clarifyAddendum("en").length).toBeLessThan(1_800);
|
||||
});
|
||||
|
||||
it("asks through the tool, and only on the listed triggers", () => {
|
||||
const text = clarifyAddendum("en");
|
||||
expect(text).toContain("ask_clarification");
|
||||
expect(text).toMatch(/visibly depends/);
|
||||
expect(text).toMatch(/round two/);
|
||||
expect(text).toMatch(/Otherwise do not ask/);
|
||||
});
|
||||
|
||||
it("teaches a block the parser accepts", () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Prompt rules for clarifying questions: ask only when the metric or window
|
||||
* stays genuinely ambiguous, at most one question, never about doses or
|
||||
* diagnoses. The server enforces the rest (`clarify.ts`): metric choices the
|
||||
* record does not hold are dropped, a second question in a row gets no card,
|
||||
* and a screened question loses its choices.
|
||||
* Prompt rules for clarifying questions (v1.41): ask only when the answer
|
||||
* visibly depends on the choice and nothing in the conversation settles it,
|
||||
* through the `ask_clarification` tool, with the assumption inside the
|
||||
* question. Otherwise assume and say so in one clause. The server enforces
|
||||
* the rest (`clarify.ts`): choices from catalogs and the record only, never
|
||||
* two in a row, one in six turns and three a day, a screened question dropped.
|
||||
*
|
||||
* English whatever the reply language, like the rest of the tool-mode
|
||||
* prompt: these are instructions to the model, never shown to the person.
|
||||
@@ -11,11 +12,18 @@
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const CLARIFY_ADDENDUM = `CLARIFYING QUESTIONS
|
||||
Ask only when the metric or window stays ambiguous after the defaults (e.g. "how is my pulse?" while the DATA INVENTORY marks pulse, resting_hr and walking_hr present); otherwise answer with the conversation's metric and the default window.
|
||||
Ask only when the answer visibly depends on a choice AND neither the conversation nor what you know about the person settles it. Then call ask_clarification instead of answering. It applies when:
|
||||
- two or more metrics the DATA INVENTORY marks present fit the person's word (the pulse family; weight or body fat): kind metric;
|
||||
- they ask whether something is good or has improved, name no period, the conversation has none, and the record reaches back more than 90 days: kind window or comparison;
|
||||
- they ask what to change and have two or more goals or active plans: kind goal;
|
||||
- they ask "since" without saying since when, and there are several candidates: kind anchor.
|
||||
Otherwise do not ask: make the assumption and name it in one clause of the answer ("Assumed: the last 30 days.").
|
||||
- You may read first in round one and ask in round two. Never ask after that.
|
||||
- One short, natural sentence, the assumption inside it ("Do you mean resting pulse or walking pulse? Otherwise I'll look at resting pulse."), the assumed choice first. No figures.
|
||||
- Never substitute a metric; with one candidate present, answer about it.
|
||||
- Never ask about doses, medication changes or diagnoses.
|
||||
- At most one question, never right after your own: then answer with what you have.
|
||||
- The question is your whole reply: one short sentence, no figures, no tool calls, no ---KEYVALUES--- block. End it with:
|
||||
- When ask_clarification answers { declined, assume }, answer with that assumption and name it in one clause.
|
||||
- A model without the tool may still end a reply that is only the question with:
|
||||
---CLARIFY---
|
||||
kind: metric
|
||||
choices: pulse, resting_hr, walking_hr
|
||||
|
||||
+140
-81
@@ -1,6 +1,17 @@
|
||||
/**
|
||||
* The day's token budget for one Coach turn: reserve before the provider
|
||||
* call, settle against the actual count after it, refund on failure.
|
||||
* The day's token budget for one Coach turn, booked round by round.
|
||||
*
|
||||
* v1.41 — a turn reserves its first round and the room for its final answer
|
||||
* when it starts, then each further round right before that round runs, and
|
||||
* settles every round against what it really cost as soon as it returns.
|
||||
* A further round the day's ceiling refuses is not an error: the loop answers
|
||||
* from the reserve with what it has read (`budget`). What a turn reserved and
|
||||
* never used (the final reserve of a turn that answered on its own, a round
|
||||
* that failed) is given back when the turn ends.
|
||||
*
|
||||
* The atomic primitives are the ledger's own (`reserveBudget` /
|
||||
* `reconcileSpend` in `coach/budget.ts`); the loop sees only the two
|
||||
* callbacks of `RoundSpend`, so it stays free of the database.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
@@ -12,49 +23,61 @@ import {
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { MAX_ROUNDS } from "@/lib/ai/coach/tools";
|
||||
import type { RoundSpend } from "@/lib/ai/coach/tools/loop";
|
||||
|
||||
import type { TurnChain } from "./chain";
|
||||
import { streamProviderError } from "./sse";
|
||||
|
||||
export interface TurnReservation {
|
||||
/** Everything reserved at the turn's start: round one plus the final reserve. */
|
||||
reserved: number;
|
||||
owner: Awaited<ReturnType<typeof reserveBudget>>["owner"];
|
||||
dateKey: string;
|
||||
}
|
||||
|
||||
/** The turn's ledger: the loop's two callbacks plus the close-out. */
|
||||
export interface TurnLedger extends RoundSpend {
|
||||
readonly reservation: TurnReservation;
|
||||
/**
|
||||
* Give back everything still reserved and not settled. Called once the
|
||||
* turn is over, whatever way it ended; a second call does nothing.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function reserveTurnBudget(args: {
|
||||
userId: string;
|
||||
chain: TurnChain;
|
||||
toolMode: boolean;
|
||||
/**
|
||||
* v1.41 — the first round, estimated from the prompt. Absent on the
|
||||
* no-tools path, which reserves one reply.
|
||||
*/
|
||||
firstRound?: number;
|
||||
/** v1.41 — the room held back for the final answer (tool path). */
|
||||
finalReserve?: number;
|
||||
}): Promise<
|
||||
{ ok: true; reservation: TurnReservation } | { ok: false; response: Response }
|
||||
{ ok: true; ledger: TurnLedger } | { ok: false; response: Response }
|
||||
> {
|
||||
const { userId, chain, toolMode } = args;
|
||||
// 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;
|
||||
// concurrent requests serialise on the row so they cannot all pass the cap.
|
||||
// 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.
|
||||
// v1.21.0 (F1) — the daily ceiling is the OPERATOR's cost cap only when the
|
||||
// chain egresses via the operator's own key (`admin-openai` primary). A
|
||||
// ChatGPT-OAuth/Codex or BYOK chain runs on the user's OWN plan/key and costs
|
||||
// the operator nothing, so it gets the generous user-plan ceiling — gating it
|
||||
// on the operator-cost cap would lock the user out of a plan they pay for.
|
||||
// v1.18.7 — atomically RESERVE before the provider call: one upsert
|
||||
// increments the day's total and returns it, so concurrent requests cannot
|
||||
// all pass the cap. Over-cap → 429 refusal frame, reservation already
|
||||
// refunded.
|
||||
// v1.21.0 — the ceiling is the operator's cost cap only when the chain
|
||||
// egresses through the operator's own key or account; a chain on the
|
||||
// person's own plan or key gets the generous user-plan ceiling.
|
||||
const dailyCap = resolveDailyCap(chain);
|
||||
const reqDateKey = buildDateKey();
|
||||
const firstRound = toolMode
|
||||
? Math.max(1, Math.floor(args.firstRound ?? AI_BUDGETS.coach.maxTokens))
|
||||
: AI_BUDGETS.coach.maxTokens;
|
||||
const finalReserve = toolMode
|
||||
? Math.max(0, Math.floor(args.finalReserve ?? 0))
|
||||
: 0;
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
toolMode
|
||||
? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS
|
||||
: AI_BUDGETS.coach.maxTokens,
|
||||
firstRound + finalReserve,
|
||||
reqDateKey,
|
||||
dailyCap,
|
||||
resolveCostOwner(chain),
|
||||
@@ -62,11 +85,6 @@ export async function reserveTurnBudget(args: {
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
// v1.38.19 — say WHICH ceiling refused and WHOSE.
|
||||
// `totalAfter` alone is the day's mixed total; on an operator refusal that
|
||||
// is not the counter that tripped, and reading `totalAfter: 1200000`
|
||||
// against a 200 000 ceiling is what turned the 06:42Z incident into a
|
||||
// production log dig. Integers and two small enums — no key, host or model
|
||||
// name is added.
|
||||
annotate({
|
||||
action: { name: "coach.budget.exceeded" },
|
||||
meta: {
|
||||
@@ -85,62 +103,103 @@ export async function reserveTurnBudget(args: {
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reservation: {
|
||||
reserved: reservation.reserved,
|
||||
owner: reservation.owner,
|
||||
dateKey: reqDateKey,
|
||||
},
|
||||
ledger: createTurnLedger({
|
||||
userId,
|
||||
cap: dailyCap,
|
||||
reservation: {
|
||||
reserved: reservation.reserved,
|
||||
owner: reservation.owner,
|
||||
dateKey: reqDateKey,
|
||||
},
|
||||
firstRound: Math.min(firstRound, reservation.reserved),
|
||||
finalReserve: Math.max(0, reservation.reserved - firstRound),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider chain failed outright — no tokens were billed, so refund the
|
||||
* full reservation before surfacing the error frame.
|
||||
* The ledger of one turn over a reservation already made. Exported for the
|
||||
* tests; the pipeline gets it from `reserveTurnBudget`.
|
||||
*/
|
||||
export async function refundReservation(
|
||||
userId: string,
|
||||
reservation: TurnReservation,
|
||||
): Promise<void> {
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
0,
|
||||
reservation.dateKey,
|
||||
0,
|
||||
{
|
||||
servedBy: null,
|
||||
reservedOwner: reservation.owner,
|
||||
},
|
||||
).catch(() => {});
|
||||
}
|
||||
export function createTurnLedger(args: {
|
||||
userId: string;
|
||||
cap: number;
|
||||
reservation: TurnReservation;
|
||||
firstRound: number;
|
||||
finalReserve: number;
|
||||
}): TurnLedger {
|
||||
const { userId, reservation } = args;
|
||||
// Reservations not settled yet, oldest first; the final reserve apart.
|
||||
const open: number[] = [args.firstRound];
|
||||
let finalHeld = args.finalReserve;
|
||||
let closed = false;
|
||||
|
||||
/**
|
||||
* v1.18.7 (SENIOR-DEV MEDIUM) — the provider call returned, so its tokens
|
||||
* were billed regardless of reply quality. Reconcile the reservation 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.
|
||||
*/
|
||||
export async function settleReservation(
|
||||
userId: string,
|
||||
reservation: TurnReservation,
|
||||
spent: {
|
||||
totalTokens: number;
|
||||
cachedTokens: number;
|
||||
servedBy: ProviderChainType;
|
||||
},
|
||||
): Promise<void> {
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
spent.totalTokens,
|
||||
reservation.dateKey,
|
||||
spent.cachedTokens,
|
||||
{ servedBy: spent.servedBy, reservedOwner: reservation.owner },
|
||||
).catch(() => {
|
||||
// Ledger reconcile is best-effort; a failure leaves the conservative
|
||||
// reservation in place (never an undercount) and never breaks the turn.
|
||||
});
|
||||
const settle = (
|
||||
reserved: number,
|
||||
actual: number,
|
||||
cached: number,
|
||||
servedBy: ProviderChainType | null,
|
||||
) =>
|
||||
reconcileSpend(userId, reserved, actual, reservation.dateKey, cached, {
|
||||
servedBy,
|
||||
reservedOwner: reservation.owner,
|
||||
}).catch(() => {
|
||||
// Ledger reconcile is best-effort; a failure leaves the conservative
|
||||
// reservation in place (never an undercount) and never breaks the turn.
|
||||
});
|
||||
|
||||
return {
|
||||
reservation,
|
||||
async reserveRound(estimate) {
|
||||
if (closed) return false;
|
||||
try {
|
||||
const next = await reserveBudget(
|
||||
userId,
|
||||
estimate,
|
||||
reservation.dateKey,
|
||||
args.cap,
|
||||
reservation.owner,
|
||||
"coach",
|
||||
undefined,
|
||||
{ countMessage: false },
|
||||
);
|
||||
if (!next.allowed) {
|
||||
annotate({
|
||||
action: { name: "coach.budget.round_refused" },
|
||||
meta: {
|
||||
owner: next.owner,
|
||||
limit: next.limit,
|
||||
cap: args.cap,
|
||||
round: open.length + 1,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
open.push(next.reserved);
|
||||
return true;
|
||||
} catch {
|
||||
// An unreadable ledger answers from the reserve, never runs unbooked.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async settleRound({ tokens, cachedTokens, servedBy, final }) {
|
||||
if (closed) return;
|
||||
let portion: number;
|
||||
if (final) {
|
||||
portion = finalHeld;
|
||||
finalHeld = 0;
|
||||
} else {
|
||||
portion = open.shift() ?? 0;
|
||||
}
|
||||
await settle(portion, tokens, cachedTokens, servedBy);
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
const unused = open.reduce((sum, n) => sum + n, 0) + finalHeld;
|
||||
open.length = 0;
|
||||
finalHeld = 0;
|
||||
if (unused > 0) await settle(unused, 0, 0, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
+348
-86
@@ -32,7 +32,39 @@ import {
|
||||
} from "@/lib/ai/coach/tools";
|
||||
import { admittedPriorResults } from "@/lib/ai/coach/tools/executor";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
import type { CoachResultTable, CoachStep } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COMPARE_SERIES_TOOL_DEF,
|
||||
COMPARE_SERIES_TOOL_NAME,
|
||||
compareSeriesArgsSchema,
|
||||
} from "@/lib/ai/coach/tools/compare-series";
|
||||
import { DIALOG_TOOL_DEFS } from "@/lib/ai/coach/tools/dialog-tools";
|
||||
import {
|
||||
createTurnBudget,
|
||||
estimateInputTokens,
|
||||
type TurnPayer,
|
||||
} from "@/lib/ai/coach/tools/turn-budget";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStop,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { AiToolCall } from "@/lib/ai/types";
|
||||
import type { ActivityRecorder } from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
createActivityRecorder,
|
||||
type TurnActivityRecorder,
|
||||
} from "@/lib/ai/coach/activity/recorder";
|
||||
import {
|
||||
digestActivityLabel,
|
||||
digestDoneActivityLabel,
|
||||
fetchActivityLabel,
|
||||
memoryActivityLabel,
|
||||
simpleActivityLabel,
|
||||
} from "@/lib/ai/coach/activity/catalog";
|
||||
import { buildMemoryContextBlock } from "@/lib/ai/coach/memory/contract";
|
||||
import {
|
||||
projectResults,
|
||||
type SettledToolCall,
|
||||
@@ -48,7 +80,8 @@ import {
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
|
||||
import { buildDialogAddenda } from "./addenda";
|
||||
import { refundReservation, type TurnReservation } from "./budget";
|
||||
import type { TurnLedger } from "./budget";
|
||||
import type { TurnReasoning } from "./reasoning";
|
||||
import type { TurnChain } from "./chain";
|
||||
import type { TurnContext } from "./context";
|
||||
import { classifyBubblingProviderError } from "./errors";
|
||||
@@ -120,6 +153,23 @@ export type ModelOutcome =
|
||||
* turn, for the related-metric chip. Empty on the no-tools path.
|
||||
*/
|
||||
correlations: CorrelationPair[];
|
||||
/** v1.41 — the turn's live trail. */
|
||||
activity: TurnActivityRecorder;
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
/** v1.41 — the question the turn ended on, asked through the tool. */
|
||||
toolClarification: {
|
||||
question: string;
|
||||
clarification: CoachClarification;
|
||||
} | null;
|
||||
/** v1.41 — questions the brake turned into assumptions. */
|
||||
declinedClarifications: CoachClarification[];
|
||||
/** v1.41 — the fact the turn saved or proposes. */
|
||||
memoryNote: CoachMemoryNote | null;
|
||||
/** v1.41 — the plan the turn proposes. */
|
||||
planProposal: CoachPlanProposal | null;
|
||||
/** v1.41 — tables already went out as interim `result` frames. */
|
||||
interimSent: boolean;
|
||||
}
|
||||
| { ok: false; code: string };
|
||||
|
||||
@@ -183,6 +233,30 @@ function appendBlocks(base: string, blocks: string[]): string {
|
||||
return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
|
||||
}
|
||||
|
||||
/** The tools a tool-mode turn offers: the catalogue, comparisons, the dialog. */
|
||||
export const COACH_TURN_TOOL_DEFS = [
|
||||
...COACH_TOOL_DEFS,
|
||||
COMPARE_SERIES_TOOL_DEF,
|
||||
...DIALOG_TOOL_DEFS,
|
||||
];
|
||||
|
||||
/** A call's validated arguments, `compare_series` included. */
|
||||
function validatedArgs(call: AiToolCall): Record<string, unknown> | undefined {
|
||||
if (call.name !== COMPARE_SERIES_TOOL_NAME) {
|
||||
return parseCoachToolArgs(call.name, call.arguments);
|
||||
}
|
||||
try {
|
||||
const parsed = compareSeriesArgsSchema.safeParse(
|
||||
call.arguments.trim() === "" ? {} : JSON.parse(call.arguments),
|
||||
);
|
||||
return parsed.success
|
||||
? (parsed.data as Record<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTurnModel(args: {
|
||||
userId: string;
|
||||
locale: Locale;
|
||||
@@ -191,8 +265,9 @@ export async function runTurnModel(args: {
|
||||
ctx: TurnContext;
|
||||
chain: TurnChain;
|
||||
toolMode: boolean;
|
||||
reservation: TurnReservation;
|
||||
/** The turn's frame channel; the live `step` frames go out on it. */
|
||||
/** v1.41 — the day's ledger, booked round by round. */
|
||||
ledger: TurnLedger;
|
||||
/** The turn's frame channel; the live `step` and `activity` frames go out on it. */
|
||||
emitter: TurnEmitter;
|
||||
/**
|
||||
* v1.39.4 — server-authored context lines for this turn (a resolved
|
||||
@@ -205,18 +280,76 @@ export async function runTurnModel(args: {
|
||||
* for the context and for `show_result`. Empty on a new conversation.
|
||||
*/
|
||||
priorResults?: PriorResultTurn[];
|
||||
/** v1.41 — the person's message, the only source of a remembered fact. */
|
||||
message: string;
|
||||
/** v1.41 — the reasoning the turn asks for, already resolved. */
|
||||
reasoning?: TurnReasoning;
|
||||
/** v1.41 — who pays: fixes the turn's budget. */
|
||||
payer: TurnPayer;
|
||||
/** v1.41 — a window was set for this conversation by the client. */
|
||||
conversationWindowSet: boolean;
|
||||
}): Promise<ModelOutcome> {
|
||||
const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args;
|
||||
const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx;
|
||||
const steps = stepRecorder(args.emitter);
|
||||
// v1.41 — the figures read so far, which screen the trail's model text.
|
||||
const figures: unknown[] = [];
|
||||
const recorder = createActivityRecorder({
|
||||
emit: (activity) => {
|
||||
if (!args.emitter.aborted()) {
|
||||
args.emitter.emit({ type: "activity", activity });
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
locale,
|
||||
figures: () => figures,
|
||||
userMessage: args.message,
|
||||
scheduleDoses: ctx.scheduleDoses,
|
||||
},
|
||||
});
|
||||
// A round's digest runs until the next entry opens: the next round's
|
||||
// thinking, or the stop before it.
|
||||
let digest: { id: string; count: number; areas: number } | null = null;
|
||||
const closeDigest = () => {
|
||||
if (!digest) return;
|
||||
const { id, count, areas } = digest;
|
||||
digest = null;
|
||||
recorder.finish(id, "done", {
|
||||
...digestDoneActivityLabel(locale, count, areas),
|
||||
count,
|
||||
});
|
||||
};
|
||||
const activity: ActivityRecorder = {
|
||||
...recorder,
|
||||
start: (entry) => {
|
||||
closeDigest();
|
||||
return recorder.start(entry);
|
||||
},
|
||||
};
|
||||
try {
|
||||
// v1.41 — what the Coach knows about the person, built only now: the
|
||||
// capability was re-checked at the egress site before the model step.
|
||||
const memory = await buildMemoryContextBlock({
|
||||
userId,
|
||||
conversationId,
|
||||
locale,
|
||||
}).catch(() => null);
|
||||
if (memory) {
|
||||
const count = memory.factIds.length + memory.planIds.length;
|
||||
const id = activity.start({
|
||||
phase: "memory",
|
||||
round: 1,
|
||||
...memoryActivityLabel(locale, count),
|
||||
count,
|
||||
});
|
||||
activity.finish(id, "done");
|
||||
}
|
||||
|
||||
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.
|
||||
// model pulls only what it needs via the retrieval tools.
|
||||
const inventory = await buildCoachDataInventory(
|
||||
userId,
|
||||
effectiveScope,
|
||||
@@ -224,8 +357,7 @@ export async function runTurnModel(args: {
|
||||
);
|
||||
// Earlier tables whose metric the person has since excluded, or whose
|
||||
// range lies beyond the lookback limit, are neither named for the
|
||||
// model nor reachable through show_result. The
|
||||
// scope is the one every tool of the turn reads under.
|
||||
// model nor reachable through show_result.
|
||||
const priorResults = await admittedPriorResults({
|
||||
userId,
|
||||
prefs: ctx.coachPrefs,
|
||||
@@ -233,8 +365,12 @@ export async function runTurnModel(args: {
|
||||
reach: ctx.reach,
|
||||
prior: args.priorResults ?? [],
|
||||
});
|
||||
const requestWith = (tableRules: boolean) =>
|
||||
buildCoachToolRequest({
|
||||
const requestWith = (tableRules: boolean) => {
|
||||
// `memoryBlock` is the memory package's parameter; held in a
|
||||
// variable so the request builds whether or not it is declared yet.
|
||||
const request: Parameters<typeof buildCoachToolRequest>[0] & {
|
||||
memoryBlock?: string;
|
||||
} = {
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
|
||||
buildDialogAddenda(locale, { tableRules }),
|
||||
@@ -247,7 +383,10 @@ export async function runTurnModel(args: {
|
||||
guidedBlock: turnContext.guidedBlock,
|
||||
transcript: turnContext.transcript,
|
||||
languageName: LANGUAGE_NAMES[locale],
|
||||
});
|
||||
...(memory ? { memoryBlock: memory.text } : {}),
|
||||
};
|
||||
return buildCoachToolRequest(request);
|
||||
};
|
||||
// The recheck and follow-up rules are about tables: they ride the
|
||||
// prompt once the conversation holds one, or from the round after this
|
||||
// turn produced its first.
|
||||
@@ -257,82 +396,156 @@ export async function runTurnModel(args: {
|
||||
// v1.39.4 — every call this turn settled, by its turn-wide index, for
|
||||
// the result tables. Only this turn's own calls ever reach them.
|
||||
const settled: SettledToolCall[] = [];
|
||||
// v1.41 — the trail entry of each call, and what each round read.
|
||||
const fetches = new Map<number, { id: string; round: number }>();
|
||||
const roundReads = new Map<
|
||||
number,
|
||||
Array<{ count: number; domain: string | undefined; done: boolean }>
|
||||
>();
|
||||
let interimSent = false;
|
||||
const budget = createTurnBudget({
|
||||
payer: args.payer,
|
||||
effort: args.reasoning?.effort,
|
||||
initialInputTokens: estimateInputTokens(
|
||||
toolRequest.system.length +
|
||||
toolRequest.messages
|
||||
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
||||
.join("").length,
|
||||
),
|
||||
});
|
||||
const loop = await runCoachToolLoop({
|
||||
userId,
|
||||
providers: chain,
|
||||
system: toolRequest.system,
|
||||
systemOnceTableShown: withTables.system,
|
||||
messages: toolRequest.messages,
|
||||
tools: COACH_TOOL_DEFS,
|
||||
tools: COACH_TURN_TOOL_DEFS,
|
||||
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.
|
||||
// every tool so the turn builds ONE snapshot, not one per tool.
|
||||
sharedScope: inventory.probeScope,
|
||||
// The lookback limit: every tool call is clamped to it.
|
||||
reach: ctx.reach,
|
||||
// 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.
|
||||
// v1.20.1 — a mid-generation disconnect tears the round calls down.
|
||||
signal,
|
||||
// v1.22 (#89) — per-user response timeout for each tool-round call.
|
||||
// v1.22 (#89) — per-user response timeout for each round's call.
|
||||
timeoutMs: ctx.aiResponseTimeoutMs,
|
||||
// v1.39.4 — result tables: each call may produce one, named `r1`..
|
||||
// in the order they settle; `show_result` resolves only against this
|
||||
// conversation's own earlier tables.
|
||||
// in the order they settle.
|
||||
turn: {
|
||||
conversationId,
|
||||
locale,
|
||||
priorResults,
|
||||
refs: createResultRefAllocator(),
|
||||
},
|
||||
budget,
|
||||
spend: args.ledger,
|
||||
...(args.reasoning ? { reasoning: args.reasoning } : {}),
|
||||
activity,
|
||||
locale,
|
||||
dialog: {
|
||||
userId,
|
||||
conversationId,
|
||||
locale,
|
||||
userMessage: args.message,
|
||||
inventory: inventory.entries,
|
||||
conversationWindowSet: args.conversationWindowSet,
|
||||
},
|
||||
checkpointScreen: { userMessage: args.message },
|
||||
// v1.39.4 — live steps: a `running` step as each call starts, its
|
||||
// final status as it settles.
|
||||
onCallStart: (call, index) =>
|
||||
steps.record(
|
||||
toStep({
|
||||
call,
|
||||
index,
|
||||
parsedArgs: parseCoachToolArgs(call.name, call.arguments),
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
// final status as it settles. v1.41 — and a `fetch` trail entry.
|
||||
onCallStart: (call, index, round) => {
|
||||
const step = toStep({
|
||||
call,
|
||||
index,
|
||||
parsedArgs: validatedArgs(call),
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
});
|
||||
steps.record(step);
|
||||
const id = activity.start({
|
||||
phase: "fetch",
|
||||
round,
|
||||
...fetchActivityLabel(locale, {
|
||||
domain: step?.domain ?? "snapshot",
|
||||
window: step?.window,
|
||||
...(step && !step.window
|
||||
? { fallback: { labelKey: step.labelKey, label: step.label } }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
onCallSettled: (call, result, index) => {
|
||||
const parsedArgs = parseCoachToolArgs(call.name, call.arguments);
|
||||
settled[index] = {
|
||||
...(step ? { stepRef: step.id } : {}),
|
||||
});
|
||||
fetches.set(index, { id, round });
|
||||
},
|
||||
onCallSettled: (call, result, index, round) => {
|
||||
const parsedArgs = validatedArgs(call);
|
||||
const settledCall: SettledToolCall = {
|
||||
name: call.name,
|
||||
...(parsedArgs ? { args: parsedArgs } : {}),
|
||||
result,
|
||||
};
|
||||
steps.record(
|
||||
toStep({
|
||||
call,
|
||||
index,
|
||||
parsedArgs,
|
||||
result,
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
}),
|
||||
);
|
||||
settled[index] = settledCall;
|
||||
if (result.present || result.available !== undefined) {
|
||||
figures.push(result.data ?? result.available);
|
||||
}
|
||||
const step = toStep({
|
||||
call,
|
||||
index,
|
||||
parsedArgs,
|
||||
result,
|
||||
locale,
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
});
|
||||
steps.record(step);
|
||||
const entry = fetches.get(index);
|
||||
const status = step?.status ?? (result.present ? "done" : "empty");
|
||||
if (entry) {
|
||||
activity.finish(entry.id, status === "running" ? "done" : status, {
|
||||
...(step?.count !== undefined ? { count: step.count } : {}),
|
||||
});
|
||||
}
|
||||
const reads = roundReads.get(round) ?? [];
|
||||
reads.push({
|
||||
count: step?.count ?? 0,
|
||||
domain: step?.domain,
|
||||
done: status === "done",
|
||||
});
|
||||
roundReads.set(round, reads);
|
||||
// v1.41 — a table goes out the moment it is read, as an interim
|
||||
// result the answer later shows or files under "Data used".
|
||||
if (result.table && !args.emitter.aborted()) {
|
||||
const [table] = buildTurnResults([settledCall], locale);
|
||||
if (table) {
|
||||
args.emitter.emit({ type: "result", result: table, interim: true });
|
||||
interimSent = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
onRoundSettled: (round) => {
|
||||
const reads = roundReads.get(round) ?? [];
|
||||
if (reads.length === 0) return;
|
||||
const count = reads.reduce((sum, read) => sum + read.count, 0);
|
||||
const areas = new Set(
|
||||
reads.filter((read) => read.done).map((read) => read.domain),
|
||||
).size;
|
||||
const id = activity.start({
|
||||
phase: "digest",
|
||||
round,
|
||||
...digestActivityLabel(locale, count),
|
||||
count,
|
||||
});
|
||||
digest = { id, count, areas };
|
||||
},
|
||||
});
|
||||
closeDigest();
|
||||
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
|
||||
// delivered figures the model was told to ground against: a pinned
|
||||
// workout-evidence block or a present tool result. The DATA INVENTORY
|
||||
// manifest (sample counts per domain) is NOT an activator — it rides
|
||||
// every tool-mode prompt even on a turn where the model answered
|
||||
// without calling a tool, and on that turn the base prompt deliberately
|
||||
// carries no pre-computed figures (the model must fetch them), so the
|
||||
// verifier must stay dormant and leave the prompt-level grounding rule
|
||||
// as the backstop, exactly as on `main`. Activating it off the
|
||||
// counts-only inventory would flag a snapshot figure the model cited
|
||||
// without a fresh tool call as ungrounded (a real regression caught by
|
||||
// the integration suite). When the turn IS active, the inventory counts
|
||||
// still WIDEN the authoritative set so a plain count restatement
|
||||
// ("you've logged 42 BP readings") stays grounded.
|
||||
// manifest is NOT an activator; when the turn IS active, the inventory
|
||||
// counts WIDEN the authoritative set so a plain count restatement stays
|
||||
// grounded.
|
||||
const presentToolPayloads = [
|
||||
...(workoutEvidence === null ? [] : [workoutEvidence]),
|
||||
// A miss carries no `data` but may carry `available` — the bounded
|
||||
@@ -364,22 +577,49 @@ export async function runTurnModel(args: {
|
||||
correlations: correlationPartners(
|
||||
settled.filter((call) => call !== undefined),
|
||||
),
|
||||
activity: recorder,
|
||||
...(loop.stop ? { stop: loop.stop } : {}),
|
||||
toolClarification: loop.clarification ?? null,
|
||||
declinedClarifications: loop.declinedClarifications ?? [],
|
||||
memoryNote: loop.memoryNote ?? null,
|
||||
planProposal: loop.planProposal ?? null,
|
||||
interimSent,
|
||||
};
|
||||
}
|
||||
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
|
||||
// that includes a non-tool provider) runs through the STREAMING runner so
|
||||
// the local client emits real tokens as they arrive and the per-idle-gap
|
||||
// timeout governs. `onDelta` counts streamed chunks for observability; the
|
||||
// heartbeat keeps the proxy connection warm and the assembled reply is
|
||||
// returned in full so every guard still runs on the complete text.
|
||||
// timeout governs. The assembled reply is returned in full so every guard
|
||||
// still runs on the complete text.
|
||||
let streamedDeltas = 0;
|
||||
// v1.39.4 — the no-tools path reads the whole snapshot: one step.
|
||||
steps.record(
|
||||
snapshotStep({
|
||||
metricCount: snapshot.provenance.metrics.length,
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
const snapshotRead = snapshotStep({
|
||||
metricCount: snapshot.provenance.metrics.length,
|
||||
locale,
|
||||
});
|
||||
steps.record(snapshotRead);
|
||||
if (snapshotRead) {
|
||||
const id = activity.start({
|
||||
phase: "fetch",
|
||||
round: 1,
|
||||
labelKey: snapshotRead.labelKey,
|
||||
label: snapshotRead.label,
|
||||
stepRef: snapshotRead.id,
|
||||
...(snapshotRead.count !== undefined
|
||||
? { count: snapshotRead.count }
|
||||
: {}),
|
||||
});
|
||||
activity.finish(id, snapshotRead.status === "done" ? "done" : "empty");
|
||||
}
|
||||
const thinking = activity.start({
|
||||
phase: "thinking",
|
||||
round: 1,
|
||||
...simpleActivityLabel(locale, "thinking"),
|
||||
});
|
||||
const summaries: string[] = [];
|
||||
// v1.41 — the memory block rides the user turn here too, ahead of the
|
||||
// snapshot, fenced like the rest of the person's own context.
|
||||
const user = memory ? `${memory.text}\n\n${ctx.userPrompt}` : ctx.userPrompt;
|
||||
const fallback = await runStreamingRawCompletionWithFallback({
|
||||
surface: "coach",
|
||||
userId,
|
||||
@@ -387,29 +627,46 @@ export async function runTurnModel(args: {
|
||||
onDelta: () => {
|
||||
streamedDeltas += 1;
|
||||
},
|
||||
// v1.20.0 — the no-tools path still builds one assembled user turn (the
|
||||
// transcript-flattening includes the current authoritative snapshot
|
||||
// on every stateless no-tools request), so it ships as a single user
|
||||
// message. The stable persona rides `system` and is cache-eligible.
|
||||
params: singleUserTurn({
|
||||
system: appendBlocks(ctx.systemPrompt, args.turnHints),
|
||||
user: ctx.userPrompt,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
maxTokens: AI_BUDGETS.coach.maxTokens,
|
||||
// v1.20.1 — thread the request's abort signal so a mid-generation
|
||||
// client disconnect tears the upstream provider call down instead of
|
||||
// paying the full token cost into a closed connection.
|
||||
signal,
|
||||
// v1.22 (#89) — per-idle-gap timeout for the streaming local call /
|
||||
// whole-call timeout for the buffered cloud fallback.
|
||||
timeoutMs: ctx.aiResponseTimeoutMs,
|
||||
}),
|
||||
params: {
|
||||
...singleUserTurn({
|
||||
system: appendBlocks(ctx.systemPrompt, args.turnHints),
|
||||
user,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
maxTokens: AI_BUDGETS.coach.maxTokens,
|
||||
// v1.20.1 — tear the upstream call down on a client disconnect.
|
||||
signal,
|
||||
// v1.22 (#89) — per-idle-gap timeout for the streaming local call.
|
||||
timeoutMs: ctx.aiResponseTimeoutMs,
|
||||
}),
|
||||
...(args.reasoning
|
||||
? {
|
||||
reasoning: args.reasoning,
|
||||
onReasoning: (event) => {
|
||||
if (event.kind === "title") {
|
||||
activity.update(thinking, { title: event.text });
|
||||
} else if (event.kind === "text" && event.text.trim()) {
|
||||
summaries.push(event.text);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.stream.deltas" },
|
||||
meta: { deltas: streamedDeltas },
|
||||
});
|
||||
const result = fallback.result;
|
||||
await args.ledger.settleRound({
|
||||
tokens: result.tokensUsed ?? 0,
|
||||
cachedTokens: result.cachedInputTokens ?? 0,
|
||||
servedBy: fallback.workingProvider.providerType,
|
||||
final: false,
|
||||
});
|
||||
const summaryText = (
|
||||
summaries.length > 0 ? summaries : (result.reasoning?.summary ?? [])
|
||||
).join("\n\n");
|
||||
activity.finish(thinking, "done", summaryText ? { text: summaryText } : {});
|
||||
return {
|
||||
ok: true,
|
||||
result,
|
||||
@@ -417,8 +674,7 @@ export async function runTurnModel(args: {
|
||||
toolTrace: [],
|
||||
toolResultPayloads: [],
|
||||
// v1.21.2 (A8) — the no-tools path grades only when it delivered the
|
||||
// full SNAPSHOT. Stateless provider requests always do; retaining the
|
||||
// conditional keeps the builder contract explicit and testable.
|
||||
// full SNAPSHOT.
|
||||
noToolsSnapshotPayloads: turnContext.includeFullSnapshot
|
||||
? [
|
||||
snapshot.sections,
|
||||
@@ -433,11 +689,17 @@ export async function runTurnModel(args: {
|
||||
forcedFinal: false,
|
||||
inventory: null,
|
||||
correlations: [],
|
||||
activity: recorder,
|
||||
toolClarification: null,
|
||||
declinedClarifications: [],
|
||||
memoryNote: null,
|
||||
planProposal: null,
|
||||
interimSent: false,
|
||||
};
|
||||
} catch (err) {
|
||||
// The provider chain failed outright — no tokens were billed, so refund
|
||||
// the full reservation before surfacing the error frame.
|
||||
await refundReservation(userId, args.reservation);
|
||||
// The provider chain failed: every round that returned is already
|
||||
// settled; give back what is still reserved before the error frame.
|
||||
await args.ledger.close();
|
||||
// #781 — the client walked away mid-generation. The request's abort
|
||||
// signal is threaded into every provider call, so the teardown surfaces
|
||||
// here as an abort-shaped failure with `request.signal` already flipped.
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
||||
import type { CoachProvenance, CoachResultTable } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachProvenance,
|
||||
CoachResultTable,
|
||||
CoachTrail,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { appendMessage } from "@/lib/ai/coach/persistence";
|
||||
|
||||
import type { TurnContext } from "./context";
|
||||
@@ -19,6 +23,8 @@ export async function persistAssistantReply(args: {
|
||||
toolMode: boolean;
|
||||
/** v1.39.4 — the tables to seal beside the message. */
|
||||
results: CoachResultTable[];
|
||||
/** v1.41 — the trail's model text, sealed beside the message. */
|
||||
trail?: CoachTrail | null;
|
||||
}): Promise<{ messageId: string }> {
|
||||
const { conversationId, replyText, provenance, model, ctx, toolMode } = args;
|
||||
const workingProviderType: ProviderChainType = model.workingProviderType;
|
||||
@@ -42,6 +48,8 @@ export async function persistAssistantReply(args: {
|
||||
model: model.result.model ?? null,
|
||||
// v1.39.4 — the tables, encrypted into their own column.
|
||||
...(args.results.length > 0 ? { results: args.results } : {}),
|
||||
// v1.41 — the trail's text, encrypted into its own column.
|
||||
...(args.trail ? { trail: args.trail } : {}),
|
||||
});
|
||||
|
||||
// v1.18.7 — the day's spend was already reconciled against the
|
||||
@@ -73,6 +81,10 @@ export async function persistAssistantReply(args: {
|
||||
// this turn (a proxy for cluster breadth) so the dashboards can
|
||||
// correlate reply shape with cluster activation.
|
||||
clusterCount: ctx.snapshot.provenance.metrics.length,
|
||||
// v1.41 — the trail and why the answer was forced, as counts and enums.
|
||||
activityEntries: provenance.activity?.length ?? 0,
|
||||
stop: provenance.stop?.reason ?? null,
|
||||
trailStored: Boolean(args.trail),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import { settleReservation, reserveTurnBudget } from "./budget";
|
||||
import { reserveTurnBudget } from "./budget";
|
||||
import { surfaceCards } from "./cards";
|
||||
import { resolveTurnChain } from "./chain";
|
||||
import { assembleTurnContext } from "./context";
|
||||
@@ -23,12 +23,19 @@ import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify";
|
||||
import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
|
||||
import { readFollowUpHistory } from "@/lib/ai/coach/follow-ups/derive";
|
||||
import { resolveContinuation } from "@/lib/ai/coach/follow-ups/continue";
|
||||
import { resolveCostOwner } from "@/lib/ai/coach/budget";
|
||||
import { simpleActivityLabel } from "@/lib/ai/coach/activity/catalog";
|
||||
import {
|
||||
createTurnBudget,
|
||||
estimateInputTokens,
|
||||
} from "@/lib/ai/coach/tools/turn-budget";
|
||||
|
||||
import { runTurnModel } from "./model";
|
||||
import { resolveTurnReasoning } from "./reasoning";
|
||||
import { persistAssistantReply } from "./persist";
|
||||
import { assembleTurnDialog, buildTurnProvenance } from "./provenance";
|
||||
import { guardReply } from "./reply-guards";
|
||||
import { runReuseTurn } from "./reuse-turn";
|
||||
import { runDecisionTurn, runReuseTurn } from "./reuse-turn";
|
||||
import {
|
||||
SSE_HEADERS,
|
||||
createTurnEmitter,
|
||||
@@ -37,7 +44,53 @@ import {
|
||||
} from "./sse";
|
||||
import type { ReplyOutcome, TurnEmitter, TurnInput } from "./types";
|
||||
|
||||
/**
|
||||
* The first round's input, estimated before the request is built: the
|
||||
* system prompt, the conversation, and a fixed allowance for the tool
|
||||
* addendum, the inventory and the tool definitions.
|
||||
*/
|
||||
function estimateFirstRoundInput(ctx: {
|
||||
systemPrompt: string;
|
||||
turnContext: { transcript: string };
|
||||
}): number {
|
||||
return estimateInputTokens(
|
||||
ctx.systemPrompt.length + ctx.turnContext.transcript.length + 24_000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One Coach turn. The person's turn slot (`releaseSlot`, held by the route
|
||||
* for the concurrency limit) is given back when the turn is over: right
|
||||
* away when it answers without a stream, when the stream closes otherwise.
|
||||
*/
|
||||
export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
input.releaseSlot?.();
|
||||
} catch {
|
||||
// The slot expires on its own; a failed release never fails the turn.
|
||||
}
|
||||
};
|
||||
let streaming = false;
|
||||
try {
|
||||
return await runTurn(input, {
|
||||
onStream: () => {
|
||||
streaming = true;
|
||||
},
|
||||
release,
|
||||
});
|
||||
} finally {
|
||||
if (!streaming) release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
input: TurnInput,
|
||||
hooks: { onStream: () => void; release: () => void },
|
||||
): Promise<Response> {
|
||||
const { userId, locale, message } = input;
|
||||
|
||||
// ── Conversation resolution ──────────────────────────────────
|
||||
@@ -61,6 +114,12 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
priorResults: conversation.priorResults,
|
||||
latest: conversation.latestMessages,
|
||||
});
|
||||
// v1.41 — a tap on "Yes, remember it" / "Take on this plan" (or their
|
||||
// "No"): answered by the memory contract with a catalog line, no model.
|
||||
if (input.memoryDecision || input.planDecision) {
|
||||
const decided = await runDecisionTurn({ input, conversation });
|
||||
if (decided) return decided;
|
||||
}
|
||||
if (resolvedFollowUp?.followUp.reuse) {
|
||||
const reused = await runReuseTurn({
|
||||
input,
|
||||
@@ -116,9 +175,29 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
const refusedAtEgress = await input.recheckCapability();
|
||||
if (refusedAtEgress) return refusedAtEgress;
|
||||
|
||||
const budget = await reserveTurnBudget({ userId, chain, toolMode });
|
||||
// v1.41 — who pays fixes the turn's budget and caps its reasoning.
|
||||
const payer = resolveCostOwner(chain);
|
||||
const reasoning = await resolveTurnReasoning({
|
||||
prefs: ctx.coachPrefs,
|
||||
payer,
|
||||
});
|
||||
// Round one and the room for the final answer, reserved before anything
|
||||
// runs; every further round is reserved right before it.
|
||||
const firstRoundInput = estimateFirstRoundInput(ctx);
|
||||
const estimate = createTurnBudget({
|
||||
payer,
|
||||
effort: reasoning.effort,
|
||||
initialInputTokens: firstRoundInput,
|
||||
});
|
||||
const budget = await reserveTurnBudget({
|
||||
userId,
|
||||
chain,
|
||||
toolMode,
|
||||
firstRound: estimate.firstRoundEstimate(),
|
||||
finalReserve: estimate.finalReserve(),
|
||||
});
|
||||
if (!budget.ok) return budget.response;
|
||||
const { reservation } = budget;
|
||||
const { ledger } = budget;
|
||||
|
||||
// v1.22 (#89) — the provider call + every safety guard + persistence run
|
||||
// INSIDE the SSE stream. This is the real fix for a slow local backend: the
|
||||
@@ -138,19 +217,19 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
ctx,
|
||||
chain,
|
||||
toolMode,
|
||||
reservation,
|
||||
ledger,
|
||||
emitter,
|
||||
turnHints,
|
||||
priorResults: conversation.priorResults,
|
||||
message,
|
||||
reasoning,
|
||||
payer,
|
||||
conversationWindowSet: input.scope?.window !== undefined,
|
||||
});
|
||||
// Every round that ran is settled; whatever is still reserved goes back.
|
||||
await ledger.close();
|
||||
if (!model.ok) return model;
|
||||
|
||||
await settleReservation(userId, reservation, {
|
||||
totalTokens: model.totalTokens,
|
||||
cachedTokens: model.cachedTokens,
|
||||
servedBy: model.workingProviderType,
|
||||
});
|
||||
|
||||
const guarded = await guardReply({
|
||||
userId,
|
||||
locale,
|
||||
@@ -185,6 +264,26 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
history,
|
||||
continuationOf: continuation?.sourceMessageId,
|
||||
});
|
||||
// v1.41 — the answer is being written: the last live entry, closed in
|
||||
// the stored trail. The fact a proposal offers is kept with the trail,
|
||||
// where a tap on "Yes, remember it" reads it back.
|
||||
const answering = model.activity.start({
|
||||
phase: "answer",
|
||||
round: Math.max(1, model.stop?.rounds ?? 1),
|
||||
...simpleActivityLabel(locale, "answer"),
|
||||
});
|
||||
const activityMeta = model.activity
|
||||
.meta()
|
||||
.map((entry) =>
|
||||
entry.id === answering ? { ...entry, status: "done" as const } : entry,
|
||||
);
|
||||
if (dialog.memoryNote?.proposal && dialog.memoryNote.proposalId) {
|
||||
model.activity.setProposal({
|
||||
proposalId: dialog.memoryNote.proposalId,
|
||||
category: dialog.memoryNote.category,
|
||||
fact: dialog.memoryNote.fact,
|
||||
});
|
||||
}
|
||||
const provenance = buildTurnProvenance({
|
||||
snapshotProvenance: ctx.snapshot.provenance,
|
||||
reply,
|
||||
@@ -195,6 +294,8 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
dialog,
|
||||
forcedFinal: model.forcedFinal,
|
||||
continuationOf: continuation?.sourceMessageId,
|
||||
activity: activityMeta,
|
||||
...(model.stop ? { stop: model.stop } : {}),
|
||||
});
|
||||
const { messageId } = await persistAssistantReply({
|
||||
conversationId: workingConversationId,
|
||||
@@ -204,6 +305,7 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
ctx,
|
||||
toolMode,
|
||||
results: dialog.results,
|
||||
trail: model.activity.trail(),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -218,6 +320,12 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
messageId,
|
||||
totalTokens: model.totalTokens,
|
||||
model: model.result.model ?? null,
|
||||
memoryNote: dialog.memoryNote,
|
||||
planProposal: dialog.planProposal,
|
||||
...(model.stop ? { stop: model.stop } : {}),
|
||||
// Interim tables went out while the turn ran; a blocked reply takes
|
||||
// them down again.
|
||||
withheldResults: model.interimSent && reply.outboundBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,23 +335,28 @@ export async function runCoachTurn(input: TurnInput): Promise<Response> {
|
||||
const stream = createSseStream(async (controller) => {
|
||||
const emitter = createTurnEmitter(controller);
|
||||
const stopHeartbeat = startHeartbeat(controller);
|
||||
|
||||
let outcome: ReplyOutcome;
|
||||
try {
|
||||
outcome = await produceReply(emitter);
|
||||
} catch (err) {
|
||||
let outcome: ReplyOutcome;
|
||||
try {
|
||||
outcome = await produceReply(emitter);
|
||||
} catch (err) {
|
||||
stopHeartbeat();
|
||||
await ledger.close();
|
||||
handleProducerFailure(err, {
|
||||
signal: input.signal,
|
||||
emitter,
|
||||
conversationId: workingConversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
stopHeartbeat();
|
||||
handleProducerFailure(err, {
|
||||
signal: input.signal,
|
||||
emitter,
|
||||
conversationId: workingConversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
stopHeartbeat();
|
||||
|
||||
await emitReply(emitter, outcome, workingConversationId);
|
||||
await emitReply(emitter, outcome, workingConversationId);
|
||||
} finally {
|
||||
hooks.release();
|
||||
}
|
||||
});
|
||||
|
||||
hooks.onStream();
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
@@ -8,23 +8,30 @@ import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
||||
import type {
|
||||
CoachActivityMeta,
|
||||
CoachAssumption,
|
||||
CoachClarification,
|
||||
CoachFollowUp,
|
||||
CoachMemoryNote,
|
||||
CoachMethod,
|
||||
CoachPlanProposal,
|
||||
CoachProvenance,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStop,
|
||||
CoachSuggestion,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import type { CoachToolTrace } from "@/lib/ai/coach/tools";
|
||||
import { buildMethod } from "@/lib/ai/coach/method";
|
||||
import {
|
||||
assumptionFollowUps,
|
||||
deriveFollowUps,
|
||||
followUpChipsEnabled,
|
||||
type FollowUpHistory,
|
||||
} from "@/lib/ai/coach/follow-ups/derive";
|
||||
import { assumptionFromClarification } from "@/lib/ai/coach/clarify";
|
||||
import { numberFollowUps } from "@/lib/ai/coach/follow-ups/catalog";
|
||||
import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
@@ -42,8 +49,17 @@ export interface TurnDialog {
|
||||
followUps: CoachFollowUp[];
|
||||
/** Null on a blocked turn. */
|
||||
clarification: CoachClarification | null;
|
||||
/** v1.41 — what the answer assumed instead of asking; empty on a blocked turn. */
|
||||
assumptions: CoachAssumption[];
|
||||
/** v1.41 — the fact the turn saved or proposes; null on a blocked turn. */
|
||||
memoryNote: CoachMemoryNote | null;
|
||||
/** v1.41 — the plan the turn proposes; null on a blocked turn. */
|
||||
planProposal: CoachPlanProposal | null;
|
||||
}
|
||||
|
||||
/** At most this many assumptions per answer. */
|
||||
export const MAX_ASSUMPTIONS = 2;
|
||||
|
||||
/** A table's metadata: everything but the values, for the plaintext blob. */
|
||||
export function toResultMeta(table: CoachResultTable): CoachResultMeta {
|
||||
return {
|
||||
@@ -95,6 +111,14 @@ export function assembleTurnDialog(args: {
|
||||
// compete with the answer the question waits for.
|
||||
const offerChips =
|
||||
!blocked && followUpChipsEnabled(prefs) && reply.clarification === null;
|
||||
// v1.41 — a question the brake declined became an assumption; the answer
|
||||
// names it, and its alternatives are one tap away.
|
||||
const assumptions = blocked
|
||||
? []
|
||||
: model.declinedClarifications
|
||||
.map(assumptionFromClarification)
|
||||
.filter((a): a is CoachAssumption => a !== null)
|
||||
.slice(0, MAX_ASSUMPTIONS);
|
||||
const continueChip = offerChips
|
||||
? buildContinueFollowUp({
|
||||
forcedFinal: model.forcedFinal,
|
||||
@@ -105,6 +129,7 @@ export function assembleTurnDialog(args: {
|
||||
const followUps = offerChips
|
||||
? numberFollowUps([
|
||||
...(continueChip ? [continueChip] : []),
|
||||
...assumptionFollowUps({ assumptions, prefs, locale }),
|
||||
...deriveFollowUps({
|
||||
results: metas,
|
||||
steps: model.steps,
|
||||
@@ -123,6 +148,32 @@ export function assembleTurnDialog(args: {
|
||||
method,
|
||||
followUps,
|
||||
clarification: reply.clarification,
|
||||
assumptions,
|
||||
// The note and the proposal belong to the reply they came with; a
|
||||
// blocked reply was replaced, so they do not ride along.
|
||||
memoryNote: blocked ? null : model.memoryNote,
|
||||
planProposal: blocked ? null : model.planProposal,
|
||||
};
|
||||
}
|
||||
|
||||
/** The persisted half of a fact note: no fact text. */
|
||||
function memoryNoteMeta(note: CoachMemoryNote): CoachProvenance["memoryNote"] {
|
||||
return {
|
||||
proposal: note.proposal,
|
||||
...(note.proposalId ? { proposalId: note.proposalId } : {}),
|
||||
...(note.factId ? { factId: note.factId } : {}),
|
||||
category: note.category,
|
||||
};
|
||||
}
|
||||
|
||||
/** The persisted half of a plan proposal: no plan text. */
|
||||
function planProposalMeta(
|
||||
proposal: CoachPlanProposal,
|
||||
): CoachProvenance["planProposal"] {
|
||||
return {
|
||||
planId: proposal.planId,
|
||||
metric: proposal.metric,
|
||||
reviewInDays: proposal.reviewInDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,6 +189,10 @@ export function buildTurnProvenance(args: {
|
||||
forcedFinal: boolean;
|
||||
/** v1.39.4 — the forced reply this turn continues, if it does. */
|
||||
continuationOf?: string;
|
||||
/** v1.41 — the trail's plaintext metadata. */
|
||||
activity?: CoachActivityMeta[];
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
}): CoachProvenance {
|
||||
const { snapshotProvenance, reply, toolTrace, steps, dialog } = args;
|
||||
const surfacedSuggestion = args.suggestion;
|
||||
@@ -190,6 +245,21 @@ export function buildTurnProvenance(args: {
|
||||
...(dialog.clarification ? { clarification: dialog.clarification } : {}),
|
||||
...(args.forcedFinal ? { forcedFinal: true as const } : {}),
|
||||
...(args.continuationOf ? { continuationOf: args.continuationOf } : {}),
|
||||
// v1.41 — the trail, the stop, the assumptions, the note and the plan.
|
||||
// Metadata only: model and fact text live in `trail_encrypted`.
|
||||
...(args.activity && args.activity.length > 0
|
||||
? { activity: args.activity }
|
||||
: {}),
|
||||
...(args.stop ? { stop: args.stop } : {}),
|
||||
...(dialog.assumptions.length > 0
|
||||
? { assumptions: dialog.assumptions }
|
||||
: {}),
|
||||
...(dialog.memoryNote
|
||||
? { memoryNote: memoryNoteMeta(dialog.memoryNote) }
|
||||
: {}),
|
||||
...(dialog.planProposal
|
||||
? { planProposal: planProposalMeta(dialog.planProposal) }
|
||||
: {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v1.41 — the reasoning a Coach turn asks its provider for, resolved once
|
||||
* per turn: the person's level (`coachPrefsJson.reasoning`, absent meaning
|
||||
* `medium`), switched off or capped by the operator
|
||||
* (`app_settings.ai_reasoning_enabled` / `ai_reasoning_max_effort`), and
|
||||
* capped again at `medium` when the operator pays for the turn.
|
||||
*
|
||||
* The full resolver (what each provider supports, the source of the value
|
||||
* for `/api/auth/me`) is `src/lib/ai/reasoning/resolve.ts`; this is the
|
||||
* turn's own reading of the same three inputs, so the loop runs with the
|
||||
* right level before that module lands. Integration points it here at the
|
||||
* shared resolver.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
REASONING_MAX_EFFORTS,
|
||||
capReasoningLevel,
|
||||
type ReasoningLevel,
|
||||
type ReasoningMaxEffort,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import type { BudgetCostOwner } from "@/lib/ai/coach/budget";
|
||||
|
||||
/** A turn the operator pays for thinks at most this hard. */
|
||||
export const OPERATOR_REASONING_CAP: ReasoningMaxEffort = "medium";
|
||||
|
||||
export interface TurnReasoning {
|
||||
effort: ReasoningLevel;
|
||||
summaries: boolean;
|
||||
}
|
||||
|
||||
function isMaxEffort(value: unknown): value is ReasoningMaxEffort {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(REASONING_MAX_EFFORTS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure: the level from the three inputs. Exported for the tests. */
|
||||
export function resolveTurnReasoningLevel(args: {
|
||||
prefs: CoachPrefs;
|
||||
operator: { enabled: boolean; maxEffort: ReasoningMaxEffort };
|
||||
payer: BudgetCostOwner;
|
||||
}): TurnReasoning {
|
||||
if (!args.operator.enabled) return { effort: "off", summaries: false };
|
||||
// Absent means the default, as `coachReasoningLevel` reads it.
|
||||
let effort = capReasoningLevel(
|
||||
args.prefs.reasoning ?? DEFAULT_REASONING_LEVEL,
|
||||
args.operator.maxEffort,
|
||||
);
|
||||
if (args.payer === "operator") {
|
||||
effort = capReasoningLevel(effort, OPERATOR_REASONING_CAP);
|
||||
}
|
||||
return { effort, summaries: effort !== "off" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's reasoning. An unreadable settings row reads as the defaults
|
||||
* (reasoning allowed, no cap): the person's own level still applies, and
|
||||
* the operator's payer cap still holds.
|
||||
*/
|
||||
export async function resolveTurnReasoning(args: {
|
||||
prefs: CoachPrefs;
|
||||
payer: BudgetCostOwner;
|
||||
}): Promise<TurnReasoning> {
|
||||
let enabled = true;
|
||||
let maxEffort: ReasoningMaxEffort = "high";
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true },
|
||||
});
|
||||
if (row) {
|
||||
enabled = row.aiReasoningEnabled;
|
||||
if (isMaxEffort(row.aiReasoningMaxEffort)) {
|
||||
maxEffort = row.aiReasoningMaxEffort;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Defaults, as above.
|
||||
}
|
||||
return resolveTurnReasoningLevel({
|
||||
prefs: args.prefs,
|
||||
operator: { enabled, maxEffort },
|
||||
payer: args.payer,
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { parseFollowUpsSentinel } from "@/lib/ai/coach/follow-ups/parse-sentinel";
|
||||
import { stripResultRefs } from "@/lib/ai/coach/results/refs";
|
||||
import type { CoachClarification } from "@/lib/ai/coach/types";
|
||||
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
|
||||
|
||||
import type { TurnContext } from "./context";
|
||||
import type { ModelOutcome } from "./model";
|
||||
@@ -61,6 +62,22 @@ export interface GuardedReply {
|
||||
clarification: CoachClarification | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The metrics a no-tools turn's snapshot covered, as inventory rows the
|
||||
* clarification filter reads. Present by construction: the snapshot carried
|
||||
* them.
|
||||
*/
|
||||
function snapshotInventory(ctx: TurnContext): InventoryEntry[] {
|
||||
return ctx.snapshot.provenance.metrics
|
||||
.filter((metric) => metric !== "general")
|
||||
.map((metric) => ({
|
||||
tool: "get_metric_series",
|
||||
domain: metric,
|
||||
present: true,
|
||||
metric,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function guardReply(args: {
|
||||
userId: string;
|
||||
locale: Locale;
|
||||
@@ -155,17 +172,23 @@ export async function guardReply(args: {
|
||||
replyText = followUpsParse.prose.trim() || replyText;
|
||||
const clarifyParse = parseClarifySentinel({
|
||||
prose: replyText,
|
||||
inventory: model.inventory,
|
||||
// v1.41 — the no-tools path has no inventory; the metrics its snapshot
|
||||
// covered stand in for one, so a metric question can still be asked.
|
||||
inventory: model.inventory ?? snapshotInventory(ctx),
|
||||
locale,
|
||||
});
|
||||
// A block with no question before it leaves nothing to show; the raw
|
||||
// marker must never reach the person, so that reply is unusable.
|
||||
replyText = clarifyParse.prose.trim();
|
||||
if (!replyText) return { ok: false, code: "coach.provider.empty" };
|
||||
// v1.41 — a question asked through `ask_clarification` is the reply
|
||||
// itself, validated and braked when it was asked; the sentinel stays the
|
||||
// no-tools path's way to ask.
|
||||
const clarification = await dropRepeatClarification({
|
||||
userId,
|
||||
conversationId: workingConversationId,
|
||||
clarification: clarifyParse.clarification,
|
||||
clarification:
|
||||
model.toolClarification?.clarification ?? clarifyParse.clarification,
|
||||
latest: conversation.latestMessages,
|
||||
});
|
||||
const resultRefs = stripResultRefs(replyText);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* A model-free turn: a reuse chip ("as a chart", "as a table") answered from
|
||||
* Model-free turns.
|
||||
*
|
||||
* v1.41 — a tapped decision ("Yes, remember it" / "No", "Take on this plan"
|
||||
* / "Not now") is answered by the memory contract and one catalog line
|
||||
* (`runDecisionTurn`): no budget, no provider, an assistant message tagged
|
||||
* `decision`. The tap names only the message and the proposal; what is
|
||||
* saved or activated is read from what that message stored.
|
||||
*
|
||||
* A reuse chip ("as a chart", "as a table") is answered from
|
||||
* a table already stored on the conversation. No budget reservation and no
|
||||
* provider call; the capability and the rate limit still apply at the route.
|
||||
* Streams `step → token (caption) → provenance → result → followUps → done`
|
||||
@@ -38,6 +46,18 @@ import { readCoachReach } from "@/lib/ai/coach/history-reach-read";
|
||||
import { tableRangeWithinReach } from "@/lib/ai/coach/tools/executor";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
COACH_PLAN_KEYS,
|
||||
decideFactProposal,
|
||||
decidePlanProposal,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
import { readLatestMessages } from "@/lib/ai/coach/latest-messages";
|
||||
import {
|
||||
coachMemoryNoteMetaSchema,
|
||||
coachPlanProposalMetaSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
|
||||
import { persistUserTurn } from "./conversation";
|
||||
import { toResultMeta } from "./provenance";
|
||||
import { SSE_HEADERS, createTurnEmitter, emitReply } from "./sse";
|
||||
@@ -191,3 +211,146 @@ export async function runReuseTurn(args: {
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply a declined proposal gets. Pending keys: integration copies them
|
||||
* into the bundles with the rest of this release's strings.
|
||||
*/
|
||||
export const COACH_DECISION_DECLINED_KEYS = {
|
||||
memory: "insights.coach.memory.declined",
|
||||
plan: "insights.coach.plan.declined",
|
||||
} as const;
|
||||
|
||||
/** The stored note and plan of a message, each held to its wire schema. */
|
||||
function storedProposals(metricSourceJson: string | null): {
|
||||
proposalId: string | null;
|
||||
planId: string | null;
|
||||
} {
|
||||
const none = { proposalId: null, planId: null };
|
||||
if (!metricSourceJson) return none;
|
||||
try {
|
||||
const raw = JSON.parse(metricSourceJson) as Record<string, unknown>;
|
||||
const note = coachMemoryNoteMetaSchema.safeParse(raw.memoryNote);
|
||||
const plan = coachPlanProposalMetaSchema.safeParse(raw.planProposal);
|
||||
return {
|
||||
proposalId:
|
||||
note.success && note.data.proposal
|
||||
? (note.data.proposalId ?? null)
|
||||
: null,
|
||||
planId: plan.success ? plan.data.planId : null,
|
||||
};
|
||||
} catch {
|
||||
return none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a tapped decision without a model, or null when it is not one this
|
||||
* conversation can take: the message is not the latest reply, it carried no
|
||||
* such proposal, or the contract reports it stale. The pipeline then runs
|
||||
* the tap's label as an ordinary message.
|
||||
*/
|
||||
export async function runDecisionTurn(args: {
|
||||
input: TurnInput;
|
||||
conversation: TurnConversation;
|
||||
}): Promise<Response | null> {
|
||||
const { input, conversation } = args;
|
||||
const { userId, locale } = input;
|
||||
const conversationId = conversation.conversationId;
|
||||
const memory = input.memoryDecision;
|
||||
const plan = memory ? undefined : input.planDecision;
|
||||
const decision = memory ?? plan;
|
||||
if (!decision || !input.conversationId) return null;
|
||||
|
||||
let latest: Awaited<ReturnType<typeof readLatestMessages>>[number] | undefined;
|
||||
try {
|
||||
const rows = await (conversation.latestMessages?.() ??
|
||||
readLatestMessages(userId, conversationId));
|
||||
latest = rows.find((m) => m.providerType !== "cancelled");
|
||||
} catch {
|
||||
latest = undefined;
|
||||
}
|
||||
const stored =
|
||||
latest?.role === "assistant" && latest.id === decision.messageId
|
||||
? storedProposals(latest.metricSourceJson)
|
||||
: null;
|
||||
const matches = memory
|
||||
? stored?.proposalId === memory.proposalId
|
||||
: stored?.planId === plan?.planId;
|
||||
if (!stored || !matches) {
|
||||
annotate({
|
||||
action: { name: "coach.decision.stale" },
|
||||
meta: { kind: memory ? "memory" : "plan", reason: "not_latest" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
let reply: string;
|
||||
if (memory) {
|
||||
const outcome = await decideFactProposal({
|
||||
userId,
|
||||
conversationId,
|
||||
messageId: memory.messageId,
|
||||
proposalId: memory.proposalId,
|
||||
accept: memory.accept,
|
||||
}).catch(() => ({ kind: "stale" as const }));
|
||||
if (outcome.kind === "stale") return null;
|
||||
reply =
|
||||
outcome.kind === "saved"
|
||||
? t(COACH_MEMORY_KEYS.confirmed)
|
||||
: t(COACH_DECISION_DECLINED_KEYS.memory);
|
||||
} else if (plan) {
|
||||
const outcome = await decidePlanProposal({
|
||||
userId,
|
||||
conversationId,
|
||||
messageId: plan.messageId,
|
||||
planId: plan.planId,
|
||||
accept: plan.accept,
|
||||
}).catch(() => ({ kind: "stale" as const }));
|
||||
if (outcome.kind === "stale") return null;
|
||||
reply =
|
||||
outcome.kind === "activated"
|
||||
? t(COACH_PLAN_KEYS.confirmed, { days: outcome.reviewInDays })
|
||||
: t(COACH_DECISION_DECLINED_KEYS.plan);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
await persistUserTurn(conversationId, input.message);
|
||||
const provenance: CoachProvenance = { windows: [], metrics: [] };
|
||||
const message = await appendMessage({
|
||||
conversationId,
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
metricSource: provenance,
|
||||
providerType: "decision",
|
||||
promptVersion: PROMPT_VERSION,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.decision.answered" },
|
||||
meta: { kind: memory ? "memory" : "plan", accept: decision.accept },
|
||||
});
|
||||
|
||||
const stream = createSseStream(async (controller) => {
|
||||
const emitter = createTurnEmitter(controller);
|
||||
await emitReply(
|
||||
emitter,
|
||||
{
|
||||
ok: true,
|
||||
replyText: reply,
|
||||
provenance,
|
||||
suggestion: null,
|
||||
action: null,
|
||||
results: [],
|
||||
followUps: [],
|
||||
clarification: null,
|
||||
messageId: message.id,
|
||||
totalTokens: 0,
|
||||
model: null,
|
||||
},
|
||||
conversationId,
|
||||
);
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
@@ -88,9 +88,11 @@ function flushTick(): Promise<void> {
|
||||
/**
|
||||
* Emit a finished turn. A failed outcome is one `error` frame. A guarded
|
||||
* reply goes out as `token* → provenance → result* → suggestion? →
|
||||
* suggestedAction? → clarification? → followUps? → done`; the token frames
|
||||
* carry the FULLY-GUARDED text, and every guard ran before the first one
|
||||
* leaves. The live `step` frames went out earlier, while the model ran.
|
||||
* suggestedAction? → memoryNote? → planProposal? → clarification? →
|
||||
* followUps? → done`; the token frames carry the FULLY-GUARDED text, and
|
||||
* every guard ran before the first one leaves. The live `step` and
|
||||
* `activity` frames went out earlier, while the model ran; the last of them
|
||||
* opens the `answer` entry, which `done` closes.
|
||||
*/
|
||||
export async function emitReply(
|
||||
emitter: TurnEmitter,
|
||||
@@ -132,6 +134,13 @@ export async function emitReply(
|
||||
suggestedAction: outcome.action,
|
||||
});
|
||||
}
|
||||
// v1.41 — additive `memoryNote` and `planProposal` frames, owner only.
|
||||
if (outcome.memoryNote) {
|
||||
emitter.emit({ type: "memoryNote", note: outcome.memoryNote });
|
||||
}
|
||||
if (outcome.planProposal) {
|
||||
emitter.emit({ type: "planProposal", proposal: outcome.planProposal });
|
||||
}
|
||||
// v1.39.4 — additive `clarification` and `followUps` frames.
|
||||
if (outcome.clarification) {
|
||||
emitter.emit({
|
||||
@@ -151,6 +160,10 @@ export async function emitReply(
|
||||
totalTokens: outcome.totalTokens || null,
|
||||
model: outcome.model,
|
||||
},
|
||||
// v1.41 — why the answer was forced, and whether the interim tables
|
||||
// must come down again.
|
||||
...(outcome.stop ? { stop: outcome.stop } : {}),
|
||||
...(outcome.withheldResults ? { withheldResults: true as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,13 @@ import {
|
||||
type CoachToolName,
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import { isTurnResultRef } from "@/lib/ai/coach/results/refs";
|
||||
import { COMPARE_SERIES_TOOL_NAME } from "@/lib/ai/coach/tools/compare-series";
|
||||
|
||||
/** At most this many steps per turn; later calls run but show no step. */
|
||||
export const MAX_TURN_STEPS = 12;
|
||||
/**
|
||||
* At most this many steps per turn; later calls run but show no step.
|
||||
* v1.41 — 48: a budgeted turn runs up to twelve rounds.
|
||||
*/
|
||||
export const MAX_TURN_STEPS = 48;
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
@@ -130,7 +134,7 @@ const MISS: Readonly<
|
||||
},
|
||||
use_get_workouts: { status: "failed", reason: "invalid_arguments" },
|
||||
// `show_result`: a name the conversation does not hold, a stored table
|
||||
// that could not be read, a turn already holding six tables.
|
||||
// that could not be read, a turn already holding eight tables.
|
||||
unknown_result: { status: "failed", reason: "invalid_arguments" },
|
||||
result_unavailable: { status: "failed", reason: "retrieval_failed" },
|
||||
result_limit: { status: "failed" },
|
||||
@@ -252,6 +256,9 @@ export function toStep(args: {
|
||||
if (call.name === SHOW_RESULT_TOOL_NAME) {
|
||||
return reuseStep({ index, result, locale });
|
||||
}
|
||||
if (call.name === COMPARE_SERIES_TOOL_NAME) {
|
||||
return compareStep({ ...args, index });
|
||||
}
|
||||
if (!isCoachToolName(call.name)) return null;
|
||||
const tool = call.name;
|
||||
|
||||
@@ -418,6 +425,56 @@ function settle(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — a `compare_series` call: two series of the metric table, shown as
|
||||
* the metric table's step for the first metric. Its count is the readings
|
||||
* the first series folded; the second series rides the same table.
|
||||
*/
|
||||
function compareStep(args: {
|
||||
index: number;
|
||||
parsedArgs: Record<string, unknown> | undefined;
|
||||
result?: CoachToolResult;
|
||||
locale: Locale;
|
||||
fallbackWindow?: CoachScopeWindow;
|
||||
}): CoachStep | null {
|
||||
const { index, parsedArgs, result, locale } = args;
|
||||
const domain = pick<CoachStepDomain>(SCOPE_SOURCES, parsedArgs?.metric);
|
||||
if (!domain) return null;
|
||||
const window =
|
||||
pick<CoachScopeWindow>(WINDOWS, parsedArgs?.window) ??
|
||||
pick<CoachScopeWindow>(WINDOWS, args.fallbackWindow);
|
||||
const granularity = pick<CoachResultGranularity>(
|
||||
GRANULARITIES,
|
||||
parsedArgs?.granularity,
|
||||
);
|
||||
const base = {
|
||||
id: `s${index + 1}`,
|
||||
tool: "get_metric_table" as const,
|
||||
...render(locale, domain, window),
|
||||
domain,
|
||||
...(window ? { window } : {}),
|
||||
...(granularity ? { granularity } : {}),
|
||||
};
|
||||
if (!result) return { ...base, status: "running" };
|
||||
if (!result.present) {
|
||||
const miss = missFor(result.reason);
|
||||
return {
|
||||
...base,
|
||||
status: miss.status,
|
||||
...(miss.reason ? { reason: miss.reason } : {}),
|
||||
};
|
||||
}
|
||||
const sideA = isRecord(result.data) ? result.data.a : undefined;
|
||||
const count = isRecord(sideA) ? tableSummaryCount(sideA) : undefined;
|
||||
const resultRef = producedRef(result);
|
||||
return {
|
||||
...base,
|
||||
status: "done",
|
||||
...(count !== undefined ? { count } : {}),
|
||||
...(resultRef ? { resultRef } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single step a no-tools turn shows: the full snapshot, with the number
|
||||
* of metrics it covered. The snapshot is already built when this runs, so
|
||||
|
||||
@@ -19,9 +19,12 @@ import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachFollowUp,
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
CoachProvenance,
|
||||
CoachResultTable,
|
||||
CoachScope,
|
||||
CoachStop,
|
||||
CoachStreamEvent,
|
||||
CoachSuggestion,
|
||||
} from "@/lib/ai/coach/types";
|
||||
@@ -41,6 +44,16 @@ export interface TurnInput {
|
||||
followUp: { messageId: string; id: string } | undefined;
|
||||
/** v1.39.4 — the clarifying question this message answers, if any. */
|
||||
clarification: { messageId: string; choiceId?: string } | undefined;
|
||||
/** v1.41 — the fact proposal this message answers with a tap, if any. */
|
||||
memoryDecision?: { messageId: string; proposalId: string; accept: boolean };
|
||||
/** v1.41 — the plan proposal this message answers with a tap, if any. */
|
||||
planDecision?: { messageId: string; planId: string; accept: boolean };
|
||||
/**
|
||||
* v1.41 — gives back the person's turn slot (at most two turns at once).
|
||||
* The route takes the slot; the pipeline gives it back when the turn is
|
||||
* over, however it ended.
|
||||
*/
|
||||
releaseSlot?: () => void;
|
||||
/**
|
||||
* The `coach` capability gate, re-run at the egress site. Owned by the
|
||||
* route (the capability inventory reads it there); returns the refusal
|
||||
@@ -110,5 +123,13 @@ export type ReplyOutcome =
|
||||
messageId: string;
|
||||
totalTokens: number;
|
||||
model: string | null;
|
||||
/** v1.41 — the fact this reply saved or proposes, owner only. */
|
||||
memoryNote?: CoachMemoryNote | null;
|
||||
/** v1.41 — the plan this reply proposes, owner only. */
|
||||
planProposal?: CoachPlanProposal | null;
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
/** v1.41 — interim tables went out and the reply was blocked. */
|
||||
withheldResults?: boolean;
|
||||
}
|
||||
| { ok: false; code: string };
|
||||
Reference in new issue
Block a user