@miller-tech/uap 1.20.48 → 1.20.49
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/package.json
CHANGED
|
@@ -712,6 +712,7 @@ class SessionMonitor:
|
|
|
712
712
|
peak_input_tokens: int = 0 # High-water mark
|
|
713
713
|
prune_count: int = 0 # How many times pruning was triggered
|
|
714
714
|
overflow_count: int = 0 # How many context overflow errors caught
|
|
715
|
+
prune_drop_count: int = 0 # monotonic: # of oldest middle msgs pruned (B3)
|
|
715
716
|
context_history: list = field(default_factory=list) # Recent token counts
|
|
716
717
|
|
|
717
718
|
# --- Token Loop Protection ---
|
|
@@ -1318,24 +1319,83 @@ def estimate_total_tokens(anthropic_body: dict) -> int:
|
|
|
1318
1319
|
return tokens
|
|
1319
1320
|
|
|
1320
1321
|
|
|
1322
|
+
# Max tool-result breadcrumbs listed in a prune summary (B2). Bounds the
|
|
1323
|
+
# summary size — beyond this the oldest breadcrumbs are elided.
|
|
1324
|
+
_PRUNE_SUMMARY_MAX_ITEMS = int(os.environ.get("PROXY_PRUNE_SUMMARY_MAX_ITEMS", "30"))
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _summarize_pruned_block(dropped: list[dict]) -> str:
|
|
1328
|
+
"""Build a compact breadcrumb summary of pruned messages (B2).
|
|
1329
|
+
|
|
1330
|
+
Instead of discarding dropped tool-results outright, leave a one-line
|
|
1331
|
+
trace of each so the agent retains *what it already found*. A recon
|
|
1332
|
+
agent that can still see "I read auth_handler.cpp — JWT validation in
|
|
1333
|
+
validateToken()" is far likelier to converge to a synthesis than one
|
|
1334
|
+
whose findings vanished entirely and which therefore re-explores.
|
|
1335
|
+
|
|
1336
|
+
Heuristic only — no LLM call. Bounded to the most recent
|
|
1337
|
+
PROXY_PRUNE_SUMMARY_MAX_ITEMS tool-result breadcrumbs so the summary
|
|
1338
|
+
itself cannot grow unbounded.
|
|
1339
|
+
"""
|
|
1340
|
+
breadcrumbs: list[str] = []
|
|
1341
|
+
for msg in dropped:
|
|
1342
|
+
content = msg.get("content", [])
|
|
1343
|
+
if not isinstance(content, list):
|
|
1344
|
+
continue
|
|
1345
|
+
for block in content:
|
|
1346
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
1347
|
+
text = _extract_text(block.get("content", "")).strip()
|
|
1348
|
+
if not text:
|
|
1349
|
+
continue
|
|
1350
|
+
excerpt = " ".join(text.split())[:100]
|
|
1351
|
+
breadcrumbs.append(
|
|
1352
|
+
f"- tool result (~{estimate_tokens(text)} tok): {excerpt}"
|
|
1353
|
+
)
|
|
1354
|
+
if not breadcrumbs:
|
|
1355
|
+
return (
|
|
1356
|
+
"[CONTEXT PRUNED: older messages were removed to fit the context "
|
|
1357
|
+
"window. The conversation continues from recent context below.]"
|
|
1358
|
+
)
|
|
1359
|
+
total = len(breadcrumbs)
|
|
1360
|
+
if total > _PRUNE_SUMMARY_MAX_ITEMS:
|
|
1361
|
+
breadcrumbs = breadcrumbs[-_PRUNE_SUMMARY_MAX_ITEMS:]
|
|
1362
|
+
header = (
|
|
1363
|
+
f"[CONTEXT PRUNED — {len(dropped)} older messages removed to fit the "
|
|
1364
|
+
"context window. Breadcrumbs of earlier findings"
|
|
1365
|
+
)
|
|
1366
|
+
if total > len(breadcrumbs):
|
|
1367
|
+
header += f" (most recent {len(breadcrumbs)} of {total} tool results)"
|
|
1368
|
+
header += " — rely on these instead of re-reading those files:]"
|
|
1369
|
+
return header + "\n" + "\n".join(breadcrumbs)
|
|
1370
|
+
|
|
1371
|
+
|
|
1321
1372
|
def prune_conversation(
|
|
1322
1373
|
anthropic_body: dict,
|
|
1323
1374
|
context_window: int,
|
|
1375
|
+
monitor: "SessionMonitor | None" = None,
|
|
1324
1376
|
target_fraction: float = 0.65,
|
|
1325
1377
|
keep_last: int = 8,
|
|
1326
1378
|
) -> dict:
|
|
1327
1379
|
"""Prune the conversation to fit within the context window.
|
|
1328
1380
|
|
|
1329
|
-
Strategy:
|
|
1330
|
-
- Always keep: system prompt, first user message, last N messages
|
|
1331
|
-
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1381
|
+
Strategy (reworked — UAP PR #186):
|
|
1382
|
+
- Always keep: system prompt, first user message, last N messages.
|
|
1383
|
+
- Drop a CONTIGUOUS block of the oldest middle messages. The drop
|
|
1384
|
+
count is persisted per-session on the monitor (`prune_drop_count`)
|
|
1385
|
+
and is monotonic — it only ever grows. This keeps the retained
|
|
1386
|
+
region a stable recent *suffix*: on turns where the boundary does
|
|
1387
|
+
not advance, the upstream KV-cache prefix stays valid and the turn
|
|
1388
|
+
is not reprocessed. (The previous priority-greedy keep was
|
|
1389
|
+
non-contiguous and reshuffled the prompt mid-stream every turn,
|
|
1390
|
+
defeating the cache.)
|
|
1391
|
+
- Replace the dropped block with a breadcrumb summary (see
|
|
1392
|
+
_summarize_pruned_block) so the agent keeps its earlier findings.
|
|
1335
1393
|
|
|
1336
1394
|
Args:
|
|
1337
1395
|
anthropic_body: The full Anthropic request body
|
|
1338
1396
|
context_window: Maximum context window in tokens
|
|
1397
|
+
monitor: SessionMonitor — carries the monotonic prune boundary.
|
|
1398
|
+
When None, pruning still works but is non-monotonic per call.
|
|
1339
1399
|
target_fraction: Target utilization after pruning (0.0-1.0)
|
|
1340
1400
|
keep_last: Number of recent messages to always keep (default 8)
|
|
1341
1401
|
|
|
@@ -1411,70 +1471,39 @@ def prune_conversation(
|
|
|
1411
1471
|
|
|
1412
1472
|
remaining_budget = message_budget - protected_tokens
|
|
1413
1473
|
|
|
1414
|
-
#
|
|
1415
|
-
#
|
|
1416
|
-
#
|
|
1417
|
-
#
|
|
1418
|
-
#
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
)
|
|
1430
|
-
|
|
1431
|
-
# Lower priority = removed first
|
|
1432
|
-
if is_tool_result:
|
|
1433
|
-
priority = 0 # Remove first
|
|
1434
|
-
elif is_assistant:
|
|
1435
|
-
priority = 1 # Remove second
|
|
1436
|
-
else:
|
|
1437
|
-
priority = 2 # Remove last (user messages)
|
|
1438
|
-
|
|
1439
|
-
scored_middle.append((priority, i, tokens, msg))
|
|
1440
|
-
|
|
1441
|
-
# Sort by priority (ascending = remove first), then by index (oldest first)
|
|
1442
|
-
scored_middle.sort(key=lambda x: (x[0], x[1]))
|
|
1443
|
-
|
|
1444
|
-
# Greedily keep messages from highest priority (keep last) until budget fills
|
|
1445
|
-
kept_middle = []
|
|
1446
|
-
used_tokens = 0
|
|
1447
|
-
# Process in reverse priority order (keep high-priority messages first)
|
|
1448
|
-
for priority, idx, tokens, msg in reversed(scored_middle):
|
|
1449
|
-
if used_tokens + tokens <= remaining_budget:
|
|
1450
|
-
kept_middle.append((idx, msg))
|
|
1451
|
-
used_tokens += tokens
|
|
1452
|
-
|
|
1453
|
-
# Sort kept messages back into original order
|
|
1454
|
-
kept_middle.sort(key=lambda x: x[0])
|
|
1455
|
-
kept_msgs = [m for _, m in kept_middle]
|
|
1474
|
+
# --- Monotonic contiguous prune boundary (cache-stable, B3) ---
|
|
1475
|
+
# Drop the oldest `drop_count` middle messages as one contiguous block.
|
|
1476
|
+
# Seed from the monitor's persisted boundary; advance it only as far as
|
|
1477
|
+
# the budget forces. Persist back monotonically so a later/looser prune
|
|
1478
|
+
# in the same turn can't shrink it (which would reshuffle the prompt).
|
|
1479
|
+
drop_count = 0
|
|
1480
|
+
if monitor is not None:
|
|
1481
|
+
drop_count = min(max(0, monitor.prune_drop_count), len(middle))
|
|
1482
|
+
while drop_count < len(middle):
|
|
1483
|
+
kept_tokens = sum(estimate_message_tokens(m) for m in middle[drop_count:])
|
|
1484
|
+
if kept_tokens <= remaining_budget:
|
|
1485
|
+
break
|
|
1486
|
+
drop_count += 1
|
|
1487
|
+
if monitor is not None:
|
|
1488
|
+
monitor.prune_drop_count = max(monitor.prune_drop_count, drop_count)
|
|
1456
1489
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1490
|
+
dropped = middle[:drop_count]
|
|
1491
|
+
kept_msgs = middle[drop_count:]
|
|
1459
1492
|
|
|
1460
|
-
if
|
|
1461
|
-
#
|
|
1493
|
+
if dropped:
|
|
1494
|
+
# Replace the dropped block with a findings-breadcrumb summary (B2).
|
|
1462
1495
|
prune_marker = {
|
|
1463
1496
|
"role": "user",
|
|
1464
|
-
"content": (
|
|
1465
|
-
f"[CONTEXT PRUNED: {removed_count} older messages (~{removed_tokens} tokens) "
|
|
1466
|
-
f"were removed to fit within the context window. "
|
|
1467
|
-
f"The conversation continues from recent context below.]"
|
|
1468
|
-
),
|
|
1497
|
+
"content": _summarize_pruned_block(dropped),
|
|
1469
1498
|
}
|
|
1470
1499
|
anthropic_body["messages"] = (
|
|
1471
1500
|
protected_head + [prune_marker] + kept_msgs + protected_tail
|
|
1472
1501
|
)
|
|
1473
1502
|
logger.warning(
|
|
1474
|
-
"PRUNED:
|
|
1475
|
-
"target=%.0f%% of %d ctx",
|
|
1476
|
-
|
|
1477
|
-
|
|
1503
|
+
"PRUNED: dropped %d oldest middle messages (boundary=%d), "
|
|
1504
|
+
"kept %d total, target=%.0f%% of %d ctx",
|
|
1505
|
+
len(dropped),
|
|
1506
|
+
drop_count,
|
|
1478
1507
|
len(anthropic_body["messages"]),
|
|
1479
1508
|
target_fraction * 100,
|
|
1480
1509
|
context_window,
|
|
@@ -7560,7 +7589,8 @@ async def messages(request: Request):
|
|
|
7560
7589
|
target_frac * 100,
|
|
7561
7590
|
)
|
|
7562
7591
|
body = prune_conversation(
|
|
7563
|
-
body, ctx_window,
|
|
7592
|
+
body, ctx_window, monitor=monitor,
|
|
7593
|
+
target_fraction=target_frac, keep_last=keep_last,
|
|
7564
7594
|
)
|
|
7565
7595
|
monitor.prune_count += 1
|
|
7566
7596
|
# Option 4: Post-prune validation — verify actual reduction
|
|
@@ -7581,7 +7611,8 @@ async def messages(request: Request):
|
|
|
7581
7611
|
post_util * 100,
|
|
7582
7612
|
)
|
|
7583
7613
|
body = prune_conversation(
|
|
7584
|
-
body, ctx_window,
|
|
7614
|
+
body, ctx_window, monitor=monitor,
|
|
7615
|
+
target_fraction=0.35, keep_last=4,
|
|
7585
7616
|
)
|
|
7586
7617
|
monitor.prune_count += 1
|
|
7587
7618
|
estimated_tokens = estimate_total_tokens(body)
|
|
@@ -5452,3 +5452,108 @@ class TestReconConvergence(unittest.TestCase):
|
|
|
5452
5452
|
body = {"messages": [{"role": "user", "content": "go"}]}
|
|
5453
5453
|
proxy._maybe_inject_recon_convergence(body, m)
|
|
5454
5454
|
self.assertEqual(len(body["messages"]), 1)
|
|
5455
|
+
|
|
5456
|
+
|
|
5457
|
+
class TestPrunerRework(unittest.TestCase):
|
|
5458
|
+
"""Tests for the reworked context pruner (B2 + B3): contiguous
|
|
5459
|
+
monotonic prune boundary (cache-stable) + breadcrumb summary of the
|
|
5460
|
+
dropped block (findings retained)."""
|
|
5461
|
+
|
|
5462
|
+
@staticmethod
|
|
5463
|
+
def _tool_result_msg(idx: int, size: int = 4000) -> dict:
|
|
5464
|
+
return {
|
|
5465
|
+
"role": "user",
|
|
5466
|
+
"content": [
|
|
5467
|
+
{
|
|
5468
|
+
"type": "tool_result",
|
|
5469
|
+
"tool_use_id": f"toolu_{idx}",
|
|
5470
|
+
"content": f"FILE-{idx} " + ("x" * size),
|
|
5471
|
+
}
|
|
5472
|
+
],
|
|
5473
|
+
}
|
|
5474
|
+
|
|
5475
|
+
def _big_body(self, n_middle: int = 20) -> dict:
|
|
5476
|
+
msgs = [{"role": "user", "content": "recon task: analyze the repo"}]
|
|
5477
|
+
for i in range(n_middle):
|
|
5478
|
+
msgs.append({"role": "assistant", "content": f"reading file {i}"})
|
|
5479
|
+
msgs.append(self._tool_result_msg(i))
|
|
5480
|
+
msgs.append({"role": "user", "content": "continue"})
|
|
5481
|
+
return {"messages": msgs}
|
|
5482
|
+
|
|
5483
|
+
def test_prune_drop_count_is_monotonic(self):
|
|
5484
|
+
"""The per-session prune boundary only ever grows."""
|
|
5485
|
+
m = proxy.SessionMonitor(context_window=8192)
|
|
5486
|
+
proxy.prune_conversation(self._big_body(), 8192, monitor=m,
|
|
5487
|
+
target_fraction=0.5, keep_last=6)
|
|
5488
|
+
first = m.prune_drop_count
|
|
5489
|
+
self.assertGreater(first, 0)
|
|
5490
|
+
# A tighter target on the same body can only drop more, never fewer.
|
|
5491
|
+
proxy.prune_conversation(self._big_body(), 8192, monitor=m,
|
|
5492
|
+
target_fraction=0.25, keep_last=6)
|
|
5493
|
+
self.assertGreaterEqual(m.prune_drop_count, first)
|
|
5494
|
+
|
|
5495
|
+
def test_kept_middle_is_contiguous_suffix(self):
|
|
5496
|
+
"""The pruner drops a contiguous oldest block — the surviving
|
|
5497
|
+
middle messages are a contiguous suffix of the original middle,
|
|
5498
|
+
never a non-contiguous greedy pick."""
|
|
5499
|
+
m = proxy.SessionMonitor(context_window=8192)
|
|
5500
|
+
body = self._big_body()
|
|
5501
|
+
original = list(body["messages"])
|
|
5502
|
+
result = proxy.prune_conversation(body, 8192, monitor=m,
|
|
5503
|
+
target_fraction=0.5, keep_last=6)
|
|
5504
|
+
out = result["messages"]
|
|
5505
|
+
survivors = [msg for msg in out if msg in original]
|
|
5506
|
+
idxs = [original.index(msg) for msg in survivors]
|
|
5507
|
+
self.assertEqual(idxs, sorted(idxs))
|
|
5508
|
+
tail_idxs = [i for i in idxs if i > 0]
|
|
5509
|
+
if len(tail_idxs) > 1:
|
|
5510
|
+
self.assertEqual(
|
|
5511
|
+
tail_idxs, list(range(tail_idxs[0], tail_idxs[0] + len(tail_idxs)))
|
|
5512
|
+
)
|
|
5513
|
+
|
|
5514
|
+
def test_stable_output_when_boundary_does_not_advance(self):
|
|
5515
|
+
"""Cache-stability: pruning the same body twice with the same
|
|
5516
|
+
monitor yields byte-identical message lists — the second call
|
|
5517
|
+
seeds from the persisted boundary and does not advance it."""
|
|
5518
|
+
m = proxy.SessionMonitor(context_window=8192)
|
|
5519
|
+
first = proxy.prune_conversation(self._big_body(), 8192, monitor=m,
|
|
5520
|
+
target_fraction=0.5, keep_last=6)
|
|
5521
|
+
boundary_after_first = m.prune_drop_count
|
|
5522
|
+
second = proxy.prune_conversation(self._big_body(), 8192, monitor=m,
|
|
5523
|
+
target_fraction=0.5, keep_last=6)
|
|
5524
|
+
self.assertEqual(m.prune_drop_count, boundary_after_first)
|
|
5525
|
+
self.assertEqual(first["messages"], second["messages"])
|
|
5526
|
+
|
|
5527
|
+
def test_dropped_tool_results_become_breadcrumbs(self):
|
|
5528
|
+
"""Pruned tool-results survive as one-line breadcrumbs in the
|
|
5529
|
+
marker, not silently discarded."""
|
|
5530
|
+
dropped = [self._tool_result_msg(i) for i in range(3)]
|
|
5531
|
+
summary = proxy._summarize_pruned_block(dropped)
|
|
5532
|
+
self.assertIn("CONTEXT PRUNED", summary)
|
|
5533
|
+
self.assertIn("tool result", summary)
|
|
5534
|
+
self.assertIn("FILE-0", summary)
|
|
5535
|
+
self.assertIn("FILE-2", summary)
|
|
5536
|
+
|
|
5537
|
+
def test_summary_is_bounded_by_max_items(self):
|
|
5538
|
+
"""A huge dropped block does not produce an unbounded summary."""
|
|
5539
|
+
old = proxy._PRUNE_SUMMARY_MAX_ITEMS
|
|
5540
|
+
try:
|
|
5541
|
+
proxy._PRUNE_SUMMARY_MAX_ITEMS = 5
|
|
5542
|
+
dropped = [self._tool_result_msg(i) for i in range(40)]
|
|
5543
|
+
summary = proxy._summarize_pruned_block(dropped)
|
|
5544
|
+
self.assertEqual(summary.count("- tool result"), 5)
|
|
5545
|
+
self.assertIn("most recent 5 of 40", summary)
|
|
5546
|
+
finally:
|
|
5547
|
+
proxy._PRUNE_SUMMARY_MAX_ITEMS = old
|
|
5548
|
+
|
|
5549
|
+
def test_summarize_no_tool_results_falls_back_to_static_marker(self):
|
|
5550
|
+
"""A dropped block with no tool-results yields the plain static
|
|
5551
|
+
marker — no per-call varying text (cache-safe)."""
|
|
5552
|
+
dropped = [
|
|
5553
|
+
{"role": "assistant", "content": "thinking out loud"},
|
|
5554
|
+
{"role": "user", "content": "ok"},
|
|
5555
|
+
]
|
|
5556
|
+
summary = proxy._summarize_pruned_block(dropped)
|
|
5557
|
+
self.assertIn("CONTEXT PRUNED", summary)
|
|
5558
|
+
self.assertNotIn("tool result", summary)
|
|
5559
|
+
self.assertNotIn("most recent", summary)
|