@theoremjs/agents 0.3.4-next.508.geda6db0 → 0.3.4-next.524.g5000023
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/esm/src/guardrails/detect-at.d.ts +6 -2
- package/esm/src/guardrails/detect-at.js +64 -19
- package/esm/src/guardrails/detect-reply.d.ts +1 -1
- package/esm/src/guardrails/detect-reply.js +5 -2
- package/esm/src/guardrails/egress-compiler.js +1 -4
- package/esm/src/guardrails/error.d.ts +1 -1
- package/esm/src/guardrails/error.js +19 -2
- package/esm/src/guardrails/event-schemas.d.ts +5 -0
- package/esm/src/guardrails/event-schemas.js +1 -0
- package/esm/src/guardrails/host-patterns.d.ts +1 -4
- package/esm/src/guardrails/host-patterns.js +1 -10
- package/esm/src/guardrails/mod.d.ts +1 -1
- package/esm/src/guardrails/mod.js +1 -1
- package/esm/src/guardrails/sanitize.d.ts +1 -1
- package/esm/src/guardrails/sanitize.js +4 -3
- package/esm/src/host/client-turn.js +11 -3
- package/esm/src/kernel/engine/runner/gates.js +2 -1
- package/esm/src/kernel/engine/runner/mod.js +13 -5
- package/esm/src/kernel/engine/runner/state.d.ts +9 -1
- package/esm/src/kernel/engine/runner/state.js +10 -1
- package/esm/src/kernel/engine/runner/steps.js +3 -3
- package/esm/src/kernel/engine/turn-trace.js +4 -2
- package/esm/src/kernel/protocols/runtime.js +2 -2
- package/esm/src/kernel/provider-runtime.js +6 -3
- package/esm/src/kernel/registry/profiles.js +2 -2
- package/esm/src/kernel/schema.js +16 -0
- package/esm/src/kernel/tools/execute.js +3 -1
- package/esm/src/kernel/tools/image-fetch.d.ts +2 -4
- package/esm/src/kernel/tools/image-fetch.js +19 -20
- package/esm/src/kernel/tools/mcp-sessions.d.ts +7 -2
- package/esm/src/kernel/tools/mcp-sessions.js +21 -12
- package/esm/src/kernel/tools/registry.js +21 -0
- package/esm/src/kernel/tools/remote.d.ts +0 -2
- package/esm/src/kernel/tools/remote.js +28 -23
- package/esm/src/kernel/tools/types.d.ts +30 -4
- package/esm/src/kernel/turn-events.d.ts +2 -0
- package/esm/src/kernel/turn-events.js +1 -0
- package/esm/src/observability/trace-catalog.js +3 -0
- package/package.json +1 -1
|
@@ -13,6 +13,10 @@ interface Detection {
|
|
|
13
13
|
text?: string;
|
|
14
14
|
/** Every match, each named for its detector. */
|
|
15
15
|
hits: GuardrailHit[];
|
|
16
|
+
/** Why a host's `find` failed, for the builder only: it reaches `guardrail.errorInternal`. */
|
|
17
|
+
errorInternal?: string;
|
|
18
|
+
/** The same failure in the kernel's words only, with no text from the host: what the trace stores. */
|
|
19
|
+
errorSummary?: string;
|
|
16
20
|
}
|
|
17
21
|
declare function stronger(left: DetectOutcome, right: DetectOutcome | 'ignore'): DetectOutcome;
|
|
18
22
|
/**
|
|
@@ -92,7 +96,7 @@ declare function storedHostSpans(text: string, detect?: ResolvedDetect): RedactS
|
|
|
92
96
|
interface BoundaryReader {
|
|
93
97
|
/** The text to let through, or `''` once a match blocks: the caller reads `found().action`. */
|
|
94
98
|
read(text: string): string;
|
|
95
|
-
found(): Pick<Detection, 'action' | 'hits'>;
|
|
99
|
+
found(): Pick<Detection, 'action' | 'hits' | 'errorInternal' | 'errorSummary'>;
|
|
96
100
|
}
|
|
97
101
|
/** A reader for everything crossing `boundary` in one request. */
|
|
98
102
|
declare function boundaryReader(boundary: Boundary, detect: ResolvedDetect): BoundaryReader;
|
|
@@ -100,6 +104,6 @@ declare function boundaryReader(boundary: Boundary, detect: ResolvedDetect): Bou
|
|
|
100
104
|
* The guardrail event for what was found at a boundary, or `undefined` when nothing matched.
|
|
101
105
|
* `stage` is the boundary's own unless the text was read as it streamed.
|
|
102
106
|
*/
|
|
103
|
-
declare function detectEvent(boundary: Boundary, found: Pick<Detection, 'action' | 'hits'>, provenance?: Provenance, stage?: GuardrailStage): GuardrailEvent | undefined;
|
|
107
|
+
declare function detectEvent(boundary: Boundary, found: Pick<Detection, 'action' | 'hits' | 'errorInternal' | 'errorSummary'>, provenance?: Provenance, stage?: GuardrailStage): GuardrailEvent | undefined;
|
|
104
108
|
export type { BoundaryReader, Detection, DetectOutcome, DetectScope, Release, StoredSides, StreamRead, Stretch, };
|
|
105
109
|
export { boundaryReader, detectAt, detectEvent, detectorsAt, detectReads, detectRelease, hostAt, isScoped, leakScopeOf, redactDetectors, scopeOf, storedHostSpans, storedSpans, stronger, };
|
|
@@ -7,6 +7,7 @@ import { actionAt, DETECTORS, detects, } from './detectors.js';
|
|
|
7
7
|
import { boundaryNote } from './egress.js';
|
|
8
8
|
import { notePattern, SYSTEM_BOUNDARY } from './egress-patterns.js';
|
|
9
9
|
import { imageLeakSpans, linkLeakSpans } from './egress-urls.js';
|
|
10
|
+
import { describeError } from './error.js';
|
|
10
11
|
import { CANARY_HIT, hitFromSpan, promptEchoHit } from './hits.js';
|
|
11
12
|
import { injectionSpans } from './injection.js';
|
|
12
13
|
import { promptEchoRanges } from './prompt-echo.js';
|
|
@@ -15,6 +16,14 @@ import { sensitiveSpans } from './sensitive.js';
|
|
|
15
16
|
import { SENSITIVE_GROUPS } from './sensitive-groups.js';
|
|
16
17
|
import { directives, mergedRanges } from './tool-directives.js';
|
|
17
18
|
import { ownToolsWithout, toolLeakSpans } from './tool-leak.js';
|
|
19
|
+
function failureText(failures) {
|
|
20
|
+
if (failures.length === 0)
|
|
21
|
+
return {};
|
|
22
|
+
return {
|
|
23
|
+
errorInternal: failures.map(({ errorInternal }) => errorInternal).join('\n'),
|
|
24
|
+
errorSummary: failures.map(({ errorSummary }) => errorSummary).join('\n'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
18
27
|
const STRENGTH = ['allow', 'flag', 'redact', 'block'];
|
|
19
28
|
function stronger(left, right) {
|
|
20
29
|
if (right === 'ignore')
|
|
@@ -138,22 +147,35 @@ function hitOf(detector, text, span) {
|
|
|
138
147
|
function leavesImages(detector, boundary, detect) {
|
|
139
148
|
return detector === 'ungiven_links' && detect.ungiven_images[boundary] !== 'ignore';
|
|
140
149
|
}
|
|
150
|
+
/** A thrown value's type: a name of letters only, else `Error`, so no host text gets through. */
|
|
151
|
+
function thrownType(err) {
|
|
152
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
153
|
+
return /^[A-Za-z]{1,64}$/.test(name) ? name : 'Error';
|
|
154
|
+
}
|
|
141
155
|
/** What a host's `find` matched in `text`, or how it failed. */
|
|
142
156
|
function foundBy(find, text, boundary) {
|
|
143
157
|
try {
|
|
144
158
|
const spans = find(text, boundary ? { boundary } : {});
|
|
159
|
+
// labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
160
|
+
const said = 'find returned no list of stretches inside the text';
|
|
161
|
+
const outside = { signal: 'find_result', reason: said, summary: said };
|
|
145
162
|
if (!Array.isArray(spans))
|
|
146
|
-
return
|
|
163
|
+
return outside;
|
|
147
164
|
const inside = spans.every(({ start, end }) => Number.isInteger(start) &&
|
|
148
165
|
Number.isInteger(end) &&
|
|
149
166
|
start >= 0 &&
|
|
150
167
|
start < end &&
|
|
151
168
|
end <= text.length);
|
|
152
|
-
return inside ? spans.map(({ start, end }) => ({ start, end, kind: 'host' })) :
|
|
169
|
+
return inside ? spans.map(({ start, end }) => ({ start, end, kind: 'host' })) : outside;
|
|
153
170
|
}
|
|
154
|
-
catch {
|
|
171
|
+
catch (err) {
|
|
155
172
|
// why: A reading that failed found nothing it can vouch for, so the text does not cross.
|
|
156
|
-
|
|
173
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
174
|
+
return {
|
|
175
|
+
signal: 'find_threw',
|
|
176
|
+
reason: `find threw: ${describeError(err)} (error ${id})`,
|
|
177
|
+
summary: `find threw ${thrownType(err)} (error ${id})`,
|
|
178
|
+
};
|
|
157
179
|
}
|
|
158
180
|
}
|
|
159
181
|
function hostReader(host, boundary, chosen) {
|
|
@@ -168,7 +190,11 @@ function hostReader(host, boundary, chosen) {
|
|
|
168
190
|
if (!host.find)
|
|
169
191
|
return matched;
|
|
170
192
|
const found = foundBy(host.find, text, boundary);
|
|
171
|
-
|
|
193
|
+
if (!Array.isArray(found)) {
|
|
194
|
+
const by = `Host detector '${host.id}'`;
|
|
195
|
+
return { ...found, reason: `${by} ${found.reason}`, summary: `${by} ${found.summary}` };
|
|
196
|
+
}
|
|
197
|
+
return [...matched, ...found];
|
|
172
198
|
},
|
|
173
199
|
hit: (text, span) => ({ ...hitFromSpan(text, span, rule, 'high'), label: host.label }),
|
|
174
200
|
};
|
|
@@ -211,11 +237,13 @@ function failedHit({ rule, label }, signal) {
|
|
|
211
237
|
function detectAt(text, boundary, detect, scope = NO_SCOPE) {
|
|
212
238
|
const hits = [];
|
|
213
239
|
const redact = [];
|
|
240
|
+
const failures = [];
|
|
214
241
|
let action = 'allow';
|
|
215
242
|
for (const reader of readersAt(boundary, detect, scope)) {
|
|
216
243
|
const spans = reader.spans(text);
|
|
217
|
-
if (
|
|
218
|
-
hits.push(failedHit(reader, spans));
|
|
244
|
+
if (!Array.isArray(spans)) {
|
|
245
|
+
hits.push(failedHit(reader, spans.signal));
|
|
246
|
+
failures.push({ errorInternal: spans.reason, errorSummary: spans.summary });
|
|
219
247
|
action = 'block';
|
|
220
248
|
continue;
|
|
221
249
|
}
|
|
@@ -228,8 +256,9 @@ function detectAt(text, boundary, detect, scope = NO_SCOPE) {
|
|
|
228
256
|
redact.push(span);
|
|
229
257
|
action = stronger(action, reader.chosen);
|
|
230
258
|
}
|
|
231
|
-
if (action === 'block')
|
|
232
|
-
return { action, hits };
|
|
259
|
+
if (action === 'block') {
|
|
260
|
+
return { action, hits, ...failureText(failures) };
|
|
261
|
+
}
|
|
233
262
|
if (redact.length === 0)
|
|
234
263
|
return { action, text, hits };
|
|
235
264
|
const replaced = applySpans(text, redact);
|
|
@@ -247,7 +276,7 @@ function leftAfterRedact(replaced, boundary, detect, scope, detectors) {
|
|
|
247
276
|
if (chosen === 'flag')
|
|
248
277
|
return false;
|
|
249
278
|
const left = spans(replaced);
|
|
250
|
-
return !left || left.length > 0;
|
|
279
|
+
return !Array.isArray(left) || left.length > 0;
|
|
251
280
|
});
|
|
252
281
|
}
|
|
253
282
|
/** The detectors that read `boundary`: every one not set to `ignore`. */
|
|
@@ -281,16 +310,21 @@ function detectReads(boundaries, detect, scope) {
|
|
|
281
310
|
*/
|
|
282
311
|
function detectRelease(window, { from, to, settled }, boundary, detect, { detectors, scope }) {
|
|
283
312
|
const failed = [];
|
|
313
|
+
const failures = [];
|
|
284
314
|
const found = readersAt(boundary, detect, scope, detectors).flatMap((reader) => {
|
|
285
315
|
const spans = reader.spans(window);
|
|
286
|
-
if (
|
|
287
|
-
failed.push(failedHit(reader, spans));
|
|
288
|
-
|
|
316
|
+
if (!Array.isArray(spans)) {
|
|
317
|
+
failed.push(failedHit(reader, spans.signal));
|
|
318
|
+
failures.push({ errorInternal: spans.reason, errorSummary: spans.summary });
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
return spans
|
|
289
322
|
.filter((span) => span.end > from && span.start < to)
|
|
290
323
|
.map((span) => ({ span, rule: reader.rule, label: reader.label, chosen: reader.chosen }));
|
|
291
324
|
});
|
|
292
|
-
if (failed.length > 0)
|
|
293
|
-
return { action: 'block', hits: failed, taken: 0 };
|
|
325
|
+
if (failed.length > 0) {
|
|
326
|
+
return { action: 'block', hits: failed, taken: 0, ...failureText(failures) };
|
|
327
|
+
}
|
|
294
328
|
const hitOf = ({ span, rule, label }) => ({
|
|
295
329
|
...hitFromSpan(window, span, rule, 'high'),
|
|
296
330
|
...(label === undefined ? {} : { label }),
|
|
@@ -353,24 +387,31 @@ function storedHostSpans(text, detect) {
|
|
|
353
387
|
if (!host.find || !text)
|
|
354
388
|
return matched;
|
|
355
389
|
const found = foundBy(host.find, text);
|
|
356
|
-
return
|
|
357
|
-
? [
|
|
358
|
-
: [
|
|
390
|
+
return Array.isArray(found)
|
|
391
|
+
? [...matched, ...found]
|
|
392
|
+
: [{ start: 0, end: text.length, kind: 'host' }];
|
|
359
393
|
});
|
|
360
394
|
}
|
|
361
395
|
/** A reader for everything crossing `boundary` in one request. */
|
|
362
396
|
function boundaryReader(boundary, detect) {
|
|
363
397
|
const hits = [];
|
|
398
|
+
const failures = [];
|
|
364
399
|
let action = 'allow';
|
|
365
400
|
return {
|
|
366
401
|
read(text) {
|
|
367
402
|
const detected = detectAt(text, boundary, detect);
|
|
368
403
|
for (const hit of detected.hits)
|
|
369
404
|
hits.push(hit);
|
|
405
|
+
if (detected.errorInternal && detected.errorSummary) {
|
|
406
|
+
failures.push({
|
|
407
|
+
errorInternal: detected.errorInternal,
|
|
408
|
+
errorSummary: detected.errorSummary,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
370
411
|
action = stronger(action, detected.action);
|
|
371
412
|
return detected.text ?? '';
|
|
372
413
|
},
|
|
373
|
-
found: () => ({ action, hits }),
|
|
414
|
+
found: () => ({ action, hits, ...failureText(failures) }),
|
|
374
415
|
};
|
|
375
416
|
}
|
|
376
417
|
const TOOL_STAGE = Object.fromEntries(TOOL_BOUNDARIES.map((boundary) => [
|
|
@@ -413,6 +454,10 @@ function detectEvent(boundary, found, provenance, stage = BOUNDARY_STAGE[boundar
|
|
|
413
454
|
action: found.action,
|
|
414
455
|
hits: found.hits,
|
|
415
456
|
...(provenance ? { provenance } : {}),
|
|
457
|
+
...(found.action === 'block' && found.errorInternal
|
|
458
|
+
? { errorInternal: found.errorInternal }
|
|
459
|
+
: {}),
|
|
460
|
+
...(found.action === 'block' && found.errorSummary ? { errorSummary: found.errorSummary } : {}),
|
|
416
461
|
};
|
|
417
462
|
}
|
|
418
463
|
export { boundaryReader, detectAt, detectEvent, detectorsAt, detectReads, detectRelease, hostAt, isScoped, leakScopeOf, redactDetectors, scopeOf, storedHostSpans, storedSpans, stronger, };
|
|
@@ -26,7 +26,7 @@ interface ReplyRead {
|
|
|
26
26
|
blocked?: GuardrailHit[];
|
|
27
27
|
}
|
|
28
28
|
/** A JSON value read at `boundary`, with each match in a string replaced, or what stops it crossing. */
|
|
29
|
-
declare function readValue(value: unknown, boundary: Boundary, detect: ResolvedDetect, scope?: DetectScope): Pick<Detection, 'action' | 'hits'> & {
|
|
29
|
+
declare function readValue(value: unknown, boundary: Boundary, detect: ResolvedDetect, scope?: DetectScope): Pick<Detection, 'action' | 'hits' | 'errorInternal' | 'errorSummary'> & {
|
|
30
30
|
value?: unknown;
|
|
31
31
|
};
|
|
32
32
|
/**
|
|
@@ -23,8 +23,11 @@ function readValue(value, boundary, detect, scope = {}) {
|
|
|
23
23
|
return { action: 'block', hits: [{ rule: EGRESS_RULES.unscannable, severity: 'high' }] };
|
|
24
24
|
}
|
|
25
25
|
const found = detectAt(scan.text, boundary, detect, scope);
|
|
26
|
-
if (found.action !== 'redact')
|
|
27
|
-
|
|
26
|
+
if (found.action !== 'redact') {
|
|
27
|
+
const { errorInternal, errorSummary } = found;
|
|
28
|
+
const why = errorInternal && errorSummary ? { errorInternal, errorSummary } : {};
|
|
29
|
+
return { action: found.action, hits: found.hits, value, ...why };
|
|
30
|
+
}
|
|
28
31
|
const redacted = mapStrings(value, (leaf) => detectAt(leaf, boundary, detect, scope).text ?? '');
|
|
29
32
|
const left = detectAt(textForScan(redacted).text, boundary, detect, scope).action;
|
|
30
33
|
// why: A match in a key, or one that runs across values, has no string to replace: it does not cross.
|
|
@@ -25,7 +25,7 @@ import { JS, NFA } from 'refa';
|
|
|
25
25
|
import { analyse } from 'scslre';
|
|
26
26
|
import { EGRESS_PATTERNS } from './egress-patterns.js';
|
|
27
27
|
import { TheoremError } from './error.js';
|
|
28
|
-
import {
|
|
28
|
+
import { PATTERN_COMPILER_VERSION, patternProblem, patternSources, } from './host-patterns.js';
|
|
29
29
|
const MAX_CHAR = 0xffff;
|
|
30
30
|
/**
|
|
31
31
|
* A bounded repeat longer than this is read as unbounded: the hold cannot
|
|
@@ -355,9 +355,6 @@ function slowPatternProblem(source, flags = '') {
|
|
|
355
355
|
* backreference to text that varies).
|
|
356
356
|
*/
|
|
357
357
|
function compilePatterns(patterns) {
|
|
358
|
-
if (patterns.length > MAX_PATTERNS) {
|
|
359
|
-
throw patternError(`${patterns.length} patterns; a detector takes at most ${MAX_PATTERNS}`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
360
|
-
}
|
|
361
358
|
patterns.forEach((pattern, index) => {
|
|
362
359
|
const problem = patternProblem(`patterns[${index}]`, pattern);
|
|
363
360
|
if (problem !== undefined)
|
|
@@ -16,7 +16,7 @@ declare function kindOfHttpStatus(status: number): ErrorKind;
|
|
|
16
16
|
declare function errorCopyText(copy: ErrorCopies, labels?: LabelOverrides): string;
|
|
17
17
|
/** Pass the profile's `labels`, or the profile's wording is skipped. */
|
|
18
18
|
declare function publicError(err: unknown, labels?: LabelOverrides): string;
|
|
19
|
-
/** Raw diagnostic for hosts, traces, and logs; never shown to end users. */
|
|
19
|
+
/** Raw diagnostic for hosts, traces, and logs; never shown to end users. Each cause that says something new follows on its own line. */
|
|
20
20
|
declare function describeError(err: unknown): string;
|
|
21
21
|
type ProducedError = TurnEventOf<'error'> & {
|
|
22
22
|
errorInternal: string;
|
|
@@ -61,8 +61,7 @@ function errorCopyText(copy, labels) {
|
|
|
61
61
|
function publicError(err, labels) {
|
|
62
62
|
return wording(errorKind(err), err instanceof TheoremError ? err.copy : undefined, labels);
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
function describeError(err) {
|
|
64
|
+
function messageOf(err) {
|
|
66
65
|
if (typeof err === 'string') {
|
|
67
66
|
return err;
|
|
68
67
|
}
|
|
@@ -71,6 +70,24 @@ function describeError(err) {
|
|
|
71
70
|
}
|
|
72
71
|
return String(err);
|
|
73
72
|
}
|
|
73
|
+
/** Raw diagnostic for hosts, traces, and logs; never shown to end users. Each cause that says something new follows on its own line. */
|
|
74
|
+
function describeError(err) {
|
|
75
|
+
const lines = [messageOf(err)];
|
|
76
|
+
const seen = new Set([err]);
|
|
77
|
+
let said = messageOf(err);
|
|
78
|
+
let cause = err instanceof Error ? err.cause : undefined;
|
|
79
|
+
while (cause !== undefined && cause !== null && !seen.has(cause)) {
|
|
80
|
+
seen.add(cause);
|
|
81
|
+
const message = messageOf(cause);
|
|
82
|
+
if (message !== said) {
|
|
83
|
+
const name = cause instanceof Error && cause.name !== 'Error' ? `${cause.name}: ` : '';
|
|
84
|
+
lines.push(`caused by: ${name}${message}`);
|
|
85
|
+
said = message;
|
|
86
|
+
}
|
|
87
|
+
cause = cause instanceof Error ? cause.cause : undefined;
|
|
88
|
+
}
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
74
91
|
/** No user wording yet: `withPublicLabels` adds it where the event reaches the host, the one place that knows the profile. */
|
|
75
92
|
function toErrorEvent(err) {
|
|
76
93
|
return {
|
|
@@ -95,6 +95,11 @@ export interface GuardrailEvent {
|
|
|
95
95
|
provenance?: Provenance;
|
|
96
96
|
/** The block verdict's `errorInternal`: builder-only, stripped by `forClient`. */
|
|
97
97
|
errorInternal?: string;
|
|
98
|
+
/**
|
|
99
|
+
* A failed host detector's `errorInternal` in the kernel's words only, with the same error id:
|
|
100
|
+
* what the trace stores. Builder-only, stripped by `forClient`.
|
|
101
|
+
*/
|
|
102
|
+
errorSummary?: string;
|
|
98
103
|
}
|
|
99
104
|
/** The schema a guardrail event parses against. */
|
|
100
105
|
export declare const guardrailEventSchema: z.ZodType<GuardrailEvent>;
|
|
@@ -59,9 +59,6 @@ interface HostMatcher {
|
|
|
59
59
|
}
|
|
60
60
|
/** Bumped whenever the compiled table's layout or meaning changes. */
|
|
61
61
|
declare const PATTERN_COMPILER_VERSION = 3;
|
|
62
|
-
/** The most patterns one detector takes, and the longest each may be once written as a regular expression. */
|
|
63
|
-
declare const MAX_PATTERNS = 64;
|
|
64
|
-
declare const MAX_PATTERN_LENGTH = 1024;
|
|
65
62
|
/** The patterns as a compiled table records them. */
|
|
66
63
|
declare function patternSources(patterns: readonly HostPattern[]): PatternSource[];
|
|
67
64
|
/** What is wrong with one pattern, or `undefined` when it runs. */
|
|
@@ -82,4 +79,4 @@ declare function listsPatterns(detect: unknown): boolean;
|
|
|
82
79
|
/** `patterns`, valid, ready to run. */
|
|
83
80
|
declare function matchersOf(patterns: readonly HostPattern[]): readonly HostMatcher[];
|
|
84
81
|
export type { AutomatonData, CompiledPatterns, HostMatcher, HostPattern, PatternSource };
|
|
85
|
-
export { listsPatterns,
|
|
82
|
+
export { listsPatterns, matchersOf, PATTERN_COMPILER_VERSION, patternListProblem, patternProblem, patternSources, patternsProblem, };
|
|
@@ -2,9 +2,6 @@
|
|
|
2
2
|
// `detectors.ts` can read a host's patterns without a cycle.
|
|
3
3
|
/** Bumped whenever the compiled table's layout or meaning changes. */
|
|
4
4
|
const PATTERN_COMPILER_VERSION = 3;
|
|
5
|
-
/** The most patterns one detector takes, and the longest each may be once written as a regular expression. */
|
|
6
|
-
const MAX_PATTERNS = 64;
|
|
7
|
-
const MAX_PATTERN_LENGTH = 1024;
|
|
8
5
|
const PATTERN_FLAGS = 'imsu';
|
|
9
6
|
/** A letter, digit, or underscore in any script: what a word may not touch on either side. */
|
|
10
7
|
const WORD_UNIT = '[\\p{L}\\p{N}_]';
|
|
@@ -79,9 +76,6 @@ function patternProblem(path, pattern) {
|
|
|
79
76
|
if (shape !== undefined)
|
|
80
77
|
return shape;
|
|
81
78
|
const { source, flags } = patternSource(pattern);
|
|
82
|
-
if (source.length > MAX_PATTERN_LENGTH) {
|
|
83
|
-
return `${path} is ${source.length} characters as a regular expression; the most is ${MAX_PATTERN_LENGTH}`;
|
|
84
|
-
}
|
|
85
79
|
let regex;
|
|
86
80
|
try {
|
|
87
81
|
// why: this only checks that the pattern parses and tests it on the empty text; a pattern that
|
|
@@ -134,9 +128,6 @@ function patternsProblem(path, patterns, compiled) {
|
|
|
134
128
|
function patternListProblem(path, patterns) {
|
|
135
129
|
if (!Array.isArray(patterns))
|
|
136
130
|
return `${path}.patterns must be a list`;
|
|
137
|
-
if (patterns.length > MAX_PATTERNS) {
|
|
138
|
-
return `${path}.patterns lists ${patterns.length} patterns; the most is ${MAX_PATTERNS}`;
|
|
139
|
-
}
|
|
140
131
|
const names = new Set();
|
|
141
132
|
for (const [index, pattern] of patterns.entries()) {
|
|
142
133
|
const at = `${path}.patterns[${index}]`;
|
|
@@ -170,4 +161,4 @@ function matchersOf(patterns) {
|
|
|
170
161
|
}
|
|
171
162
|
return matchers;
|
|
172
163
|
}
|
|
173
|
-
export { listsPatterns,
|
|
164
|
+
export { listsPatterns, matchersOf, PATTERN_COMPILER_VERSION, patternListProblem, patternProblem, patternSources, patternsProblem, };
|
|
@@ -25,7 +25,7 @@ export { errorCopiesSchema, errorKindSchema, guardrailEventSchema } from './even
|
|
|
25
25
|
export { guardrailFromHits, guardrailFromVerdict, guardrailTurnEvent, projectGuardrailTurnEvent, } from './events.js';
|
|
26
26
|
export { hitFromSpan, projectGuardrailEvent, } from './hits.js';
|
|
27
27
|
export type { CompiledPatterns, HostPattern } from './host-patterns.js';
|
|
28
|
-
export { listsPatterns,
|
|
28
|
+
export { listsPatterns, patternListProblem, } from './host-patterns.js';
|
|
29
29
|
export { injectionSpans } from './injection.js';
|
|
30
30
|
export type { ClientLabelKey, LabelKey, LabelOverrides, LabelParams } from './labels.js';
|
|
31
31
|
export { CLIENT_LABEL_KEYS, LABEL_KEYS, labelCatalogRows, labelDefault, labelText, overrideLabels, resetLabels, validateLabelOverrides, } from './labels.js';
|
|
@@ -16,7 +16,7 @@ export { describeError, ERROR_KINDS, errorKind, isAbortError, isTimeoutError, ki
|
|
|
16
16
|
export { errorCopiesSchema, errorKindSchema, guardrailEventSchema } from './event-schemas.js';
|
|
17
17
|
export { guardrailFromHits, guardrailFromVerdict, guardrailTurnEvent, projectGuardrailTurnEvent, } from './events.js';
|
|
18
18
|
export { hitFromSpan, projectGuardrailEvent, } from './hits.js';
|
|
19
|
-
export { listsPatterns,
|
|
19
|
+
export { listsPatterns, patternListProblem, } from './host-patterns.js';
|
|
20
20
|
export { injectionSpans } from './injection.js';
|
|
21
21
|
export { CLIENT_LABEL_KEYS, LABEL_KEYS, labelCatalogRows, labelDefault, labelText, overrideLabels, resetLabels, validateLabelOverrides, } from './labels.js';
|
|
22
22
|
export { abortLiveOutboundTurn, createLiveOutboundGateSession, finalizeLiveOutboundTurn, processLiveOutboundBatch, } from './live-outbound-gate.js';
|
|
@@ -19,7 +19,7 @@ declare function sanitizeProjectId(id: string | undefined): string | undefined;
|
|
|
19
19
|
*/
|
|
20
20
|
declare function sanitizeHistory(history: TurnHistoryMessage[], reader: BoundaryReader): TurnHistoryMessage[];
|
|
21
21
|
/** The error a turn ends on when a detector set to `block` matched at `boundary`. */
|
|
22
|
-
declare function requestRefused(boundary: Boundary): TheoremError;
|
|
22
|
+
declare function requestRefused(boundary: Boundary, reason?: string): TheoremError;
|
|
23
23
|
/** A turn request read at each boundary it crosses. */
|
|
24
24
|
interface SanitizedTurnRequest {
|
|
25
25
|
request: NormalizedTurnRequest;
|
|
@@ -91,9 +91,10 @@ function sanitizeHistory(history, reader) {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
/** The error a turn ends on when a detector set to `block` matched at `boundary`. */
|
|
94
|
-
function requestRefused(boundary) {
|
|
94
|
+
function requestRefused(boundary, reason) {
|
|
95
|
+
const why = reason ? `: ${reason}` : '';
|
|
95
96
|
// labels-exempt: developer contract / internal diagnostic — the user's wording is `detect.blocked`
|
|
96
|
-
return new TheoremError('input', `guardrails.detect blocked the request at ${boundary}`, {
|
|
97
|
+
return new TheoremError('input', `guardrails.detect blocked the request at ${boundary}${why}`, {
|
|
97
98
|
copy: { key: 'detect.blocked' },
|
|
98
99
|
});
|
|
99
100
|
}
|
|
@@ -125,7 +126,7 @@ function sanitizeTurnRequestWithEvents(req, profile) {
|
|
|
125
126
|
if (event)
|
|
126
127
|
events.push(guardrailTurnEvent(event));
|
|
127
128
|
if (found.action === 'block')
|
|
128
|
-
refusal ??= requestRefused(boundary);
|
|
129
|
+
refusal ??= requestRefused(boundary, found.errorInternal);
|
|
129
130
|
}
|
|
130
131
|
events.push(...told.events.map(guardrailTurnEvent));
|
|
131
132
|
if (told.blocked)
|
|
@@ -33,11 +33,19 @@ function stripErrorInternal(event) {
|
|
|
33
33
|
const { errorInternal: _internal, ...rest } = event;
|
|
34
34
|
return rest;
|
|
35
35
|
}
|
|
36
|
+
function stripCompactionInternal(event) {
|
|
37
|
+
if (event.type !== 'compaction' || event.failure?.errorInternal === undefined) {
|
|
38
|
+
return event;
|
|
39
|
+
}
|
|
40
|
+
const { errorInternal: _internal, ...failure } = event.failure;
|
|
41
|
+
return { ...event, failure };
|
|
42
|
+
}
|
|
36
43
|
function stripGuardrailInternal(event) {
|
|
37
|
-
if (event.type !== 'guardrail' ||
|
|
44
|
+
if (event.type !== 'guardrail' ||
|
|
45
|
+
!(event.guardrail?.errorInternal || event.guardrail?.errorSummary)) {
|
|
38
46
|
return event;
|
|
39
47
|
}
|
|
40
|
-
const { errorInternal: _internal, ...guardrail } = event.guardrail;
|
|
48
|
+
const { errorInternal: _internal, errorSummary: _summary, ...guardrail } = event.guardrail;
|
|
41
49
|
return { ...event, guardrail };
|
|
42
50
|
}
|
|
43
51
|
function stripEvidenceRaw(event) {
|
|
@@ -54,7 +62,7 @@ function stripEvidenceRaw(event) {
|
|
|
54
62
|
function forClient(event, options) {
|
|
55
63
|
let out = options?.reader === 'builder'
|
|
56
64
|
? builderWording(event)
|
|
57
|
-
: stripGuardrailInternal(stripErrorInternal(event));
|
|
65
|
+
: stripCompactionInternal(stripGuardrailInternal(stripErrorInternal(event)));
|
|
58
66
|
if (!options?.includeEvidenceRaw) {
|
|
59
67
|
out = stripEvidenceRaw(out);
|
|
60
68
|
}
|
|
@@ -153,7 +153,8 @@ function updateFlowForRetry(flow, state, profile, nextReq, reason) {
|
|
|
153
153
|
flow.currentReq = nextReq;
|
|
154
154
|
const safe = sanitizeTurnRequest(nextReq, profile);
|
|
155
155
|
if (profile.type === 'text') {
|
|
156
|
-
// why: The
|
|
156
|
+
// why: The repair is the turn's next user message, and the retry reads this turn alone.
|
|
157
|
+
state.turnOnly = true;
|
|
157
158
|
const before = state.currentHistory.length;
|
|
158
159
|
appendUserInput(state, resolveInputParts(profile, { ...safe, input: { repair: safe.input?.repair } }));
|
|
159
160
|
for (const message of state.currentHistory.slice(before))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canaryNote, contextNote, profileCanary, userDataNote, } from '../../../guardrails/canary.js';
|
|
2
|
-
import { isAbortError, TheoremError, throwIfAborted, withPublicLabels, } from '../../../guardrails/error.js';
|
|
2
|
+
import { describeError, errorKind, isAbortError, TheoremError, throwIfAborted, withPublicLabels, } from '../../../guardrails/error.js';
|
|
3
3
|
import { projectGuardrailTurnEvent } from '../../../guardrails/events.js';
|
|
4
4
|
import { labelText } from '../../../guardrails/labels.js';
|
|
5
5
|
import { sanitizeTurnRequestWithEvents } from '../../../guardrails/sanitize.js';
|
|
@@ -22,7 +22,7 @@ import { sumEventTokens, sumTurnTokens } from '../usage.js';
|
|
|
22
22
|
import { runAttemptsWithValidation } from './gates.js';
|
|
23
23
|
import { createAgentCaller, NESTED_THROWS, turnError } from './nested.js';
|
|
24
24
|
import { applyTurnStage } from './stages.js';
|
|
25
|
-
import { openTurnState } from './state.js';
|
|
25
|
+
import { cutConversation, openTurnState, } from './state.js';
|
|
26
26
|
import { shouldSkipStreamEvent } from './stream.js';
|
|
27
27
|
function projectForObs(event, policy) {
|
|
28
28
|
return projectGuardrailTurnEvent(event, policy?.include.guardrailMatchPreview ?? false);
|
|
@@ -105,10 +105,10 @@ async function runCompactor(args) {
|
|
|
105
105
|
}
|
|
106
106
|
catch (err) {
|
|
107
107
|
throwIfAborted(args.signal);
|
|
108
|
-
const error = err
|
|
108
|
+
const error = errorKind(err);
|
|
109
109
|
if (NESTED_THROWS.has(error))
|
|
110
110
|
throw err;
|
|
111
|
-
return { droppedMedia, failure: { error } };
|
|
111
|
+
return { droppedMedia, failure: { error, errorInternal: describeError(err) } };
|
|
112
112
|
}
|
|
113
113
|
throwIfAborted(args.signal);
|
|
114
114
|
const tokens = sumEventTokens(events);
|
|
@@ -122,7 +122,11 @@ async function runCompactor(args) {
|
|
|
122
122
|
if (stop !== 'completed' || error) {
|
|
123
123
|
return {
|
|
124
124
|
droppedMedia,
|
|
125
|
-
failure: {
|
|
125
|
+
failure: {
|
|
126
|
+
...(stop ? { stop } : {}),
|
|
127
|
+
...(error ? { error } : {}),
|
|
128
|
+
...(reported?.errorInternal ? { errorInternal: reported.errorInternal } : {}),
|
|
129
|
+
},
|
|
126
130
|
...usage,
|
|
127
131
|
};
|
|
128
132
|
}
|
|
@@ -163,6 +167,9 @@ function compactionOutcome(result, messagesBefore) {
|
|
|
163
167
|
dropped_media: result.droppedMedia,
|
|
164
168
|
...(result.failure?.stop ? { failure_stop: result.failure.stop } : {}),
|
|
165
169
|
...(result.failure?.error ? { failure_error: result.failure.error } : {}),
|
|
170
|
+
...(result.failure?.errorInternal
|
|
171
|
+
? { failure_reason: traceContent(result.failure.errorInternal) }
|
|
172
|
+
: {}),
|
|
166
173
|
...(result.failure?.empty ? { failure_empty: true } : {}),
|
|
167
174
|
...(result.failure?.unreadable ? { failure_unreadable: true } : {}),
|
|
168
175
|
...(result.summary === undefined ? {} : { summary: traceContent(result.summary) }),
|
|
@@ -274,6 +281,7 @@ async function* emitTurn(args) {
|
|
|
274
281
|
const done = turnDoneOf({
|
|
275
282
|
stop: state.lastStop ?? { kind: 'completed' },
|
|
276
283
|
...(state.providerState &&
|
|
284
|
+
!cutConversation(state) &&
|
|
277
285
|
!('providerContinuation' in profile && profile.providerContinuation?.history === 'stateless') &&
|
|
278
286
|
(!state.lastStop || ['completed', 'tool', 'gate'].includes(state.lastStop.kind))
|
|
279
287
|
? { providerState: state.providerState }
|
|
@@ -23,6 +23,10 @@ interface StepExecutionState {
|
|
|
23
23
|
agents?: AgentCaller;
|
|
24
24
|
trace: TurnTraceState;
|
|
25
25
|
currentHistory: TurnHistoryMessage[];
|
|
26
|
+
/** Where this turn begins in `currentHistory`: what came before is the conversation the host passed. */
|
|
27
|
+
turnStart: number;
|
|
28
|
+
/** Set by a retry: from then on the model reads this turn only, from `turnStart`. */
|
|
29
|
+
turnOnly?: boolean;
|
|
26
30
|
stepCount: number;
|
|
27
31
|
/** Model calls sent again this turn under the profile's resumption (`executeResumedStep`). */
|
|
28
32
|
resumes: number;
|
|
@@ -106,5 +110,9 @@ declare function openTurnState(args: {
|
|
|
106
110
|
allEmittedEvents?: TurnEvent[];
|
|
107
111
|
agents?: AgentCaller;
|
|
108
112
|
}): StepExecutionState;
|
|
113
|
+
/** The history the next model call reads: this turn alone once a retry has cut it. */
|
|
114
|
+
declare function modelHistory(state: StepExecutionState): TurnHistoryMessage[];
|
|
115
|
+
/** Whether the turn's checkpoint covers less than the conversation the host keeps. */
|
|
116
|
+
declare function cutConversation(state: StepExecutionState): boolean;
|
|
109
117
|
export type { AttemptFlowState, PendingItemFix, StepExecutionState, TurnTraceState };
|
|
110
|
-
export { appendUserInput, openTurnState, recordStepEvent };
|
|
118
|
+
export { appendUserInput, cutConversation, modelHistory, openTurnState, recordStepEvent };
|
|
@@ -28,6 +28,7 @@ function openTurnState(args) {
|
|
|
28
28
|
tools: args.tools,
|
|
29
29
|
trace: args.trace,
|
|
30
30
|
currentHistory: [...(generation.history ?? [])],
|
|
31
|
+
turnStart: generation.history?.length ?? 0,
|
|
31
32
|
stepCount: 0,
|
|
32
33
|
resumes: 0,
|
|
33
34
|
toolResults: [],
|
|
@@ -49,4 +50,12 @@ function openTurnState(args) {
|
|
|
49
50
|
}
|
|
50
51
|
return state;
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** The history the next model call reads: this turn alone once a retry has cut it. */
|
|
54
|
+
function modelHistory(state) {
|
|
55
|
+
return state.turnOnly ? state.currentHistory.slice(state.turnStart) : state.currentHistory;
|
|
56
|
+
}
|
|
57
|
+
/** Whether the turn's checkpoint covers less than the conversation the host keeps. */
|
|
58
|
+
function cutConversation(state) {
|
|
59
|
+
return state.turnOnly === true && state.turnStart > 0;
|
|
60
|
+
}
|
|
61
|
+
export { appendUserInput, cutConversation, modelHistory, openTurnState, recordStepEvent };
|
|
@@ -21,7 +21,7 @@ import { findLast } from '../../util/find-last.js';
|
|
|
21
21
|
import { startToolTrace } from '../tool-trace.js';
|
|
22
22
|
import { startCallTrace } from '../turn-trace.js';
|
|
23
23
|
import { applyStageInjects, recordReply } from './stages.js';
|
|
24
|
-
import { recordStepEvent } from './state.js';
|
|
24
|
+
import { modelHistory, recordStepEvent } from './state.js';
|
|
25
25
|
import { inStep, runSideBySide, stepOrder } from './step-order.js';
|
|
26
26
|
import { isWithheldOnBlock, StructuredDrafts, yieldProviderEvents, } from './stream.js';
|
|
27
27
|
import { callTokensEvent, observeCallEvent, startCallUsage } from './usage.js';
|
|
@@ -30,7 +30,7 @@ function isStepLimitReached(step, maxSteps) {
|
|
|
30
30
|
}
|
|
31
31
|
/** The step's generation: the turn's history so far, and no tools when the step must answer. */
|
|
32
32
|
function generationForProviderStep(generation, state, answerOnly) {
|
|
33
|
-
const forStep = { ...generation, history: state
|
|
33
|
+
const forStep = { ...generation, history: modelHistory(state) };
|
|
34
34
|
if (!answerOnly)
|
|
35
35
|
return forStep;
|
|
36
36
|
return { ...forStep, builtins: [], tools: { ...generation.tools, wire: [] } };
|
|
@@ -63,7 +63,7 @@ function holdModelCall(calls, tool) {
|
|
|
63
63
|
/** The step's request, with what it gives the model noted on `state`, and its call's usage and trace. */
|
|
64
64
|
function startProviderCall(generation, system, state, answerOnly, labels) {
|
|
65
65
|
const usage = startCallUsage(system.text, {
|
|
66
|
-
history: state
|
|
66
|
+
history: modelHistory(state),
|
|
67
67
|
input: generation.input,
|
|
68
68
|
});
|
|
69
69
|
state.lastCall = usage;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { errorKind } from '../../guardrails/error.js';
|
|
1
|
+
import { describeError, errorKind } from '../../guardrails/error.js';
|
|
2
2
|
import { traceBytes, traceContent, traceJson, } from '../../observability/trace-span.js';
|
|
3
3
|
import { historyMessageParts } from '../interaction-parts.js';
|
|
4
4
|
import { isFailedStopKind } from '../stop.js';
|
|
@@ -278,6 +278,7 @@ function guardrailAttributes(guardrail) {
|
|
|
278
278
|
hits: guardrail.hits.map((hit) => ({
|
|
279
279
|
rule: hit.rule,
|
|
280
280
|
severity: hit.severity,
|
|
281
|
+
...optional('signal', hit.signal),
|
|
281
282
|
...(hit.span ? { start: hit.span.start, end: hit.span.end } : {}),
|
|
282
283
|
...optional('match', hit.match),
|
|
283
284
|
...optional('label', hit.label),
|
|
@@ -285,6 +286,7 @@ function guardrailAttributes(guardrail) {
|
|
|
285
286
|
})),
|
|
286
287
|
...(guardrail.provenance ? { provenance: { ...guardrail.provenance } } : {}),
|
|
287
288
|
...(guardrail.errorInternal ? { error: traceContent(guardrail.errorInternal) } : {}),
|
|
289
|
+
...optional('error_summary', guardrail.errorSummary),
|
|
288
290
|
};
|
|
289
291
|
}
|
|
290
292
|
/** What a stream check looked at, for its pass record. */
|
|
@@ -597,7 +599,7 @@ function errorName(err) {
|
|
|
597
599
|
function recordException(span, err) {
|
|
598
600
|
span.event('exception', {
|
|
599
601
|
'exception.type': errorName(err),
|
|
600
|
-
'exception.message': traceContent(
|
|
602
|
+
'exception.message': traceContent(describeError(err)),
|
|
601
603
|
...(err instanceof Error && err.stack
|
|
602
604
|
? { 'exception.stacktrace': traceContent(err.stack) }
|
|
603
605
|
: {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { kindOfHttpStatus, TheoremError } from '../../guardrails/error.js';
|
|
1
|
+
import { describeError, kindOfHttpStatus, TheoremError } from '../../guardrails/error.js';
|
|
2
2
|
import { labelText } from '../../guardrails/labels.js';
|
|
3
3
|
import { retryTransient, waitDefault } from '../../providers/shared/retry.js';
|
|
4
4
|
import { readSseChunks } from '../../providers/shared/sse.js';
|
|
@@ -374,7 +374,7 @@ function failureEvent(error) {
|
|
|
374
374
|
type: 'error',
|
|
375
375
|
errorKind: error.kind,
|
|
376
376
|
// invariant: `error` is the person's wording, added from the profile's labels; the upstream text is internal.
|
|
377
|
-
errorInternal: error
|
|
377
|
+
errorInternal: describeError(error),
|
|
378
378
|
...(error.copy ? { errorCopy: error.copy } : {}),
|
|
379
379
|
};
|
|
380
380
|
}
|
|
@@ -128,11 +128,14 @@ function diagnosticError(error, clean) {
|
|
|
128
128
|
return new DOMException(message, error.name);
|
|
129
129
|
if (isAbortError(error))
|
|
130
130
|
return new DOMException(message, 'AbortError');
|
|
131
|
+
const cause = error instanceof Error && error.cause !== undefined
|
|
132
|
+
? { cause: diagnosticError(error.cause, clean) }
|
|
133
|
+
: {};
|
|
131
134
|
const result = error instanceof TheoremError
|
|
132
|
-
? new TheoremError(error.kind, message, { copy: diagnosticCopy(error.copy, clean) })
|
|
135
|
+
? new TheoremError(error.kind, message, { copy: diagnosticCopy(error.copy, clean), ...cause })
|
|
133
136
|
: error instanceof TypeError
|
|
134
|
-
? new TypeError(message)
|
|
135
|
-
: new Error(message);
|
|
137
|
+
? new TypeError(message, cause)
|
|
138
|
+
: new Error(message, cause);
|
|
136
139
|
if (isRecord(error) && typeof error.status === 'number')
|
|
137
140
|
Object.assign(result, { status: error.status });
|
|
138
141
|
return result;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TOOL_BOUNDARIES } from '../../guardrails/boundaries.js';
|
|
2
2
|
import { detectProblem } from '../../guardrails/detectors.js';
|
|
3
|
-
import { TheoremError } from '../../guardrails/error.js';
|
|
3
|
+
import { errorKind, TheoremError } from '../../guardrails/error.js';
|
|
4
4
|
import { labelText, validateLabelOverrides } from '../../guardrails/labels.js';
|
|
5
5
|
import { BLOCKED_REPLY_ON_BLOCK, } from '../../guardrails/types.js';
|
|
6
6
|
import { resolveObservabilityPolicy } from '../../observability/resolve-policy.js';
|
|
@@ -332,7 +332,7 @@ function assertObservability(profileId, spec) {
|
|
|
332
332
|
throw err;
|
|
333
333
|
}
|
|
334
334
|
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
-
throw new TheoremError(
|
|
335
|
+
throw new TheoremError(errorKind(err), `Profile ${profileId}: ${message}`, { cause: err });
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
function assertIdentitySystem(input) {
|
package/esm/src/kernel/schema.js
CHANGED
|
@@ -798,6 +798,22 @@ export const EXTRA_FIELDS = {
|
|
|
798
798
|
...field("'page'", 'Who answers a call to a function tool. With page, the page the agent is on returns the output, and the tool has no handler.', ['page']),
|
|
799
799
|
unset: 'Its handler',
|
|
800
800
|
},
|
|
801
|
+
'registerTool.maxResponseBytes': {
|
|
802
|
+
...field('number', 'The most bytes of a response body an HTTP or MCP tool reads; past it the call fails with response_too_large.'),
|
|
803
|
+
unset: 'No limit',
|
|
804
|
+
},
|
|
805
|
+
'registerTool.session': {
|
|
806
|
+
...field('McpSessionLimits', "How long and how many of an MCP tool's server sessions the kernel keeps."),
|
|
807
|
+
unset: 'No limits',
|
|
808
|
+
},
|
|
809
|
+
'registerTool.session.idleMs': {
|
|
810
|
+
...field('number', 'How long a session may sit unused before the next call opens a new one, in milliseconds.'),
|
|
811
|
+
unset: 'No limit',
|
|
812
|
+
},
|
|
813
|
+
'registerTool.session.max': {
|
|
814
|
+
...field('number', 'The most sessions the tool keeps open; past it the least recently used is dropped.'),
|
|
815
|
+
unset: 'No limit',
|
|
816
|
+
},
|
|
801
817
|
'registerTool.maxCallsPerTurn': {
|
|
802
818
|
...field('number', 'How many times one turn of the calling agent may run this tool.'),
|
|
803
819
|
unset: 'As many as its steps allow',
|
|
@@ -742,7 +742,9 @@ async function* settleBodyOutcome(args) {
|
|
|
742
742
|
provisional: { outputRaw: outcome.outputRaw, modelResult: outcome.modelResult },
|
|
743
743
|
sources: tool.sources,
|
|
744
744
|
...(tool.type !== 'function' && tool.images
|
|
745
|
-
? {
|
|
745
|
+
? {
|
|
746
|
+
images: (output) => fetchToolImages(tool.images, output, ctx, tool.imageLimits),
|
|
747
|
+
}
|
|
746
748
|
: {}),
|
|
747
749
|
labels: tool.labels,
|
|
748
750
|
reproject: makeReproject((v) => parseToolOutput(tool.output, v),
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import type { InteractionPart, ToolWarning } from '../turn-events.js';
|
|
2
|
-
import type { ToolContext } from './types.js';
|
|
3
|
-
/** The most pictures one call hands the model. */
|
|
4
|
-
export declare const MAX_TOOL_IMAGES = 4;
|
|
2
|
+
import type { ToolContext, ToolImageLimits } from './types.js';
|
|
5
3
|
/**
|
|
6
4
|
* The pictures a tool's `images(output)` names, fetched through the profile's network policy (every
|
|
7
5
|
* hop, redirects included, no tool credentials) and handed to the model as image parts. A picture
|
|
8
6
|
* that is not an https address, blocked, slow, too big or not an image is left out and reported in
|
|
9
7
|
* one `images_unfetched` warning: the call itself stays whole. `urls` are the ones fetched.
|
|
10
8
|
*/
|
|
11
|
-
export declare function fetchToolImages(images: (output: never) => string[], output: unknown, ctx: ToolContext): Promise<{
|
|
9
|
+
export declare function fetchToolImages(images: (output: never) => string[], output: unknown, ctx: ToolContext, limits?: ToolImageLimits): Promise<{
|
|
12
10
|
parts: InteractionPart[];
|
|
13
11
|
urls: string[];
|
|
14
12
|
warning?: ToolWarning;
|
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { fetchGuarded } from '../../guardrails/network.js';
|
|
2
2
|
import { bytesToBase64 } from '../util/base64.js';
|
|
3
3
|
import { toolNetworkPolicy } from './events.js';
|
|
4
|
-
/** The most pictures one call hands the model. */
|
|
5
|
-
export const MAX_TOOL_IMAGES = 4;
|
|
6
|
-
/** The largest picture fetched; a larger one is left out, not cut. */
|
|
7
|
-
const MAX_TOOL_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8
|
-
/** The most one call hands the model in all, so pictures cannot crowd a request past a provider's limit. */
|
|
9
|
-
const MAX_TOOL_IMAGES_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
10
|
-
/** How long each picture may take, so a slow host cannot hold the call. */
|
|
11
|
-
const IMAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
12
4
|
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
13
|
-
/** The body, read up to `maxBytes
|
|
14
|
-
async function readCappedBytes(response, maxBytes) {
|
|
5
|
+
/** The body, read up to `maxBytes` when set; `undefined` when it runs past. */
|
|
6
|
+
async function readCappedBytes(response, maxBytes = Number.POSITIVE_INFINITY) {
|
|
15
7
|
const declared = Number(response.headers.get('content-length'));
|
|
16
8
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
17
9
|
await response.body?.cancel().catch(() => { });
|
|
@@ -41,9 +33,15 @@ async function readCappedBytes(response, maxBytes) {
|
|
|
41
33
|
}
|
|
42
34
|
return bytes;
|
|
43
35
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
/** The call's signal, joined by the picture's own timeout when the tool sets one. */
|
|
37
|
+
function fetchSignal(ctx, timeoutMs) {
|
|
38
|
+
if (timeoutMs === undefined)
|
|
39
|
+
return ctx.signal;
|
|
40
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
41
|
+
return ctx.signal ? AbortSignal.any([ctx.signal, timeout]) : timeout;
|
|
42
|
+
}
|
|
43
|
+
async function fetchOne(url, ctx, limits) {
|
|
44
|
+
const signal = fetchSignal(ctx, limits.timeoutMs);
|
|
47
45
|
const response = await fetchGuarded(url, { method: 'GET', headers: { Accept: 'image/*' }, signal }, {
|
|
48
46
|
policy: toolNetworkPolicy(ctx),
|
|
49
47
|
followRedirects: true,
|
|
@@ -56,9 +54,9 @@ async function fetchOne(url, ctx) {
|
|
|
56
54
|
await response.body?.cancel().catch(() => { });
|
|
57
55
|
throw new Error(`not a png, jpeg, gif or webp image (${mimeType || 'no type'})`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
58
56
|
}
|
|
59
|
-
const bytes = await readCappedBytes(response,
|
|
57
|
+
const bytes = await readCappedBytes(response, limits.bytes);
|
|
60
58
|
if (!bytes)
|
|
61
|
-
throw new Error(`larger than ${
|
|
59
|
+
throw new Error(`larger than ${limits.bytes} bytes`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
62
60
|
return { type: 'image', mimeType, data: bytesToBase64(bytes) };
|
|
63
61
|
}
|
|
64
62
|
/** `url` when it is an absolute https address; otherwise the reason it is not. */
|
|
@@ -80,7 +78,7 @@ function httpsAddress(url) {
|
|
|
80
78
|
* that is not an https address, blocked, slow, too big or not an image is left out and reported in
|
|
81
79
|
* one `images_unfetched` warning: the call itself stays whole. `urls` are the ones fetched.
|
|
82
80
|
*/
|
|
83
|
-
export async function fetchToolImages(images, output, ctx) {
|
|
81
|
+
export async function fetchToolImages(images, output, ctx, limits = {}) {
|
|
84
82
|
const reasons = [];
|
|
85
83
|
const named = [];
|
|
86
84
|
try {
|
|
@@ -100,8 +98,9 @@ export async function fetchToolImages(images, output, ctx) {
|
|
|
100
98
|
warning: unfetched([`the images hook threw: ${String(err)}`]), // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
101
99
|
};
|
|
102
100
|
}
|
|
103
|
-
const wanted = named.slice(0,
|
|
104
|
-
const settled = await Promise.allSettled(wanted.map((url) => fetchOne(url, ctx)));
|
|
101
|
+
const wanted = named.slice(0, limits.count);
|
|
102
|
+
const settled = await Promise.allSettled(wanted.map((url) => fetchOne(url, ctx, limits)));
|
|
103
|
+
const totalBytes = limits.totalBytes ?? Number.POSITIVE_INFINITY;
|
|
105
104
|
const parts = [];
|
|
106
105
|
const urls = [];
|
|
107
106
|
let total = 0;
|
|
@@ -113,8 +112,8 @@ export async function fetchToolImages(images, output, ctx) {
|
|
|
113
112
|
return;
|
|
114
113
|
}
|
|
115
114
|
const size = 'data' in result.value ? result.value.data.length : 0;
|
|
116
|
-
if (total + size >
|
|
117
|
-
reasons.push(`${new URL(url).hostname}: past the ${
|
|
115
|
+
if (total + size > totalBytes) {
|
|
116
|
+
reasons.push(`${new URL(url).hostname}: past the ${totalBytes} byte total`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
118
117
|
return;
|
|
119
118
|
}
|
|
120
119
|
total += size;
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
* Sessions for MCP servers that refuse a call without one. A session is a cache, not state: it
|
|
3
3
|
* lives in memory on one tool registry, is never persisted, and losing it costs one reopen.
|
|
4
4
|
*/
|
|
5
|
+
import type { McpSessionLimits } from './types.js';
|
|
5
6
|
export type McpSession = {
|
|
6
7
|
id: string;
|
|
7
8
|
protocolVersion: string;
|
|
8
9
|
};
|
|
10
|
+
/** The tool a session is used for, and that tool's own `session` limits. */
|
|
11
|
+
export interface McpSessionUse extends McpSessionLimits {
|
|
12
|
+
tool: string;
|
|
13
|
+
}
|
|
9
14
|
export interface McpSessionCache {
|
|
10
|
-
get(key: string): McpSession | undefined;
|
|
15
|
+
get(key: string, use: McpSessionUse): McpSession | undefined;
|
|
11
16
|
/** Parallel callers for one key share a single `open`. */
|
|
12
|
-
open(key: string, open: () => Promise<McpSession>): Promise<McpSession>;
|
|
17
|
+
open(key: string, use: McpSessionUse, open: () => Promise<McpSession>): Promise<McpSession>;
|
|
13
18
|
/** Only drops the session if it's still `id`, so a late expiry can't evict a newer one. */
|
|
14
19
|
drop(key: string, id: string): void;
|
|
15
20
|
clear(): void;
|
|
@@ -2,38 +2,47 @@
|
|
|
2
2
|
* Sessions for MCP servers that refuse a call without one. A session is a cache, not state: it
|
|
3
3
|
* lives in memory on one tool registry, is never persisted, and losing it costs one reopen.
|
|
4
4
|
*/
|
|
5
|
-
const MAX_SESSIONS = 256;
|
|
6
|
-
const IDLE_MS = 30 * 60 * 1000;
|
|
7
5
|
// why: The spec allows visible ASCII only; the cap keeps a server from bloating every later request.
|
|
8
6
|
const SESSION_ID = /^[\x21-\x7E]{1,256}$/;
|
|
9
7
|
export function createMcpSessionCache(now = Date.now) {
|
|
10
8
|
const sessions = new Map();
|
|
11
9
|
const pending = new Map();
|
|
10
|
+
/** Drops the tool's least recently used sessions past its `max`. */
|
|
11
|
+
const trim = ({ tool, max }) => {
|
|
12
|
+
if (max === undefined)
|
|
13
|
+
return;
|
|
14
|
+
let held = [...sessions.values()].filter((entry) => entry.tool === tool).length;
|
|
15
|
+
for (const [key, entry] of sessions) {
|
|
16
|
+
if (held <= max)
|
|
17
|
+
break;
|
|
18
|
+
if (entry.tool !== tool)
|
|
19
|
+
continue;
|
|
20
|
+
sessions.delete(key);
|
|
21
|
+
held--;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
12
24
|
return {
|
|
13
|
-
get(key) {
|
|
25
|
+
get(key, use) {
|
|
14
26
|
const entry = sessions.get(key);
|
|
15
27
|
if (!entry)
|
|
16
28
|
return undefined;
|
|
17
29
|
sessions.delete(key);
|
|
18
|
-
if (now() - entry.usedAt >
|
|
30
|
+
if (use.idleMs !== undefined && now() - entry.usedAt > use.idleMs)
|
|
19
31
|
return undefined;
|
|
20
32
|
// why: Re-inserting keeps the map in least-recently-used order for eviction.
|
|
21
|
-
sessions.set(key, { ...entry, usedAt: now() });
|
|
33
|
+
sessions.set(key, { ...entry, usedAt: now(), tool: use.tool });
|
|
34
|
+
trim(use);
|
|
22
35
|
return { id: entry.id, protocolVersion: entry.protocolVersion };
|
|
23
36
|
},
|
|
24
|
-
open(key, open) {
|
|
37
|
+
open(key, use, open) {
|
|
25
38
|
const inFlight = pending.get(key);
|
|
26
39
|
if (inFlight)
|
|
27
40
|
return inFlight;
|
|
28
41
|
const opening = open()
|
|
29
42
|
.then((session) => {
|
|
30
43
|
sessions.delete(key);
|
|
31
|
-
sessions.set(key, { ...session, usedAt: now() });
|
|
32
|
-
|
|
33
|
-
if (sessions.size <= MAX_SESSIONS)
|
|
34
|
-
break;
|
|
35
|
-
sessions.delete(oldest);
|
|
36
|
-
}
|
|
44
|
+
sessions.set(key, { ...session, usedAt: now(), tool: use.tool });
|
|
45
|
+
trim(use);
|
|
37
46
|
return session;
|
|
38
47
|
})
|
|
39
48
|
.finally(() => pending.delete(key));
|
|
@@ -63,17 +63,38 @@ function assertTimeout(name, timeoutMs) {
|
|
|
63
63
|
return;
|
|
64
64
|
throw new TheoremError('config', `Tool "${name}" timeoutMs needs a finite number of ms above 0`);
|
|
65
65
|
}
|
|
66
|
+
/** A count or size limit is a whole number above 0. */
|
|
67
|
+
function assertWhole(name, field, value) {
|
|
68
|
+
if (value === undefined || (Number.isInteger(value) && value > 0))
|
|
69
|
+
return;
|
|
70
|
+
throw new TheoremError('config', `Tool "${name}" ${field} needs a whole number above 0`);
|
|
71
|
+
}
|
|
72
|
+
/** What a remote tool may set on how much it reads: its body, and the pictures it names. */
|
|
73
|
+
function assertReadLimits(def) {
|
|
74
|
+
assertWhole(def.name, 'maxResponseBytes', def.maxResponseBytes);
|
|
75
|
+
const images = def.imageLimits;
|
|
76
|
+
if (!images)
|
|
77
|
+
return;
|
|
78
|
+
assertWhole(def.name, 'imageLimits.count', images.count);
|
|
79
|
+
assertWhole(def.name, 'imageLimits.bytes', images.bytes);
|
|
80
|
+
assertWhole(def.name, 'imageLimits.totalBytes', images.totalBytes);
|
|
81
|
+
assertTimeout(def.name, images.timeoutMs);
|
|
82
|
+
}
|
|
66
83
|
function normalizeHttp(def) {
|
|
67
84
|
assertFixedEndpointOrigin(def.endpoint);
|
|
68
85
|
assertAuthService(def.name, def.auth);
|
|
69
86
|
assertQueryParam(def.name, def.auth, def.mapping?.queryParams);
|
|
70
87
|
assertLimit(def.name, def.limit);
|
|
71
88
|
assertTimeout(def.name, def.timeoutMs);
|
|
89
|
+
assertReadLimits(def);
|
|
72
90
|
return { ...def, type: 'http', ...schemasFromZod(def) };
|
|
73
91
|
}
|
|
74
92
|
function normalizeMcp(def) {
|
|
75
93
|
assertAuthService(def.name, def.auth);
|
|
76
94
|
assertQueryParam(def.name, def.auth);
|
|
95
|
+
assertReadLimits(def);
|
|
96
|
+
assertWhole(def.name, 'session.idleMs', def.session?.idleMs);
|
|
97
|
+
assertWhole(def.name, 'session.max', def.session?.max);
|
|
77
98
|
return { ...def, type: 'mcp', ...schemasFromZod(def) };
|
|
78
99
|
}
|
|
79
100
|
function normalizeFunction(def) {
|
|
@@ -96,8 +96,6 @@ export declare function refusedCredentialOutcome(tool: {
|
|
|
96
96
|
name: string;
|
|
97
97
|
auth?: ToolAuthConfig;
|
|
98
98
|
}, refusal: CredentialRefusal | undefined, carried: boolean, ctx: ToolContext, base: ToolCallBase): Generator<TurnEvent, ToolBodyOutcome | undefined>;
|
|
99
|
-
/** The most of a tool's response body the kernel reads; past it the call fails instead of exhausting the host's memory. */
|
|
100
|
-
export declare const MAX_TOOL_RESPONSE_BYTES: number;
|
|
101
99
|
/** Every string in `value` with the credential replaced. */
|
|
102
100
|
export declare function omitSecret(value: unknown, secret: string): unknown;
|
|
103
101
|
/** A failure's message and details with the credential replaced. */
|
|
@@ -391,12 +391,10 @@ function* thrownOutcome(err, checks) {
|
|
|
391
391
|
return failureOutcome({ code: 'network_error', kind: 'network', message: messageOf(err) }, false);
|
|
392
392
|
}
|
|
393
393
|
const OMIT_CREDENTIAL = '[omitted - credential]';
|
|
394
|
-
/** The most of a tool's response body the kernel reads; past it the call fails instead of exhausting the host's memory. */
|
|
395
|
-
export const MAX_TOOL_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
396
394
|
/** The most of an error or unparseable body quoted back in a failure message. */
|
|
397
395
|
const MAX_QUOTED_BODY_CHARS = 2000;
|
|
398
|
-
/** A body's text as it arrives, up to `maxBytes
|
|
399
|
-
async function* cappedPieces(response, maxBytes) {
|
|
396
|
+
/** A body's text as it arrives, up to `maxBytes` when set; returns `false` when it runs past, with the rest left unread. */
|
|
397
|
+
async function* cappedPieces(response, maxBytes = Number.POSITIVE_INFINITY) {
|
|
400
398
|
const declared = Number(response.headers.get('content-length'));
|
|
401
399
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
402
400
|
await response.body?.cancel().catch(() => { });
|
|
@@ -427,8 +425,8 @@ async function* cappedPieces(response, maxBytes) {
|
|
|
427
425
|
yield rest;
|
|
428
426
|
return true;
|
|
429
427
|
}
|
|
430
|
-
/** A body, read up to `maxBytes
|
|
431
|
-
async function readCappedText(response, maxBytes
|
|
428
|
+
/** A body, read up to `maxBytes` when set; `undefined` when it runs past, with the rest left unread. */
|
|
429
|
+
async function readCappedText(response, maxBytes) {
|
|
432
430
|
const pieces = cappedPieces(response, maxBytes);
|
|
433
431
|
let text = '';
|
|
434
432
|
for (;;) {
|
|
@@ -443,8 +441,8 @@ async function readCappedText(response, maxBytes = MAX_TOOL_RESPONSE_BYTES) {
|
|
|
443
441
|
* shows: the last `secret.length - 1` characters wait for the next piece, so one split across
|
|
444
442
|
* pieces is whole when it is replaced. Drafts that ran before the body ran past its cap stay sent.
|
|
445
443
|
*/
|
|
446
|
-
async function* readCappedTextShowing(response, shown, secret) {
|
|
447
|
-
const pieces = cappedPieces(response,
|
|
444
|
+
async function* readCappedTextShowing(response, shown, secret, maxBytes) {
|
|
445
|
+
const pieces = cappedPieces(response, maxBytes);
|
|
448
446
|
const hold = secret ? secret.length - 1 : 0;
|
|
449
447
|
let text = '';
|
|
450
448
|
let pending = '';
|
|
@@ -471,12 +469,12 @@ async function* readCappedTextShowing(response, shown, secret) {
|
|
|
471
469
|
function quotedBody(text) {
|
|
472
470
|
return text.length > MAX_QUOTED_BODY_CHARS ? `${text.slice(0, MAX_QUOTED_BODY_CHARS)}…` : text;
|
|
473
471
|
}
|
|
474
|
-
/** The failure for a body past
|
|
475
|
-
function tooLargeFailure(host) {
|
|
472
|
+
/** The failure for a body past the tool's `maxResponseBytes`. */
|
|
473
|
+
function tooLargeFailure(host, maxBytes) {
|
|
476
474
|
return {
|
|
477
475
|
code: 'response_too_large',
|
|
478
476
|
kind: 'bad_response',
|
|
479
|
-
message: `Response from ${host} is larger than ${
|
|
477
|
+
message: `Response from ${host} is larger than ${maxBytes} bytes`, // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
480
478
|
};
|
|
481
479
|
}
|
|
482
480
|
/** The text with the credential replaced. */
|
|
@@ -568,10 +566,10 @@ function isDraftable(response) {
|
|
|
568
566
|
return type === '' || type.startsWith('text/') || /[/+]json\b/.test(type);
|
|
569
567
|
}
|
|
570
568
|
/** An HTTP tool's body; a successful text body shows as drafts while it arrives, any other reads whole. */
|
|
571
|
-
async function* readHttpBody(response, shown, secret) {
|
|
569
|
+
async function* readHttpBody(response, shown, secret, maxBytes) {
|
|
572
570
|
if (!shown || !isDraftable(response))
|
|
573
|
-
return await readCappedText(response);
|
|
574
|
-
return yield* readCappedTextShowing(response, shown, secret);
|
|
571
|
+
return await readCappedText(response, maxBytes);
|
|
572
|
+
return yield* readCappedTextShowing(response, shown, secret, maxBytes);
|
|
575
573
|
}
|
|
576
574
|
async function* sendHttpRequest(tool, targetUrl, body, { authHeaders, secret }, ctx, base, span, shown) {
|
|
577
575
|
const checks = requestChecks(span);
|
|
@@ -597,9 +595,10 @@ async function* sendHttpRequest(tool, targetUrl, body, { authHeaders, secret },
|
|
|
597
595
|
resolveHost: ctx.resolveHost,
|
|
598
596
|
onCheck: checks.onCheck,
|
|
599
597
|
}));
|
|
600
|
-
const text = yield* readHttpBody(response, shown, secret);
|
|
601
|
-
if (text === undefined)
|
|
602
|
-
return failureOutcome(tooLargeFailure(targetUrl.hostname), false);
|
|
598
|
+
const text = yield* readHttpBody(response, shown, secret, tool.maxResponseBytes);
|
|
599
|
+
if (text === undefined) {
|
|
600
|
+
return failureOutcome(tooLargeFailure(targetUrl.hostname, tool.maxResponseBytes), false);
|
|
601
|
+
}
|
|
603
602
|
if (!response.ok) {
|
|
604
603
|
const refused = yield* refusedCredentialOutcome(tool, credentialRefusal(response), secret !== undefined, ctx, base);
|
|
605
604
|
if (refused)
|
|
@@ -747,9 +746,10 @@ async function fetchMcpProtocolAttempt(transport, rpcId, mcpToolName, input, pro
|
|
|
747
746
|
resolveHost: transport.resolveHost,
|
|
748
747
|
onCheck: transport.onCheck,
|
|
749
748
|
});
|
|
750
|
-
const text = await readCappedText(response);
|
|
749
|
+
const text = await readCappedText(response, transport.maxResponseBytes);
|
|
751
750
|
if (text === undefined) {
|
|
752
|
-
|
|
751
|
+
const host = new URL(transport.url).hostname;
|
|
752
|
+
return { kind: 'failure', failure: tooLargeFailure(host, transport.maxResponseBytes) };
|
|
753
753
|
}
|
|
754
754
|
if (!response.ok) {
|
|
755
755
|
const sessionSent = SESSION_HEADER in transport.originBoundHeaders;
|
|
@@ -854,9 +854,10 @@ async function openMcpSession(transport) {
|
|
|
854
854
|
clientInfo: { name: 'theorem', version: '1' },
|
|
855
855
|
},
|
|
856
856
|
}, { 'Mcp-Method': 'initialize' });
|
|
857
|
-
const text = await readCappedText(response);
|
|
857
|
+
const text = await readCappedText(response, transport.maxResponseBytes);
|
|
858
858
|
if (text === undefined) {
|
|
859
|
-
|
|
859
|
+
const host = new URL(transport.url).hostname;
|
|
860
|
+
throw new McpSessionFailure(tooLargeFailure(host, transport.maxResponseBytes));
|
|
860
861
|
}
|
|
861
862
|
if (!response.ok) {
|
|
862
863
|
throw sessionFailure(`mcp_session_http_${response.status}`, `MCP server refused to open a session: HTTP ${response.status}: ${quotedBody(text)}`);
|
|
@@ -886,7 +887,7 @@ async function negotiateInSession(transport, rpcId, mcpToolName, input, sessions
|
|
|
886
887
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
887
888
|
if (!session) {
|
|
888
889
|
try {
|
|
889
|
-
session = await sessions.cache.open(sessions.key, () => openMcpSession(transport));
|
|
890
|
+
session = await sessions.cache.open(sessions.key, sessions.use, () => openMcpSession(transport));
|
|
890
891
|
}
|
|
891
892
|
catch (err) {
|
|
892
893
|
if (err instanceof McpSessionFailure)
|
|
@@ -1090,13 +1091,16 @@ async function* sendMcpRequest(tool, url, input, { authHeaders, secret }, ctx, b
|
|
|
1090
1091
|
resolveHost: ctx.resolveHost,
|
|
1091
1092
|
signal: ctx.signal,
|
|
1092
1093
|
onCheck: checks.onCheck,
|
|
1094
|
+
...(tool.maxResponseBytes === undefined ? {} : { maxResponseBytes: tool.maxResponseBytes }),
|
|
1093
1095
|
};
|
|
1094
1096
|
const key = await mcpSessionKey(url.href, transport.originBoundHeaders);
|
|
1095
|
-
const
|
|
1097
|
+
const use = { tool: tool.name, ...tool.session };
|
|
1098
|
+
const cached = sessions.get(key, use);
|
|
1096
1099
|
const first = cached
|
|
1097
1100
|
? await negotiateInSession(transport, base.callId, tool.mcpToolName, input, {
|
|
1098
1101
|
cache: sessions,
|
|
1099
1102
|
key,
|
|
1103
|
+
use,
|
|
1100
1104
|
cached,
|
|
1101
1105
|
})
|
|
1102
1106
|
: await negotiateMcpRpc(transport, base.callId, tool.mcpToolName, input);
|
|
@@ -1104,6 +1108,7 @@ async function* sendMcpRequest(tool, url, input, { authHeaders, secret }, ctx, b
|
|
|
1104
1108
|
? await negotiateInSession(transport, base.callId, tool.mcpToolName, input, {
|
|
1105
1109
|
cache: sessions,
|
|
1106
1110
|
key,
|
|
1111
|
+
use,
|
|
1107
1112
|
})
|
|
1108
1113
|
: first;
|
|
1109
1114
|
const refused = yield* refusedCredentialOutcome(tool, negotiated.refusal, secret !== undefined, ctx, base);
|
|
@@ -163,12 +163,25 @@ export interface ToolOutputHooks<TOut = unknown> {
|
|
|
163
163
|
/** For a tool whose result names pictures by address; function tools return the bytes themselves as `parts`. */
|
|
164
164
|
export interface ToolImageHooks<TOut = unknown> {
|
|
165
165
|
/**
|
|
166
|
-
* The https addresses of pictures in `output` the model should see
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
166
|
+
* The https addresses of pictures in `output` the model should see. After the result passes the
|
|
167
|
+
* output guard, the kernel fetches each one through the profile's network policy, without the
|
|
168
|
+
* tool's credentials, and hands the model the image as a part; one it cannot fetch is an
|
|
169
|
+
* `images_unfetched` warning, never a failed call.
|
|
170
170
|
*/
|
|
171
171
|
images?: (output: TOut) => string[];
|
|
172
|
+
/** How many of those pictures the kernel fetches, how large, and how slowly. Left out, there is no limit. */
|
|
173
|
+
imageLimits?: ToolImageLimits;
|
|
174
|
+
}
|
|
175
|
+
/** Each limit left out is no limit. A picture past one is left out, not cut. */
|
|
176
|
+
export interface ToolImageLimits {
|
|
177
|
+
/** The most pictures one call hands the model; the first ones named are kept. */
|
|
178
|
+
count?: number;
|
|
179
|
+
/** The largest picture fetched. */
|
|
180
|
+
bytes?: number;
|
|
181
|
+
/** The most one call hands the model in all. */
|
|
182
|
+
totalBytes?: number;
|
|
183
|
+
/** How long each picture may take. */
|
|
184
|
+
timeoutMs?: number;
|
|
172
185
|
}
|
|
173
186
|
export interface FunctionToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut> {
|
|
174
187
|
type: 'function';
|
|
@@ -226,6 +239,8 @@ export interface HttpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, To
|
|
|
226
239
|
};
|
|
227
240
|
/** The service's own limit for one key; the kernel keeps this tool's requests within it. */
|
|
228
241
|
limit?: HttpToolLimit;
|
|
242
|
+
/** The most of a response body the kernel reads; past it the call fails `response_too_large`. Left out, there is no limit. */
|
|
243
|
+
maxResponseBytes?: number;
|
|
229
244
|
/**
|
|
230
245
|
* How long the service may take to answer, its body read included. Past it the call
|
|
231
246
|
* fails `timeout` and frees its place in the limit. Left out, a call waits as long as
|
|
@@ -246,6 +261,17 @@ export interface McpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, Too
|
|
|
246
261
|
mcpToolName: string;
|
|
247
262
|
headers?: Record<string, string>;
|
|
248
263
|
auth?: ToolAuthConfig;
|
|
264
|
+
/** The most of a response body the kernel reads; past it the call fails `response_too_large`. Left out, there is no limit. */
|
|
265
|
+
maxResponseBytes?: number;
|
|
266
|
+
/** How long and how many of this tool's server sessions the kernel keeps. Left out, there is no limit. */
|
|
267
|
+
session?: McpSessionLimits;
|
|
268
|
+
}
|
|
269
|
+
/** The sessions an MCP tool keeps: each field left out is no limit. */
|
|
270
|
+
export interface McpSessionLimits {
|
|
271
|
+
/** How long a session may sit unused before the next call opens a new one, in milliseconds. */
|
|
272
|
+
idleMs?: number;
|
|
273
|
+
/** The most sessions the tool keeps open; past it the least recently used is dropped. */
|
|
274
|
+
max?: number;
|
|
249
275
|
}
|
|
250
276
|
/**
|
|
251
277
|
* What an agent tool call carries: the text, and whatever else the called
|
|
@@ -520,6 +520,8 @@ export interface CompactionFailure {
|
|
|
520
520
|
stop?: TurnStopKind;
|
|
521
521
|
/** The error it reported, when it reported one. */
|
|
522
522
|
error?: ErrorKind;
|
|
523
|
+
/** Why it failed, for the builder only: `forClient` strips it for a visitor. */
|
|
524
|
+
errorInternal?: string;
|
|
523
525
|
/** It completed without a summary. */
|
|
524
526
|
empty?: true;
|
|
525
527
|
/** Nothing in `toCompact` was left for it to read, so it did not run. */
|
|
@@ -333,6 +333,7 @@ true;
|
|
|
333
333
|
const compactionFailure = z.object({
|
|
334
334
|
stop: z.enum(TURN_STOP_KINDS).optional(),
|
|
335
335
|
error: errorKindSchema.optional(),
|
|
336
|
+
errorInternal: z.string().optional(),
|
|
336
337
|
empty: z.literal(true).optional(),
|
|
337
338
|
unreadable: z.literal(true).optional(),
|
|
338
339
|
});
|
|
@@ -646,6 +646,7 @@ const HITS = fields('error', 'Hits', 'Each rule that matched.', {
|
|
|
646
646
|
open: true,
|
|
647
647
|
},
|
|
648
648
|
severity: attr('error', 'Severity', 'text', 'How serious the rule says a match is.', SEVERITY_OPTIONS),
|
|
649
|
+
signal: attr('error', 'Signal', 'text', "What made a `tool_instructions` match one, or how a host detector's `find` failed: `find_threw` or `find_result`."),
|
|
649
650
|
start: attr('error', 'From', 'number', 'Where the match starts in the checked text.'),
|
|
650
651
|
end: attr('error', 'To', 'number', 'Where the match ends in the checked text.'),
|
|
651
652
|
match: attr('error', 'Match', 'text', 'The matched text, exact. Kept only when the profile records match previews.'),
|
|
@@ -680,6 +681,7 @@ const TRACE_EVENTS = {
|
|
|
680
681
|
depth: attr('tool', 'Hops', 'number', "Tool calls between the user's turn and this text: 1 for a direct call, more through another agent."),
|
|
681
682
|
}),
|
|
682
683
|
error: attr('error', 'Detail', 'content', "The guardrail's internal reason."),
|
|
684
|
+
error_summary: attr('error', 'Failure', 'text', "Which host detector failed, how, and its error id, in the kernel's words only. The host stream's reason ends with the same id."),
|
|
683
685
|
},
|
|
684
686
|
},
|
|
685
687
|
'theorem.attempt.retry': {
|
|
@@ -718,6 +720,7 @@ const TRACE_EVENTS = {
|
|
|
718
720
|
dropped_media: attr('messages', 'Media left out', 'number', 'Media the compactor does not take, left out of what it read.'),
|
|
719
721
|
failure_stop: attr('agent', 'Compactor stop', 'text', 'How the failed compactor stopped.', STOP_KINDS),
|
|
720
722
|
failure_error: attr('agent', 'Compactor error', 'text', 'The error the compactor failed with.', ERROR_KIND_OPTIONS),
|
|
723
|
+
failure_reason: attr('agent', 'Compactor reason', 'content', 'Why the compactor failed, in its own words.'),
|
|
721
724
|
failure_empty: attr('agent', 'Empty summary', 'boolean', 'The compactor replied with nothing.'),
|
|
722
725
|
failure_unreadable: attr('agent', 'Nothing to read', 'boolean', 'Everything to compact was media the compactor does not take, so it did not run.'),
|
|
723
726
|
summary: attr('messages', 'Summary', 'content', 'The summary that replaced them.'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoremjs/agents",
|
|
3
|
-
"version": "0.3.4-next.
|
|
3
|
+
"version": "0.3.4-next.524.g5000023",
|
|
4
4
|
"description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, registered tools, provider adapters, guardrails, and host-injected traces.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|