@miller-tech/uap 2.1.7 → 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.
- package/config/capacity-policy.json +32 -0
- package/config/classify-thresholds.json +9 -0
- package/config/supervise-policy.json +13 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +87 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +67 -0
- package/dist/capacity/policy.d.ts.map +1 -0
- package/dist/capacity/policy.js +133 -0
- package/dist/capacity/policy.js.map +1 -0
- package/dist/capacity/probe.d.ts +23 -0
- package/dist/capacity/probe.d.ts.map +1 -0
- package/dist/capacity/probe.js +93 -0
- package/dist/capacity/probe.js.map +1 -0
- package/dist/classify/baseline.d.ts +24 -0
- package/dist/classify/baseline.d.ts.map +1 -0
- package/dist/classify/baseline.js +140 -0
- package/dist/classify/baseline.js.map +1 -0
- package/dist/classify/index.d.ts +30 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +84 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/shadow.d.ts +34 -0
- package/dist/classify/shadow.d.ts.map +1 -0
- package/dist/classify/shadow.js +33 -0
- package/dist/classify/shadow.js.map +1 -0
- package/dist/classify/types.d.ts +64 -0
- package/dist/classify/types.d.ts.map +1 -0
- package/dist/classify/types.js +37 -0
- package/dist/classify/types.js.map +1 -0
- package/dist/cli/classify.d.ts +24 -0
- package/dist/cli/classify.d.ts.map +1 -0
- package/dist/cli/classify.js +94 -0
- package/dist/cli/classify.js.map +1 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +26 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/doctor.d.ts +9 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +49 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/review.d.ts +12 -0
- package/dist/cli/review.d.ts.map +1 -0
- package/dist/cli/review.js +118 -0
- package/dist/cli/review.js.map +1 -0
- package/dist/cli/supervise.d.ts +8 -0
- package/dist/cli/supervise.d.ts.map +1 -0
- package/dist/cli/supervise.js +76 -0
- package/dist/cli/supervise.js.map +1 -0
- package/dist/delivery/convergence-loop.d.ts +8 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +1 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/delivery-result.d.ts.map +1 -1
- package/dist/delivery/delivery-result.js +5 -0
- package/dist/delivery/delivery-result.js.map +1 -1
- package/dist/delivery/gate-evidence.d.ts +143 -0
- package/dist/delivery/gate-evidence.d.ts.map +1 -0
- package/dist/delivery/gate-evidence.js +239 -0
- package/dist/delivery/gate-evidence.js.map +1 -0
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/evals/routing-corpus.d.ts +35 -0
- package/dist/evals/routing-corpus.d.ts.map +1 -0
- package/dist/evals/routing-corpus.js +142 -0
- package/dist/evals/routing-corpus.js.map +1 -0
- package/dist/evals/routing-eval.d.ts +104 -0
- package/dist/evals/routing-eval.d.ts.map +1 -0
- package/dist/evals/routing-eval.js +231 -0
- package/dist/evals/routing-eval.js.map +1 -0
- package/dist/evals/tfidf.d.ts +35 -0
- package/dist/evals/tfidf.d.ts.map +1 -0
- package/dist/evals/tfidf.js +132 -0
- package/dist/evals/tfidf.js.map +1 -0
- package/dist/review/prepass.d.ts +88 -0
- package/dist/review/prepass.d.ts.map +1 -0
- package/dist/review/prepass.js +428 -0
- package/dist/review/prepass.js.map +1 -0
- package/dist/review/visual-captures.d.ts +60 -0
- package/dist/review/visual-captures.d.ts.map +1 -0
- package/dist/review/visual-captures.js +222 -0
- package/dist/review/visual-captures.js.map +1 -0
- package/dist/supervise/assess.d.ts +73 -0
- package/dist/supervise/assess.d.ts.map +1 -0
- package/dist/supervise/assess.js +215 -0
- package/dist/supervise/assess.js.map +1 -0
- package/dist/supervise/config.d.ts +13 -0
- package/dist/supervise/config.d.ts.map +1 -0
- package/dist/supervise/config.js +81 -0
- package/dist/supervise/config.js.map +1 -0
- package/dist/supervise/loop.d.ts +78 -0
- package/dist/supervise/loop.d.ts.map +1 -0
- package/dist/supervise/loop.js +311 -0
- package/dist/supervise/loop.js.map +1 -0
- package/dist/supervise/observe.d.ts +31 -0
- package/dist/supervise/observe.d.ts.map +1 -0
- package/dist/supervise/observe.js +172 -0
- package/dist/supervise/observe.js.map +1 -0
- package/dist/supervise/policy.d.ts +16 -0
- package/dist/supervise/policy.d.ts.map +1 -0
- package/dist/supervise/policy.js +147 -0
- package/dist/supervise/policy.js.map +1 -0
- package/dist/supervise/types.d.ts +109 -0
- package/dist/supervise/types.d.ts.map +1 -0
- package/dist/supervise/types.js +11 -0
- package/dist/supervise/types.js.map +1 -0
- package/docs/INDEX.md +9 -3
- package/docs/architecture/adr/0001-automode-risk-floor.md +75 -0
- package/docs/architecture/adr/0002-evidence-bound-ship.md +81 -0
- package/docs/guides/CAPACITY_POLICY.md +98 -0
- package/docs/performance/automode-risk-calibration.md +72 -0
- package/docs/performance/reliability-ladder.md +78 -0
- package/docs/plans/system1-uplift-2026-09-18.md +191 -0
- package/docs/reference/automode-risk.md +134 -0
- package/docs/reference/evidence-bound-ship.md +145 -0
- package/docs/reference/review-prepass.md +77 -0
- package/docs/reference/routing-evals.md +71 -0
- package/docs/reference/semantic-supervisor.md +140 -0
- package/docs/reference/system1-classifier.md +103 -0
- package/docs/reference/visual-captures.md +113 -0
- package/package.json +5 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +25 -2
- package/src/policies/enforcers/evidence_bound_ship.py +257 -0
- package/src/policies/enforcers/expert_review_required.py +120 -0
- package/src/policies/schemas/policies/evidence-bound-ship.md +106 -0
- package/src/policies/schemas/policies/visual-verification.md +8 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +782 -19
- package/tools/agents/scripts/project_telemetry.py +52 -0
- package/tools/agents/scripts/tool_risk.py +789 -0
- package/tools/agents/scripts/tool_risk_calibrate.py +255 -0
- package/tools/agents/tests/fixtures/tool_risk_cases.json +124 -0
- package/tools/agents/tests/test_enforcer_escape_hatches.py +13 -0
- package/tools/agents/tests/test_gate_evidence.py +56 -0
- package/tools/agents/tests/test_tool_risk.py +521 -0
- 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
|
-
|
|
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
|
-
#
|
|
3412
|
-
#
|
|
3413
|
-
overhead_tokens =
|
|
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 =
|
|
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 =
|
|
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
|