@miller-tech/uap 2.1.6 → 2.11.0

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 (143) hide show
  1. package/config/capacity-policy.json +32 -0
  2. package/config/classify-thresholds.json +9 -0
  3. package/config/supervise-policy.json +13 -0
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/bin/cli.js +87 -2
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/capacity/policy.d.ts +67 -0
  8. package/dist/capacity/policy.d.ts.map +1 -0
  9. package/dist/capacity/policy.js +133 -0
  10. package/dist/capacity/policy.js.map +1 -0
  11. package/dist/capacity/probe.d.ts +23 -0
  12. package/dist/capacity/probe.d.ts.map +1 -0
  13. package/dist/capacity/probe.js +93 -0
  14. package/dist/capacity/probe.js.map +1 -0
  15. package/dist/classify/baseline.d.ts +24 -0
  16. package/dist/classify/baseline.d.ts.map +1 -0
  17. package/dist/classify/baseline.js +140 -0
  18. package/dist/classify/baseline.js.map +1 -0
  19. package/dist/classify/index.d.ts +30 -0
  20. package/dist/classify/index.d.ts.map +1 -0
  21. package/dist/classify/index.js +84 -0
  22. package/dist/classify/index.js.map +1 -0
  23. package/dist/classify/shadow.d.ts +34 -0
  24. package/dist/classify/shadow.d.ts.map +1 -0
  25. package/dist/classify/shadow.js +33 -0
  26. package/dist/classify/shadow.js.map +1 -0
  27. package/dist/classify/types.d.ts +64 -0
  28. package/dist/classify/types.d.ts.map +1 -0
  29. package/dist/classify/types.js +37 -0
  30. package/dist/classify/types.js.map +1 -0
  31. package/dist/cli/classify.d.ts +24 -0
  32. package/dist/cli/classify.d.ts.map +1 -0
  33. package/dist/cli/classify.js +94 -0
  34. package/dist/cli/classify.js.map +1 -0
  35. package/dist/cli/deliver.d.ts.map +1 -1
  36. package/dist/cli/deliver.js +26 -0
  37. package/dist/cli/deliver.js.map +1 -1
  38. package/dist/cli/doctor.d.ts +9 -0
  39. package/dist/cli/doctor.d.ts.map +1 -0
  40. package/dist/cli/doctor.js +49 -0
  41. package/dist/cli/doctor.js.map +1 -0
  42. package/dist/cli/review.d.ts +12 -0
  43. package/dist/cli/review.d.ts.map +1 -0
  44. package/dist/cli/review.js +118 -0
  45. package/dist/cli/review.js.map +1 -0
  46. package/dist/cli/supervise.d.ts +8 -0
  47. package/dist/cli/supervise.d.ts.map +1 -0
  48. package/dist/cli/supervise.js +76 -0
  49. package/dist/cli/supervise.js.map +1 -0
  50. package/dist/delivery/convergence-loop.d.ts +8 -0
  51. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  52. package/dist/delivery/convergence-loop.js +1 -0
  53. package/dist/delivery/convergence-loop.js.map +1 -1
  54. package/dist/delivery/delivery-result.d.ts.map +1 -1
  55. package/dist/delivery/delivery-result.js +5 -0
  56. package/dist/delivery/delivery-result.js.map +1 -1
  57. package/dist/delivery/gate-evidence.d.ts +143 -0
  58. package/dist/delivery/gate-evidence.d.ts.map +1 -0
  59. package/dist/delivery/gate-evidence.js +239 -0
  60. package/dist/delivery/gate-evidence.js.map +1 -0
  61. package/dist/delivery/index.d.ts +1 -0
  62. package/dist/delivery/index.d.ts.map +1 -1
  63. package/dist/delivery/index.js +1 -0
  64. package/dist/delivery/index.js.map +1 -1
  65. package/dist/evals/routing-corpus.d.ts +35 -0
  66. package/dist/evals/routing-corpus.d.ts.map +1 -0
  67. package/dist/evals/routing-corpus.js +142 -0
  68. package/dist/evals/routing-corpus.js.map +1 -0
  69. package/dist/evals/routing-eval.d.ts +104 -0
  70. package/dist/evals/routing-eval.d.ts.map +1 -0
  71. package/dist/evals/routing-eval.js +231 -0
  72. package/dist/evals/routing-eval.js.map +1 -0
  73. package/dist/evals/tfidf.d.ts +35 -0
  74. package/dist/evals/tfidf.d.ts.map +1 -0
  75. package/dist/evals/tfidf.js +132 -0
  76. package/dist/evals/tfidf.js.map +1 -0
  77. package/dist/review/prepass.d.ts +88 -0
  78. package/dist/review/prepass.d.ts.map +1 -0
  79. package/dist/review/prepass.js +428 -0
  80. package/dist/review/prepass.js.map +1 -0
  81. package/dist/review/visual-captures.d.ts +60 -0
  82. package/dist/review/visual-captures.d.ts.map +1 -0
  83. package/dist/review/visual-captures.js +222 -0
  84. package/dist/review/visual-captures.js.map +1 -0
  85. package/dist/supervise/assess.d.ts +73 -0
  86. package/dist/supervise/assess.d.ts.map +1 -0
  87. package/dist/supervise/assess.js +215 -0
  88. package/dist/supervise/assess.js.map +1 -0
  89. package/dist/supervise/config.d.ts +13 -0
  90. package/dist/supervise/config.d.ts.map +1 -0
  91. package/dist/supervise/config.js +81 -0
  92. package/dist/supervise/config.js.map +1 -0
  93. package/dist/supervise/loop.d.ts +78 -0
  94. package/dist/supervise/loop.d.ts.map +1 -0
  95. package/dist/supervise/loop.js +311 -0
  96. package/dist/supervise/loop.js.map +1 -0
  97. package/dist/supervise/observe.d.ts +31 -0
  98. package/dist/supervise/observe.d.ts.map +1 -0
  99. package/dist/supervise/observe.js +172 -0
  100. package/dist/supervise/observe.js.map +1 -0
  101. package/dist/supervise/policy.d.ts +16 -0
  102. package/dist/supervise/policy.d.ts.map +1 -0
  103. package/dist/supervise/policy.js +147 -0
  104. package/dist/supervise/policy.js.map +1 -0
  105. package/dist/supervise/types.d.ts +109 -0
  106. package/dist/supervise/types.d.ts.map +1 -0
  107. package/dist/supervise/types.js +11 -0
  108. package/dist/supervise/types.js.map +1 -0
  109. package/docs/INDEX.md +9 -3
  110. package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
  111. package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
  112. package/docs/guides/CAPACITY_POLICY.md +98 -0
  113. package/docs/performance/automode-risk-calibration.md +72 -0
  114. package/docs/performance/reliability-ladder.md +78 -0
  115. package/docs/plans/system1-uplift-2026-09-18.md +191 -0
  116. package/docs/reference/automode-risk.md +134 -0
  117. package/docs/reference/evidence-bound-ship.md +145 -0
  118. package/docs/reference/review-prepass.md +77 -0
  119. package/docs/reference/routing-evals.md +71 -0
  120. package/docs/reference/semantic-supervisor.md +140 -0
  121. package/docs/reference/system1-classifier.md +103 -0
  122. package/docs/reference/visual-captures.md +113 -0
  123. package/package.json +5 -3
  124. package/scripts/run-anthropic-proxy-continuity.sh +36 -0
  125. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  126. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  127. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  128. package/src/policies/enforcers/enforcement_self_protect.py +25 -2
  129. package/src/policies/enforcers/evidence_bound_ship.py +257 -0
  130. package/src/policies/enforcers/expert_review_required.py +120 -0
  131. package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
  132. package/src/policies/schemas/policies/visual-verification.md +8 -0
  133. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  134. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  135. package/tools/agents/scripts/anthropic_proxy.py +782 -19
  136. package/tools/agents/scripts/project_telemetry.py +52 -0
  137. package/tools/agents/scripts/tool_risk.py +789 -0
  138. package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
  139. package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
  140. package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
  141. package/tools/agents/tests/test_gate_evidence.py +56 -0
  142. package/tools/agents/tests/test_tool_risk.py +521 -0
  143. package/tools/agents/tests/test_verbatim_compact.py +629 -0
