@miller-tech/uap 1.118.1 → 1.118.2

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.118.1",
3
+ "version": "1.118.2",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -340,6 +340,83 @@ _WRITE_TOOL_CLASS = frozenset({
340
340
  "write_file", "edit_file", "save_file",
341
341
  })
342
342
 
343
+ # Open-ended exploration tools the agent uses to make a DIFFERENT move once a
344
+ # read/grep/glob loop is broken. The code cannot enumerate every exploration
345
+ # tool, but these are the universal escape hatches. Cycle-break narrowing must
346
+ # NEVER strip these: a cycling *Bash* means "vary the command" (handled by the
347
+ # injected cycle hint), NOT "lose the ability to run bash at all". Excluding
348
+ # Bash here stranded autonomous ("auto mode") agents with no way to explore the
349
+ # filesystem — the whole toolset minus its exploration escape hatch.
350
+ _EXPLORATION_ESCAPE_TOOLS = frozenset({
351
+ "bash", "shell", "sh", "run_bash", "runbash", "run_command",
352
+ "run_terminal_cmd", "execute_command", "executecommand", "terminal",
353
+ "webfetch", "web_fetch", "fetch", "websearch", "web_search",
354
+ "agent", "task", "dispatch_agent",
355
+ })
356
+
357
+
358
+ def _narrow_tools_for_cycle_break(tools, cycling_tool_names, session_banned_tools):
359
+ """Drop cycling + session-banned tools from the toolset, expanding to the
360
+ whole read-only class when any excluded tool is read-only.
361
+
362
+ The open-ended exploration escape hatch (Bash/WebFetch/Agent) is kept out of
363
+ the CYCLING-derived exclusion: a cycling Bash means "vary the command"
364
+ (handled by the injected cycle hint), not "lose the ability to run bash".
365
+ An explicit session ban is a stronger, deliberate signal (a tool that keeps
366
+ malforming) and IS still honored, even for those tools.
367
+
368
+ Returns ``(narrowed_tools, expanded_read_only_class)``. Tool-name matching is
369
+ case-insensitive so "Bash"/"bash" and "Read"/"read" behave identically.
370
+ """
371
+ read_only_lower = {c.lower() for c in _READ_ONLY_TOOL_CLASS}
372
+ cycling_lower = {n.lower() for n in cycling_tool_names}
373
+ banned_lower = {n.lower() for n in session_banned_tools}
374
+ # Expand to the full read-only class if any excluded tool is read-only
375
+ # (preserves the original trigger over cycling ∪ banned).
376
+ expanded = any(n in read_only_lower for n in (cycling_lower | banned_lower))
377
+ cycling_exclude = set(cycling_lower)
378
+ if expanded:
379
+ cycling_exclude |= read_only_lower
380
+ # Never let the cycling path narrow away the exploration escape hatch — that
381
+ # is exactly the filesystem-exploration capability the cycle-break is trying
382
+ # to redirect the agent toward.
383
+ cycling_exclude -= _EXPLORATION_ESCAPE_TOOLS
384
+ exclude_set = cycling_exclude | banned_lower
385
+
386
+ def _name(t):
387
+ return ((t.get("function", {}) or {}).get("name", "") or "").lower()
388
+
389
+ narrowed = [t for t in tools if _name(t) not in exclude_set]
390
+
391
+ # Floor invariant: a loop-breaker must never strand the agent. If narrowing
392
+ # would remove the last way to make a DIFFERENT move — i.e. no exploration
393
+ # escape hatch AND no write tool survives, but the original set had one —
394
+ # keep the original toolset. This closes the door where an explicit ban of
395
+ # the sole exploration tool re-creates the very "can't explore" bug.
396
+ write_lower = {w.lower() for w in _WRITE_TOOL_CLASS}
397
+
398
+ def _has_action_path(tool_list):
399
+ names = {_name(t) for t in tool_list}
400
+ return bool(names & _EXPLORATION_ESCAPE_TOOLS) or bool(names & write_lower)
401
+
402
+ if _has_action_path(tools) and not _has_action_path(narrowed):
403
+ return list(tools), expanded
404
+ return narrowed, expanded
405
+
406
+
407
+ def _should_auto_ban(name, cycle_count, ban_at):
408
+ """Whether a cycling tool should be added to session_banned_tools this round.
409
+
410
+ The auto-ban accumulator is itself cycling-derived (ban after N detections),
411
+ so exploration escape-hatch tools (Bash/WebFetch/Agent) are NEVER auto-banned
412
+ — banning Bash would re-strip the agent's only way to explore the filesystem
413
+ on the Nth cycle, re-creating the bug the narrowing exemption fixes for
414
+ earlier cycles. Repeated Bash is redirected via the injected cycle hint.
415
+ """
416
+ if name.lower() in _EXPLORATION_ESCAPE_TOOLS:
417
+ return False
418
+ return cycle_count >= ban_at
419
+
343
420
  PROXY_GUARDRAIL_RETRY = os.environ.get("PROXY_GUARDRAIL_RETRY", "on").lower() not in {
344
421
  "0",
345
422
  "false",
@@ -4028,7 +4105,13 @@ def _resolve_state_machine_tool_choice(
4028
4105
  and PROXY_COORDINATION_BAN_THRESHOLD > 0
4029
4106
  )
4030
4107
  ban_at = PROXY_COORDINATION_BAN_THRESHOLD if is_coord else 3
4031
- if monitor.tool_cycle_counts[name] >= ban_at and name not in monitor.session_banned_tools:
4108
+ # Exploration escape-hatch tools are never auto-banned see
4109
+ # _should_auto_ban (banning Bash re-creates the "can't explore"
4110
+ # bug since the ban is itself cycling-derived).
4111
+ if (
4112
+ _should_auto_ban(name, monitor.tool_cycle_counts[name], ban_at)
4113
+ and name not in monitor.session_banned_tools
4114
+ ):
4032
4115
  monitor.session_banned_tools.add(name)
4033
4116
  logger.warning(
4034
4117
  "TOOL BAN: '%s' banned for session after %d cycle detections "
@@ -4853,16 +4936,15 @@ def build_openai_request(
4853
4936
  (monitor.cycling_tool_names or monitor.session_banned_tools)
4854
4937
  and "tools" in openai_body
4855
4938
  ):
4856
- exclude_set = set(monitor.cycling_tool_names) | monitor.session_banned_tools
4857
- # Expand to full read-only class if any cycling tool is read-only
4858
- if any(n.lower() in {c.lower() for c in _READ_ONLY_TOOL_CLASS} for n in exclude_set):
4859
- exclude_set |= _READ_ONLY_TOOL_CLASS
4860
4939
  original_count = len(openai_body["tools"])
4861
- narrowed = [
4862
- t
4863
- for t in openai_body["tools"]
4864
- if t.get("function", {}).get("name") not in exclude_set
4865
- ]
4940
+ # Drop cycling/banned tools (and the read-only class if any
4941
+ # cycling tool is read-only) but always keep the exploration
4942
+ # escape hatch — see _narrow_tools_for_cycle_break.
4943
+ narrowed, expanded_read_only = _narrow_tools_for_cycle_break(
4944
+ openai_body["tools"],
4945
+ monitor.cycling_tool_names,
4946
+ monitor.session_banned_tools,
4947
+ )
4866
4948
  if narrowed:
4867
4949
  openai_body["tools"] = narrowed
4868
4950
  # Only log on first activation or phase transitions to reduce noise
@@ -4872,7 +4954,7 @@ def build_openai_request(
4872
4954
  original_count,
4873
4955
  len(narrowed),
4874
4956
  monitor.cycling_tool_names,
4875
- any(n.lower() in {c.lower() for c in _READ_ONLY_TOOL_CLASS} for n in monitor.cycling_tool_names),
4957
+ expanded_read_only,
4876
4958
  )
4877
4959
  else:
4878
4960
  logger.warning(
@@ -3465,8 +3465,10 @@ class TestCycleBreakOptions(unittest.TestCase):
3465
3465
  setattr(proxy, "PROXY_TOOL_STATE_STAGNATION_THRESHOLD", old_stagnation)
3466
3466
  setattr(proxy, "PROXY_TOOL_STATE_CYCLE_WINDOW", old_cycle_window)
3467
3467
 
3468
- def test_cycle_break_narrows_tools(self):
3469
- """Option 2: cycling tools are excluded from the tools array during review."""
3468
+ def test_cycle_break_keeps_bash_exploration(self):
3469
+ """Cycling on Bash must NOT strip the Bash tool it is the agent's
3470
+ exploration escape hatch. (Regression: the cycle-break used to exclude
3471
+ Bash by name, blocking all filesystem exploration.)"""
3470
3472
  old_state = getattr(proxy, "PROXY_TOOL_STATE_MACHINE")
3471
3473
  old_min_msgs = getattr(proxy, "PROXY_TOOL_STATE_MIN_MESSAGES")
3472
3474
  old_forced = getattr(proxy, "PROXY_TOOL_STATE_FORCED_BUDGET")
@@ -3516,9 +3518,10 @@ class TestCycleBreakOptions(unittest.TestCase):
3516
3518
 
3517
3519
  openai = proxy.build_openai_request(body, monitor)
3518
3520
  self.assertEqual(monitor.tool_turn_phase, "review")
3519
- # Bash should be excluded, Read and Write should remain
3521
+ # Bash is the exploration escape hatch it must remain available
3522
+ # even while cycling. Read and Write remain too.
3520
3523
  tool_names = [t["function"]["name"] for t in openai.get("tools", [])]
3521
- self.assertNotIn("Bash", tool_names)
3524
+ self.assertIn("Bash", tool_names)
3522
3525
  self.assertIn("Read", tool_names)
3523
3526
  self.assertIn("Write", tool_names)
3524
3527
  finally:
@@ -4548,7 +4551,8 @@ class TestReadOnlyCycleClassExclusion(unittest.TestCase):
4548
4551
  setattr(proxy, k, v)
4549
4552
 
4550
4553
  def test_non_read_tool_cycling_no_class_expansion(self):
4551
- """When 'bash' is cycling, only 'bash' is excluded, not read-only tools."""
4554
+ """When 'bash' is cycling, the read-only class is NOT expanded, and bash
4555
+ itself is preserved as the exploration escape hatch."""
4552
4556
  old_vals = {
4553
4557
  "PROXY_TOOL_STATE_MACHINE": getattr(proxy, "PROXY_TOOL_STATE_MACHINE"),
4554
4558
  "PROXY_TOOL_STATE_MIN_MESSAGES": getattr(proxy, "PROXY_TOOL_STATE_MIN_MESSAGES"),
@@ -4581,8 +4585,9 @@ class TestReadOnlyCycleClassExclusion(unittest.TestCase):
4581
4585
  remaining_names = [
4582
4586
  t.get("function", {}).get("name") for t in openai_body.get("tools", [])
4583
4587
  ]
4584
- self.assertNotIn("bash", remaining_names)
4585
- # Read-only tools should still be available
4588
+ # bash is an exploration escape hatch — cycling does not strip it.
4589
+ self.assertIn("bash", remaining_names)
4590
+ # Read-only tools should still be available (no class expansion)
4586
4591
  self.assertIn("read", remaining_names)
4587
4592
  self.assertIn("glob", remaining_names)
4588
4593
  self.assertIn("grep", remaining_names)
@@ -4685,16 +4690,20 @@ class TestPersistentCycleExclusion(unittest.TestCase):
4685
4690
  body = self._make_body_with_tools(all_tools)
4686
4691
  monitor = proxy.SessionMonitor(context_window=262144)
4687
4692
 
4688
- # Simulate bash cycling that triggers review
4689
- monitor.cycling_tool_names = ["bash"]
4693
+ # Simulate a non-exploration tool cycling that triggers review. (Bash
4694
+ # is exempt from cycling exclusion, so use 'edit' to exercise the
4695
+ # persistence path.)
4696
+ monitor.cycling_tool_names = ["edit"]
4690
4697
  monitor.tool_turn_phase = "act"
4691
4698
  monitor.tool_state_forced_budget_remaining = 5
4692
4699
 
4693
4700
  openai = proxy.build_openai_request(body, monitor)
4694
4701
 
4695
- # In act phase with cycling_tool_names set, bash should be excluded
4702
+ # In act phase with cycling_tool_names set, the cycling tool stays
4703
+ # excluded — but the exploration escape hatch (bash) is preserved.
4696
4704
  remaining = [t["function"]["name"] for t in openai.get("tools", [])]
4697
- self.assertNotIn("bash", remaining)
4705
+ self.assertNotIn("edit", remaining)
4706
+ self.assertIn("bash", remaining)
4698
4707
  self.assertIn("read", remaining)
4699
4708
  self.assertIn("write", remaining)
4700
4709
  finally:
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for cycle-break tool narrowing preserving the exploration escape hatch.
3
+
4
+ Regression: the CYCLE BREAK narrowing removed every cycling tool by name (and the
5
+ whole read-only class if any cycling tool was read-only). Because Bash can be a
6
+ cycling tool, the agent lost the Bash tool entirely and could no longer run any
7
+ bash command to explore the filesystem. The narrowing must keep the open-ended
8
+ exploration escape hatch (Bash/WebFetch/Agent) available so the agent can make a
9
+ DIFFERENT move — the cycle *hint* handles "vary the command".
10
+ """
11
+
12
+ import importlib.util
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+
17
+ def _load_proxy_module():
18
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
19
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
20
+ assert spec is not None and spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ proxy = _load_proxy_module()
27
+
28
+
29
+ def _tools(*names):
30
+ return [{"function": {"name": n}} for n in names]
31
+
32
+
33
+ def _names(tools):
34
+ return sorted(t["function"]["name"] for t in tools)
35
+
36
+
37
+ class TestCycleBreakExploration(unittest.TestCase):
38
+ def test_cycling_bash_keeps_bash(self):
39
+ """The core bug: cycling on Bash must NOT remove the Bash tool."""
40
+ tools = _tools("Bash", "Read", "Write")
41
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(tools, {"Bash"}, set())
42
+ self.assertIn("Bash", _names(narrowed))
43
+ self.assertFalse(expanded) # Bash is not read-only, so no class expansion
44
+
45
+ def test_read_only_cycle_expands_class_but_keeps_exploration(self):
46
+ """Cycling a read-only tool drops the whole read-only class, yet the
47
+ exploration escape hatch (Bash/WebFetch/Agent) survives."""
48
+ tools = _tools("Read", "Grep", "Glob", "Bash", "WebFetch", "Agent", "Write")
49
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(tools, {"Grep"}, set())
50
+ self.assertTrue(expanded)
51
+ kept = _names(narrowed)
52
+ # read-only class dropped
53
+ for n in ("Read", "Grep", "Glob"):
54
+ self.assertNotIn(n, kept)
55
+ # exploration escape hatch + writes preserved
56
+ for n in ("Bash", "WebFetch", "Agent", "Write"):
57
+ self.assertIn(n, kept)
58
+
59
+ def test_session_ban_is_honored_even_for_exploration_tools(self):
60
+ """An explicit session ban is a stronger, deliberate signal than cycling
61
+ and IS honored — even for an exploration tool. (Only the cycling path
62
+ preserves the escape hatch.)"""
63
+ tools = _tools("Bash", "Read", "Write")
64
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
65
+ self.assertNotIn("Bash", _names(narrowed))
66
+
67
+ def test_cycling_bash_kept_even_when_another_tool_is_banned(self):
68
+ """A cycling Bash is preserved even while an unrelated tool is banned."""
69
+ tools = _tools("Bash", "Read", "Task")
70
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, {"Bash"}, {"Task"})
71
+ kept = _names(narrowed)
72
+ self.assertIn("Bash", kept) # cycling exploration hatch preserved
73
+ self.assertNotIn("Task", kept) # explicit ban honored
74
+
75
+ def test_case_insensitive_matching(self):
76
+ """Lowercase 'bash' in the cycling set must still match a 'Bash' tool
77
+ (and keep it), and 'read' must drop a 'Read' tool."""
78
+ tools = _tools("Bash", "Read")
79
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(
80
+ tools, {"bash", "read"}, set()
81
+ )
82
+ kept = _names(narrowed)
83
+ self.assertIn("Bash", kept) # exploration hatch kept despite cycling
84
+ self.assertNotIn("Read", kept) # read-only dropped
85
+ self.assertTrue(expanded)
86
+
87
+ def test_floor_keeps_last_action_tool_when_ban_would_strip_it(self):
88
+ """Floor invariant: if honoring a ban would leave NO exploration tool and
89
+ NO write tool, keep the original set — a loop-breaker must never strand
90
+ the agent with no way to make a different move."""
91
+ tools = _tools("Bash", "Read") # Read is read-only, not an action path
92
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
93
+ self.assertIn("Bash", _names(narrowed)) # floor restored the escape hatch
94
+
95
+ def test_floor_not_triggered_when_a_write_tool_survives(self):
96
+ """The ban IS honored when another action path (a write tool) remains."""
97
+ tools = _tools("Bash", "Read", "Write")
98
+ narrowed, _ = proxy._narrow_tools_for_cycle_break(tools, set(), {"Bash"})
99
+ kept = _names(narrowed)
100
+ self.assertNotIn("Bash", kept) # honored: Write still gives an action path
101
+ self.assertIn("Write", kept)
102
+
103
+ def test_tool_class_taxonomy_is_pairwise_disjoint(self):
104
+ """Regression insurance: the three tool-class sets must never overlap, or
105
+ convergence accounting / cycle-break scope would silently change."""
106
+ ro = {c.lower() for c in proxy._READ_ONLY_TOOL_CLASS}
107
+ wr = {c.lower() for c in proxy._WRITE_TOOL_CLASS}
108
+ ex = {c.lower() for c in proxy._EXPLORATION_ESCAPE_TOOLS}
109
+ self.assertEqual(ro & wr, set())
110
+ self.assertEqual(ro & ex, set())
111
+ self.assertEqual(wr & ex, set())
112
+
113
+ def test_non_exploration_non_readonly_tool_dropped(self):
114
+ """A cycling tool that is neither read-only nor an exploration hatch is
115
+ dropped, and the read-only class is NOT expanded."""
116
+ tools = _tools("SomeTool", "Read", "Bash")
117
+ narrowed, expanded = proxy._narrow_tools_for_cycle_break(
118
+ tools, {"SomeTool"}, set()
119
+ )
120
+ kept = _names(narrowed)
121
+ self.assertNotIn("SomeTool", kept)
122
+ self.assertIn("Read", kept) # class not expanded, Read survives
123
+ self.assertIn("Bash", kept)
124
+ self.assertFalse(expanded)
125
+
126
+
127
+ class TestAutoBanExemption(unittest.TestCase):
128
+ """The session-ban accumulator is cycling-derived (ban after N detections).
129
+ Exploration escape-hatch tools must never be auto-banned, or the 'cannot
130
+ explore the filesystem' bug reappears on the Nth cycle."""
131
+
132
+ def test_bash_never_auto_banned_even_far_past_threshold(self):
133
+ self.assertFalse(proxy._should_auto_ban("bash", cycle_count=99, ban_at=3))
134
+ self.assertFalse(proxy._should_auto_ban("Bash", cycle_count=3, ban_at=3))
135
+
136
+ def test_other_exploration_tools_never_auto_banned(self):
137
+ for name in ("webfetch", "WebSearch", "agent", "task"):
138
+ self.assertFalse(proxy._should_auto_ban(name, cycle_count=10, ban_at=3))
139
+
140
+ def test_non_exploration_tool_banned_at_threshold(self):
141
+ self.assertTrue(proxy._should_auto_ban("edit", cycle_count=3, ban_at=3))
142
+ self.assertTrue(proxy._should_auto_ban("read", cycle_count=3, ban_at=3))
143
+
144
+ def test_non_exploration_tool_not_banned_below_threshold(self):
145
+ self.assertFalse(proxy._should_auto_ban("edit", cycle_count=2, ban_at=3))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()