@tangle-network/agent-app 0.44.12 → 0.44.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -153,7 +153,11 @@ interface TurnHealthVerdict {
153
153
  * reported so a WINDOW can be judged: a product whose deliverable is tool
154
154
  * output and which produced zero tool calls across every turn in the
155
155
  * lookback has a dead tool surface, and that is the shape no per-turn rule
156
- * can see. (Measured: tax-agent, 129 of 129 assistant rows all-time.) */
156
+ * can see.
157
+ *
158
+ * Only meaningful alongside {@link interpretedParts} > 0. Zero tool calls
159
+ * read off a row that persisted NO parts is not an observation about the
160
+ * tool surface — it is the absence of one. */
157
161
  toolCalls: number;
158
162
  /** True when nothing about this turn could be judged — no interpretable part
159
163
  * AND no visible text. Callers MUST exclude these from any healthy/unhealthy
@@ -177,6 +181,19 @@ interface TurnHealthVerdict {
177
181
  * {@link partsReadable}. Named in the alert so a reader can see EXACTLY what
178
182
  * the detector was blind to instead of taking "unreadable" on faith. */
179
183
  opaquePartTypes: string[];
184
+ /** How many parts this module actually READ (text, artifact, tool, or a known
185
+ * non-output kind).
186
+ *
187
+ * The third blindness, and the one that shipped a false page. `partsReadable`
188
+ * only says nothing was *uninterpretable*; a row that persisted NO parts at
189
+ * all satisfies that vacuously. Production: 97 of tax-agent's 156 assistant
190
+ * rows predate parts persistence entirely and store `[]`, and reading "zero
191
+ * tool calls" off them raised a critical `dead_tool_surface` against a
192
+ * product whose own `turn_events` table holds 243 `tool_call` frames.
193
+ *
194
+ * So a tool verdict requires BOTH `partsReadable` AND this being > 0 —
195
+ * evidence that was read, not merely evidence that failed to be unreadable. */
196
+ interpretedParts: number;
180
197
  }
181
198
  /**
182
199
  * Judge one settled turn.
@@ -480,12 +497,36 @@ interface SweepOptions {
480
497
  * returns fluent prose and HTTP 200.
481
498
  *
482
499
  * This is the fourth failure shape, and the only one no per-turn rule can
483
- * see. Measured on production: tax-agent has 129 assistant turns across 64
484
- * threads, all-time, with zero tool parts — while every other detector in
485
- * this module reports it healthy. */
500
+ * see.
501
+ *
502
+ * The verdict is drawn ONLY over turns whose parts were present and
503
+ * readable. An earlier revision counted rows that stored no parts, and the
504
+ * production example this comment used to cite was that bug rather than a
505
+ * finding: tax-agent's 97 parts-less rows all predate its parts persistence
506
+ * (they stop at 2026-07-17T02:20Z, the encrypted rows start 02:58Z), while
507
+ * the same database's `turn_events` table holds 243 `tool_call` frames over
508
+ * 15 turns. The tool surface was never dead; the rows were empty. */
486
509
  expectsToolCalls?: boolean;
487
510
  /** Turns needed before a dead tool surface is called. Default 10. */
488
511
  minTurnsForToolSurface?: number;
512
+ /**
513
+ * Make an at-rest parts encoding readable, so this sweep can judge a product
514
+ * that does not store parts in the clear.
515
+ *
516
+ * Without it, a product that encrypts `parts` (tax-agent wraps the whole
517
+ * array in one `__encrypted_parts__` part) is permanently UNMEASURABLE by the
518
+ * tool-surface rule: every row is opaque, so the honest verdict is "cannot
519
+ * certify" forever. This seam is how such a product gets a real verdict
520
+ * instead of permanent blindness — the sweep stays domain-free and the
521
+ * product supplies the decoder.
522
+ *
523
+ * Return the decoded parts value (it is parsed exactly like a stored one).
524
+ * Returning `null`/`undefined` or THROWING leaves the row's stored value in
525
+ * place, so a decoder that fails reports the row as opaque. It must never
526
+ * report success as an empty array: that manufactures the exact absent-parts
527
+ * blindness this module now refuses to draw conclusions from.
528
+ */
529
+ decodeParts?: (raw: unknown, row: PersistedTurnRow) => Promise<unknown> | unknown;
489
530
  now?: number;
490
531
  }
491
532
  /** What the sweep found. Returned as well as alerted, so a cron can log it and
@@ -515,6 +556,13 @@ interface SweepResult {
515
556
  * {@link unreadableTurns} (a row can have readable text and opaque parts).
516
557
  * No tool verdict is drawn over these. */
517
558
  opaquePartsTurns: number;
559
+ /** Turns that persisted NO parts at all. Distinct from
560
+ * {@link opaquePartsTurns}: nothing failed to parse, there was simply
561
+ * nothing there. Also excluded from every tool verdict. */
562
+ noPartsTurns: number;
563
+ /** Turns a tool verdict may actually be drawn from — parts present AND
564
+ * interpretable. The denominator behind `dead_tool_surface`. */
565
+ toolReadableTurns: number;
518
566
  alerts: TurnHealthAlert[];
519
567
  }
520
568
  declare const SHELL_ERROR_REPLY_PREFIXES: readonly string[];
@@ -105,7 +105,11 @@ function classifyTurnOutcome(input) {
105
105
  toolCalls,
106
106
  unreadable: true,
107
107
  partsReadable: false,
108
- opaquePartTypes: uniqueOpaque
108
+ opaquePartTypes: uniqueOpaque,
109
+ // Structurally 0 here — `unreadable` is only reached when
110
+ // `interpretedParts === 0`. Carried rather than hardcoded so the field
111
+ // has one source on every return path.
112
+ interpretedParts
109
113
  };
110
114
  }
111
115
  if (input.gated) {
@@ -125,7 +129,8 @@ function classifyTurnOutcome(input) {
125
129
  toolCalls,
126
130
  unreadable: false,
127
131
  partsReadable,
128
- opaquePartTypes: uniqueOpaque
132
+ opaquePartTypes: uniqueOpaque,
133
+ interpretedParts
129
134
  };
130
135
  }
