toolbound 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,711 @@
1
+ """The output pipeline: from an upstream value to bounded, model-visible data.
2
+
3
+ Order (each step is deterministic)::
4
+
5
+ select root -> schema check -> skip continuation offset -> project (shape)
6
+ -> trusted transform -> redact -> item/string limits -> fit token budget
7
+
8
+ Everything that is dropped or shortened is recorded as an :class:`Omission`, so
9
+ the result can say exactly what is missing and why. Nothing is summarized.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import re
17
+ from collections.abc import Callable, Mapping, Sequence
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+ from urllib.parse import quote, quote_plus
21
+
22
+ from jsonschema import Draft202012Validator
23
+
24
+ from .budget import to_json
25
+ from .contracts import Field, FieldSpec, Output
26
+ from .errors import ConfigurationError, ErrorCode, ToolFailure
27
+ from .paths import MISSING, Path, parse_path
28
+ from .ports import TokenEstimator
29
+
30
+ REDACTED = "[REDACTED]"
31
+ ELLIPSIS = "…"
32
+ _MAX_BUDGET_STEPS = 400
33
+ _MIN_STRING = 32
34
+ #: Upper bound on bytes serialized while trimming one result. Past it the call
35
+ #: fails with ``result_too_large`` instead of burning CPU.
36
+ _MAX_TRIM_WORK = 64 * 1024 * 1024
37
+
38
+ #: Every token pattern starts only at the beginning of a token-character run
39
+ #: (negative lookbehind) and uses possessive, bounded quantifiers, so matching is
40
+ #: linear in the input. Private-key blocks are handled by ``_redact_pem`` with
41
+ #: plain string searches, never with a lazy ``[\s\S]*?`` scan.
42
+ _T = r"(?<![A-Za-z0-9_\-])"
43
+ _SECRET_PATTERN = re.compile(
44
+ # One shared lookbehind in front of the alternation lets the engine dispatch on
45
+ # the first character; it is faster than per-branch anchors and stays linear.
46
+ _T
47
+ + "(?:"
48
+ + "|".join(
49
+ [
50
+ r"(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
51
+ r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,4096}+",
52
+ r"github_pat_[A-Za-z0-9_]{22,4096}+",
53
+ r"xox[abposr]-[A-Za-z0-9-]{10,4096}+",
54
+ r"(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,4096}+",
55
+ r"sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,4096}+",
56
+ r"AIza[0-9A-Za-z_-]{35}",
57
+ r"eyJ[A-Za-z0-9_-]{8,4096}+\.eyJ[A-Za-z0-9_-]{8,4096}+\.[A-Za-z0-9_-]{8,4096}+",
58
+ ]
59
+ )
60
+ + r")|(?i:(?<![A-Za-z0-9])bearer[ \t]{1,8}+[A-Za-z0-9._~+/-]{16,4096}+=*+)"
61
+ )
62
+
63
+
64
+ def _first(value: Any) -> Any:
65
+ if isinstance(value, list):
66
+ return value[0] if value else None
67
+ return value
68
+
69
+
70
+ def _join(value: Any) -> Any:
71
+ if isinstance(value, list):
72
+ return ", ".join(str(v) for v in value)
73
+ return value
74
+
75
+
76
+ def _count(value: Any) -> Any:
77
+ return len(value) if isinstance(value, list | str | dict) else value
78
+
79
+
80
+ BUILTIN_TRANSFORMS: dict[str, Callable[[Any], Any]] = {
81
+ "lower": lambda v: v.lower() if isinstance(v, str) else v,
82
+ "upper": lambda v: v.upper() if isinstance(v, str) else v,
83
+ "strip": lambda v: v.strip() if isinstance(v, str) else v,
84
+ "count": _count,
85
+ "first": _first,
86
+ "join": _join,
87
+ "bool": bool,
88
+ "str": lambda v: v if v is None else str(v),
89
+ }
90
+
91
+
92
+ # --------------------------------------------------------------------------- reports
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class Omission:
97
+ """Something left out of the result. ``count`` = items dropped or values cut."""
98
+
99
+ path: str
100
+ reason: str
101
+ count: int = 1
102
+
103
+ def to_dict(self) -> dict[str, Any]:
104
+ return {"path": self.path, "reason": self.reason, "count": self.count}
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class ShapeReport:
109
+ omitted: dict[tuple[str, str], Omission] = field(default_factory=dict)
110
+ redacted: int = 0
111
+ warnings: list[str] = field(default_factory=list)
112
+
113
+ def merge(self, other: ShapeReport) -> None:
114
+ for omission in other.omitted.values():
115
+ self.omit(omission.path, omission.reason, omission.count)
116
+ self.redacted += other.redacted
117
+ self.warnings.extend(w for w in other.warnings if w not in self.warnings)
118
+
119
+ def omit(self, path: str, reason: str, count: int = 1) -> None:
120
+ key = (path or "$", reason)
121
+ if key in self.omitted:
122
+ self.omitted[key].count += count
123
+ else:
124
+ self.omitted[key] = Omission(path or "$", reason, count)
125
+
126
+ @property
127
+ def truncated(self) -> bool:
128
+ return any(o.reason != "missing" for o in self.omitted.values())
129
+
130
+ def omissions(self) -> list[Omission]:
131
+ return list(self.omitted.values())
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class ShapeResult:
136
+ data: Any
137
+ report: ShapeReport
138
+ total_items: int | None = None
139
+ next_offset: int | None = None
140
+ estimated_tokens: int = 0
141
+
142
+
143
+ # --------------------------------------------------------------------------- compile
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class CompiledField:
148
+ key: str
149
+ path: Path
150
+ max_chars: int | None
151
+ max_items: int | None
152
+ transform: Callable[[Any], Any] | None
153
+ default: Any
154
+ shape: dict[str, CompiledField] | None
155
+ #: Lower-cased last key of the source path ("" when it ends in an index).
156
+ source_key: str = ""
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class CompiledOutput:
161
+ spec: Output
162
+ root: Path
163
+ shape: dict[str, CompiledField] | None
164
+ error_shape: dict[str, CompiledField] | None
165
+ required: frozenset[str]
166
+ redact_keys: frozenset[str]
167
+ validator: Draft202012Validator | None
168
+ #: The longest string any field may keep (global or per-field ``max_chars``).
169
+ max_string_chars: int = 4000
170
+
171
+ @property
172
+ def selectable_fields(self) -> list[str]:
173
+ return list(self.shape) if self.shape else []
174
+
175
+
176
+ def compile_output(
177
+ output: Output,
178
+ transforms: Mapping[str, Callable[[Any], Any]] | None = None,
179
+ ) -> CompiledOutput:
180
+ """Parse paths and resolve transforms once, at registration time."""
181
+ registry = {**BUILTIN_TRANSFORMS, **(transforms or {})}
182
+ validator = None
183
+ if output.schema is not None:
184
+ try:
185
+ Draft202012Validator.check_schema(dict(output.schema))
186
+ except Exception as exc:
187
+ raise ConfigurationError(f"invalid output schema: {exc}") from exc
188
+ validator = Draft202012Validator(dict(output.schema))
189
+ return CompiledOutput(
190
+ spec=output,
191
+ root=parse_path(output.root),
192
+ shape=_compile_shape(output.shape, registry) if output.shape is not None else None,
193
+ error_shape=(
194
+ _compile_shape(output.error_shape, registry) if output.error_shape is not None else None
195
+ ),
196
+ required=frozenset(output.required),
197
+ redact_keys=frozenset(normalize_key(k) for k in output.redact_keys),
198
+ validator=validator,
199
+ max_string_chars=max([output.max_chars, *_field_limits(output.shape)]),
200
+ )
201
+
202
+
203
+ def _field_limits(shape: Mapping[str, FieldSpec] | None) -> list[int]:
204
+ limits: list[int] = []
205
+ for spec in (shape or {}).values():
206
+ if isinstance(spec, Field):
207
+ if spec.max_chars is not None:
208
+ limits.append(spec.max_chars)
209
+ limits.extend(_field_limits(spec.shape))
210
+ return limits
211
+
212
+
213
+ def _compile_shape(
214
+ shape: Mapping[str, FieldSpec], registry: Mapping[str, Callable[[Any], Any]]
215
+ ) -> dict[str, CompiledField]:
216
+ if not shape:
217
+ raise ConfigurationError("shape must contain at least one field")
218
+ compiled: dict[str, CompiledField] = {}
219
+ for key, spec in shape.items():
220
+ if not isinstance(key, str) or not key:
221
+ raise ConfigurationError("shape keys must be non-empty strings")
222
+ fspec = Field(spec) if isinstance(spec, str) else spec
223
+ if not isinstance(fspec, Field):
224
+ raise ConfigurationError(f"shape field {key!r} must be a path string or Field")
225
+ transform: Callable[[Any], Any] | None
226
+ if fspec.transform is None:
227
+ transform = None
228
+ elif callable(fspec.transform):
229
+ transform = fspec.transform
230
+ elif fspec.transform in registry:
231
+ transform = registry[fspec.transform]
232
+ else:
233
+ raise ConfigurationError(f"shape field {key!r}: unknown transform {fspec.transform!r}")
234
+ if fspec.max_chars is not None and fspec.max_chars < 1:
235
+ raise ConfigurationError(f"shape field {key!r}: max_chars must be >= 1")
236
+ if fspec.max_items is not None and fspec.max_items < 1:
237
+ raise ConfigurationError(f"shape field {key!r}: max_items must be >= 1")
238
+ compiled[key] = CompiledField(
239
+ key=key,
240
+ path=parse_path(fspec.source),
241
+ max_chars=fspec.max_chars,
242
+ max_items=fspec.max_items,
243
+ transform=transform,
244
+ default=fspec.default,
245
+ shape=_compile_shape(fspec.shape, registry) if fspec.shape is not None else None,
246
+ source_key=_last_key(fspec.source),
247
+ )
248
+ return compiled
249
+
250
+
251
+ def _last_key(source: str) -> str:
252
+ name = source.rsplit(".", 1)[-1].split("[", 1)[0]
253
+ return normalize_key(name)
254
+
255
+
256
+ # --------------------------------------------------------------------------- pipeline
257
+
258
+
259
+ def process(
260
+ compiled: CompiledOutput,
261
+ value: Any,
262
+ *,
263
+ estimator: TokenEstimator,
264
+ offset: int = 0,
265
+ selected: Sequence[str] | None = None,
266
+ secrets: Sequence[str] = (),
267
+ ) -> ShapeResult:
268
+ """Run the full output pipeline. Raises :class:`ToolFailure` on hard failures."""
269
+ spec = compiled.spec
270
+ report = ShapeReport()
271
+
272
+ data = compiled.root.get(value)
273
+ if data is MISSING:
274
+ _schema_problem(compiled, report, f"root path {compiled.root} not found in response")
275
+ data = None
276
+ if compiled.validator is not None and data is not None:
277
+ error = next(iter(compiled.validator.iter_errors(data)), None)
278
+ if error is not None:
279
+ where = ".".join(str(p) for p in error.absolute_path) or "$"
280
+ _schema_problem(compiled, report, f"response does not match schema at {where}")
281
+
282
+ total_items: int | None = None
283
+ selected_keys = None if selected is None else set(selected) | compiled.required
284
+ secret_values = _secret_values(secrets)
285
+
286
+ if isinstance(data, list) and spec.transform is None:
287
+ # Per-item reports, so omissions are only reported for items actually returned.
288
+ total_items = len(data)
289
+ data = data[offset:]
290
+ root_dropped = 0
291
+ if len(data) > spec.max_items:
292
+ root_dropped = len(data) - spec.max_items
293
+ report.omit("$", "max_items", root_dropped)
294
+ data = data[: spec.max_items]
295
+ items: list[Any] = []
296
+ item_reports: list[ShapeReport] = []
297
+ raw_index: list[int] = [] # position of each kept item in the sliced upstream list
298
+ not_objects = 0
299
+ for position, raw in enumerate(data):
300
+ item_report = ShapeReport()
301
+ item = raw
302
+ if compiled.shape is not None:
303
+ if not isinstance(raw, dict):
304
+ not_objects += 1
305
+ continue
306
+ item = _apply_shape(compiled.shape, raw, "[]", item_report, compiled, selected_keys)
307
+ item = _redact(item, "[]", compiled, item_report, secret_values)
308
+ item = _limit(item, "[]", spec.max_items, spec.max_chars, item_report, root=False)
309
+ items.append(item)
310
+ item_reports.append(item_report)
311
+ raw_index.append(position)
312
+ if not_objects:
313
+ report.omit("$", "not_object", not_objects)
314
+ budget_report = ShapeReport()
315
+ fitted, budget_dropped, tokens = _fit_budget(items, compiled, estimator, budget_report)
316
+ for item_report in item_reports[: len(fitted)]:
317
+ report.merge(item_report)
318
+ for item_report in item_reports[len(fitted) :]:
319
+ report.warnings.extend(w for w in item_report.warnings if w not in report.warnings)
320
+ report.merge(budget_report)
321
+ next_offset = None
322
+ if root_dropped or budget_dropped:
323
+ # Resume at the first upstream element that was not returned.
324
+ consumed = raw_index[len(fitted)] if len(fitted) < len(raw_index) else len(data)
325
+ next_offset = offset + consumed
326
+ return ShapeResult(fitted, report, total_items, next_offset, tokens)
327
+
328
+ if isinstance(data, list):
329
+ total_items = len(data)
330
+ data = data[offset:]
331
+ if compiled.shape is not None and data is not None:
332
+ if isinstance(data, list):
333
+ data = [
334
+ _apply_shape(compiled.shape, item, "[]", report, compiled, selected_keys)
335
+ for item in data
336
+ if isinstance(item, dict)
337
+ ]
338
+ elif isinstance(data, dict):
339
+ data = _apply_shape(compiled.shape, data, "", report, compiled, selected_keys)
340
+ else:
341
+ _schema_problem(compiled, report, "response root is not an object or list")
342
+
343
+ if spec.transform is not None:
344
+ data = spec.transform(data)
345
+
346
+ data = _redact(data, "", compiled, report, secret_values)
347
+
348
+ root_dropped = 0
349
+ if isinstance(data, list) and len(data) > spec.max_items:
350
+ root_dropped = len(data) - spec.max_items
351
+ report.omit("$", "max_items", root_dropped)
352
+ data = data[: spec.max_items]
353
+ data = _limit(data, "", spec.max_items, spec.max_chars, report, root=True)
354
+
355
+ data, budget_dropped, tokens = _fit_budget(data, compiled, estimator, report)
356
+
357
+ next_offset = None
358
+ if isinstance(data, list) and (root_dropped or budget_dropped):
359
+ next_offset = offset + len(data)
360
+ return ShapeResult(data, report, total_items, next_offset, tokens)
361
+
362
+
363
+ def shape_error_body(compiled: CompiledOutput, body: Any, *, secrets: Sequence[str] = ()) -> Any:
364
+ """Apply ``error_shape`` to an upstream error body, tightly bounded."""
365
+ if compiled.error_shape is None or not isinstance(body, dict):
366
+ return None
367
+ report = ShapeReport()
368
+ shaped = _apply_shape(compiled.error_shape, body, "", report, compiled, None, check=False)
369
+ shaped = _redact(shaped, "", compiled, report, _secret_values(secrets))
370
+ return _limit(shaped, "", 5, 300, report, root=False)
371
+
372
+
373
+ def _schema_problem(compiled: CompiledOutput, report: ShapeReport, message: str) -> None:
374
+ if compiled.spec.schema_mode == "strict":
375
+ raise ToolFailure(ErrorCode.UPSTREAM_SCHEMA_MISMATCH, message, retryable=False)
376
+ report.warnings.append(message)
377
+
378
+
379
+ def _apply_shape(
380
+ shape: Mapping[str, CompiledField],
381
+ obj: Any,
382
+ prefix: str,
383
+ report: ShapeReport,
384
+ compiled: CompiledOutput,
385
+ selected: set[str] | None,
386
+ *,
387
+ check: bool = True,
388
+ ) -> dict[str, Any]:
389
+ out: dict[str, Any] = {}
390
+ if not isinstance(obj, dict):
391
+ return out
392
+ for key, fld in shape.items():
393
+ if selected is not None and key not in selected:
394
+ continue
395
+ path = f"{prefix}.{key}" if prefix else key
396
+ value = fld.path.get(obj)
397
+ # Renaming a sensitive source field must not bypass key-based redaction.
398
+ if (
399
+ value is not MISSING
400
+ and fld.source_key in compiled.redact_keys
401
+ and value not in (None, "")
402
+ ):
403
+ report.redacted += 1
404
+ out[key] = REDACTED
405
+ continue
406
+ if value is MISSING:
407
+ if fld.default is not MISSING:
408
+ out[key] = fld.default
409
+ continue
410
+ if check and key in compiled.required and prefix in ("", "[]"):
411
+ _schema_problem(compiled, report, f"required field {path!r} missing upstream")
412
+ report.omit(path, "missing")
413
+ continue
414
+ if fld.shape is not None:
415
+ if isinstance(value, list):
416
+ value = [
417
+ _apply_shape(fld.shape, item, f"{path}[]", report, compiled, None, check=False)
418
+ for item in value
419
+ if isinstance(item, dict)
420
+ ]
421
+ elif isinstance(value, dict):
422
+ value = _apply_shape(fld.shape, value, path, report, compiled, None, check=False)
423
+ if fld.transform is not None:
424
+ value = fld.transform(value)
425
+ if fld.max_items is not None and isinstance(value, list) and len(value) > fld.max_items:
426
+ report.omit(path, "max_items", len(value) - fld.max_items)
427
+ value = value[: fld.max_items]
428
+ if fld.max_chars is not None and isinstance(value, str) and len(value) > fld.max_chars:
429
+ report.omit(path, "max_chars")
430
+ value = _cut(value, fld.max_chars)
431
+ out[key] = value
432
+ return out
433
+
434
+
435
+ _PEM_BEGIN = "-----BEGIN "
436
+ _PEM_LABEL = re.compile(r"[A-Z0-9 ]{0,40}PRIVATE KEY-----")
437
+ _PEM_END = "-----END "
438
+ #: Extra characters kept beyond the string limit while redacting, so a secret that
439
+ #: straddles the cut is still seen whole before the final truncation.
440
+ _REDACT_MARGIN = 4096
441
+
442
+
443
+ def _redact_pem(text: str) -> tuple[str, int]:
444
+ """Replace private-key blocks in linear time. An unterminated block is redacted
445
+ to the end of the string (a partial key is still a secret)."""
446
+ if _PEM_BEGIN not in text:
447
+ return text, 0
448
+ out: list[str] = []
449
+ count = 0
450
+ pos = 0
451
+ while True:
452
+ start = text.find(_PEM_BEGIN, pos)
453
+ if start < 0:
454
+ out.append(text[pos:])
455
+ break
456
+ label_end = start + len(_PEM_BEGIN)
457
+ match = _PEM_LABEL.match(text, label_end)
458
+ if match is None:
459
+ out.append(text[pos:label_end])
460
+ pos = label_end
461
+ continue
462
+ out.append(text[pos:start])
463
+ out.append(REDACTED)
464
+ count += 1
465
+ end = text.find(_PEM_END, match.end())
466
+ if end < 0:
467
+ break
468
+ tail = _PEM_LABEL.match(text, end + len(_PEM_END))
469
+ pos = tail.end() if tail is not None else end + len(_PEM_END)
470
+ return "".join(out), count
471
+
472
+
473
+ def _secret_values(secrets: Sequence[str]) -> tuple[str, ...]:
474
+ """Credential values plus the encodings in which an upstream may echo them."""
475
+ variants: set[str] = set()
476
+ for secret in secrets:
477
+ variants.update(
478
+ {
479
+ secret,
480
+ quote(secret, safe=""),
481
+ quote_plus(secret),
482
+ json.dumps(secret)[1:-1],
483
+ base64.b64encode(secret.encode()).decode(),
484
+ base64.urlsafe_b64encode(secret.encode()).decode(),
485
+ }
486
+ )
487
+ # Only redact values long enough not to collide with ordinary text.
488
+ return tuple(sorted({v for v in variants if len(v) >= 8}, key=len, reverse=True))
489
+
490
+
491
+ def normalize_key(key: str) -> str:
492
+ """``X-Api-Key`` / ``api_key`` / ``apiKey`` all become ``apikey``."""
493
+ return key.lower().replace("-", "").replace("_", "")
494
+
495
+
496
+ def _redact(
497
+ value: Any,
498
+ path: str,
499
+ compiled: CompiledOutput,
500
+ report: ShapeReport,
501
+ secrets: tuple[str, ...],
502
+ ) -> Any:
503
+ spec = compiled.spec
504
+ if isinstance(value, dict):
505
+ out: dict[str, Any] = {}
506
+ for key, item in value.items():
507
+ child = f"{path}.{key}" if path else str(key)
508
+ clean = key
509
+ if isinstance(key, str) and len(key) >= 8 and _key_holds_secret(key, compiled, secrets):
510
+ clean = f"{REDACTED}#{len(out)}" # a credential used as a key: hide the key
511
+ report.redacted += 1
512
+ if (
513
+ isinstance(key, str)
514
+ and normalize_key(key) in compiled.redact_keys
515
+ and item not in (None, "")
516
+ ):
517
+ out[clean] = REDACTED
518
+ report.redacted += 1
519
+ else:
520
+ out[clean] = _redact(item, child, compiled, report, secrets)
521
+ return out
522
+ if isinstance(value, list):
523
+ return [_redact(item, f"{path}[]", compiled, report, secrets) for item in value]
524
+ if isinstance(value, str):
525
+ value = _redact_text(value, compiled, report, secrets)
526
+ for hook in spec.redactors:
527
+ value = hook(path or "$", value)
528
+ return value
529
+
530
+
531
+ def _key_holds_secret(key: str, compiled: CompiledOutput, secrets: tuple[str, ...]) -> bool:
532
+ if any(secret in key for secret in secrets):
533
+ return True
534
+ # Ordinary keys are short identifiers; only long ones can hold a token pattern.
535
+ return (
536
+ compiled.spec.redact_secrets
537
+ and len(key) >= 20
538
+ and (_SECRET_PATTERN.search(key) is not None or _PEM_BEGIN in key)
539
+ )
540
+
541
+
542
+ def _redact_text(
543
+ text: str, compiled: CompiledOutput, report: ShapeReport, secrets: tuple[str, ...]
544
+ ) -> str:
545
+ # Nothing past the longest allowed string (plus a margin) can reach the model,
546
+ # so never spend work on it. The final cut happens in ``_limit``.
547
+ window = compiled.max_string_chars + _REDACT_MARGIN
548
+ if len(text) > window:
549
+ text = text[:window]
550
+ for secret in secrets:
551
+ if secret in text:
552
+ report.redacted += text.count(secret)
553
+ text = text.replace(secret, REDACTED)
554
+ if compiled.spec.redact_secrets:
555
+ text, n = _redact_pem(text)
556
+ report.redacted += n
557
+ text, n = _SECRET_PATTERN.subn(REDACTED, text)
558
+ report.redacted += n
559
+ return text
560
+
561
+
562
+ def _cut(text: str, limit: int) -> str:
563
+ if len(text) <= limit:
564
+ return text
565
+ return text[: max(limit - 1, 0)] + ELLIPSIS
566
+
567
+
568
+ def _limit(
569
+ value: Any, path: str, max_items: int, max_chars: int, report: ShapeReport, *, root: bool
570
+ ) -> Any:
571
+ if isinstance(value, dict):
572
+ return {
573
+ k: _limit(
574
+ v, f"{path}.{k}" if path else str(k), max_items, max_chars, report, root=False
575
+ )
576
+ for k, v in value.items()
577
+ }
578
+ if isinstance(value, list):
579
+ if not root and len(value) > max_items:
580
+ report.omit(path, "max_items", len(value) - max_items)
581
+ value = value[:max_items]
582
+ return [_limit(v, f"{path}[]", max_items, max_chars, report, root=False) for v in value]
583
+ if isinstance(value, str) and len(value) > max_chars:
584
+ report.omit(path, "max_chars")
585
+ return _cut(value, max_chars)
586
+ return value
587
+
588
+
589
+ # --------------------------------------------------------------------------- budget
590
+
591
+
592
+ def _fit_budget(
593
+ data: Any,
594
+ compiled: CompiledOutput,
595
+ estimator: TokenEstimator,
596
+ report: ShapeReport,
597
+ ) -> tuple[Any, int, int]:
598
+ """Return (data, root items dropped, estimated tokens)."""
599
+ spec = compiled.spec
600
+ tokens = estimator.count(to_json(data))
601
+ if spec.max_tokens is None or tokens <= spec.max_tokens:
602
+ return data, 0, tokens
603
+ if spec.overflow == "reject":
604
+ raise ToolFailure(
605
+ ErrorCode.RESULT_TOO_LARGE,
606
+ f"result needs ~{tokens} tokens but the budget is {spec.max_tokens}; "
607
+ "narrow the request (filters, fields, or a smaller page)",
608
+ retryable=False,
609
+ )
610
+ budget = spec.max_tokens
611
+ dropped = 0
612
+ if isinstance(data, list) and len(data) > 1:
613
+ lo, hi = 1, len(data)
614
+ while lo < hi: # largest prefix that fits
615
+ mid = (lo + hi + 1) // 2
616
+ if estimator.count(to_json(data[:mid])) <= budget:
617
+ lo = mid
618
+ else:
619
+ hi = mid - 1
620
+ if lo < len(data):
621
+ dropped = len(data) - lo
622
+ report.omit("$", "budget", dropped)
623
+ data = data[:lo]
624
+ tokens = estimator.count(to_json(data))
625
+ if tokens <= budget:
626
+ return data, dropped, tokens
627
+
628
+ data = json.loads(to_json(data)) # one private, JSON-safe copy, trimmed in place
629
+ work = 0
630
+ for _ in range(_MAX_BUDGET_STEPS):
631
+ candidates = _trimmable(data, compiled.required)
632
+ if not candidates:
633
+ break
634
+ if tokens > 2 * budget:
635
+ # Far over budget: halve every value at least half as large as the
636
+ # largest in one step, so the number of steps grows with log(size).
637
+ largest = candidates[0][0]
638
+ batch = [c for c in candidates if c[0] * 2 > largest]
639
+ else:
640
+ batch = candidates[:1]
641
+ # Deepest first, so halving a list never invalidates a later location.
642
+ for _size, path_key, size_path in sorted(batch, key=lambda c: -len(c[2])):
643
+ data = _halve_at(data, size_path)
644
+ report.omit(path_key, "budget")
645
+ text = to_json(data)
646
+ work += len(text)
647
+ tokens = estimator.count(text)
648
+ if tokens <= budget:
649
+ return data, dropped, tokens
650
+ if work > _MAX_TRIM_WORK:
651
+ raise ToolFailure(
652
+ ErrorCode.RESULT_TOO_LARGE,
653
+ f"result is far over the {budget}-token budget; narrow the request "
654
+ "(filters, fields, or a smaller page)",
655
+ retryable=False,
656
+ )
657
+ raise ToolFailure(
658
+ ErrorCode.RESULT_TOO_LARGE,
659
+ f"result cannot fit the {budget}-token budget even after trimming (required "
660
+ "fields and object keys are never removed); narrow the request",
661
+ retryable=False,
662
+ )
663
+
664
+
665
+ def _trimmable(data: Any, required: frozenset[str]) -> list[tuple[int, str, tuple[str | int, ...]]]:
666
+ """Every value that may be halved, largest first (ties by path, deterministic)."""
667
+ found: list[tuple[int, str, tuple[str | int, ...]]] = []
668
+
669
+ def visit(node: Any, norm: str, loc: tuple[str | int, ...]) -> None:
670
+ if isinstance(node, dict):
671
+ for key, child in node.items():
672
+ if key in required and _record_level(loc):
673
+ continue
674
+ visit(child, f"{norm}.{key}" if norm else key, (*loc, key))
675
+ return
676
+ if isinstance(node, list):
677
+ if len(node) > 1 and loc:
678
+ found.append((len(to_json(node)), norm or "$", loc))
679
+ for i, child in enumerate(node):
680
+ visit(child, f"{norm}[]", (*loc, i))
681
+ return
682
+ if isinstance(node, str) and len(node) > _MIN_STRING:
683
+ found.append((len(node.encode()), norm or "$", loc))
684
+
685
+ visit(data, "", ())
686
+ found.sort(key=lambda c: (-c[0], c[1], [str(p) for p in c[2]]))
687
+ return found
688
+
689
+
690
+ def _record_level(loc: tuple[str | int, ...]) -> bool:
691
+ # A top-level object (loc == ()) or an element of the root list (loc == (i,)).
692
+ return len(loc) == 0 or (len(loc) == 1 and isinstance(loc[0], int))
693
+
694
+
695
+ def _halve_at(data: Any, loc: tuple[str | int, ...]) -> Any:
696
+ """Halve the value at ``loc`` in place (``data`` is a private copy)."""
697
+ if not loc:
698
+ return _halve(data)
699
+ parent = data
700
+ for step in loc[:-1]:
701
+ parent = parent[step]
702
+ parent[loc[-1]] = _halve(parent[loc[-1]])
703
+ return data
704
+
705
+
706
+ def _halve(value: Any) -> Any:
707
+ if isinstance(value, list):
708
+ return value[: max(1, len(value) // 2)]
709
+ if isinstance(value, str):
710
+ return _cut(value, max(_MIN_STRING, len(value) // 2))
711
+ return value