@ictechgy/context-guard 0.4.10 → 0.4.12

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 (32) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.ko.md +46 -28
  3. package/README.md +42 -33
  4. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  5. package/docs/benchmark-workflow-examples.md +3 -0
  6. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  7. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  8. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  9. package/docs/experimental-benchmark-fixtures.md +24 -7
  10. package/package.json +2 -1
  11. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  12. package/plugins/context-guard/README.ko.md +14 -11
  13. package/plugins/context-guard/README.md +15 -14
  14. package/plugins/context-guard/bin/context-guard +48 -17
  15. package/plugins/context-guard/bin/context-guard-artifact +342 -33
  16. package/plugins/context-guard/bin/context-guard-audit +36 -5
  17. package/plugins/context-guard/bin/context-guard-bench +1675 -44
  18. package/plugins/context-guard/bin/context-guard-cache-score +347 -35
  19. package/plugins/context-guard/bin/context-guard-compress +89 -27
  20. package/plugins/context-guard/bin/context-guard-cost +7 -2
  21. package/plugins/context-guard/bin/context-guard-experiments +364 -8
  22. package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
  23. package/plugins/context-guard/bin/context-guard-filter +88 -18
  24. package/plugins/context-guard/bin/context-guard-pack +329 -19
  25. package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
  26. package/plugins/context-guard/bin/context-guard-sanitize-output +245 -18
  27. package/plugins/context-guard/bin/context-guard-setup +21 -5
  28. package/plugins/context-guard/bin/context-guard-tool-prune +287 -62
  29. package/plugins/context-guard/bin/context-guard-trim-output +394 -90
  30. package/plugins/context-guard/brief/README.md +5 -5
  31. package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
  32. package/plugins/context-guard/lib/context_guard_commands.py +217 -190
@@ -38,6 +38,11 @@ MAX_TOP = 200
38
38
  MAX_DEFERRED_TOP = 1_000
39
39
  MAX_NAMESPACE_TOP = 200
40
40
  MAX_LABEL_CHARS = 160
41
+ NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
42
+ DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
43
+ DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
44
+ DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
45
+ DIR_FD_UNLINK_SUPPORTED = bool(os.supports_dir_fd and os.unlink in os.supports_dir_fd)
41
46
  MAX_DESCRIPTION_CHARS = 360
42
47
  MAX_OMITTED_TOOLS = 30
43
48
  TOKEN_PROXY_CHARS_PER_TOKEN = 4
@@ -82,6 +87,8 @@ class Candidate:
82
87
  index: int
83
88
  score: float = 0.0
84
89
  rank: int = 0
90
+ schema_bytes: int = 0
91
+ parameter_terms: frozenset[str] | None = None
85
92
 
86
93
 
87
94
  def fail(message: str) -> NoReturn:
@@ -201,12 +208,26 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
201
208
 
202
209
 
203
210
  def read_limited_path(path: Path, max_bytes: int) -> str:
211
+ if not NO_FOLLOW_SUPPORTED:
212
+ fail("catalog reads require O_NOFOLLOW support")
213
+ reject_parent_traversal(path, label="catalog")
214
+ # Preserve clear diagnostics for stable symlink paths, then anchor the real
215
+ # read to an opened no-follow parent fd so parent/leaf swaps after this
216
+ # precheck still fail closed.
204
217
  reject_symlink_components(path)
205
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
218
+ parent_fd = open_private_directory_no_follow(path.parent, label="catalog directory", create=False)
219
+ flags = os.O_RDONLY | os.O_NOFOLLOW
220
+ if hasattr(os, "O_CLOEXEC"):
221
+ flags |= os.O_CLOEXEC
222
+ leaf = path.name
223
+ if leaf in {"", ".", ".."}:
224
+ os.close(parent_fd)
225
+ fail("catalog must name a regular file")
206
226
  try:
207
- fd = os.open(str(path), flags)
227
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
208
228
  except OSError as exc:
209
- fail(f"catalog read failed: {exc}")
229
+ os.close(parent_fd)
230
+ fail(f"catalog read failed: {os_error_detail(exc)}")
210
231
  try:
211
232
  st = os.fstat(fd)
212
233
  if not stat.S_ISREG(st.st_mode):
@@ -216,6 +237,7 @@ def read_limited_path(path: Path, max_bytes: int) -> str:
216
237
  data = os.read(fd, max_bytes + 1)
