@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.179.8",
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",
@@ -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: 8192
49
+ Default: 32768
47
50
 
48
51
  PROXY_TOOL_TURN_MAX_TOKENS_GARBLED Max tokens after garbled/malformed output
49
- Default: 4096
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
- "max_tokens": int(openai_body.get("max_tokens", 4096) or 4096),
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()