@miller-tech/uap 1.64.2 → 1.64.4

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.64.2",
3
+ "version": "1.64.4",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1005,6 +1005,14 @@ class SessionMonitor:
1005
1005
  # back into a structured tool_use, which would continue the very loop the
1006
1006
  # breaker is ending. Reset to False at the start of every request.
1007
1007
  suppress_text_tool_extraction: bool = False
1008
+ # Tool-turn count at which the TURN-COUNT FINALIZE BREAKER last fired. The
1009
+ # count is derived from the (only-growing) conversation, so without this the
1010
+ # breaker would re-fire on EVERY turn once the ceiling is first crossed —
1011
+ # permanently stripping tools and stalling a legitimately long agentic task.
1012
+ # Gating on (last + ceiling) makes it a PERIODIC nudge (80, 160, 240, ...)
1013
+ # with tools restored in between, so long tasks complete while a true runaway
1014
+ # still gets bounded (and the contamination/prune/cycle breakers catch faster).
1015
+ last_hard_finalize_turn_count: int = 0
1008
1016
  finalize_continuation_count: int = 0
1009
1017
  finalize_hard_stop_count: int = 0 # monotonic, not reset by fresh user text
1010
1018
  finalize_synthetic_tool_id: str = ""
@@ -4095,7 +4103,14 @@ def build_openai_request(
4095
4103
  # genuine runaway trips it; see PROXY_HARD_FINALIZE_TURNS.
4096
4104
  if PROXY_HARD_FINALIZE_TURNS > 0:
4097
4105
  _agent_tool_turns = _count_agent_tool_turns(anthropic_body)
4098
- if _agent_tool_turns >= PROXY_HARD_FINALIZE_TURNS:
4106
+ # PERIODIC, not permanent: fire once each time the count crosses
4107
+ # another `ceiling` worth of tool turns past the last firing. Without
4108
+ # the `last + ceiling` gate this fires on EVERY turn past the first
4109
+ # crossing (the count only grows), permanently denying tools and
4110
+ # stalling a long-but-legitimate task. Between fires tools are restored
4111
+ # so the agent keeps making progress.
4112
+ if _agent_tool_turns >= monitor.last_hard_finalize_turn_count + PROXY_HARD_FINALIZE_TURNS:
4113
+ monitor.last_hard_finalize_turn_count = _agent_tool_turns
4099
4114
  openai_body.pop("tool_choice", None)
4100
4115
  openai_body.pop("tools", None)
4101
4116
  openai_body.pop("grammar", None)
@@ -4103,11 +4118,12 @@ def build_openai_request(
4103
4118
  msgs.append({
4104
4119
  "role": "user",
4105
4120
  "content": (
4106
- f"You have made {_agent_tool_turns} tool calls without "
4107
- "converging on a final answer. STOP now. No tools are "
4108
- "available this turn. Reply with a brief plain-text summary "
4109
- "of what you accomplished and what remains. Do NOT emit any "
4110
- "tool call or tool-call-like syntax."
4121
+ f"You have made {_agent_tool_turns} tool calls. Pause for a "
4122
+ "progress checkpoint: in a brief plain-text summary, state "
4123
+ "what is done and the single most important next step. No "
4124
+ "tools are available this turn do NOT emit any tool call "
4125
+ "or tool-call-like syntax. If the task is complete, say so; "
4126
+ "otherwise you will continue on the next turn."
4111
4127
  ),
4112
4128
  })
4113
4129
  openai_body["messages"] = msgs
@@ -4330,6 +4346,19 @@ def build_openai_request(
4330
4346
 
4331
4347
  _apply_thinking_grammar(openai_body)
4332
4348
 
4349
+ # qwen3.5-enhanced.jinja (the MTP/130 config template) rejects an assistant
4350
+ # PREFILL (trailing assistant message) unless thinking is disabled VIA
4351
+ # chat_template_kwargs — the top-level `enable_thinking` flag is not read by
4352
+ # this template, so a prefill otherwise 400s ("Assistant response prefill is
4353
+ # incompatible with enable_thinking"). There is nothing to think about on a
4354
+ # continuation, so disable thinking the way the template actually reads.
4355
+ _final_msgs = openai_body.get("messages") or []
4356
+ if _final_msgs and isinstance(_final_msgs[-1], dict) and _final_msgs[-1].get("role") == "assistant":
4357
+ ctk = openai_body.setdefault("chat_template_kwargs", {})
4358
+ if isinstance(ctk, dict):
4359
+ ctk["enable_thinking"] = False
4360
+ openai_body.pop("enable_thinking", None)
4361
+
4333
4362
  return openai_body
4334
4363
 
4335
4364
 
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """The TURN-COUNT FINALIZE BREAKER must fire PERIODICALLY, not on every turn once
3
+ the ceiling is first crossed.
4
+
5
+ `_count_agent_tool_turns` is derived from the (only-growing) conversation, so the
6
+ naive `count >= ceiling` check re-fires on every request past the first crossing —
7
+ permanently stripping tools and stalling a legitimately long agentic task (observed
8
+ live: msgs 206→208→…→214, breaker every turn, then the client gives up). Gating on
9
+ `count >= last_hard_finalize_turn_count + ceiling` makes it fire at ceiling, 2x,
10
+ 3x… with tools restored in between.
11
+ """
12
+
13
+ import importlib.util
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+
18
+ def _load():
19
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
20
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
21
+ m = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(m)
23
+ return m
24
+
25
+
26
+ proxy = _load()
27
+ CEILING = 80
28
+
29
+
30
+ def _fire_turns(counts):
31
+ """Replay the breaker's gating decision (as in build_openai_request) over a
32
+ sequence of cumulative tool-turn counts; return the counts at which it fired."""
33
+ m = proxy.SessionMonitor(context_window=100000)
34
+ fired = []
35
+ for count in counts:
36
+ if count >= m.last_hard_finalize_turn_count + CEILING:
37
+ m.last_hard_finalize_turn_count = count
38
+ fired.append(count)
39
+ return fired
40
+
41
+
42
+ class TestTurnCountBreakerPeriodic(unittest.TestCase):
43
+ def test_monitor_has_the_field_defaulting_zero(self):
44
+ m = proxy.SessionMonitor(context_window=100000)
45
+ self.assertEqual(m.last_hard_finalize_turn_count, 0)
46
+
47
+ def test_fires_periodically_not_every_turn(self):
48
+ # cumulative tool turns 1..244 (grows by 1 each request)
49
+ fired = _fire_turns(range(1, 245))
50
+ # periodic at the ceiling multiples — NOT every turn past 80
51
+ self.assertEqual(fired, [80, 160, 240])
52
+
53
+ def test_does_not_refire_between_crossings(self):
54
+ # turns 80..159 -> fires once at 80, then silent until 160
55
+ fired = _fire_turns(range(80, 160))
56
+ self.assertEqual(fired, [80])
57
+
58
+ def test_first_fire_at_ceiling(self):
59
+ self.assertEqual(_fire_turns(range(1, 81)), [80])
60
+ self.assertEqual(_fire_turns(range(1, 80)), []) # never reaches ceiling
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()