131
136
  function severityOf(reasons) {
@@ -339,6 +344,15 @@ function createTurnHealthLifecycle(options) {
339
344
  }
340
345
 
341
346
  // src/turn-health/sweep.ts
347
+ async function decodeRowParts(decode, row) {
348
+ if (!decode) return row.parts;
349
+ try {
350
+ const decoded = await decode(row.parts, row);
351
+ return decoded ?? row.parts;
352
+ } catch {
353
+ return row.parts;
354
+ }
355
+ }
342
356
  function parseParts(raw) {
343
357
  if (Array.isArray(raw)) return raw;
344
358
  if (typeof raw !== "string" || raw.trim().length === 0) return [];
@@ -404,18 +418,20 @@ async function sweepSilentFailures(options) {
404
418
  let unreadableTurns = 0;
405
419
  let toolReadableTurns = 0;
406
420
  let opaquePartsTurns = 0;
421
+ let noPartsTurns = 0;
407
422
  const opaqueTypes = /* @__PURE__ */ new Set();
408
423
  const malformedSamples = [];
409
424
  const rejectedSamples = [];
410
425
  for (const row of turns) {
411
426
  const verdict = classifyTurnOutcome({
412
427
  finalText: row.content,
413
- parts: parseParts(row.parts),
428
+ parts: parseParts(await decodeRowParts(options.decodeParts, row)),
414
429
  outputTokens: row.outputTokens ?? null
415
430
  });
416
431
  toolCalls += verdict.toolCalls;
417
432
  if (verdict.toolCalls > 0) turnsWithToolCalls += 1;
418
- if (verdict.partsReadable) toolReadableTurns += 1;
433
+ if (verdict.partsReadable && verdict.interpretedParts > 0) toolReadableTurns += 1;
434
+ else if (verdict.partsReadable) noPartsTurns += 1;
419
435
  else {
420
436
  opaquePartsTurns += 1;
421
437
  for (const t of verdict.opaquePartTypes) opaqueTypes.add(t);
@@ -488,13 +504,39 @@ async function sweepSilentFailures(options) {
488
504
  details: [
489
505
  `${toolReadableTurns} assistant turns with readable parts in the lookback, none of which called a tool`,
490
506
  "the product declares its deliverable comes from tool calls, so it has answered without doing anything",
491
- ...opaquePartsTurns > 0 ? [`${opaquePartsTurns} further turn(s) had unreadable parts and were not judged`] : []
507
+ ...opaquePartsTurns > 0 ? [`${opaquePartsTurns} further turn(s) had unreadable parts and were not judged`] : [],
508
+ ...noPartsTurns > 0 ? [`${noPartsTurns} further turn(s) persisted no parts at all and were not judged`] : []
492
509
  ],
493
510
  data: {
494
511
  turnsJudged: toolReadableTurns,
495
512
  toolCalls: 0,
496
513
  turnsWithToolCalls: 0,
497
- turnsNotJudged: opaquePartsTurns
514
+ turnsNotJudged: opaquePartsTurns + noPartsTurns,
515
+ opaquePartsTurns,
516
+ noPartsTurns
517
+ },
518
+ at: now
519
+ });
520
+ }
521
+ if (options.expectsToolCalls && toolReadableTurns < minTurnsForToolSurface && opaquePartsTurns + noPartsTurns > 0) {
522
+ alerts.push({
523
+ product: options.product,
524
+ severity: "warning",
525
+ key: `sweep:${options.product}:tool_surface_unmeasurable`,
526
+ title: `${options.product}: tool surface could not be certified \u2014 only ${toolReadableTurns} of ${turns.length} turns carried readable parts`,
527
+ details: [
528
+ ...noPartsTurns > 0 ? [`${noPartsTurns} turn(s) persisted no parts at all \u2014 nothing to read a tool call from`] : [],
529
+ ...opaquePartsTurns > 0 ? [
530
+ `${opaquePartsTurns} turn(s) stored parts this sweep cannot interpret (${[...opaqueTypes].join(", ") || "unnamed"}) \u2014 supply \`decodeParts\` to make them readable`
531
+ ] : [],
532
+ "this is NOT a dead tool surface finding; it is the absence of evidence either way"
533
+ ],
534
+ data: {
535
+ toolReadableTurns,
536
+ noPartsTurns,
537
+ opaquePartsTurns,
538
+ turnsSeen: turns.length,
539
+ opaqueTypes: [...opaqueTypes]
498
540
  },
499
541
  at: now
500
542
  });
@@ -534,6 +576,8 @@ async function sweepSilentFailures(options) {
534
576
  toolCalls,
535
577
  unreadableTurns,
536
578
  opaquePartsTurns,
579
+ noPartsTurns,
580
+ toolReadableTurns,
537
581
  alerts
538
582
  };
539
583
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/turn-health/classify.ts","../../src/turn-health/sink.ts","../../src/turn-health/lifecycle.ts","../../src/turn-health/sweep.ts"],"sourcesContent":["/**\n * The classifier for turns that FAIL BY RETURNING SUCCESS.\n *\n * Every failure this module names shipped to a customer with HTTP 200, no\n * thrown error, and no log line anyone read. Three were measured in production\n * in a single week:\n *\n * - a turn settled `{\"outcome\":{\"type\":\"completed\"},\"finalText\":\"\",\n * \"tokenUsage\":{\"outputTokens\":0}}` — the customer saw a blank bubble;\n * - six `submit_proposal` tool calls collapsed into ONE whose arguments were\n * a 1,652-character non-JSON string, so zero proposals persisted and\n * nothing errored (agent-runtime #626);\n * - a thread took 255 user messages over 17 days and produced 2 replies,\n * both of them error text.\n *\n * A conventional health check cannot see any of these, because it probes\n * DEPENDENCIES (is the sandbox reachable, is the router up) and every one of\n * these failures happens with all dependencies green. This classifier probes\n * the OUTCOME instead.\n *\n * It is deliberately pure and structural: it reads a settled turn's own\n * projection, so the SAME function judges a live turn through the\n * `/chat-routes` lifecycle seam and a historical row read back out of the\n * store during a sweep. One definition of \"silently broken\", two call sites.\n */\n\n/** How loudly a reason should be routed. `critical` means a customer got\n * nothing usable; `warning` means the turn degraded but still produced\n * something a human could read. */\nexport type TurnHealthSeverity = 'critical' | 'warning'\n\n/** One specific way a turn returned success while failing.\n *\n * Each variant carries the evidence that identified it, so an alert can name\n * the offending value instead of asserting a verdict the reader has to take\n * on faith. */\nexport type TurnHealthReason =\n /** Settled without error and produced nothing a user can read: no text, and\n * no artifact part (file/image/work-product/plan/interaction). This is the\n * verbatim blank-completion capture. */\n | {\n kind: 'empty_completion'\n outputTokens: number | null\n partCount: number\n durationMs?: number\n }\n /** A tool call whose arguments never parsed. The engine surfaces unparseable\n * arguments as a RAW STRING rather than throwing, so the call is neither\n * dropped nor errored — it silently does nothing. Detecting a string-typed\n * tool input that fails `JSON.parse` is the exact fingerprint of the\n * index-less parallel-tool-call collapse. */\n | {\n kind: 'malformed_tool_call'\n tool: string\n inputLength: number\n /** Leading characters of the offending input, for the alert body. */\n sample: string\n }\n /** A tool call that never reached a terminal state carrying output. The call\n * was issued and then simply produced no effect. */\n | {\n kind: 'tool_call_no_effect'\n tool: string\n status: string\n }\n /** The turn failed outright. Not silent by itself — but it becomes silent\n * the moment nothing is watching, which is how 16 days of\n * `TANGLE_HUB_URL is required` reached customers unnoticed. */\n | { kind: 'turn_failed'; reason: string }\n /** A tool call the harness REJECTED, settled as `completed`.\n *\n * Found live in legal-agent production, and missed by every other rule here:\n * the model called `submit_proposal`, the harness answered \"Model tried to\n * call unavailable tool 'submit_proposal'\", and the part persisted as\n * `{\"tool\":\"invalid\",\"state\":{\"status\":\"completed\",\"input\":{\"error\":\"…\"}}}`.\n *\n * Status is `completed`, the arguments parse cleanly, and the turn has text —\n * so the blank-completion, malformed-argument and no-effect rules all pass it.\n * Six deliverables were requested and silently discarded while the product\n * reported success six times.\n *\n * Detected structurally, on the presence of an `error` in the settled state\n * rather than on any harness's name for a rejected call — `invalid` is one\n * harness's convention and must not be baked into the shell. */\n | {\n kind: 'tool_call_rejected'\n /** The tool the model was trying to reach, when the payload names it. */\n tool: string\n error: string\n }\n /** The turn was answered without the model ever running — a pre-producer gate\n * short-circuited and returned the product's own response.\n *\n * Reported as a `warning`, never `critical`: gating an unready turn is a\n * legitimate design (an intake flow SHOULD answer before spending a model\n * call). What is pathological is the RATE, which only the caller's own\n * threshold can judge — so this variant exists to be COUNTED, and alerting\n * on it is opt-in. It is the per-turn evidence behind a dead tool surface:\n * a gate that answers every turn means the agent never runs at all. */\n | { kind: 'answered_without_model' }\n /** The detector could not read this turn.\n *\n * Every part carried a type outside the vocabulary this classifier\n * understands — which is what field-level encryption at rest looks like from\n * the outside (tax-agent persists `{\"type\":\"__encrypted_parts__\"}`, its own\n * convention, and 32 of its 129 assistant rows are exactly that).\n *\n * This exists because the alternative is the bug this whole module hunts.\n * An unreadable row has no text, no artifact and no tool part, so every\n * other rule here would happily conclude \"nothing wrong\" — a detector\n * reporting health from data it cannot see. Blindness is a finding, not a\n * pass, so it gets its own reason and its own counter. */\n | { kind: 'unreadable_turn'; partTypes: string[] }\n\n/** A settled turn, in the narrowest shape both call sites can supply.\n *\n * Structural on purpose: the lifecycle seam supplies `finalText`/`usage`, a\n * store sweep supplies `content`/`parts` read back from a row, and neither\n * has to import the other's types. */\nexport interface TurnOutcomeInput {\n /** The turn's final assistant text. */\n finalText?: string | null\n /** The persisted assistant parts. Untyped by design — a sweep reads these\n * out of a JSON column and must not be forced to validate them first. */\n parts?: readonly unknown[] | null\n /** Output tokens, when the caller has usage. `null`/absent is unknown, which\n * is NOT the same as zero and is never treated as evidence. */\n outputTokens?: number | null\n /** Set when the turn surfaced a terminal error event. */\n failed?: boolean\n failureReason?: string | null\n durationMs?: number\n /** Set when a pre-producer gate answered this turn and the model never ran.\n * Supplied by `/chat-routes`' lifecycle seam, which stamps `gated` on the\n * completion it now fires for a `contextGate` short-circuit. */\n gated?: boolean\n}\n\n/** The verdict for one turn. `healthy` is exactly `reasons.length === 0`, kept\n * as a field so callers read intent rather than an array length. */\nexport interface TurnHealthVerdict {\n healthy: boolean\n severity: TurnHealthSeverity | null\n reasons: TurnHealthReason[]\n /** How many tool parts this turn carried.\n *\n * Zero is NOT a per-turn defect — plenty of good turns answer from context\n * without touching a tool, and paging on each one would be pure noise. It is\n * reported so a WINDOW can be judged: a product whose deliverable is tool\n * output and which produced zero tool calls across every turn in the\n * lookback has a dead tool surface, and that is the shape no per-turn rule\n * can see. (Measured: tax-agent, 129 of 129 assistant rows all-time.) */\n toolCalls: number\n /** True when nothing about this turn could be judged — no interpretable part\n * AND no visible text. Callers MUST exclude these from any healthy/unhealthy\n * ratio, because counting an unreadable turn as healthy is how a detector\n * reports green on data it never read. */\n unreadable: boolean\n /** False when this turn carried parts that could not be interpreted.\n *\n * Separate from {@link unreadable} because the two blindnesses have\n * different consequences, and production has a row that is one but not the\n * other: tax-agent persists CIPHERTEXT as `content` alongside an encrypted\n * `parts` blob, so the turn plainly delivered something (there is text) while\n * its tool calls are completely invisible.\n *\n * Any conclusion ABOUT TOOLS — above all the dead-tool-surface verdict — may\n * only be drawn over turns where this is true. Reading \"no tool parts\" off an\n * encrypted blob and declaring the tool surface dead would be the same\n * crime as declaring it healthy: a finding asserted from data never read. */\n partsReadable: boolean\n /** The part types that could not be interpreted. Empty when\n * {@link partsReadable}. Named in the alert so a reader can see EXACTLY what\n * the detector was blind to instead of taking \"unreadable\" on faith. */\n opaquePartTypes: string[]\n}\n\n/** Part kinds that count as something a user actually receives.\n *\n * A tool part is deliberately NOT here. A turn that ran six tools and said\n * nothing, with no artifact to show for it, is the malformed-tool-call\n * disaster — counting a tool chip as output would suppress the very alert\n * this module exists to raise. */\nconst ARTIFACT_PART_KINDS = new Set(['file', 'image', 'work-product', 'plan', 'interaction'])\n\n/** Part types that carry no user-visible output but ARE understood.\n *\n * The distinction matters: a turn made of nothing but `reasoning` parts\n * thought hard and said nothing, which is a real empty completion. A turn made\n * of types this module has never heard of is a turn it cannot read. Only the\n * second is blindness — so these are enumerated rather than lumped in with the\n * unknown.\n *\n * Grounded in what the fleet actually persists, not guessed: a scan of every\n * assistant part in the gtm / legal / tax production tables returns exactly\n * `tool` (138), `text` (69), `reasoning` (26), `step-start` (3),\n * `step-finish` (3) and tax's opaque `__encrypted_parts__` (32). */\nconst KNOWN_NON_OUTPUT_PART_KINDS = new Set([\n 'reasoning',\n 'step-start',\n 'step-finish',\n 'source',\n 'source-url',\n 'data',\n])\n\nconst SAMPLE_CHARS = 120\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null\n}\n\nfunction nonEmptyString(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value : null\n}\n\n/** True when a string is not parseable JSON.\n *\n * Only meaningful for tool INPUT, where the engine's contract is that a\n * well-formed call carries an object (or a string that parses into one). A\n * string that fails to parse means the arguments were concatenated or\n * truncated upstream. */\nfunction isUnparseableJson(value: string): boolean {\n const trimmed = value.trim()\n if (trimmed.length === 0) return false\n try {\n JSON.parse(trimmed)\n return false\n } catch {\n return true\n }\n}\n\n/** Tool statuses that mean the call actually landed.\n *\n * Anything else — `pending`, `running`, `error`, an unknown string — left no\n * persisted effect by the time the turn settled. */\nconst SETTLED_TOOL_STATUSES = new Set(['completed', 'complete', 'success', 'done'])\n\n/**\n * Judge one settled turn.\n *\n * Never throws: a malformed `parts` blob is a thing this function REPORTS on,\n * so it must not be a thing it dies on. Telemetry that can crash the turn it\n * measures is worse than no telemetry.\n */\nexport function classifyTurnOutcome(input: TurnOutcomeInput): TurnHealthVerdict {\n const reasons: TurnHealthReason[] = []\n\n if (input.failed) {\n reasons.push({\n kind: 'turn_failed',\n reason: nonEmptyString(input.failureReason) ?? 'unspecified',\n })\n }\n\n const parts = Array.isArray(input.parts) ? input.parts : []\n\n let hasVisibleText = nonEmptyString(input.finalText) !== null\n let artifactCount = 0\n let toolCalls = 0\n // Part types this module could not interpret. Tracked so blindness can be\n // reported as blindness instead of silently reading as health.\n const opaqueTypes: string[] = []\n let interpretedParts = 0\n\n for (const raw of parts) {\n const part = asRecord(raw)\n if (!part) continue\n const type = typeof part.type === 'string' ? part.type : ''\n\n if (type === 'text') {\n interpretedParts += 1\n if (nonEmptyString(part.text) !== null) hasVisibleText = true\n continue\n }\n if (ARTIFACT_PART_KINDS.has(type)) {\n interpretedParts += 1\n artifactCount += 1\n continue\n }\n if (KNOWN_NON_OUTPUT_PART_KINDS.has(type)) {\n interpretedParts += 1\n continue\n }\n if (type !== 'tool') {\n opaqueTypes.push(type || '(missing type)')\n continue\n }\n interpretedParts += 1\n toolCalls += 1\n\n const tool = nonEmptyString(part.tool) ?? 'unknown'\n const state = asRecord(part.state)\n const status = typeof state?.status === 'string' ? state.status : 'unknown'\n\n // The #626 fingerprint: arguments surfaced as a raw string because they\n // failed to parse upstream. Checked before the status gate — a malformed\n // call can still be marked completed, which is precisely why it is silent.\n // A rejected call settles as `completed` and carries its rejection in the\n // state. Checked BEFORE the status gate for the same reason as the\n // malformed case: the status is exactly what makes it silent.\n const toolInput = state?.input\n const inputRecord = asRecord(toolInput)\n const rejection =\n nonEmptyString(inputRecord?.error) ?? nonEmptyString((state as Record<string, unknown>)?.error)\n if (rejection) {\n reasons.push({\n kind: 'tool_call_rejected',\n // The payload names the tool the model MEANT to call; the part's own\n // `tool` is the harness's placeholder for a rejected call.\n tool: nonEmptyString(inputRecord?.tool) ?? tool,\n error: rejection.slice(0, 200),\n })\n continue\n }\n if (typeof toolInput === 'string' && isUnparseableJson(toolInput)) {\n reasons.push({\n kind: 'malformed_tool_call',\n tool,\n inputLength: toolInput.length,\n sample: toolInput.slice(0, SAMPLE_CHARS),\n })\n continue\n }\n\n if (!SETTLED_TOOL_STATUSES.has(status)) {\n reasons.push({ kind: 'tool_call_no_effect', tool, status })\n }\n }\n\n // A turn whose parts were ALL uninterpretable cannot be judged. Reported as\n // blindness and returned early, because every rule below would otherwise read\n // \"no text, no artifact\" off data that was simply encrypted and call it a\n // blank completion — a false page that would train readers to ignore this\n // module, which is the same ending as no detector at all.\n const partsReadable = opaqueTypes.length === 0\n const uniqueOpaque = [...new Set(opaqueTypes)]\n const unreadable = opaqueTypes.length > 0 && interpretedParts === 0 && !hasVisibleText\n if (unreadable) {\n reasons.push({ kind: 'unreadable_turn', partTypes: uniqueOpaque })\n return {\n healthy: false,\n severity: 'warning',\n reasons,\n toolCalls,\n unreadable: true,\n partsReadable: false,\n opaquePartTypes: uniqueOpaque,\n }\n }\n\n // A gate answered before the producer ran. Recorded rather than judged: see\n // `answered_without_model`. It also SUPPRESSES the empty-completion rule\n // below — the model producing no text is the expected outcome when the model\n // never ran, and firing `empty_completion` here would page on every healthy\n // intake turn.\n if (input.gated) {\n reasons.push({ kind: 'answered_without_model' })\n } else if (!input.failed && !hasVisibleText && artifactCount === 0) {\n // `outputTokens` is corroborating evidence, never the trigger: a turn can\n // spend tokens on reasoning and still deliver nothing, and a turn with\n // unknown usage can still be perfectly fine.\n reasons.push({\n kind: 'empty_completion',\n outputTokens: input.outputTokens ?? null,\n partCount: parts.length,\n ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}),\n })\n }\n\n return {\n healthy: reasons.length === 0,\n severity: severityOf(reasons),\n reasons,\n toolCalls,\n unreadable: false,\n partsReadable,\n opaquePartTypes: uniqueOpaque,\n }\n}\n\n/** `critical` when the customer got nothing usable out of the turn. A\n * malformed tool call alongside readable text is a `warning` — degraded, but\n * a human still received an answer. */\nfunction severityOf(reasons: TurnHealthReason[]): TurnHealthSeverity | null {\n if (reasons.length === 0) return null\n // A rejected tool call is critical even alongside readable prose: the\n // customer got words where they should have got a filed deliverable.\n const critical = reasons.some(\n (r) =>\n r.kind === 'empty_completion' ||\n r.kind === 'turn_failed' ||\n r.kind === 'tool_call_rejected',\n )\n return critical ? 'critical' : 'warning'\n}\n\n/** One-line human summary of a reason, for an alert body. */\nexport function describeReason(reason: TurnHealthReason): string {\n switch (reason.kind) {\n case 'empty_completion':\n return `completed with NO output (${reason.partCount} parts, outputTokens=${\n reason.outputTokens ?? 'unknown'\n })`\n case 'malformed_tool_call':\n return `tool \\`${reason.tool}\\` arguments did not parse (${reason.inputLength} chars): ${reason.sample}`\n case 'tool_call_no_effect':\n return `tool \\`${reason.tool}\\` left no effect (status=${reason.status})`\n case 'turn_failed':\n return `turn failed: ${reason.reason}`\n case 'tool_call_rejected':\n return `tool \\`${reason.tool}\\` was REJECTED but settled as completed: ${reason.error}`\n case 'answered_without_model':\n return 'answered by a pre-producer gate — the model never ran'\n case 'unreadable_turn':\n return `turn could not be read: every part had an uninterpretable type (${reason.partTypes.join(\n ', ',\n )})`\n }\n}\n","/**\n * Where a silent-failure verdict GOES.\n *\n * The detection half is worthless without this half. Every failure this module\n * finds was already visible in the database the whole time — 255 unanswered\n * messages sat in a table for 17 days. What was missing was not the data, it\n * was delivery to a human who had not thought to look.\n *\n * So the sink is a seam, not a channel: the product supplies the transport,\n * and agent-app ships the two shapes the fleet already has credentials for\n * (an ops webhook, and stderr). No new channel is invented here.\n */\n\nimport { describeReason, type TurnHealthReason, type TurnHealthSeverity } from './classify.js'\n\n/** One deliverable alert. */\nexport interface TurnHealthAlert {\n /** Which product raised it (`projectId`). Alerts from four products land in\n * one channel, so this is what makes the message actionable. */\n product: string\n severity: TurnHealthSeverity\n /** Stable grouping key. Throttling is keyed on this, so it must NOT contain\n * a turn id or a timestamp or every alert is unique and nothing dedupes. */\n key: string\n title: string\n /** Human-readable lines. */\n details: string[]\n /** Structured payload for a machine consumer. */\n data?: Record<string, unknown>\n at: number\n}\n\n/** Deliver an alert. Implementations MUST NOT throw — see\n * {@link createGuardedAlertSink}. */\nexport interface AlertSink {\n deliver(alert: TurnHealthAlert): Promise<void>\n}\n\n/** Build the alert for a set of reasons found on one turn. */\nexport function turnAlert(input: {\n product: string\n severity: TurnHealthSeverity\n reasons: TurnHealthReason[]\n threadId?: string\n turnId?: string\n model?: string\n at?: number\n}): TurnHealthAlert {\n const kinds = [...new Set(input.reasons.map((r) => r.kind))].sort()\n return {\n product: input.product,\n severity: input.severity,\n // Keyed by product + reason kinds ONLY. A blank-completion storm across\n // 200 turns is one incident, not 200 pages.\n key: `turn:${input.product}:${kinds.join('+')}`,\n title: `${input.product}: turn completed but delivered nothing (${kinds.join(', ')})`,\n details: input.reasons.map(describeReason),\n data: {\n kinds,\n ...(input.threadId ? { threadId: input.threadId } : {}),\n ...(input.turnId ? { turnId: input.turnId } : {}),\n ...(input.model ? { model: input.model } : {}),\n },\n at: input.at ?? Date.now(),\n }\n}\n\n// ── transports ────────────────────────────────────────────────────────────\n\n/** Minimal structural fetch, so this module has no lib-dom dependency and can\n * be driven by a fake in tests. */\nexport type FetchLike = (\n url: string,\n init: { method: string; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; text?(): Promise<string> }>\n\n/**\n * POST to an incoming webhook in the Slack message format.\n *\n * Chosen because the org already runs one (`SLACK_OPS_WEBHOOK_URL`) and\n * gtm-agent's outbound webhook code already speaks this exact shape — the\n * instruction was to route somewhere humans already look, not to stand up a\n * new channel. Discord and most log drains accept the same `{text}` body.\n */\nexport function createWebhookAlertSink(options: {\n webhookUrl: string\n fetchImpl?: FetchLike\n}): AlertSink {\n const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)\n return {\n async deliver(alert) {\n const icon = alert.severity === 'critical' ? ':rotating_light:' : ':warning:'\n const lines = [\n `${icon} *${alert.title}*`,\n ...alert.details.map((d) => `• ${d}`),\n `_${new Date(alert.at).toISOString()}_`,\n ]\n const response = await fetchImpl(options.webhookUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ text: lines.join('\\n') }),\n })\n if (!response.ok) {\n // A dropped alert is a silent failure of the silent-failure detector.\n // It must be loud in the one place that is still working: the log.\n throw new Error(`alert webhook responded ${response.status}`)\n }\n },\n }\n}\n\n/**\n * POST to Slack `chat.postMessage` with a bot token.\n *\n * This is the transport the org actually has. A survey of the four product\n * repos found NO ops alert path of any kind — no incoming webhook, no pager, no\n * notifier — which is the mechanical reason a 17-day outage never reached a\n * human. What does exist is a Slack bot token in the shared secrets store, and\n * `@tangle-network/agent-integrations` already speaks this exact API, so this\n * routes alerts through the channel the org runs rather than standing up a new\n * one.\n *\n * Slack answers `200 OK` with `{\"ok\": false, \"error\": \"...\"}` for an invalid\n * token or channel, so the body is checked and not just the status — a\n * transport that reports success on a rejected post would make the alerter\n * itself a silent failure.\n */\nexport function createSlackBotAlertSink(options: {\n botToken: string\n channel: string\n fetchImpl?: FetchLike\n}): AlertSink {\n const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)\n return {\n async deliver(alert) {\n const icon = alert.severity === 'critical' ? ':rotating_light:' : ':warning:'\n const lines = [\n `${icon} *${alert.title}*`,\n ...alert.details.map((d) => `• ${d}`),\n `_${new Date(alert.at).toISOString()}_`,\n ]\n const response = await fetchImpl('https://slack.com/api/chat.postMessage', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n Authorization: `Bearer ${options.botToken}`,\n },\n body: JSON.stringify({ channel: options.channel, text: lines.join('\\n') }),\n })\n if (!response.ok) throw new Error(`slack chat.postMessage responded ${response.status}`)\n const body = (await response.text?.()) ?? ''\n if (body && !/\"ok\"\\s*:\\s*true/.test(body)) {\n throw new Error(`slack rejected the alert: ${body.slice(0, 200)}`)\n }\n },\n }\n}\n\n/** stderr sink. The zero-config fallback so a product that has not yet been\n * given a webhook still emits something a log search can find. */\nexport function createConsoleAlertSink(log: (message: string) => void = console.error): AlertSink {\n return {\n async deliver(alert) {\n log(\n `[turn-health] ${alert.severity.toUpperCase()} ${alert.title} :: ${alert.details.join(' | ')}`,\n )\n },\n }\n}\n\n/** Fan out to several sinks. One failing transport must not stop the others. */\nexport function createMultiAlertSink(sinks: readonly AlertSink[]): AlertSink {\n return {\n async deliver(alert) {\n const settled = await Promise.allSettled(sinks.map((s) => s.deliver(alert)))\n const failures = settled.filter((r) => r.status === 'rejected')\n if (failures.length === sinks.length && sinks.length > 0) {\n throw new Error('every alert sink failed')\n }\n },\n }\n}\n\n// ── throttling ────────────────────────────────────────────────────────────\n\n/** Records the last time a key was alerted on. A product backs this with KV,\n * D1, or a Durable Object; the in-memory default is correct for a sweep that\n * runs as a single cron invocation. */\nexport interface AlertThrottleStore {\n lastSentAt(key: string): Promise<number | null>\n markSent(key: string, at: number): Promise<void>\n}\n\n/** Process-local throttle store. */\nexport function createMemoryThrottleStore(): AlertThrottleStore {\n const seen = new Map<string, number>()\n return {\n async lastSentAt(key) {\n return seen.get(key) ?? null\n },\n async markSent(key, at) {\n seen.set(key, at)\n },\n }\n}\n\n/**\n * Collapse repeats of the same `key` inside `windowMs`.\n *\n * Deliberately re-alerts once per window rather than going silent after the\n * first: an incident that is still burning must keep saying so. Going quiet\n * after one message is how a 17-day outage stays invisible after someone\n * dismisses the first notification.\n */\nexport function createThrottledAlertSink(\n inner: AlertSink,\n options: { windowMs: number; store?: AlertThrottleStore },\n): AlertSink {\n const store = options.store ?? createMemoryThrottleStore()\n return {\n async deliver(alert) {\n const last = await store.lastSentAt(alert.key)\n if (last !== null && alert.at - last < options.windowMs) return\n await inner.deliver(alert)\n await store.markSent(alert.key, alert.at)\n },\n }\n}\n\n/**\n * Swallow transport errors so telemetry can never fail the turn it measures.\n *\n * Use this at the LIVE lifecycle call site only. A sweep should let the error\n * surface, because a sweep that cannot deliver has done nothing at all and its\n * cron run should go red.\n */\nexport function createGuardedAlertSink(\n inner: AlertSink,\n onError: (error: unknown) => void = (e) => console.error('[turn-health] alert delivery failed', e),\n): AlertSink {\n return {\n async deliver(alert) {\n try {\n await inner.deliver(alert)\n } catch (error) {\n onError(error)\n }\n },\n }\n}\n","/**\n * The LIVE half: judge each turn the moment it settles.\n *\n * This adds no control flow. `createChatTurnRoutes` already exposes a\n * `lifecycle` seam that fires exactly one of `onTurnComplete`/`onTurnError`\n * after a turn settles, and already swallows hook errors so telemetry cannot\n * fail a turn. That seam was shipped and then wired by nobody, which is a fair\n * description of why the outage lasted 17 days. This function fills it.\n *\n * The shape is declared structurally rather than imported from\n * `/chat-routes`, so `/turn-health` stays free of the server chat vertical and\n * can be used by any turn driver that reports the same three moments.\n */\n\nimport { classifyTurnOutcome } from './classify.js'\nimport { type AlertSink, createGuardedAlertSink, turnAlert } from './sink.js'\n\n/** Structural mirror of `/chat-routes`' `ChatTurnLifecycle` complete payload. */\nexport interface TurnHealthCompleteInfo {\n finalText: string\n usage?: { outputTokens?: number | null } | null\n durationMs: number\n threadId?: string\n turnStreamId?: string\n executionId?: string\n /** `/chat-routes` sets this when a `contextGate` short-circuited the turn and\n * the producer never ran. */\n gated?: boolean\n}\n\n/** Structural mirror of the lifecycle error payload. */\nexport interface TurnHealthErrorInfo {\n error: unknown\n durationMs: number\n threadId?: string\n turnStreamId?: string\n executionId?: string\n}\n\n/** What {@link createTurnHealthLifecycle} returns — assignable to\n * `createChatTurnRoutes`' `lifecycle` option. */\nexport interface TurnHealthLifecycle {\n onTurnComplete(info: TurnHealthCompleteInfo): Promise<void>\n onTurnError(info: TurnHealthErrorInfo): Promise<void>\n}\n\nexport interface TurnHealthLifecycleOptions {\n /** Names the product in every alert. */\n product: string\n sink: AlertSink\n /** Called for every verdict, healthy or not — the hook for a counter or a\n * metrics push. Alerts are for humans; this is for graphs. */\n onVerdict?(verdict: {\n product: string\n healthy: boolean\n kinds: string[]\n durationMs: number\n }): void\n /** Page when a turn was answered by a gate instead of the model.\n *\n * Default `false`, and the default is the honest one: gating is a legitimate\n * design and a product that gates its intake would otherwise page on every\n * healthy turn. Whether the rate is pathological is domain knowledge, so it\n * stays a product decision — the verdict is ALWAYS reported through\n * {@link TurnHealthLifecycleOptions.onVerdict} so a counter can watch the\n * rate even when nobody is paged. */\n alertOnGatedTurn?: boolean\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n return String(error)\n}\n\n/**\n * Build the lifecycle hooks that page on a turn which succeeded at nothing.\n *\n * The live lane sees `finalText` and usage but not the persisted parts, so it\n * catches the blank-completion and hard-failure shapes immediately. The\n * parts-dependent shapes (a tool call whose arguments never parsed, a tool\n * call that left no effect) are caught by {@link sweepSilentFailures}, which\n * reads what was actually written to the store — the honest place to ask\n * whether an effect persisted.\n */\nexport function createTurnHealthLifecycle(\n options: TurnHealthLifecycleOptions,\n): TurnHealthLifecycle {\n // Guarded: a paging failure must never take down a customer's turn.\n const sink = createGuardedAlertSink(options.sink)\n\n return {\n async onTurnComplete(info) {\n const verdict = classifyTurnOutcome({\n finalText: info.finalText,\n outputTokens: info.usage?.outputTokens ?? null,\n durationMs: info.durationMs,\n ...(info.gated ? { gated: true } : {}),\n })\n options.onVerdict?.({\n product: options.product,\n healthy: verdict.healthy,\n kinds: verdict.reasons.map((r) => r.kind),\n durationMs: info.durationMs,\n })\n if (verdict.healthy || verdict.severity === null) return\n // Counted above, paged only on request — see `alertOnGatedTurn`.\n if (!options.alertOnGatedTurn && verdict.reasons.every((r) => r.kind === 'answered_without_model')) {\n return\n }\n await sink.deliver(\n turnAlert({\n product: options.product,\n severity: verdict.severity,\n reasons: verdict.reasons,\n ...(info.threadId ? { threadId: info.threadId } : {}),\n ...(info.executionId ? { turnId: info.executionId } : {}),\n }),\n )\n },\n\n async onTurnError(info) {\n const verdict = classifyTurnOutcome({\n failed: true,\n failureReason: errorText(info.error),\n durationMs: info.durationMs,\n })\n options.onVerdict?.({\n product: options.product,\n healthy: false,\n kinds: verdict.reasons.map((r) => r.kind),\n durationMs: info.durationMs,\n })\n await sink.deliver(\n turnAlert({\n product: options.product,\n severity: 'critical',\n reasons: verdict.reasons,\n ...(info.threadId ? { threadId: info.threadId } : {}),\n ...(info.executionId ? { turnId: info.executionId } : {}),\n }),\n )\n },\n }\n}\n","/**\n * The SWEEP half: ask the store what it has been quietly accumulating.\n *\n * A live per-turn hook cannot see the failure that matters most, because the\n * worst outage produced NO turns at all to hook: gtm-agent took 9–21 user\n * messages a day for sixteen straight days and wrote zero real assistant\n * replies. Nothing crashed on a schedule; the product simply stopped\n * answering. The only thing that could have noticed is something that\n * periodically counts what arrived against what was answered.\n *\n * That is this. It runs on a cron, reads the shared `/chat-store` schema, and\n * pages when the ratio breaks.\n *\n * The queries live HERE and not in each product because all four products\n * (gtm, tax, legal, workcomp) persist to the same `message`/`thread` tables —\n * four copies of this cron is exactly the duplication the repo's engine/shell\n * rule exists to prevent.\n */\n\nimport { classifyTurnOutcome, describeReason, type TurnHealthReason } from './classify.js'\nimport type { AlertSink, TurnHealthAlert } from './sink.js'\n\n/** A thread that has taken user messages with no reply since. */\nexport interface UnansweredThread {\n threadId: string\n /** User messages newer than the newest real assistant reply. */\n pendingMessages: number\n /** Age of the OLDEST unanswered user message, in ms. */\n oldestAgeMs: number\n}\n\n/** A persisted assistant row, as the sweep needs to judge it. */\nexport interface PersistedTurnRow {\n id: string\n threadId: string\n content: string\n /** Raw `parts` column. A JSON string or an already-parsed array; the sweep\n * accepts both because D1 drivers differ. */\n parts: unknown\n outputTokens?: number | null\n model?: string | null\n createdAt: number\n}\n\n/** What the sweep needs from a store. A product on a non-standard schema\n * implements these two reads; everything else is shared. */\nexport interface TurnHealthSource {\n findUnansweredThreads(input: {\n minAgeMs: number\n /** Ignore user messages older than this. See {@link SweepOptions.maxAgeMs}. */\n maxAgeMs: number\n now: number\n }): Promise<UnansweredThread[]>\n listRecentAssistantTurns(input: { sinceMs: number; now: number; limit: number }): Promise<\n PersistedTurnRow[]\n >\n}\n\nexport interface SweepOptions {\n product: string\n source: TurnHealthSource\n sink: AlertSink\n /** A user message must go unanswered this long before it counts. Guards\n * against alerting on a turn that is simply still streaming. Default 15 min. */\n minAgeMs?: number\n /**\n * A user message OLDER than this is abandoned, not unanswered — it stops\n * counting. Default 7 days.\n *\n * Without this bound the sweep is worse than useless. gtm-agent's table\n * holds 384 unanswered messages whose oldest is 1,676 h (70 days) old;\n * paging hourly on a backlog nobody will ever reply to is exactly how an\n * alert channel gets muted, and a muted channel is the state this module\n * exists to escape. The alert has to mean \"something broke recently\".\n */\n maxAgeMs?: number\n /** How far back to judge settled turns. Default 24 h. */\n lookbackMs?: number\n /** Cap on rows judged per sweep. Default 500. */\n limit?: number\n /** Fraction of recent turns allowed to be silently broken before paging.\n * Default 0.05 — the measured blank-completion rate on the tax tool surface\n * was 12.2%, so 5% separates a real regression from noise. */\n emptyRateThreshold?: number\n /** Absolute floor: never page on a rate computed from fewer turns than this. */\n minTurnsForRate?: number\n /** Declare that this product's deliverable comes from TOOL calls, which\n * switches on the dead-tool-surface detector.\n *\n * Opt-in because only the product knows: a copilot that answers from context\n * is perfectly healthy with zero tool calls, while an agent whose entire job\n * is to file, draft, or submit something is broken the moment its tool\n * surface goes quiet — and broken INVISIBLY, because every turn still\n * returns fluent prose and HTTP 200.\n *\n * This is the fourth failure shape, and the only one no per-turn rule can\n * see. Measured on production: tax-agent has 129 assistant turns across 64\n * threads, all-time, with zero tool parts — while every other detector in\n * this module reports it healthy. */\n expectsToolCalls?: boolean\n /** Turns needed before a dead tool surface is called. Default 10. */\n minTurnsForToolSurface?: number\n now?: number\n}\n\n/** What the sweep found. Returned as well as alerted, so a cron can log it and\n * a test can assert on it. */\nexport interface SweepResult {\n product: string\n unansweredThreads: number\n pendingUserMessages: number\n oldestUnansweredMs: number\n turnsJudged: number\n unhealthyTurns: number\n emptyCompletions: number\n malformedToolCalls: number\n toolCallsWithoutEffect: number\n /** Tool calls the harness rejected while settling them as `completed`. */\n rejectedToolCalls: number\n /** Turns carrying at least one tool part. */\n turnsWithToolCalls: number\n /** Total tool parts across the window. */\n toolCalls: number\n /** Turns the classifier could not interpret at all (encrypted at rest, or a\n * part vocabulary this module does not know). These are EXCLUDED from\n * `turnsJudged`-based rates — a rate computed over rows nobody could read is\n * a fabricated number. */\n unreadableTurns: number\n /** Turns whose PARTS could not be interpreted. A superset of\n * {@link unreadableTurns} (a row can have readable text and opaque parts).\n * No tool verdict is drawn over these. */\n opaquePartsTurns: number\n alerts: TurnHealthAlert[]\n}\n\nfunction parseParts(raw: unknown): unknown[] {\n if (Array.isArray(raw)) return raw\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed : []\n } catch {\n // A parts column that is not JSON is itself a corruption worth seeing, but\n // it is not this detector's job — treat as no parts rather than throwing.\n return []\n }\n}\n\nconst HOUR_MS = 3_600_000\n\n/**\n * Assistant-row openers agent-app writes ITSELF when a sandbox turn fails.\n *\n * Kept byte-identical to the strings `createSandboxChatProducer` composes\n * (`src/chat-routes/sandbox-producer.ts`). They are shell vocabulary, not\n * product domain, so recognising them is this package's job — a product on\n * the shared producer gets a correct sweep with no configuration.\n *\n * `tests/turn-health/turn-health.test.ts` pins these against the producer, so\n * changing the producer's wording without changing this list fails CI rather\n * than silently making dead threads look answered.\n */\n/** D1's maximum LIKE pattern length, including the trailing `%`. Measured, not\n * documented: 50 succeeds, 51 raises `SQLITE_ERROR: LIKE or GLOB pattern too\n * complex`. */\nexport const D1_MAX_LIKE_PATTERN_LENGTH = 50\n\nexport const SHELL_ERROR_REPLY_PREFIXES: readonly string[] = [\n 'The sandbox model stream stopped before a clean completion.',\n 'The sandbox agent returned an error before producing a visible answer.',\n]\n\n/**\n * Run one sweep and deliver whatever it finds.\n *\n * Errors from the sink are NOT swallowed here (unlike the live lane): a sweep\n * that could not deliver has accomplished nothing, and its cron invocation\n * should go red rather than report a clean run.\n */\nexport async function sweepSilentFailures(options: SweepOptions): Promise<SweepResult> {\n const now = options.now ?? Date.now()\n const minAgeMs = options.minAgeMs ?? 15 * 60_000\n const maxAgeMs = options.maxAgeMs ?? 7 * 24 * HOUR_MS\n const lookbackMs = options.lookbackMs ?? 24 * HOUR_MS\n const limit = options.limit ?? 500\n const emptyRateThreshold = options.emptyRateThreshold ?? 0.05\n const minTurnsForRate = options.minTurnsForRate ?? 10\n const minTurnsForToolSurface = options.minTurnsForToolSurface ?? 10\n // Half the window opaque means the sweep is guessing. Fixed rather than\n // configurable: a product must not be able to tune its own blindness report\n // into silence.\n const blindThreshold = 0.5\n\n const [unanswered, turns] = await Promise.all([\n options.source.findUnansweredThreads({ minAgeMs, maxAgeMs, now }),\n options.source.listRecentAssistantTurns({ sinceMs: now - lookbackMs, now, limit }),\n ])\n\n const alerts: TurnHealthAlert[] = []\n\n // ── silence: messages in, nothing out ──────────────────────────────────\n const pendingUserMessages = unanswered.reduce((sum, t) => sum + t.pendingMessages, 0)\n const oldestUnansweredMs = unanswered.reduce((max, t) => Math.max(max, t.oldestAgeMs), 0)\n\n if (unanswered.length > 0) {\n const hours = (oldestUnansweredMs / HOUR_MS).toFixed(1)\n alerts.push({\n product: options.product,\n // A day of total silence is not a warning.\n severity: oldestUnansweredMs >= 24 * HOUR_MS ? 'critical' : 'warning',\n key: `sweep:${options.product}:unanswered_threads`,\n title: `${options.product}: ${pendingUserMessages} user message(s) unanswered across ${unanswered.length} thread(s)`,\n details: [\n `oldest unanswered message: ${hours}h`,\n ...unanswered\n .slice(0, 5)\n .map(\n (t) =>\n `thread ${t.threadId}: ${t.pendingMessages} pending, oldest ${(\n t.oldestAgeMs / HOUR_MS\n ).toFixed(1)}h`,\n ),\n ],\n data: {\n unansweredThreads: unanswered.length,\n pendingUserMessages,\n oldestUnansweredMs,\n },\n at: now,\n })\n }\n\n // ── success that delivered nothing ─────────────────────────────────────\n let emptyCompletions = 0\n let malformedToolCalls = 0\n let toolCallsWithoutEffect = 0\n let rejectedToolCalls = 0\n let unhealthyTurns = 0\n let turnsWithToolCalls = 0\n let toolCalls = 0\n let unreadableTurns = 0\n // Turns whose PARTS were interpretable — the only rows a tool verdict may be\n // drawn from. Distinct from `unreadableTurns`, which is the harder blindness\n // (nothing judgeable at all).\n let toolReadableTurns = 0\n let opaquePartsTurns = 0\n const opaqueTypes = new Set<string>()\n const malformedSamples: TurnHealthReason[] = []\n const rejectedSamples: TurnHealthReason[] = []\n\n for (const row of turns) {\n const verdict = classifyTurnOutcome({\n finalText: row.content,\n parts: parseParts(row.parts),\n outputTokens: row.outputTokens ?? null,\n })\n toolCalls += verdict.toolCalls\n if (verdict.toolCalls > 0) turnsWithToolCalls += 1\n if (verdict.partsReadable) toolReadableTurns += 1\n else {\n opaquePartsTurns += 1\n for (const t of verdict.opaquePartTypes) opaqueTypes.add(t)\n }\n if (verdict.unreadable) {\n unreadableTurns += 1\n continue\n }\n if (verdict.healthy) continue\n unhealthyTurns += 1\n for (const reason of verdict.reasons) {\n if (reason.kind === 'empty_completion') emptyCompletions += 1\n if (reason.kind === 'malformed_tool_call') {\n malformedToolCalls += 1\n if (malformedSamples.length < 3) malformedSamples.push(reason)\n }\n if (reason.kind === 'tool_call_no_effect') toolCallsWithoutEffect += 1\n if (reason.kind === 'tool_call_rejected') {\n rejectedToolCalls += 1\n if (rejectedSamples.length < 3) rejectedSamples.push(reason)\n }\n }\n }\n\n // Turns this sweep could actually judge. Every rate below divides by THIS,\n // never by the raw row count.\n const readableTurns = turns.length - unreadableTurns\n\n // A malformed tool call is never acceptable at any rate — it means a\n // deliverable was requested and silently discarded. Page on the first one.\n if (malformedToolCalls > 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:malformed_tool_call`,\n title: `${options.product}: ${malformedToolCalls} tool call(s) had unparseable arguments — deliverables silently dropped`,\n details: malformedSamples.map(describeReason),\n data: { malformedToolCalls, turnsJudged: turns.length },\n at: now,\n })\n }\n\n // A rejected call means a deliverable was requested and thrown away while the\n // turn reported success. Never acceptable at any rate.\n if (rejectedToolCalls > 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:tool_call_rejected`,\n title: `${options.product}: ${rejectedToolCalls} tool call(s) were REJECTED but settled as completed — deliverables silently dropped`,\n details: rejectedSamples.map(describeReason),\n data: { rejectedToolCalls, turnsJudged: readableTurns },\n at: now,\n })\n }\n\n if (readableTurns >= minTurnsForRate) {\n const rate = emptyCompletions / readableTurns\n if (rate > emptyRateThreshold) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:empty_completion_rate`,\n title: `${options.product}: ${(rate * 100).toFixed(1)}% of turns completed with no output`,\n details: [\n `${emptyCompletions} of ${readableTurns} readable settled turns delivered nothing`,\n `threshold ${(emptyRateThreshold * 100).toFixed(1)}%`,\n ],\n data: { emptyCompletions, turnsJudged: readableTurns, rate },\n at: now,\n })\n }\n }\n\n // ── the fourth shape: a tool surface that has gone quiet ───────────────\n //\n // Nothing errors, every turn answers fluently, and the product stops DOING\n // anything. Only visible across a window, which is why it needs its own pass\n // rather than a rule inside the per-turn classifier.\n // Judged ONLY over turns whose parts could be interpreted, and the count of\n // rows that could not is stated in the alert — a verdict that names its own\n // coverage can be trusted; one that hides it cannot.\n if (options.expectsToolCalls && toolReadableTurns >= minTurnsForToolSurface && toolCalls === 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:dead_tool_surface`,\n title: `${options.product}: ZERO tool calls across ${toolReadableTurns} turns — the tool surface is dead`,\n details: [\n `${toolReadableTurns} assistant turns with readable parts in the lookback, none of which called a tool`,\n 'the product declares its deliverable comes from tool calls, so it has answered without doing anything',\n ...(opaquePartsTurns > 0\n ? [`${opaquePartsTurns} further turn(s) had unreadable parts and were not judged`]\n : []),\n ],\n data: {\n turnsJudged: toolReadableTurns,\n toolCalls: 0,\n turnsWithToolCalls: 0,\n turnsNotJudged: opaquePartsTurns,\n },\n at: now,\n })\n }\n\n // ── the detector reporting on ITSELF ───────────────────────────────────\n //\n // The one verdict this module must never give is \"healthy\" derived from rows\n // it could not read. If most of the window was opaque, that fact is the\n // alert — silence here would be the module committing the exact failure it\n // was built to catch.\n if (opaquePartsTurns > 0 && opaquePartsTurns >= turns.length * blindThreshold) {\n alerts.push({\n product: options.product,\n severity: 'warning',\n key: `sweep:${options.product}:detector_blind`,\n title: `${options.product}: ${opaquePartsTurns} of ${turns.length} turns have unreadable parts — this sweep cannot certify them`,\n details: [\n `uninterpretable part types: ${[...opaqueTypes].join(', ') || '(none named)'}`,\n 'these rows are excluded from every verdict above; treat them as UNMEASURED, not healthy',\n ],\n data: {\n opaquePartsTurns,\n unreadableTurns,\n turnsSeen: turns.length,\n opaqueTypes: [...opaqueTypes],\n },\n at: now,\n })\n }\n\n for (const alert of alerts) await options.sink.deliver(alert)\n\n return {\n product: options.product,\n unansweredThreads: unanswered.length,\n pendingUserMessages,\n oldestUnansweredMs,\n turnsJudged: turns.length,\n unhealthyTurns,\n emptyCompletions,\n malformedToolCalls,\n toolCallsWithoutEffect,\n rejectedToolCalls,\n turnsWithToolCalls,\n toolCalls,\n unreadableTurns,\n opaquePartsTurns,\n alerts,\n }\n}\n\n// ── D1 source for the shared chat-store schema ────────────────────────────\n\n/** Minimal structural D1 contract (Cloudflare's `D1Database` satisfies it). */\nexport interface D1LikeForHealth {\n prepare(sql: string): {\n bind(...values: unknown[]): {\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>\n }\n }\n}\n\n/**\n * The sweep source for products on the canonical `/chat-store` tables.\n *\n * \"Answered\" deliberately means an assistant row with NON-EMPTY content. A\n * blank assistant row is what a broken turn writes, so counting it as an\n * answer would let the exact failure being hunted mark itself resolved. That\n * single predicate is the difference between this catching the gtm outage and\n * sleeping through it — during those sixteen days the table was NOT empty.\n *\n * The query deliberately does NOT join the thread table. Products do not all\n * keep one: tax-agent's `thread` table holds zero rows because it groups by\n * its own `tax_sessions`, and an inner join against it silently reported \"0\n * unanswered threads, healthy\" while 18 real messages sat unanswered. A\n * detector that reports healthy because its join found nothing is the same\n * bug class it was built to catch.\n */\nexport function createD1TurnHealthSource(\n db: D1LikeForHealth,\n options: {\n messageTable?: string\n threadTable?: string\n /**\n * Content prefixes that mark an assistant row as an ERROR SURFACE rather\n * than an answer. A row matching one of these stops counting as a reply,\n * so the thread keeps reporting as unanswered.\n *\n * This exists because the obvious rule — \"an assistant row with non-empty\n * content is an answer\" — is wrong in the exact case that matters. On\n * 2026-07-27 gtm-agent's newest assistant row read:\n *\n * \"The sandbox model stream stopped before a clean completion.\n * Error: All 2 model(s) failed. gpt-5-mini: TANGLE_HUB_URL is required …\"\n *\n * 246 characters of well-formed prose that answers nothing. Counting it\n * marks a dead product healthy — the same failure-returning-success shape\n * this module exists to catch, recursing into the detector itself.\n *\n * There is no schema-level way to recognise it: `output_tokens IS NULL`\n * looked promising until legal-agent showed 22 of 25 GENUINE replies with\n * null usage — it would have reported a working product broken.\n *\n * Defaults to {@link SHELL_ERROR_REPLY_PREFIXES}, the openers agent-app\n * ITSELF writes in `createSandboxChatProducer`. Those are not domain —\n * this package composed them, so this package is what must recognise\n * them, and every product on the shared producer is correct with no\n * configuration. Pass your own list to ADD product-specific error prose;\n * pass `[]` to disable the rule.\n *\n * Prefixes are bound as query parameters, never interpolated.\n */\n errorReplyPrefixes?: readonly string[]\n } = {},\n): TurnHealthSource {\n // Table names are identifiers and cannot be bound as parameters. They come\n // from deploy-time product config, never from a request, and are validated\n // here so this can never become an injection point.\n const message = safeIdentifier(options.messageTable ?? 'message')\n const thread = safeIdentifier(options.threadTable ?? 'thread')\n // D1 rejects a LIKE pattern longer than 50 characters with\n // `SQLITE_ERROR: LIKE or GLOB pattern too complex`, and the pattern is the\n // prefix PLUS the trailing `%`. Measured against production D1 on\n // 2026-07-27: a 49-char prefix (50-char pattern) succeeds, 50 fails.\n //\n // Both shipped defaults are longer than that (55 and 70 characters), so\n // before this clamp `findUnansweredThreads` threw on every product database\n // — the detector could not run at all against the only store the fleet uses.\n //\n // Truncating is safe in the one direction that matters: a shorter prefix\n // matches MORE rows as non-answers, so a thread is reported unanswered\n // rather than silently marked healthy. The first 49 characters of each\n // default are still unambiguous.\n const errorPrefixes = [...(options.errorReplyPrefixes ?? SHELL_ERROR_REPLY_PREFIXES)].map((p) =>\n p.slice(0, D1_MAX_LIKE_PATTERN_LENGTH - 1),\n )\n\n return {\n async findUnansweredThreads({ minAgeMs, maxAgeMs, now }) {\n const cutoffSeconds = Math.floor((now - minAgeMs) / 1000)\n const floorSeconds = Math.floor((now - maxAgeMs) / 1000)\n // Each prefix becomes one bound `NOT LIKE ?||'%'` term. Parameters, not\n // interpolation — a product-supplied string never reaches the SQL text.\n const errorClause = errorPrefixes\n .map((_, i) => ` AND a.content NOT LIKE ?${i + 3} || '%'`)\n .join('')\n const { results } = await db\n .prepare(\n `SELECT m.thread_id AS threadId,\n COUNT(*) AS pendingMessages,\n MIN(m.created_at) AS oldestCreatedAt\n FROM ${message} m\n WHERE m.role = 'user'\n AND m.created_at <= ?1\n AND m.created_at >= ?2\n AND m.created_at > COALESCE(\n (SELECT MAX(a.created_at)\n FROM ${message} a\n WHERE a.thread_id = m.thread_id\n AND a.role = 'assistant'\n AND length(trim(a.content)) > 0${errorClause}), 0)\n GROUP BY m.thread_id\n ORDER BY oldestCreatedAt ASC`,\n )\n .bind(cutoffSeconds, floorSeconds, ...errorPrefixes)\n .all<{ threadId: string; pendingMessages: number; oldestCreatedAt: number }>()\n\n return results.map((row) => ({\n threadId: row.threadId,\n pendingMessages: Number(row.pendingMessages),\n oldestAgeMs: now - Number(row.oldestCreatedAt) * 1000,\n }))\n },\n\n async listRecentAssistantTurns({ sinceMs, limit }) {\n const sinceSeconds = Math.floor(sinceMs / 1000)\n const { results } = await db\n .prepare(\n `SELECT id, thread_id AS threadId, content, parts,\n output_tokens AS outputTokens, model, created_at AS createdAt\n FROM ${message}\n WHERE role = 'assistant' AND created_at >= ?1\n ORDER BY created_at DESC\n LIMIT ?2`,\n )\n .bind(sinceSeconds, limit)\n .all<Record<string, unknown>>()\n\n return results.map((row) => ({\n id: String(row.id),\n threadId: String(row.threadId),\n content: typeof row.content === 'string' ? row.content : '',\n parts: row.parts,\n outputTokens: row.outputTokens === null ? null : Number(row.outputTokens),\n model: (row.model as string | null) ?? null,\n createdAt: Number(row.createdAt) * 1000,\n }))\n },\n }\n\n function safeIdentifier(name: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {\n throw new Error(`unsafe table identifier: ${name}`)\n }\n return name\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/turn-health/classify.ts","../../src/turn-health/sink.ts","../../src/turn-health/lifecycle.ts","../../src/turn-health/sweep.ts"],"sourcesContent":["/**\n * The classifier for turns that FAIL BY RETURNING SUCCESS.\n *\n * Every failure this module names shipped to a customer with HTTP 200, no\n * thrown error, and no log line anyone read. Three were measured in production\n * in a single week:\n *\n * - a turn settled `{\"outcome\":{\"type\":\"completed\"},\"finalText\":\"\",\n * \"tokenUsage\":{\"outputTokens\":0}}` — the customer saw a blank bubble;\n * - six `submit_proposal` tool calls collapsed into ONE whose arguments were\n * a 1,652-character non-JSON string, so zero proposals persisted and\n * nothing errored (agent-runtime #626);\n * - a thread took 255 user messages over 17 days and produced 2 replies,\n * both of them error text.\n *\n * A conventional health check cannot see any of these, because it probes\n * DEPENDENCIES (is the sandbox reachable, is the router up) and every one of\n * these failures happens with all dependencies green. This classifier probes\n * the OUTCOME instead.\n *\n * It is deliberately pure and structural: it reads a settled turn's own\n * projection, so the SAME function judges a live turn through the\n * `/chat-routes` lifecycle seam and a historical row read back out of the\n * store during a sweep. One definition of \"silently broken\", two call sites.\n */\n\n/** How loudly a reason should be routed. `critical` means a customer got\n * nothing usable; `warning` means the turn degraded but still produced\n * something a human could read. */\nexport type TurnHealthSeverity = 'critical' | 'warning'\n\n/** One specific way a turn returned success while failing.\n *\n * Each variant carries the evidence that identified it, so an alert can name\n * the offending value instead of asserting a verdict the reader has to take\n * on faith. */\nexport type TurnHealthReason =\n /** Settled without error and produced nothing a user can read: no text, and\n * no artifact part (file/image/work-product/plan/interaction). This is the\n * verbatim blank-completion capture. */\n | {\n kind: 'empty_completion'\n outputTokens: number | null\n partCount: number\n durationMs?: number\n }\n /** A tool call whose arguments never parsed. The engine surfaces unparseable\n * arguments as a RAW STRING rather than throwing, so the call is neither\n * dropped nor errored — it silently does nothing. Detecting a string-typed\n * tool input that fails `JSON.parse` is the exact fingerprint of the\n * index-less parallel-tool-call collapse. */\n | {\n kind: 'malformed_tool_call'\n tool: string\n inputLength: number\n /** Leading characters of the offending input, for the alert body. */\n sample: string\n }\n /** A tool call that never reached a terminal state carrying output. The call\n * was issued and then simply produced no effect. */\n | {\n kind: 'tool_call_no_effect'\n tool: string\n status: string\n }\n /** The turn failed outright. Not silent by itself — but it becomes silent\n * the moment nothing is watching, which is how 16 days of\n * `TANGLE_HUB_URL is required` reached customers unnoticed. */\n | { kind: 'turn_failed'; reason: string }\n /** A tool call the harness REJECTED, settled as `completed`.\n *\n * Found live in legal-agent production, and missed by every other rule here:\n * the model called `submit_proposal`, the harness answered \"Model tried to\n * call unavailable tool 'submit_proposal'\", and the part persisted as\n * `{\"tool\":\"invalid\",\"state\":{\"status\":\"completed\",\"input\":{\"error\":\"…\"}}}`.\n *\n * Status is `completed`, the arguments parse cleanly, and the turn has text —\n * so the blank-completion, malformed-argument and no-effect rules all pass it.\n * Six deliverables were requested and silently discarded while the product\n * reported success six times.\n *\n * Detected structurally, on the presence of an `error` in the settled state\n * rather than on any harness's name for a rejected call — `invalid` is one\n * harness's convention and must not be baked into the shell. */\n | {\n kind: 'tool_call_rejected'\n /** The tool the model was trying to reach, when the payload names it. */\n tool: string\n error: string\n }\n /** The turn was answered without the model ever running — a pre-producer gate\n * short-circuited and returned the product's own response.\n *\n * Reported as a `warning`, never `critical`: gating an unready turn is a\n * legitimate design (an intake flow SHOULD answer before spending a model\n * call). What is pathological is the RATE, which only the caller's own\n * threshold can judge — so this variant exists to be COUNTED, and alerting\n * on it is opt-in. It is the per-turn evidence behind a dead tool surface:\n * a gate that answers every turn means the agent never runs at all. */\n | { kind: 'answered_without_model' }\n /** The detector could not read this turn.\n *\n * Every part carried a type outside the vocabulary this classifier\n * understands — which is what field-level encryption at rest looks like from\n * the outside (tax-agent persists `{\"type\":\"__encrypted_parts__\"}`, its own\n * convention, and 32 of its 129 assistant rows are exactly that).\n *\n * This exists because the alternative is the bug this whole module hunts.\n * An unreadable row has no text, no artifact and no tool part, so every\n * other rule here would happily conclude \"nothing wrong\" — a detector\n * reporting health from data it cannot see. Blindness is a finding, not a\n * pass, so it gets its own reason and its own counter. */\n | { kind: 'unreadable_turn'; partTypes: string[] }\n\n/** A settled turn, in the narrowest shape both call sites can supply.\n *\n * Structural on purpose: the lifecycle seam supplies `finalText`/`usage`, a\n * store sweep supplies `content`/`parts` read back from a row, and neither\n * has to import the other's types. */\nexport interface TurnOutcomeInput {\n /** The turn's final assistant text. */\n finalText?: string | null\n /** The persisted assistant parts. Untyped by design — a sweep reads these\n * out of a JSON column and must not be forced to validate them first. */\n parts?: readonly unknown[] | null\n /** Output tokens, when the caller has usage. `null`/absent is unknown, which\n * is NOT the same as zero and is never treated as evidence. */\n outputTokens?: number | null\n /** Set when the turn surfaced a terminal error event. */\n failed?: boolean\n failureReason?: string | null\n durationMs?: number\n /** Set when a pre-producer gate answered this turn and the model never ran.\n * Supplied by `/chat-routes`' lifecycle seam, which stamps `gated` on the\n * completion it now fires for a `contextGate` short-circuit. */\n gated?: boolean\n}\n\n/** The verdict for one turn. `healthy` is exactly `reasons.length === 0`, kept\n * as a field so callers read intent rather than an array length. */\nexport interface TurnHealthVerdict {\n healthy: boolean\n severity: TurnHealthSeverity | null\n reasons: TurnHealthReason[]\n /** How many tool parts this turn carried.\n *\n * Zero is NOT a per-turn defect — plenty of good turns answer from context\n * without touching a tool, and paging on each one would be pure noise. It is\n * reported so a WINDOW can be judged: a product whose deliverable is tool\n * output and which produced zero tool calls across every turn in the\n * lookback has a dead tool surface, and that is the shape no per-turn rule\n * can see.\n *\n * Only meaningful alongside {@link interpretedParts} > 0. Zero tool calls\n * read off a row that persisted NO parts is not an observation about the\n * tool surface — it is the absence of one. */\n toolCalls: number\n /** True when nothing about this turn could be judged — no interpretable part\n * AND no visible text. Callers MUST exclude these from any healthy/unhealthy\n * ratio, because counting an unreadable turn as healthy is how a detector\n * reports green on data it never read. */\n unreadable: boolean\n /** False when this turn carried parts that could not be interpreted.\n *\n * Separate from {@link unreadable} because the two blindnesses have\n * different consequences, and production has a row that is one but not the\n * other: tax-agent persists CIPHERTEXT as `content` alongside an encrypted\n * `parts` blob, so the turn plainly delivered something (there is text) while\n * its tool calls are completely invisible.\n *\n * Any conclusion ABOUT TOOLS — above all the dead-tool-surface verdict — may\n * only be drawn over turns where this is true. Reading \"no tool parts\" off an\n * encrypted blob and declaring the tool surface dead would be the same\n * crime as declaring it healthy: a finding asserted from data never read. */\n partsReadable: boolean\n /** The part types that could not be interpreted. Empty when\n * {@link partsReadable}. Named in the alert so a reader can see EXACTLY what\n * the detector was blind to instead of taking \"unreadable\" on faith. */\n opaquePartTypes: string[]\n /** How many parts this module actually READ (text, artifact, tool, or a known\n * non-output kind).\n *\n * The third blindness, and the one that shipped a false page. `partsReadable`\n * only says nothing was *uninterpretable*; a row that persisted NO parts at\n * all satisfies that vacuously. Production: 97 of tax-agent's 156 assistant\n * rows predate parts persistence entirely and store `[]`, and reading \"zero\n * tool calls\" off them raised a critical `dead_tool_surface` against a\n * product whose own `turn_events` table holds 243 `tool_call` frames.\n *\n * So a tool verdict requires BOTH `partsReadable` AND this being > 0 —\n * evidence that was read, not merely evidence that failed to be unreadable. */\n interpretedParts: number\n}\n\n/** Part kinds that count as something a user actually receives.\n *\n * A tool part is deliberately NOT here. A turn that ran six tools and said\n * nothing, with no artifact to show for it, is the malformed-tool-call\n * disaster — counting a tool chip as output would suppress the very alert\n * this module exists to raise. */\nconst ARTIFACT_PART_KINDS = new Set(['file', 'image', 'work-product', 'plan', 'interaction'])\n\n/** Part types that carry no user-visible output but ARE understood.\n *\n * The distinction matters: a turn made of nothing but `reasoning` parts\n * thought hard and said nothing, which is a real empty completion. A turn made\n * of types this module has never heard of is a turn it cannot read. Only the\n * second is blindness — so these are enumerated rather than lumped in with the\n * unknown.\n *\n * Grounded in what the fleet actually persists, not guessed: a scan of every\n * assistant part in the gtm / legal / tax production tables returns exactly\n * `tool` (138), `text` (69), `reasoning` (26), `step-start` (3),\n * `step-finish` (3) and tax's opaque `__encrypted_parts__` (32). */\nconst KNOWN_NON_OUTPUT_PART_KINDS = new Set([\n 'reasoning',\n 'step-start',\n 'step-finish',\n 'source',\n 'source-url',\n 'data',\n])\n\nconst SAMPLE_CHARS = 120\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null\n}\n\nfunction nonEmptyString(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value : null\n}\n\n/** True when a string is not parseable JSON.\n *\n * Only meaningful for tool INPUT, where the engine's contract is that a\n * well-formed call carries an object (or a string that parses into one). A\n * string that fails to parse means the arguments were concatenated or\n * truncated upstream. */\nfunction isUnparseableJson(value: string): boolean {\n const trimmed = value.trim()\n if (trimmed.length === 0) return false\n try {\n JSON.parse(trimmed)\n return false\n } catch {\n return true\n }\n}\n\n/** Tool statuses that mean the call actually landed.\n *\n * Anything else — `pending`, `running`, `error`, an unknown string — left no\n * persisted effect by the time the turn settled. */\nconst SETTLED_TOOL_STATUSES = new Set(['completed', 'complete', 'success', 'done'])\n\n/**\n * Judge one settled turn.\n *\n * Never throws: a malformed `parts` blob is a thing this function REPORTS on,\n * so it must not be a thing it dies on. Telemetry that can crash the turn it\n * measures is worse than no telemetry.\n */\nexport function classifyTurnOutcome(input: TurnOutcomeInput): TurnHealthVerdict {\n const reasons: TurnHealthReason[] = []\n\n if (input.failed) {\n reasons.push({\n kind: 'turn_failed',\n reason: nonEmptyString(input.failureReason) ?? 'unspecified',\n })\n }\n\n const parts = Array.isArray(input.parts) ? input.parts : []\n\n let hasVisibleText = nonEmptyString(input.finalText) !== null\n let artifactCount = 0\n let toolCalls = 0\n // Part types this module could not interpret. Tracked so blindness can be\n // reported as blindness instead of silently reading as health.\n const opaqueTypes: string[] = []\n let interpretedParts = 0\n\n for (const raw of parts) {\n const part = asRecord(raw)\n if (!part) continue\n const type = typeof part.type === 'string' ? part.type : ''\n\n if (type === 'text') {\n interpretedParts += 1\n if (nonEmptyString(part.text) !== null) hasVisibleText = true\n continue\n }\n if (ARTIFACT_PART_KINDS.has(type)) {\n interpretedParts += 1\n artifactCount += 1\n continue\n }\n if (KNOWN_NON_OUTPUT_PART_KINDS.has(type)) {\n interpretedParts += 1\n continue\n }\n if (type !== 'tool') {\n opaqueTypes.push(type || '(missing type)')\n continue\n }\n interpretedParts += 1\n toolCalls += 1\n\n const tool = nonEmptyString(part.tool) ?? 'unknown'\n const state = asRecord(part.state)\n const status = typeof state?.status === 'string' ? state.status : 'unknown'\n\n // The #626 fingerprint: arguments surfaced as a raw string because they\n // failed to parse upstream. Checked before the status gate — a malformed\n // call can still be marked completed, which is precisely why it is silent.\n // A rejected call settles as `completed` and carries its rejection in the\n // state. Checked BEFORE the status gate for the same reason as the\n // malformed case: the status is exactly what makes it silent.\n const toolInput = state?.input\n const inputRecord = asRecord(toolInput)\n const rejection =\n nonEmptyString(inputRecord?.error) ?? nonEmptyString((state as Record<string, unknown>)?.error)\n if (rejection) {\n reasons.push({\n kind: 'tool_call_rejected',\n // The payload names the tool the model MEANT to call; the part's own\n // `tool` is the harness's placeholder for a rejected call.\n tool: nonEmptyString(inputRecord?.tool) ?? tool,\n error: rejection.slice(0, 200),\n })\n continue\n }\n if (typeof toolInput === 'string' && isUnparseableJson(toolInput)) {\n reasons.push({\n kind: 'malformed_tool_call',\n tool,\n inputLength: toolInput.length,\n sample: toolInput.slice(0, SAMPLE_CHARS),\n })\n continue\n }\n\n if (!SETTLED_TOOL_STATUSES.has(status)) {\n reasons.push({ kind: 'tool_call_no_effect', tool, status })\n }\n }\n\n // A turn whose parts were ALL uninterpretable cannot be judged. Reported as\n // blindness and returned early, because every rule below would otherwise read\n // \"no text, no artifact\" off data that was simply encrypted and call it a\n // blank completion — a false page that would train readers to ignore this\n // module, which is the same ending as no detector at all.\n const partsReadable = opaqueTypes.length === 0\n const uniqueOpaque = [...new Set(opaqueTypes)]\n const unreadable = opaqueTypes.length > 0 && interpretedParts === 0 && !hasVisibleText\n if (unreadable) {\n reasons.push({ kind: 'unreadable_turn', partTypes: uniqueOpaque })\n return {\n healthy: false,\n severity: 'warning',\n reasons,\n toolCalls,\n unreadable: true,\n partsReadable: false,\n opaquePartTypes: uniqueOpaque,\n // Structurally 0 here — `unreadable` is only reached when\n // `interpretedParts === 0`. Carried rather than hardcoded so the field\n // has one source on every return path.\n interpretedParts,\n }\n }\n\n // A gate answered before the producer ran. Recorded rather than judged: see\n // `answered_without_model`. It also SUPPRESSES the empty-completion rule\n // below — the model producing no text is the expected outcome when the model\n // never ran, and firing `empty_completion` here would page on every healthy\n // intake turn.\n if (input.gated) {\n reasons.push({ kind: 'answered_without_model' })\n } else if (!input.failed && !hasVisibleText && artifactCount === 0) {\n // `outputTokens` is corroborating evidence, never the trigger: a turn can\n // spend tokens on reasoning and still deliver nothing, and a turn with\n // unknown usage can still be perfectly fine.\n reasons.push({\n kind: 'empty_completion',\n outputTokens: input.outputTokens ?? null,\n partCount: parts.length,\n ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}),\n })\n }\n\n return {\n healthy: reasons.length === 0,\n severity: severityOf(reasons),\n reasons,\n toolCalls,\n unreadable: false,\n partsReadable,\n opaquePartTypes: uniqueOpaque,\n interpretedParts,\n }\n}\n\n/** `critical` when the customer got nothing usable out of the turn. A\n * malformed tool call alongside readable text is a `warning` — degraded, but\n * a human still received an answer. */\nfunction severityOf(reasons: TurnHealthReason[]): TurnHealthSeverity | null {\n if (reasons.length === 0) return null\n // A rejected tool call is critical even alongside readable prose: the\n // customer got words where they should have got a filed deliverable.\n const critical = reasons.some(\n (r) =>\n r.kind === 'empty_completion' ||\n r.kind === 'turn_failed' ||\n r.kind === 'tool_call_rejected',\n )\n return critical ? 'critical' : 'warning'\n}\n\n/** One-line human summary of a reason, for an alert body. */\nexport function describeReason(reason: TurnHealthReason): string {\n switch (reason.kind) {\n case 'empty_completion':\n return `completed with NO output (${reason.partCount} parts, outputTokens=${\n reason.outputTokens ?? 'unknown'\n })`\n case 'malformed_tool_call':\n return `tool \\`${reason.tool}\\` arguments did not parse (${reason.inputLength} chars): ${reason.sample}`\n case 'tool_call_no_effect':\n return `tool \\`${reason.tool}\\` left no effect (status=${reason.status})`\n case 'turn_failed':\n return `turn failed: ${reason.reason}`\n case 'tool_call_rejected':\n return `tool \\`${reason.tool}\\` was REJECTED but settled as completed: ${reason.error}`\n case 'answered_without_model':\n return 'answered by a pre-producer gate — the model never ran'\n case 'unreadable_turn':\n return `turn could not be read: every part had an uninterpretable type (${reason.partTypes.join(\n ', ',\n )})`\n }\n}\n","/**\n * Where a silent-failure verdict GOES.\n *\n * The detection half is worthless without this half. Every failure this module\n * finds was already visible in the database the whole time — 255 unanswered\n * messages sat in a table for 17 days. What was missing was not the data, it\n * was delivery to a human who had not thought to look.\n *\n * So the sink is a seam, not a channel: the product supplies the transport,\n * and agent-app ships the two shapes the fleet already has credentials for\n * (an ops webhook, and stderr). No new channel is invented here.\n */\n\nimport { describeReason, type TurnHealthReason, type TurnHealthSeverity } from './classify.js'\n\n/** One deliverable alert. */\nexport interface TurnHealthAlert {\n /** Which product raised it (`projectId`). Alerts from four products land in\n * one channel, so this is what makes the message actionable. */\n product: string\n severity: TurnHealthSeverity\n /** Stable grouping key. Throttling is keyed on this, so it must NOT contain\n * a turn id or a timestamp or every alert is unique and nothing dedupes. */\n key: string\n title: string\n /** Human-readable lines. */\n details: string[]\n /** Structured payload for a machine consumer. */\n data?: Record<string, unknown>\n at: number\n}\n\n/** Deliver an alert. Implementations MUST NOT throw — see\n * {@link createGuardedAlertSink}. */\nexport interface AlertSink {\n deliver(alert: TurnHealthAlert): Promise<void>\n}\n\n/** Build the alert for a set of reasons found on one turn. */\nexport function turnAlert(input: {\n product: string\n severity: TurnHealthSeverity\n reasons: TurnHealthReason[]\n threadId?: string\n turnId?: string\n model?: string\n at?: number\n}): TurnHealthAlert {\n const kinds = [...new Set(input.reasons.map((r) => r.kind))].sort()\n return {\n product: input.product,\n severity: input.severity,\n // Keyed by product + reason kinds ONLY. A blank-completion storm across\n // 200 turns is one incident, not 200 pages.\n key: `turn:${input.product}:${kinds.join('+')}`,\n title: `${input.product}: turn completed but delivered nothing (${kinds.join(', ')})`,\n details: input.reasons.map(describeReason),\n data: {\n kinds,\n ...(input.threadId ? { threadId: input.threadId } : {}),\n ...(input.turnId ? { turnId: input.turnId } : {}),\n ...(input.model ? { model: input.model } : {}),\n },\n at: input.at ?? Date.now(),\n }\n}\n\n// ── transports ────────────────────────────────────────────────────────────\n\n/** Minimal structural fetch, so this module has no lib-dom dependency and can\n * be driven by a fake in tests. */\nexport type FetchLike = (\n url: string,\n init: { method: string; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; text?(): Promise<string> }>\n\n/**\n * POST to an incoming webhook in the Slack message format.\n *\n * Chosen because the org already runs one (`SLACK_OPS_WEBHOOK_URL`) and\n * gtm-agent's outbound webhook code already speaks this exact shape — the\n * instruction was to route somewhere humans already look, not to stand up a\n * new channel. Discord and most log drains accept the same `{text}` body.\n */\nexport function createWebhookAlertSink(options: {\n webhookUrl: string\n fetchImpl?: FetchLike\n}): AlertSink {\n const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)\n return {\n async deliver(alert) {\n const icon = alert.severity === 'critical' ? ':rotating_light:' : ':warning:'\n const lines = [\n `${icon} *${alert.title}*`,\n ...alert.details.map((d) => `• ${d}`),\n `_${new Date(alert.at).toISOString()}_`,\n ]\n const response = await fetchImpl(options.webhookUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ text: lines.join('\\n') }),\n })\n if (!response.ok) {\n // A dropped alert is a silent failure of the silent-failure detector.\n // It must be loud in the one place that is still working: the log.\n throw new Error(`alert webhook responded ${response.status}`)\n }\n },\n }\n}\n\n/**\n * POST to Slack `chat.postMessage` with a bot token.\n *\n * This is the transport the org actually has. A survey of the four product\n * repos found NO ops alert path of any kind — no incoming webhook, no pager, no\n * notifier — which is the mechanical reason a 17-day outage never reached a\n * human. What does exist is a Slack bot token in the shared secrets store, and\n * `@tangle-network/agent-integrations` already speaks this exact API, so this\n * routes alerts through the channel the org runs rather than standing up a new\n * one.\n *\n * Slack answers `200 OK` with `{\"ok\": false, \"error\": \"...\"}` for an invalid\n * token or channel, so the body is checked and not just the status — a\n * transport that reports success on a rejected post would make the alerter\n * itself a silent failure.\n */\nexport function createSlackBotAlertSink(options: {\n botToken: string\n channel: string\n fetchImpl?: FetchLike\n}): AlertSink {\n const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)\n return {\n async deliver(alert) {\n const icon = alert.severity === 'critical' ? ':rotating_light:' : ':warning:'\n const lines = [\n `${icon} *${alert.title}*`,\n ...alert.details.map((d) => `• ${d}`),\n `_${new Date(alert.at).toISOString()}_`,\n ]\n const response = await fetchImpl('https://slack.com/api/chat.postMessage', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n Authorization: `Bearer ${options.botToken}`,\n },\n body: JSON.stringify({ channel: options.channel, text: lines.join('\\n') }),\n })\n if (!response.ok) throw new Error(`slack chat.postMessage responded ${response.status}`)\n const body = (await response.text?.()) ?? ''\n if (body && !/\"ok\"\\s*:\\s*true/.test(body)) {\n throw new Error(`slack rejected the alert: ${body.slice(0, 200)}`)\n }\n },\n }\n}\n\n/** stderr sink. The zero-config fallback so a product that has not yet been\n * given a webhook still emits something a log search can find. */\nexport function createConsoleAlertSink(log: (message: string) => void = console.error): AlertSink {\n return {\n async deliver(alert) {\n log(\n `[turn-health] ${alert.severity.toUpperCase()} ${alert.title} :: ${alert.details.join(' | ')}`,\n )\n },\n }\n}\n\n/** Fan out to several sinks. One failing transport must not stop the others. */\nexport function createMultiAlertSink(sinks: readonly AlertSink[]): AlertSink {\n return {\n async deliver(alert) {\n const settled = await Promise.allSettled(sinks.map((s) => s.deliver(alert)))\n const failures = settled.filter((r) => r.status === 'rejected')\n if (failures.length === sinks.length && sinks.length > 0) {\n throw new Error('every alert sink failed')\n }\n },\n }\n}\n\n// ── throttling ────────────────────────────────────────────────────────────\n\n/** Records the last time a key was alerted on. A product backs this with KV,\n * D1, or a Durable Object; the in-memory default is correct for a sweep that\n * runs as a single cron invocation. */\nexport interface AlertThrottleStore {\n lastSentAt(key: string): Promise<number | null>\n markSent(key: string, at: number): Promise<void>\n}\n\n/** Process-local throttle store. */\nexport function createMemoryThrottleStore(): AlertThrottleStore {\n const seen = new Map<string, number>()\n return {\n async lastSentAt(key) {\n return seen.get(key) ?? null\n },\n async markSent(key, at) {\n seen.set(key, at)\n },\n }\n}\n\n/**\n * Collapse repeats of the same `key` inside `windowMs`.\n *\n * Deliberately re-alerts once per window rather than going silent after the\n * first: an incident that is still burning must keep saying so. Going quiet\n * after one message is how a 17-day outage stays invisible after someone\n * dismisses the first notification.\n */\nexport function createThrottledAlertSink(\n inner: AlertSink,\n options: { windowMs: number; store?: AlertThrottleStore },\n): AlertSink {\n const store = options.store ?? createMemoryThrottleStore()\n return {\n async deliver(alert) {\n const last = await store.lastSentAt(alert.key)\n if (last !== null && alert.at - last < options.windowMs) return\n await inner.deliver(alert)\n await store.markSent(alert.key, alert.at)\n },\n }\n}\n\n/**\n * Swallow transport errors so telemetry can never fail the turn it measures.\n *\n * Use this at the LIVE lifecycle call site only. A sweep should let the error\n * surface, because a sweep that cannot deliver has done nothing at all and its\n * cron run should go red.\n */\nexport function createGuardedAlertSink(\n inner: AlertSink,\n onError: (error: unknown) => void = (e) => console.error('[turn-health] alert delivery failed', e),\n): AlertSink {\n return {\n async deliver(alert) {\n try {\n await inner.deliver(alert)\n } catch (error) {\n onError(error)\n }\n },\n }\n}\n","/**\n * The LIVE half: judge each turn the moment it settles.\n *\n * This adds no control flow. `createChatTurnRoutes` already exposes a\n * `lifecycle` seam that fires exactly one of `onTurnComplete`/`onTurnError`\n * after a turn settles, and already swallows hook errors so telemetry cannot\n * fail a turn. That seam was shipped and then wired by nobody, which is a fair\n * description of why the outage lasted 17 days. This function fills it.\n *\n * The shape is declared structurally rather than imported from\n * `/chat-routes`, so `/turn-health` stays free of the server chat vertical and\n * can be used by any turn driver that reports the same three moments.\n */\n\nimport { classifyTurnOutcome } from './classify.js'\nimport { type AlertSink, createGuardedAlertSink, turnAlert } from './sink.js'\n\n/** Structural mirror of `/chat-routes`' `ChatTurnLifecycle` complete payload. */\nexport interface TurnHealthCompleteInfo {\n finalText: string\n usage?: { outputTokens?: number | null } | null\n durationMs: number\n threadId?: string\n turnStreamId?: string\n executionId?: string\n /** `/chat-routes` sets this when a `contextGate` short-circuited the turn and\n * the producer never ran. */\n gated?: boolean\n}\n\n/** Structural mirror of the lifecycle error payload. */\nexport interface TurnHealthErrorInfo {\n error: unknown\n durationMs: number\n threadId?: string\n turnStreamId?: string\n executionId?: string\n}\n\n/** What {@link createTurnHealthLifecycle} returns — assignable to\n * `createChatTurnRoutes`' `lifecycle` option. */\nexport interface TurnHealthLifecycle {\n onTurnComplete(info: TurnHealthCompleteInfo): Promise<void>\n onTurnError(info: TurnHealthErrorInfo): Promise<void>\n}\n\nexport interface TurnHealthLifecycleOptions {\n /** Names the product in every alert. */\n product: string\n sink: AlertSink\n /** Called for every verdict, healthy or not — the hook for a counter or a\n * metrics push. Alerts are for humans; this is for graphs. */\n onVerdict?(verdict: {\n product: string\n healthy: boolean\n kinds: string[]\n durationMs: number\n }): void\n /** Page when a turn was answered by a gate instead of the model.\n *\n * Default `false`, and the default is the honest one: gating is a legitimate\n * design and a product that gates its intake would otherwise page on every\n * healthy turn. Whether the rate is pathological is domain knowledge, so it\n * stays a product decision — the verdict is ALWAYS reported through\n * {@link TurnHealthLifecycleOptions.onVerdict} so a counter can watch the\n * rate even when nobody is paged. */\n alertOnGatedTurn?: boolean\n}\n\nfunction errorText(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n return String(error)\n}\n\n/**\n * Build the lifecycle hooks that page on a turn which succeeded at nothing.\n *\n * The live lane sees `finalText` and usage but not the persisted parts, so it\n * catches the blank-completion and hard-failure shapes immediately. The\n * parts-dependent shapes (a tool call whose arguments never parsed, a tool\n * call that left no effect) are caught by {@link sweepSilentFailures}, which\n * reads what was actually written to the store — the honest place to ask\n * whether an effect persisted.\n */\nexport function createTurnHealthLifecycle(\n options: TurnHealthLifecycleOptions,\n): TurnHealthLifecycle {\n // Guarded: a paging failure must never take down a customer's turn.\n const sink = createGuardedAlertSink(options.sink)\n\n return {\n async onTurnComplete(info) {\n const verdict = classifyTurnOutcome({\n finalText: info.finalText,\n outputTokens: info.usage?.outputTokens ?? null,\n durationMs: info.durationMs,\n ...(info.gated ? { gated: true } : {}),\n })\n options.onVerdict?.({\n product: options.product,\n healthy: verdict.healthy,\n kinds: verdict.reasons.map((r) => r.kind),\n durationMs: info.durationMs,\n })\n if (verdict.healthy || verdict.severity === null) return\n // Counted above, paged only on request — see `alertOnGatedTurn`.\n if (!options.alertOnGatedTurn && verdict.reasons.every((r) => r.kind === 'answered_without_model')) {\n return\n }\n await sink.deliver(\n turnAlert({\n product: options.product,\n severity: verdict.severity,\n reasons: verdict.reasons,\n ...(info.threadId ? { threadId: info.threadId } : {}),\n ...(info.executionId ? { turnId: info.executionId } : {}),\n }),\n )\n },\n\n async onTurnError(info) {\n const verdict = classifyTurnOutcome({\n failed: true,\n failureReason: errorText(info.error),\n durationMs: info.durationMs,\n })\n options.onVerdict?.({\n product: options.product,\n healthy: false,\n kinds: verdict.reasons.map((r) => r.kind),\n durationMs: info.durationMs,\n })\n await sink.deliver(\n turnAlert({\n product: options.product,\n severity: 'critical',\n reasons: verdict.reasons,\n ...(info.threadId ? { threadId: info.threadId } : {}),\n ...(info.executionId ? { turnId: info.executionId } : {}),\n }),\n )\n },\n }\n}\n","/**\n * The SWEEP half: ask the store what it has been quietly accumulating.\n *\n * A live per-turn hook cannot see the failure that matters most, because the\n * worst outage produced NO turns at all to hook: gtm-agent took 9–21 user\n * messages a day for sixteen straight days and wrote zero real assistant\n * replies. Nothing crashed on a schedule; the product simply stopped\n * answering. The only thing that could have noticed is something that\n * periodically counts what arrived against what was answered.\n *\n * That is this. It runs on a cron, reads the shared `/chat-store` schema, and\n * pages when the ratio breaks.\n *\n * The queries live HERE and not in each product because all four products\n * (gtm, tax, legal, workcomp) persist to the same `message`/`thread` tables —\n * four copies of this cron is exactly the duplication the repo's engine/shell\n * rule exists to prevent.\n */\n\nimport { classifyTurnOutcome, describeReason, type TurnHealthReason } from './classify.js'\nimport type { AlertSink, TurnHealthAlert } from './sink.js'\n\n/** A thread that has taken user messages with no reply since. */\nexport interface UnansweredThread {\n threadId: string\n /** User messages newer than the newest real assistant reply. */\n pendingMessages: number\n /** Age of the OLDEST unanswered user message, in ms. */\n oldestAgeMs: number\n}\n\n/** A persisted assistant row, as the sweep needs to judge it. */\nexport interface PersistedTurnRow {\n id: string\n threadId: string\n content: string\n /** Raw `parts` column. A JSON string or an already-parsed array; the sweep\n * accepts both because D1 drivers differ. */\n parts: unknown\n outputTokens?: number | null\n model?: string | null\n createdAt: number\n}\n\n/** What the sweep needs from a store. A product on a non-standard schema\n * implements these two reads; everything else is shared. */\nexport interface TurnHealthSource {\n findUnansweredThreads(input: {\n minAgeMs: number\n /** Ignore user messages older than this. See {@link SweepOptions.maxAgeMs}. */\n maxAgeMs: number\n now: number\n }): Promise<UnansweredThread[]>\n listRecentAssistantTurns(input: { sinceMs: number; now: number; limit: number }): Promise<\n PersistedTurnRow[]\n >\n}\n\nexport interface SweepOptions {\n product: string\n source: TurnHealthSource\n sink: AlertSink\n /** A user message must go unanswered this long before it counts. Guards\n * against alerting on a turn that is simply still streaming. Default 15 min. */\n minAgeMs?: number\n /**\n * A user message OLDER than this is abandoned, not unanswered — it stops\n * counting. Default 7 days.\n *\n * Without this bound the sweep is worse than useless. gtm-agent's table\n * holds 384 unanswered messages whose oldest is 1,676 h (70 days) old;\n * paging hourly on a backlog nobody will ever reply to is exactly how an\n * alert channel gets muted, and a muted channel is the state this module\n * exists to escape. The alert has to mean \"something broke recently\".\n */\n maxAgeMs?: number\n /** How far back to judge settled turns. Default 24 h. */\n lookbackMs?: number\n /** Cap on rows judged per sweep. Default 500. */\n limit?: number\n /** Fraction of recent turns allowed to be silently broken before paging.\n * Default 0.05 — the measured blank-completion rate on the tax tool surface\n * was 12.2%, so 5% separates a real regression from noise. */\n emptyRateThreshold?: number\n /** Absolute floor: never page on a rate computed from fewer turns than this. */\n minTurnsForRate?: number\n /** Declare that this product's deliverable comes from TOOL calls, which\n * switches on the dead-tool-surface detector.\n *\n * Opt-in because only the product knows: a copilot that answers from context\n * is perfectly healthy with zero tool calls, while an agent whose entire job\n * is to file, draft, or submit something is broken the moment its tool\n * surface goes quiet — and broken INVISIBLY, because every turn still\n * returns fluent prose and HTTP 200.\n *\n * This is the fourth failure shape, and the only one no per-turn rule can\n * see.\n *\n * The verdict is drawn ONLY over turns whose parts were present and\n * readable. An earlier revision counted rows that stored no parts, and the\n * production example this comment used to cite was that bug rather than a\n * finding: tax-agent's 97 parts-less rows all predate its parts persistence\n * (they stop at 2026-07-17T02:20Z, the encrypted rows start 02:58Z), while\n * the same database's `turn_events` table holds 243 `tool_call` frames over\n * 15 turns. The tool surface was never dead; the rows were empty. */\n expectsToolCalls?: boolean\n /** Turns needed before a dead tool surface is called. Default 10. */\n minTurnsForToolSurface?: number\n /**\n * Make an at-rest parts encoding readable, so this sweep can judge a product\n * that does not store parts in the clear.\n *\n * Without it, a product that encrypts `parts` (tax-agent wraps the whole\n * array in one `__encrypted_parts__` part) is permanently UNMEASURABLE by the\n * tool-surface rule: every row is opaque, so the honest verdict is \"cannot\n * certify\" forever. This seam is how such a product gets a real verdict\n * instead of permanent blindness — the sweep stays domain-free and the\n * product supplies the decoder.\n *\n * Return the decoded parts value (it is parsed exactly like a stored one).\n * Returning `null`/`undefined` or THROWING leaves the row's stored value in\n * place, so a decoder that fails reports the row as opaque. It must never\n * report success as an empty array: that manufactures the exact absent-parts\n * blindness this module now refuses to draw conclusions from.\n */\n decodeParts?: (raw: unknown, row: PersistedTurnRow) => Promise<unknown> | unknown\n now?: number\n}\n\n/** Apply the optional decoder, falling back to the stored value on any failure\n * so a broken decoder degrades to blindness (reported) rather than to a fake\n * empty parts array (silent). */\nasync function decodeRowParts(\n decode: SweepOptions['decodeParts'],\n row: PersistedTurnRow,\n): Promise<unknown> {\n if (!decode) return row.parts\n try {\n const decoded = await decode(row.parts, row)\n return decoded ?? row.parts\n } catch {\n return row.parts\n }\n}\n\n/** What the sweep found. Returned as well as alerted, so a cron can log it and\n * a test can assert on it. */\nexport interface SweepResult {\n product: string\n unansweredThreads: number\n pendingUserMessages: number\n oldestUnansweredMs: number\n turnsJudged: number\n unhealthyTurns: number\n emptyCompletions: number\n malformedToolCalls: number\n toolCallsWithoutEffect: number\n /** Tool calls the harness rejected while settling them as `completed`. */\n rejectedToolCalls: number\n /** Turns carrying at least one tool part. */\n turnsWithToolCalls: number\n /** Total tool parts across the window. */\n toolCalls: number\n /** Turns the classifier could not interpret at all (encrypted at rest, or a\n * part vocabulary this module does not know). These are EXCLUDED from\n * `turnsJudged`-based rates — a rate computed over rows nobody could read is\n * a fabricated number. */\n unreadableTurns: number\n /** Turns whose PARTS could not be interpreted. A superset of\n * {@link unreadableTurns} (a row can have readable text and opaque parts).\n * No tool verdict is drawn over these. */\n opaquePartsTurns: number\n /** Turns that persisted NO parts at all. Distinct from\n * {@link opaquePartsTurns}: nothing failed to parse, there was simply\n * nothing there. Also excluded from every tool verdict. */\n noPartsTurns: number\n /** Turns a tool verdict may actually be drawn from — parts present AND\n * interpretable. The denominator behind `dead_tool_surface`. */\n toolReadableTurns: number\n alerts: TurnHealthAlert[]\n}\n\nfunction parseParts(raw: unknown): unknown[] {\n if (Array.isArray(raw)) return raw\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed : []\n } catch {\n // A parts column that is not JSON is itself a corruption worth seeing, but\n // it is not this detector's job — treat as no parts rather than throwing.\n return []\n }\n}\n\nconst HOUR_MS = 3_600_000\n\n/**\n * Assistant-row openers agent-app writes ITSELF when a sandbox turn fails.\n *\n * Kept byte-identical to the strings `createSandboxChatProducer` composes\n * (`src/chat-routes/sandbox-producer.ts`). They are shell vocabulary, not\n * product domain, so recognising them is this package's job — a product on\n * the shared producer gets a correct sweep with no configuration.\n *\n * `tests/turn-health/turn-health.test.ts` pins these against the producer, so\n * changing the producer's wording without changing this list fails CI rather\n * than silently making dead threads look answered.\n */\n/** D1's maximum LIKE pattern length, including the trailing `%`. Measured, not\n * documented: 50 succeeds, 51 raises `SQLITE_ERROR: LIKE or GLOB pattern too\n * complex`. */\nexport const D1_MAX_LIKE_PATTERN_LENGTH = 50\n\nexport const SHELL_ERROR_REPLY_PREFIXES: readonly string[] = [\n 'The sandbox model stream stopped before a clean completion.',\n 'The sandbox agent returned an error before producing a visible answer.',\n]\n\n/**\n * Run one sweep and deliver whatever it finds.\n *\n * Errors from the sink are NOT swallowed here (unlike the live lane): a sweep\n * that could not deliver has accomplished nothing, and its cron invocation\n * should go red rather than report a clean run.\n */\nexport async function sweepSilentFailures(options: SweepOptions): Promise<SweepResult> {\n const now = options.now ?? Date.now()\n const minAgeMs = options.minAgeMs ?? 15 * 60_000\n const maxAgeMs = options.maxAgeMs ?? 7 * 24 * HOUR_MS\n const lookbackMs = options.lookbackMs ?? 24 * HOUR_MS\n const limit = options.limit ?? 500\n const emptyRateThreshold = options.emptyRateThreshold ?? 0.05\n const minTurnsForRate = options.minTurnsForRate ?? 10\n const minTurnsForToolSurface = options.minTurnsForToolSurface ?? 10\n // Half the window opaque means the sweep is guessing. Fixed rather than\n // configurable: a product must not be able to tune its own blindness report\n // into silence.\n const blindThreshold = 0.5\n\n const [unanswered, turns] = await Promise.all([\n options.source.findUnansweredThreads({ minAgeMs, maxAgeMs, now }),\n options.source.listRecentAssistantTurns({ sinceMs: now - lookbackMs, now, limit }),\n ])\n\n const alerts: TurnHealthAlert[] = []\n\n // ── silence: messages in, nothing out ──────────────────────────────────\n const pendingUserMessages = unanswered.reduce((sum, t) => sum + t.pendingMessages, 0)\n const oldestUnansweredMs = unanswered.reduce((max, t) => Math.max(max, t.oldestAgeMs), 0)\n\n if (unanswered.length > 0) {\n const hours = (oldestUnansweredMs / HOUR_MS).toFixed(1)\n alerts.push({\n product: options.product,\n // A day of total silence is not a warning.\n severity: oldestUnansweredMs >= 24 * HOUR_MS ? 'critical' : 'warning',\n key: `sweep:${options.product}:unanswered_threads`,\n title: `${options.product}: ${pendingUserMessages} user message(s) unanswered across ${unanswered.length} thread(s)`,\n details: [\n `oldest unanswered message: ${hours}h`,\n ...unanswered\n .slice(0, 5)\n .map(\n (t) =>\n `thread ${t.threadId}: ${t.pendingMessages} pending, oldest ${(\n t.oldestAgeMs / HOUR_MS\n ).toFixed(1)}h`,\n ),\n ],\n data: {\n unansweredThreads: unanswered.length,\n pendingUserMessages,\n oldestUnansweredMs,\n },\n at: now,\n })\n }\n\n // ── success that delivered nothing ─────────────────────────────────────\n let emptyCompletions = 0\n let malformedToolCalls = 0\n let toolCallsWithoutEffect = 0\n let rejectedToolCalls = 0\n let unhealthyTurns = 0\n let turnsWithToolCalls = 0\n let toolCalls = 0\n let unreadableTurns = 0\n // Turns whose PARTS were interpretable — the only rows a tool verdict may be\n // drawn from. Distinct from `unreadableTurns`, which is the harder blindness\n // (nothing judgeable at all).\n let toolReadableTurns = 0\n let opaquePartsTurns = 0\n // Rows that persisted NO parts at all. Not opaque (nothing failed to parse)\n // and not evidence either — counted separately so the coverage line can say\n // which of the two blindnesses applies.\n let noPartsTurns = 0\n const opaqueTypes = new Set<string>()\n const malformedSamples: TurnHealthReason[] = []\n const rejectedSamples: TurnHealthReason[] = []\n\n for (const row of turns) {\n const verdict = classifyTurnOutcome({\n finalText: row.content,\n parts: parseParts(await decodeRowParts(options.decodeParts, row)),\n outputTokens: row.outputTokens ?? null,\n })\n toolCalls += verdict.toolCalls\n if (verdict.toolCalls > 0) turnsWithToolCalls += 1\n // A tool verdict needs evidence that was READ. `partsReadable` alone is\n // satisfied vacuously by a row that persisted no parts, which is how a\n // product with 243 real tool calls was paged as having a dead tool surface.\n if (verdict.partsReadable && verdict.interpretedParts > 0) toolReadableTurns += 1\n else if (verdict.partsReadable) noPartsTurns += 1\n else {\n opaquePartsTurns += 1\n for (const t of verdict.opaquePartTypes) opaqueTypes.add(t)\n }\n if (verdict.unreadable) {\n unreadableTurns += 1\n continue\n }\n if (verdict.healthy) continue\n unhealthyTurns += 1\n for (const reason of verdict.reasons) {\n if (reason.kind === 'empty_completion') emptyCompletions += 1\n if (reason.kind === 'malformed_tool_call') {\n malformedToolCalls += 1\n if (malformedSamples.length < 3) malformedSamples.push(reason)\n }\n if (reason.kind === 'tool_call_no_effect') toolCallsWithoutEffect += 1\n if (reason.kind === 'tool_call_rejected') {\n rejectedToolCalls += 1\n if (rejectedSamples.length < 3) rejectedSamples.push(reason)\n }\n }\n }\n\n // Turns this sweep could actually judge. Every rate below divides by THIS,\n // never by the raw row count.\n const readableTurns = turns.length - unreadableTurns\n\n // A malformed tool call is never acceptable at any rate — it means a\n // deliverable was requested and silently discarded. Page on the first one.\n if (malformedToolCalls > 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:malformed_tool_call`,\n title: `${options.product}: ${malformedToolCalls} tool call(s) had unparseable arguments — deliverables silently dropped`,\n details: malformedSamples.map(describeReason),\n data: { malformedToolCalls, turnsJudged: turns.length },\n at: now,\n })\n }\n\n // A rejected call means a deliverable was requested and thrown away while the\n // turn reported success. Never acceptable at any rate.\n if (rejectedToolCalls > 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:tool_call_rejected`,\n title: `${options.product}: ${rejectedToolCalls} tool call(s) were REJECTED but settled as completed — deliverables silently dropped`,\n details: rejectedSamples.map(describeReason),\n data: { rejectedToolCalls, turnsJudged: readableTurns },\n at: now,\n })\n }\n\n if (readableTurns >= minTurnsForRate) {\n const rate = emptyCompletions / readableTurns\n if (rate > emptyRateThreshold) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:empty_completion_rate`,\n title: `${options.product}: ${(rate * 100).toFixed(1)}% of turns completed with no output`,\n details: [\n `${emptyCompletions} of ${readableTurns} readable settled turns delivered nothing`,\n `threshold ${(emptyRateThreshold * 100).toFixed(1)}%`,\n ],\n data: { emptyCompletions, turnsJudged: readableTurns, rate },\n at: now,\n })\n }\n }\n\n // ── the fourth shape: a tool surface that has gone quiet ───────────────\n //\n // Nothing errors, every turn answers fluently, and the product stops DOING\n // anything. Only visible across a window, which is why it needs its own pass\n // rather than a rule inside the per-turn classifier.\n // Judged ONLY over turns whose parts could be interpreted, and the count of\n // rows that could not is stated in the alert — a verdict that names its own\n // coverage can be trusted; one that hides it cannot.\n if (options.expectsToolCalls && toolReadableTurns >= minTurnsForToolSurface && toolCalls === 0) {\n alerts.push({\n product: options.product,\n severity: 'critical',\n key: `sweep:${options.product}:dead_tool_surface`,\n title: `${options.product}: ZERO tool calls across ${toolReadableTurns} turns — the tool surface is dead`,\n details: [\n `${toolReadableTurns} assistant turns with readable parts in the lookback, none of which called a tool`,\n 'the product declares its deliverable comes from tool calls, so it has answered without doing anything',\n ...(opaquePartsTurns > 0\n ? [`${opaquePartsTurns} further turn(s) had unreadable parts and were not judged`]\n : []),\n ...(noPartsTurns > 0\n ? [`${noPartsTurns} further turn(s) persisted no parts at all and were not judged`]\n : []),\n ],\n data: {\n turnsJudged: toolReadableTurns,\n toolCalls: 0,\n turnsWithToolCalls: 0,\n turnsNotJudged: opaquePartsTurns + noPartsTurns,\n opaquePartsTurns,\n noPartsTurns,\n },\n at: now,\n })\n }\n\n // A product that ASKED for a tool-surface verdict and did not get one must be\n // told that, or the absence of a page reads as a pass. This is the honest\n // form of the alert above: same question, and the answer is \"I could not\n // see\", which is a real finding about the product's observability rather than\n // a fabricated finding about its behaviour.\n if (\n options.expectsToolCalls &&\n toolReadableTurns < minTurnsForToolSurface &&\n opaquePartsTurns + noPartsTurns > 0\n ) {\n alerts.push({\n product: options.product,\n severity: 'warning',\n key: `sweep:${options.product}:tool_surface_unmeasurable`,\n title: `${options.product}: tool surface could not be certified — only ${toolReadableTurns} of ${turns.length} turns carried readable parts`,\n details: [\n ...(noPartsTurns > 0\n ? [`${noPartsTurns} turn(s) persisted no parts at all — nothing to read a tool call from`]\n : []),\n ...(opaquePartsTurns > 0\n ? [\n `${opaquePartsTurns} turn(s) stored parts this sweep cannot interpret (${\n [...opaqueTypes].join(', ') || 'unnamed'\n }) — supply \\`decodeParts\\` to make them readable`,\n ]\n : []),\n 'this is NOT a dead tool surface finding; it is the absence of evidence either way',\n ],\n data: {\n toolReadableTurns,\n noPartsTurns,\n opaquePartsTurns,\n turnsSeen: turns.length,\n opaqueTypes: [...opaqueTypes],\n },\n at: now,\n })\n }\n\n // ── the detector reporting on ITSELF ───────────────────────────────────\n //\n // The one verdict this module must never give is \"healthy\" derived from rows\n // it could not read. If most of the window was opaque, that fact is the\n // alert — silence here would be the module committing the exact failure it\n // was built to catch.\n if (opaquePartsTurns > 0 && opaquePartsTurns >= turns.length * blindThreshold) {\n alerts.push({\n product: options.product,\n severity: 'warning',\n key: `sweep:${options.product}:detector_blind`,\n title: `${options.product}: ${opaquePartsTurns} of ${turns.length} turns have unreadable parts — this sweep cannot certify them`,\n details: [\n `uninterpretable part types: ${[...opaqueTypes].join(', ') || '(none named)'}`,\n 'these rows are excluded from every verdict above; treat them as UNMEASURED, not healthy',\n ],\n data: {\n opaquePartsTurns,\n unreadableTurns,\n turnsSeen: turns.length,\n opaqueTypes: [...opaqueTypes],\n },\n at: now,\n })\n }\n\n for (const alert of alerts) await options.sink.deliver(alert)\n\n return {\n product: options.product,\n unansweredThreads: unanswered.length,\n pendingUserMessages,\n oldestUnansweredMs,\n turnsJudged: turns.length,\n unhealthyTurns,\n emptyCompletions,\n malformedToolCalls,\n toolCallsWithoutEffect,\n rejectedToolCalls,\n turnsWithToolCalls,\n toolCalls,\n unreadableTurns,\n opaquePartsTurns,\n noPartsTurns,\n toolReadableTurns,\n alerts,\n }\n}\n\n// ── D1 source for the shared chat-store schema ────────────────────────────\n\n/** Minimal structural D1 contract (Cloudflare's `D1Database` satisfies it). */\nexport interface D1LikeForHealth {\n prepare(sql: string): {\n bind(...values: unknown[]): {\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>\n }\n }\n}\n\n/**\n * The sweep source for products on the canonical `/chat-store` tables.\n *\n * \"Answered\" deliberately means an assistant row with NON-EMPTY content. A\n * blank assistant row is what a broken turn writes, so counting it as an\n * answer would let the exact failure being hunted mark itself resolved. That\n * single predicate is the difference between this catching the gtm outage and\n * sleeping through it — during those sixteen days the table was NOT empty.\n *\n * The query deliberately does NOT join the thread table. Products do not all\n * keep one: tax-agent's `thread` table holds zero rows because it groups by\n * its own `tax_sessions`, and an inner join against it silently reported \"0\n * unanswered threads, healthy\" while 18 real messages sat unanswered. A\n * detector that reports healthy because its join found nothing is the same\n * bug class it was built to catch.\n */\nexport function createD1TurnHealthSource(\n db: D1LikeForHealth,\n options: {\n messageTable?: string\n threadTable?: string\n /**\n * Content prefixes that mark an assistant row as an ERROR SURFACE rather\n * than an answer. A row matching one of these stops counting as a reply,\n * so the thread keeps reporting as unanswered.\n *\n * This exists because the obvious rule — \"an assistant row with non-empty\n * content is an answer\" — is wrong in the exact case that matters. On\n * 2026-07-27 gtm-agent's newest assistant row read:\n *\n * \"The sandbox model stream stopped before a clean completion.\n * Error: All 2 model(s) failed. gpt-5-mini: TANGLE_HUB_URL is required …\"\n *\n * 246 characters of well-formed prose that answers nothing. Counting it\n * marks a dead product healthy — the same failure-returning-success shape\n * this module exists to catch, recursing into the detector itself.\n *\n * There is no schema-level way to recognise it: `output_tokens IS NULL`\n * looked promising until legal-agent showed 22 of 25 GENUINE replies with\n * null usage — it would have reported a working product broken.\n *\n * Defaults to {@link SHELL_ERROR_REPLY_PREFIXES}, the openers agent-app\n * ITSELF writes in `createSandboxChatProducer`. Those are not domain —\n * this package composed them, so this package is what must recognise\n * them, and every product on the shared producer is correct with no\n * configuration. Pass your own list to ADD product-specific error prose;\n * pass `[]` to disable the rule.\n *\n * Prefixes are bound as query parameters, never interpolated.\n */\n errorReplyPrefixes?: readonly string[]\n } = {},\n): TurnHealthSource {\n // Table names are identifiers and cannot be bound as parameters. They come\n // from deploy-time product config, never from a request, and are validated\n // here so this can never become an injection point.\n const message = safeIdentifier(options.messageTable ?? 'message')\n const thread = safeIdentifier(options.threadTable ?? 'thread')\n // D1 rejects a LIKE pattern longer than 50 characters with\n // `SQLITE_ERROR: LIKE or GLOB pattern too complex`, and the pattern is the\n // prefix PLUS the trailing `%`. Measured against production D1 on\n // 2026-07-27: a 49-char prefix (50-char pattern) succeeds, 50 fails.\n //\n // Both shipped defaults are longer than that (55 and 70 characters), so\n // before this clamp `findUnansweredThreads` threw on every product database\n // — the detector could not run at all against the only store the fleet uses.\n //\n // Truncating is safe in the one direction that matters: a shorter prefix\n // matches MORE rows as non-answers, so a thread is reported unanswered\n // rather than silently marked healthy. The first 49 characters of each\n // default are still unambiguous.\n const errorPrefixes = [...(options.errorReplyPrefixes ?? SHELL_ERROR_REPLY_PREFIXES)].map((p) =>\n p.slice(0, D1_MAX_LIKE_PATTERN_LENGTH - 1),\n )\n\n return {\n async findUnansweredThreads({ minAgeMs, maxAgeMs, now }) {\n const cutoffSeconds = Math.floor((now - minAgeMs) / 1000)\n const floorSeconds = Math.floor((now - maxAgeMs) / 1000)\n // Each prefix becomes one bound `NOT LIKE ?||'%'` term. Parameters, not\n // interpolation — a product-supplied string never reaches the SQL text.\n const errorClause = errorPrefixes\n .map((_, i) => ` AND a.content NOT LIKE ?${i + 3} || '%'`)\n .join('')\n const { results } = await db\n .prepare(\n `SELECT m.thread_id AS threadId,\n COUNT(*) AS pendingMessages,\n MIN(m.created_at) AS oldestCreatedAt\n FROM ${message} m\n WHERE m.role = 'user'\n AND m.created_at <= ?1\n AND m.created_at >= ?2\n AND m.created_at > COALESCE(\n (SELECT MAX(a.created_at)\n FROM ${message} a\n WHERE a.thread_id = m.thread_id\n AND a.role = 'assistant'\n AND length(trim(a.content)) > 0${errorClause}), 0)\n GROUP BY m.thread_id\n ORDER BY oldestCreatedAt ASC`,\n )\n .bind(cutoffSeconds, floorSeconds, ...errorPrefixes)\n .all<{ threadId: string; pendingMessages: number; oldestCreatedAt: number }>()\n\n return results.map((row) => ({\n threadId: row.threadId,\n pendingMessages: Number(row.pendingMessages),\n oldestAgeMs: now - Number(row.oldestCreatedAt) * 1000,\n }))\n },\n\n async listRecentAssistantTurns({ sinceMs, limit }) {\n const sinceSeconds = Math.floor(sinceMs / 1000)\n const { results } = await db\n .prepare(\n `SELECT id, thread_id AS threadId, content, parts,\n output_tokens AS outputTokens, model, created_at AS createdAt\n FROM ${message}\n WHERE role = 'assistant' AND created_at >= ?1\n ORDER BY created_at DESC\n LIMIT ?2`,\n )\n .bind(sinceSeconds, limit)\n .all<Record<string, unknown>>()\n\n return results.map((row) => ({\n id: String(row.id),\n threadId: String(row.threadId),\n content: typeof row.content === 'string' ? row.content : '',\n parts: row.parts,\n outputTokens: row.outputTokens === null ? null : Number(row.outputTokens),\n model: (row.model as string | null) ?? null,\n createdAt: Number(row.createdAt) * 1000,\n }))\n },\n }\n\n function safeIdentifier(name: string): string {\n if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {\n throw new Error(`unsafe table identifier: ${name}`)\n }\n return name\n }\n}\n"],"mappings":"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","names":[]}