@miller-tech/uap 1.93.1 → 1.94.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.93.1",
3
+ "version": "1.94.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1799,6 +1799,60 @@ def _summarize_pruned_block(dropped: list[dict]) -> str:
1799
1799
  return header + "\n" + "\n".join(breadcrumbs)
1800
1800
 
1801
1801
 
1802
+ def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> bool:
1803
+ """Truncate the largest message content in-place until the total fits
1804
+ budget_tokens. Used when message-DROPPING cannot reduce below the window —
1805
+ e.g. Claude Code's auto-compact sends a single `<transcript>` message LARGER
1806
+ than the whole context window, so keeping even the last 2 messages overflows
1807
+ and the pruner would otherwise thrash (prune -> still >100% -> retry).
1808
+
1809
+ Truncatable content = plain-string content, `text` blocks, and `tool_result`
1810
+ blocks. Each truncated block keeps a head+tail slice (70/30) around a marker
1811
+ so a summarization request still sees the start and end. Returns True if it
1812
+ got the total under budget, False if nothing left worth truncating.
1813
+ """
1814
+ MARKER = "\n...[TRUNCATED FOR CONTEXT WINDOW]...\n"
1815
+
1816
+ def _texts(msg):
1817
+ # yield (kind, block_or_None, text) for each truncatable content in msg
1818
+ content = msg.get("content", "")
1819
+ if isinstance(content, str):
1820
+ yield ("str", None, content)
1821
+ elif isinstance(content, list):
1822
+ for block in content:
1823
+ if isinstance(block, dict):
1824
+ if block.get("type") == "text":
1825
+ yield ("text", block, block.get("text", "") or "")
1826
+ elif block.get("type") == "tool_result":
1827
+ yield ("tool_result", block, _extract_text(block.get("content", "")))
1828
+
1829
+ for _ in range(40): # bounded: each pass truncates the single largest block
1830
+ total = sum(estimate_message_tokens(m) for m in messages)
1831
+ if total <= budget_tokens:
1832
+ return True
1833
+ biggest = None # (len, msg, kind, block, text)
1834
+ for msg in messages:
1835
+ for kind, block, text in _texts(msg):
1836
+ if biggest is None or len(text) > biggest[0]:
1837
+ biggest = (len(text), msg, kind, block, text)
1838
+ if biggest is None or biggest[0] < 400:
1839
+ return False # nothing left worth truncating
1840
+ _, msg, kind, block, text = biggest
1841
+ excess_tokens = total - budget_tokens
1842
+ cut_chars = int(excess_tokens * CHARS_PER_TOKEN) + len(MARKER) + 512
1843
+ keep = max(400, len(text) - cut_chars)
1844
+ head = int(keep * 0.7)
1845
+ tail = keep - head
1846
+ new_text = text[:head] + MARKER + (text[-tail:] if tail > 0 else "")
1847
+ if kind == "str":
1848
+ msg["content"] = new_text
1849
+ elif kind == "text":
1850
+ block["text"] = new_text
1851
+ elif kind == "tool_result":
1852
+ block["content"] = new_text
1853
+ return sum(estimate_message_tokens(m) for m in messages) <= budget_tokens
1854
+
1855
+
1802
1856
  def prune_conversation(
1803
1857
  anthropic_body: dict,
1804
1858
  context_window: int,
@@ -1834,7 +1888,19 @@ def prune_conversation(
1834
1888
  """
1835
1889
  messages = anthropic_body.get("messages", [])
1836
1890
  if len(messages) <= 4:
1837
- # Too few messages to prune meaningfully
1891
+ # Too few messages to prune by DROPPING — but a single message can still
1892
+ # exceed the whole window (Claude Code's auto-compact sends a
1893
+ # `<transcript>` larger than the context window). Message-dropping can't
1894
+ # help and returning as-is wedges the request in a prune->still-over->
1895
+ # retry loop, so truncate the oversized content in-place to fit.
1896
+ if messages and estimate_total_tokens(anthropic_body) > context_window:
1897
+ budget = max(1, int(context_window * target_fraction))
1898
+ logger.warning(
1899
+ "Few-message request (%d msgs) exceeds window %d -- truncating oversized content to fit",
1900
+ len(messages),
1901
+ context_window,
1902
+ )
1903
+ _truncate_oversized_message_content(messages, budget)
1838
1904
  return anthropic_body
1839
1905
 
1840
1906
  target_tokens = int(context_window * target_fraction)
@@ -1877,26 +1943,25 @@ def prune_conversation(
1877
1943
  )
1878
1944
 
1879
1945
  if protected_tokens >= message_budget:
1880
- # Even protected messages exceed budget -- truncate tool_result content
1881
- # in the tail to fit
1946
+ # Even the protected (undroppable) messages exceed budget. Message-
1947
+ # dropping can't help, so truncate the largest content in-place to fit.
1948
+ # Covers tool_result AND oversized user/text messages (the auto-compact
1949
+ # <transcript> larger than the whole window) — the tool_result-only
1950
+ # version left such a message intact, so pruning never converged and the
1951
+ # request wedged in a prune->still-over->retry loop.
1882
1952
  logger.warning(
1883
- "Protected messages (%d tokens) exceed budget (%d) -- truncating tool results",
1953
+ "Protected messages (%d tokens) exceed budget (%d) -- truncating oversized content",
1884
1954
  protected_tokens,
1885
1955
  message_budget,
1886
1956
  )
1887
- for msg in protected_tail:
1888
- content = msg.get("content", [])
1889
- if isinstance(content, list):
1890
- for block in content:
1891
- if isinstance(block, dict) and block.get("type") == "tool_result":
1892
- result_text = _extract_text(block.get("content", ""))
1893
- if len(result_text) > 2000:
1894
- block["content"] = (
1895
- result_text[:1000]
1896
- + "\n...[TRUNCATED]...\n"
1897
- + result_text[-500:]
1898
- )
1899
- anthropic_body["messages"] = protected_head + protected_tail
1957
+ protected = protected_head + protected_tail
1958
+ fit = _truncate_oversized_message_content(protected, message_budget)
1959
+ if not fit:
1960
+ logger.warning(
1961
+ "Post-truncation still over budget (%d msgs) -- forwarding truncated best-effort",
1962
+ len(protected),
1963
+ )
1964
+ anthropic_body["messages"] = protected
1900
1965
  return anthropic_body
1901
1966
 
1902
1967
  remaining_budget = message_budget - protected_tokens
@@ -8671,6 +8736,30 @@ async def _passthrough_anthropic_request(
8671
8736
  )
8672
8737
 
8673
8738
 
8739
+ @app.post("/v1/messages/count_tokens")
8740
+ async def count_tokens(request: Request):
8741
+ """Anthropic-compatible token counter.
8742
+
8743
+ Claude Code calls POST /v1/messages/count_tokens to measure a request
8744
+ against the context window and decide when to auto-compact. Returning 404
8745
+ (unimplemented) blinds the client to the real window, so it sent auto-compact
8746
+ `<transcript>` requests LARGER than the window -> single-oversized-message
8747
+ overflow wedge. We estimate with the SAME accounting the pruner uses
8748
+ (estimate_total_tokens) so the client's view matches the proxy's window math.
8749
+ """
8750
+ try:
8751
+ body = await request.json()
8752
+ except Exception:
8753
+ return Response(
8754
+ content=json.dumps(
8755
+ {"type": "error", "error": {"type": "invalid_request_error", "message": "invalid JSON body"}}
8756
+ ),
8757
+ status_code=400,
8758
+ media_type="application/json",
8759
+ )
8760
+ return {"input_tokens": estimate_total_tokens(body)}
8761
+
8762
+
8674
8763
  @app.post("/v1/messages")
8675
8764
  async def messages(request: Request):
8676
8765
  """Handle Anthropic Messages API requests (streaming and non-streaming).
@@ -3372,9 +3372,14 @@ class TestPruningImprovements(unittest.TestCase):
3372
3372
  {"role": "user", "content": "last"},
3373
3373
  ],
3374
3374
  }
