@tangle-network/agent-app 0.44.9 → 0.44.10

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.
@@ -67,6 +67,55 @@ type TurnHealthReason =
67
67
  | {
68
68
  kind: 'turn_failed';
69
69
  reason: string;
70
+ }
71
+ /** A tool call the harness REJECTED, settled as `completed`.
72
+ *
73
+ * Found live in legal-agent production, and missed by every other rule here:
74
+ * the model called `submit_proposal`, the harness answered "Model tried to
75
+ * call unavailable tool 'submit_proposal'", and the part persisted as
76
+ * `{"tool":"invalid","state":{"status":"completed","input":{"error":"…"}}}`.
77
+ *
78
+ * Status is `completed`, the arguments parse cleanly, and the turn has text —
79
+ * so the blank-completion, malformed-argument and no-effect rules all pass it.
80
+ * Six deliverables were requested and silently discarded while the product
81
+ * reported success six times.
82
+ *
83
+ * Detected structurally, on the presence of an `error` in the settled state
84
+ * rather than on any harness's name for a rejected call — `invalid` is one
85
+ * harness's convention and must not be baked into the shell. */
86
+ | {
87
+ kind: 'tool_call_rejected';
88
+ /** The tool the model was trying to reach, when the payload names it. */
89
+ tool: string;
90
+ error: string;
91
+ }
92
+ /** The turn was answered without the model ever running — a pre-producer gate
93
+ * short-circuited and returned the product's own response.
94
+ *
95
+ * Reported as a `warning`, never `critical`: gating an unready turn is a
96
+ * legitimate design (an intake flow SHOULD answer before spending a model
97
+ * call). What is pathological is the RATE, which only the caller's own
98
+ * threshold can judge — so this variant exists to be COUNTED, and alerting
99
+ * on it is opt-in. It is the per-turn evidence behind a dead tool surface:
100
+ * a gate that answers every turn means the agent never runs at all. */
101
+ | {
102
+ kind: 'answered_without_model';
103
+ }
104
+ /** The detector could not read this turn.
105
+ *
106
+ * Every part carried a type outside the vocabulary this classifier
107
+ * understands — which is what field-level encryption at rest looks like from
108
+ * the outside (tax-agent persists `{"type":"__encrypted_parts__"}`, its own
109
+ * convention, and 32 of its 129 assistant rows are exactly that).
110
+ *
111
+ * This exists because the alternative is the bug this whole module hunts.
112
+ * An unreadable row has no text, no artifact and no tool part, so every
113
+ * other rule here would happily conclude "nothing wrong" — a detector
114
+ * reporting health from data it cannot see. Blindness is a finding, not a
115
+ * pass, so it gets its own reason and its own counter. */
116
+ | {
117
+ kind: 'unreadable_turn';
118
+ partTypes: string[];
70
119
  };
71
120
  /** A settled turn, in the narrowest shape both call sites can supply.
72
121
  *
@@ -86,6 +135,10 @@ interface TurnOutcomeInput {
86
135
  failed?: boolean;
87
136
  failureReason?: string | null;
88
137
  durationMs?: number;
138
+ /** Set when a pre-producer gate answered this turn and the model never ran.
139
+ * Supplied by `/chat-routes`' lifecycle seam, which stamps `gated` on the
140
+ * completion it now fires for a `contextGate` short-circuit. */
141
+ gated?: boolean;
89
142
  }
90
143
  /** The verdict for one turn. `healthy` is exactly `reasons.length === 0`, kept
91
144
  * as a field so callers read intent rather than an array length. */
