feat(coach): name a read past the lookback limit on the trail

A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 14:12:09 +01:00
1 parent 2e28d4ed4c
commit f39598f2e7
17 files changed
+212 -33

No files matched your search

+6
View File
@@ -20,6 +20,12 @@
it. It used to be skipped. It is summed per day, charted, listed with your
measurements, included in backups and exports, and readable over MCP. It
feeds no score and no insight. (#1340)
- When the Coach looks for readings older than the lookback limit you set
for it, its live trail now says so on the wire: the `step` and `activity`
frames carry `reason: "outside_reach"` instead of looking like an empty
window. The native client can then tell "nothing in this window" from
"further back than the Coach may read". The answer still names the limit,
and the web trail keeps its wording.
### Fixed
+15 -2
View File
@@ -62336,6 +62336,7 @@ components:
enum:
- no_data
- outside_window
- outside_reach
- module_disabled
- retrieval_failed
- invalid_arguments
@@ -62355,7 +62356,10 @@ components:
additionalProperties: false
description: "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done`
/ `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only:
never free text, an analyte name or a value."
never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window`
means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit
set for the Coach (the answer names the limit). A client without a string for a reason shows the step without
one."
CoachStepDomain:
type: string
enum:
@@ -62916,6 +62920,10 @@ components:
- time
- cap
- no_progress
reason:
type: string
enum:
- outside_reach
required:
- id
- phase
@@ -62926,7 +62934,8 @@ components:
additionalProperties: false
description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog
label, counts. The model-written title and text are behind `GET
/api/insights/chat/{id}/messages/{messageId}/trail`."
/api/insights/chat/{id}/messages/{messageId}/trail`. `reason` is set only on a settled `fetch` entry whose
readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for."
CoachStop:
type: object
properties:
@@ -63536,6 +63545,10 @@ components:
- time
- cap
- no_progress
reason:
type: string
enum:
- outside_reach
title:
description: A screened reasoning title or checkpoint sentence.
type: string
@@ -46,6 +46,8 @@ export interface DialogWorld {
resultsWithheld: boolean;
/** The provider's rounds, consumed in order. */
script: ScriptRound[];
/** The stored `User.coachPrefsJson`; null is the defaults. */
coachPrefs: Record<string, unknown> | null;
}
export const world: DialogWorld = {
@@ -56,6 +58,7 @@ export const world: DialogWorld = {
storedProvenance: {},
resultsWithheld: false,
script: [],
coachPrefs: null,
};
/** Everything the scripted provider was sent and answered, in order. */
@@ -412,6 +415,7 @@ export function resetDialog(): void {
world.storedProvenance = {};
world.resultsWithheld = false;
world.script = [];
world.coachPrefs = null;
providerCalls.length = 0;
messageSeq = 0;
for (const fn of Object.values(m)) fn.mockReset();
@@ -465,7 +469,7 @@ export const modules = {
prisma: {
user: {
findUnique: async () => ({
coachPrefsJson: null,
coachPrefsJson: world.coachPrefs,
displayName: null,
aiResponseTimeoutSeconds: null,
}),
@@ -171,6 +171,62 @@ describe("the live trail", () => {
expect(interim).toBeLessThan(firstToken);
});
it("names a read past the lookback limit on its step and its fetch entry", async () => {
world.coachPrefs = { defaultWindow: "last7days" };
world.script = [
{ calls: [{ name: "get_day", args: { date: "2026-06-01" } }] },
{ text: "That day lies beyond the lookback limit you set." },
];
const { status, frames } = await h.postTurn(post, {
message: "What happened on 1 June?",
locale: "en",
});
expect(status).toBe(200);
const step = framesOf<{ step: { id: string; status: string } }>(
frames,
"step",
)
.map((f) => f.step)
.at(-1);
expect(step).toMatchObject({
tool: "get_day",
status: "empty",
reason: "outside_reach",
});
const fetch = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch")
.at(-1);
expect(fetch).toMatchObject({
status: "empty",
stepRef: step?.id,
reason: "outside_reach",
});
});
it("names no reason on a fetch inside the limit", async () => {
world.script = [
{
calls: [
{
name: "get_metric_table",
args: { metric: "bp", window: "last30days" },
},
],
},
{ text: "Steady. result:r1" },
];
const { frames } = await h.postTurn(post, {
message: "How was my blood pressure?",
locale: "en",
});
const fetches = framesOf<{ activity: CoachActivity }>(frames, "activity")
.map((f) => f.activity)
.filter((a) => a.phase === "fetch");
expect(fetches.length).toBeGreaterThan(0);
for (const entry of fetches) expect(entry).not.toHaveProperty("reason");
});
it("persists the structure on the provenance and the text in the trail", async () => {
world.script = [
{
@@ -102,6 +102,14 @@ describe("describeStep", () => {
]);
});
it("keeps the window sentence on a read past the lookback limit", () => {
const reach: CoachStep = { ...SLEEP, reason: "outside_reach" };
expect(describeStep(reach, t, tCount).meta).toEqual([
"last 30 days",
"no readings in this window",
]);
});
it("counts metrics on the snapshot step and rows on labs", () => {
const snapshot: CoachStep = {
id: "s1",
+25
View File
@@ -83,6 +83,31 @@ describe("buildMethod", () => {
});
});
it("keeps the window sentence for a read past the lookback limit", () => {
const method = buildMethod({
steps: [
step({
id: "s1",
tool: "get_sleep",
domain: "sleep",
window: "last30days",
status: "empty",
reason: "outside_reach",
}),
],
results: [],
locale: "en",
});
expect(method?.text).toBe(
"Sleep, last 30 days: no readings in this window",
);
expect(method?.entries[0]).toEqual({
domain: "sleep",
window: "last30days",
absent: "outside_window",
});
});
it("says totals for a summed metric and a rate for compliance", () => {
const method = buildMethod({
steps: [
@@ -62,6 +62,30 @@ describe("createActivityRecorder", () => {
});
});
it("names a fetch past the lookback limit on the entry and its metadata", () => {
const { rec, frames } = recorder();
const reach = rec.start({
phase: "fetch",
round: 1,
...fetchActivityLabel("en", { domain: "weight", window: "last90days" }),
stepRef: "s1",
});
rec.finish(reach, "empty", { reason: "outside_reach" });
const plain = rec.start({
phase: "fetch",
round: 1,
...fetchActivityLabel("en", { domain: "pulse", window: "last90days" }),
stepRef: "s2",
});
rec.finish(plain, "empty");
expect(
frames.find((f) => f.id === reach && f.status === "empty"),
).toMatchObject({ reason: "outside_reach" });
expect(frames.at(-1)).not.toHaveProperty("reason");
expect(rec.meta()[0]).toMatchObject({ reason: "outside_reach" });
expect(rec.meta()[1]).not.toHaveProperty("reason");
});
it("drops a title that prescribes a dose and keeps the catalog label", () => {
const { rec, frames } = recorder();
const id = rec.start({
+22
View File
@@ -22,6 +22,7 @@
import type {
CoachActivityMeta,
CoachActivityPhase,
CoachStep,
CoachStepStatus,
CoachStopReason,
CoachTrail,
@@ -67,6 +68,27 @@ export interface ActivityPatch {
labelKey?: string;
label?: string;
count?: number;
/** Why a settled fetch found nothing, when it is a reason the trail names. */
reason?: CoachActivityMeta["reason"];
}
/**
* How a `fetch` entry closes for the step it stands for: the step's status
* (`done` for one that never settled), its count, and `outside_reach` when
* the step says its readings all lie past the lookback limit. Any other
* step reason stays on the step.
*/
export function settledFetch(
status: CoachStepStatus,
step: Pick<CoachStep, "count" | "reason"> | null,
): [Exclude<CoachStepStatus, "running">, ActivityPatch] {
return [
status === "running" ? "done" : status,
{
...(step?.count !== undefined ? { count: step.count } : {}),
...(step?.reason === "outside_reach" ? { reason: step.reason } : {}),
},
];
}
export interface ActivityRecorder {
+1
View File
@@ -86,6 +86,7 @@ export function createActivityRecorder(args: {
if (patch.count !== undefined && Number.isSafeInteger(patch.count)) {
entry.meta.count = Math.max(0, patch.count);
}
if (patch.reason === "outside_reach") entry.meta.reason = patch.reason;
if (patch.title !== undefined) {
const title = screenActivityTitle(patch.title, args.screen);
if (title) entry.title = title;
+2
View File
@@ -65,6 +65,8 @@ export const COACH_STEP_REASON_KEYS: Readonly<Record<CoachStepReason, string>> =
{
no_data: "coach.step.reason.noData",
outside_window: "coach.step.reason.outsideWindow",
// The web trail keeps the window sentence; the answer names the limit.
outside_reach: "coach.step.reason.outsideWindow",
module_disabled: "coach.step.reason.moduleDisabled",
retrieval_failed: "coach.step.reason.retrievalFailed",
invalid_arguments: "coach.step.reason.invalidArguments",
+6 -2
View File
@@ -145,8 +145,12 @@ function collectEntries(
...(granularity ? { granularity } : {}),
};
if (step.status === "empty") {
if (step.reason && ABSENT_REASONS.has(step.reason)) {
add({ ...base, absent: step.reason as Absent });
// Past the lookback limit the method line keeps the window sentence,
// as it always has; the answer names the limit.
const reason =
step.reason === "outside_reach" ? "outside_window" : step.reason;
if (reason && ABSENT_REASONS.has(reason)) {
add({ ...base, absent: reason as Absent });
}
continue;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Why a Coach step found nothing or failed: the one list behind the
* `CoachStepReason` type and the `step` frame's schema.
*
* `outside_window`: no readings in the window read. `outside_reach`: readings
* exist, all of them older than the lookback limit set for the Coach; the
* answer names the limit, and a method entry says `outside_window` for it.
*
* Client-safe: no server import.
*/
export const COACH_STEP_REASONS = [
"no_data",
"outside_window",
"outside_reach",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
] as const;
+5 -11
View File
@@ -28,6 +28,7 @@ import {
SUGGESTED_ACTION_TYPES,
type CheckupIntervalId,
} from "@/lib/ai/coach/suggest-action";
import { COACH_STEP_REASONS } from "@/lib/ai/coach/step-reasons";
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
import {
COACH_MEMORY_CATEGORIES,
@@ -100,15 +101,7 @@ export const coachStepSchema = z
.int()
.optional()
.describe("Readings or rows the server counted. Never a health value."),
reason: z
.enum([
"no_data",
"outside_window",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
])
.optional(),
reason: z.enum(COACH_STEP_REASONS).optional(),
resultRef: resultRefSchema
.optional()
.describe("The table this step produced (`r1`..), when it produced one."),
@@ -123,7 +116,7 @@ export const coachStepSchema = z
.meta({
id: "CoachStep",
description:
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.",
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window` means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit set for the Coach (the answer names the limit). A client without a string for a reason shows the step without one.",
});
// ── Results ───────────────────────────────────────────────────────────────
@@ -464,12 +457,13 @@ const coachActivityMetaShape = {
.describe("Readings, lookups or entries the server counted."),
durationMs: z.number().int().optional(),
stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."),
reason: z.enum(["outside_reach"]).optional(),
};
export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({
id: "CoachActivityMeta",
description:
"v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`.",
"v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`. `reason` is set only on a settled `fetch` entry whose readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for.",
});
export const coachActivitySchema = z
@@ -55,6 +55,7 @@ const STATUSES = new Set(["running", "done", "empty", "failed"]);
const REASONS = new Set([
"no_data",
"outside_window",
"outside_reach",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
@@ -218,6 +219,7 @@ function fuzzResult(r: () => number): CoachToolResult | undefined {
const reasons = [
"no_data",
"outside_window",
"outside_reach",
"unavailable_in_scope",
"no_data_unconfirmed",
"retrieval_failed",
@@ -416,6 +418,10 @@ describe("toStep — mapping", () => {
},
}),
).toMatchObject({ status: "empty", reason: "outside_window", count: 40 });
// Past the lookback limit: its own reason, and no figure from the rows.
const reach = at("outside_reach");
expect(reach).toMatchObject({ status: "empty", reason: "outside_reach" });
expect(reach).not.toHaveProperty("count");
expect(at("retrieval_failed")).toMatchObject({
status: "failed",
reason: "retrieval_failed",
+5 -6
View File
@@ -54,7 +54,10 @@ import type {
CoachStop,
} from "@/lib/ai/coach/types";
import type { AiToolCall } from "@/lib/ai/types";
import type { ActivityRecorder } from "@/lib/ai/coach/activity/contract";
import {
settledFetch,
type ActivityRecorder,
} from "@/lib/ai/coach/activity/contract";
import {
createActivityRecorder,
type TurnActivityRecorder,
@@ -518,11 +521,7 @@ export async function runTurnModel(args: {
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 } : {}),
});
}
if (entry) activity.finish(entry.id, ...settledFetch(status, step));
const reads = roundReads.get(round) ?? [];
reads.push({
count: step?.count ?? 0,
+3 -3
View File
@@ -119,9 +119,9 @@ const MISS: Readonly<
no_data: { status: "empty", reason: "no_data" },
analyte_not_found: { status: "empty", reason: "no_data" },
outside_window: { status: "empty", reason: "outside_window" },
// Beyond the lookback limit: to the person, "no readings in this window"
// is the true sentence; the limit itself is named in the answer.
outside_reach: { status: "empty", reason: "outside_window" },
// Beyond the lookback limit: the readings exist, the limit keeps them
// out. Its own reason, so a trail can say so; the answer names the limit.
outside_reach: { status: "empty", reason: "outside_reach" },
module_disabled: { status: "empty", reason: "module_disabled" },
retrieval_failed: { status: "failed", reason: "retrieval_failed" },
no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" },
+5 -8
View File
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import { COACH_SCOPE_SOURCES } from "./scope-sources";
import type { COACH_STEP_REASONS } from "./step-reasons";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import type { CoachSuggestedAction } from "./suggest-action";
@@ -339,8 +340,9 @@ export interface CoachActivityMeta {
/** Readings, lookups or remembered entries the server counted. */
count?: number;
durationMs?: number;
/** Set on the `stop` entry. */
/** `stop`: on the stop entry. `reason`: a fetch past the lookback limit. */
stop?: CoachStopReason;
reason?: "outside_reach";
}
/** One trail entry on the wire: the metadata plus the owner-only model text. */
@@ -446,13 +448,8 @@ export type CoachStepDomain =
| "day"
| "snapshot";
/** Why a step or a method entry found nothing. */
export type CoachStepReason =
| "no_data"
| "outside_window"
| "module_disabled"
| "retrieval_failed"
| "invalid_arguments";
/** Why a step or a method entry found nothing (`step-reasons.ts`). */
export type CoachStepReason = (typeof COACH_STEP_REASONS)[number];
export interface CoachStep {
/** `s1`..`s64`, unique within a turn; frames upsert by id. */