@miller-tech/uap 1.20.27 → 1.20.29

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.20.27",
3
+ "version": "1.20.29",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -656,6 +656,8 @@ class SessionMonitor:
656
656
  tool_state_review_cycles: int = 0
657
657
  last_tool_fingerprint: str = ""
658
658
  cycling_tool_names: list = field(default_factory=list)
659
+ session_banned_tools: set = field(default_factory=set) # tools banned for entire session after repeated cycling
660
+ tool_cycle_counts: dict = field(default_factory=dict) # {tool_name: cycle_count} across resets
659
661
  last_response_garbled: bool = False # previous turn had garbled/malformed output
660
662
  finalize_turn_active: bool = False
661
663
  finalize_continuation_count: int = 0
@@ -2240,6 +2242,16 @@ def _resolve_state_machine_tool_choice(
2240
2242
  for part in fp.split("|"):
2241
2243
  raw_names.append(part.split(":")[0])
2242
2244
  monitor.cycling_tool_names = list(dict.fromkeys(raw_names))
2245
+ # Cycle 18 Option 2: track per-tool cycle counts and ban after 3 cycles
2246
+ for name in monitor.cycling_tool_names:
2247
+ monitor.tool_cycle_counts[name] = monitor.tool_cycle_counts.get(name, 0) + 1
2248
+ if monitor.tool_cycle_counts[name] >= 3 and name not in monitor.session_banned_tools:
2249
+ monitor.session_banned_tools.add(name)
2250
+ logger.warning(
2251
+ "TOOL BAN: '%s' banned for session after %d cycle detections",
2252
+ name,
2253
+ monitor.tool_cycle_counts[name],
2254
+ )
2243
2255
  logger.warning(
2244
2256
  "TOOL STATE MACHINE: entering review (cycle=%s repeat=%d stagnation=%d cycles=%d cycling_tools=%s)",
2245
2257
  cycle_looping,
@@ -2629,14 +2641,15 @@ def build_openai_request(
2629
2641
  cycling_names,
2630
2642
  cycles,
2631
2643
  )
2632
- # Narrow tools to exclude cycling tools
2644
+ # Narrow tools to exclude cycling tools + session-banned tools
2633
2645
  # Option 1 (Cycle 13): if any cycling tool is read-only, exclude entire class
2634
2646
  # Option 1 (Cycle 14): persist exclusion during act phase too, not just review
2647
+ # Option 2 (Cycle 18): always exclude session-banned tools
2635
2648
  if (
2636
- monitor.cycling_tool_names
2649
+ (monitor.cycling_tool_names or monitor.session_banned_tools)
2637
2650
  and "tools" in openai_body
2638
2651
  ):
2639
- exclude_set = set(monitor.cycling_tool_names)
2652
+ exclude_set = set(monitor.cycling_tool_names) | monitor.session_banned_tools
2640
2653
  # Expand to full read-only class if any cycling tool is read-only
2641
2654
  if any(n.lower() in {c.lower() for c in _READ_ONLY_TOOL_CLASS} for n in exclude_set):
2642
2655
  exclude_set |= _READ_ONLY_TOOL_CLASS
@@ -2648,13 +2661,15 @@ def build_openai_request(
2648
2661
  ]
2649
2662
  if narrowed:
2650
2663
  openai_body["tools"] = narrowed
2651
- logger.warning(
2652
- "CYCLE BREAK: narrowed tools from %d to %d (excluded %s, read_only_class=%s)",
2653
- original_count,
2654
- len(narrowed),
2655
- monitor.cycling_tool_names,
2656
- any(n.lower() in {c.lower() for c in _READ_ONLY_TOOL_CLASS} for n in monitor.cycling_tool_names),
2657
- )
2664
+ # Only log on first activation or phase transitions to reduce noise
2665
+ if state_reason in {"cycle_detected", "stagnation"}:
2666
+ logger.warning(
2667
+ "CYCLE BREAK: narrowed tools from %d to %d (excluded %s, read_only_class=%s)",
2668
+ original_count,
2669
+ len(narrowed),
2670
+ monitor.cycling_tool_names,
2671
+ any(n.lower() in {c.lower() for c in _READ_ONLY_TOOL_CLASS} for n in monitor.cycling_tool_names),
2672
+ )
2658
2673
  else:
2659
2674
  logger.warning(
2660
2675
  "CYCLE BREAK: cannot narrow tools — all tools are cycling, keeping original set",
@@ -3092,6 +3107,47 @@ _TOOL_CALL_XML_RE = re.compile(
3092
3107
  )
3093
3108
 
3094
3109
 
3110
+ def _repair_tool_call_json(raw: str) -> str | None:
3111
+ """Attempt to repair common garbled JSON in tool call payloads.
3112
+
3113
+ Returns repaired JSON string, or None if repair is not possible.
3114
+ Handles: trailing braces, unbalanced brackets, truncated strings.
3115
+ """
3116
+ s = raw.strip()
3117
+ if not s.startswith("{"):
3118
+ return None
3119
+ # Strip trailing garbage (runaway braces/brackets)
3120
+ while s.endswith("}}") and s.count("{") < s.count("}"):
3121
+ s = s[:-1]
3122
+ while s.endswith("]]") and s.count("[") < s.count("]"):
3123
+ s = s[:-1]
3124
+ # Balance braces
3125
+ open_b = s.count("{") - s.count("}")
3126
+ if open_b > 0:
3127
+ s += "}" * open_b
3128
+ elif open_b < 0:
3129
+ # Too many closing braces — trim from end
3130
+ for _ in range(-open_b):
3131
+ idx = s.rfind("}")
3132
+ if idx > 0:
3133
+ s = s[:idx] + s[idx + 1:]
3134
+ # Try to parse
3135
+ try:
3136
+ json.loads(s)
3137
+ return s
3138
+ except json.JSONDecodeError:
3139
+ pass
3140
+ # Try truncating at last valid comma + closing
3141
+ for end in range(len(s) - 1, max(0, len(s) - 200), -1):
3142
+ candidate = s[:end].rstrip().rstrip(",") + "}" * max(0, s[:end].count("{") - s[:end].count("}"))
3143
+ try:
3144
+ json.loads(candidate)
3145
+ return candidate
3146
+ except json.JSONDecodeError:
3147
+ continue
3148
+ return None
3149
+
3150
+
3095
3151
  def _extract_tool_calls_from_text(text: str) -> tuple[list[dict], str]:
3096
3152
  """Parse ``<tool_call>{...}</tool_call>`` blocks out of *text*.
3097
3153
 
@@ -3112,7 +3168,18 @@ def _extract_tool_calls_from_text(text: str) -> tuple[list[dict], str]:
3112
3168
  try:
3113
3169
  payload = json.loads(raw_json)
3114
3170
  except json.JSONDecodeError:
3115
- continue
3171
+ # Cycle 15 Option 1: attempt JSON repair before giving up
3172
+ repaired = _repair_tool_call_json(raw_json)
3173
+ if repaired:
3174
+ try:
3175
+ payload = json.loads(repaired)
3176
+ logger.info(
3177
+ "TOOL CALL EXTRACTION: repaired garbled JSON in <tool_call> block"
3178
+ )
3179
+ except json.JSONDecodeError:
3180
+ continue
3181
+ else:
3182
+ continue
3116
3183
  if not isinstance(payload, dict):
3117
3184
  continue
3118
3185
 
@@ -4380,9 +4447,11 @@ def _build_malformed_retry_body(
4380
4447
  retry_body = dict(openai_body)
4381
4448
  retry_body["stream"] = False
4382
4449
  retry_body["tool_choice"] = tool_choice
4383
- # Escalate temperature down on successive retries for more deterministic output
4450
+ # Cycle 15 Option 3: vary temperature across retries to break degenerate patterns.
4451
+ # Attempt 1: use configured retry temp (default 0.0) for deterministic first try.
4452
+ # Attempt 2+: increase to 0.5 to escape the degenerate local minimum.
4384
4453
  if total_attempts > 1 and attempt > 1:
4385
- retry_body["temperature"] = 0.0
4454
+ retry_body["temperature"] = 0.5
4386
4455
  else:
4387
4456
  retry_body["temperature"] = PROXY_MALFORMED_TOOL_RETRY_TEMPERATURE
4388
4457
 
@@ -4662,3 +4662,137 @@ class TestMalformedPayloadLoopFix(unittest.TestCase):
4662
4662
  }
4663
4663
  openai = proxy.build_openai_request(body, monitor)
4664
4664
  self.assertAlmostEqual(openai.get("temperature", 1.0), 0.3, places=1)
4665
+
4666
+
4667
+ class TestToolCallJsonRepair(unittest.TestCase):
4668
+ """Tests for Cycle 15 Option 1: JSON repair in tool call extraction."""
4669
+
4670
+ def test_repairs_trailing_braces(self):
4671
+ """Runaway closing braces are trimmed and JSON parsed."""
4672
+ garbled = '{"name":"bash","arguments":{"command":"ls"}}}}'
4673
+ repaired = proxy._repair_tool_call_json(garbled)
4674
+ self.assertIsNotNone(repaired)
4675
+ parsed = json.loads(repaired)
4676
+ self.assertEqual(parsed["name"], "bash")
4677
+
4678
+ def test_repairs_unbalanced_open_braces(self):
4679
+ """Missing closing braces are added."""
4680
+ garbled = '{"name":"read","arguments":{"file_path":"/foo"}'
4681
+ repaired = proxy._repair_tool_call_json(garbled)
4682
+ self.assertIsNotNone(repaired)
4683
+ parsed = json.loads(repaired)
4684
+ self.assertEqual(parsed["name"], "read")
4685
+
4686
+ def test_returns_none_for_total_garbage(self):
4687
+ """Completely invalid JSON returns None."""
4688
+ result = proxy._repair_tool_call_json("not json at all")
4689
+ self.assertIsNone(result)
4690
+
4691
+ def test_extracts_repaired_tool_call_from_text(self):
4692
+ """End-to-end: garbled <tool_call> XML is extracted after repair."""
4693
+ text = '<tool_call>\n{"name":"bash","arguments":{"command":"pwd"}}}\n</tool_call>'
4694
+ extracted, remaining = proxy._extract_tool_calls_from_text(text)
4695
+ self.assertEqual(len(extracted), 1)
4696
+ self.assertEqual(extracted[0]["function"]["name"], "bash")
4697
+
4698
+
4699
+ class TestRetryTemperatureVariance(unittest.TestCase):
4700
+ """Tests for Cycle 15 Option 3: retry temperature variance."""
4701
+
4702
+ def test_retry_attempt_1_uses_configured_temp(self):
4703
+ """First retry attempt uses PROXY_MALFORMED_TOOL_RETRY_TEMPERATURE."""
4704
+ body = proxy._build_malformed_retry_body(
4705
+ {"messages": [{"role": "user", "content": "test"}], "tools": []},
4706
+ {"messages": [{"role": "user", "content": "test"}], "tools": []},
4707
+ retry_hint="fix it",
4708
+ tool_choice="required",
4709
+ attempt=1,
4710
+ total_attempts=3,
4711
+ is_garbled=False,
4712
+ )
4713
+ self.assertEqual(body["temperature"], proxy.PROXY_MALFORMED_TOOL_RETRY_TEMPERATURE)
4714
+
4715
+ def test_retry_attempt_2_uses_higher_temp(self):
4716
+ """Second retry attempt uses temp=0.5 to break degenerate patterns."""
4717
+ body = proxy._build_malformed_retry_body(
4718
+ {"messages": [{"role": "user", "content": "test"}], "tools": []},
4719
+ {"messages": [{"role": "user", "content": "test"}], "tools": []},
4720
+ retry_hint="fix it",
4721
+ tool_choice="required",
4722
+ attempt=2,
4723
+ total_attempts=3,
4724
+ is_garbled=False,
4725
+ )
4726
+ self.assertEqual(body["temperature"], 0.5)
4727
+
4728
+
4729
+ class TestCycle18SessionBanAndLogNoise(unittest.TestCase):
4730
+ """Tests for Cycle 18: session tool banning and log noise reduction."""
4731
+
4732
+ def test_tool_banned_after_3_cycle_detections(self):
4733
+ """Option 2: tool gets session-banned after cycling 3 times."""
4734
+ monitor = proxy.SessionMonitor(context_window=262144)
4735
+ # Simulate 3 separate cycle detections for 'task'
4736
+ monitor.tool_cycle_counts["task"] = 2
4737
+ monitor.cycling_tool_names = ["task"]
4738
+
4739
+ # This is what happens inside the cycle detection — manually trigger
4740
+ for name in monitor.cycling_tool_names:
4741
+ monitor.tool_cycle_counts[name] = monitor.tool_cycle_counts.get(name, 0) + 1
4742
+ if monitor.tool_cycle_counts[name] >= 3:
4743
+ monitor.session_banned_tools.add(name)
4744
+
4745
+ self.assertIn("task", monitor.session_banned_tools)
4746
+ self.assertEqual(monitor.tool_cycle_counts["task"], 3)
4747
+
4748
+ def test_session_ban_survives_state_reset(self):
4749
+ """Option 2: session_banned_tools persists through reset_tool_turn_state."""
4750
+ monitor = proxy.SessionMonitor(context_window=262144)
4751
+ monitor.session_banned_tools.add("task")
4752
+ monitor.tool_cycle_counts["task"] = 3
4753
+
4754
+ monitor.reset_tool_turn_state(reason="test")
4755
+
4756
+ # Session bans survive resets — they're session-level, not phase-level
4757
+ self.assertIn("task", monitor.session_banned_tools)
4758
+ self.assertEqual(monitor.tool_cycle_counts["task"], 3)
4759
+
4760
+ def test_banned_tools_excluded_even_without_cycling(self):
4761
+ """Option 2: session-banned tools are excluded even when cycling_tool_names is empty."""
4762
+ old_vals = {}
4763
+ for k in ["PROXY_TOOL_STATE_MACHINE", "PROXY_TOOL_STATE_MIN_MESSAGES",
4764
+ "PROXY_TOOL_STATE_FORCED_BUDGET"]:
4765
+ old_vals[k] = getattr(proxy, k)
4766
+ try:
4767
+ setattr(proxy, "PROXY_TOOL_STATE_MACHINE", True)
4768
+ setattr(proxy, "PROXY_TOOL_STATE_MIN_MESSAGES", 3)
4769
+ setattr(proxy, "PROXY_TOOL_STATE_FORCED_BUDGET", 6)
4770
+
4771
+ body = {
4772
+ "model": "test",
4773
+ "messages": [
4774
+ {"role": "user", "content": "do"},
4775
+ {"role": "assistant", "content": [
4776
+ {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "ls"}}
4777
+ ]},
4778
+ {"role": "user", "content": [
4779
+ {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}
4780
+ ]},
4781
+ ],
4782
+ "tools": [
4783
+ {"name": "task", "description": "Task", "input_schema": {"type": "object"}},
4784
+ {"name": "bash", "description": "Bash", "input_schema": {"type": "object"}},
4785
+ {"name": "read", "description": "Read", "input_schema": {"type": "object"}},
4786
+ ],
4787
+ }
4788
+ monitor = proxy.SessionMonitor(context_window=262144)
4789
+ monitor.session_banned_tools.add("task")
4790
+ monitor.cycling_tool_names = [] # no active cycling
4791
+
4792
+ openai = proxy.build_openai_request(body, monitor)
4793
+ remaining = [t["function"]["name"] for t in openai.get("tools", [])]
4794
+ self.assertNotIn("task", remaining)
4795
+ self.assertIn("bash", remaining)
4796
+ finally:
4797
+ for k, v in old_vals.items():
4798
+ setattr(proxy, k, v)