@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"
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
333
|
-
|
|
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 ${
|
|
474
|
+
`${emptyCompletions} of ${readableTurns} readable settled turns delivered nothing`,
|
|
342
475
|
`threshold ${(emptyRateThreshold * 100).toFixed(1)}%`
|
|
343
476
|
],
|
|
344
|
-
data: { emptyCompletions, turnsJudged:
|
|
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,
|