217
238
  finally:
218
239
  os.close(fd)
240
+ os.close(parent_fd)
219
241
  if len(data) > max_bytes:
220
242
  fail(f"catalog exceeds --max-catalog-bytes: > {max_bytes}")
221
243
  return data.decode("utf-8", errors="replace")
@@ -256,7 +278,15 @@ def tool_schema_from_dict(raw: dict[str, Any], *, fallback_name: str | None = No
256
278
  schema["description"] = description
257
279
  if server and "server" not in schema:
258
280
  schema["server"] = server
259
- return Candidate(name=name, server=cap_text(server, MAX_LABEL_CHARS) if server else None, description=description, schema=schema, index=index)
281
+ return Candidate(
282
+ name=name,
283
+ server=cap_text(server, MAX_LABEL_CHARS) if server else None,
284
+ description=description,
285
+ schema=schema,
286
+ index=index,
287
+ schema_bytes=byte_len_json(schema),
288
+ parameter_terms=frozenset(terms(" ".join(collect_parameter_text(schema)))),
289
+ )
260
290
 
261
291
 
262
292
  def normalize_catalog(raw: Any) -> list[Candidate]:
@@ -342,7 +372,11 @@ def score_candidate(candidate: Candidate, query_terms: set[str]) -> float:
342
372
  return 0.0
343
373
  name_terms = terms(candidate.name)
344
374
  desc_terms = terms(candidate.description)
345
- parameter_terms = terms(" ".join(collect_parameter_text(candidate.schema)))
375
+ parameter_terms = (
376
+ set(candidate.parameter_terms)
377
+ if candidate.parameter_terms is not None
378
+ else terms(" ".join(collect_parameter_text(candidate.schema)))
379
+ )
346
380
  score = 0.0
347
381
  score += 4.0 * len(query_terms & name_terms)
348
382
  score += 1.5 * len(query_terms & desc_terms)
@@ -359,14 +393,38 @@ def rank_candidates(candidates: list[Candidate], query: str) -> list[Candidate]:
359
393
  query_terms = terms(query)
360
394
  scored: list[Candidate] = []
361
395
  for cand in candidates:
362
- scored.append(Candidate(cand.name, cand.server, cand.description, cand.schema, cand.index, score_candidate(cand, query_terms), 0))
396
+ scored.append(Candidate(
397
+ cand.name,
398
+ cand.server,
399
+ cand.description,
400
+ cand.schema,
401
+ cand.index,
402
+ score_candidate(cand, query_terms),
403
+ 0,
404
+ schema_bytes=cand.schema_bytes,
405
+ parameter_terms=cand.parameter_terms,
406
+ ))
363
407
  scored.sort(key=lambda item: (-item.score, item.index))
364
408
  ranked: list[Candidate] = []
365
409
  for rank, cand in enumerate(scored, start=1):
366
- ranked.append(Candidate(cand.name, cand.server, cand.description, cand.schema, cand.index, cand.score, rank))
410
+ ranked.append(Candidate(
411
+ cand.name,
412
+ cand.server,
413
+ cand.description,
414
+ cand.schema,
415
+ cand.index,
416
+ cand.score,
417
+ rank,
418
+ schema_bytes=cand.schema_bytes,
419
+ parameter_terms=cand.parameter_terms,
420
+ ))
367
421
  return ranked
368
422
 
369
423
 
424
+ def candidate_schema_bytes(cand: Candidate) -> int:
425
+ return cand.schema_bytes if cand.schema_bytes > 0 else byte_len_json(cand.schema)
426
+
427
+
370
428
  def normalized_link_target(parent: Path, raw_target: str) -> Path:
371
429
  target = Path(raw_target)
372
430
  if not target.is_absolute():
@@ -409,15 +467,142 @@ def reject_symlink_components(path: Path) -> None:
409
467
  fail(f"refusing path through non-directory component: {current}")
410
468
 
411
469
 
470
+ def dir_fd_replace_supported() -> bool:
471
+ try:
472
+ import inspect
473
+
474
+ signature = inspect.signature(os.replace)
475
+ except (TypeError, ValueError):
476
+ return True
477
+ return "src_dir_fd" in signature.parameters and "dst_dir_fd" in signature.parameters
478
+
479
+
480
+ DIR_FD_REPLACE_SUPPORTED = dir_fd_replace_supported()
481
+
482
+
483
+ def reject_parent_traversal(path: Path, *, label: str) -> None:
484
+ if ".." in path.parts:
485
+ fail(f"{label} must not contain parent traversal")
486
+
487
+
488
+ def os_error_detail(exc: OSError) -> str:
489
+ detail = exc.strerror or str(exc) or exc.__class__.__name__
490
+ if exc.errno is not None:
491
+ return f"{detail} (errno {exc.errno})"
492
+ return detail
493
+
494
+
495
+ def no_follow_dir_flags() -> int:
496
+ if not NO_FOLLOW_SUPPORTED:
497
+ fail("private store IO requires O_NOFOLLOW support")
498
+ flags = os.O_RDONLY | os.O_NOFOLLOW
499
+ if hasattr(os, "O_CLOEXEC"):
500
+ flags |= os.O_CLOEXEC
501
+ if hasattr(os, "O_DIRECTORY"):
502
+ flags |= os.O_DIRECTORY
503
+ return flags
504
+
505
+
506
+ def private_temp_file_flags() -> int:
507
+ if not NO_FOLLOW_SUPPORTED:
508
+ fail("private store IO requires O_NOFOLLOW support")
509
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
510
+ if hasattr(os, "O_CLOEXEC"):
511
+ flags |= os.O_CLOEXEC
512
+ if hasattr(os, "O_NOCTTY"):
513
+ flags |= os.O_NOCTTY
514
+ return flags
515
+
516
+
517
+ def open_private_directory_no_follow(path: Path, *, label: str, create: bool) -> int:
518
+ reject_parent_traversal(path, label=label)
519
+ path = normalize_allowed_first_absolute_symlink(path.expanduser())
520
+ if not DIR_FD_OPEN_SUPPORTED:
521
+ fail(f"{label} requires dir_fd open support")
522
+ if create and not DIR_FD_MKDIR_SUPPORTED:
523
+ fail(f"{label} requires dir_fd mkdir support")
524
+ flags = no_follow_dir_flags()
525
+ if path.is_absolute():
526
+ root_flags = os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0)
527
+ current_fd = os.open(path.anchor or os.sep, root_flags)
528
+ parts = path.parts[1:]
529
+ else:
530
+ current_fd = os.open(".", flags)
531
+ parts = path.parts
532
+ try:
533
+ for part in parts:
534
+ if part in {"", "."}:
535
+ continue
536
+ if part == "..":
537
+ fail(f"{label} must not contain parent traversal")
538
+ try:
539
+ next_fd = os.open(part, flags, dir_fd=current_fd)
540
+ except FileNotFoundError:
541
+ if not create:
542
+ raise
543
+ os.mkdir(part, 0o700, dir_fd=current_fd)
544
+ next_fd = os.open(part, flags, dir_fd=current_fd)
545
+ try:
546
+ if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
547
+ fail(f"{label} must not traverse non-directory components")
548
+ except Exception:
549
+ os.close(next_fd)
550
+ raise
551
+ os.close(current_fd)
552
+ current_fd = next_fd
553
+ owned_fd = current_fd
554
+ current_fd = -1
555
+ return owned_fd
556
+ except OSError as exc:
557
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
558
+ finally:
559
+ if current_fd >= 0:
560
+ os.close(current_fd)
561
+
562
+
563
+ def precheck_private_leaf(parent_fd: int, leaf: str, *, label: str) -> None:
564
+ if not DIR_FD_STAT_SUPPORTED:
565
+ fail(f"{label} requires dir_fd stat support")
566
+ try:
567
+ st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
568
+ except FileNotFoundError:
569
+ return
570
+ except OSError as exc:
571
+ fail(f"could not inspect {label}: {os_error_detail(exc)}")
572
+ if not stat.S_ISREG(st.st_mode):
573
+ fail(f"{label} must be missing or a regular file")
574
+
575
+
576
+ def write_all_fd(fd: int, data: bytes) -> None:
577
+ view = memoryview(data)
578
+ offset = 0
579
+ while offset < len(view):
580
+ written = os.write(fd, view[offset:])
581
+ if written <= 0:
582
+ raise OSError("short write")
583
+ offset += written
584
+
585
+
586
+ def fsync_best_effort(fd: int) -> None:
587
+ try:
588
+ os.fsync(fd)
589
+ except OSError:
590
+ pass
591
+
592
+
412
593
  def ensure_private_dir(path: Path) -> None:
