@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.20.48",
3
+ "version": "1.20.49",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
- - Remove from the middle: oldest tool_result messages first (they're
1332
- the largest -- file contents, command output, etc.), then oldest
1333
- assistant messages, then oldest user messages.
1334
- - Inject a [CONTEXT PRUNED] marker so the model knows history was trimmed.
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
- # Score middle messages for removal priority:
1415
- # - tool_result messages: remove first (biggest, least important historically)
1416
- # - assistant text-only: remove second
1417
- # - user messages: remove last (provide context for the model's actions)
1418
- # Within each category, remove oldest first.
1419
- scored_middle = []
1420
- for i, msg in enumerate(middle):
1421
- content = msg.get("content", [])
1422
- tokens = estimate_message_tokens(msg)
1423
- is_tool_result = False
1424
- is_assistant = msg.get("role") == "assistant"
1425
-
1426
- if isinstance(content, list):
1427
- is_tool_result = any(
1428
- isinstance(b, dict) and b.get("type") == "tool_result" for b in content
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
- removed_count = len(middle) - len(kept_msgs)
1458
- removed_tokens = sum(t for _, _, t, _ in scored_middle) - used_tokens
1490
+ dropped = middle[:drop_count]
1491
+ kept_msgs = middle[drop_count:]
1459
1492
 
1460
- if removed_count > 0:
1461
- # Insert a context-pruned marker
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: removed %d messages (~%d tokens), kept %d messages, "
1475
- "target=%.0f%% of %d ctx",
1476
- removed_count,
1477
- removed_tokens,
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, target_fraction=target_frac, keep_last=keep_last
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, target_fraction=0.35, keep_last=4
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)