@tangle-network/agent-app 0.44.2 → 0.44.4

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.
@@ -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 };