413
- path = normalize_allowed_first_absolute_symlink(path)
414
- reject_symlink_components(path)
594
+ reject_parent_traversal(path, label="store directory")
415
595
  try:
416
- path.mkdir(parents=True, exist_ok=True)
417
- reject_symlink_components(path)
418
- os.chmod(path, 0o700)
596
+ fd = open_private_directory_no_follow(path, label="store directory", create=True)
419
597
  except OSError as exc:
420
598
  fail(f"store directory unavailable: {exc}")
599
+ try:
600
+ try:
601
+ os.fchmod(fd, 0o700)
602
+ except OSError:
603
+ pass
604
+ finally:
605
+ os.close(fd)
421
606
 
422
607
 
423
608
  def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: int, label: str) -> int:
@@ -425,43 +610,77 @@ def write_private_json_atomic(path: Path, data: dict[str, Any], *, max_bytes: in
425
610
  size = byte_len_text(text)
426
611
  if size > max_bytes:
427
612
  fail(f"{label} exceeds size cap: {size} > {max_bytes}")
428
- ensure_private_dir(path.parent)
429
- tmp = path.with_name(path.name + f".tmp-{os.getpid()}-{time.time_ns()}")
430
- flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
431
- try:
432
- fd = os.open(str(tmp), flags, 0o600)
433
- except OSError as exc:
434
- fail(f"{label} write failed: {exc}")
613
+ reject_parent_traversal(path, label=label)
614
+ if not DIR_FD_REPLACE_SUPPORTED:
615
+ fail(f"{label} write requires dir_fd replace support")
616
+ if not DIR_FD_UNLINK_SUPPORTED:
617
+ fail(f"{label} write requires dir_fd unlink support")
618
+ if not DIR_FD_STAT_SUPPORTED:
619
+ fail(f"{label} write requires dir_fd stat support")
620
+ parent_fd = open_private_directory_no_follow(path.parent, label="store directory", create=True)
621
+ fd = -1
622
+ temp_leaf: str | None = None
435
623
  try:
436
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
437
- handle.write(text)
438
- handle.flush()
439
- try:
440
- os.fsync(handle.fileno())
441
- except OSError:
442
- pass
443
- os.replace(tmp, path)
444
624
  try:
445
- os.chmod(path, 0o600)
625
+ os.fchmod(parent_fd, 0o700)
446
626
  except OSError:
447
627
  pass
628
+ leaf = path.name
629
+ if leaf in {"", ".", ".."}:
630
+ fail(f"{label} must name a regular file")
631
+ precheck_private_leaf(parent_fd, leaf, label=label)
632
+ for _attempt in range(20):
633
+ candidate = f".{leaf}.{os.getpid()}.{time.time_ns()}.tmp"
634
+ try:
635
+ fd = os.open(candidate, private_temp_file_flags(), 0o600, dir_fd=parent_fd)
636
+ temp_leaf = candidate
637
+ break
638
+ except FileExistsError:
639
+ continue
640
+ if fd < 0 or temp_leaf is None:
641
+ fail(f"{label} write failed: could not create temporary file")
642
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
643
+ fail(f"{label} temporary file must be a regular file")
644
+ os.fchmod(fd, 0o600)
645
+ write_all_fd(fd, text.encode("utf-8"))
646
+ fsync_best_effort(fd)
647
+ os.close(fd)
648
+ fd = -1
649
+ fsync_best_effort(parent_fd)
650
+ os.replace(temp_leaf, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
651
+ temp_leaf = None
652
+ fsync_best_effort(parent_fd)
653
+ except OSError as exc:
654
+ fail(f"{label} write failed: {os_error_detail(exc)}")
448
655
  except Exception:
449
- try:
450
- tmp.unlink()
451
- except OSError:
452
- pass
453
656
  raise
657
+ finally:
658
+ if fd >= 0:
659
+ os.close(fd)
660
+ if temp_leaf is not None:
661
+ try:
662
+ os.unlink(temp_leaf, dir_fd=parent_fd)
663
+ except OSError:
664
+ pass
665
+ os.close(parent_fd)
454
666
  return size
455
667
 
456
668
 
457
669
  def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, int]:
458
- if path.is_symlink():
459
- fail(f"{label} must not be a symlink")
460
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
670
+ reject_parent_traversal(path, label=label)
671
+ parent_fd = open_private_directory_no_follow(path.parent, label=f"{label} directory", create=False)
672
+ flags = os.O_RDONLY | os.O_NOFOLLOW
673
+ if hasattr(os, "O_CLOEXEC"):
674
+ flags |= os.O_CLOEXEC
675
+ leaf = path.name
676
+ if leaf in {"", ".", ".."}:
677
+ os.close(parent_fd)
678
+ fail(f"{label} must name a regular file")
461
679
  try:
462
- fd = os.open(str(path), flags)
680
+ fd = os.open(leaf, flags, dir_fd=parent_fd)
463
681
  except OSError as exc:
464
- fail(f"{label} read failed: {exc}")
682
+ os.close(parent_fd)
683
+ fail(f"{label} read failed: {os_error_detail(exc)}")
465
684
  try:
466
685
  st = os.fstat(fd)
467
686
  if not stat.S_ISREG(st.st_mode):
@@ -471,32 +690,16 @@ def read_private_text(path: Path, *, max_bytes: int, label: str) -> tuple[str, i
471
690
  data = os.read(fd, max_bytes + 1)
472
691
  finally:
473
692
  os.close(fd)
693
+ os.close(parent_fd)
474
694
  if len(data) > max_bytes:
475
695
  fail(f"{label} exceeds trusted size cap: > {max_bytes}")
476
696
  return data.decode("utf-8", errors="replace"), len(data)
477
697
 
478
698
 
479
699
  def read_private_json(path: Path, *, max_bytes: int, label: str) -> dict[str, Any]:
480
- if path.is_symlink():
481
- fail(f"{label} must not be a symlink")
482
- flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
483
- try:
484
- fd = os.open(str(path), flags)
485
- except OSError as exc:
486
- fail(f"{label} read failed: {exc}")
487
- try:
488
- st = os.fstat(fd)
489
- if not stat.S_ISREG(st.st_mode):
490
- fail(f"{label} must be a regular file")
491
- if st.st_size > max_bytes:
492
- fail(f"{label} exceeds trusted size cap: {st.st_size} > {max_bytes}")
493
- data = os.read(fd, max_bytes + 1)
494
- finally:
495
- os.close(fd)
496
- if len(data) > max_bytes:
497
- fail(f"{label} exceeds trusted size cap: > {max_bytes}")
700
+ text, _size = read_private_text(path, max_bytes=max_bytes, label=label)
498
701
  try:
499
- parsed = json.loads(data.decode("utf-8", errors="replace"))
702
+ parsed = json.loads(text)
500
703
  except json.JSONDecodeError as exc:
501
704
  fail(f"{label} is malformed JSON: {exc.msg}")
502
705
  if not isinstance(parsed, dict):
@@ -542,7 +745,7 @@ def build_payload(receipt_id: str, ranked: list[Candidate], query: str, redactio
542
745
  "description": cand.description,
543
746
  "score": cand.score,
544
747
  "rank": cand.rank,
545
- "schema_bytes": byte_len_json(cand.schema),
748
+ "schema_bytes": candidate_schema_bytes(cand),
546
749
  "schema": cand.schema,
547
750
  }
548
751
  for cand in ranked
@@ -574,7 +777,7 @@ def retrieval_command(receipt_id: str, *, store_dir: str, tool_name: str | None
574
777
 
575
778
 
576
779
  def selected_tool_record(cand: Candidate, receipt_id: str, budget_left: int, *, store_dir: str) -> tuple[dict[str, Any], int]:
577
- schema_size = byte_len_json(cand.schema)
780
+ schema_size = candidate_schema_bytes(cand)
578
781
  record: dict[str, Any] = {
579
782
  "name": cand.name,
580
783
  "server": cand.server,
@@ -600,7 +803,7 @@ def deferred_tool_record(cand: Candidate, receipt_id: str, *, store_dir: str) ->
600
803
  "score": cand.score,
601
804
  "rank": cand.rank,
602
805
  "description": cand.description,
603
- "schema_bytes": byte_len_json(cand.schema),
806
+ "schema_bytes": candidate_schema_bytes(cand),
604
807
  "reason": "deferred_after_core_top",
605
808
  "retrieval": retrieval_command(receipt_id, store_dir=store_dir, tool_name=cand.name),
606
809
  }
@@ -843,8 +1046,15 @@ def defer_report(args: argparse.Namespace) -> str:
843
1046
  store_dir=args.store_dir,
844
1047
  namespace_top=namespace_top,
845
1048
  )
846
- all_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked)
1049
+ all_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked)
1050
+ listed_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in deferred_candidates)
1051
+ total_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked[core_top:])
847
1052
  tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