@@ -299,6 +299,12 @@ PROXY_CONTEXT_WINDOW = int(os.environ.get("PROXY_CONTEXT_WINDOW", "0"))
299
299
  PROXY_CONTEXT_PRUNE_THRESHOLD = float(
300
300
  os.environ.get("PROXY_CONTEXT_PRUNE_THRESHOLD", "0.85")
301
301
  )
302
+ # Verbatim decision compaction (uplift 1.2): when context pressure triggers a
303
+ # prune, first try per-tool-call keep/truncate/drop decisions that keep all
304
+ # retained content VERBATIM; fall back to the lossy breadcrumb pruner only
305
+ # when the verbatim stages can't reach budget or fail integrity validation.
306
+ # "off" restores the old path entirely.
307
+ PROXY_VERBATIM_COMPACT = os.environ.get("PROXY_VERBATIM_COMPACT", "on")
302
308
  # Compaction forcing (Option A, 2026-07-10): Claude Code decides when to
303
309
  # auto-compact against ITS believed model window (~200k) using
304
310
  # /v1/messages/count_tokens, so an HONEST count on a smaller local rail lets
@@ -988,6 +994,38 @@ try:
988
994
  except Exception: # pragma: no cover - optional middleware
989
995
  _TOOLCALL_NORMALIZER_OK = False
990
996
 
997
+ # AutoMode pre-execution risk classification (uplift 1.3): an ADVISORY
998
+ # calibrated heuristic scorer (tools/agents/scripts/tool_risk.py) classifies
999
+ # each outbound tool_use block before the client dispatches it. It NEVER
1000
+ # blocks and NEVER modifies the stream — the deterministic enforcers
1001
+ # (src/policies/enforcers/*.py) remain the blocking floor; this path only
1002
+ # observes, logs and records. Escape hatch: PROXY_AUTOMODE_RISK=off.
1003
+ # Default ON: the scorer is pure regex/dict work measured in microseconds
1004
+ # (see docs/performance/automode-risk-calibration.md).
1005
+ PROXY_AUTOMODE_RISK = os.environ.get(
1006
+ "PROXY_AUTOMODE_RISK", "on"
1007
+ ).lower() not in ("off", "0", "false", "no")
1008
+ try:
1009
+ _d = os.path.dirname(os.path.abspath(__file__))
1010
+ if _d not in sys.path:
1011
+ # append, not insert(0): a position-0 prepend would shadow stdlib
1012
+ # process-wide for anything ever added to this directory.
1013
+ sys.path.append(_d)
1014
+ from tool_risk import classify_tool_call as _automode_classify
1015
+ _AUTOMODE_RISK_OK = True
1016
+ except Exception as _exc: # pragma: no cover - optional advisory middleware
1017
+ _AUTOMODE_RISK_OK = False
1018
+ if PROXY_AUTOMODE_RISK:
1019
+ # A default-on feature must never be silently dead — same visibility
1020
+ # doctrine as the source-drift startup warning (PR #799). `logger` is
1021
+ # defined further down the module, so address the channel by name;
1022
+ # this fires exactly once, at import (= proxy startup).
1023
+ logging.getLogger("uap.anthropic_proxy").warning(
1024
+ "AUTOMODE RISK: tool_risk import failed (%s) — advisory risk "
1025
+ "classification is DISABLED; the deterministic enforcers remain "
1026
+ "the blocking floor", type(_exc).__name__,
1027
+ )
1028
+
991
1029
 
992
1030
  def _record_project_telemetry(body, model, usage, duration_ms: float = 0.0) -> None:
993
1031
  """Fail-open per-project routing/cost telemetry: derive the request's project
@@ -1071,6 +1109,101 @@ def _maybe_normalize_toolcall_paths(anthropic_resp: dict, request_body: dict) ->
1071
1109
  )
1072
1110
  except Exception as exc: # never break a response over normalization
1073
1111
  logger.warning("TOOLCALL PATH NORMALIZER: skipped (%s)", type(exc).__name__)
1112
+
1113
+
1114
+ def _tool_args_dict(raw: str) -> dict:
1115
+ """Parse a streamed tool-call arguments buffer into a dict ({} on any
1116
+ failure — classification is advisory and must not fail on truncation)."""
1117
+ try:
1118
+ value = json.loads(raw) if raw else {}
1119
+ return value if isinstance(value, dict) else {}
1120
+ except (ValueError, TypeError):
1121
+ return {}
1122
+
1123
+
1124
+ _TOOL_NAME_UNSAFE = re.compile(r"[\x00-\x1f\x7f]")
1125
+
1126
+
1127
+ def _log_safe_tool_name(name) -> str:
1128
+ """Tool names are MODEL-CONTROLLED and reach log lines and dashboard rows:
1129
+ a name with a newline would forge a log line, an unbounded one would bloat
1130
+ a telemetry row. Strip control chars to '?' and cap at 64 chars. Used only
1131
+ at the sinks — classification itself sees the raw name."""
1132
+ return _TOOL_NAME_UNSAFE.sub("?", str(name or ""))[:64]
1133
+
1134
+
1135
+ def _automode_risk_scan(pairs, request_body: "dict | None" = None) -> list:
1136
+ """Advisory AutoMode risk scan of outbound tool_use blocks.
1137
+
1138
+ pairs: iterable of (tool_use_id, tool_name, input_dict).
1139
+
1140
+ DOCTRINE — advisory, floor-preserved: this path classifies and records
1141
+ ONLY. It never blocks, never mutates the response, and on any classifier
1142
+ error it logs a warning and passes the tool call through unchanged. That
1143
+ is deliberately fail-OPEN for the advisory path precisely because the
1144
+ deterministic enforcer chain (src/policies/enforcers/*.py) is fail-CLOSED:
1145
+ the heuristics remain the blocking floor, so a dead classifier degrades
1146
+ to exactly today's behaviour plus one warning line. Never raises.
1147
+ """
1148
+ if not (_AUTOMODE_RISK_OK and PROXY_AUTOMODE_RISK):
1149
+ return []
1150
+ results = []
1151
+ for tu_id, name, inp in pairs:
1152
+ safe_name = _log_safe_tool_name(name)
1153
+ try:
1154
+ assessment = _automode_classify(
1155
+ str(name or ""), inp if isinstance(inp, dict) else {}
1156
+ )
1157
+ except Exception as exc:
1158
+ logger.warning(
1159
+ "AUTOMODE RISK: classifier error on %s (%s) — passing through "
1160
+ "unchanged; deterministic enforcers remain the floor",
1161
+ safe_name, type(exc).__name__,
1162
+ )
1163
+ continue
1164
+ results.append((tu_id, name, assessment))
1165
+ logger.debug(
1166
+ "AUTOMODE RISK: %s %s class=%s score=%d signals=%s latency=%.3fms",
1167
+ tu_id, safe_name, assessment.risk_class, assessment.risk_score,
1168
+ ",".join(assessment.signals) or "-", assessment.latency_ms,
1169
+ )
1170
+ # Telemetry (same shape as _record_project_telemetry: fail-open, project
1171
+ # dir derived from the request). Only score>=4 calls earn a dashboard
1172
+ # event — one row per routine read would drown the live feed.
1173
+ high = [(tid, n, a) for tid, n, a in results if a.risk_score >= 4]
1174
+ if high and request_body:
1175
+ try:
1176
+ import project_telemetry as _pt
1177
+ project_dir = _pt.derive_project_dir(request_body)
1178
+ if project_dir:
1179
+ for tu_id, name, a in high:
1180
+ _pt.record_risk_event(
1181
+ project_dir, _log_safe_tool_name(name), a.risk_class,
1182
+ a.risk_score, list(a.signals), a.latency_ms,
1183
+ )
1184
+ except Exception:
1185
+ pass # advisory telemetry must never stall a turn
1186
+ return results
1187
+
1188
+
1189
+ def _automode_risk_scan_response(anthropic_resp: dict,
1190
+ request_body: "dict | None" = None) -> list:
1191
+ """Extract tool_use blocks from a fully assembled Anthropic response and
1192
+ risk-scan them (advisory; see _automode_risk_scan for the doctrine)."""
1193
+ if not (_AUTOMODE_RISK_OK and PROXY_AUTOMODE_RISK):
1194
+ return []
1195
+ try:
1196
+ content = anthropic_resp.get("content")
1197
+ if not isinstance(content, list):
1198
+ return []
1199
+ pairs = [
1200
+ (b.get("id", ""), b.get("name", ""), b.get("input"))
1201
+ for b in content
1202
+ if isinstance(b, dict) and b.get("type") == "tool_use"
1203
+ ]
1204
+ except Exception:
1205
+ return []
1206
+ return _automode_risk_scan(pairs, request_body)
1074
1207
  # Recon-convergence guardrail: after this many consecutive turns that use
1075
1208
  # tools but produce NO write/deliverable tool call (see _WRITE_TOOL_CLASS),
1076
1209
  # the proxy injects a directive telling the model to stop exploring and
@@ -2225,7 +2358,11 @@ class SessionMonitor:
2225
2358
  pre_prune_input_tokens: int = 0
2226
2359
  prune_count: int = 0 # How many times pruning was triggered
2227
2360
  overflow_count: int = 0 # How many context overflow errors caught
2228
- prune_drop_count: int = 0 # monotonic: # of oldest middle msgs pruned (B3)
2361
+ # Monotonic: # of oldest middle msgs pruned (B3). Only meaningful for
2362
+ # legacy lossy-path turns — a successful verbatim compaction (uplift 1.2)
2363
+ # re-indexes the middle non-contiguously and RESETS this to 0, since a
2364
+ # stale boundary would force-drop kept signal on a later fallback.
2365
+ prune_drop_count: int = 0
2229
2366
  context_history: list = field(default_factory=list) # Recent token counts
2230
2367
 
2231
2368
  # --- Token Loop Protection ---
@@ -3301,6 +3438,28 @@ def _summarize_pruned_block(dropped: list[dict]) -> str:
3301
3438
  return header + "\n" + "\n".join(breadcrumbs)
3302
3439
 
3303
3440
 
3441
+ def _prune_overhead_tokens(anthropic_body: dict) -> int:
3442
+ """Estimated non-message tokens (system, agentic supplement, tools) with
3443
+ the 1.5x safety factor for chat-template overhead and tokenization
3444
+ differences between the local estimate and upstream. Shared by
3445
+ prune_conversation and verbatim_compact_conversation so both budget
3446
+ against the same overhead."""
3447
+ overhead_tokens = 0
3448
+ system = anthropic_body.get("system", "")
3449
+ if isinstance(system, str):
3450
+ overhead_tokens += estimate_tokens(system)
3451
+ elif isinstance(system, list):
3452
+ for block in system:
3453
+ if isinstance(block, dict) and block.get("type") == "text":
3454
+ overhead_tokens += estimate_tokens(block.get("text", ""))
3455
+ if _has_tool_definitions(anthropic_body):
3456
+ overhead_tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
3457
+ tools = anthropic_body.get("tools", [])
3458
+ if tools:
3459
+ overhead_tokens += estimate_tokens(json.dumps(tools))
3460
+ return int(overhead_tokens * 1.5) # Safety factor for template overhead
3461
+
3462
+
3304
3463
  def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> bool:
3305
3464
  """Truncate the largest message content in-place until the total fits
3306
3465
  budget_tokens. Used when message-DROPPING cannot reduce below the window —
@@ -3355,6 +3514,595 @@ def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> b
3355
3514
  return sum(estimate_message_tokens(m) for m in messages) <= budget_tokens
3356
3515
 
3357
3516
 
3517
+ # ---------------------------------------------------------------------------
3518
+ # Verbatim decision compaction (uplift 1.2)
3519
+ # ---------------------------------------------------------------------------
3520
+ # Per-tool-call keep/truncate/drop decisions that keep everything retained
3521
+ # VERBATIM — no summarization of retained messages. Staged fitting:
3522
+ # stage 1: drop pure-noise tool pairs (acks, superseded reads, repeats)
3523
+ # stage 2: truncate large kept tool results in place (verbatim head + note)
3524
+ # stage 3: return None -> caller falls back to the contiguous breadcrumb
3525
+ # pruner (prune_conversation), whose monotonic prune_drop_count
3526
+ # semantics stay intact for the fallback.
3527
+ # HARD INVARIANT — pairing integrity: a tool_use and its tool_result are an
3528
+ # atomic pair. Drop/truncate decisions are made at pair granularity so the
3529
+ # output never has an orphan tool_result or a dangling tool_use; the output
3530
+ # is validated before being accepted, and any failure fails CLOSED (None ->
3531
+ # old path). KV-cache doctrine, stated honestly: llama.cpp prefix matching
3532
+ # stops at the FIRST divergence, so a byte-identical tail buys no reuse by
3533
+ # itself. What this path guarantees is that divergence is DEFERRED as far
3534
+ # into the prompt as possible — it diverges at the first dropped/truncated
3535
+ # middle pair, where the legacy pruner inserts its marker at index 1 on
3536
+ # every run — and never COMPOUNDS: survivors are never reshuffled or
3537
+ # re-summarized, so repeated compactions don't keep invalidating more of
3538
+ # the prefix.
3539
+
3540
+ # Tool-result texts at or below this length that fullmatch an acknowledgment
3541
+ # pattern carry no information worth context budget. Whole-string match, NOT
3542
+ # a prefix test — "ok, here are the 3 failing tests" is not an ack.
3543
+ _VERBATIM_ACK_MAX_CHARS = 120
3544
+ _VERBATIM_ACK_PATTERNS = tuple(
3545
+ re.compile(p, re.IGNORECASE)
3546
+ for p in (
3547
+ r"ok(?:ay)?[.!]?",
3548
+ r"done[.!]?",
3549
+ r"success(?:fully)?(?: completed| done)?[.!]?",
3550
+ r"acknowledged[.!]?",
3551
+ r"got it[.!]?",
3552
+ r"no changes(?: made)?[.!]?",
3553
+ r"file (?:created|updated|written|saved) successfully(?: at: \S+)?[.!]?",
3554
+ r"the file has been (?:updated|written|saved)[.!]?",
3555
+ )
3556
+ )
3557
+
3558
+ # Read-class tools whose result for a path is superseded by a later read of
3559
+ # the same path. Write-class tools are deliberately absent: their results
3560
+ # only qualify as noise via the acknowledgment rule.
3561
+ _VERBATIM_READ_TOOLS = frozenset({
3562
+ "read", "read_file", "readfile", "cat", "view", "view_file",
3563
+ "open_file", "grep", "glob", "ls", "list_files", "search", "find",
3564
+ })
3565
+
3566
+ # Write-class tools: a write to a path between two reads of it means the
3567
+ # first read is NOT superseded (the before/after record is state), and their
3568
+ # input payloads are the model's own action history — never truncated.
3569
+ _VERBATIM_WRITE_TOOLS = frozenset({
3570
+ "write", "edit", "multiedit", "multi_edit", "write_file", "edit_file",
3571
+ "notebookedit", "notebook_edit", "patch", "apply_patch",
3572
+ })
3573
+
3574
+ # Stage-1 drop breadcrumb shape: one bounded note at the position of the
3575
+ # first drop so the elision is accountable in-context.
3576
+ _VERBATIM_BREADCRUMB_MAX_ITEMS = 6
3577
+ _VERBATIM_BREADCRUMB_MAX_CHARS = 300
3578
+
3579
+ # Stage 2 truncation shape: only results larger than MIN are touched; each
3580
+ # keeps a verbatim head of HEAD chars followed by a truncation note.
3581
+ _VERBATIM_TRUNCATE_MIN_CHARS = 1200
3582
+ _VERBATIM_TRUNCATE_HEAD_CHARS = 1500
3583
+
3584
+
3585
+ def _iter_tool_use_blocks(msg: dict):
3586
+ """Yield the tool_use blocks of a single message."""
3587
+ content = msg.get("content")
3588
+ if isinstance(content, list):
3589
+ for block in content:
3590
+ if isinstance(block, dict) and block.get("type") == "tool_use":
3591
+ yield block
3592
+
3593
+
3594
+ def _iter_tool_result_blocks(msg: dict):
3595
+ """Yield the tool_result blocks of a single message."""
3596
+ content = msg.get("content")
3597
+ if isinstance(content, list):
3598
+ for block in content:
3599
+ if isinstance(block, dict) and block.get("type") == "tool_result":
3600
+ yield block
3601
+
3602
+
3603
+ def _tool_call_path(tool_input) -> str | None:
3604
+ """The file path a tool call operates on, if it names one."""
3605
+ if not isinstance(tool_input, dict):
3606
+ return None
3607
+ for key in ("file_path", "path", "filename", "file"):
3608
+ value = tool_input.get(key)
3609
+ if isinstance(value, str) and value:
3610
+ return value
3611
+ return None
3612
+
3613
+
3614
+ def _call_fingerprint(name: str, tool_input) -> str:
3615
+ try:
3616
+ return name + "|" + json.dumps(tool_input, sort_keys=True, default=str)
3617
+ except (TypeError, ValueError):
3618
+ return name + "|" + repr(tool_input)
3619
+
3620
+
3621
+ def _is_ack_result(text: str) -> bool:
3622
+ """Pure-acknowledgment tool result: empty, or a short formulaic string
3623
+ that FULLMATCHES an ack pattern (whole-string, case-insensitive — a
3624
+ real finding that merely STARTS with "ok" is not an ack)."""
3625
+ stripped = text.strip()
3626
+ if not stripped:
3627
+ return True
3628
+ if len(stripped) > _VERBATIM_ACK_MAX_CHARS:
3629
+ return False
3630
+ return any(p.fullmatch(stripped) for p in _VERBATIM_ACK_PATTERNS)
3631
+
3632
+
3633
+ def _has_meaningful_text(msg: dict, keep_block_type: str) -> bool:
3634
+ """True when the message carries prose worth keeping besides its tool
3635
+ blocks of type keep_block_type (tool_use for assistant, tool_result for
3636
+ user). Any unknown block type counts as meaningful — when in doubt, keep.
3637
+ """
3638
+ content = msg.get("content")
3639
+ if isinstance(content, str):
3640
+ return bool(content.strip())
3641
+ if isinstance(content, list):
3642
+ for block in content:
3643
+ if isinstance(block, str):
3644
+ if block.strip():
3645
+ return True
3646
+ elif isinstance(block, dict):
3647
+ btype = block.get("type")
3648
+ if btype == keep_block_type:
3649
+ continue
3650
+ if btype in ("text", "thinking"):
3651
+ text = block.get("text") or block.get("thinking") or ""
3652
+ if text.strip():
3653
+ return True
3654
+ else:
3655
+ return True
3656
+ return False
3657
+
3658
+
3659
+ def _result_blocks_by_use_id(msg: dict) -> dict:
3660
+ """tool_use_id -> tool_result block for a user message."""
3661
+ out = {}
3662
+ for block in _iter_tool_result_blocks(msg):
3663
+ tid = block.get("tool_use_id")
3664
+ if isinstance(tid, str):
3665
+ out[tid] = block
3666
+ return out
3667
+
3668
+
3669
+ def _later_occurrence_maps(messages: list) -> tuple[dict, dict, dict]:
3670
+ """Occurrence index for stage-1 noise detection — O(n) memory via
3671
+ per-key ascending occurrence lists (a later-occurrence query is a
3672
+ nearest-neighbor scan of one small list, not a per-index set copy).
3673
+
3674
+ Returns (reads, writes, fps):
3675
+ reads: read-path -> [(msg_idx, errored), ...] ascending
3676
+ writes: write-path -> [msg_idx, ...] ascending
3677
+ fps: call fingerprint -> [(msg_idx, result_text), ...] ascending
3678
+ The protected tail counts as "later" (a read whose freshest copy lives
3679
+ in the tail is superseded); the FINAL message, when it is an unanswered
3680
+ assistant prefill, records no occurrences."""
3681
+ reads: dict = {}
3682
+ writes: dict = {}
3683
+ fps: dict = {}
3684
+ last = len(messages) - 1
3685
+ for idx, msg in enumerate(messages):
3686
+ if idx == last and msg.get("role") == "assistant":
3687
+ break # unanswered prefill — not an occurrence of anything
3688
+ result_blocks = {}
3689
+ if idx + 1 <= last and messages[idx + 1].get("role") == "user":
3690
+ result_blocks = _result_blocks_by_use_id(messages[idx + 1])
3691
+ for block in _iter_tool_use_blocks(msg):
3692
+ name = block.get("name") or ""
3693
+ tool_input = block.get("input")
3694
+ result = result_blocks.get(block.get("id"))
3695
+ text = _extract_text(result.get("content", "")) if result else ""
3696
+ errored = bool(
3697
+ result
3698
+ and (
3699
+ result.get("is_error") is True
3700
+ or text.lower().startswith("error")
3701
+ )
3702
+ )
3703
+ path = _tool_call_path(tool_input)
3704
+ lname = name.lower()
3705
+ if path and lname in _VERBATIM_READ_TOOLS:
3706
+ reads.setdefault(path, []).append((idx, errored))
3707
+ elif path and lname in _VERBATIM_WRITE_TOOLS:
3708
+ writes.setdefault(path, []).append(idx)
3709
+ fps.setdefault(_call_fingerprint(name, tool_input), []).append((idx, text))
3710
+ return reads, writes, fps
3711
+
3712
+
3713
+ def _read_is_superseded(path: str, idx: int, reads: dict, writes: dict) -> bool:
3714
+ """A read is superseded only by a LATER, NON-ERRORED read of the same
3715
+ path with NO write to that path in between — Read A -> Write A -> Read A
3716
+ keeps the first read, because the before/after record is state."""
3717
+ later = None
3718
+ for j, errored in reads.get(path, []):
3719
+ if j > idx:
3720
+ later = (j, errored)
3721
+ break
3722
+ if later is None or later[1]:
3723
+ return False # no later read, or the nearest later read errored
3724
+ for w in writes.get(path, []):
3725
+ if idx < w < later[0]:
3726
+ return False
3727
+ return True
3728
+
3729
+
3730
+ def _repeat_is_noise(entries: list, idx: int, result_text: str) -> bool:
3731
+ """A repeated identical call is noise only when the nearest LATER
3732
+ occurrence's result is identical to the earlier one — an earlier result
3733
+ that was an error or a policy denial must never vanish because a later
3734
+ identical call succeeded blandly. (The ack-class early branch in the
3735
+ caller covers the 'earlier result is an ack' case.)"""
3736
+ for j, j_text in entries:
3737
+ if j > idx:
3738
+ return result_text == j_text
3739
+ return False
3740
+
3741
+
3742
+ def _pair_units(middle: list) -> list[tuple]:
3743
+ """Group middle messages into atomic units: an assistant message carrying
3744
+ tool_use blocks plus the immediately following user message carrying the
3745
+ matching tool_result blocks form one (assistant, user) pair unit; every
3746
+ other message is a singleton unit. Pairing is decided CONSERVATIVELY —
3747
+ the user's result ids must cover all of the assistant's use ids — so a
3748
+ malformed adjacency degrades to singletons (never dropped, only the old
3749
+ path touches them)."""
3750
+ units: list[tuple] = []
3751
+ i = 0
3752
+ while i < len(middle):
3753
+ msg = middle[i]
3754
+ use_ids = {b.get("id") for b in _iter_tool_use_blocks(msg)}
3755
+ if use_ids and i + 1 < len(middle):
3756
+ nxt = middle[i + 1]
3757
+ result_ids = {b.get("tool_use_id") for b in _iter_tool_result_blocks(nxt)}
3758
+ if nxt.get("role") == "user" and use_ids <= result_ids:
3759
+ units.append((i, i + 1))
3760
+ i += 2
3761
+ continue
3762
+ units.append((i,))
3763
+ i += 1
3764
+ return units
3765
+
3766
+
3767
+ def _unit_is_pure_noise(
3768
+ middle: list,
3769
+ unit: tuple,
3770
+ occ: tuple,
3771
+ middle_offset: int,
3772
+ ) -> bool:
3773
+ """Stage-1 classifier. A pair is pure noise only when it carries no
3774
+ meaningful prose AND every tool call in it is independently noise:
3775
+ a pure acknowledgment result, a superseded read (a later non-errored
3776
+ read of the same path with no intervening write), or a repeated
3777
+ identical call whose fresher copy returned the SAME result. Singletons
3778
+ are never noise. When in doubt: keep."""
3779
+ if len(unit) != 2:
3780
+ return False
3781
+ reads, writes, fps = occ
3782
+ a_msg, u_msg = middle[unit[0]], middle[unit[1]]
3783
+ if _has_meaningful_text(a_msg, "tool_use"):
3784
+ return False
3785
+ if _has_meaningful_text(u_msg, "tool_result"):
3786
+ return False
3787
+ results = {
3788
+ b.get("tool_use_id"): _extract_text(b.get("content", ""))
3789
+ for b in _iter_tool_result_blocks(u_msg)
3790
+ }
3791
+ calls = list(_iter_tool_use_blocks(a_msg))
3792
+ if not calls:
3793
+ return False
3794
+ global_idx = middle_offset + unit[0]
3795
+ for call in calls:
3796
+ result_text = results.get(call.get("id"), "")
3797
+ if _is_ack_result(result_text):
3798
+ continue
3799
+ name = call.get("name") or ""
3800
+ tool_input = call.get("input")
3801
+ if name.lower() in _VERBATIM_READ_TOOLS:
3802
+ path = _tool_call_path(tool_input)
3803
+ if path and _read_is_superseded(path, global_idx, reads, writes):
3804
+ continue
3805
+ fp_entries = fps.get(_call_fingerprint(name, tool_input), [])
3806
+ if _repeat_is_noise(fp_entries, global_idx, result_text):
3807
+ continue
3808
+ return False
3809
+ return True
3810
+
3811
+
3812
+ def _truncate_text_block(text: str) -> tuple[str, int]:
3813
+ """(truncated_text, chars_removed) keeping a verbatim head + note, or
3814
+ (text, 0) when truncation would not shrink the block — a text in the
3815
+ (HEAD, HEAD + len(note)] band would otherwise GROW by the note."""
3816
+ if len(text) <= _VERBATIM_TRUNCATE_MIN_CHARS:
3817
+ return text, 0
3818
+ cut = text[_VERBATIM_TRUNCATE_HEAD_CHARS:]
3819
+ note = f"\n\n[… truncated ~{estimate_tokens(cut)} tokens …]"
3820
+ if len(text) <= _VERBATIM_TRUNCATE_HEAD_CHARS + len(note):
3821
+ return text, 0
3822
+ return text[:_VERBATIM_TRUNCATE_HEAD_CHARS] + note, len(cut) - len(note)
3823
+
3824
+
3825
+ def _truncate_pair(pair_msgs: list) -> int:
3826
+ """Stage 2: truncate a (assistant, user) tool pair in place, keeping a
3827
+ verbatim head plus a note on each truncated block. tool_result texts are
3828
+ always eligible; tool_use INPUT fields are truncated only for read-class
3829
+ tools — write-class input payloads (content/new_string/old_string etc.)
3830
+ are the model's own action history, and truncating them is state
3831
+ mutation. Returns chars removed (0 when nothing was worth truncating)."""
3832
+ a_msg, u_msg = pair_msgs
3833
+ removed = 0
3834
+ for block in _iter_tool_result_blocks(u_msg):
3835
+ text = _extract_text(block.get("content", ""))
3836
+ new_text, delta = _truncate_text_block(text)
3837
+ if delta > 0:
3838
+ block["content"] = new_text
3839
+ removed += delta
3840
+ for block in _iter_tool_use_blocks(a_msg):
3841
+ if (block.get("name") or "").lower() not in _VERBATIM_READ_TOOLS:
3842
+ continue # write-class inputs are action history — never truncate
3843
+ tool_input = block.get("input")
3844
+ if not isinstance(tool_input, dict):
3845
+ continue
3846
+ for key, value in tool_input.items():
3847
+ if isinstance(value, str):
3848
+ new_value, delta = _truncate_text_block(value)
3849
+ if delta > 0:
3850
+ tool_input[key] = new_value
3851
+ removed += delta
3852
+ return removed
3853
+
3854
+
3855
+ def _noise_breadcrumb(middle: list, units: list, noise_ids: set) -> str:
3856
+ """One bounded accountability note for the stage-1 elision, inserted at
3857
+ the position of the first drop: which tool pairs vanished, so the model
3858
+ isn't silently missing them. Capped in items and total chars."""
3859
+ items: list[str] = []
3860
+ n_pairs = 0
3861
+ for unit in units:
3862
+ if unit[0] not in noise_ids:
3863
+ continue
3864
+ n_pairs += 1
3865
+ for call in _iter_tool_use_blocks(middle[unit[0]]):
3866
+ name = (call.get("name") or "tool").lower()
3867
+ path = _tool_call_path(call.get("input"))
3868
+ items.append(f"{name} {path}" if path else name)
3869
+ header = (
3870
+ f"[compaction: {n_pairs} noise tool "
3871
+ f"pair{'s' if n_pairs != 1 else ''} elided"
3872
+ )
3873
+ if not items:
3874
+ return header + "]"
3875
+ shown = items[:_VERBATIM_BREADCRUMB_MAX_ITEMS]
3876
+
3877
+ def _render() -> str:
3878
+ suffix = "" if len(items) <= len(shown) else f", +{len(items) - len(shown)} more"
3879
+ return header + (" — " + ", ".join(shown) + suffix if shown else "") + "]"
3880
+
3881
+ text = _render()
3882
+ while shown and len(text) > _VERBATIM_BREADCRUMB_MAX_CHARS:
3883
+ shown = shown[:-1]
3884
+ text = _render()
3885
+ return text
3886
+
3887
+
3888
+ def _tool_pairing_intact(messages: list) -> bool:
3889
+ """Validate the tool_use/tool_result pairing invariant (uplift 1.2).
3890
+
3891
+ Every assistant message carrying tool_use blocks must be answered by the
3892
+ IMMEDIATELY following user message carrying exactly the matching
3893
+ tool_result ids — no orphan tool_result, no dangling tool_use. One
3894
+ legitimate exception: a dangling tool_use in the FINAL message (an
3895
+ assistant prefill awaiting its results)."""
3896
+ pending: set | None = None
3897
+ last_idx = len(messages) - 1
3898
+ for idx, msg in enumerate(messages):
3899
+ use_ids = {
3900
+ b.get("id")
3901
+ for b in _iter_tool_use_blocks(msg)
3902
+ if isinstance(b.get("id"), str)
3903
+ }
3904
+ result_ids = {
3905
+ b.get("tool_use_id")
3906
+ for b in _iter_tool_result_blocks(msg)
3907
+ if isinstance(b.get("tool_use_id"), str)
3908
+ }
3909
+ if pending is not None:
3910
+ if msg.get("role") != "user" or result_ids != pending:
3911
+ return False
3912
+ pending = None
3913
+ continue
3914
+ if use_ids and msg.get("role") == "assistant":
3915
+ pending = use_ids
3916
+ elif result_ids:
3917
+ return False # orphan tool_result with no pending tool_use
3918
+ # Reaching the end with pending set means the FINAL message is an
3919
+ # assistant prefill awaiting its results — the one legitimate dangling
3920
+ # tool_use (any message after a pending one either clears it in the loop
3921
+ # above or fails the check outright).
3922
+ return pending is None or messages[last_idx].get("role") == "assistant"
3923
+
3924
+
3925
+ def verbatim_compact_conversation(
3926
+ anthropic_body: dict,
3927
+ context_window: int,
3928
+ monitor: "SessionMonitor | None" = None,
3929
+ target_fraction: float = 0.65,
3930
+ keep_last: int = 8,
3931
+ ) -> dict | None:
3932
+ """Compact the conversation with per-tool-call keep/truncate/drop
3933
+ decisions, keeping everything retained VERBATIM (uplift 1.2).
3934
+
3935
+ Returns the compacted body, or None to signal "fall back to
3936
+ prune_conversation" — used when the verbatim stages cannot reach the
3937
+ budget, when pairing integrity cannot be guaranteed, or when there is
3938
+ nothing for the verbatim path to do. Never raises on malformed input by
3939
+ design; the caller also wraps this in try/except and fails closed.
3940
+
3941
+ KV-cache note: this path diverges from the previous prompt at the first
3942
+ dropped/truncated middle pair (the legacy pruner diverges at index 1 via
3943
+ its marker), and survivors are never reshuffled or re-summarized, so
3944
+ repeated compactions don't compound divergence.
3945
+
3946
+ On success the middle has been re-indexed non-contiguously, so the
3947
+ legacy path's monotonic boundary no longer maps to the same messages:
3948
+ monitor.prune_drop_count is RESET to 0 (a stale boundary would
3949
+ force-drop kept signal on a later fallback turn).
3950
+ """
3951
+ messages = anthropic_body.get("messages", [])
3952
+ if len(messages) <= 4:
3953
+ # Few-message overflow needs single-message content truncation,
3954
+ # which is exactly what the old path's oversized-content pass does.
3955
+ return None
3956
+
3957
+ keep_last = max(1, keep_last)
3958
+ message_budget = int(context_window * target_fraction) - _prune_overhead_tokens(
3959
+ anthropic_body
3960
+ )
3961
+ if message_budget <= 0:
3962
+ # Tool-heavy clients: same minimal floor the old path prunes into.
3963
+ message_budget = max(1, int(context_window * 0.20))
3964
+
3965
+ protected_tail = (
3966
+ messages[-keep_last:] if len(messages) > keep_last else messages[1:]
3967
+ )
3968
+ middle = messages[1:-keep_last] if len(messages) > keep_last + 1 else []
3969
+ if not middle:
3970
+ return None
3971
+ protected_tokens = sum(
3972
+ estimate_message_tokens(m) for m in messages[:1] + protected_tail
3973
+ )
3974
+ if protected_tokens >= message_budget:
3975
+ # Even the undroppable head+tail overflow — the old path truncates
3976
+ # them in place; nothing the verbatim stages can do.
3977
+ return None
3978
+
3979
+ def _total() -> int:
3980
+ return sum(estimate_message_tokens(m) for m in messages)
3981
+
3982
+ # No-op check on the ORIGINAL messages — before paying for the deepcopy.
3983
+ if _total() <= message_budget:
3984
+ return None # nothing to do; let the old path confirm no-op
3985
+
3986
+ # Work on a deep copy so any mid-flight failure leaves the caller's body
3987
+ # pristine for the lossy fallback.
3988
+ body = copy.deepcopy(anthropic_body)
3989
+ messages = body["messages"]
3990
+ middle = messages[1:-keep_last]
3991
+
3992
+ units = _pair_units(middle)
3993
+ occ = _later_occurrence_maps(messages)
3994
+
3995
+ # --- Stage 1: drop pure-noise tool pairs (survivors keep their order) ---
3996
+ noise_ids = {
3997
+ idx
3998
+ for unit in units
3999
+ if _unit_is_pure_noise(middle, unit, occ, 1)
4000
+ for idx in unit
4001
+ }
4002
+ changed = False
4003
+ if noise_ids:
4004
+ # Drops are never silent: one bounded accountability note at the
4005
+ # position of the first drop (all indices below it are kept, so it
4006
+ # lands exactly where the first elided pair used to be).
4007
+ breadcrumb = _noise_breadcrumb(middle, units, noise_ids)
4008
+ first_drop = min(noise_ids)
4009
+ middle = [m for i, m in enumerate(middle) if i not in noise_ids]
4010
+ middle.insert(first_drop, {"role": "user", "content": breadcrumb})
4011
+ messages = messages[:1] + middle + messages[len(messages) - keep_last:]
4012
+ body["messages"] = messages
4013
+ changed = True
4014
+
4015
+ # --- Stage 2: truncate large kept tool results, oldest first, against a
4016
+ # running budget (an exact _total() is recomputed once at the end) ---
4017
+ running = _total()
4018
+ if running > message_budget:
4019
+ middle = messages[1:-keep_last]
4020
+ for unit in _pair_units(middle):
4021
+ if len(unit) != 2:
4022
+ continue
4023
+ if running <= message_budget:
4024
+ break
4025
+ removed = _truncate_pair([middle[unit[0]], middle[unit[1]]])
4026
+ if removed > 0:
4027
+ changed = True
4028
+ running -= int(removed / CHARS_PER_TOKEN)
4029
+
4030
+ if not changed:
4031
+ return None # nothing the verbatim path could do — old path decides
4032
+
4033
+ if _total() > message_budget:
4034
+ # Stage 3: stages 1-2 cannot reach budget — fail closed to the
4035
+ # contiguous breadcrumb pruner.
4036
+ return None
4037
+
4038
+ # HARD INVARIANT: validate pairing on the transformed output; any
4039
+ # integrity failure fails closed to the old path.
4040
+ if not _tool_pairing_intact(messages):
4041
+ logger.warning(
4042
+ "verbatim compaction produced a pairing-integrity violation -- "
4043
+ "falling back to lossy pruner"
4044
+ )
4045
+ logger.debug(
4046
+ "verbatim skipped: tool_use/tool_result id sets mismatch after "
4047
+ "transform (id-less or mismatched tool blocks)"
4048
+ )
4049
+ return None
4050
+
4051
+ if monitor is not None:
4052
+ # The middle was re-indexed non-contiguously; the legacy monotonic
4053
+ # boundary no longer maps to the same messages.
4054
+ monitor.prune_drop_count = 0
4055
+
4056
+ logger.warning(
4057
+ "VERBATIM COMPACT: dropped %d noise messages, kept %d total, "
4058
+ "target=%.0f%% of %d ctx (est %d -> %d message tokens)",
4059
+ len(noise_ids),
4060
+ len(messages),
4061
+ target_fraction * 100,
4062
+ context_window,
4063
+ sum(estimate_message_tokens(m) for m in anthropic_body.get("messages", [])),
4064
+ _total(),
4065
+ )
4066
+ return body
4067
+
4068
+
4069
+ def _compact_conversation(
4070
+ body: dict,
4071
+ ctx_window: int,
4072
+ monitor: "SessionMonitor | None",
4073
+ target_fraction: float,
4074
+ keep_last: int,
4075
+ ) -> dict:
4076
+ """Context-pressure compaction entry point: try the verbatim
4077
+ keep/truncate/drop path first (kept messages stay byte-identical; only
4078
+ noise pairs are elided, with one bounded breadcrumb, and oversized
4079
+ results keep verbatim heads); on None or ANY exception, fail closed to
4080
+ the known-good lossy pruner. PROXY_VERBATIM_COMPACT=off skips the
4081
+ verbatim path entirely."""
4082
+ if PROXY_VERBATIM_COMPACT != "off":
4083
+ try:
4084
+ compacted = verbatim_compact_conversation(
4085
+ body,
4086
+ ctx_window,
4087
+ monitor=monitor,
4088
+ target_fraction=target_fraction,
4089
+ keep_last=keep_last,
4090
+ )
4091
+ if compacted is not None:
4092
+ return compacted
4093
+ except Exception:
4094
+ logger.exception(
4095
+ "verbatim compaction failed -- falling back to lossy pruner"
4096
+ )
4097
+ return prune_conversation(
4098
+ body,
4099
+ ctx_window,
4100
+ monitor=monitor,
4101
+ target_fraction=target_fraction,
4102
+ keep_last=keep_last,
4103
+ )
4104
+
4105
+
3358
4106
  def prune_conversation(
3359
4107
  anthropic_body: dict,
3360
4108
  context_window: int,
@@ -3408,22 +4156,9 @@ def prune_conversation(
3408
4156
  target_tokens = int(context_window * target_fraction)
3409
4157
 
3410
4158
  # Estimate non-message tokens (system, tools, agentic supplement)
3411
- # Apply a 1.5x safety factor to account for chat template overhead
3412
- # and tokenization differences between local estimate and upstream
3413
- overhead_tokens = 0
3414
- system = anthropic_body.get("system", "")
3415
- if isinstance(system, str):
3416
- overhead_tokens += estimate_tokens(system)
3417
- elif isinstance(system, list):
3418
- for block in system:
3419
- if isinstance(block, dict) and block.get("type") == "text":
3420
- overhead_tokens += estimate_tokens(block.get("text", ""))
3421
- if _has_tool_definitions(anthropic_body):
3422
- overhead_tokens += estimate_tokens(_AGENTIC_SYSTEM_SUPPLEMENT)
3423
- tools = anthropic_body.get("tools", [])
3424
- if tools:
3425
- overhead_tokens += estimate_tokens(json.dumps(tools))
3426
- overhead_tokens = int(overhead_tokens * 1.5) # Safety factor for template overhead
4159
+ # with the 1.5x safety factor for chat-template overhead and
4160
+ # tokenization differences between local estimate and upstream.
4161
+ overhead_tokens = _prune_overhead_tokens(anthropic_body)
3427
4162
 
3428
4163
  # Budget for messages
3429
4164
  message_budget = target_tokens - overhead_tokens
@@ -10773,6 +11508,14 @@ async def _apply_unexpected_end_turn_guardrail(
10773
11508
  retry_body = dict(openai_body)
10774
11509
  retry_body["tool_choice"] = "required"
10775
11510
  retry_body["stream"] = False
11511
+ # Thinking runaway defeats tool_choice=required: with reasoning enabled the
11512
+ # model can burn the whole retry budget in <think> and return end_turn
11513
+ # (or length) without any tool call — reproduced live 2026-09-17 against
11514
+ # qwen38-gsq-rco-27b (5917 reasoning chars, finish=length, 0 tool_calls;
11515
+ # thinking-off + required returned a clean tool_call immediately). The
11516
+ # retry is a recovery path, so determinism beats reasoning depth here:
11517
+ # force thinking OFF only on this retry, never on the original turn.
11518
+ _set_thinking(retry_body, False)
10776
11519
  _apply_tool_call_grammar(retry_body, tool_choice="required")
10777
11520
 
10778
11521
  retry_resp = await client.post(
@@ -12575,6 +13318,20 @@ async def stream_anthropic_response(
12575
13318
 
12576
13319
  # Close any open tool call blocks (skip if XML recovery already emitted them)
12577
13320
  xml_recovered = tool_calls_by_index.pop("_xml_recovered", False)
13321
+ # AutoMode advisory risk scan (uplift 1.3) of the completed streamed tool
13322
+ # calls: the full arguments buffer has accumulated by now, so this is the
13323
+ # earliest point the stream path can classify. Classification only — the
13324
+ # events already forwarded to the client are byte-identical either way.
13325
+ if tool_calls_by_index:
13326
+ _automode_risk_scan(
13327
+ [
13328
+ (tc.get("id", ""), tc.get("name", ""),
13329
+ _tool_args_dict(tc.get("arguments", "")))
13330
+ for tc in tool_calls_by_index.values()
13331
+ if isinstance(tc, dict) and "block_index" in tc
13332
+ ],
13333
+ anthropic_body,
13334
+ )
12578
13335
  if tool_calls_by_index and not xml_recovered:
12579
13336
  for tc in tool_calls_by_index.values():
12580
13337
  if isinstance(tc, dict) and "block_index" in tc:
@@ -13296,7 +14053,7 @@ async def messages(request: Request):
13296
14053
  keep_last,
13297
14054
  target_frac * 100,
13298
14055
  )
13299
- body = prune_conversation(
14056
+ body = _compact_conversation(
13300
14057
  body, ctx_window, monitor=monitor,
13301
14058
  target_fraction=target_frac, keep_last=keep_last,
13302
14059
  )
@@ -13318,7 +14075,7 @@ async def messages(request: Request):
13318
14075
  "POST-PRUNE VALIDATION: still at %.1f%% after prune, doing aggressive pass",
13319
14076
  post_util * 100,
13320
14077
  )
13321
- body = prune_conversation(
14078
+ body = _compact_conversation(
13322
14079
  body, ctx_window, monitor=monitor,
13323
14080
  target_fraction=0.35, keep_last=4,
13324
14081
  )
@@ -13594,6 +14351,9 @@ async def messages(request: Request):
13594
14351
  suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
13595
14352
  )
13596
14353
  _maybe_normalize_toolcall_paths(anthropic_resp, body)
14354
+ # AutoMode advisory risk scan (uplift 1.3): classify every outbound
14355
+ # tool_use before the client sees it. Never blocks, never mutates.
14356
+ _automode_risk_scan_response(anthropic_resp, body)
13597
14357
  # FINALIZE CONTINUATION: inject synthetic tool_use to keep client loop alive
13598
14358
  if (
13599
14359
  monitor.finalize_turn_active
@@ -14041,6 +14801,9 @@ async def messages(request: Request):
14041
14801
  suppress_text_tool_extraction=monitor.suppress_text_tool_extraction,
14042
14802
  )
14043
14803
  _maybe_normalize_toolcall_paths(anthropic_resp, body)
14804
+ # AutoMode advisory risk scan (uplift 1.3): classify every outbound
14805
+ # tool_use before the client sees it. Never blocks, never mutates.
14806
+ _automode_risk_scan_response(anthropic_resp, body)
14044
14807
  # FINALIZE CONTINUATION: inject synthetic tool_use (non-guarded stream path)
14045
14808
  if (
14046
14809
  monitor.finalize_turn_active