@animalabs/agent-framework 0.22.0 → 0.22.1
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/README.md +11 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +41 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +319 -20
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1500 -182
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +20 -9
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +29 -6
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +57 -16
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +28 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +32 -4
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/module-registry.d.ts +9 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +3 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +46 -4
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +13 -3
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +24 -4
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +14 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +10 -2
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/operator-log.d.ts +41 -0
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/provider-waits.d.ts +216 -0
- package/dist/src/provider-waits.d.ts.map +1 -0
- package/dist/src/provider-waits.js +482 -0
- package/dist/src/provider-waits.js.map +1 -0
- package/dist/src/refusal-reactions.d.ts +14 -12
- package/dist/src/refusal-reactions.d.ts.map +1 -1
- package/dist/src/refusal-reactions.js +14 -12
- package/dist/src/refusal-reactions.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +30 -13
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +71 -25
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/types/events.d.ts +12 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +12 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +14 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +3 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +0 -2
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +160 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +3 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/inference-exhaustion-rewind.test.js +142 -14
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.d.ts +2 -0
- package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
- package/dist/test/inference-failure-bounds.test.js +1201 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -0
- package/dist/test/late-events-after-stop.test.d.ts +2 -0
- package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
- package/dist/test/late-events-after-stop.test.js +122 -0
- package/dist/test/late-events-after-stop.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +141 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-coalescing.test.js +78 -16
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.js +6 -3
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
- package/dist/test/mcpl-manifest-changed.test.js +2 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js +232 -4
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js +69 -2
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-ws-transport.test.js +103 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/ops-alert.test.js +2 -1
- package/dist/test/ops-alert.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
- package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/placed-reaction-baseline.test.js +312 -0
- package/dist/test/placed-reaction-baseline.test.js.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +5 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.d.ts +2 -0
- package/dist/test/provider-waits.test.d.ts.map +1 -0
- package/dist/test/provider-waits.test.js +2196 -0
- package/dist/test/provider-waits.test.js.map +1 -0
- package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
- package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
- package/dist/test/reaction-baseline-platform.test.js +124 -0
- package/dist/test/reaction-baseline-platform.test.js.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +10 -3
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
- package/dist/test/save-recent-image.test.js +138 -2
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
- package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
- package/dist/test/surgery-marks-receipt.test.js +540 -0
- package/dist/test/surgery-marks-receipt.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +221 -7
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/workspace-three-way.test.js +115 -0
- package/dist/test/workspace-three-way.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/agent.ts +4 -1
- package/src/api/server.ts +43 -4
- package/src/framework.ts +1538 -189
- package/src/index.ts +7 -2
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/reference-fetcher.ts +18 -8
- package/src/mcpl/references.ts +57 -13
- package/src/mcpl/server-connection.ts +55 -4
- package/src/module-registry.ts +17 -9
- package/src/modules/workspace/index.ts +44 -4
- package/src/modules/workspace/observe.ts +31 -5
- package/src/modules/workspace/reconcile.ts +23 -2
- package/src/operator-log.ts +30 -0
- package/src/provider-waits.ts +523 -0
- package/src/refusal-reactions.ts +14 -12
- package/src/tool-result-guard.ts +72 -24
- package/src/types/events.ts +12 -0
- package/src/types/module.ts +12 -4
- package/src/types/trace.ts +14 -3
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure text is bounded in every sink the framework writes it to.
|
|
3
|
+
*
|
|
4
|
+
* A failure's text is whatever its source put in `Error.message`, and a
|
|
5
|
+
* provider can echo the whole rejected request there. In one household an
|
|
6
|
+
* OpenAI-compatible 400 echoed ~1.7 MB, and the framework stored it in the
|
|
7
|
+
* resident's own context as an [inference-failed] marker, wrote it to
|
|
8
|
+
* stderr and failures.log, and traced it. These tests drive ~1 MB reasons
|
|
9
|
+
* through the real failure paths and through each sink, and check that what
|
|
10
|
+
* a small failure produced before is produced unchanged.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import vm from 'node:vm';
|
|
18
|
+
import { MembraneError } from '@animalabs/membrane';
|
|
19
|
+
import { AgentFramework } from '../src/index.js';
|
|
20
|
+
import { AgentFramework as FrameworkClass } from '../src/framework.js';
|
|
21
|
+
import { MockMembrane } from './helpers/mock-membrane.js';
|
|
22
|
+
const MEGA = 1_000_000;
|
|
23
|
+
/** ~1 MB of echoed request with a recognizable head and tail. */
|
|
24
|
+
const echo = (n = MEGA) => `zz-head ${'e'.repeat(n)} zz-tail`;
|
|
25
|
+
const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
26
|
+
function hugeProviderError(request) {
|
|
27
|
+
return new MembraneError({
|
|
28
|
+
type: 'invalid_request',
|
|
29
|
+
retryable: false,
|
|
30
|
+
httpStatus: 400,
|
|
31
|
+
providerErrorCode: 'zz_bad_request',
|
|
32
|
+
message: `Bad request: ${echo()}`,
|
|
33
|
+
rawError: { status: 400 },
|
|
34
|
+
rawRequest: request ?? { system: echo() },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// The private sinks, on a prototype instance (the pattern of
|
|
39
|
+
// inference-failure-observability.test.ts).
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
function makeHarness(opts) {
|
|
42
|
+
const fw = Object.create(FrameworkClass.prototype);
|
|
43
|
+
fw.consecutiveInferenceFailures = new Map();
|
|
44
|
+
fw.inferenceFailureEscalationThreshold = 3;
|
|
45
|
+
fw.exhaustionRewinds = new Map();
|
|
46
|
+
fw.rewindEpisode = new Map();
|
|
47
|
+
fw.lastInferenceAt = new Map();
|
|
48
|
+
fw.pendingRequests = [];
|
|
49
|
+
fw.overBudgetDrainInFlight = new Set();
|
|
50
|
+
fw.opsAlertLastSent = new Map();
|
|
51
|
+
fw.opsAlertCooldownMs = 15 * 60_000;
|
|
52
|
+
const traces = [];
|
|
53
|
+
// Subscribed as a caller does, so the harness holds whatever form the
|
|
54
|
+
// framework keeps its listeners in.
|
|
55
|
+
fw.traceListeners = [];
|
|
56
|
+
fw.onTrace((e) => traces.push(e));
|
|
57
|
+
const logged = [];
|
|
58
|
+
fw.logFailure = (r) => logged.push(r);
|
|
59
|
+
const markers = [];
|
|
60
|
+
let ticks = 0;
|
|
61
|
+
fw.agents = new Map([['cairn', {
|
|
62
|
+
name: 'cairn',
|
|
63
|
+
refusalHandling: { maxRewinds: 3 },
|
|
64
|
+
getContextManager: () => ({
|
|
65
|
+
addMessage: opts?.addMessage ?? ((_p, content, meta) => { markers.push({ text: content[0].text, meta }); }),
|
|
66
|
+
tick: async () => { ticks++; await opts?.tick?.(); },
|
|
67
|
+
}),
|
|
68
|
+
}]]);
|
|
69
|
+
const errs = [];
|
|
70
|
+
// The arguments as passed: printing an object (rather than a string built
|
|
71
|
+
// from it) is what wrote a MembraneError's whole rawRequest to stderr.
|
|
72
|
+
const rawArgs = [];
|
|
73
|
+
const orig = console.error;
|
|
74
|
+
console.error = (...a) => { rawArgs.push(a); errs.push(a.map(String).join(' ')); };
|
|
75
|
+
const restore = () => { console.error = orig; };
|
|
76
|
+
return { fw, traces, logged, markers, errs, rawArgs, restore, ticks: () => ticks };
|
|
77
|
+
}
|
|
78
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
79
|
+
test('a ~1 MB classified reason: bounded marker, logs and stderr, with the classification beside the text', () => {
|
|
80
|
+
const { fw, markers, logged, errs, restore } = makeHarness();
|
|
81
|
+
const reason = `Bad request: ${echo()}`;
|
|
82
|
+
try {
|
|
83
|
+
fw.noteInferenceExhausted('cairn', reason, false, 'invalid_request', { httpStatus: 400, providerErrorCode: 'zz_bad_request' });
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
restore();
|
|
87
|
+
}
|
|
88
|
+
assert.equal(markers.length, 1);
|
|
89
|
+
const { text, meta } = markers[0];
|
|
90
|
+
assert.ok(text.length < 1_200, `marker text is ${text.length} chars`);
|
|
91
|
+
assert.match(text, /^\[inference-failed\] Your previous turn did not complete: the model call failed \(invalid_request, HTTP 400\) and produced no response/);
|
|
92
|
+
assert.match(text, /Reason: Bad request: zz-head e+ …\[\d+ of \d+ characters omitted\]… e+ zz-tail\. If this recurs/);
|
|
93
|
+
assert.ok(meta.reason.length <= 600);
|
|
94
|
+
assert.equal(meta.kind, 'inference-failed');
|
|
95
|
+
assert.equal(meta.errorType, 'invalid_request');
|
|
96
|
+
assert.equal(meta.retryable, false);
|
|
97
|
+
assert.equal(meta.httpStatus, 400);
|
|
98
|
+
assert.equal(meta.providerErrorCode, 'zz_bad_request');
|
|
99
|
+
assert.equal(meta.reasonChars, reason.length);
|
|
100
|
+
assert.equal(logged.length, 1);
|
|
101
|
+
assert.ok(logged[0].reason.length <= 2_000);
|
|
102
|
+
assert.deepEqual({ kind: logged[0].kind, errorType: logged[0].errorType, httpStatus: logged[0].httpStatus, providerErrorCode: logged[0].providerErrorCode, reasonChars: logged[0].reasonChars }, { kind: 'inference-exhausted', errorType: 'invalid_request', httpStatus: 400, providerErrorCode: 'zz_bad_request', reasonChars: reason.length });
|
|
103
|
+
const line = errs.find((e) => e.startsWith('[inference-failed]'));
|
|
104
|
+
assert.ok(line.length < 2_200, `stderr line is ${line.length} chars`);
|
|
105
|
+
assert.match(line, /^\[inference-failed\] agent=cairn consecutive=1 type=invalid_request status=400 code=zz_bad_request: Bad request: zz-head/);
|
|
106
|
+
});
|
|
107
|
+
test('a small unclassified reason produces exactly the marker it did before', () => {
|
|
108
|
+
const { fw, markers, logged, errs, restore } = makeHarness();
|
|
109
|
+
try {
|
|
110
|
+
fw.noteInferenceExhausted('cairn', '400 image exceeds 5 MB');
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
restore();
|
|
114
|
+
}
|
|
115
|
+
assert.equal(markers[0].text, '[inference-failed] Your previous turn did not complete: the model call failed and produced no response, ' +
|
|
116
|
+
'so nothing was sent. Reason: 400 image exceeds 5 MB. If this recurs with the same cause, change approach ' +
|
|
117
|
+
'rather than retrying identically (e.g. drop an oversized attachment or an unsupported setting).');
|
|
118
|
+
assert.deepEqual(markers[0].meta, { system: true, kind: 'inference-failed', reason: '400 image exceeds 5 MB', consecutive: 1 });
|
|
119
|
+
assert.deepEqual(logged[0], { agent: 'cairn', consecutive: 1, reason: '400 image exceeds 5 MB', kind: 'inference-exhausted' });
|
|
120
|
+
assert.ok(errs.includes('[inference-failed] agent=cairn consecutive=1: 400 image exceeds 5 MB'));
|
|
121
|
+
});
|
|
122
|
+
const isHigh = (text, index) => { const code = text.charCodeAt(index); return code >= 0xd800 && code <= 0xdbff; };
|
|
123
|
+
const isLow = (text, index) => { const code = text.charCodeAt(index); return code >= 0xdc00 && code <= 0xdfff; };
|
|
124
|
+
/**
|
|
125
|
+
* `excerpt` is `whole` cut to `max` by the one rule: the text's own head and
|
|
126
|
+
* tail around a marker that states the whole length and the omission exactly,
|
|
127
|
+
* with neither cut separating a surrogate pair the text held whole.
|
|
128
|
+
*/
|
|
129
|
+
function assertCut(whole, excerpt, max, where) {
|
|
130
|
+
if (whole.length <= max) {
|
|
131
|
+
assert.equal(excerpt, whole, `${where}: cut though it fits`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
assert.ok(excerpt.length <= max, `${where}: ${excerpt.length} characters`);
|
|
135
|
+
const stated = / …\[(\d+) of (\d+) characters omitted\]… /.exec(excerpt);
|
|
136
|
+
assert.ok(stated, `${where}: no omission marker`);
|
|
137
|
+
const head = excerpt.slice(0, stated.index);
|
|
138
|
+
const tail = excerpt.slice(stated.index + stated[0].length);
|
|
139
|
+
assert.equal(Number(stated[2]), whole.length, `${where}: the stated whole length`);
|
|
140
|
+
assert.equal(head.length + Number(stated[1]) + tail.length, whole.length, `${where}: the stated omission`);
|
|
141
|
+
assert.ok(whole.startsWith(head), `${where}: the head is not the text's own`);
|
|
142
|
+
assert.ok(whole.endsWith(tail), `${where}: the tail is not the text's own`);
|
|
143
|
+
const tailStart = whole.length - tail.length;
|
|
144
|
+
assert.ok(!(isHigh(whole, head.length - 1) && isLow(whole, head.length)), `${where}: the head cut splits a pair`);
|
|
145
|
+
assert.ok(!(isHigh(whole, tailStart - 1) && isLow(whole, tailStart)), `${where}: the tail cut splits a pair`);
|
|
146
|
+
}
|
|
147
|
+
test('every cut of failure text keeps surrogate pairs whole and states the whole length, at any position', () => {
|
|
148
|
+
// A reason longer than every bound, with a pair at every position, so that
|
|
149
|
+
// wherever a sink's cuts fall, some position straddles each of them. The
|
|
150
|
+
// second family puts a stray low surrogate right after the pair: a head
|
|
151
|
+
// cut landing between them must not move into the pair.
|
|
152
|
+
const length = 2_500;
|
|
153
|
+
const hook = process.env.CONNECTOME_OPS_WEBHOOK;
|
|
154
|
+
const realFetch = globalThis.fetch;
|
|
155
|
+
const posted = [];
|
|
156
|
+
process.env.CONNECTOME_OPS_WEBHOOK = 'http://zz.invalid/hook';
|
|
157
|
+
globalThis.fetch = (async (_url, init) => {
|
|
158
|
+
posted.push(JSON.parse(String(init?.body)).content);
|
|
159
|
+
return new Response(null);
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
for (const inserted of ['😀', '😀\uDC00']) {
|
|
163
|
+
for (let at = 0; at <= length - inserted.length; at++) {
|
|
164
|
+
const text = `${'x'.repeat(at)}${inserted}${'x'.repeat(length - inserted.length - at)}`;
|
|
165
|
+
const where = (sink) => `${JSON.stringify(inserted)} at ${at}, ${sink}`;
|
|
166
|
+
posted.length = 0;
|
|
167
|
+
// An invalid_request at the escalation threshold: the marker, the
|
|
168
|
+
// logs, the status, the hard-down alert and the rewind marker.
|
|
169
|
+
const exhausted = makeHarness();
|
|
170
|
+
exhausted.fw.inferenceFailureEscalationThreshold = 1;
|
|
171
|
+
const rewound = [];
|
|
172
|
+
exhausted.fw.shedNewestTurn = () => ({ descriptor: 'zz-turn' });
|
|
173
|
+
exhausted.fw.updateRewindMarker = (_agent, category) => { rewound.push(category); return 1; };
|
|
174
|
+
try {
|
|
175
|
+
exhausted.fw.noteInferenceExhausted('cairn', text, false, 'invalid_request');
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
exhausted.restore();
|
|
179
|
+
}
|
|
180
|
+
assertCut(text, exhausted.markers[0].meta.reason, 600, where('[inference-failed] marker'));
|
|
181
|
+
assertCut(text, exhausted.logged[0].reason, 2_000, where('failures.log'));
|
|
182
|
+
assertCut(text, exhausted.fw.lastInferenceAt.get('cairn').lastError, 300, where('lastError'));
|
|
183
|
+
assertCut(text, rewound[0], 160, where('rewind marker'));
|
|
184
|
+
const hardDown = exhausted.traces.find((t) => t.type === 'ops:alert' && t.kind === 'hard-down').message;
|
|
185
|
+
const prefix = '1 consecutive inference failures. Last reason: ';
|
|
186
|
+
assert.ok(hardDown.startsWith(prefix), where('hard-down alert'));
|
|
187
|
+
assertCut(text, hardDown.slice(prefix.length), 2_000 - prefix.length, where('hard-down alert'));
|
|
188
|
+
// A refused compile: the context-refusal alert (no drain kick here).
|
|
189
|
+
const refused = makeHarness();
|
|
190
|
+
refused.fw.overBudgetDrainInFlight.add('cairn');
|
|
191
|
+
try {
|
|
192
|
+
refused.fw.noteInferenceExhausted('cairn', text, undefined, 'over_budget');
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
refused.restore();
|
|
196
|
+
}
|
|
197
|
+
const refusal = refused.traces.find((t) => t.type === 'ops:alert' && t.kind === 'context-refusal').message;
|
|
198
|
+
assertCut(text, /^compile refused \(over_budget\): ([\s\S]*) — drain breaker/.exec(refusal)[1], 300, where('compile-refused alert'));
|
|
199
|
+
// An alert's message, posted to the ops webhook.
|
|
200
|
+
const alerted = makeHarness();
|
|
201
|
+
try {
|
|
202
|
+
alerted.fw.opsAlert('zz-webhook', 'cairn', text);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
alerted.restore();
|
|
206
|
+
}
|
|
207
|
+
const webhook = '\u{1F6A8} **cairn** zz-webhook: ';
|
|
208
|
+
const content = posted.find((c) => c.startsWith(webhook));
|
|
209
|
+
assert.ok(content, where('webhook'));
|
|
210
|
+
assertCut(text, content.slice(webhook.length), 500, where('webhook'));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
globalThis.fetch = realFetch;
|
|
216
|
+
if (hook === undefined)
|
|
217
|
+
delete process.env.CONNECTOME_OPS_WEBHOOK;
|
|
218
|
+
else
|
|
219
|
+
process.env.CONNECTOME_OPS_WEBHOOK = hook;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test('an over-budget phrase quoted inside a classified provider error does not kick the drain', async () => {
|
|
223
|
+
const quoted = `Bad request. Request: [inference-failed] ... Context would exceed hard budget ... no summary covers ${echo(10_000)}`;
|
|
224
|
+
const classified = makeHarness();
|
|
225
|
+
try {
|
|
226
|
+
classified.fw.noteInferenceExhausted('cairn', quoted, false, 'invalid_request', { httpStatus: 400 });
|
|
227
|
+
await settle();
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
classified.restore();
|
|
231
|
+
}
|
|
232
|
+
assert.equal(classified.ticks(), 0, 'a classified 400 is not an over-budget compile');
|
|
233
|
+
assert.equal(classified.traces.filter((t) => t.type === 'ops:alert' && t.kind === 'context-refusal').length, 0);
|
|
234
|
+
// The fallback still serves the unclassified string it exists for.
|
|
235
|
+
const unclassified = makeHarness();
|
|
236
|
+
try {
|
|
237
|
+
unclassified.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget (90000 > 80000)');
|
|
238
|
+
await settle();
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
unclassified.restore();
|
|
242
|
+
}
|
|
243
|
+
assert.equal(unclassified.ticks(), 8);
|
|
244
|
+
// ...decided on the whole reason, even where the excerpts omit the phrase.
|
|
245
|
+
const long = makeHarness();
|
|
246
|
+
try {
|
|
247
|
+
long.fw.noteInferenceExhausted('cairn', `${'x'.repeat(50_000)} Context would exceed hard budget ${'y'.repeat(50_000)}`);
|
|
248
|
+
await settle();
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
long.restore();
|
|
252
|
+
}
|
|
253
|
+
assert.equal(long.ticks(), 8, 'presentation bounds never change the decision');
|
|
254
|
+
});
|
|
255
|
+
test('a reason that is not a string is described whole, not thrown on: each sink cuts it once, and the fallback reads it uncut', async () => {
|
|
256
|
+
// Unreachable from the stream paths, which pass a message; a malformed
|
|
257
|
+
// trace event must still leave its failure record.
|
|
258
|
+
const error = new Error(`zz-not-a-string ${echo()}`);
|
|
259
|
+
const { fw, markers, logged, restore } = makeHarness();
|
|
260
|
+
try {
|
|
261
|
+
fw.noteInferenceExhausted('cairn', error);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
restore();
|
|
265
|
+
}
|
|
266
|
+
const whole = `Error: ${error.message}`;
|
|
267
|
+
assertCut(whole, markers[0].meta.reason, 600, 'marker');
|
|
268
|
+
assert.equal(markers[0].meta.reasonChars, whole.length);
|
|
269
|
+
assertCut(whole, logged[0].reason, 2_000, 'failures.log');
|
|
270
|
+
assert.equal(logged[0].reasonChars, whole.length);
|
|
271
|
+
// The OverBudget fallback reads the whole description, so a phrase that
|
|
272
|
+
// every excerpt omits still kicks the drain.
|
|
273
|
+
const phrased = makeHarness();
|
|
274
|
+
try {
|
|
275
|
+
phrased.fw.noteInferenceExhausted('cairn', new Error(`${'x'.repeat(50_000)} Context would exceed hard budget ${'y'.repeat(50_000)}`));
|
|
276
|
+
await settle();
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
phrased.restore();
|
|
280
|
+
}
|
|
281
|
+
assert.equal(phrased.ticks(), 8);
|
|
282
|
+
});
|
|
283
|
+
test("a caller's long error type is bounded wherever it is written", () => {
|
|
284
|
+
// classifyInferenceError bounds the types it reads; a caller that passes
|
|
285
|
+
// its own is bounded the same way.
|
|
286
|
+
const { fw, markers, logged, errs, restore } = makeHarness();
|
|
287
|
+
const type = `zz${'T'.repeat(MEGA)}`;
|
|
288
|
+
try {
|
|
289
|
+
fw.noteInferenceExhausted('cairn', 'zz-reason', false, type);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
restore();
|
|
293
|
+
}
|
|
294
|
+
assertCut(type, markers[0].meta.errorType, 128, 'marker metadata');
|
|
295
|
+
assertCut(type, logged[0].errorType, 128, 'failures.log');
|
|
296
|
+
assert.ok(markers[0].text.length < 1_000, `marker text is ${markers[0].text.length} chars`);
|
|
297
|
+
const line = errs.find((e) => e.startsWith('[inference-failed] agent=cairn'));
|
|
298
|
+
assert.ok(line.length < 400, `stderr line is ${line.length} chars`);
|
|
299
|
+
});
|
|
300
|
+
test('each excerpt states the whole length when it was cut, and only then', () => {
|
|
301
|
+
const { fw, markers, logged, restore } = makeHarness();
|
|
302
|
+
const reason = `zz-reason ${'r'.repeat(990)}`; // 1,000 chars: cut for the marker, whole in the logs
|
|
303
|
+
try {
|
|
304
|
+
fw.noteInferenceExhausted('cairn', reason, false, 'invalid_request');
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
restore();
|
|
308
|
+
}
|
|
309
|
+
assert.ok(markers[0].meta.reason.length <= 600);
|
|
310
|
+
assert.equal(markers[0].meta.reasonChars, 1_000);
|
|
311
|
+
assert.equal(logged[0].reason, reason);
|
|
312
|
+
assert.equal('reasonChars' in logged[0], false);
|
|
313
|
+
});
|
|
314
|
+
/** Every argument a failure log passed is a bounded string: no object for the
|
|
315
|
+
* runtime to inspect. The projection and the stack frames below it are each
|
|
316
|
+
* bounded to 2,000 characters. */
|
|
317
|
+
function assertBoundedStrings(rawArgs, marker) {
|
|
318
|
+
const call = rawArgs.find((args) => typeof args[0] === 'string' && args[0].includes(marker));
|
|
319
|
+
assert.ok(call, `a log line containing "${marker}"`);
|
|
320
|
+
for (const arg of call)
|
|
321
|
+
assert.equal(typeof arg, 'string', `${marker}: an argument was a ${typeof arg}`);
|
|
322
|
+
const line = call.join(' ');
|
|
323
|
+
assert.ok(line.length < 4_500, `${marker} line is ${line.length} chars`);
|
|
324
|
+
return line;
|
|
325
|
+
}
|
|
326
|
+
test('failure logs print a bounded projection, never the error object with its request', async () => {
|
|
327
|
+
// A short message with a huge request: Membrane's message cap does not help
|
|
328
|
+
// here; only not printing the object does.
|
|
329
|
+
const short = new MembraneError({
|
|
330
|
+
type: 'invalid_request', retryable: false, httpStatus: 400, providerErrorCode: 'zz_bad_request',
|
|
331
|
+
message: 'Bad request: zz-short', rawError: { status: 400 }, rawRequest: { system: echo() },
|
|
332
|
+
});
|
|
333
|
+
for (const error of [hugeProviderError(), short]) {
|
|
334
|
+
const kicked = makeHarness({ tick: async () => { throw error; } });
|
|
335
|
+
try {
|
|
336
|
+
kicked.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
337
|
+
await settle();
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
kicked.restore();
|
|
341
|
+
}
|
|
342
|
+
const kick = assertBoundedStrings(kicked.rawArgs, 'drain kick failed');
|
|
343
|
+
assert.match(kick, /MembraneError \(invalid_request, HTTP 400, zz_bad_request, retryable=false\): Bad request: zz-/);
|
|
344
|
+
// The frames follow the projection, so an internal throw still says where.
|
|
345
|
+
assert.match(kick, /\n {4}at .*inference-failure-bounds\.test/);
|
|
346
|
+
const refused = makeHarness({ addMessage: () => { throw error; } });
|
|
347
|
+
try {
|
|
348
|
+
refused.fw.noteInferenceExhausted('cairn', 'boom');
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
refused.restore();
|
|
352
|
+
}
|
|
353
|
+
assert.match(assertBoundedStrings(refused.rawArgs, 'could not record chronicle marker'), /\n {4}at .*inference-failure-bounds\.test/);
|
|
354
|
+
}
|
|
355
|
+
// A message's own lines can look like frames (an echoed request quoting a
|
|
356
|
+
// stack trace): the frames are the stack's, after the message, so the throw
|
|
357
|
+
// site is kept rather than spending the frames' bound on the message.
|
|
358
|
+
const quoting = new Error(`tool output:\n at zz-quoted (quoted.js:1:1)\n${echo()}`);
|
|
359
|
+
const quoted = makeHarness({ tick: async () => { throw quoting; } });
|
|
360
|
+
try {
|
|
361
|
+
quoted.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
362
|
+
await settle();
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
quoted.restore();
|
|
366
|
+
}
|
|
367
|
+
const quotedLine = assertBoundedStrings(quoted.rawArgs, 'drain kick failed');
|
|
368
|
+
assert.equal(quotedLine.split('zz-quoted').length - 1, 1, 'the quoted frame is in the projection only');
|
|
369
|
+
assert.match(quotedLine, /\n {4}at .*inference-failure-bounds\.test/);
|
|
370
|
+
// A stack without its header, as a copied one can be: the message's text
|
|
371
|
+
// inside a frame's name isn't taken for the header, so the throw site stays.
|
|
372
|
+
const requestHandler = () => new Error('request');
|
|
373
|
+
const headless = requestHandler();
|
|
374
|
+
headless.stack = headless.stack.split('\n').slice(1).join('\n');
|
|
375
|
+
const bare = makeHarness({ tick: async () => { throw headless; } });
|
|
376
|
+
try {
|
|
377
|
+
bare.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
378
|
+
await settle();
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
bare.restore();
|
|
382
|
+
}
|
|
383
|
+
assert.match(assertBoundedStrings(bare.rawArgs, 'drain kick failed'), /\n {4}at requestHandler /);
|
|
384
|
+
// Every component of the projection is bounded, the name and type included.
|
|
385
|
+
const named = new Error('zz-short');
|
|
386
|
+
named.name = `Zz${'N'.repeat(MEGA)}`;
|
|
387
|
+
named.type = `t${'T'.repeat(MEGA)}`;
|
|
388
|
+
const odd = makeHarness({ tick: async () => { throw named; } });
|
|
389
|
+
try {
|
|
390
|
+
odd.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
391
|
+
await settle();
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
odd.restore();
|
|
395
|
+
}
|
|
396
|
+
assertBoundedStrings(odd.rawArgs, 'drain kick failed');
|
|
397
|
+
});
|
|
398
|
+
/** The line a drain kick logs when the context manager's tick throws `thrown`. */
|
|
399
|
+
async function kickedWith(thrown) {
|
|
400
|
+
const kicked = makeHarness({ tick: async () => { throw thrown; } });
|
|
401
|
+
try {
|
|
402
|
+
kicked.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
403
|
+
await settle();
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
kicked.restore();
|
|
407
|
+
}
|
|
408
|
+
return assertBoundedStrings(kicked.rawArgs, 'drain kick failed');
|
|
409
|
+
}
|
|
410
|
+
test("a thrown value that isn't an Error logs its own text, or its JSON when its text is only a tag", async () => {
|
|
411
|
+
const prefix = '[inference-failed] drain kick failed for cairn after 0 ticks: ';
|
|
412
|
+
class Refusal {
|
|
413
|
+
#why;
|
|
414
|
+
constructor(why) { this.#why = why; }
|
|
415
|
+
toString() { return `refused: ${this.#why}`; }
|
|
416
|
+
}
|
|
417
|
+
class Payload {
|
|
418
|
+
code = 'zz_code';
|
|
419
|
+
detail = 'zz-detail';
|
|
420
|
+
}
|
|
421
|
+
const cyclic = { zz: 1 };
|
|
422
|
+
cyclic.self = cyclic;
|
|
423
|
+
const cases = [
|
|
424
|
+
['a class with its own toString', new Refusal('zz-quota'), 'refused: zz-quota'],
|
|
425
|
+
['a Buffer', Buffer.from('zz-buffer text'), 'zz-buffer text'],
|
|
426
|
+
['an Error from another realm', vm.runInNewContext("new Error('zz-realm')"), 'Error: zz-realm'],
|
|
427
|
+
['a plain object', { code: 'zz_plain', count: 1 }, '{"code":"zz_plain","count":1}'],
|
|
428
|
+
['a class instance without toString', new Payload(), '{"code":"zz_code","detail":"zz-detail"}'],
|
|
429
|
+
// An array's String joins its elements' Strings; its JSON keeps them.
|
|
430
|
+
['an array', [{ code: 'zz_a' }, new Error('zz-item')], '[{"code":"zz_a"},"Error: zz-item"]'],
|
|
431
|
+
['an empty array', [], '[]'],
|
|
432
|
+
['an object with no String of its own', Object.assign(Object.create(null), { code: 'zz_bare' }), '{"code":"zz_bare"}'],
|
|
433
|
+
['an object JSON cannot write', cyclic, '[object Object]'],
|
|
434
|
+
['an object with no fields but its tag', { [Symbol.toStringTag]: 'ZzTagged' }, '[object ZzTagged]'],
|
|
435
|
+
['an object whose String is empty', { toString: () => '' }, '[object Object]'],
|
|
436
|
+
['an object with neither a String nor fields', Object.create(null), '[object Object]'],
|
|
437
|
+
['an object whose JSON is nothing', { toJSON: () => undefined }, '[object Object]'],
|
|
438
|
+
];
|
|
439
|
+
for (const [what, thrown, logged] of cases) {
|
|
440
|
+
assert.equal(await kickedWith(thrown), `${prefix}${logged}`, what);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
test("a failure's frames: cut to their bound, and from the stack's first frame line when the message isn't its header", async () => {
|
|
444
|
+
const prefix = '[inference-failed] drain kick failed for cairn after 0 ticks: ';
|
|
445
|
+
const framesOf = (stack) => stack.slice(stack.search(/^[ \t]+at /m));
|
|
446
|
+
// A stack far past the bound: its frames are cut by the one rule.
|
|
447
|
+
const deep = new Error('zz-deep');
|
|
448
|
+
const deepStack = `Error: zz-deep\n${Array.from({ length: 1_000 }, (_, i) => ` at zzFrame${i} (deep.js:${i + 1}:1)`).join('\n')}`;
|
|
449
|
+
deep.stack = deepStack;
|
|
450
|
+
const deepLine = await kickedWith(deep);
|
|
451
|
+
const deepHead = `${prefix}Error: zz-deep\n`;
|
|
452
|
+
assert.ok(deepLine.startsWith(deepHead));
|
|
453
|
+
assertCut(framesOf(deepStack), deepLine.slice(deepHead.length), 2_000, 'the frames of a deep stack');
|
|
454
|
+
// A message that isn't in the stack (replaced after the stack was taken),
|
|
455
|
+
// and an empty message: the frames are the stack's from its first frame line.
|
|
456
|
+
const replaced = new Error('zz-original');
|
|
457
|
+
const replacedStack = 'Error: zz-original\n at zzThrower (replaced.js:1:1)\n at zzCaller (replaced.js:2:1)';
|
|
458
|
+
replaced.stack = replacedStack;
|
|
459
|
+
replaced.message = `zz-replaced ${'r'.repeat(300)}`;
|
|
460
|
+
assert.equal(await kickedWith(replaced), `${prefix}Error: ${replaced.message}\n${framesOf(replacedStack)}`);
|
|
461
|
+
const empty = new Error('');
|
|
462
|
+
empty.stack = 'Error\n at zzThrower (empty.js:1:1)';
|
|
463
|
+
assert.equal(await kickedWith(empty), `${prefix}Error: \n at zzThrower (empty.js:1:1)`);
|
|
464
|
+
// A message that ends in blank lines: the frames start at the frame line,
|
|
465
|
+
// since a frame's indent is spaces or tabs and never crosses a line break
|
|
466
|
+
// (an indent that did made a run of blank lines quadratic to scan).
|
|
467
|
+
const blank = new Error('zz-blank\n\n');
|
|
468
|
+
blank.stack = 'Error: zz-blank\n\n\n at zzThrower (blank.js:1:1)';
|
|
469
|
+
assert.equal(await kickedWith(blank), `${prefix}Error: zz-blank\n\n\n at zzThrower (blank.js:1:1)`);
|
|
470
|
+
});
|
|
471
|
+
test('a thrown non-Error, and an error whose projection throws, still log bounded lines', async () => {
|
|
472
|
+
const thrownString = makeHarness({ tick: async () => { throw `socket said: ${echo()}`; } });
|
|
473
|
+
try {
|
|
474
|
+
thrownString.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
475
|
+
await settle();
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
thrownString.restore();
|
|
479
|
+
}
|
|
480
|
+
const line = assertBoundedStrings(thrownString.rawArgs, 'drain kick failed');
|
|
481
|
+
assert.match(line, /after 0 ticks: socket said: zz-head e+ …\[\d+ of \d+ characters omitted\]… e+ zz-tail$/);
|
|
482
|
+
// A name getter that throws breaks the projection and the stack alike.
|
|
483
|
+
const hostile = new Error('zz-short');
|
|
484
|
+
Object.defineProperty(hostile, 'name', { get() { throw new Error('zz-no-name'); } });
|
|
485
|
+
const unprintable = makeHarness({ tick: async () => { throw hostile; } });
|
|
486
|
+
try {
|
|
487
|
+
unprintable.fw.noteInferenceExhausted('cairn', 'Context would exceed hard budget', undefined, 'over_budget');
|
|
488
|
+
await settle();
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
unprintable.restore();
|
|
492
|
+
}
|
|
493
|
+
assert.match(assertBoundedStrings(unprintable.rawArgs, 'drain kick failed'), /after 0 ticks: \[failure could not be described\]$/);
|
|
494
|
+
});
|
|
495
|
+
test('a provider hold keeps a bounded reason, and a failed resume logs the bounded projection', async () => {
|
|
496
|
+
const providerState = (fw) => Object.assign(fw, {
|
|
497
|
+
ephemeralRuns: new Map(), conversationAgentHomes: new Map(), providerGates: new Map(),
|
|
498
|
+
providerAccelerationCooldowns: new Map(), providerAccelerationRecoveries: new Map(),
|
|
499
|
+
providerAdmissionClosed: false, activeTurnTokens: new Map(), quiesced: false,
|
|
500
|
+
});
|
|
501
|
+
const held = makeHarness();
|
|
502
|
+
providerState(held.fw);
|
|
503
|
+
const agent = held.fw.agents.get('cairn');
|
|
504
|
+
try {
|
|
505
|
+
// A host hold with no reason of its own: the error's message stands in.
|
|
506
|
+
held.fw.providerHoldHook = () => ({ holdMs: 60_000 });
|
|
507
|
+
assert.equal(held.fw.holdProviderAcceleration(agent, hugeProviderError()), true);
|
|
508
|
+
const cooldown = held.fw.providerAccelerationCooldowns.get('cairn');
|
|
509
|
+
assert.ok(cooldown.reason.length <= 2_000, `cooldown reason is ${cooldown.reason.length} chars`);
|
|
510
|
+
assert.match(cooldown.reason, /^Bad request: zz-head/);
|
|
511
|
+
assert.ok(held.fw.providerAccelerationRecoveries.get('cairn').reason.length <= 2_000);
|
|
512
|
+
const holdLine = held.errs.find((e) => e.startsWith('[provider-cooldown] agent=cairn host hold ('));
|
|
513
|
+
assert.ok(holdLine && holdLine.length < 2_300, `hold line is ${holdLine?.length} chars`);
|
|
514
|
+
// A reason of the host's own is bounded too, here when a slice is extended.
|
|
515
|
+
held.fw.providerHoldHook = () => ({ holdMs: 60_000, reason: `host says ${echo()}` });
|
|
516
|
+
assert.equal(held.fw.extendHostProviderHold('cairn', cooldown), true);
|
|
517
|
+
assert.ok(cooldown.reason.length <= 2_000);
|
|
518
|
+
assert.match(cooldown.reason, /^host says zz-head/);
|
|
519
|
+
const extended = held.errs.find((e) => e.includes('host hold extended'));
|
|
520
|
+
assert.ok(extended && extended.length < 2_300, `extension line is ${extended?.length} chars`);
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
held.fw.cancelProviderAdmission();
|
|
524
|
+
held.restore();
|
|
525
|
+
}
|
|
526
|
+
// A hook is handed the model's error, and one that throws it back logs its
|
|
527
|
+
// projection, never the object with its request.
|
|
528
|
+
const threw = makeHarness();
|
|
529
|
+
providerState(threw.fw);
|
|
530
|
+
try {
|
|
531
|
+
threw.fw.providerHoldHook = (error) => { throw error; };
|
|
532
|
+
assert.equal(threw.fw.holdProviderAcceleration(threw.fw.agents.get('cairn'), hugeProviderError()), false);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
threw.restore();
|
|
536
|
+
}
|
|
537
|
+
const threwLine = assertBoundedStrings(threw.rawArgs, 'providerHold hook threw');
|
|
538
|
+
assert.match(threwLine, /treating as no hold: MembraneError \(invalid_request, HTTP 400, zz_bad_request, retryable=false\): Bad request: zz-head/);
|
|
539
|
+
assert.match(threwLine, /\n {4}at /);
|
|
540
|
+
// One that throws a plain object logs its JSON, bounded, so the fields that
|
|
541
|
+
// printing the object showed are kept.
|
|
542
|
+
const thrown = { code: 'zz_hold', detail: echo() };
|
|
543
|
+
const plain = makeHarness();
|
|
544
|
+
providerState(plain.fw);
|
|
545
|
+
try {
|
|
546
|
+
plain.fw.providerHoldHook = () => { throw thrown; };
|
|
547
|
+
assert.equal(plain.fw.holdProviderAcceleration(plain.fw.agents.get('cairn'), hugeProviderError()), false);
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
plain.restore();
|
|
551
|
+
}
|
|
552
|
+
const plainLine = assertBoundedStrings(plain.rawArgs, 'providerHold hook threw');
|
|
553
|
+
const noHold = 'treating as no hold: ';
|
|
554
|
+
assertCut(JSON.stringify(thrown), plainLine.slice(plainLine.indexOf(noHold) + noHold.length), 2_000, 'a thrown plain object');
|
|
555
|
+
// The built-in acceleration cooldown keeps the error's own message as its reason.
|
|
556
|
+
const accelerated = makeHarness();
|
|
557
|
+
providerState(accelerated.fw);
|
|
558
|
+
Object.assign(accelerated.fw, { providerAccelerationDefaultCooldownMs: 30_000, providerAccelerationJitterMs: 0 });
|
|
559
|
+
try {
|
|
560
|
+
const limited = new MembraneError({
|
|
561
|
+
type: 'rate_limit', retryable: true, httpStatus: 429, rawError: { status: 429 },
|
|
562
|
+
message: `Your organization has hit its acceleration limit. ${echo()}`,
|
|
563
|
+
});
|
|
564
|
+
assert.equal(accelerated.fw.holdProviderAcceleration(accelerated.fw.agents.get('cairn'), limited), true);
|
|
565
|
+
const reason = accelerated.fw.providerAccelerationCooldowns.get('cairn').reason;
|
|
566
|
+
assert.ok(reason.length <= 2_000, `acceleration cooldown reason is ${reason.length} chars`);
|
|
567
|
+
assert.match(reason, /^Your organization has hit its acceleration limit\. zz-head/);
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
accelerated.fw.cancelProviderAdmission();
|
|
571
|
+
accelerated.restore();
|
|
572
|
+
}
|
|
573
|
+
// Resuming the primary after the auxiliary call drains, and failing.
|
|
574
|
+
const resumed = makeHarness();
|
|
575
|
+
providerState(resumed.fw);
|
|
576
|
+
resumed.fw.providerGates.set('cairn', {
|
|
577
|
+
primaryDepth: 0, primaryPending: false, auxiliaryInFlight: 1, auxiliaryWaiters: [], idleWaiters: [], deferredAuxiliary: 0,
|
|
578
|
+
});
|
|
579
|
+
resumed.fw.waitForAuxiliaryIdle = async () => { };
|
|
580
|
+
resumed.fw.startAgentStream = async () => { throw hugeProviderError(); };
|
|
581
|
+
try {
|
|
582
|
+
await FrameworkClass.prototype.startAgentStream.call(resumed.fw, resumed.fw.agents.get('cairn'));
|
|
583
|
+
await settle();
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
resumed.restore();
|
|
587
|
+
}
|
|
588
|
+
const line = assertBoundedStrings(resumed.rawArgs, 'failed to resume primary');
|
|
589
|
+
assert.match(line, /failed to resume primary for cairn: MembraneError \(invalid_request, HTTP 400, zz_bad_request, retryable=false\): Bad request: zz-head/);
|
|
590
|
+
assert.match(line, /\n {4}at /);
|
|
591
|
+
});
|
|
592
|
+
/** Runs `body` with the ops webhook set and fetch stubbed; returns what it posted. */
|
|
593
|
+
async function withWebhook(body) {
|
|
594
|
+
const hook = process.env.CONNECTOME_OPS_WEBHOOK;
|
|
595
|
+
const realFetch = globalThis.fetch;
|
|
596
|
+
const posted = [];
|
|
597
|
+
process.env.CONNECTOME_OPS_WEBHOOK = 'http://zz.invalid/hook';
|
|
598
|
+
globalThis.fetch = (async (_url, init) => {
|
|
599
|
+
posted.push(JSON.parse(String(init?.body)).content);
|
|
600
|
+
return new Response(null);
|
|
601
|
+
});
|
|
602
|
+
try {
|
|
603
|
+
await body();
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
globalThis.fetch = realFetch;
|
|
607
|
+
if (hook === undefined)
|
|
608
|
+
delete process.env.CONNECTOME_OPS_WEBHOOK;
|
|
609
|
+
else
|
|
610
|
+
process.env.CONNECTOME_OPS_WEBHOOK = hook;
|
|
611
|
+
}
|
|
612
|
+
return posted;
|
|
613
|
+
}
|
|
614
|
+
test('a failed maintenance pass logs and alerts a bounded reason', async () => {
|
|
615
|
+
const { fw, traces, rawArgs, restore } = makeHarness();
|
|
616
|
+
fw.running = true;
|
|
617
|
+
fw.maintenancePass = null;
|
|
618
|
+
const error = hugeProviderError();
|
|
619
|
+
fw.runQueuedMaintenance = async () => { throw error; };
|
|
620
|
+
const posted = await withWebhook(async () => {
|
|
621
|
+
try {
|
|
622
|
+
fw.startQueuedMaintenance();
|
|
623
|
+
await settle();
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
restore();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
assert.match(assertBoundedStrings(rawArgs, '[context-maintenance] pass failed:'), /pass failed: Bad request: zz-head/);
|
|
630
|
+
const alert = traces.find((t) => t.type === 'ops:alert' && t.kind === 'context-maintenance-failed');
|
|
631
|
+
assert.ok(alert && alert.message.length <= 2_000);
|
|
632
|
+
assert.deepEqual(alert.data, { scope: 'pass' });
|
|
633
|
+
// The webhook's copy is cut from the whole message, so it states its length.
|
|
634
|
+
const webhook = '\u{1F6A8} **framework** context-maintenance-failed: ';
|
|
635
|
+
assert.ok(posted[0]?.startsWith(webhook), posted[0]?.slice(0, 80));
|
|
636
|
+
assertCut(error.message, posted[0].slice(webhook.length), 500, 'maintenance webhook');
|
|
637
|
+
// A thrown value that isn't an Error is described as the other logs
|
|
638
|
+
// describe one, with no String() to throw on it.
|
|
639
|
+
const bare = makeHarness();
|
|
640
|
+
bare.fw.running = true;
|
|
641
|
+
bare.fw.maintenancePass = null;
|
|
642
|
+
bare.fw.runQueuedMaintenance = async () => { throw Object.assign(Object.create(null), { code: 'zz_bare' }); };
|
|
643
|
+
try {
|
|
644
|
+
bare.fw.startQueuedMaintenance();
|
|
645
|
+
await settle();
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
bare.restore();
|
|
649
|
+
}
|
|
650
|
+
assert.match(assertBoundedStrings(bare.rawArgs, '[context-maintenance] pass failed:'), /pass failed: \{"code":"zz_bare"\}$/);
|
|
651
|
+
});
|
|
652
|
+
test('emitTrace: listeners get bounded error and stack; the marker still learns the full length', () => {
|
|
653
|
+
const { fw, traces, markers, restore } = makeHarness();
|
|
654
|
+
const error = `Bad request: ${echo()}`;
|
|
655
|
+
try {
|
|
656
|
+
fw.emitTrace({ type: 'inference:exhausted', agentName: 'cairn', error, retryable: false, errorType: 'invalid_request', httpStatus: 400, providerErrorCode: 'zz_bad_request' });
|
|
657
|
+
fw.emitTrace({ type: 'inference:failed', agentName: 'cairn', error, stack: `Error: ${error}\n at zz (zz.js:1:1)` });
|
|
658
|
+
// Any event's error string, including failure events added later.
|
|
659
|
+
fw.emitTrace({ type: 'module:batch_hook_failed', agentName: 'cairn', module: 'zz', error });
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
restore();
|
|
663
|
+
}
|
|
664
|
+
const failureEvents = traces.filter((t) => typeof t.error === 'string');
|
|
665
|
+
assert.equal(failureEvents.length, 3);
|
|
666
|
+
for (const event of failureEvents) {
|
|
667
|
+
assert.ok(event.error.length <= 2_000, `${event.type} error is ${event.error.length} chars`);
|
|
668
|
+
if (event.stack)
|
|
669
|
+
assert.ok(event.stack.length <= 2_000);
|
|
670
|
+
}
|
|
671
|
+
assert.equal(markers[0].meta.reasonChars, error.length);
|
|
672
|
+
assert.equal(markers[0].meta.httpStatus, 400);
|
|
673
|
+
assert.equal(markers[0].meta.providerErrorCode, 'zz_bad_request');
|
|
674
|
+
});
|
|
675
|
+
test('opsAlert bounds its message on the trace and the failures.log record', () => {
|
|
676
|
+
const { fw, traces, logged, restore } = makeHarness();
|
|
677
|
+
try {
|
|
678
|
+
fw.opsAlert('hard-down', 'cairn', `3 consecutive inference failures. Last reason: ${echo()}`);
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
restore();
|
|
682
|
+
}
|
|
683
|
+
const alert = traces.find((t) => t.type === 'ops:alert');
|
|
684
|
+
assert.ok(alert.message.length <= 2_000);
|
|
685
|
+
assert.ok(logged[0].reason.length <= 2_000);
|
|
686
|
+
});
|
|
687
|
+
test('alert data of null, from a JavaScript caller, is no data: the alert is still logged and traced', () => {
|
|
688
|
+
const { fw, traces, logged, restore } = makeHarness();
|
|
689
|
+
try {
|
|
690
|
+
fw.notifyOpsAlert('zz-detector', 'cairn', 'short message', null);
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
restore();
|
|
694
|
+
}
|
|
695
|
+
assert.equal(traces.find((t) => t.type === 'ops:alert')?.message, 'short message');
|
|
696
|
+
assert.deepEqual(logged, [{ agent: 'cairn', kind: 'zz-detector', reason: 'short message' }]);
|
|
697
|
+
});
|
|
698
|
+
test('an alert message that is not a string, from a JavaScript caller, is described as a thrown value is', async () => {
|
|
699
|
+
const { fw, traces, logged, restore } = makeHarness();
|
|
700
|
+
const posted = await withWebhook(() => {
|
|
701
|
+
try {
|
|
702
|
+
fw.notifyOpsAlert('zz-untyped', 'cairn', undefined, { count: 1 });
|
|
703
|
+
// One with no String of its own, which String() throws on.
|
|
704
|
+
fw.notifyOpsAlert('zz-bare', 'cairn', Object.assign(Object.create(null), { code: 'zz_bare' }));
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
restore();
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
assert.deepEqual(traces.filter((t) => t.type === 'ops:alert').map((t) => t.message), ['undefined', '{"code":"zz_bare"}']);
|
|
711
|
+
assert.deepEqual(logged, [
|
|
712
|
+
{ agent: 'cairn', kind: 'zz-untyped', reason: 'undefined', count: 1 },
|
|
713
|
+
{ agent: 'cairn', kind: 'zz-bare', reason: '{"code":"zz_bare"}' },
|
|
714
|
+
]);
|
|
715
|
+
assert.deepEqual(posted, ['\u{1F6A8} **cairn** zz-untyped: undefined', '\u{1F6A8} **cairn** zz-bare: {"code":"zz_bare"}']);
|
|
716
|
+
});
|
|
717
|
+
test('alert data that is not a plain object is one bounded field, not a field per character or element', () => {
|
|
718
|
+
const { fw, traces, logged, restore } = makeHarness();
|
|
719
|
+
const text = echo(10_000);
|
|
720
|
+
const list = Array.from({ length: 2_000 }, (_, i) => i);
|
|
721
|
+
try {
|
|
722
|
+
// From a JavaScript caller: bounded field by field, a string or an array
|
|
723
|
+
// would spread into a field per character or element.
|
|
724
|
+
fw.notifyOpsAlert('zz-string', 'cairn', 'short message', text);
|
|
725
|
+
fw.notifyOpsAlert('zz-array', 'cairn', 'short message', list);
|
|
726
|
+
fw.notifyOpsAlert('zz-small', 'cairn', 'short message', [1, 2]);
|
|
727
|
+
// Only a plain object's fields are spread: a class instance is kept whole
|
|
728
|
+
// as its JSON, and an object with a null prototype is a plain record.
|
|
729
|
+
fw.notifyOpsAlert('zz-instance', 'cairn', 'short message', new (class ZzPayload {
|
|
730
|
+
channel = 'zz-channel';
|
|
731
|
+
})());
|
|
732
|
+
fw.notifyOpsAlert('zz-record', 'cairn', 'short message', Object.assign(Object.create(null), { channel: 'zz-channel' }));
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
restore();
|
|
736
|
+
}
|
|
737
|
+
const [stringAlert, arrayAlert, smallAlert, instanceAlert, recordAlert] = traces.filter((t) => t.type === 'ops:alert');
|
|
738
|
+
assert.deepEqual(instanceAlert.data, { data: { channel: 'zz-channel' } });
|
|
739
|
+
assert.deepEqual(logged[3], { agent: 'cairn', kind: 'zz-instance', reason: 'short message', data: { channel: 'zz-channel' } });
|
|
740
|
+
assert.deepEqual({ ...recordAlert.data }, { channel: 'zz-channel' });
|
|
741
|
+
assert.deepEqual(logged[4], { agent: 'cairn', kind: 'zz-record', reason: 'short message', channel: 'zz-channel' });
|
|
742
|
+
for (const [sink, fields] of [['trace', stringAlert.data], ['failures.log', logged[0]]]) {
|
|
743
|
+
assert.equal(Object.keys(fields).length, sink === 'trace' ? 1 : 4, `${sink}: string data's fields`);
|
|
744
|
+
assertCut(text, fields.data, 2_000, `${sink}: string data`);
|
|
745
|
+
}
|
|
746
|
+
for (const [sink, fields] of [['trace', arrayAlert.data], ['failures.log', logged[1]]]) {
|
|
747
|
+
assert.equal(Object.keys(fields).length, sink === 'trace' ? 1 : 4, `${sink}: array data's fields`);
|
|
748
|
+
assertCut(JSON.stringify(list), fields.data, 2_000, `${sink}: array data`);
|
|
749
|
+
}
|
|
750
|
+
assert.deepEqual(smallAlert.data, { data: [1, 2] });
|
|
751
|
+
assert.deepEqual(logged[2], { agent: 'cairn', kind: 'zz-small', reason: 'short message', data: [1, 2] });
|
|
752
|
+
});
|
|
753
|
+
/** The projection's head for hugeProviderError(), before ': ' and the message. */
|
|
754
|
+
const HUGE_ERROR_HEAD = 'MembraneError (invalid_request, HTTP 400, zz_bad_request, retryable=false)';
|
|
755
|
+
test('alert data is bounded field by field, on the trace and the failures.log record', () => {
|
|
756
|
+
const { fw, traces, logged, restore } = makeHarness();
|
|
757
|
+
const nested = { items: Array.from({ length: 2_000 }, (_, i) => ({ i, note: 'zz' })) };
|
|
758
|
+
const text = echo();
|
|
759
|
+
const error = hugeProviderError();
|
|
760
|
+
try {
|
|
761
|
+
fw.notifyOpsAlert('zz-detector', 'cairn', 'short message', {
|
|
762
|
+
text, error, nested, count: 3, small: { a: 1 },
|
|
763
|
+
// Small JSON, large value: the listener gets the JSON's data, not the value.
|
|
764
|
+
held: new Map([['zz', echo()]]), at: new Date(0),
|
|
765
|
+
// An Error inside a value is its projection too, in an array as well.
|
|
766
|
+
wrapped: { cause: new Error('zz-small'), list: [new Error('zz-item')] },
|
|
767
|
+
// A value JSON writes nothing for is left out, as the log's JSON leaves it out.
|
|
768
|
+
callback: () => 'zz',
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
restore();
|
|
773
|
+
}
|
|
774
|
+
const alert = traces.find((t) => t.type === 'ops:alert');
|
|
775
|
+
for (const [sink, fields] of [['trace', alert.data], ['failures.log', logged[0]]]) {
|
|
776
|
+
assertCut(text, fields.text, 2_000, `${sink}: text`);
|
|
777
|
+
// An Error is its projection: serialising it would write its rawRequest.
|
|
778
|
+
// It is cut once, so its marker states the head and the whole message.
|
|
779
|
+
assertCut(`${HUGE_ERROR_HEAD}: ${error.message}`, fields.error, 2_000, `${sink}: error`);
|
|
780
|
+
assert.deepEqual(fields.wrapped, { cause: 'Error: zz-small', list: ['Error: zz-item'] }, `${sink}: nested Errors`);
|
|
781
|
+
assert.equal('callback' in fields, false, `${sink}: a function is left out`);
|
|
782
|
+
// A value whose JSON is too long becomes its JSON, bounded.
|
|
783
|
+
assert.equal(typeof fields.nested, 'string', `${sink}: nested`);
|
|
784
|
+
assert.ok(fields.nested.length <= 2_000 && fields.nested.startsWith('{"items":[{"i":0,'), `${sink}: nested`);
|
|
785
|
+
// Small values are what their JSON describes.
|
|
786
|
+
assert.equal(fields.count, 3);
|
|
787
|
+
assert.deepEqual(fields.small, { a: 1 });
|
|
788
|
+
assert.deepEqual(fields.held, {}, `${sink}: a Map is its JSON's data`);
|
|
789
|
+
assert.equal(fields.at, '1970-01-01T00:00:00.000Z', `${sink}: a Date is its JSON's data`);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
test('logFailure bounds every field it writes', () => {
|
|
793
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-failure-bounds-'));
|
|
794
|
+
const cwd = process.cwd();
|
|
795
|
+
process.chdir(dir);
|
|
796
|
+
try {
|
|
797
|
+
const fw = Object.create(FrameworkClass.prototype);
|
|
798
|
+
const circular = { zz: 1 };
|
|
799
|
+
circular.self = circular;
|
|
800
|
+
const reason = echo();
|
|
801
|
+
const note = `zz-note ${echo(5_000)}`;
|
|
802
|
+
const error = hugeProviderError();
|
|
803
|
+
fw.logFailure({
|
|
804
|
+
agent: 'cairn', reason, note, consecutive: 1,
|
|
805
|
+
error, nested: { list: Array.from({ length: 2_000 }, (_, i) => i) }, circular,
|
|
806
|
+
wrapped: { cause: error },
|
|
807
|
+
});
|
|
808
|
+
const line = readFileSync(join(dir, 'logs', 'failures.log'), 'utf-8').trim();
|
|
809
|
+
assert.ok(line.length < 11_000, `failures.log line is ${line.length} chars`);
|
|
810
|
+
const record = JSON.parse(line);
|
|
811
|
+
// The written record, not what a caller passed: each string is cut by the one rule.
|
|
812
|
+
assertCut(reason, record.reason, 2_000, 'failures.log reason');
|
|
813
|
+
assertCut(note, record.note, 2_000, 'failures.log note');
|
|
814
|
+
assert.equal(record.consecutive, 1);
|
|
815
|
+
assertCut(`${HUGE_ERROR_HEAD}: ${error.message}`, record.error, 2_000, 'failures.log error');
|
|
816
|
+
assert.ok(typeof record.nested === 'string' && record.nested.length <= 2_000);
|
|
817
|
+
// An Error inside a value is written as its whole projection, not its own
|
|
818
|
+
// fields with the request, and the value's JSON is cut once.
|
|
819
|
+
assertCut(JSON.stringify({ cause: `${HUGE_ERROR_HEAD}: ${error.message}` }), record.wrapped, 2_000, 'failures.log wrapped');
|
|
820
|
+
// A value JSON can't write no longer loses the whole record.
|
|
821
|
+
assert.equal(record.circular, '[object Object]');
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
process.chdir(cwd);
|
|
825
|
+
rmSync(dir, { recursive: true, force: true });
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// The real failure paths, on a real framework, store and context manager.
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
class InputModule {
|
|
832
|
+
name = 'input';
|
|
833
|
+
async start(_ctx) { }
|
|
834
|
+
async stop() { }
|
|
835
|
+
getTools() { return []; }
|
|
836
|
+
async handleToolCall(_call) { return { success: false, isError: true, error: 'none' }; }
|
|
837
|
+
async onProcess(event, _state) {
|
|
838
|
+
if (event.type !== 'external-message')
|
|
839
|
+
return {};
|
|
840
|
+
return { addMessages: [{ participant: 'User', content: [{ type: 'text', text: String(event.content) }] }], requestInference: true };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
class ErrorEventStream {
|
|
844
|
+
error;
|
|
845
|
+
isWaitingForTools = false;
|
|
846
|
+
pendingToolCallIds = [];
|
|
847
|
+
toolDepth = 0;
|
|
848
|
+
isCancelled = false;
|
|
849
|
+
constructor(error) {
|
|
850
|
+
this.error = error;
|
|
851
|
+
}
|
|
852
|
+
provideToolResults() { throw new Error('not waiting'); }
|
|
853
|
+
cancel() { this.isCancelled = true; }
|
|
854
|
+
async *[Symbol.asyncIterator]() {
|
|
855
|
+
yield { type: 'error', error: this.error };
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
class ThrowingStream extends ErrorEventStream {
|
|
859
|
+
thrown;
|
|
860
|
+
constructor(thrown) {
|
|
861
|
+
super(thrown);
|
|
862
|
+
this.thrown = thrown;
|
|
863
|
+
}
|
|
864
|
+
async *[Symbol.asyncIterator]() {
|
|
865
|
+
throw this.thrown;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
class FailingMembrane extends MockMembrane {
|
|
869
|
+
path;
|
|
870
|
+
make;
|
|
871
|
+
constructor(path, make) {
|
|
872
|
+
super();
|
|
873
|
+
this.path = path;
|
|
874
|
+
this.make = make;
|
|
875
|
+
}
|
|
876
|
+
streamYielding(request) {
|
|
877
|
+
this.calls.push(request);
|
|
878
|
+
const error = this.make(request);
|
|
879
|
+
if (this.path === 'stream setup throws')
|
|
880
|
+
throw error;
|
|
881
|
+
return this.path === 'stream throws' ? new ThrowingStream(error) : new ErrorEventStream(error);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** A console.error spy that keeps the arguments as passed: printing an object
|
|
885
|
+
* (rather than a string built from it) is what wrote a MembraneError's whole
|
|
886
|
+
* rawRequest to stderr, and String() of the argument would hide it. */
|
|
887
|
+
function spyConsoleError() {
|
|
888
|
+
const errs = [];
|
|
889
|
+
const rawArgs = [];
|
|
890
|
+
const orig = console.error;
|
|
891
|
+
console.error = (...a) => { rawArgs.push(a); errs.push(a.map(String).join(' ')); };
|
|
892
|
+
return { errs, rawArgs, restore: () => { console.error = orig; } };
|
|
893
|
+
}
|
|
894
|
+
/** No failure log printed an object: every argument of every line was a string. */
|
|
895
|
+
function assertNoObjectPrinted(rawArgs) {
|
|
896
|
+
for (const args of rawArgs) {
|
|
897
|
+
for (const arg of args)
|
|
898
|
+
assert.equal(typeof arg, 'string', `${String(args[0]).slice(0, 80)}: an argument was a ${typeof arg}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/** Never retries, so a test reaches exhaustion on the first failure. */
|
|
902
|
+
const noRetry = { maxRetries: 0, onInferenceError: () => ({ retry: false }) };
|
|
903
|
+
async function runFailingTurn(path, make, opts) {
|
|
904
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-failure-bounds-'));
|
|
905
|
+
const cwd = process.cwd();
|
|
906
|
+
process.chdir(dir);
|
|
907
|
+
const { errs, rawArgs, restore } = spyConsoleError();
|
|
908
|
+
const membrane = new FailingMembrane(path, make);
|
|
909
|
+
const framework = await AgentFramework.create({
|
|
910
|
+
storePath: join(dir, 'store.chronicle'), membrane: membrane.asMembrane(),
|
|
911
|
+
agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'system' }],
|
|
912
|
+
modules: [new InputModule()], syncIntervalMs: 0, maintenanceIntervalMs: 0,
|
|
913
|
+
...(opts?.errorPolicy ? { errorPolicy: opts.errorPolicy } : {}),
|
|
914
|
+
});
|
|
915
|
+
const traces = [];
|
|
916
|
+
framework.onTrace((e) => traces.push(e));
|
|
917
|
+
try {
|
|
918
|
+
for (let turn = 0; turn < (opts?.turns ?? 1); turn++) {
|
|
919
|
+
framework.pushEvent({ type: 'external-message', source: 'test', content: `hello ${turn}`, metadata: {} });
|
|
920
|
+
await framework.runUntilIdle();
|
|
921
|
+
}
|
|
922
|
+
const messages = framework.getAgent('resident').getContextManager().queryMessages({}).messages;
|
|
923
|
+
const markers = messages.filter((m) => m.metadata?.kind === 'inference-failed');
|
|
924
|
+
const failuresLog = existsSync(join(dir, 'logs', 'failures.log')) ? readFileSync(join(dir, 'logs', 'failures.log'), 'utf-8') : '';
|
|
925
|
+
const inferenceLogs = framework.queryInferenceLogs({}).entries;
|
|
926
|
+
return { messages, markers, traces, errs, rawArgs, failuresLog, inferenceLogs, calls: membrane.calls.length };
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
await framework.stop();
|
|
930
|
+
restore();
|
|
931
|
+
process.chdir(cwd);
|
|
932
|
+
rmSync(dir, { recursive: true, force: true });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (const path of ['stream error event', 'stream throws', 'stream setup throws']) {
|
|
936
|
+
test(`${path}: a ~1 MB provider error leaves a bounded, classified trail`, async () => {
|
|
937
|
+
const { markers, traces, errs, rawArgs, failuresLog, inferenceLogs } = await runFailingTurn(path, (request) => hugeProviderError(request));
|
|
938
|
+
assertNoObjectPrinted(rawArgs);
|
|
939
|
+
assert.equal(markers.length, 1, 'one failure marker');
|
|
940
|
+
const text = markers[0].content[0].text;
|
|
941
|
+
assert.ok(text.length < 1_200, `marker is ${text.length} chars`);
|
|
942
|
+
assert.match(text, /model call failed \(invalid_request, HTTP 400\)/);
|
|
943
|
+
const meta = markers[0].metadata;
|
|
944
|
+
assert.equal(meta.errorType, 'invalid_request');
|
|
945
|
+
assert.equal(meta.httpStatus, 400);
|
|
946
|
+
assert.ok(meta.reasonChars > MEGA);
|
|
947
|
+
for (const event of traces.filter((t) => typeof t.error === 'string')) {
|
|
948
|
+
assert.ok(event.error.length <= 2_000, `${event.type} error is ${event.error.length} chars`);
|
|
949
|
+
}
|
|
950
|
+
assert.ok(traces.some((t) => t.type === 'inference:exhausted' && t.httpStatus === 400 && t.providerErrorCode === 'zz_bad_request'));
|
|
951
|
+
for (const line of failuresLog.trim().split('\n').filter(Boolean)) {
|
|
952
|
+
assert.ok(line.length < 4_500, `failures.log line is ${line.length} chars`);
|
|
953
|
+
}
|
|
954
|
+
assert.ok(failuresLog.includes('"errorType":"invalid_request"'));
|
|
955
|
+
const longest = errs.reduce((max, e) => Math.max(max, e.length), 0);
|
|
956
|
+
assert.ok(longest < 4_500, `longest stderr line is ${longest} chars`);
|
|
957
|
+
const loggedErrors = inferenceLogs.map(({ entry }) => entry.error).filter((e) => typeof e === 'string');
|
|
958
|
+
// A stream that never started writes no inference-log entry (as before);
|
|
959
|
+
// the two stream paths do.
|
|
960
|
+
if (path !== 'stream setup throws')
|
|
961
|
+
assert.ok(loggedErrors.length > 0, 'the failed inference is logged');
|
|
962
|
+
for (const logged of loggedErrors) {
|
|
963
|
+
assert.ok(logged.length <= 2_000, `inference log error is ${logged.length} chars`);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
test('a ~1 MB plain Error (no classification) is bounded the same way', async () => {
|
|
968
|
+
const { markers, errs, rawArgs } = await runFailingTurn('stream throws', () => new Error(`socket said: ${echo()}`));
|
|
969
|
+
assertNoObjectPrinted(rawArgs);
|
|
970
|
+
assert.equal(markers.length, 1);
|
|
971
|
+
const text = markers[0].content[0].text;
|
|
972
|
+
assert.ok(text.length < 1_200);
|
|
973
|
+
assert.match(text, /model call failed and produced no response/);
|
|
974
|
+
const longest = errs.reduce((max, e) => Math.max(max, e.length), 0);
|
|
975
|
+
assert.ok(longest < 4_500, `longest stderr line is ${longest} chars`);
|
|
976
|
+
});
|
|
977
|
+
test('an oversized provider code is bounded in the trace, the marker and failures.log', async () => {
|
|
978
|
+
const { markers, traces, failuresLog } = await runFailingTurn('stream error event', () => new MembraneError({
|
|
979
|
+
type: 'invalid_request', retryable: false, httpStatus: 400, providerErrorCode: `zz${'C'.repeat(MEGA)}`,
|
|
980
|
+
message: 'Bad request: zz-short', rawError: { status: 400 },
|
|
981
|
+
}));
|
|
982
|
+
const exhausted = traces.find((t) => t.type === 'inference:exhausted');
|
|
983
|
+
assert.ok(exhausted.providerErrorCode.length <= 128, `trace code is ${exhausted.providerErrorCode.length} chars`);
|
|
984
|
+
assert.ok(markers[0].metadata.providerErrorCode.length <= 128);
|
|
985
|
+
const record = failuresLog.trim().split('\n').map((l) => JSON.parse(l)).find((r) => r.kind === 'inference-exhausted');
|
|
986
|
+
assert.ok(record.providerErrorCode.length <= 128, `failures.log code is ${record.providerErrorCode.length} chars`);
|
|
987
|
+
});
|
|
988
|
+
/** A MembraneError built by another copy of the package: the same fields and name, another class. */
|
|
989
|
+
function foreignMembraneError(message, type = 'invalid_request') {
|
|
990
|
+
return Object.assign(new Error(message), {
|
|
991
|
+
name: 'MembraneError', type, retryable: false, httpStatus: 400, providerErrorCode: 'zz_bad_request', rawRequest: { system: echo() },
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
test('a MembraneError from another copy of membrane is recognised by its name', async () => {
|
|
995
|
+
const quoted = `Bad request. Request: ... Context would exceed hard budget ... ${echo(10_000)}`;
|
|
996
|
+
// The default error policy, which a stream's error event consults: a
|
|
997
|
+
// non-retryable error fails at once, not after three retries.
|
|
998
|
+
const { markers, traces, errs, rawArgs, calls } = await runFailingTurn('stream error event', () => foreignMembraneError(quoted));
|
|
999
|
+
assert.equal(calls, 1, 'not retried');
|
|
1000
|
+
assert.match(markers[0].content[0].text, /model call failed \(invalid_request, HTTP 400\)/);
|
|
1001
|
+
assert.equal(markers[0].metadata.errorType, 'invalid_request');
|
|
1002
|
+
// Classified, so the quoted phrase is the provider's text, not a refused compile.
|
|
1003
|
+
assert.equal(traces.filter((t) => t.type === 'ops:alert' && t.kind === 'context-refusal').length, 0);
|
|
1004
|
+
assert.equal(errs.some((e) => e.includes('drain kicked')), false);
|
|
1005
|
+
assertNoObjectPrinted(rawArgs);
|
|
1006
|
+
// Classified, it gets the poisoned-history quarantine at the hard-down
|
|
1007
|
+
// threshold, as the framework's own copy does: the third failure sheds the
|
|
1008
|
+
// newest exchange, counted in the one rewind marker. Each shed queues a
|
|
1009
|
+
// retry on the rewound history, which may fail and shed again before the
|
|
1010
|
+
// run is stopped, so only the first shed is asserted.
|
|
1011
|
+
const shed = await runFailingTurn('stream error event', () => foreignMembraneError(quoted), { turns: 3 });
|
|
1012
|
+
const quarantined = shed.errs.filter((e) => e.startsWith('[inference-rewind] agent=resident auto-quarantined'));
|
|
1013
|
+
assert.ok(quarantined.length > 0, 'the third failure sheds');
|
|
1014
|
+
assert.match(quarantined[0], /\(1 turn\(s\) shed this episode, cap 3\)/);
|
|
1015
|
+
const kept = shed.messages.filter((m) => !m.metadata?.system)
|
|
1016
|
+
.map((m) => m.content[0].text);
|
|
1017
|
+
assert.ok(!kept.includes('hello 2'), 'the newest exchange is shed');
|
|
1018
|
+
const rewind = shed.messages.filter((m) => m.metadata?.kind === 'refusal-rewind');
|
|
1019
|
+
assert.equal(rewind.length, 1, 'one consolidated rewind marker');
|
|
1020
|
+
assert.match(rewind[0].content[0].text, /^\[refusal-rewind\] The \d+ most recent turn\(s\) were set aside/);
|
|
1021
|
+
// Its type is bounded like a provider code, in every sink it reaches.
|
|
1022
|
+
const odd = await runFailingTurn('stream throws', () => foreignMembraneError('Bad request: zz-short', `zz${'T'.repeat(MEGA)}`), { errorPolicy: noRetry });
|
|
1023
|
+
const exhausted = odd.traces.find((t) => t.type === 'inference:exhausted');
|
|
1024
|
+
assert.ok(exhausted.errorType.length <= 128, `trace type is ${exhausted.errorType.length} chars`);
|
|
1025
|
+
assert.ok(odd.markers[0].metadata.errorType.length <= 128);
|
|
1026
|
+
const line = odd.errs.find((e) => e.startsWith('[inference-failed] agent=resident'));
|
|
1027
|
+
assert.ok(line.length < 2_500, `stderr line is ${line.length} chars`);
|
|
1028
|
+
// The organization acceleration limit is recognised too, and held.
|
|
1029
|
+
const harness = makeHarness();
|
|
1030
|
+
Object.assign(harness.fw, {
|
|
1031
|
+
ephemeralRuns: new Map(), conversationAgentHomes: new Map(), providerGates: new Map(),
|
|
1032
|
+
providerAccelerationCooldowns: new Map(), providerAccelerationRecoveries: new Map(),
|
|
1033
|
+
providerAccelerationDefaultCooldownMs: 30_000, providerAccelerationJitterMs: 0,
|
|
1034
|
+
});
|
|
1035
|
+
const limited = Object.assign(new Error('Your organization has hit its acceleration limit.'), {
|
|
1036
|
+
name: 'MembraneError', type: 'rate_limit', retryable: true, httpStatus: 429,
|
|
1037
|
+
});
|
|
1038
|
+
try {
|
|
1039
|
+
assert.equal(harness.fw.holdProviderAcceleration(harness.fw.agents.get('cairn'), limited), true);
|
|
1040
|
+
}
|
|
1041
|
+
finally {
|
|
1042
|
+
harness.fw.cancelProviderAdmission();
|
|
1043
|
+
harness.restore();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
test("the default error policy reads either copy's retryable and retryAfterMs", async () => {
|
|
1047
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-failure-bounds-'));
|
|
1048
|
+
const framework = await AgentFramework.create({
|
|
1049
|
+
storePath: join(dir, 'store.chronicle'),
|
|
1050
|
+
membrane: new FailingMembrane('stream setup throws', () => hugeProviderError()).asMembrane(),
|
|
1051
|
+
agents: [], modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0,
|
|
1052
|
+
});
|
|
1053
|
+
try {
|
|
1054
|
+
// The policy the framework built for itself, with no errorPolicy configured.
|
|
1055
|
+
const policy = framework.errorPolicy;
|
|
1056
|
+
const foreign = (fields) => Object.assign(new Error('Rate limited'), {
|
|
1057
|
+
name: 'MembraneError', type: 'rate_limit', httpStatus: 429, ...fields,
|
|
1058
|
+
});
|
|
1059
|
+
const own = new MembraneError({
|
|
1060
|
+
type: 'rate_limit', retryable: true, httpStatus: 429, retryAfterMs: 1234, message: 'Rate limited', rawError: { status: 429 },
|
|
1061
|
+
});
|
|
1062
|
+
// A stated wait is honoured, from another copy as from the framework's own.
|
|
1063
|
+
assert.deepEqual(policy.onInferenceError(own, 'resident', 0), { retry: true, delayMs: 1234 });
|
|
1064
|
+
assert.deepEqual(policy.onInferenceError(foreign({ retryable: true, retryAfterMs: 1234 }), 'resident', 0), { retry: true, delayMs: 1234 });
|
|
1065
|
+
// Without retryable, it retries as an unknown error does; refused only when false.
|
|
1066
|
+
assert.deepEqual(policy.onInferenceError(foreign({}), 'resident', 0), { retry: true, delayMs: 1_000 });
|
|
1067
|
+
assert.deepEqual(policy.onInferenceError(foreign({ retryable: false, retryAfterMs: 1234 }), 'resident', 0), { retry: false });
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
await framework.stop();
|
|
1071
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
test('the breaker reads the status a real failure carries: a 404 never sheds; a 400, a 413 under either label and no status do', async () => {
|
|
1075
|
+
// Through classifyInferenceError, the inference:exhausted trace and
|
|
1076
|
+
// noteInferenceExhausted to the gate, three failed turns each: the
|
|
1077
|
+
// escalation threshold. A shed is read from its own line, since the cap's
|
|
1078
|
+
// and nothing-left-to-shed's lines share its prefix.
|
|
1079
|
+
const legs = [
|
|
1080
|
+
['invalid_request', 404, false], ['invalid_request', 400, true], ['invalid_request', 413, true],
|
|
1081
|
+
['context_length', 413, true], ['invalid_request', undefined, true],
|
|
1082
|
+
];
|
|
1083
|
+
for (const [type, status, sheds] of legs) {
|
|
1084
|
+
const label = `${type} ${status === undefined ? 'with no status' : `at ${status}`}`;
|
|
1085
|
+
const { errs, traces, messages, markers, failuresLog } = await runFailingTurn('stream error event', () => new MembraneError({
|
|
1086
|
+
type, retryable: false, message: `zz-${label}`, rawError: { status },
|
|
1087
|
+
...(status === undefined ? {} : { httpStatus: status }),
|
|
1088
|
+
}), { turns: 3 });
|
|
1089
|
+
const exhausted = traces.filter((t) => t.type === 'inference:exhausted');
|
|
1090
|
+
assert.ok(exhausted.length >= 3, `${label}: ${exhausted.length} exhausted traces`);
|
|
1091
|
+
// The status as the failure carried it: none is invented for an error without one.
|
|
1092
|
+
assert.ok(exhausted.every((t) => t.httpStatus === status), `${label} reached the trace as it was`);
|
|
1093
|
+
assert.ok(errs.some((e) => e.startsWith('[inference-hard-down] agent=resident')), `${label} is reported hard-down`);
|
|
1094
|
+
const shed = errs.filter((e) => e.startsWith('[inference-rewind] agent=resident auto-quarantined'));
|
|
1095
|
+
const kept = messages.filter((m) => !m.metadata?.system)
|
|
1096
|
+
.map((m) => m.content[0].text);
|
|
1097
|
+
if (sheds) {
|
|
1098
|
+
assert.ok(shed.length > 0, `${label} sheds`);
|
|
1099
|
+
assert.ok(!kept.includes('hello 2'), `${label}: the newest exchange is shed`);
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
assert.equal(shed.length, 0, `${label} sheds nothing`);
|
|
1103
|
+
assert.deepEqual(kept, ['hello 0', 'hello 1', 'hello 2'], `${label}: the history still holds every exchange`);
|
|
1104
|
+
// Only the shed is gated: each failure is still marked and logged.
|
|
1105
|
+
assert.equal(markers.length, 3, `${label}: an [inference-failed] marker per failure`);
|
|
1106
|
+
const records = failuresLog.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))
|
|
1107
|
+
.filter((record) => record.kind === 'inference-exhausted');
|
|
1108
|
+
assert.deepEqual(records.map((record) => record.httpStatus), [404, 404, 404], `${label}: a failures.log record per failure`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
test('an ephemeral run rejects with the bounded failure text', async () => {
|
|
1113
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-failure-bounds-'));
|
|
1114
|
+
const cwd = process.cwd();
|
|
1115
|
+
process.chdir(dir);
|
|
1116
|
+
const { restore } = spyConsoleError();
|
|
1117
|
+
const framework = await AgentFramework.create({
|
|
1118
|
+
storePath: join(dir, 'store.chronicle'),
|
|
1119
|
+
membrane: new FailingMembrane('stream setup throws', () => hugeProviderError()).asMembrane(),
|
|
1120
|
+
agents: [], modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0, errorPolicy: noRetry,
|
|
1121
|
+
});
|
|
1122
|
+
try {
|
|
1123
|
+
const { agent, contextManager } = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.' });
|
|
1124
|
+
contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]);
|
|
1125
|
+
const run = framework.runEphemeralToCompletion(agent, contextManager);
|
|
1126
|
+
framework.start();
|
|
1127
|
+
await assert.rejects(run, (error) => {
|
|
1128
|
+
assert.ok(error.message.length <= 2_000, `rejection message is ${error.message.length} chars`);
|
|
1129
|
+
assert.match(error.message, /^Bad request: zz-head e+ …\[\d+ of \d+ characters omitted\]… e+ zz-tail$/);
|
|
1130
|
+
return true;
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
finally {
|
|
1134
|
+
await framework.stop();
|
|
1135
|
+
restore();
|
|
1136
|
+
process.chdir(cwd);
|
|
1137
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
class FailingStrategy {
|
|
1141
|
+
make;
|
|
1142
|
+
name = 'failing-maintenance-test';
|
|
1143
|
+
constructor(make) {
|
|
1144
|
+
this.make = make;
|
|
1145
|
+
}
|
|
1146
|
+
checkReadiness() { return { ready: false, description: 'compression blocked' }; }
|
|
1147
|
+
async tick(_ctx) { throw this.make(); }
|
|
1148
|
+
select(_store, _log, _budget) { return []; }
|
|
1149
|
+
}
|
|
1150
|
+
/** A real framework's first maintenance pass, its agent's tick throwing `make()`, with the ops webhook set. */
|
|
1151
|
+
async function runFailingMaintenance(make) {
|
|
1152
|
+
const dir = mkdtempSync(join(tmpdir(), 'af-failure-bounds-'));
|
|
1153
|
+
const cwd = process.cwd();
|
|
1154
|
+
process.chdir(dir);
|
|
1155
|
+
const { errs, rawArgs, restore } = spyConsoleError();
|
|
1156
|
+
const framework = await AgentFramework.create({
|
|
1157
|
+
storePath: join(dir, 'store.chronicle'), membrane: new MockMembrane().asMembrane(),
|
|
1158
|
+
agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', strategy: new FailingStrategy(make) }],
|
|
1159
|
+
modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 10,
|
|
1160
|
+
});
|
|
1161
|
+
const traces = [];
|
|
1162
|
+
framework.onTrace((e) => traces.push(e));
|
|
1163
|
+
try {
|
|
1164
|
+
let record;
|
|
1165
|
+
const posted = await withWebhook(async () => {
|
|
1166
|
+
framework.start();
|
|
1167
|
+
const deadline = Date.now() + 5_000;
|
|
1168
|
+
while (framework.getContextMaintenanceSnapshot().history.length === 0 && Date.now() < deadline) {
|
|
1169
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1170
|
+
}
|
|
1171
|
+
record = framework.getContextMaintenanceSnapshot().history[0].agents[0];
|
|
1172
|
+
});
|
|
1173
|
+
return { record, traces, errs, rawArgs, posted };
|
|
1174
|
+
}
|
|
1175
|
+
finally {
|
|
1176
|
+
await framework.stop();
|
|
1177
|
+
restore();
|
|
1178
|
+
process.chdir(cwd);
|
|
1179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
test('a ~1 MB maintenance failure: bounded record, ops alert and stderr', async () => {
|
|
1183
|
+
const error = hugeProviderError();
|
|
1184
|
+
const { record, traces, errs, rawArgs, posted } = await runFailingMaintenance(() => error);
|
|
1185
|
+
assert.ok(typeof record.error === 'string' && record.error.length <= 2_000, `maintenance record error is ${record.error?.length} chars`);
|
|
1186
|
+
assert.match(record.error, /^Bad request: zz-head/);
|
|
1187
|
+
const alert = traces.find((t) => t.type === 'ops:alert' && t.kind === 'context-maintenance-failed');
|
|
1188
|
+
assert.ok(alert && alert.message.length <= 2_000);
|
|
1189
|
+
const longest = errs.reduce((max, e) => Math.max(max, e.length), 0);
|
|
1190
|
+
assert.ok(longest < 4_500, `longest stderr line is ${longest} chars`);
|
|
1191
|
+
assertNoObjectPrinted(rawArgs);
|
|
1192
|
+
// The webhook's copy is cut from the whole message, so it states its length.
|
|
1193
|
+
const webhook = '\u{1F6A8} **agent** context-maintenance-failed: ';
|
|
1194
|
+
const content = posted.find((c) => c.startsWith(webhook));
|
|
1195
|
+
assert.ok(content, 'the webhook posted the alert');
|
|
1196
|
+
assertCut(error.message, content.slice(webhook.length), 500, 'maintenance webhook');
|
|
1197
|
+
// A thrown value that isn't an Error is described as the other logs describe one.
|
|
1198
|
+
const bare = await runFailingMaintenance(() => Object.assign(Object.create(null), { code: 'zz_bare' }));
|
|
1199
|
+
assert.equal(bare.record.error, '{"code":"zz_bare"}');
|
|
1200
|
+
});
|
|
1201
|
+
//# sourceMappingURL=inference-failure-bounds.test.js.map
|