@@ -93,6 +146,37 @@ interface TurnHealthVerdict {
93
146
  healthy: boolean;
94
147
  severity: TurnHealthSeverity | null;
95
148
  reasons: TurnHealthReason[];
149
+ /** How many tool parts this turn carried.
150
+ *
151
+ * Zero is NOT a per-turn defect — plenty of good turns answer from context
152
+ * without touching a tool, and paging on each one would be pure noise. It is
153
+ * reported so a WINDOW can be judged: a product whose deliverable is tool
154
+ * output and which produced zero tool calls across every turn in the
155
+ * lookback has a dead tool surface, and that is the shape no per-turn rule
156
+ * can see. (Measured: tax-agent, 129 of 129 assistant rows all-time.) */
157
+ toolCalls: number;
158
+ /** True when nothing about this turn could be judged — no interpretable part
159
+ * AND no visible text. Callers MUST exclude these from any healthy/unhealthy
160
+ * ratio, because counting an unreadable turn as healthy is how a detector
161
+ * reports green on data it never read. */
162
+ unreadable: boolean;
163
+ /** False when this turn carried parts that could not be interpreted.
164
+ *
165
+ * Separate from {@link unreadable} because the two blindnesses have
166
+ * different consequences, and production has a row that is one but not the
167
+ * other: tax-agent persists CIPHERTEXT as `content` alongside an encrypted
168
+ * `parts` blob, so the turn plainly delivered something (there is text) while
169
+ * its tool calls are completely invisible.
170
+ *
171
+ * Any conclusion ABOUT TOOLS — above all the dead-tool-surface verdict — may
172
+ * only be drawn over turns where this is true. Reading "no tool parts" off an
173
+ * encrypted blob and declaring the tool surface dead would be the same
174
+ * crime as declaring it healthy: a finding asserted from data never read. */
175
+ partsReadable: boolean;
176
+ /** The part types that could not be interpreted. Empty when
177
+ * {@link partsReadable}. Named in the alert so a reader can see EXACTLY what
178
+ * the detector was blind to instead of taking "unreadable" on faith. */
179
+ opaquePartTypes: string[];
96
180
  }
97
181
  /**
98
182
  * Judge one settled turn.
@@ -172,6 +256,27 @@ declare function createWebhookAlertSink(options: {
172
256
  webhookUrl: string;
173
257
  fetchImpl?: FetchLike;
174
258
  }): AlertSink;
259
+ /**
260
+ * POST to Slack `chat.postMessage` with a bot token.
261
+ *
262
+ * This is the transport the org actually has. A survey of the four product
263
+ * repos found NO ops alert path of any kind — no incoming webhook, no pager, no
264
+ * notifier — which is the mechanical reason a 17-day outage never reached a
265
+ * human. What does exist is a Slack bot token in the shared secrets store, and
266
+ * `@tangle-network/agent-integrations` already speaks this exact API, so this
267
+ * routes alerts through the channel the org runs rather than standing up a new
268
+ * one.
269
+ *
270
+ * Slack answers `200 OK` with `{"ok": false, "error": "..."}` for an invalid
271
+ * token or channel, so the body is checked and not just the status — a
272
+ * transport that reports success on a rejected post would make the alerter
273
+ * itself a silent failure.
274
+ */
275
+ declare function createSlackBotAlertSink(options: {
276
+ botToken: string;
277
+ channel: string;
278
+ fetchImpl?: FetchLike;
279
+ }): AlertSink;
175
280
  /** stderr sink. The zero-config fallback so a product that has not yet been
176
281
  * given a webhook still emits something a log search can find. */
177
282
  declare function createConsoleAlertSink(log?: (message: string) => void): AlertSink;
@@ -231,6 +336,9 @@ interface TurnHealthCompleteInfo {
231
336
  threadId?: string;
232
337
  turnStreamId?: string;
233
338
  executionId?: string;
339
+ /** `/chat-routes` sets this when a `contextGate` short-circuited the turn and
340
+ * the producer never ran. */
341
+ gated?: boolean;
234
342
  }
235
343
  /** Structural mirror of the lifecycle error payload. */
236
344
  interface TurnHealthErrorInfo {
@@ -258,6 +366,15 @@ interface TurnHealthLifecycleOptions {
258
366
  kinds: string[];
259
367
  durationMs: number;
260
368
  }): void;
369
+ /** Page when a turn was answered by a gate instead of the model.
370
+ *
371
+ * Default `false`, and the default is the honest one: gating is a legitimate
372
+ * design and a product that gates its intake would otherwise page on every
373
+ * healthy turn. Whether the rate is pathological is domain knowledge, so it
374
+ * stays a product decision — the verdict is ALWAYS reported through
375
+ * {@link TurnHealthLifecycleOptions.onVerdict} so a counter can watch the
376
+ * rate even when nobody is paged. */
377
+ alertOnGatedTurn?: boolean;
261
378
  }
