@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.
Files changed (39) hide show
  1. package/esm/src/guardrails/detect-at.d.ts +6 -2
  2. package/esm/src/guardrails/detect-at.js +64 -19
  3. package/esm/src/guardrails/detect-reply.d.ts +1 -1
  4. package/esm/src/guardrails/detect-reply.js +5 -2
  5. package/esm/src/guardrails/egress-compiler.js +1 -4
  6. package/esm/src/guardrails/error.d.ts +1 -1
  7. package/esm/src/guardrails/error.js +19 -2
  8. package/esm/src/guardrails/event-schemas.d.ts +5 -0
  9. package/esm/src/guardrails/event-schemas.js +1 -0
  10. package/esm/src/guardrails/host-patterns.d.ts +1 -4
  11. package/esm/src/guardrails/host-patterns.js +1 -10
  12. package/esm/src/guardrails/mod.d.ts +1 -1
  13. package/esm/src/guardrails/mod.js +1 -1
  14. package/esm/src/guardrails/sanitize.d.ts +1 -1
  15. package/esm/src/guardrails/sanitize.js +4 -3
  16. package/esm/src/host/client-turn.js +11 -3
  17. package/esm/src/kernel/engine/runner/gates.js +2 -1
  18. package/esm/src/kernel/engine/runner/mod.js +13 -5
  19. package/esm/src/kernel/engine/runner/state.d.ts +9 -1
  20. package/esm/src/kernel/engine/runner/state.js +10 -1
  21. package/esm/src/kernel/engine/runner/steps.js +3 -3
  22. package/esm/src/kernel/engine/turn-trace.js +4 -2
  23. package/esm/src/kernel/protocols/runtime.js +2 -2
  24. package/esm/src/kernel/provider-runtime.js +6 -3
  25. package/esm/src/kernel/registry/profiles.js +2 -2
  26. package/esm/src/kernel/schema.js +16 -0
  27. package/esm/src/kernel/tools/execute.js +3 -1
  28. package/esm/src/kernel/tools/image-fetch.d.ts +2 -4
  29. package/esm/src/kernel/tools/image-fetch.js +19 -20
  30. package/esm/src/kernel/tools/mcp-sessions.d.ts +7 -2
  31. package/esm/src/kernel/tools/mcp-sessions.js +21 -12
  32. package/esm/src/kernel/tools/registry.js +21 -0
  33. package/esm/src/kernel/tools/remote.d.ts +0 -2
  34. package/esm/src/kernel/tools/remote.js +28 -23
  35. package/esm/src/kernel/tools/types.d.ts +30 -4
  36. package/esm/src/kernel/turn-events.d.ts +2 -0
  37. package/esm/src/kernel/turn-events.js +1 -0
  38. package/esm/src/observability/trace-catalog.js +3 -0
  39. 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 'find_result';
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' })) : 'find_result';
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
- return 'find_threw';
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
- return typeof found === 'string' ? found : [...matched, ...found];
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 (typeof spans === 'string') {
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 (typeof spans === 'string')
287
- failed.push(failedHit(reader, spans));
288
- return (typeof spans === 'string' ? [] : spans)
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 typeof found === 'string'
357
- ? [{ start: 0, end: text.length, kind: 'host' }]
358
- : [...matched, ...found];
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
- return { action: found.action, hits: found.hits, value };
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 { MAX_PATTERNS, PATTERN_COMPILER_VERSION, patternProblem, patternSources, } from './host-patterns.js';
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
- /** Raw diagnostic for hosts, traces, and logs; never shown to end users. */
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>;
@@ -42,6 +42,7 @@ const guardrailEvent = z.object({
42
42
  hits: z.array(guardrailHit),
43
43
  provenance: provenance.optional(),
44
44
  errorInternal: z.string().optional(),
45
+ errorSummary: z.string().optional(),
45
46
  });
46
47
  true;
47
48
  /** The schema a guardrail event parses against. */
@@ -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, MAX_PATTERN_LENGTH, MAX_PATTERNS, matchersOf, PATTERN_COMPILER_VERSION, patternListProblem, patternProblem, patternSources, patternsProblem, };
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, MAX_PATTERN_LENGTH, MAX_PATTERNS, matchersOf, PATTERN_COMPILER_VERSION, patternListProblem, patternProblem, patternSources, patternsProblem, };
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, MAX_PATTERN_LENGTH, MAX_PATTERNS, patternListProblem, } from './host-patterns.js';
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, MAX_PATTERN_LENGTH, MAX_PATTERNS, patternListProblem, } from './host-patterns.js';
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' || !event.guardrail?.errorInternal) {
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 conversation is already in turn history: the repair is its next user message.
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 instanceof TheoremError ? err.kind : 'internal';
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: { ...(stop ? { stop } : {}), ...(error ? { error } : {}) },
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
- export { appendUserInput, openTurnState, recordStepEvent };
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.currentHistory };
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.currentHistory,
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(err instanceof Error ? err.message : String(err)),
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.message,
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('config', `Profile ${profileId}: ${message}`);
335
+ throw new TheoremError(errorKind(err), `Profile ${profileId}: ${message}`, { cause: err });
336
336
  }
337
337
  }
338
338
  function assertIdentitySystem(input) {
@@ -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
- ? { images: (output) => fetchToolImages(tool.images, output, ctx) }
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`; `undefined` when it runs past. */
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
- async function fetchOne(url, ctx) {
45
- const timeout = AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS);
46
- const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeout]) : timeout;
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, MAX_TOOL_IMAGE_BYTES);
57
+ const bytes = await readCappedBytes(response, limits.bytes);
60
58
  if (!bytes)
61
- throw new Error(`larger than ${MAX_TOOL_IMAGE_BYTES} bytes`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
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, MAX_TOOL_IMAGES);
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 > MAX_TOOL_IMAGES_TOTAL_BYTES) {
117
- reasons.push(`${new URL(url).hostname}: past the ${MAX_TOOL_IMAGES_TOTAL_BYTES} byte total`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
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 > IDLE_MS)
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
- for (const oldest of sessions.keys()) {
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`; returns `false` when it runs past, with the rest left unread. */
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`; `undefined` when it runs past, with the rest left unread. */
431
- async function readCappedText(response, maxBytes = MAX_TOOL_RESPONSE_BYTES) {
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, MAX_TOOL_RESPONSE_BYTES);
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 MAX_TOOL_RESPONSE_BYTES. */
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 ${MAX_TOOL_RESPONSE_BYTES} bytes`, // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
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
- return { kind: 'failure', failure: tooLargeFailure(new URL(transport.url).hostname) };
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
- throw new McpSessionFailure(tooLargeFailure(new URL(transport.url).hostname));
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 cached = sessions.get(key);
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, at most `MAX_TOOL_IMAGES`
167
- * of them. After the result passes the output guard, the kernel fetches each one through the
168
- * profile's network policy, without the tool's credentials, and hands the model the image as a
169
- * part; one it cannot fetch is an `images_unfetched` warning, never a failed call.
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.508.geda6db0",
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",