@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
|
@@ -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
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
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)
|