feat(coach): partial-malformed observability on sentinel parser

The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.

- `SentinelParseResult.malformedEntries[]` records each skipped row
  with a typed reason (`missing_colon` / `value_overflow` /
  `label_overflow` / `no_END_marker` / `byte_overflow` /
  `schema_invalid`). Block-level failures (truncation, missing close
  marker) land here as synthetic entries so the wide-event always
  carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
  parser so the row-level reason can be `label_overflow` /
  `value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
  when at least one row parsed AND at least one row failed, the
  existing `coach.keyvalues.parse_failed` stays for full-block
  failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.

Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 01:20:01 +02:00
1 parent 921cb7d42f
commit 58ae9bca42
3 files changed
+205 -24

No files matched your search

+15 -2
View File
@@ -300,11 +300,24 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
if (sentinel.malformed) { if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose // Graceful degrade: log so ops can spot a provider whose
// sentinel format has drifted, but pass the prose through // sentinel format has drifted, but pass the prose through
// unchanged. // unchanged. v1.4.23 H1 — split the annotation:
// - parse_partial: at least one row parsed AND at least one
// row failed (mixed-format drift on a single reply)
// - parse_failed: the whole block was unusable
// Both annotations carry the per-line `reasons` array so an ops
// dashboard can attribute the failure cause without re-running
// the parser.
const reasons = sentinel.malformedEntries.map((entry) => entry.reason);
const annotationName =
sentinel.keyValues.length > 0 && sentinel.malformedEntries.length > 0
? "coach.keyvalues.parse_partial"
: "coach.keyvalues.parse_failed";
annotate({ annotate({
action: { name: "coach.keyvalues.parse_failed" }, action: { name: annotationName },
meta: { meta: {
kept: sentinel.keyValues.length, kept: sentinel.keyValues.length,
malformedCount: sentinel.malformedEntries.length,
reasons,
promptVersion: PROMPT_VERSION, promptVersion: PROMPT_VERSION,
}, },
}); });
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseKeyValueLine, parseKeyValuesSentinel } from "../keyvalues"; import {
parseKeyValueLine,
parseKeyValuesSentinel,
tryParseKeyValueLine,
} from "../keyvalues";
/** /**
* v1.4.22 Wave 3 B1+B2 — Coach evidence-block sentinel parser. * v1.4.22 Wave 3 B1+B2 — Coach evidence-block sentinel parser.
@@ -91,6 +95,7 @@ describe("parseKeyValuesSentinel", () => {
}, },
]); ]);
expect(out.malformed).toBe(false); expect(out.malformed).toBe(false);
expect(out.malformedEntries).toEqual([]);
}); });
it("returns prose untouched when no sentinel is present", () => { it("returns prose untouched when no sentinel is present", () => {
@@ -100,6 +105,7 @@ describe("parseKeyValuesSentinel", () => {
expect(out.prose).toBe("Just a qualitative reply with no numbers cited."); expect(out.prose).toBe("Just a qualitative reply with no numbers cited.");
expect(out.keyValues).toEqual([]); expect(out.keyValues).toEqual([]);
expect(out.malformed).toBe(false); expect(out.malformed).toBe(false);
expect(out.malformedEntries).toEqual([]);
}); });
it("graceful-degrades when the closing ---END--- is missing", () => { it("graceful-degrades when the closing ---END--- is missing", () => {
@@ -115,6 +121,9 @@ describe("parseKeyValuesSentinel", () => {
expect(out.prose).toBe("Prose still streams cleanly."); expect(out.prose).toBe("Prose still streams cleanly.");
expect(out.keyValues.length).toBe(1); expect(out.keyValues.length).toBe(1);
expect(out.malformed).toBe(true); expect(out.malformed).toBe(true);
expect(out.malformedEntries).toEqual([
{ rawLine: "---END---", reason: "no_END_marker" },
]);
}); });
it("clips the block to 8 entries (prompt-contract cap)", () => { it("clips the block to 8 entries (prompt-contract cap)", () => {
@@ -143,7 +152,7 @@ describe("parseKeyValuesSentinel", () => {
expect(out.malformed).toBe(true); expect(out.malformed).toBe(true);
}); });
it("silently drops malformed lines mixed in with valid ones", () => { it("records malformed lines mixed in with valid ones (v1.4.23 H1)", () => {
const raw = [ const raw = [
"Prose.", "Prose.",
"", "",
@@ -160,6 +169,65 @@ describe("parseKeyValuesSentinel", () => {
"avg30 systolic", "avg30 systolic",
"avg30 mood", "avg30 mood",
]); ]);
// Partial malformed surfaces — the good rows survive while the
// bad ones land in the entry array with typed reasons.
expect(out.malformed).toBe(true);
expect(out.malformedEntries).toEqual([
{ rawLine: "not a valid line", reason: "missing_colon" },
{ rawLine: ":no-label-here", reason: "missing_colon" },
]);
});
it("flags label_overflow when a row's label exceeds the cap", () => {
const longLabel = "x".repeat(120);
const raw = [
"---KEYVALUES---",
"ok label: 138 [mmHg] (last30days)",
`${longLabel}: 99 [mmHg]`,
"---END---",
].join("\n");
const out = parseKeyValuesSentinel(raw);
expect(out.keyValues.length).toBe(1);
expect(out.keyValues[0].label).toBe("ok label");
expect(out.malformed).toBe(true);
expect(out.malformedEntries).toEqual([
{ rawLine: `${longLabel}: 99 [mmHg]`, reason: "label_overflow" },
]);
});
it("flags value_overflow when a row's value exceeds the cap", () => {
const longValue = "9".repeat(220);
const raw = [
"---KEYVALUES---",
`avg30 systolic: ${longValue} [mmHg]`,
"ok label: 138 [mmHg] (last30days)",
"---END---",
].join("\n");
const out = parseKeyValuesSentinel(raw);
expect(out.keyValues.length).toBe(1);
expect(out.keyValues[0].label).toBe("ok label");
expect(out.malformed).toBe(true);
expect(out.malformedEntries[0]?.reason).toBe("value_overflow");
});
it("flags byte_overflow when the block exceeds the 1 KB cap", () => {
const padded = Array.from(
{ length: 60 },
(_, i) => `label-${i}: ${i.toString().padEnd(15, "x")}`,
);
const raw = ["---KEYVALUES---", ...padded, "---END---"].join("\n");
const out = parseKeyValuesSentinel(raw);
expect(out.malformed).toBe(true);
expect(
out.malformedEntries.some((entry) => entry.reason === "byte_overflow"),
).toBe(true);
});
it("tryParseKeyValueLine surfaces missing_colon for separator-less rows", () => {
expect(tryParseKeyValueLine("no separator here")).toEqual({
ok: false,
reason: "missing_colon",
});
}); });
it("returns malformed when the sentinel block is empty", () => { it("returns malformed when the sentinel block is empty", () => {
@@ -168,6 +236,8 @@ describe("parseKeyValuesSentinel", () => {
expect(out.keyValues).toEqual([]); expect(out.keyValues).toEqual([]);
expect(out.malformed).toBe(true); expect(out.malformed).toBe(true);
expect(out.prose).toBe("Prose."); expect(out.prose).toBe("Prose.");
// No body lines to record — only the block-level state flips.
expect(out.malformedEntries).toEqual([]);
}); });
it("returns prose and an empty array for empty input", () => { it("returns prose and an empty array for empty input", () => {
@@ -175,5 +245,6 @@ describe("parseKeyValuesSentinel", () => {
expect(out.prose).toBe(""); expect(out.prose).toBe("");
expect(out.keyValues).toEqual([]); expect(out.keyValues).toEqual([]);
expect(out.malformed).toBe(false); expect(out.malformed).toBe(false);
expect(out.malformedEntries).toEqual([]);
}); });
}); });
+117 -20
View File
@@ -19,11 +19,14 @@
* - Hard cap 1 KB on the sentinel block (prompt-injection guard). * - Hard cap 1 KB on the sentinel block (prompt-injection guard).
* - Hard cap 8 lines kept (per the prompt contract). * - Hard cap 8 lines kept (per the prompt contract).
* - Each line is validated against `coachKeyValueSchema`; malformed * - Each line is validated against `coachKeyValueSchema`; malformed
* lines are dropped silently. * lines surface in `parsed.malformed[]` with a typed reason
* (v1.4.23 H1 — replaced the silent-drop path).
* - When the sentinel pair is malformed (e.g. no closing `---END---`, * - When the sentinel pair is malformed (e.g. no closing `---END---`,
* unbalanced sentinels), the parser returns the original prose * unbalanced sentinels), the parser still returns whatever rows it
* untouched and emits no keyValues — the caller logs a wide-event * could parse plus a malformed flag; the caller logs
* `coach.keyvalues.parse_failed` for ops visibility. * `coach.keyvalues.parse_failed` (full block invalid) or
* `coach.keyvalues.parse_partial` (mixed valid + invalid) for ops
* visibility.
*/ */
import { coachKeyValueSchema, type CoachKeyValue } from "./types"; import { coachKeyValueSchema, type CoachKeyValue } from "./types";
@@ -31,31 +34,76 @@ import { coachKeyValueSchema, type CoachKeyValue } from "./types";
const SENTINEL_BYTE_CAP = 1024; const SENTINEL_BYTE_CAP = 1024;
/** Max key/value rows kept inside the block (prompt-contract value). */ /** Max key/value rows kept inside the block (prompt-contract value). */
const SENTINEL_LINE_CAP = 8; const SENTINEL_LINE_CAP = 8;
/** Per-line value cap before we reject the row (defence-in-depth). */
const VALUE_BYTE_CAP = 200;
/** Per-line label cap mirroring the prompt-contract value of 40 chars. */
const LABEL_BYTE_CAP = 80;
const OPEN_SENTINEL = "---KEYVALUES---"; const OPEN_SENTINEL = "---KEYVALUES---";
const CLOSE_SENTINEL = "---END---"; const CLOSE_SENTINEL = "---END---";
/**
* Why a candidate row failed to parse. Surfaces to the caller via
* `SentinelParseResult.malformed[]` so ops dashboards can attribute
* partial failures (mixed valid + invalid rows) without the silent
* drops the v1.4.22 W3 implementation paid.
*
* - `missing_colon` — line had no `:` separator.
* - `value_overflow` — value exceeded `VALUE_BYTE_CAP`.
* - `label_overflow` — label exceeded `LABEL_BYTE_CAP`.
* - `no_END_marker` — the closing `---END---` was missing.
* - `byte_overflow` — block exceeded `SENTINEL_BYTE_CAP`.
* - `schema_invalid` — row passed shape but Zod rejected it
* (e.g. empty value after stripping).
*/
export type SentinelMalformedReason =
| "missing_colon"
| "value_overflow"
| "label_overflow"
| "no_END_marker"
| "byte_overflow"
| "schema_invalid";
export interface SentinelMalformedEntry {
rawLine: string;
reason: SentinelMalformedReason;
}
/**
* Result of parsing one body line. Either returns the `CoachKeyValue`
* shape or a typed reason the line failed (so the caller can surface
* partial-failure observability in the wide-event annotation).
*/
export type LineParseOutcome =
| { ok: true; value: CoachKeyValue }
| { ok: false; reason: SentinelMalformedReason };
/** /**
* Parse one body line of the form * Parse one body line of the form
* `<label>: <value> [<unit>] (<window>)` * `<label>: <value> [<unit>] (<window>)`
* into a `CoachKeyValue`. The unit + window are both optional; an * into a `CoachKeyValue`. The unit + window are both optional; an
* input missing either or both still parses cleanly. Returns `null` * input missing either or both still parses cleanly. Returns
* when the line does not match the expected shape — the caller drops * `{ ok: false, reason }` when the line does not match the expected
* the line rather than rejecting the whole block. * shape — the caller now records the malformed row rather than
* silently dropping it (v1.4.23 H1).
*/ */
export function parseKeyValueLine(line: string): CoachKeyValue | null { export function tryParseKeyValueLine(line: string): LineParseOutcome {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) return null; if (!trimmed) return { ok: false, reason: "missing_colon" };
// Find the first `:` — the label cannot itself contain `:`. The // Find the first `:` — the label cannot itself contain `:`. The
// remainder may carry the value, an optional `[unit]`, and an // remainder may carry the value, an optional `[unit]`, and an
// optional `(window)`. // optional `(window)`.
const colon = trimmed.indexOf(":"); const colon = trimmed.indexOf(":");
if (colon < 1) return null; if (colon < 1) return { ok: false, reason: "missing_colon" };
const label = trimmed.slice(0, colon).trim(); const label = trimmed.slice(0, colon).trim();
let rest = trimmed.slice(colon + 1).trim(); let rest = trimmed.slice(colon + 1).trim();
if (!label || !rest) return null; if (!label) return { ok: false, reason: "missing_colon" };
if (!rest) return { ok: false, reason: "schema_invalid" };
if (label.length > LABEL_BYTE_CAP) {
return { ok: false, reason: "label_overflow" };
}
let unit: string | undefined; let unit: string | undefined;
let window: string | undefined; let window: string | undefined;
@@ -78,7 +126,10 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
} }
const value = rest.trim(); const value = rest.trim();
if (!value) return null; if (!value) return { ok: false, reason: "schema_invalid" };
if (value.length > VALUE_BYTE_CAP) {
return { ok: false, reason: "value_overflow" };
}
const parsed = coachKeyValueSchema.safeParse({ const parsed = coachKeyValueSchema.safeParse({
label, label,
@@ -86,8 +137,18 @@ export function parseKeyValueLine(line: string): CoachKeyValue | null {
...(unit ? { unit } : {}), ...(unit ? { unit } : {}),
...(window ? { window } : {}), ...(window ? { window } : {}),
}); });
if (!parsed.success) return null; if (!parsed.success) return { ok: false, reason: "schema_invalid" };
return parsed.data; return { ok: true, value: parsed.data };
}
/**
* Backwards-compatible wrapper around `tryParseKeyValueLine`. Returns
* the parsed value on success, `null` on any failure — kept so existing
* callers and the public test surface keep working unchanged.
*/
export function parseKeyValueLine(line: string): CoachKeyValue | null {
const outcome = tryParseKeyValueLine(line);
return outcome.ok ? outcome.value : null;
} }
export interface SentinelParseResult { export interface SentinelParseResult {
@@ -101,6 +162,17 @@ export interface SentinelParseResult {
* Callers log this for ops visibility (graceful degrade). * Callers log this for ops visibility (graceful degrade).
*/ */
malformed: boolean; malformed: boolean;
/**
* Per-line diagnostics for rows the parser skipped — populated even
* when `keyValues.length > 0` so ops can spot mixed-format drift
* (v1.4.23 H1). Empty when every body line parsed cleanly.
*
* Block-level failures (missing `---END---`, payload truncated to
* fit the byte cap) land here as a synthetic entry with `rawLine`
* set to the marker / cap descriptor and the corresponding reason
* code so the wide-event annotation always carries a typed cause.
*/
malformedEntries: SentinelMalformedEntry[];
} }
/** /**
@@ -112,12 +184,17 @@ export interface SentinelParseResult {
*/ */
export function parseKeyValuesSentinel(raw: string): SentinelParseResult { export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
if (!raw) { if (!raw) {
return { prose: "", keyValues: [], malformed: false }; return { prose: "", keyValues: [], malformed: false, malformedEntries: [] };
} }
const openIdx = raw.indexOf(OPEN_SENTINEL); const openIdx = raw.indexOf(OPEN_SENTINEL);
if (openIdx === -1) { if (openIdx === -1) {
return { prose: raw, keyValues: [], malformed: false }; return {
prose: raw,
keyValues: [],
malformed: false,
malformedEntries: [],
};
} }
// Everything before the opening marker is prose; trim trailing // Everything before the opening marker is prose; trim trailing
@@ -125,6 +202,8 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
// bubble doesn't render an extra blank paragraph. // bubble doesn't render an extra blank paragraph.
const prose = raw.slice(0, openIdx).replace(/\s+$/u, ""); const prose = raw.slice(0, openIdx).replace(/\s+$/u, "");
const malformedEntries: SentinelMalformedEntry[] = [];
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length); const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
// Look for the closing sentinel; if missing, the block is // Look for the closing sentinel; if missing, the block is
// malformed — fall back to the original prose and emit nothing. // malformed — fall back to the original prose and emit nothing.
@@ -137,6 +216,10 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
// the contract wasn't fully met. // the contract wasn't fully met.
bodyRaw = afterOpen; bodyRaw = afterOpen;
malformedClose = true; malformedClose = true;
malformedEntries.push({
rawLine: CLOSE_SENTINEL,
reason: "no_END_marker",
});
} else { } else {
bodyRaw = afterOpen.slice(0, closeRel); bodyRaw = afterOpen.slice(0, closeRel);
} }
@@ -149,6 +232,10 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
if (body.length > SENTINEL_BYTE_CAP) { if (body.length > SENTINEL_BYTE_CAP) {
body = body.slice(0, SENTINEL_BYTE_CAP); body = body.slice(0, SENTINEL_BYTE_CAP);
truncated = true; truncated = true;
malformedEntries.push({
rawLine: `<payload truncated at ${SENTINEL_BYTE_CAP} bytes>`,
reason: "byte_overflow",
});
} }
const lines = body const lines = body
@@ -162,19 +249,29 @@ export function parseKeyValuesSentinel(raw: string): SentinelParseResult {
const kept: CoachKeyValue[] = []; const kept: CoachKeyValue[] = [];
for (const line of lines) { for (const line of lines) {
if (kept.length >= SENTINEL_LINE_CAP) break; if (kept.length >= SENTINEL_LINE_CAP) break;
const entry = parseKeyValueLine(line); const outcome = tryParseKeyValueLine(line);
if (entry) kept.push(entry); if (outcome.ok) {
kept.push(outcome.value);
} else {
malformedEntries.push({ rawLine: line, reason: outcome.reason });
}
} }
// Malformed when: // Malformed when:
// - closing sentinel missing, OR // - closing sentinel missing, OR
// - block payload was truncated to fit the 1 KB cap, OR // - block payload was truncated to fit the 1 KB cap, OR
// - the block was present but yielded zero valid rows. // - the block was present but yielded zero valid rows, OR
const malformed = malformedClose || truncated || kept.length === 0; // - any individual row failed to parse (mixed-format drift).
const malformed =
malformedClose ||
truncated ||
kept.length === 0 ||
malformedEntries.length > 0;
return { return {
prose, prose,
keyValues: kept, keyValues: kept,
malformed, malformed,
malformedEntries,
}; };
} }