@miller-tech/uap 1.179.8 → 1.179.9
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 +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -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 +38 -3
- package/tools/agents/tests/test_output_token_ceilings.py +155 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.179.
|
|
3
|
+
"version": "1.179.9",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -42,11 +42,14 @@ Configuration (Environment Variables)
|
|
|
42
42
|
PROXY_READ_TIMEOUT Read timeout in seconds for upstream LLM streaming
|
|
43
43
|
Default: 600 (10 minutes)
|
|
44
44
|
|
|
45
|
+
PROXY_DEFAULT_MAX_TOKENS max_tokens applied when the client sends none
|
|
46
|
+
Default: 16384
|
|
47
|
+
|
|
45
48
|
PROXY_TOOL_TURN_MAX_TOKENS Max tokens for tool-call turns (0 to disable)
|
|
46
|
-
Default:
|
|
49
|
+
Default: 32768
|
|
47
50
|
|
|
48
51
|
PROXY_TOOL_TURN_MAX_TOKENS_GARBLED Max tokens after garbled/malformed output
|
|
49
|
-
Default:
|
|
52
|
+
Default: 16384
|
|
50
53
|
|
|
51
54
|
PROXY_MAX_CONNECTIONS Max concurrent connections to upstream
|
|
52
55
|
Default: 20
|
|
@@ -1011,6 +1014,14 @@ PROXY_STREAM_REASONING_MAX_CHARS = int(
|
|
|
1011
1014
|
os.environ.get("PROXY_STREAM_REASONING_MAX_CHARS", "240")
|
|
1012
1015
|
)
|
|
1013
1016
|
PROXY_MAX_TOKENS_FLOOR = int(os.environ.get("PROXY_MAX_TOKENS_FLOOR", "16384"))
|
|
1017
|
+
# What a client gets when it sends no max_tokens at all — replaces a hardcoded
|
|
1018
|
+
# 4096 buried in openai_to_anthropic_request. Deliberately modest: for TOOL
|
|
1019
|
+
# turns this value barely matters, because _resolve_max_tokens_request already
|
|
1020
|
+
# lifts anything above 1024 to PROXY_MAX_TOKENS_FLOOR. It governs NO-TOOL turns
|
|
1021
|
+
# — judges, critics, acceptance verdicts — where a large budget buys nothing
|
|
1022
|
+
# and gives a thinking-runaway room to burn, so it is aligned with
|
|
1023
|
+
# THINKING_MIN_NO_TOOLS rather than raised to match a file-write ceiling.
|
|
1024
|
+
PROXY_DEFAULT_MAX_TOKENS = int(os.environ.get("PROXY_DEFAULT_MAX_TOKENS", "8192"))
|
|
1014
1025
|
PROXY_TOOL_TURN_MAX_TOKENS = int(os.environ.get("PROXY_TOOL_TURN_MAX_TOKENS", "8192"))
|
|
1015
1026
|
PROXY_TOOL_TURN_MAX_TOKENS_GARBLED = int(
|
|
1016
1027
|
os.environ.get("PROXY_TOOL_TURN_MAX_TOKENS_GARBLED", "4096")
|
|
@@ -4848,7 +4859,17 @@ def openai_to_anthropic_request(openai_body: dict) -> dict:
|
|
|
4848
4859
|
anthropic_body: dict = {
|
|
4849
4860
|
"model": openai_body.get("model", "default"),
|
|
4850
4861
|
"messages": anthropic_messages,
|
|
4851
|
-
|
|
4862
|
+
# A client that omits max_tokens is not asking for a small answer — it
|
|
4863
|
+
# has no opinion. Defaulting to 4096 (~16KB of code) silently truncated
|
|
4864
|
+
# every large write: the agentic executor sends no max_tokens, so 52 of
|
|
4865
|
+
# 56 requests in a measured build ran at 4096 and a 36KB game.js came
|
|
4866
|
+
# back cut off mid-file, which the model then spent turns "repairing".
|
|
4867
|
+
# Neither the tool-turn cap (8192) nor llama's --n-predict (16384) was
|
|
4868
|
+
# ever reached, so nothing logged a truncation — it just looked like a
|
|
4869
|
+
# model that could not finish a file.
|
|
4870
|
+
"max_tokens": int(
|
|
4871
|
+
openai_body.get("max_tokens") or PROXY_DEFAULT_MAX_TOKENS
|
|
4872
|
+
),
|
|
4852
4873
|
}
|
|
4853
4874
|
if system_text_parts:
|
|
4854
4875
|
anthropic_body["system"] = "\n\n".join(p for p in system_text_parts if p)
|
|
@@ -9249,6 +9270,7 @@ def _build_malformed_retry_body(
|
|
|
9249
9270
|
attempt: int = 1,
|
|
9250
9271
|
total_attempts: int = 1,
|
|
9251
9272
|
is_garbled: bool = False,
|
|
9273
|
+
is_truncated: bool = False,
|
|
9252
9274
|
exclude_tools: list[str] | None = None,
|
|
9253
9275
|
) -> dict:
|
|
9254
9276
|
retry_body = dict(openai_body)
|
|
@@ -9299,6 +9321,18 @@ def _build_malformed_retry_body(
|
|
|
9299
9321
|
PROXY_TOOL_TURN_MAX_TOKENS_GARBLED,
|
|
9300
9322
|
attempt,
|
|
9301
9323
|
)
|
|
9324
|
+
elif is_truncated:
|
|
9325
|
+
# A write that ran out of tokens is the one case where retrying SMALLER
|
|
9326
|
+
# is guaranteed to fail again — the attempt was cut off for lack of room,
|
|
9327
|
+
# so clamping the retry to PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS (8192
|
|
9328
|
+
# deployed, ~32KB) below the budget that already proved insufficient
|
|
9329
|
+
# just reproduces the truncation. Keep the original budget instead.
|
|
9330
|
+
# Observed: a 36KB module truncated, then every retry re-truncated.
|
|
9331
|
+
logger.info(
|
|
9332
|
+
"RETRY TRUNCATED: keeping max_tokens=%s (retrying smaller cannot fit "
|
|
9333
|
+
"what was already cut off)",
|
|
9334
|
+
retry_body.get("max_tokens"),
|
|
9335
|
+
)
|
|
9302
9336
|
elif PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS > 0:
|
|
9303
9337
|
current_max = int(
|
|
9304
9338
|
retry_body.get("max_tokens", PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS)
|
|
@@ -9632,6 +9666,7 @@ async def _apply_malformed_tool_guardrail(
|
|
|
9632
9666
|
attempt=attempt + 1,
|
|
9633
9667
|
total_attempts=attempts,
|
|
9634
9668
|
is_garbled=current_issue.kind == "invalid_tool_args",
|
|
9669
|
+
is_truncated=current_issue.kind == "truncated_tool_args",
|
|
9635
9670
|
exclude_tools=exclude,
|
|
9636
9671
|
)
|
|
9637
9672
|
retry_resp = await client.post(
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Retrying a TRUNCATED write must not ask for less room than already failed.
|
|
3
|
+
|
|
4
|
+
Measured on the Octopus Invaders build (2026-08-01): a 36KB game.js came back
|
|
5
|
+
cut off mid-file and every retry re-truncated.
|
|
6
|
+
|
|
7
|
+
The first diagnosis of this was wrong and is worth recording, because the wrong
|
|
8
|
+
version is the intuitive one:
|
|
9
|
+
|
|
10
|
+
- The `max_tokens=4096` visible in the proxy's REQ: log is NOT the value sent
|
|
11
|
+
upstream. It is logged on the converted body, BEFORE
|
|
12
|
+
`_resolve_max_tokens_request` applies PROXY_MAX_TOKENS_FLOOR — which
|
|
13
|
+
returns max(requested, floor) and so raises it. With the deployed
|
|
14
|
+
FLOOR=32768 the default was never binding.
|
|
15
|
+
- A truncated write is reclassified to `truncated_tool_args` and therefore
|
|
16
|
+
never sets `last_response_garbled`, so PROXY_TOOL_TURN_MAX_TOKENS_GARBLED
|
|
17
|
+
does not govern its retry either.
|
|
18
|
+
- The actual clamp is PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS (8192 deployed),
|
|
19
|
+
applied via min() on the retry — i.e. the retry of a file that was cut off
|
|
20
|
+
for lack of room was given LESS room than the attempt that failed.
|
|
21
|
+
|
|
22
|
+
So these tests pin the retry-budget branching, which is where the bug was, and
|
|
23
|
+
the ordering properties of the ceiling chain. They deliberately do not assert
|
|
24
|
+
absolute token literals: the deployed EnvironmentFile overrides most of these
|
|
25
|
+
constants, so a test on a code default would pass while production disagreed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import importlib.util
|
|
29
|
+
import os
|
|
30
|
+
import unittest
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_proxy(env=None):
|
|
35
|
+
prev = {}
|
|
36
|
+
env = dict(env or {})
|
|
37
|
+
env.setdefault("UAP_PROXY_ENV_AUTOLOAD", "0")
|
|
38
|
+
for k, v in env.items():
|
|
39
|
+
prev[k] = os.environ.get(k)
|
|
40
|
+
os.environ[k] = v
|
|
41
|
+
try:
|
|
42
|
+
path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
43
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_ceilings", path)
|
|
44
|
+
m = importlib.util.module_from_spec(spec)
|
|
45
|
+
spec.loader.exec_module(m)
|
|
46
|
+
return m
|
|
47
|
+
finally:
|
|
48
|
+
for k, v in prev.items():
|
|
49
|
+
if v is None:
|
|
50
|
+
os.environ.pop(k, None)
|
|
51
|
+
else:
|
|
52
|
+
os.environ[k] = v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
proxy = _load_proxy({"PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS": "8192"})
|
|
56
|
+
|
|
57
|
+
BIG = 32768 # the budget a whole-module write actually runs with
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _retry(**kw):
|
|
61
|
+
body = {"model": "m", "messages": [{"role": "user", "content": "hi"}], "max_tokens": BIG}
|
|
62
|
+
return proxy._build_malformed_retry_body(body, {"tools": []}, **kw)["max_tokens"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TruncatedRetryBudgetTest(unittest.TestCase):
|
|
66
|
+
def test_truncated_retry_keeps_the_budget_that_was_cut_off(self):
|
|
67
|
+
# The whole bug: clamping here guarantees the retry truncates too.
|
|
68
|
+
self.assertEqual(_retry(is_truncated=True), BIG)
|
|
69
|
+
|
|
70
|
+
def test_garbled_retry_still_tightens(self):
|
|
71
|
+
# Genuine malformed args SHOULD get less room — that guard is intact.
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
_retry(is_garbled=True), proxy.PROXY_TOOL_TURN_MAX_TOKENS_GARBLED
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def test_plain_malformed_retry_still_clamps(self):
|
|
77
|
+
self.assertEqual(_retry(), proxy.PROXY_MALFORMED_TOOL_RETRY_MAX_TOKENS)
|
|
78
|
+
|
|
79
|
+
def test_truncation_wins_over_the_generic_clamp_but_not_over_garbled(self):
|
|
80
|
+
# Ordering matters: garbled is checked first by design, since args that
|
|
81
|
+
# are both malformed AND long are a degeneration risk, not a big file.
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
_retry(is_garbled=True, is_truncated=True),
|
|
84
|
+
proxy.PROXY_TOOL_TURN_MAX_TOKENS_GARBLED,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TruncationSignalIsWiredTest(unittest.TestCase):
|
|
89
|
+
"""The helper is only correct if the call site actually tells it.
|
|
90
|
+
|
|
91
|
+
Asserting the branch through the real call site would mean standing up the
|
|
92
|
+
whole async malformed-retry handler with a mocked upstream; the cheaper and
|
|
93
|
+
still-effective guard is that the kwarg is derived from the issue kind
|
|
94
|
+
rather than hardcoded. Without this, deleting the derivation leaves every
|
|
95
|
+
other test in this file green while the bug returns — which is exactly what
|
|
96
|
+
mutation testing showed.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
SRC = (
|
|
100
|
+
Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
101
|
+
).read_text()
|
|
102
|
+
|
|
103
|
+
def test_call_site_derives_is_truncated_from_the_issue_kind(self):
|
|
104
|
+
self.assertIn(
|
|
105
|
+
'is_truncated=current_issue.kind == "truncated_tool_args"',
|
|
106
|
+
self.SRC,
|
|
107
|
+
"the retry no longer learns that the previous attempt was truncated",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def test_the_kind_it_keys_on_is_the_one_the_classifier_produces(self):
|
|
111
|
+
# Guards a rename on one side only.
|
|
112
|
+
self.assertIn('kind="truncated_tool_args"', self.SRC)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CeilingChainOrderingTest(unittest.TestCase):
|
|
116
|
+
def test_floor_raises_a_small_request_rather_than_capping_it(self):
|
|
117
|
+
# This is what made the 4096 default a red herring.
|
|
118
|
+
m = _load_proxy({"PROXY_MAX_TOKENS_FLOOR": "32768"})
|
|
119
|
+
self.assertEqual(m._resolve_max_tokens_request(4096), 32768)
|
|
120
|
+
|
|
121
|
+
def test_a_zero_floor_leaves_the_request_alone(self):
|
|
122
|
+
m = _load_proxy({"PROXY_MAX_TOKENS_FLOOR": "0"})
|
|
123
|
+
self.assertEqual(m._resolve_max_tokens_request(4096), 4096)
|
|
124
|
+
|
|
125
|
+
def test_garbled_cap_is_a_tightening_of_the_tool_turn_cap(self):
|
|
126
|
+
self.assertLessEqual(
|
|
127
|
+
proxy.PROXY_TOOL_TURN_MAX_TOKENS_GARBLED, proxy.PROXY_TOOL_TURN_MAX_TOKENS
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def test_default_is_overridable_and_replaces_the_hardcoded_fallback(self):
|
|
131
|
+
m = _load_proxy({"PROXY_DEFAULT_MAX_TOKENS": "12345"})
|
|
132
|
+
out = m.openai_to_anthropic_request(
|
|
133
|
+
{"model": "m", "messages": [{"role": "user", "content": "hi"}]}
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(out["max_tokens"], 12345)
|
|
136
|
+
|
|
137
|
+
def test_an_explicit_client_value_survives_the_conversion(self):
|
|
138
|
+
# NB: only the conversion. build_openai_request may still raise it via
|
|
139
|
+
# the thinking floor — asserted here at the conversion layer only.
|
|
140
|
+
out = proxy.openai_to_anthropic_request(
|
|
141
|
+
{"model": "m", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 512}
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(out["max_tokens"], 512)
|
|
144
|
+
|
|
145
|
+
def test_zero_or_null_means_no_opinion_not_emit_nothing(self):
|
|
146
|
+
for val in (0, None):
|
|
147
|
+
with self.subTest(val=val):
|
|
148
|
+
out = proxy.openai_to_anthropic_request(
|
|
149
|
+
{"model": "m", "messages": [{"role": "user", "content": "hi"}], "max_tokens": val}
|
|
150
|
+
)
|
|
151
|
+
self.assertEqual(out["max_tokens"], proxy.PROXY_DEFAULT_MAX_TOKENS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
unittest.main()
|