262
379
  /**
263
380
  * Build the lifecycle hooks that page on a turn which succeeded at nothing.
@@ -353,6 +470,22 @@ interface SweepOptions {
353
470
  emptyRateThreshold?: number;
354
471
  /** Absolute floor: never page on a rate computed from fewer turns than this. */
355
472
  minTurnsForRate?: number;
473
+ /** Declare that this product's deliverable comes from TOOL calls, which
474
+ * switches on the dead-tool-surface detector.
475
+ *
476
+ * Opt-in because only the product knows: a copilot that answers from context
477
+ * is perfectly healthy with zero tool calls, while an agent whose entire job
478
+ * is to file, draft, or submit something is broken the moment its tool
479
+ * surface goes quiet — and broken INVISIBLY, because every turn still
480
+ * returns fluent prose and HTTP 200.
481
+ *
482
+ * This is the fourth failure shape, and the only one no per-turn rule can
483
+ * see. Measured on production: tax-agent has 129 assistant turns across 64
484
+ * threads, all-time, with zero tool parts — while every other detector in
485
+ * this module reports it healthy. */
486
+ expectsToolCalls?: boolean;
487
+ /** Turns needed before a dead tool surface is called. Default 10. */
488
+ minTurnsForToolSurface?: number;
356
489
  now?: number;
357
490
  }
358
491
  /** What the sweep found. Returned as well as alerted, so a cron can log it and
@@ -367,20 +500,23 @@ interface SweepResult {
367
500
  emptyCompletions: number;
368
501
  malformedToolCalls: number;
369
502
  toolCallsWithoutEffect: number;
503
+ /** Tool calls the harness rejected while settling them as `completed`. */
504
+ rejectedToolCalls: number;
505
+ /** Turns carrying at least one tool part. */
506
+ turnsWithToolCalls: number;
507
+ /** Total tool parts across the window. */
508
+ toolCalls: number;
509
+ /** Turns the classifier could not interpret at all (encrypted at rest, or a
510
+ * part vocabulary this module does not know). These are EXCLUDED from
511
+ * `turnsJudged`-based rates — a rate computed over rows nobody could read is
512
+ * a fabricated number. */
513
+ unreadableTurns: number;
514
+ /** Turns whose PARTS could not be interpreted. A superset of
515
+ * {@link unreadableTurns} (a row can have readable text and opaque parts).
516
+ * No tool verdict is drawn over these. */
517
+ opaquePartsTurns: number;
370
518
  alerts: TurnHealthAlert[];
371
519
  }
372
- /**
373
- * Assistant-row openers agent-app writes ITSELF when a sandbox turn fails.
374
- *
375
- * Kept byte-identical to the strings `createSandboxChatProducer` composes
376
- * (`src/chat-routes/sandbox-producer.ts`). They are shell vocabulary, not
377
- * product domain, so recognising them is this package's job — a product on
378
- * the shared producer gets a correct sweep with no configuration.
379
- *
380
- * `tests/turn-health/turn-health.test.ts` pins these against the producer, so
381
- * changing the producer's wording without changing this list fails CI rather
382
- * than silently making dead threads look answered.
383
- */
384
520
  declare const SHELL_ERROR_REPLY_PREFIXES: readonly string[];
385
521
  /**
386
522
  * Run one sweep and deliver whatever it finds.
@@ -451,4 +587,4 @@ declare function createD1TurnHealthSource(db: D1LikeForHealth, options?: {
451
587
  errorReplyPrefixes?: readonly string[];
452
588
  }): TurnHealthSource;
453
589
 
454
- export { type AlertSink, type AlertThrottleStore, type D1LikeForHealth, type FetchLike, type PersistedTurnRow, SHELL_ERROR_REPLY_PREFIXES, 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 };
590
+ export { type AlertSink, type AlertThrottleStore, type D1LikeForHealth, type FetchLike, type PersistedTurnRow, SHELL_ERROR_REPLY_PREFIXES, 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, createSlackBotAlertSink, createThrottledAlertSink, createTurnHealthLifecycle, createWebhookAlertSink, describeReason, sweepSilentFailures, turnAlert };
@@ -1,5 +1,13 @@
1
1
  // src/turn-health/classify.ts
2
2
  var ARTIFACT_PART_KINDS = /* @__PURE__ */ new Set(["file", "image", "work-product", "plan", "interaction"]);