1053
+ all_schema_tokens = proxy_tokens(all_schema_bytes)
1054
+ inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
1055
+ listed_deferred_schema_tokens = proxy_tokens(listed_deferred_schema_bytes)
1056
+ total_deferred_schema_tokens = proxy_tokens(total_deferred_schema_bytes)
1057
+ tool_stub_report_tokens = proxy_tokens(tool_stub_report_bytes)
848
1058
  result = {
849
1059
  "tool": TOOL_NAME,
850
1060
  "schema_version": DEFER_SCHEMA_VERSION,
@@ -862,6 +1072,7 @@ def defer_report(args: argparse.Namespace) -> str:
862
1072
  "deferred_tools_truncated_count": max(0, len(ranked) - core_top - len(deferred_tools)),
863
1073
  "deferred_namespaces": deferred_namespaces,
864
1074
  "deferred_namespaces_truncated_count": deferred_namespaces_truncated_count,
1075
+ "deferred_schema_retrieval_required_before_use": True,
865
1076
  "receipt": {
866
1077
  **receipt,
867
1078
  "bytes": receipt_size,
@@ -871,9 +1082,21 @@ def defer_report(args: argparse.Namespace) -> str:
871
1082
  "method": "char4_proxy",
872
1083
  "chars_per_token": TOKEN_PROXY_CHARS_PER_TOKEN,
873
1084
  "all_schema_bytes": all_schema_bytes,
1085
+ "inline_core_schema_bytes": core_schema_bytes,
1086
+ "listed_deferred_schema_bytes": listed_deferred_schema_bytes,
1087
+ "total_deferred_schema_bytes": total_deferred_schema_bytes,
874
1088
  "tool_stub_report_bytes": tool_stub_report_bytes,
875
- "all_schema_tokens_estimated": proxy_tokens(all_schema_bytes),
876
- "tool_stub_report_tokens_estimated": proxy_tokens(tool_stub_report_bytes),
1089
+ "all_schema_tokens_estimated": all_schema_tokens,
1090
+ "inline_core_schema_tokens_estimated": inline_core_schema_tokens,
1091
+ "listed_deferred_schema_tokens_estimated": listed_deferred_schema_tokens,
1092
+ "total_deferred_schema_tokens_estimated": total_deferred_schema_tokens,
1093
+ "tool_stub_report_tokens_estimated": tool_stub_report_tokens,
1094
+ "gross_listed_deferred_schema_tokens_avoided": listed_deferred_schema_tokens,
1095
+ "gross_total_deferred_schema_tokens_avoided": total_deferred_schema_tokens,
1096
+ "net_initial_report_tokens_delta": tool_stub_report_tokens - all_schema_tokens,
1097
+ "net_initial_report_tokens_delta_semantics": "tool_stub_report_tokens_estimated_minus_all_schema_tokens_estimated",
1098
+ "estimated_initial_schema_tokens_avoided": max(0, all_schema_tokens - tool_stub_report_tokens),
1099
+ "estimated_initial_schema_tokens_avoided_semantics": "max(0, all_schema_tokens_estimated - tool_stub_report_tokens_estimated)",
877
1100
  "claim_boundary": "proxy_only_not_provider_billed_tokens",
878
1101
  },
879
1102
  "provider_patterns": [
@@ -899,11 +1122,13 @@ def defer_report(args: argparse.Namespace) -> str:
899
1122
  "provider_tool_search_configured": False,
900
1123
  "hosted_api_token_or_cost_savings_claim_allowed": False,
901
1124
  "requires_provider_measured_matched_tasks_for_savings_claims": True,
1125
+ "deferred_schema_retrieval_required_before_use": True,
902
1126
  },
903
1127
  "redaction": {"redacted_values": total_redactions},
904
1128
  "caveats": [
905
1129
  "Deferred loading is an application strategy report, not a native provider integration.",
906
1130
  "Token proxy values are char/4 estimates over sanitized local JSON, not billed provider tokens.",
1131
+ "Deferred schema token fields are initial-prompt proxy accounting; full schemas must be retrieved before deferred tool use.",
907
1132
  "Use receipt get commands to retrieve full sanitized schemas before using deferred tools.",
908
1133
  ],
909
1134
  }