@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 +1 -1
- package/src/policies/enforcers/__pycache__/_common.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 +106 -17
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +8 -3
- package/tools/agents/tests/test_overflow_truncate_count_tokens.py +112 -0
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
1881
|
-
#
|
|
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
|
|
1953
|
+
"Protected messages (%d tokens) exceed budget (%d) -- truncating oversized content",
|
|
1884
1954
|
protected_tokens,
|
|
1885
1955
|
message_budget,
|
|
1886
1956
|
)
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
-
|
|
3377
|
-
|
|
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()
|