3375
- # With keep_last=4, more middle messages should be prunable
3376
- result_8 = proxy.prune_conversation(dict(body), 2000, target_fraction=0.50, keep_last=8)
3377
- result_4 = proxy.prune_conversation(dict(body), 2000, target_fraction=0.50, keep_last=4)
3375
+ # With keep_last=4, more middle messages should be prunable.
3376
+ # deepcopy so the two calls are independent: prune_conversation truncates
3377
+ # oversized message content IN PLACE (always has for tool_result; now
3378
+ # also for text/string), so a shared shallow copy would let the first
3379
+ # call's truncation leak into the second. Real requests never share objects.
3380
+ import copy as _copy
3381
+ result_8 = proxy.prune_conversation(_copy.deepcopy(body), 2000, target_fraction=0.50, keep_last=8)
3382
+ result_4 = proxy.prune_conversation(_copy.deepcopy(body), 2000, target_fraction=0.50, keep_last=4)
3378
3383
  # keep_last=4 should result in fewer or equal messages
3379
3384
  self.assertLessEqual(
3380
3385
  len(result_4.get("messages", [])),
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Single-oversized-message overflow wedge + count_tokens.
3
+
4
+ Claude Code's auto-compact sends a single `<transcript>` message that can be
5
+ LARGER than the whole context window. The pruner reduces context by DROPPING
6
+ messages, so with one giant undroppable message it can't get under the window
7
+ and thrashes (prune -> still >100% -> retry). Two fixes:
8
+
9
+ 1. `_truncate_oversized_message_content` truncates the largest content in-place
10
+ (head+tail keep) so pruning always converges — covers plain-string / `text`
11
+ / `tool_result` content, not just tool_result (the old gap).
12
+ 2. `POST /v1/messages/count_tokens` returns `{"input_tokens": N}` (was 404) so
13
+ the client can size its auto-compact to the real window in the first place.
14
+ """
15
+
16
+ import asyncio
17
+ import importlib.util
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+
22
+ def _load():
23
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
24
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
25
+ m = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(m)
27
+ return m
28
+
29
+
30
+ proxy = _load()
31
+
32
+
33
+ class _FakeReq:
34
+ def __init__(self, payload):
35
+ self._p = payload
36
+
37
+ async def json(self):
38
+ return self._p
39
+
40
+
41
+ class _BadReq:
42
+ async def json(self):
43
+ raise ValueError("bad json")
44
+
45
+
46
+ class TestTruncateOversized(unittest.TestCase):
47
+ def test_giant_string_message_truncated_to_budget(self):
48
+ msgs = [{"role": "user", "content": "HEAD" + ("x" * 700_000) + "TAIL"}]
49
+ budget = 50_000 # tokens
50
+ fit = proxy._truncate_oversized_message_content(msgs, budget)
51
+ total = sum(proxy.estimate_message_tokens(m) for m in msgs)
52
+ self.assertTrue(fit)
53
+ self.assertLessEqual(total, budget)
54
+ content = msgs[0]["content"]
55
+ self.assertIn("[TRUNCATED FOR CONTEXT WINDOW]", content)
56
+ self.assertTrue(content.startswith("HEAD")) # head preserved
57
+ self.assertTrue(content.rstrip().endswith("TAIL")) # tail preserved
58
+
59
+ def test_text_block_and_tool_result_truncated(self):
60
+ msgs = [
61
+ {"role": "user", "content": [{"type": "text", "text": "A" * 400_000}]},
62
+ {"role": "user", "content": [{"type": "tool_result", "content": "B" * 400_000}]},
63
+ ]
64
+ budget = 30_000
65
+ fit = proxy._truncate_oversized_message_content(msgs, budget)
66
+ total = sum(proxy.estimate_message_tokens(m) for m in msgs)
67
+ self.assertTrue(fit)
68
+ self.assertLessEqual(total, budget)
69
+
70
+ def test_small_messages_untouched(self):
71
+ msgs = [{"role": "user", "content": "hello"}]
72
+ before = msgs[0]["content"]
73
+ fit = proxy._truncate_oversized_message_content(msgs, 50_000)
74
+ self.assertTrue(fit)
75
+ self.assertEqual(msgs[0]["content"], before) # nothing truncated
76
+
77
+ def test_prune_conversation_converges_on_transcript_over_window(self):
78
+ # 2-message request, one is a transcript LARGER than the whole window —
79
+ # message-dropping can't help; must converge via content truncation.
80
+ window = 100_000
81
+ body = {
82
+ "system": "You are a summarizer.",
83
+ "messages": [
84
+ {"role": "user", "content": "Summarize this transcript:"},
85
+ {"role": "user", "content": "<transcript>" + ("t" * 500_000) + "</transcript>"},
86
+ ],
87
+ "max_tokens": 2048,
88
+ }
89
+ out = proxy.prune_conversation(body, window, target_fraction=0.5, keep_last=8)
90
+ total = proxy.estimate_total_tokens(out)
91
+ self.assertLessEqual(total, window) # fits — no wedge
92
+
93
+
94
+ class TestCountTokens(unittest.TestCase):
95
+ def test_returns_input_tokens_matching_estimator(self):
96
+ payload = {
97
+ "model": "m",
98
+ "system": "sys prompt here",
99
+ "messages": [{"role": "user", "content": "hello world, count me"}],
100
+ "max_tokens": 10,
101
+ }
102
+ res = asyncio.run(proxy.count_tokens(_FakeReq(payload)))
103
+ self.assertEqual(res, {"input_tokens": proxy.estimate_total_tokens(payload)})
104
+ self.assertGreater(res["input_tokens"], 0)
105
+
106
+ def test_invalid_json_returns_400(self):
107
+ res = asyncio.run(proxy.count_tokens(_BadReq()))
108
+ self.assertEqual(res.status_code, 400)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ unittest.main()