3
+ var KNOWN_NON_OUTPUT_PART_KINDS = /* @__PURE__ */ new Set([
4
+ "reasoning",
5
+ "step-start",
6
+ "step-finish",
7
+ "source",
8
+ "source-url",
9
+ "data"
10
+ ]);
3
11
  var SAMPLE_CHARS = 120;
4
12
  function asRecord(value) {
5
13
  return typeof value === "object" && value !== null ? value : null;
@@ -29,23 +37,49 @@ function classifyTurnOutcome(input) {
29
37
  const parts = Array.isArray(input.parts) ? input.parts : [];
30
38
  let hasVisibleText = nonEmptyString(input.finalText) !== null;
31
39
  let artifactCount = 0;
40
+ let toolCalls = 0;
41
+ const opaqueTypes = [];
42
+ let interpretedParts = 0;
32
43
  for (const raw of parts) {
33
44
  const part = asRecord(raw);
34
45
  if (!part) continue;
35
46
  const type = typeof part.type === "string" ? part.type : "";
36
- if (type === "text" && nonEmptyString(part.text) !== null) {
37
- hasVisibleText = true;
47
+ if (type === "text") {
48
+ interpretedParts += 1;
49
+ if (nonEmptyString(part.text) !== null) hasVisibleText = true;
38
50
  continue;
39
51
  }
40
52
  if (ARTIFACT_PART_KINDS.has(type)) {
53
+ interpretedParts += 1;
41
54
  artifactCount += 1;
42
55
  continue;
43
56
  }
44
- if (type !== "tool") continue;
57
+ if (KNOWN_NON_OUTPUT_PART_KINDS.has(type)) {
58
+ interpretedParts += 1;
59
+ continue;
60
+ }
61
+ if (type !== "tool") {
62
+ opaqueTypes.push(type || "(missing type)");
63
+ continue;
64
+ }
65
+ interpretedParts += 1;
66
+ toolCalls += 1;
45
67
  const tool = nonEmptyString(part.tool) ?? "unknown";
46
68
  const state = asRecord(part.state);
47
69
  const status = typeof state?.status === "string" ? state.status : "unknown";
48
70
  const toolInput = state?.input;
71
+ const inputRecord = asRecord(toolInput);
72
+ const rejection = nonEmptyString(inputRecord?.error) ?? nonEmptyString(state?.error);
73
+ if (rejection) {
74
+ reasons.push({
75
+ kind: "tool_call_rejected",
76
+ // The payload names the tool the model MEANT to call; the part's own
77
+ // `tool` is the harness's placeholder for a rejected call.
78
+ tool: nonEmptyString(inputRecord?.tool) ?? tool,
79
+ error: rejection.slice(0, 200)
80
+ });
81
+ continue;
82
+ }
49
83
  if (typeof toolInput === "string" && isUnparseableJson(toolInput)) {
50
84
  reasons.push({
51
85
  kind: "malformed_tool_call",
@@ -59,7 +93,24 @@ function classifyTurnOutcome(input) {
59
93
  reasons.push({ kind: "tool_call_no_effect", tool, status });
60
94
  }
61
95
  }
62
- if (!input.failed && !hasVisibleText && artifactCount === 0) {
96
+ const partsReadable = opaqueTypes.length === 0;
97
+ const uniqueOpaque = [...new Set(opaqueTypes)];
98
+ const unreadable = opaqueTypes.length > 0 && interpretedParts === 0 && !hasVisibleText;
99
+ if (unreadable) {
100
+ reasons.push({ kind: "unreadable_turn", partTypes: uniqueOpaque });
101
+ return {
102
+ healthy: false,
103
+ severity: "warning",
104
+ reasons,
105
+ toolCalls,
106
+ unreadable: true,
107
+ partsReadable: false,
108
+ opaquePartTypes: uniqueOpaque
109
+ };
110
+ }
111
+ if (input.gated) {
112
+ reasons.push({ kind: "answered_without_model" });
113
+ } else if (!input.failed && !hasVisibleText && artifactCount === 0) {
63
114
  reasons.push({
64
115
  kind: "empty_completion",
65
116
  outputTokens: input.outputTokens ?? null,
@@ -70,12 +121,18 @@ function classifyTurnOutcome(input) {
70
121
  return {
71
122
  healthy: reasons.length === 0,
72
123
  severity: severityOf(reasons),
73
- reasons
124
+ reasons,
125
+ toolCalls,
126
+ unreadable: false,
127
+ partsReadable,
128
+ opaquePartTypes: uniqueOpaque
74
129
  };
75
130
  }
76
131
  function severityOf(reasons) {
77
132
  if (reasons.length === 0) return null;
78
- const critical = reasons.some((r) => r.kind === "empty_completion" || r.kind === "turn_failed");
133
+ const critical = reasons.some(
134
+ (r) => r.kind === "empty_completion" || r.kind === "turn_failed" || r.kind === "tool_call_rejected"
135
+ );
79
136
  return critical ? "critical" : "warning";
80
137
  }
81
138
  function describeReason(reason) {
@@ -88,6 +145,14 @@ function describeReason(reason) {
88
145
  return `tool \`${reason.tool}\` left no effect (status=${reason.status})`;
89
146
  case "turn_failed":
90
147
  return `turn failed: ${reason.reason}`;
148
+ case "tool_call_rejected":
149
+ return `tool \`${reason.tool}\` was REJECTED but settled as completed: ${reason.error}`;
150
+ case "answered_without_model":
151
+ return "answered by a pre-producer gate \u2014 the model never ran";
152
+ case "unreadable_turn":
153
+ return `turn could not be read: every part had an uninterpretable type (${reason.partTypes.join(
154
+ ", "
155
+ )})`;
91
156
  }
92
157
  }
93
158
 
@@ -132,6 +197,32 @@ function createWebhookAlertSink(options) {
132
197
  }
133
198
  };
134
199
  }
200
+ function createSlackBotAlertSink(options) {
201
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
202
+ return {
203
+ async deliver(alert) {
204
+ const icon = alert.severity === "critical" ? ":rotating_light:" : ":warning:";
205
+ const lines = [
206
+ `${icon} *${alert.title}*`,
207
+ ...alert.details.map((d) => `\u2022 ${d}`),
208
+ `_${new Date(alert.at).toISOString()}_`
209
+ ];
210
+ const response = await fetchImpl("https://slack.com/api/chat.postMessage", {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json; charset=utf-8",
214
+ Authorization: `Bearer ${options.botToken}`
215
+ },
216
+ body: JSON.stringify({ channel: options.channel, text: lines.join("\n") })
217
+ });
218
+ if (!response.ok) throw new Error(`slack chat.postMessage responded ${response.status}`);
219
+ const body = await response.text?.() ?? "";
220
+ if (body && !/"ok"\s*:\s*true/.test(body)) {
221
+ throw new Error(`slack rejected the alert: ${body.slice(0, 200)}`);
222
+ }
223
+ }
224
+ };
225
+ }
135
226
  function createConsoleAlertSink(log = console.error) {
136
227
  return {
137
228
  async deliver(alert) {
@@ -199,7 +290,8 @@ function createTurnHealthLifecycle(options) {
199
290
  const verdict = classifyTurnOutcome({
200
291
  finalText: info.finalText,
201
292
  outputTokens: info.usage?.outputTokens ?? null,
202
- durationMs: info.durationMs
293
+ durationMs: info.durationMs,
294
+ ...info.gated ? { gated: true } : {}
203
295
  });
204
296
  options.onVerdict?.({
205
297
  product: options.product,
@@ -208,6 +300,9 @@ function createTurnHealthLifecycle(options) {
208
300
  durationMs: info.durationMs
209
301
  });
210
302
  if (verdict.healthy || verdict.severity === null) return;
303
+ if (!options.alertOnGatedTurn && verdict.reasons.every((r) => r.kind === "answered_without_model")) {
304
+ return;
305
+ }
211
306
  await sink.deliver(
212
307
  turnAlert({
213
308
  product: options.product,
@@ -255,6 +350,7 @@ function parseParts(raw) {
255
350
  }
256
351
  }
257
352
  var HOUR_MS = 36e5;
353
+ var D1_MAX_LIKE_PATTERN_LENGTH = 50;
258
354
  var SHELL_ERROR_REPLY_PREFIXES = [
259
355
  "The sandbox model stream stopped before a clean completion.",
260
356
  "The sandbox agent returned an error before producing a visible answer."
@@ -267,6 +363,8 @@ async function sweepSilentFailures(options) {
267
363
  const limit = options.limit ?? 500;
268
364
  const emptyRateThreshold = options.emptyRateThreshold ?? 0.05;
269
365
  const minTurnsForRate = options.minTurnsForRate ?? 10;
366
+ const minTurnsForToolSurface = options.minTurnsForToolSurface ?? 10;
367
+ const blindThreshold = 0.5;
270
368
  const [unanswered, turns] = await Promise.all([
271
369
  options.source.findUnansweredThreads({ minAgeMs, maxAgeMs, now }),
272
370
  options.source.listRecentAssistantTurns({ sinceMs: now - lookbackMs, now, limit })
@@ -299,14 +397,33 @@ async function sweepSilentFailures(options) {
299
397
  let emptyCompletions = 0;
300
398
  let malformedToolCalls = 0;
301
399
  let toolCallsWithoutEffect = 0;
400
+ let rejectedToolCalls = 0;
302
401
  let unhealthyTurns = 0;
402
+ let turnsWithToolCalls = 0;
403
+ let toolCalls = 0;
404
+ let unreadableTurns = 0;
405
+ let toolReadableTurns = 0;
406
+ let opaquePartsTurns = 0;
407
+ const opaqueTypes = /* @__PURE__ */ new Set();
303
408
  const malformedSamples = [];
409
+ const rejectedSamples = [];
304
410
  for (const row of turns) {
305
411
  const verdict = classifyTurnOutcome({
306
412
  finalText: row.content,
307
413
  parts: parseParts(row.parts),
308
414
  outputTokens: row.outputTokens ?? null
309
415
  });
416
+ toolCalls += verdict.toolCalls;
417
+ if (verdict.toolCalls > 0) turnsWithToolCalls += 1;
418
+ if (verdict.partsReadable) toolReadableTurns += 1;
419
+ else {
420
+ opaquePartsTurns += 1;
421
+ for (const t of verdict.opaquePartTypes) opaqueTypes.add(t);
422
+ }
423
+ if (verdict.unreadable) {
424
+ unreadableTurns += 1;
425
+ continue;
426
+ }
310
427
  if (verdict.healthy) continue;
311
428
  unhealthyTurns += 1;
312
429
  for (const reason of verdict.reasons) {
@@ -316,8 +433,13 @@ async function sweepSilentFailures(options) {
316
433
  if (malformedSamples.length < 3) malformedSamples.push(reason);
317
434
  }
318
435
  if (reason.kind === "tool_call_no_effect") toolCallsWithoutEffect += 1;
436
+ if (reason.kind === "tool_call_rejected") {
437
+ rejectedToolCalls += 1;
438
+ if (rejectedSamples.length < 3) rejectedSamples.push(reason);
439
+ }
319
440
  }
320
441
  }
442
+ const readableTurns = turns.length - unreadableTurns;
321
443
  if (malformedToolCalls > 0) {
322
444
  alerts.push({
323
445
  product: options.product,
@@ -329,8 +451,19 @@ async function sweepSilentFailures(options) {
329
451
  at: now
330
452
  });
331
453
  }
332
- if (turns.length >= minTurnsForRate) {
333
- const rate = emptyCompletions / turns.length;
454
+ if (rejectedToolCalls > 0) {
455
+ alerts.push({
456
+ product: options.product,
457
+ severity: "critical",
458
+ key: `sweep:${options.product}:tool_call_rejected`,
459
+ title: `${options.product}: ${rejectedToolCalls} tool call(s) were REJECTED but settled as completed \u2014 deliverables silently dropped`,
460
+ details: rejectedSamples.map(describeReason),
461
+ data: { rejectedToolCalls, turnsJudged: readableTurns },
462
+ at: now
463
+ });
464
+ }
465
+ if (readableTurns >= minTurnsForRate) {
466
+ const rate = emptyCompletions / readableTurns;
334
467
  if (rate > emptyRateThreshold) {
335
468
  alerts.push({
336
469
  product: options.product,
@@ -338,14 +471,53 @@ async function sweepSilentFailures(options) {
338
471
  key: `sweep:${options.product}:empty_completion_rate`,
339
472
  title: `${options.product}: ${(rate * 100).toFixed(1)}% of turns completed with no output`,
340
473
  details: [
341
- `${emptyCompletions} of ${turns.length} settled turns delivered nothing`,
474
+ `${emptyCompletions} of ${readableTurns} readable settled turns delivered nothing`,
342
475
  `threshold ${(emptyRateThreshold * 100).toFixed(1)}%`
343
476
  ],
344
- data: { emptyCompletions, turnsJudged: turns.length, rate },
477
+ data: { emptyCompletions, turnsJudged: readableTurns, rate },
345
478
  at: now
346
479
  });
347
480
  }
348
481
  }
482
+ if (options.expectsToolCalls && toolReadableTurns >= minTurnsForToolSurface && toolCalls === 0) {
483
+ alerts.push({
484
+ product: options.product,
485
+ severity: "critical",
486
+ key: `sweep:${options.product}:dead_tool_surface`,
487
+ title: `${options.product}: ZERO tool calls across ${toolReadableTurns} turns \u2014 the tool surface is dead`,
488
+ details: [
489
+ `${toolReadableTurns} assistant turns with readable parts in the lookback, none of which called a tool`,
490
+ "the product declares its deliverable comes from tool calls, so it has answered without doing anything",
491
+ ...opaquePartsTurns > 0 ? [`${opaquePartsTurns} further turn(s) had unreadable parts and were not judged`] : []
492
+ ],
493
+ data: {
494
+ turnsJudged: toolReadableTurns,
495
+ toolCalls: 0,
496
+ turnsWithToolCalls: 0,
497
+ turnsNotJudged: opaquePartsTurns
498
+ },
499
+ at: now
500
+ });
501
+ }
502
+ if (opaquePartsTurns > 0 && opaquePartsTurns >= turns.length * blindThreshold) {
503
+ alerts.push({
504
+ product: options.product,
505
+ severity: "warning",
506
+ key: `sweep:${options.product}:detector_blind`,
507
+ title: `${options.product}: ${opaquePartsTurns} of ${turns.length} turns have unreadable parts \u2014 this sweep cannot certify them`,
508
+ details: [
509
+ `uninterpretable part types: ${[...opaqueTypes].join(", ") || "(none named)"}`,
510
+ "these rows are excluded from every verdict above; treat them as UNMEASURED, not healthy"
511
+ ],
512
+ data: {
513
+ opaquePartsTurns,
514
+ unreadableTurns,
515
+ turnsSeen: turns.length,
516
+ opaqueTypes: [...opaqueTypes]
517
+ },
518
+ at: now
519
+ });
520
+ }
349
521
  for (const alert of alerts) await options.sink.deliver(alert);
350
522
  return {
351
523
  product: options.product,
@@ -357,13 +529,20 @@ async function sweepSilentFailures(options) {
357
529
  emptyCompletions,
358
530
  malformedToolCalls,
359
531
  toolCallsWithoutEffect,
532
+ rejectedToolCalls,
533
+ turnsWithToolCalls,
534
+ toolCalls,
535
+ unreadableTurns,
536
+ opaquePartsTurns,
360
537
  alerts
361
538
  };
362
539
  }
363
540
  function createD1TurnHealthSource(db, options = {}) {
364
541
  const message = safeIdentifier(options.messageTable ?? "message");
365
542
  const thread = safeIdentifier(options.threadTable ?? "thread");
366
- const errorPrefixes = [...options.errorReplyPrefixes ?? SHELL_ERROR_REPLY_PREFIXES];
543
+ const errorPrefixes = [...options.errorReplyPrefixes ?? SHELL_ERROR_REPLY_PREFIXES].map(
544
+ (p) => p.slice(0, D1_MAX_LIKE_PATTERN_LENGTH - 1)
545
+ );
367
546
  return {
368
547
  async findUnansweredThreads({ minAgeMs, maxAgeMs, now }) {
369
548
  const cutoffSeconds = Math.floor((now - minAgeMs) / 1e3);
@@ -428,6 +607,7 @@ export {
428
607
  createGuardedAlertSink,
429
608
  createMemoryThrottleStore,
430
609
  createMultiAlertSink,
610
+ createSlackBotAlertSink,
431
611
  createThrottledAlertSink,
432
612
  createTurnHealthLifecycle,
433
613
  createWebhookAlertSink,