@tangle-network/agent-app 0.44.3 → 0.44.5
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.
- package/dist/chat-routes/index.js +1 -1
- package/dist/{chunk-CHLOH4DG.js → chunk-DEXBRUZR.js} +1 -1
- package/dist/chunk-DEXBRUZR.js.map +1 -0
- package/dist/design-canvas-react/index.js +4 -4
- package/dist/model-resolution/index.js +1 -1
- package/dist/turn-health/index.d.ts +398 -0
- package/dist/turn-health/index.js +428 -0
- package/dist/turn-health/index.js.map +1 -0
- package/package.json +6 -1
- package/dist/chunk-CHLOH4DG.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/model-resolution/failover.ts"],"sourcesContent":["/**\n * Model failover — the answer to \"one upstream hit a quota wall and the customer\n * got a Bad Gateway even though the router had abundant healthy capacity\".\n *\n * This is deliberately NOT a health probe. Probing before every turn buys a\n * round-trip of latency on the happy path and still races the outage (a model\n * healthy at probe time can 502 a second later). Failover is REACTIVE: run the\n * preferred model, and on an upstream-outage signal move to the next model in\n * the chain. Zero added latency when the preferred model works.\n *\n * Two facts drive the design, both measured against a live box during the\n * 2026-07-25 Anthropic/DeepSeek outage:\n *\n * 1. An outage is NOT always a thrown error. The sandbox resolves with a\n * payload — `{ success: false, errorCode: 'provider_inference_unavailable' }`\n * — so a classifier that only inspects `catch` misses the customer-visible\n * case entirely. `isUpstreamUnavailable` inspects resolved values too.\n * 2. Catalog membership is NOT liveness. The router's `/v1/models` still listed\n * every dead model during the outage, so `validateChatModelId` admitted\n * `claude-sonnet-4-6` while every call to it returned 502. Nothing upstream\n * of the actual call can be trusted to tell you a model is serving.\n *\n * Substrate-free: the caller injects `run`, so this composes with a sandbox\n * turn, a router chat completion, or a test fake without importing any of them.\n */\n\n/**\n * Error codes that mean \"this model's upstream is unavailable — a different\n * model may still work\". Deliberately excludes codes that would fail identically\n * on every model (bad request, auth, content filter): retrying those down a\n * chain burns latency and money to reach the same failure.\n */\nexport const UPSTREAM_UNAVAILABLE_CODES: readonly string[] = [\n 'provider_inference_unavailable',\n 'upstream_unavailable',\n 'insufficient_quota',\n 'model_not_available',\n 'server_error',\n 'bad_gateway',\n 'service_unavailable',\n]\n\n/** HTTP statuses that indicate an upstream capacity/availability problem. */\nexport const UPSTREAM_UNAVAILABLE_STATUSES: readonly number[] = [429, 500, 502, 503, 504]\n\n/**\n * Statuses that mean the REQUEST is wrong, so every model in the chain would\n * fail on it identically. Listed explicitly rather than as \"any 4xx\" because\n * 404 is NOT one of them: a model the endpoint cannot find is model-scoped, and\n * the next model in the chain may well be there. 429 is absent for the same\n * reason in reverse — it is a capacity signal and lives in the list above.\n */\nconst REQUEST_ERROR_STATUSES: readonly number[] = [400, 401, 403, 405, 413, 422]\n\n/**\n * Message fragments emitted by real upstreams during this class of outage.\n * Matched case-insensitively as a last resort, after code and status.\n */\nconst UPSTREAM_UNAVAILABLE_MESSAGES: readonly string[] = [\n 'bad gateway',\n 'service unavailable',\n 'inference temporarily unavailable',\n 'provider inference is unavailable',\n 'insufficient balance',\n 'usage limits',\n 'quota exceeded',\n 'rate limit',\n 'overloaded',\n 'temporarily unavailable',\n // Model-SCOPED unavailability. A different model in the chain can still\n // serve, so this belongs here and not with the client errors. Measured on a\n // real box 2026-07-27: the sandbox now reports an unservable model as a\n // terminal `error` whose data is `{\"message\":\"Session error:\n // {\\\"error\\\":{\\\"name\\\":\\\"UnknownError\\\",\\\"data\\\":{\\\"message\\\":\\\"Model not\n // found: openai-compat/zai/glm-4.7.\\\"}}}\"}` — no `code`, no numeric status,\n // and none of the fragments above. The shipped live product-path proof went\n // red on exactly this: `usedFallback:false`, `failed:true`, an error row to\n // the customer where a fallback was available and would have answered.\n 'model not found',\n 'is not currently available',\n]\n\n/**\n * Textual forms an HTTP status arrives in when NO numeric field carries it.\n *\n * The case that forced this is the opaque Cloudflare 502. Captured verbatim\n * from `router.tangle.tools` on 2026-07-27 17:48:12 GMT (cf-ray\n * `a21d793899f6cef3-DEN`): `HTTP/2 502`, `content-type: text/plain;\n * charset=UTF-8`, a 16-byte body reading `error code: 502\\n`, `server:\n * cloudflare`, and **zero `x-tangle-*` headers** where a healthy 200 from the\n * same endpoint carries nine of them.\n *\n * That capture was originally read as \"zero headers means the origin never\n * executed\". It does not, and the correction matters more than the original\n * guess did. Matching client-observed cf-rays against the origin's own access\n * log (tangle-router #307) showed all 62 of those responses WERE produced by\n * the router — `content-type: application/json`, an `X-Generation-Id`, and a\n * routing trace showing the upstream had returned 200 — and Cloudflare\n * replaced the body and every response header with its own error page, because\n * an origin 502 reads to the edge as the origin having failed.\n *\n * The classifier's BEHAVIOR here was right for the wrong reason, and stays\n * exactly as it is. What changes is why: the rescue is needed not because the\n * origin is unreachable, but because a response the origin carefully\n * structured arrives at the client as sixteen unlabelled bytes. The router now\n * emits 503 instead of 502 on those paths so the body survives, but this\n * pattern must remain — it is the client's only handle on any origin 5xx the\n * edge decides to rewrite, including from a router that has not deployed the\n * fix yet.\n *\n * Every pattern below matches a string a real producer emits:\n * - `error code: 502` — Cloudflare's edge body (the capture above).\n * - `(HTTP 502)` — `src/runtime/openai-stream.ts` wrapping a non-ok response.\n * - `returned 502` — `src/runtime/model-catalog.ts`.\n * - `failed with status 502` — `src/turn-stream/adapters.ts`.\n * - `502 Bad Gateway` — an HTTP/1.1 status line. HTTP/2 carries no reason\n * phrase at all, which is precisely why matching the phrase alone (the\n * `'bad gateway'` fragment below) read the capture as a non-outage.\n */\nconst HTTP_STATUS_HINT_PATTERNS: readonly RegExp[] = [\n /\\berror\\s+code:?\\s*([1-5]\\d{2})\\b/i,\n /\\bhttp(?:\\/[\\d.]+)?[\\s:]\\s*([1-5]\\d{2})\\b/i,\n /\\bstatus(?:\\s*code)?[\\s:=]\\s*([1-5]\\d{2})\\b/i,\n /\\b(?:returned|responded(?:\\s+with)?)\\s+([1-5]\\d{2})\\b/i,\n /\\b([1-5]\\d{2})\\s+(?:bad\\s+gateway|service\\s+unavailable|gateway\\s+time-?out|internal\\s+server\\s+error|too\\s+many\\s+requests)\\b/i,\n]\n\n/**\n * The HTTP status a message states in prose, or `undefined` when it states\n * none. Deliberately NOT \"any three digits in the string\" — an unanchored digit\n * scan would read a token count or a duration as a status.\n */\nexport function readHttpStatusHint(text: string): number | undefined {\n for (const pattern of HTTP_STATUS_HINT_PATTERNS) {\n const match = pattern.exec(text)\n if (match?.[1]) return Number(match[1])\n }\n return undefined\n}\n\nfunction readString(source: Record<string, unknown>, key: string): string | undefined {\n const value = source[key]\n return typeof value === 'string' && value.trim().length > 0 ? value : undefined\n}\n\n/**\n * True when `signal` — a thrown error OR a resolved result payload — indicates\n * the model's upstream is unavailable and another model is worth trying.\n *\n * Checked in order of decreasing confidence: explicit code, HTTP status, then\n * message text. A resolved payload only counts as a failure when it carries an\n * explicit failure marker (`success: false`, or an `error`/`errorCode` field) —\n * a successful result is never misread as an outage.\n */\nexport function isUpstreamUnavailable(signal: unknown): boolean {\n if (signal === null || typeof signal !== 'object') return false\n const record = signal as Record<string, unknown>\n\n // A resolved payload that explicitly reports success is never an outage.\n if (record.success === true) return false\n\n const nested = record.error\n const nestedRecord = nested !== null && typeof nested === 'object' ? (nested as Record<string, unknown>) : undefined\n\n const code =\n readString(record, 'errorCode') ??\n readString(record, 'code') ??\n (nestedRecord ? (readString(nestedRecord, 'code') ?? readString(nestedRecord, 'type')) : undefined)\n if (code && UPSTREAM_UNAVAILABLE_CODES.includes(code)) return true\n\n for (const key of ['status', 'statusCode', 'httpStatus']) {\n const value = record[key]\n if (typeof value === 'number' && UPSTREAM_UNAVAILABLE_STATUSES.includes(value)) return true\n }\n\n const message =\n readString(record, 'message') ??\n readString(record, 'error') ??\n (nestedRecord ? readString(nestedRecord, 'message') : undefined)\n if (!message) return false\n\n // A status carried as PROSE, checked before the fragment list because it is\n // the stronger signal and it cuts BOTH ways. An edge 502 names no exception\n // type and sets no numeric field, so without this the shipped classifier read\n // `error code: 502` as a non-outage and handed the customer a Bad Gateway\n // instead of trying the next model — the exact failure the router cannot\n // rescue from inside its own origin.\n const hinted = readHttpStatusHint(message)\n if (hinted !== undefined) {\n if (UPSTREAM_UNAVAILABLE_STATUSES.includes(hinted)) return true\n // An explicit REQUEST-error status is decisive the OTHER way. A 400\n // carrying a validation message must surface rather than walk the chain,\n // even when its body happens to contain a word from the fragment list —\n // the router's own `Function tools with reasoning_effort are not supported`\n // 400 is a request-shaping bug that fails identically on every model.\n if (REQUEST_ERROR_STATUSES.includes(hinted)) return false\n }\n\n const lowered = message.toLowerCase()\n return UPSTREAM_UNAVAILABLE_MESSAGES.some((fragment) => lowered.includes(fragment))\n}\n\n/** One model tried, and how it went. */\nexport interface ModelFailoverAttempt {\n model: string\n ok: boolean\n /** Why this model was abandoned. Absent when `ok`. */\n reason?: string\n}\n\n/** The outcome of a failover run: the value plus the full attempt trail. */\nexport interface ModelFailoverResult<T> {\n value: T\n /** The model that actually produced `value`. */\n model: string\n attempts: ModelFailoverAttempt[]\n /** True when the preferred (first) model did not serve the request. */\n usedFallback: boolean\n}\n\n/** Every model in the chain failed; carries the trail for logging. */\nexport class ModelFailoverExhaustedError extends Error {\n readonly attempts: ModelFailoverAttempt[]\n constructor(attempts: ModelFailoverAttempt[]) {\n const trail = attempts.map((a) => `${a.model}: ${a.reason ?? 'failed'}`).join(' | ')\n super(`All ${attempts.length} model(s) failed. ${trail}`)\n this.name = 'ModelFailoverExhaustedError'\n this.attempts = attempts\n }\n}\n\n/** Inputs to {@link runWithModelFailover}. */\nexport interface RunWithModelFailoverInput<T> {\n /** Preferred model first, then fallbacks in descending preference. */\n models: readonly string[]\n /** Executes one turn with the given model. */\n run: (model: string) => Promise<T>\n /**\n * Classifies a resolved result as an upstream outage. Defaults to\n * {@link isUpstreamUnavailable}, which understands the sandbox's\n * `{ success: false, errorCode }` payload.\n */\n isUnavailableResult?: (result: T) => boolean\n /** Classifies a thrown error. Defaults to {@link isUpstreamUnavailable}. */\n isUnavailableError?: (error: unknown) => boolean\n /** Observability hook fired each time a model is abandoned. */\n onFallback?: (attempt: ModelFailoverAttempt, nextModel: string) => void\n}\n\nfunction describe(signal: unknown): string {\n if (signal instanceof Error) return signal.message\n if (signal !== null && typeof signal === 'object') {\n const record = signal as Record<string, unknown>\n const message = readString(record, 'error') ?? readString(record, 'message') ?? readString(record, 'errorCode')\n if (message) return message\n }\n return String(signal)\n}\n\n/**\n * Run `run` against the first model in `models` that does not report an upstream\n * outage, falling through the chain in order.\n *\n * A non-outage failure (bad request, auth, content filter) is re-thrown\n * immediately rather than retried down the chain — those fail identically on\n * every model, so walking the chain would only multiply latency and spend.\n *\n * @throws ModelFailoverExhaustedError when every model reports an outage.\n */\nexport async function runWithModelFailover<T>(\n input: RunWithModelFailoverInput<T>,\n): Promise<ModelFailoverResult<T>> {\n const models = input.models.map((m) => m.trim()).filter((m) => m.length > 0)\n if (models.length === 0) throw new Error('runWithModelFailover requires at least one model')\n\n const isUnavailableResult = input.isUnavailableResult ?? ((r: T) => isUpstreamUnavailable(r))\n const isUnavailableError = input.isUnavailableError ?? isUpstreamUnavailable\n const attempts: ModelFailoverAttempt[] = []\n\n for (let index = 0; index < models.length; index += 1) {\n const model = models[index]!\n let result: T\n try {\n result = await input.run(model)\n } catch (error) {\n if (!isUnavailableError(error)) throw error\n const attempt: ModelFailoverAttempt = { model, ok: false, reason: describe(error) }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n if (isUnavailableResult(result)) {\n const attempt: ModelFailoverAttempt = { model, ok: false, reason: describe(result) }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n attempts.push({ model, ok: true })\n return { value: result, model, attempts, usedFallback: index > 0 }\n }\n\n throw new ModelFailoverExhaustedError(attempts)\n}\n\n/**\n * Build a failover chain: the preferred model first, then `fallbacks`, with\n * duplicates removed so a model is never retried twice in one turn.\n */\nexport function buildModelChain(preferred: string, fallbacks: readonly string[]): string[] {\n const chain: string[] = []\n for (const model of [preferred, ...fallbacks]) {\n const cleaned = typeof model === 'string' ? model.trim() : ''\n if (cleaned.length > 0 && !chain.includes(cleaned)) chain.push(cleaned)\n }\n return chain\n}\n"],"mappings":"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","names":[]}
|
|
@@ -83,15 +83,15 @@ import {
|
|
|
83
83
|
setPagePropsCommand,
|
|
84
84
|
ungroupElementCommand
|
|
85
85
|
} from "../chunk-VTWKH4YU.js";
|
|
86
|
-
import {
|
|
87
|
-
DesignCanvasChromeLazy,
|
|
88
|
-
DesignCanvasLazy
|
|
89
|
-
} from "../chunk-4TI7QWLW.js";
|
|
90
86
|
import {
|
|
91
87
|
assertSceneMediaSrc,
|
|
92
88
|
bleedAwareExportBounds,
|
|
93
89
|
scaleForPreset
|
|
94
90
|
} from "../chunk-VWOPOLVO.js";
|
|
91
|
+
import {
|
|
92
|
+
DesignCanvasChromeLazy,
|
|
93
|
+
DesignCanvasLazy
|
|
94
|
+
} from "../chunk-4TI7QWLW.js";
|
|
95
95
|
import "../chunk-PTUMBMVH.js";
|
|
96
96
|
|
|
97
97
|
// src/design-canvas-react/components/CanvasInsertPanel.tsx
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The classifier for turns that FAIL BY RETURNING SUCCESS.
|
|
3
|
+
*
|
|
4
|
+
* Every failure this module names shipped to a customer with HTTP 200, no
|
|
5
|
+
* thrown error, and no log line anyone read. Three were measured in production
|
|
6
|
+
* in a single week:
|
|
7
|
+
*
|
|
8
|
+
* - a turn settled `{"outcome":{"type":"completed"},"finalText":"",
|
|
9
|
+
* "tokenUsage":{"outputTokens":0}}` — the customer saw a blank bubble;
|
|
10
|
+
* - six `submit_proposal` tool calls collapsed into ONE whose arguments were
|
|
11
|
+
* a 1,652-character non-JSON string, so zero proposals persisted and
|
|
12
|
+
* nothing errored (agent-runtime #626);
|
|
13
|
+
* - a thread took 255 user messages over 17 days and produced 2 replies,
|
|
14
|
+
* both of them error text.
|
|
15
|
+
*
|
|
16
|
+
* A conventional health check cannot see any of these, because it probes
|
|
17
|
+
* DEPENDENCIES (is the sandbox reachable, is the router up) and every one of
|
|
18
|
+
* these failures happens with all dependencies green. This classifier probes
|
|
19
|
+
* the OUTCOME instead.
|
|
20
|
+
*
|
|
21
|
+
* It is deliberately pure and structural: it reads a settled turn's own
|
|
22
|
+
* projection, so the SAME function judges a live turn through the
|
|
23
|
+
* `/chat-routes` lifecycle seam and a historical row read back out of the
|
|
24
|
+
* store during a sweep. One definition of "silently broken", two call sites.
|
|
25
|
+
*/
|
|
26
|
+
/** How loudly a reason should be routed. `critical` means a customer got
|
|
27
|
+
* nothing usable; `warning` means the turn degraded but still produced
|
|
28
|
+
* something a human could read. */
|
|
29
|
+
type TurnHealthSeverity = 'critical' | 'warning';
|
|
30
|
+
/** One specific way a turn returned success while failing.
|
|
31
|
+
*
|
|
32
|
+
* Each variant carries the evidence that identified it, so an alert can name
|
|
33
|
+
* the offending value instead of asserting a verdict the reader has to take
|
|
34
|
+
* on faith. */
|
|
35
|
+
type TurnHealthReason =
|
|
36
|
+
/** Settled without error and produced nothing a user can read: no text, and
|
|
37
|
+
* no artifact part (file/image/work-product/plan/interaction). This is the
|
|
38
|
+
* verbatim blank-completion capture. */
|
|
39
|
+
{
|
|
40
|
+
kind: 'empty_completion';
|
|
41
|
+
outputTokens: number | null;
|
|
42
|
+
partCount: number;
|
|
43
|
+
durationMs?: number;
|
|
44
|
+
}
|
|
45
|
+
/** A tool call whose arguments never parsed. The engine surfaces unparseable
|
|
46
|
+
* arguments as a RAW STRING rather than throwing, so the call is neither
|
|
47
|
+
* dropped nor errored — it silently does nothing. Detecting a string-typed
|
|
48
|
+
* tool input that fails `JSON.parse` is the exact fingerprint of the
|
|
49
|
+
* index-less parallel-tool-call collapse. */
|
|
50
|
+
| {
|
|
51
|
+
kind: 'malformed_tool_call';
|
|
52
|
+
tool: string;
|
|
53
|
+
inputLength: number;
|
|
54
|
+
/** Leading characters of the offending input, for the alert body. */
|
|
55
|
+
sample: string;
|
|
56
|
+
}
|
|
57
|
+
/** A tool call that never reached a terminal state carrying output. The call
|
|
58
|
+
* was issued and then simply produced no effect. */
|
|
59
|
+
| {
|
|
60
|
+
kind: 'tool_call_no_effect';
|
|
61
|
+
tool: string;
|
|
62
|
+
status: string;
|
|
63
|
+
}
|
|
64
|
+
/** The turn failed outright. Not silent by itself — but it becomes silent
|
|
65
|
+
* the moment nothing is watching, which is how 16 days of
|
|
66
|
+
* `TANGLE_HUB_URL is required` reached customers unnoticed. */
|
|
67
|
+
| {
|
|
68
|
+
kind: 'turn_failed';
|
|
69
|
+
reason: string;
|
|
70
|
+
};
|
|
71
|
+
/** A settled turn, in the narrowest shape both call sites can supply.
|
|
72
|
+
*
|
|
73
|
+
* Structural on purpose: the lifecycle seam supplies `finalText`/`usage`, a
|
|
74
|
+
* store sweep supplies `content`/`parts` read back from a row, and neither
|
|
75
|
+
* has to import the other's types. */
|
|
76
|
+
interface TurnOutcomeInput {
|
|
77
|
+
/** The turn's final assistant text. */
|
|
78
|
+
finalText?: string | null;
|
|
79
|
+
/** The persisted assistant parts. Untyped by design — a sweep reads these
|
|
80
|
+
* out of a JSON column and must not be forced to validate them first. */
|
|
81
|
+
parts?: readonly unknown[] | null;
|
|
82
|
+
/** Output tokens, when the caller has usage. `null`/absent is unknown, which
|
|
83
|
+
* is NOT the same as zero and is never treated as evidence. */
|
|
84
|
+
outputTokens?: number | null;
|
|
85
|
+
/** Set when the turn surfaced a terminal error event. */
|
|
86
|
+
failed?: boolean;
|
|
87
|
+
failureReason?: string | null;
|
|
88
|
+
durationMs?: number;
|
|
89
|
+
}
|
|
90
|
+
/** The verdict for one turn. `healthy` is exactly `reasons.length === 0`, kept
|
|
91
|
+
* as a field so callers read intent rather than an array length. */
|
|
92
|
+
interface TurnHealthVerdict {
|
|
93
|
+
healthy: boolean;
|
|
94
|
+
severity: TurnHealthSeverity | null;
|
|
95
|
+
reasons: TurnHealthReason[];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Judge one settled turn.
|
|
99
|
+
*
|
|
100
|
+
* Never throws: a malformed `parts` blob is a thing this function REPORTS on,
|
|
101
|
+
* so it must not be a thing it dies on. Telemetry that can crash the turn it
|
|
102
|
+
* measures is worse than no telemetry.
|
|
103
|
+
*/
|
|
104
|
+
declare function classifyTurnOutcome(input: TurnOutcomeInput): TurnHealthVerdict;
|
|
105
|
+
/** One-line human summary of a reason, for an alert body. */
|
|
106
|
+
declare function describeReason(reason: TurnHealthReason): string;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where a silent-failure verdict GOES.
|
|
110
|
+
*
|
|
111
|
+
* The detection half is worthless without this half. Every failure this module
|
|
112
|
+
* finds was already visible in the database the whole time — 255 unanswered
|
|
113
|
+
* messages sat in a table for 17 days. What was missing was not the data, it
|
|
114
|
+
* was delivery to a human who had not thought to look.
|
|
115
|
+
*
|
|
116
|
+
* So the sink is a seam, not a channel: the product supplies the transport,
|
|
117
|
+
* and agent-app ships the two shapes the fleet already has credentials for
|
|
118
|
+
* (an ops webhook, and stderr). No new channel is invented here.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/** One deliverable alert. */
|
|
122
|
+
interface TurnHealthAlert {
|
|
123
|
+
/** Which product raised it (`projectId`). Alerts from four products land in
|
|
124
|
+
* one channel, so this is what makes the message actionable. */
|
|
125
|
+
product: string;
|
|
126
|
+
severity: TurnHealthSeverity;
|
|
127
|
+
/** Stable grouping key. Throttling is keyed on this, so it must NOT contain
|
|
128
|
+
* a turn id or a timestamp or every alert is unique and nothing dedupes. */
|
|
129
|
+
key: string;
|
|
130
|
+
title: string;
|
|
131
|
+
/** Human-readable lines. */
|
|
132
|
+
details: string[];
|
|
133
|
+
/** Structured payload for a machine consumer. */
|
|
134
|
+
data?: Record<string, unknown>;
|
|
135
|
+
at: number;
|
|
136
|
+
}
|
|
137
|
+
/** Deliver an alert. Implementations MUST NOT throw — see
|
|
138
|
+
* {@link createGuardedAlertSink}. */
|
|
139
|
+
interface AlertSink {
|
|
140
|
+
deliver(alert: TurnHealthAlert): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
/** Build the alert for a set of reasons found on one turn. */
|
|
143
|
+
declare function turnAlert(input: {
|
|
144
|
+
product: string;
|
|
145
|
+
severity: TurnHealthSeverity;
|
|
146
|
+
reasons: TurnHealthReason[];
|
|
147
|
+
threadId?: string;
|
|
148
|
+
turnId?: string;
|
|
149
|
+
model?: string;
|
|
150
|
+
at?: number;
|
|
151
|
+
}): TurnHealthAlert;
|
|
152
|
+
/** Minimal structural fetch, so this module has no lib-dom dependency and can
|
|
153
|
+
* be driven by a fake in tests. */
|
|
154
|
+
type FetchLike = (url: string, init: {
|
|
155
|
+
method: string;
|
|
156
|
+
headers: Record<string, string>;
|
|
157
|
+
body: string;
|
|
158
|
+
}) => Promise<{
|
|
159
|
+
ok: boolean;
|
|
160
|
+
status: number;
|
|
161
|
+
text?(): Promise<string>;
|
|
162
|
+
}>;
|
|
163
|
+
/**
|
|
164
|
+
* POST to an incoming webhook in the Slack message format.
|
|
165
|
+
*
|
|
166
|
+
* Chosen because the org already runs one (`SLACK_OPS_WEBHOOK_URL`) and
|
|
167
|
+
* gtm-agent's outbound webhook code already speaks this exact shape — the
|
|
168
|
+
* instruction was to route somewhere humans already look, not to stand up a
|
|
169
|
+
* new channel. Discord and most log drains accept the same `{text}` body.
|
|
170
|
+
*/
|
|
171
|
+
declare function createWebhookAlertSink(options: {
|
|
172
|
+
webhookUrl: string;
|
|
173
|
+
fetchImpl?: FetchLike;
|
|
174
|
+
}): AlertSink;
|
|
175
|
+
/** stderr sink. The zero-config fallback so a product that has not yet been
|
|
176
|
+
* given a webhook still emits something a log search can find. */
|
|
177
|
+
declare function createConsoleAlertSink(log?: (message: string) => void): AlertSink;
|
|
178
|
+
/** Fan out to several sinks. One failing transport must not stop the others. */
|
|
179
|
+
declare function createMultiAlertSink(sinks: readonly AlertSink[]): AlertSink;
|
|
180
|
+
/** Records the last time a key was alerted on. A product backs this with KV,
|
|
181
|
+
* D1, or a Durable Object; the in-memory default is correct for a sweep that
|
|
182
|
+
* runs as a single cron invocation. */
|
|
183
|
+
interface AlertThrottleStore {
|
|
184
|
+
lastSentAt(key: string): Promise<number | null>;
|
|
185
|
+
markSent(key: string, at: number): Promise<void>;
|
|
186
|
+
}
|
|
187
|
+
/** Process-local throttle store. */
|
|
188
|
+
declare function createMemoryThrottleStore(): AlertThrottleStore;
|
|
189
|
+
/**
|
|
190
|
+
* Collapse repeats of the same `key` inside `windowMs`.
|
|
191
|
+
*
|
|
192
|
+
* Deliberately re-alerts once per window rather than going silent after the
|
|
193
|
+
* first: an incident that is still burning must keep saying so. Going quiet
|
|
194
|
+
* after one message is how a 17-day outage stays invisible after someone
|
|
195
|
+
* dismisses the first notification.
|
|
196
|
+
*/
|
|
197
|
+
declare function createThrottledAlertSink(inner: AlertSink, options: {
|
|
198
|
+
windowMs: number;
|
|
199
|
+
store?: AlertThrottleStore;
|
|
200
|
+
}): AlertSink;
|
|
201
|
+
/**
|
|
202
|
+
* Swallow transport errors so telemetry can never fail the turn it measures.
|
|
203
|
+
*
|
|
204
|
+
* Use this at the LIVE lifecycle call site only. A sweep should let the error
|
|
205
|
+
* surface, because a sweep that cannot deliver has done nothing at all and its
|
|
206
|
+
* cron run should go red.
|
|
207
|
+
*/
|
|
208
|
+
declare function createGuardedAlertSink(inner: AlertSink, onError?: (error: unknown) => void): AlertSink;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The LIVE half: judge each turn the moment it settles.
|
|
212
|
+
*
|
|
213
|
+
* This adds no control flow. `createChatTurnRoutes` already exposes a
|
|
214
|
+
* `lifecycle` seam that fires exactly one of `onTurnComplete`/`onTurnError`
|
|
215
|
+
* after a turn settles, and already swallows hook errors so telemetry cannot
|
|
216
|
+
* fail a turn. That seam was shipped and then wired by nobody, which is a fair
|
|
217
|
+
* description of why the outage lasted 17 days. This function fills it.
|
|
218
|
+
*
|
|
219
|
+
* The shape is declared structurally rather than imported from
|
|
220
|
+
* `/chat-routes`, so `/turn-health` stays free of the server chat vertical and
|
|
221
|
+
* can be used by any turn driver that reports the same three moments.
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/** Structural mirror of `/chat-routes`' `ChatTurnLifecycle` complete payload. */
|
|
225
|
+
interface TurnHealthCompleteInfo {
|
|
226
|
+
finalText: string;
|
|
227
|
+
usage?: {
|
|
228
|
+
outputTokens?: number | null;
|
|
229
|
+
} | null;
|
|
230
|
+
durationMs: number;
|
|
231
|
+
threadId?: string;
|
|
232
|
+
turnStreamId?: string;
|
|
233
|
+
executionId?: string;
|
|
234
|
+
}
|
|
235
|
+
/** Structural mirror of the lifecycle error payload. */
|
|
236
|
+
interface TurnHealthErrorInfo {
|
|
237
|
+
error: unknown;
|
|
238
|
+
durationMs: number;
|
|
239
|
+
threadId?: string;
|
|
240
|
+
turnStreamId?: string;
|
|
241
|
+
executionId?: string;
|
|
242
|
+
}
|
|
243
|
+
/** What {@link createTurnHealthLifecycle} returns — assignable to
|
|
244
|
+
* `createChatTurnRoutes`' `lifecycle` option. */
|
|
245
|
+
interface TurnHealthLifecycle {
|
|
246
|
+
onTurnComplete(info: TurnHealthCompleteInfo): Promise<void>;
|
|
247
|
+
onTurnError(info: TurnHealthErrorInfo): Promise<void>;
|
|
248
|
+
}
|
|
249
|
+
interface TurnHealthLifecycleOptions {
|
|
250
|
+
/** Names the product in every alert. */
|
|
251
|
+
product: string;
|
|
252
|
+
sink: AlertSink;
|
|
253
|
+
/** Called for every verdict, healthy or not — the hook for a counter or a
|
|
254
|
+
* metrics push. Alerts are for humans; this is for graphs. */
|
|
255
|
+
onVerdict?(verdict: {
|
|
256
|
+
product: string;
|
|
257
|
+
healthy: boolean;
|
|
258
|
+
kinds: string[];
|
|
259
|
+
durationMs: number;
|
|
260
|
+
}): void;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Build the lifecycle hooks that page on a turn which succeeded at nothing.
|
|
264
|
+
*
|
|
265
|
+
* The live lane sees `finalText` and usage but not the persisted parts, so it
|
|
266
|
+
* catches the blank-completion and hard-failure shapes immediately. The
|
|
267
|
+
* parts-dependent shapes (a tool call whose arguments never parsed, a tool
|
|
268
|
+
* call that left no effect) are caught by {@link sweepSilentFailures}, which
|
|
269
|
+
* reads what was actually written to the store — the honest place to ask
|
|
270
|
+
* whether an effect persisted.
|
|
271
|
+
*/
|
|
272
|
+
declare function createTurnHealthLifecycle(options: TurnHealthLifecycleOptions): TurnHealthLifecycle;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The SWEEP half: ask the store what it has been quietly accumulating.
|
|
276
|
+
*
|
|
277
|
+
* A live per-turn hook cannot see the failure that matters most, because the
|
|
278
|
+
* worst outage produced NO turns at all to hook: gtm-agent took 9–21 user
|
|
279
|
+
* messages a day for sixteen straight days and wrote zero real assistant
|
|
280
|
+
* replies. Nothing crashed on a schedule; the product simply stopped
|
|
281
|
+
* answering. The only thing that could have noticed is something that
|
|
282
|
+
* periodically counts what arrived against what was answered.
|
|
283
|
+
*
|
|
284
|
+
* That is this. It runs on a cron, reads the shared `/chat-store` schema, and
|
|
285
|
+
* pages when the ratio breaks.
|
|
286
|
+
*
|
|
287
|
+
* The queries live HERE and not in each product because all four products
|
|
288
|
+
* (gtm, tax, legal, workcomp) persist to the same `message`/`thread` tables —
|
|
289
|
+
* four copies of this cron is exactly the duplication the repo's engine/shell
|
|
290
|
+
* rule exists to prevent.
|
|
291
|
+
*/
|
|
292
|
+
|
|
293
|
+
/** A thread that has taken user messages with no reply since. */
|
|
294
|
+
interface UnansweredThread {
|
|
295
|
+
threadId: string;
|
|
296
|
+
/** User messages newer than the newest real assistant reply. */
|
|
297
|
+
pendingMessages: number;
|
|
298
|
+
/** Age of the OLDEST unanswered user message, in ms. */
|
|
299
|
+
oldestAgeMs: number;
|
|
300
|
+
}
|
|
301
|
+
/** A persisted assistant row, as the sweep needs to judge it. */
|
|
302
|
+
interface PersistedTurnRow {
|
|
303
|
+
id: string;
|
|
304
|
+
threadId: string;
|
|
305
|
+
content: string;
|
|
306
|
+
/** Raw `parts` column. A JSON string or an already-parsed array; the sweep
|
|
307
|
+
* accepts both because D1 drivers differ. */
|
|
308
|
+
parts: unknown;
|
|
309
|
+
outputTokens?: number | null;
|
|
310
|
+
model?: string | null;
|
|
311
|
+
createdAt: number;
|
|
312
|
+
}
|
|
313
|
+
/** What the sweep needs from a store. A product on a non-standard schema
|
|
314
|
+
* implements these two reads; everything else is shared. */
|
|
315
|
+
interface TurnHealthSource {
|
|
316
|
+
findUnansweredThreads(input: {
|
|
317
|
+
minAgeMs: number;
|
|
318
|
+
now: number;
|
|
319
|
+
}): Promise<UnansweredThread[]>;
|
|
320
|
+
listRecentAssistantTurns(input: {
|
|
321
|
+
sinceMs: number;
|
|
322
|
+
now: number;
|
|
323
|
+
limit: number;
|
|
324
|
+
}): Promise<PersistedTurnRow[]>;
|
|
325
|
+
}
|
|
326
|
+
interface SweepOptions {
|
|
327
|
+
product: string;
|
|
328
|
+
source: TurnHealthSource;
|
|
329
|
+
sink: AlertSink;
|
|
330
|
+
/** A user message must go unanswered this long before it counts. Guards
|
|
331
|
+
* against alerting on a turn that is simply still streaming. Default 15 min. */
|
|
332
|
+
minAgeMs?: number;
|
|
333
|
+
/** How far back to judge settled turns. Default 24 h. */
|
|
334
|
+
lookbackMs?: number;
|
|
335
|
+
/** Cap on rows judged per sweep. Default 500. */
|
|
336
|
+
limit?: number;
|
|
337
|
+
/** Fraction of recent turns allowed to be silently broken before paging.
|
|
338
|
+
* Default 0.05 — the measured blank-completion rate on the tax tool surface
|
|
339
|
+
* was 12.2%, so 5% separates a real regression from noise. */
|
|
340
|
+
emptyRateThreshold?: number;
|
|
341
|
+
/** Absolute floor: never page on a rate computed from fewer turns than this. */
|
|
342
|
+
minTurnsForRate?: number;
|
|
343
|
+
now?: number;
|
|
344
|
+
}
|
|
345
|
+
/** What the sweep found. Returned as well as alerted, so a cron can log it and
|
|
346
|
+
* a test can assert on it. */
|
|
347
|
+
interface SweepResult {
|
|
348
|
+
product: string;
|
|
349
|
+
unansweredThreads: number;
|
|
350
|
+
pendingUserMessages: number;
|
|
351
|
+
oldestUnansweredMs: number;
|
|
352
|
+
turnsJudged: number;
|
|
353
|
+
unhealthyTurns: number;
|
|
354
|
+
emptyCompletions: number;
|
|
355
|
+
malformedToolCalls: number;
|
|
356
|
+
toolCallsWithoutEffect: number;
|
|
357
|
+
alerts: TurnHealthAlert[];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Run one sweep and deliver whatever it finds.
|
|
361
|
+
*
|
|
362
|
+
* Errors from the sink are NOT swallowed here (unlike the live lane): a sweep
|
|
363
|
+
* that could not deliver has accomplished nothing, and its cron invocation
|
|
364
|
+
* should go red rather than report a clean run.
|
|
365
|
+
*/
|
|
366
|
+
declare function sweepSilentFailures(options: SweepOptions): Promise<SweepResult>;
|
|
367
|
+
/** Minimal structural D1 contract (Cloudflare's `D1Database` satisfies it). */
|
|
368
|
+
interface D1LikeForHealth {
|
|
369
|
+
prepare(sql: string): {
|
|
370
|
+
bind(...values: unknown[]): {
|
|
371
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
372
|
+
results: T[];
|
|
373
|
+
}>;
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The sweep source for products on the canonical `/chat-store` tables.
|
|
379
|
+
*
|
|
380
|
+
* "Answered" deliberately means an assistant row with NON-EMPTY content. A
|
|
381
|
+
* blank assistant row is what a broken turn writes, so counting it as an
|
|
382
|
+
* answer would let the exact failure being hunted mark itself resolved. That
|
|
383
|
+
* single predicate is the difference between this catching the gtm outage and
|
|
384
|
+
* sleeping through it — during those sixteen days the table was NOT empty.
|
|
385
|
+
*
|
|
386
|
+
* The query deliberately does NOT join the thread table. Products do not all
|
|
387
|
+
* keep one: tax-agent's `thread` table holds zero rows because it groups by
|
|
388
|
+
* its own `tax_sessions`, and an inner join against it silently reported "0
|
|
389
|
+
* unanswered threads, healthy" while 18 real messages sat unanswered. A
|
|
390
|
+
* detector that reports healthy because its join found nothing is the same
|
|
391
|
+
* bug class it was built to catch.
|
|
392
|
+
*/
|
|
393
|
+
declare function createD1TurnHealthSource(db: D1LikeForHealth, options?: {
|
|
394
|
+
messageTable?: string;
|
|
395
|
+
threadTable?: string;
|
|
396
|
+
}): TurnHealthSource;
|
|
397
|
+
|
|
398
|
+
export { type AlertSink, type AlertThrottleStore, type D1LikeForHealth, type FetchLike, type PersistedTurnRow, type SweepOptions, type SweepResult, type TurnHealthAlert, type TurnHealthCompleteInfo, type TurnHealthErrorInfo, type TurnHealthLifecycle, type TurnHealthLifecycleOptions, type TurnHealthReason, type TurnHealthSeverity, type TurnHealthSource, type TurnHealthVerdict, type TurnOutcomeInput, type UnansweredThread, classifyTurnOutcome, createConsoleAlertSink, createD1TurnHealthSource, createGuardedAlertSink, createMemoryThrottleStore, createMultiAlertSink, createThrottledAlertSink, createTurnHealthLifecycle, createWebhookAlertSink, describeReason, sweepSilentFailures, turnAlert };
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// src/turn-health/classify.ts
|
|
2
|
+
var ARTIFACT_PART_KINDS = /* @__PURE__ */ new Set(["file", "image", "work-product", "plan", "interaction"]);
|
|
3
|
+
var SAMPLE_CHARS = 120;
|
|
4
|
+
function asRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
6
|
+
}
|
|
7
|
+
function nonEmptyString(value) {
|
|
8
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
9
|
+
}
|
|
10
|
+
function isUnparseableJson(value) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (trimmed.length === 0) return false;
|
|
13
|
+
try {
|
|
14
|
+
JSON.parse(trimmed);
|
|
15
|
+
return false;
|
|
16
|
+
} catch {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var SETTLED_TOOL_STATUSES = /* @__PURE__ */ new Set(["completed", "complete", "success", "done"]);
|
|
21
|
+
function classifyTurnOutcome(input) {
|
|
22
|
+
const reasons = [];
|
|
23
|
+
if (input.failed) {
|
|
24
|
+
reasons.push({
|
|
25
|
+
kind: "turn_failed",
|
|
26
|
+
reason: nonEmptyString(input.failureReason) ?? "unspecified"
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const parts = Array.isArray(input.parts) ? input.parts : [];
|
|
30
|
+
let hasVisibleText = nonEmptyString(input.finalText) !== null;
|
|
31
|
+
let artifactCount = 0;
|
|
32
|
+
for (const raw of parts) {
|
|
33
|
+
const part = asRecord(raw);
|
|
34
|
+
if (!part) continue;
|
|
35
|
+
const type = typeof part.type === "string" ? part.type : "";
|
|
36
|
+
if (type === "text" && nonEmptyString(part.text) !== null) {
|
|
37
|
+
hasVisibleText = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ARTIFACT_PART_KINDS.has(type)) {
|
|
41
|
+
artifactCount += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (type !== "tool") continue;
|
|
45
|
+
const tool = nonEmptyString(part.tool) ?? "unknown";
|
|
46
|
+
const state = asRecord(part.state);
|
|
47
|
+
const status = typeof state?.status === "string" ? state.status : "unknown";
|
|
48
|
+
const toolInput = state?.input;
|
|
49
|
+
if (typeof toolInput === "string" && isUnparseableJson(toolInput)) {
|
|
50
|
+
reasons.push({
|
|
51
|
+
kind: "malformed_tool_call",
|
|
52
|
+
tool,
|
|
53
|
+
inputLength: toolInput.length,
|
|
54
|
+
sample: toolInput.slice(0, SAMPLE_CHARS)
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!SETTLED_TOOL_STATUSES.has(status)) {
|
|
59
|
+
reasons.push({ kind: "tool_call_no_effect", tool, status });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!input.failed && !hasVisibleText && artifactCount === 0) {
|
|
63
|
+
reasons.push({
|
|
64
|
+
kind: "empty_completion",
|
|
65
|
+
outputTokens: input.outputTokens ?? null,
|
|
66
|
+
partCount: parts.length,
|
|
67
|
+
...input.durationMs !== void 0 ? { durationMs: input.durationMs } : {}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
healthy: reasons.length === 0,
|
|
72
|
+
severity: severityOf(reasons),
|
|
73
|
+
reasons
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function severityOf(reasons) {
|
|
77
|
+
if (reasons.length === 0) return null;
|
|
78
|
+
const critical = reasons.some((r) => r.kind === "empty_completion" || r.kind === "turn_failed");
|
|
79
|
+
return critical ? "critical" : "warning";
|
|
80
|
+
}
|
|
81
|
+
function describeReason(reason) {
|
|
82
|
+
switch (reason.kind) {
|
|
83
|
+
case "empty_completion":
|
|
84
|
+
return `completed with NO output (${reason.partCount} parts, outputTokens=${reason.outputTokens ?? "unknown"})`;
|
|
85
|
+
case "malformed_tool_call":
|
|
86
|
+
return `tool \`${reason.tool}\` arguments did not parse (${reason.inputLength} chars): ${reason.sample}`;
|
|
87
|
+
case "tool_call_no_effect":
|
|
88
|
+
return `tool \`${reason.tool}\` left no effect (status=${reason.status})`;
|
|
89
|
+
case "turn_failed":
|
|
90
|
+
return `turn failed: ${reason.reason}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/turn-health/sink.ts
|
|
95
|
+
function turnAlert(input) {
|
|
96
|
+
const kinds = [...new Set(input.reasons.map((r) => r.kind))].sort();
|
|
97
|
+
return {
|
|
98
|
+
product: input.product,
|
|
99
|
+
severity: input.severity,
|
|
100
|
+
// Keyed by product + reason kinds ONLY. A blank-completion storm across
|
|
101
|
+
// 200 turns is one incident, not 200 pages.
|
|
102
|
+
key: `turn:${input.product}:${kinds.join("+")}`,
|
|
103
|
+
title: `${input.product}: turn completed but delivered nothing (${kinds.join(", ")})`,
|
|
104
|
+
details: input.reasons.map(describeReason),
|
|
105
|
+
data: {
|
|
106
|
+
kinds,
|
|
107
|
+
...input.threadId ? { threadId: input.threadId } : {},
|
|
108
|
+
...input.turnId ? { turnId: input.turnId } : {},
|
|
109
|
+
...input.model ? { model: input.model } : {}
|
|
110
|
+
},
|
|
111
|
+
at: input.at ?? Date.now()
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function createWebhookAlertSink(options) {
|
|
115
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
return {
|
|
117
|
+
async deliver(alert) {
|
|
118
|
+
const icon = alert.severity === "critical" ? ":rotating_light:" : ":warning:";
|
|
119
|
+
const lines = [
|
|
120
|
+
`${icon} *${alert.title}*`,
|
|
121
|
+
...alert.details.map((d) => `\u2022 ${d}`),
|
|
122
|
+
`_${new Date(alert.at).toISOString()}_`
|
|
123
|
+
];
|
|
124
|
+
const response = await fetchImpl(options.webhookUrl, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({ text: lines.join("\n") })
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
throw new Error(`alert webhook responded ${response.status}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function createConsoleAlertSink(log = console.error) {
|
|
136
|
+
return {
|
|
137
|
+
async deliver(alert) {
|
|
138
|
+
log(
|
|
139
|
+
`[turn-health] ${alert.severity.toUpperCase()} ${alert.title} :: ${alert.details.join(" | ")}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function createMultiAlertSink(sinks) {
|
|
145
|
+
return {
|
|
146
|
+
async deliver(alert) {
|
|
147
|
+
const settled = await Promise.allSettled(sinks.map((s) => s.deliver(alert)));
|
|
148
|
+
const failures = settled.filter((r) => r.status === "rejected");
|
|
149
|
+
if (failures.length === sinks.length && sinks.length > 0) {
|
|
150
|
+
throw new Error("every alert sink failed");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function createMemoryThrottleStore() {
|
|
156
|
+
const seen = /* @__PURE__ */ new Map();
|
|
157
|
+
return {
|
|
158
|
+
async lastSentAt(key) {
|
|
159
|
+
return seen.get(key) ?? null;
|
|
160
|
+
},
|
|
161
|
+
async markSent(key, at) {
|
|
162
|
+
seen.set(key, at);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function createThrottledAlertSink(inner, options) {
|
|
167
|
+
const store = options.store ?? createMemoryThrottleStore();
|
|
168
|
+
return {
|
|
169
|
+
async deliver(alert) {
|
|
170
|
+
const last = await store.lastSentAt(alert.key);
|
|
171
|
+
if (last !== null && alert.at - last < options.windowMs) return;
|
|
172
|
+
await inner.deliver(alert);
|
|
173
|
+
await store.markSent(alert.key, alert.at);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function createGuardedAlertSink(inner, onError = (e) => console.error("[turn-health] alert delivery failed", e)) {
|
|
178
|
+
return {
|
|
179
|
+
async deliver(alert) {
|
|
180
|
+
try {
|
|
181
|
+
await inner.deliver(alert);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
onError(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/turn-health/lifecycle.ts
|
|
190
|
+
function errorText(error) {
|
|
191
|
+
if (error instanceof Error) return error.message;
|
|
192
|
+
if (typeof error === "string") return error;
|
|
193
|
+
return String(error);
|
|
194
|
+
}
|
|
195
|
+
function createTurnHealthLifecycle(options) {
|
|
196
|
+
const sink = createGuardedAlertSink(options.sink);
|
|
197
|
+
return {
|
|
198
|
+
async onTurnComplete(info) {
|
|
199
|
+
const verdict = classifyTurnOutcome({
|
|
200
|
+
finalText: info.finalText,
|
|
201
|
+
outputTokens: info.usage?.outputTokens ?? null,
|
|
202
|
+
durationMs: info.durationMs
|
|
203
|
+
});
|
|
204
|
+
options.onVerdict?.({
|
|
205
|
+
product: options.product,
|
|
206
|
+
healthy: verdict.healthy,
|
|
207
|
+
kinds: verdict.reasons.map((r) => r.kind),
|
|
208
|
+
durationMs: info.durationMs
|
|
209
|
+
});
|
|
210
|
+
if (verdict.healthy || verdict.severity === null) return;
|
|
211
|
+
await sink.deliver(
|
|
212
|
+
turnAlert({
|
|
213
|
+
product: options.product,
|
|
214
|
+
severity: verdict.severity,
|
|
215
|
+
reasons: verdict.reasons,
|
|
216
|
+
...info.threadId ? { threadId: info.threadId } : {},
|
|
217
|
+
...info.executionId ? { turnId: info.executionId } : {}
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
async onTurnError(info) {
|
|
222
|
+
const verdict = classifyTurnOutcome({
|
|
223
|
+
failed: true,
|
|
224
|
+
failureReason: errorText(info.error),
|
|
225
|
+
durationMs: info.durationMs
|
|
226
|
+
});
|
|
227
|
+
options.onVerdict?.({
|
|
228
|
+
product: options.product,
|
|
229
|
+
healthy: false,
|
|
230
|
+
kinds: verdict.reasons.map((r) => r.kind),
|
|
231
|
+
durationMs: info.durationMs
|
|
232
|
+
});
|
|
233
|
+
await sink.deliver(
|
|
234
|
+
turnAlert({
|
|
235
|
+
product: options.product,
|
|
236
|
+
severity: "critical",
|
|
237
|
+
reasons: verdict.reasons,
|
|
238
|
+
...info.threadId ? { threadId: info.threadId } : {},
|
|
239
|
+
...info.executionId ? { turnId: info.executionId } : {}
|
|
240
|
+
})
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/turn-health/sweep.ts
|
|
247
|
+
function parseParts(raw) {
|
|
248
|
+
if (Array.isArray(raw)) return raw;
|
|
249
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return [];
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(raw);
|
|
252
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
var HOUR_MS = 36e5;
|
|
258
|
+
async function sweepSilentFailures(options) {
|
|
259
|
+
const now = options.now ?? Date.now();
|
|
260
|
+
const minAgeMs = options.minAgeMs ?? 15 * 6e4;
|
|
261
|
+
const lookbackMs = options.lookbackMs ?? 24 * HOUR_MS;
|
|
262
|
+
const limit = options.limit ?? 500;
|
|
263
|
+
const emptyRateThreshold = options.emptyRateThreshold ?? 0.05;
|
|
264
|
+
const minTurnsForRate = options.minTurnsForRate ?? 10;
|
|
265
|
+
const [unanswered, turns] = await Promise.all([
|
|
266
|
+
options.source.findUnansweredThreads({ minAgeMs, now }),
|
|
267
|
+
options.source.listRecentAssistantTurns({ sinceMs: now - lookbackMs, now, limit })
|
|
268
|
+
]);
|
|
269
|
+
const alerts = [];
|
|
270
|
+
const pendingUserMessages = unanswered.reduce((sum, t) => sum + t.pendingMessages, 0);
|
|
271
|
+
const oldestUnansweredMs = unanswered.reduce((max, t) => Math.max(max, t.oldestAgeMs), 0);
|
|
272
|
+
if (unanswered.length > 0) {
|
|
273
|
+
const hours = (oldestUnansweredMs / HOUR_MS).toFixed(1);
|
|
274
|
+
alerts.push({
|
|
275
|
+
product: options.product,
|
|
276
|
+
// A day of total silence is not a warning.
|
|
277
|
+
severity: oldestUnansweredMs >= 24 * HOUR_MS ? "critical" : "warning",
|
|
278
|
+
key: `sweep:${options.product}:unanswered_threads`,
|
|
279
|
+
title: `${options.product}: ${pendingUserMessages} user message(s) unanswered across ${unanswered.length} thread(s)`,
|
|
280
|
+
details: [
|
|
281
|
+
`oldest unanswered message: ${hours}h`,
|
|
282
|
+
...unanswered.slice(0, 5).map(
|
|
283
|
+
(t) => `thread ${t.threadId}: ${t.pendingMessages} pending, oldest ${(t.oldestAgeMs / HOUR_MS).toFixed(1)}h`
|
|
284
|
+
)
|
|
285
|
+
],
|
|
286
|
+
data: {
|
|
287
|
+
unansweredThreads: unanswered.length,
|
|
288
|
+
pendingUserMessages,
|
|
289
|
+
oldestUnansweredMs
|
|
290
|
+
},
|
|
291
|
+
at: now
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
let emptyCompletions = 0;
|
|
295
|
+
let malformedToolCalls = 0;
|
|
296
|
+
let toolCallsWithoutEffect = 0;
|
|
297
|
+
let unhealthyTurns = 0;
|
|
298
|
+
const malformedSamples = [];
|
|
299
|
+
for (const row of turns) {
|
|
300
|
+
const verdict = classifyTurnOutcome({
|
|
301
|
+
finalText: row.content,
|
|
302
|
+
parts: parseParts(row.parts),
|
|
303
|
+
outputTokens: row.outputTokens ?? null
|
|
304
|
+
});
|
|
305
|
+
if (verdict.healthy) continue;
|
|
306
|
+
unhealthyTurns += 1;
|
|
307
|
+
for (const reason of verdict.reasons) {
|
|
308
|
+
if (reason.kind === "empty_completion") emptyCompletions += 1;
|
|
309
|
+
if (reason.kind === "malformed_tool_call") {
|
|
310
|
+
malformedToolCalls += 1;
|
|
311
|
+
if (malformedSamples.length < 3) malformedSamples.push(reason);
|
|
312
|
+
}
|
|
313
|
+
if (reason.kind === "tool_call_no_effect") toolCallsWithoutEffect += 1;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (malformedToolCalls > 0) {
|
|
317
|
+
alerts.push({
|
|
318
|
+
product: options.product,
|
|
319
|
+
severity: "critical",
|
|
320
|
+
key: `sweep:${options.product}:malformed_tool_call`,
|
|
321
|
+
title: `${options.product}: ${malformedToolCalls} tool call(s) had unparseable arguments \u2014 deliverables silently dropped`,
|
|
322
|
+
details: malformedSamples.map(describeReason),
|
|
323
|
+
data: { malformedToolCalls, turnsJudged: turns.length },
|
|
324
|
+
at: now
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (turns.length >= minTurnsForRate) {
|
|
328
|
+
const rate = emptyCompletions / turns.length;
|
|
329
|
+
if (rate > emptyRateThreshold) {
|
|
330
|
+
alerts.push({
|
|
331
|
+
product: options.product,
|
|
332
|
+
severity: "critical",
|
|
333
|
+
key: `sweep:${options.product}:empty_completion_rate`,
|
|
334
|
+
title: `${options.product}: ${(rate * 100).toFixed(1)}% of turns completed with no output`,
|
|
335
|
+
details: [
|
|
336
|
+
`${emptyCompletions} of ${turns.length} settled turns delivered nothing`,
|
|
337
|
+
`threshold ${(emptyRateThreshold * 100).toFixed(1)}%`
|
|
338
|
+
],
|
|
339
|
+
data: { emptyCompletions, turnsJudged: turns.length, rate },
|
|
340
|
+
at: now
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const alert of alerts) await options.sink.deliver(alert);
|
|
345
|
+
return {
|
|
346
|
+
product: options.product,
|
|
347
|
+
unansweredThreads: unanswered.length,
|
|
348
|
+
pendingUserMessages,
|
|
349
|
+
oldestUnansweredMs,
|
|
350
|
+
turnsJudged: turns.length,
|
|
351
|
+
unhealthyTurns,
|
|
352
|
+
emptyCompletions,
|
|
353
|
+
malformedToolCalls,
|
|
354
|
+
toolCallsWithoutEffect,
|
|
355
|
+
alerts
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function createD1TurnHealthSource(db, options = {}) {
|
|
359
|
+
const message = safeIdentifier(options.messageTable ?? "message");
|
|
360
|
+
const thread = safeIdentifier(options.threadTable ?? "thread");
|
|
361
|
+
return {
|
|
362
|
+
async findUnansweredThreads({ minAgeMs, now }) {
|
|
363
|
+
const cutoffSeconds = Math.floor((now - minAgeMs) / 1e3);
|
|
364
|
+
const { results } = await db.prepare(
|
|
365
|
+
`SELECT m.thread_id AS threadId,
|
|
366
|
+
COUNT(*) AS pendingMessages,
|
|
367
|
+
MIN(m.created_at) AS oldestCreatedAt
|
|
368
|
+
FROM ${message} m
|
|
369
|
+
WHERE m.role = 'user'
|
|
370
|
+
AND m.created_at <= ?1
|
|
371
|
+
AND m.created_at > COALESCE(
|
|
372
|
+
(SELECT MAX(a.created_at)
|
|
373
|
+
FROM ${message} a
|
|
374
|
+
WHERE a.thread_id = m.thread_id
|
|
375
|
+
AND a.role = 'assistant'
|
|
376
|
+
AND length(trim(a.content)) > 0), 0)
|
|
377
|
+
GROUP BY m.thread_id
|
|
378
|
+
ORDER BY oldestCreatedAt ASC`
|
|
379
|
+
).bind(cutoffSeconds).all();
|
|
380
|
+
return results.map((row) => ({
|
|
381
|
+
threadId: row.threadId,
|
|
382
|
+
pendingMessages: Number(row.pendingMessages),
|
|
383
|
+
oldestAgeMs: now - Number(row.oldestCreatedAt) * 1e3
|
|
384
|
+
}));
|
|
385
|
+
},
|
|
386
|
+
async listRecentAssistantTurns({ sinceMs, limit }) {
|
|
387
|
+
const sinceSeconds = Math.floor(sinceMs / 1e3);
|
|
388
|
+
const { results } = await db.prepare(
|
|
389
|
+
`SELECT id, thread_id AS threadId, content, parts,
|
|
390
|
+
output_tokens AS outputTokens, model, created_at AS createdAt
|
|
391
|
+
FROM ${message}
|
|
392
|
+
WHERE role = 'assistant' AND created_at >= ?1
|
|
393
|
+
ORDER BY created_at DESC
|
|
394
|
+
LIMIT ?2`
|
|
395
|
+
).bind(sinceSeconds, limit).all();
|
|
396
|
+
return results.map((row) => ({
|
|
397
|
+
id: String(row.id),
|
|
398
|
+
threadId: String(row.threadId),
|
|
399
|
+
content: typeof row.content === "string" ? row.content : "",
|
|
400
|
+
parts: row.parts,
|
|
401
|
+
outputTokens: row.outputTokens === null ? null : Number(row.outputTokens),
|
|
402
|
+
model: row.model ?? null,
|
|
403
|
+
createdAt: Number(row.createdAt) * 1e3
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
function safeIdentifier(name) {
|
|
408
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
409
|
+
throw new Error(`unsafe table identifier: ${name}`);
|
|
410
|
+
}
|
|
411
|
+
return name;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
export {
|
|
415
|
+
classifyTurnOutcome,
|
|
416
|
+
createConsoleAlertSink,
|
|
417
|
+
createD1TurnHealthSource,
|
|
418
|
+
createGuardedAlertSink,
|
|
419
|
+
createMemoryThrottleStore,
|
|
420
|
+
createMultiAlertSink,
|
|
421
|
+
createThrottledAlertSink,
|
|
422
|
+
createTurnHealthLifecycle,
|
|
423
|
+
createWebhookAlertSink,
|
|
424
|
+
describeReason,
|
|
425
|
+
sweepSilentFailures,
|
|
426
|
+
turnAlert
|
|
427
|
+
};
|
|
428
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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\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}\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}\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\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\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' && nonEmptyString(part.text) !== null) {\n hasVisibleText = true\n continue\n }\n if (ARTIFACT_PART_KINDS.has(type)) {\n artifactCount += 1\n continue\n }\n if (type !== 'tool') continue\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 const toolInput = state?.input\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 // `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 if (!input.failed && !hasVisibleText && artifactCount === 0) {\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 }\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 const critical = reasons.some((r) => r.kind === 'empty_completion' || r.kind === 'turn_failed')\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 }\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/** 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}\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}\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 })\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 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: { minAgeMs: number; now: number }): 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 /** 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 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 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 * 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 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\n const [unanswered, turns] = await Promise.all([\n options.source.findUnansweredThreads({ minAgeMs, 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 unhealthyTurns = 0\n const malformedSamples: 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 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 }\n }\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 if (turns.length >= minTurnsForRate) {\n const rate = emptyCompletions / turns.length\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 ${turns.length} settled turns delivered nothing`,\n `threshold ${(emptyRateThreshold * 100).toFixed(1)}%`,\n ],\n data: { emptyCompletions, turnsJudged: turns.length, rate },\n at: now,\n })\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 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: { messageTable?: string; threadTable?: string } = {},\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\n return {\n async findUnansweredThreads({ minAgeMs, now }) {\n const cutoffSeconds = Math.floor((now - minAgeMs) / 1000)\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 > 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), 0)\n GROUP BY m.thread_id\n ORDER BY oldestCreatedAt ASC`,\n )\n .bind(cutoffSeconds)\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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-app",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.5",
|
|
4
4
|
"packageManager": "pnpm@10.33.4",
|
|
5
5
|
"description": "Application-shell framework for Tangle agent products: a bounded tool loop, the structured agent→app tool side channel, integration-hub client, per-workspace billing, and crypto — composed over the Tangle agent substrate through typed seams.",
|
|
6
6
|
"keywords": [
|
|
@@ -177,6 +177,11 @@
|
|
|
177
177
|
"import": "./dist/turn-stream/index.js",
|
|
178
178
|
"default": "./dist/turn-stream/index.js"
|
|
179
179
|
},
|
|
180
|
+
"./turn-health": {
|
|
181
|
+
"types": "./dist/turn-health/index.d.ts",
|
|
182
|
+
"import": "./dist/turn-health/index.js",
|
|
183
|
+
"default": "./dist/turn-health/index.js"
|
|
184
|
+
},
|
|
180
185
|
"./integrations": {
|
|
181
186
|
"types": "./dist/integrations/index.d.ts",
|
|
182
187
|
"import": "./dist/integrations/index.js",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/model-resolution/failover.ts"],"sourcesContent":["/**\n * Model failover — the answer to \"one upstream hit a quota wall and the customer\n * got a Bad Gateway even though the router had abundant healthy capacity\".\n *\n * This is deliberately NOT a health probe. Probing before every turn buys a\n * round-trip of latency on the happy path and still races the outage (a model\n * healthy at probe time can 502 a second later). Failover is REACTIVE: run the\n * preferred model, and on an upstream-outage signal move to the next model in\n * the chain. Zero added latency when the preferred model works.\n *\n * Two facts drive the design, both measured against a live box during the\n * 2026-07-25 Anthropic/DeepSeek outage:\n *\n * 1. An outage is NOT always a thrown error. The sandbox resolves with a\n * payload — `{ success: false, errorCode: 'provider_inference_unavailable' }`\n * — so a classifier that only inspects `catch` misses the customer-visible\n * case entirely. `isUpstreamUnavailable` inspects resolved values too.\n * 2. Catalog membership is NOT liveness. The router's `/v1/models` still listed\n * every dead model during the outage, so `validateChatModelId` admitted\n * `claude-sonnet-4-6` while every call to it returned 502. Nothing upstream\n * of the actual call can be trusted to tell you a model is serving.\n *\n * Substrate-free: the caller injects `run`, so this composes with a sandbox\n * turn, a router chat completion, or a test fake without importing any of them.\n */\n\n/**\n * Error codes that mean \"this model's upstream is unavailable — a different\n * model may still work\". Deliberately excludes codes that would fail identically\n * on every model (bad request, auth, content filter): retrying those down a\n * chain burns latency and money to reach the same failure.\n */\nexport const UPSTREAM_UNAVAILABLE_CODES: readonly string[] = [\n 'provider_inference_unavailable',\n 'upstream_unavailable',\n 'insufficient_quota',\n 'model_not_available',\n 'server_error',\n 'bad_gateway',\n 'service_unavailable',\n]\n\n/** HTTP statuses that indicate an upstream capacity/availability problem. */\nexport const UPSTREAM_UNAVAILABLE_STATUSES: readonly number[] = [429, 500, 502, 503, 504]\n\n/**\n * Statuses that mean the REQUEST is wrong, so every model in the chain would\n * fail on it identically. Listed explicitly rather than as \"any 4xx\" because\n * 404 is NOT one of them: a model the endpoint cannot find is model-scoped, and\n * the next model in the chain may well be there. 429 is absent for the same\n * reason in reverse — it is a capacity signal and lives in the list above.\n */\nconst REQUEST_ERROR_STATUSES: readonly number[] = [400, 401, 403, 405, 413, 422]\n\n/**\n * Message fragments emitted by real upstreams during this class of outage.\n * Matched case-insensitively as a last resort, after code and status.\n */\nconst UPSTREAM_UNAVAILABLE_MESSAGES: readonly string[] = [\n 'bad gateway',\n 'service unavailable',\n 'inference temporarily unavailable',\n 'provider inference is unavailable',\n 'insufficient balance',\n 'usage limits',\n 'quota exceeded',\n 'rate limit',\n 'overloaded',\n 'temporarily unavailable',\n // Model-SCOPED unavailability. A different model in the chain can still\n // serve, so this belongs here and not with the client errors. Measured on a\n // real box 2026-07-27: the sandbox now reports an unservable model as a\n // terminal `error` whose data is `{\"message\":\"Session error:\n // {\\\"error\\\":{\\\"name\\\":\\\"UnknownError\\\",\\\"data\\\":{\\\"message\\\":\\\"Model not\n // found: openai-compat/zai/glm-4.7.\\\"}}}\"}` — no `code`, no numeric status,\n // and none of the fragments above. The shipped live product-path proof went\n // red on exactly this: `usedFallback:false`, `failed:true`, an error row to\n // the customer where a fallback was available and would have answered.\n 'model not found',\n 'is not currently available',\n]\n\n/**\n * Textual forms an HTTP status arrives in when NO numeric field carries it.\n *\n * The case that forced this is the Cloudflare EDGE 502. Captured verbatim from\n * `router.tangle.tools` on 2026-07-27 17:48:12 GMT (cf-ray\n * `a21d793899f6cef3-DEN`): `HTTP/2 502`, `content-type: text/plain;\n * charset=UTF-8`, a 16-byte body reading `error code: 502\\n`, `server:\n * cloudflare`, and **zero `x-tangle-*` headers** where a healthy 200 from the\n * same endpoint carries nine of them. Zero means the origin never executed —\n * so the router's own upstream failover, which lives inside the origin,\n * structurally cannot fire. Only a client OUTSIDE the edge can rescue that\n * turn, which is why this classifier has to recognize a failure shape that\n * nobody upstream will ever label for it.\n *\n * Every pattern below matches a string a real producer emits:\n * - `error code: 502` — Cloudflare's edge body (the capture above).\n * - `(HTTP 502)` — `src/runtime/openai-stream.ts` wrapping a non-ok response.\n * - `returned 502` — `src/runtime/model-catalog.ts`.\n * - `failed with status 502` — `src/turn-stream/adapters.ts`.\n * - `502 Bad Gateway` — an HTTP/1.1 status line. HTTP/2 carries no reason\n * phrase at all, which is precisely why matching the phrase alone (the\n * `'bad gateway'` fragment below) read the capture as a non-outage.\n */\nconst HTTP_STATUS_HINT_PATTERNS: readonly RegExp[] = [\n /\\berror\\s+code:?\\s*([1-5]\\d{2})\\b/i,\n /\\bhttp(?:\\/[\\d.]+)?[\\s:]\\s*([1-5]\\d{2})\\b/i,\n /\\bstatus(?:\\s*code)?[\\s:=]\\s*([1-5]\\d{2})\\b/i,\n /\\b(?:returned|responded(?:\\s+with)?)\\s+([1-5]\\d{2})\\b/i,\n /\\b([1-5]\\d{2})\\s+(?:bad\\s+gateway|service\\s+unavailable|gateway\\s+time-?out|internal\\s+server\\s+error|too\\s+many\\s+requests)\\b/i,\n]\n\n/**\n * The HTTP status a message states in prose, or `undefined` when it states\n * none. Deliberately NOT \"any three digits in the string\" — an unanchored digit\n * scan would read a token count or a duration as a status.\n */\nexport function readHttpStatusHint(text: string): number | undefined {\n for (const pattern of HTTP_STATUS_HINT_PATTERNS) {\n const match = pattern.exec(text)\n if (match?.[1]) return Number(match[1])\n }\n return undefined\n}\n\nfunction readString(source: Record<string, unknown>, key: string): string | undefined {\n const value = source[key]\n return typeof value === 'string' && value.trim().length > 0 ? value : undefined\n}\n\n/**\n * True when `signal` — a thrown error OR a resolved result payload — indicates\n * the model's upstream is unavailable and another model is worth trying.\n *\n * Checked in order of decreasing confidence: explicit code, HTTP status, then\n * message text. A resolved payload only counts as a failure when it carries an\n * explicit failure marker (`success: false`, or an `error`/`errorCode` field) —\n * a successful result is never misread as an outage.\n */\nexport function isUpstreamUnavailable(signal: unknown): boolean {\n if (signal === null || typeof signal !== 'object') return false\n const record = signal as Record<string, unknown>\n\n // A resolved payload that explicitly reports success is never an outage.\n if (record.success === true) return false\n\n const nested = record.error\n const nestedRecord = nested !== null && typeof nested === 'object' ? (nested as Record<string, unknown>) : undefined\n\n const code =\n readString(record, 'errorCode') ??\n readString(record, 'code') ??\n (nestedRecord ? (readString(nestedRecord, 'code') ?? readString(nestedRecord, 'type')) : undefined)\n if (code && UPSTREAM_UNAVAILABLE_CODES.includes(code)) return true\n\n for (const key of ['status', 'statusCode', 'httpStatus']) {\n const value = record[key]\n if (typeof value === 'number' && UPSTREAM_UNAVAILABLE_STATUSES.includes(value)) return true\n }\n\n const message =\n readString(record, 'message') ??\n readString(record, 'error') ??\n (nestedRecord ? readString(nestedRecord, 'message') : undefined)\n if (!message) return false\n\n // A status carried as PROSE, checked before the fragment list because it is\n // the stronger signal and it cuts BOTH ways. An edge 502 names no exception\n // type and sets no numeric field, so without this the shipped classifier read\n // `error code: 502` as a non-outage and handed the customer a Bad Gateway\n // instead of trying the next model — the exact failure the router cannot\n // rescue from inside its own origin.\n const hinted = readHttpStatusHint(message)\n if (hinted !== undefined) {\n if (UPSTREAM_UNAVAILABLE_STATUSES.includes(hinted)) return true\n // An explicit REQUEST-error status is decisive the OTHER way. A 400\n // carrying a validation message must surface rather than walk the chain,\n // even when its body happens to contain a word from the fragment list —\n // the router's own `Function tools with reasoning_effort are not supported`\n // 400 is a request-shaping bug that fails identically on every model.\n if (REQUEST_ERROR_STATUSES.includes(hinted)) return false\n }\n\n const lowered = message.toLowerCase()\n return UPSTREAM_UNAVAILABLE_MESSAGES.some((fragment) => lowered.includes(fragment))\n}\n\n/** One model tried, and how it went. */\nexport interface ModelFailoverAttempt {\n model: string\n ok: boolean\n /** Why this model was abandoned. Absent when `ok`. */\n reason?: string\n}\n\n/** The outcome of a failover run: the value plus the full attempt trail. */\nexport interface ModelFailoverResult<T> {\n value: T\n /** The model that actually produced `value`. */\n model: string\n attempts: ModelFailoverAttempt[]\n /** True when the preferred (first) model did not serve the request. */\n usedFallback: boolean\n}\n\n/** Every model in the chain failed; carries the trail for logging. */\nexport class ModelFailoverExhaustedError extends Error {\n readonly attempts: ModelFailoverAttempt[]\n constructor(attempts: ModelFailoverAttempt[]) {\n const trail = attempts.map((a) => `${a.model}: ${a.reason ?? 'failed'}`).join(' | ')\n super(`All ${attempts.length} model(s) failed. ${trail}`)\n this.name = 'ModelFailoverExhaustedError'\n this.attempts = attempts\n }\n}\n\n/** Inputs to {@link runWithModelFailover}. */\nexport interface RunWithModelFailoverInput<T> {\n /** Preferred model first, then fallbacks in descending preference. */\n models: readonly string[]\n /** Executes one turn with the given model. */\n run: (model: string) => Promise<T>\n /**\n * Classifies a resolved result as an upstream outage. Defaults to\n * {@link isUpstreamUnavailable}, which understands the sandbox's\n * `{ success: false, errorCode }` payload.\n */\n isUnavailableResult?: (result: T) => boolean\n /** Classifies a thrown error. Defaults to {@link isUpstreamUnavailable}. */\n isUnavailableError?: (error: unknown) => boolean\n /** Observability hook fired each time a model is abandoned. */\n onFallback?: (attempt: ModelFailoverAttempt, nextModel: string) => void\n}\n\nfunction describe(signal: unknown): string {\n if (signal instanceof Error) return signal.message\n if (signal !== null && typeof signal === 'object') {\n const record = signal as Record<string, unknown>\n const message = readString(record, 'error') ?? readString(record, 'message') ?? readString(record, 'errorCode')\n if (message) return message\n }\n return String(signal)\n}\n\n/**\n * Run `run` against the first model in `models` that does not report an upstream\n * outage, falling through the chain in order.\n *\n * A non-outage failure (bad request, auth, content filter) is re-thrown\n * immediately rather than retried down the chain — those fail identically on\n * every model, so walking the chain would only multiply latency and spend.\n *\n * @throws ModelFailoverExhaustedError when every model reports an outage.\n */\nexport async function runWithModelFailover<T>(\n input: RunWithModelFailoverInput<T>,\n): Promise<ModelFailoverResult<T>> {\n const models = input.models.map((m) => m.trim()).filter((m) => m.length > 0)\n if (models.length === 0) throw new Error('runWithModelFailover requires at least one model')\n\n const isUnavailableResult = input.isUnavailableResult ?? ((r: T) => isUpstreamUnavailable(r))\n const isUnavailableError = input.isUnavailableError ?? isUpstreamUnavailable\n const attempts: ModelFailoverAttempt[] = []\n\n for (let index = 0; index < models.length; index += 1) {\n const model = models[index]!\n let result: T\n try {\n result = await input.run(model)\n } catch (error) {\n if (!isUnavailableError(error)) throw error\n const attempt: ModelFailoverAttempt = { model, ok: false, reason: describe(error) }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n if (isUnavailableResult(result)) {\n const attempt: ModelFailoverAttempt = { model, ok: false, reason: describe(result) }\n attempts.push(attempt)\n const next = models[index + 1]\n if (next) input.onFallback?.(attempt, next)\n continue\n }\n\n attempts.push({ model, ok: true })\n return { value: result, model, attempts, usedFallback: index > 0 }\n }\n\n throw new ModelFailoverExhaustedError(attempts)\n}\n\n/**\n * Build a failover chain: the preferred model first, then `fallbacks`, with\n * duplicates removed so a model is never retried twice in one turn.\n */\nexport function buildModelChain(preferred: string, fallbacks: readonly string[]): string[] {\n const chain: string[] = []\n for (const model of [preferred, ...fallbacks]) {\n const cleaned = typeof model === 'string' ? model.trim() : ''\n if (cleaned.length > 0 && !chain.includes(cleaned)) chain.push(cleaned)\n }\n return chain\n}\n"],"mappings":"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","names":[]}
|