@miller-tech/uap 1.186.2 → 1.187.3
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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts +100 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +507 -20
- package/dist/cli/deliver.js.map +1 -1
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +28 -5
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/delivery/agentic-executor.d.ts +3 -3
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +74 -4
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +6 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +31 -0
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/edit-match.d.ts.map +1 -1
- package/dist/delivery/edit-match.js +19 -1
- package/dist/delivery/edit-match.js.map +1 -1
- package/dist/delivery/epic-controller.d.ts +6 -0
- package/dist/delivery/epic-controller.d.ts.map +1 -1
- package/dist/delivery/epic-controller.js +10 -0
- package/dist/delivery/epic-controller.js.map +1 -1
- package/dist/delivery/epic-mission.d.ts.map +1 -1
- package/dist/delivery/epic-mission.js +3 -0
- package/dist/delivery/epic-mission.js.map +1 -1
- package/dist/delivery/verifier-ladder.d.ts +99 -6
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +208 -25
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
- package/dist/mcp-router/tools/deliver.js +17 -0
- package/dist/mcp-router/tools/deliver.js.map +1 -1
- package/dist/models/long-fetch.d.ts +19 -0
- package/dist/models/long-fetch.d.ts.map +1 -1
- package/dist/models/long-fetch.js +47 -0
- package/dist/models/long-fetch.js.map +1 -1
- package/dist/telemetry/tool-failure.d.ts +9 -0
- package/dist/telemetry/tool-failure.d.ts.map +1 -1
- package/dist/telemetry/tool-failure.js +4 -0
- package/dist/telemetry/tool-failure.js.map +1 -1
- package/docs/reference/CLI.md +13 -2
- package/docs/reference/CONFIGURATION.md +2 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_infra_protect.py +68 -5
- package/src/policies/enforcers/enforcement_self_protect.py +36 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/pre-tool-use-bash.sh +10 -2
- package/templates/hooks/uap-policy-gate.sh +4 -1
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +101 -9
- package/tools/agents/tests/test_repeat_call_guard.py +228 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A tool call that keeps SUCCEEDING can loop forever, and every guard missed it.
|
|
3
|
+
|
|
4
|
+
Observed live (opencode + qwen3.6, 2026-08-07): `git diff --stat` re-issued 44
|
|
5
|
+
times in one run, ~2.5s apart, until the operator interrupted. On screen it
|
|
6
|
+
reads as the final message repeating without end.
|
|
7
|
+
|
|
8
|
+
Each existing guard declined it for a defensible reason:
|
|
9
|
+
|
|
10
|
+
STUCK-BREAK wants self-reported "stuck" phrasing, or an `api.github.com`
|
|
11
|
+
argument. The model said nothing and this is a git command.
|
|
12
|
+
ERROR-LOOP wants a repeated tool-RESULT error signature. This call works.
|
|
13
|
+
LOOP BREAKER does detect the identical fingerprint, but ANDs it with
|
|
14
|
+
`no_progress_streak` — and a command that returns output every
|
|
15
|
+
time never accumulates one, so the condition never held.
|
|
16
|
+
|
|
17
|
+
The blind spot is a repeatedly-SUCCESSFUL identical call: the other guards all
|
|
18
|
+
key off failure or self-awareness, and this loop has neither. So this guard
|
|
19
|
+
fires on the fingerprint alone, at 4, independent of outcome.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import importlib.util
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import unittest
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
PROXY = Path(__file__).resolve().parents[3] / "tools" / "agents" / "scripts" / "anthropic_proxy.py"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_proxy(env: dict | None = None):
|
|
32
|
+
"""Import the proxy module with a chosen env (thresholds are read at import)."""
|
|
33
|
+
saved = dict(os.environ)
|
|
34
|
+
os.environ.update(env or {})
|
|
35
|
+
try:
|
|
36
|
+
spec = importlib.util.spec_from_file_location(f"proxy_{len(sys.modules)}", PROXY)
|
|
37
|
+
mod = importlib.util.module_from_spec(spec)
|
|
38
|
+
spec.loader.exec_module(mod)
|
|
39
|
+
return mod
|
|
40
|
+
finally:
|
|
41
|
+
os.environ.clear()
|
|
42
|
+
os.environ.update(saved)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestRepeatCallGuard(unittest.TestCase):
|
|
46
|
+
@classmethod
|
|
47
|
+
def setUpClass(cls):
|
|
48
|
+
cls.proxy = load_proxy()
|
|
49
|
+
|
|
50
|
+
def monitor(self):
|
|
51
|
+
return self.proxy.SessionMonitor(context_window=100000)
|
|
52
|
+
|
|
53
|
+
def record(self, mon, fingerprint, times):
|
|
54
|
+
for _ in range(times):
|
|
55
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint=fingerprint)
|
|
56
|
+
|
|
57
|
+
def test_the_observed_loop_is_caught(self):
|
|
58
|
+
# The real thing: same tool, same args, succeeding every time.
|
|
59
|
+
mon = self.monitor()
|
|
60
|
+
self.record(mon, "Bash|git diff --stat", 4)
|
|
61
|
+
should, reason = mon.should_force_stuck_break()
|
|
62
|
+
self.assertTrue(should, "4 identical successful calls must break the loop")
|
|
63
|
+
self.assertIn("identical tool call", reason)
|
|
64
|
+
|
|
65
|
+
def test_three_identical_calls_are_left_alone(self):
|
|
66
|
+
# Repetition is not automatically a loop — a couple of retries is normal.
|
|
67
|
+
mon = self.monitor()
|
|
68
|
+
self.record(mon, "Bash|git diff --stat", 3)
|
|
69
|
+
should, _ = mon.should_force_stuck_break()
|
|
70
|
+
self.assertFalse(should)
|
|
71
|
+
|
|
72
|
+
def test_no_progress_streak_is_NOT_required(self):
|
|
73
|
+
# The precise reason the existing LOOP BREAKER never fired: a succeeding
|
|
74
|
+
# command leaves no_progress_streak at 0 forever.
|
|
75
|
+
mon = self.monitor()
|
|
76
|
+
self.record(mon, "Bash|git diff --stat", 6)
|
|
77
|
+
self.assertEqual(mon.no_progress_streak, 0)
|
|
78
|
+
should, _ = mon.should_force_stuck_break()
|
|
79
|
+
self.assertTrue(should)
|
|
80
|
+
|
|
81
|
+
def test_varied_calls_do_not_trip_it(self):
|
|
82
|
+
# Ordinary agentic work: different tools, different arguments.
|
|
83
|
+
mon = self.monitor()
|
|
84
|
+
for fp in ("Bash|ls", "Read|a.ts", "Bash|npm test", "Edit|a.ts", "Bash|git status"):
|
|
85
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint=fp)
|
|
86
|
+
should, _ = mon.should_force_stuck_break()
|
|
87
|
+
self.assertFalse(should)
|
|
88
|
+
|
|
89
|
+
def test_a_broken_streak_resets_it(self):
|
|
90
|
+
# Three repeats, something else, three repeats — not a loop.
|
|
91
|
+
mon = self.monitor()
|
|
92
|
+
self.record(mon, "Bash|git diff --stat", 3)
|
|
93
|
+
mon.record_tool_calls(tool_names=["Read"], fingerprint="Read|src/index.ts")
|
|
94
|
+
self.record(mon, "Bash|git diff --stat", 3)
|
|
95
|
+
should, _ = mon.should_force_stuck_break()
|
|
96
|
+
self.assertFalse(should)
|
|
97
|
+
|
|
98
|
+
def test_the_guard_can_be_disabled(self):
|
|
99
|
+
proxy = load_proxy({"PROXY_REPEAT_CALL_THRESHOLD": "0"})
|
|
100
|
+
mon = proxy.SessionMonitor(context_window=100000)
|
|
101
|
+
for _ in range(12):
|
|
102
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
|
|
103
|
+
should, _ = mon.should_force_stuck_break()
|
|
104
|
+
self.assertFalse(should, "PROXY_REPEAT_CALL_THRESHOLD=0 must disable it")
|
|
105
|
+
|
|
106
|
+
def test_existing_stuck_signals_still_work(self):
|
|
107
|
+
# The new branch must not shadow the two guards that were already there.
|
|
108
|
+
mon = self.monitor()
|
|
109
|
+
for _ in range(self.proxy.PROXY_STUCK_TEXT_THRESHOLD):
|
|
110
|
+
mon.note_assistant_text("I've been stuck in a loop, let me break out")
|
|
111
|
+
should, reason = mon.should_force_stuck_break()
|
|
112
|
+
self.assertTrue(should)
|
|
113
|
+
self.assertIn("self-reported", reason)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestDirectiveMatchesTheFailure(unittest.TestCase):
|
|
117
|
+
"""A succeeding loop must not be told to stop retrying 'a failing action'."""
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def setUpClass(cls):
|
|
121
|
+
cls.proxy = load_proxy()
|
|
122
|
+
|
|
123
|
+
def inject(self, mon):
|
|
124
|
+
body = {"messages": [{"role": "system", "content": "base"}], "tool_choice": "required"}
|
|
125
|
+
self.proxy._maybe_inject_stuck_break(body, mon)
|
|
126
|
+
return body
|
|
127
|
+
|
|
128
|
+
def test_repeat_loop_gets_the_right_words(self):
|
|
129
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
130
|
+
for _ in range(4):
|
|
131
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
|
|
132
|
+
text = self.inject(mon)["messages"][0]["content"]
|
|
133
|
+
self.assertIn("SUCCEEDED each time", text)
|
|
134
|
+
self.assertIn("will not change", text)
|
|
135
|
+
# Wrong-diagnosis wording from the other branch must not appear.
|
|
136
|
+
self.assertNotIn("failing action", text)
|
|
137
|
+
self.assertNotIn("api.github.com", text)
|
|
138
|
+
|
|
139
|
+
def test_tool_choice_is_released_so_a_text_turn_is_possible(self):
|
|
140
|
+
# Without this the model is still coerced into calling a tool, which is
|
|
141
|
+
# the loop it is being asked to leave.
|
|
142
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
143
|
+
for _ in range(4):
|
|
144
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|git diff --stat")
|
|
145
|
+
self.assertEqual(self.inject(mon)["tool_choice"], "auto")
|
|
146
|
+
|
|
147
|
+
def test_failing_loop_keeps_its_original_directive(self):
|
|
148
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
149
|
+
for _ in range(self.proxy.PROXY_STUCK_API_THRESHOLD):
|
|
150
|
+
mon.note_tool_arg_hosts(["https://api.github.com/repos/x/y"])
|
|
151
|
+
text = self.inject(mon)["messages"][0]["content"]
|
|
152
|
+
self.assertIn("failing action", text)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
unittest.main()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestStreakSurvivesAFreshMonitor(unittest.TestCase):
|
|
160
|
+
"""The guards counted server-side state that silently resets.
|
|
161
|
+
|
|
162
|
+
The monitor is keyed `fp:<hash of the first user message>` when the client
|
|
163
|
+
sends no session header — and opencode sends none. Compaction, a re-summarised
|
|
164
|
+
opening turn, or a proxy restart therefore starts a FRESH monitor with empty
|
|
165
|
+
history, and every streak restarts at zero no matter how long the real loop is.
|
|
166
|
+
|
|
167
|
+
The conversation is re-sent whole each turn, so the streak is derivable from
|
|
168
|
+
the request. These tests pin that.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def setUpClass(cls):
|
|
173
|
+
cls.proxy = load_proxy()
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def convo(n, cmd="git diff --stat"):
|
|
177
|
+
"""A conversation containing `n` identical assistant tool calls."""
|
|
178
|
+
msgs = [{"role": "user", "content": "check the diff"}]
|
|
179
|
+
for i in range(n):
|
|
180
|
+
msgs.append({"role": "assistant", "content": [
|
|
181
|
+
{"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
|
|
182
|
+
msgs.append({"role": "user", "content": [
|
|
183
|
+
{"type": "tool_result", "tool_use_id": f"t{i}", "content": "1 file changed"}]})
|
|
184
|
+
return msgs
|
|
185
|
+
|
|
186
|
+
def test_a_fresh_monitor_still_sees_the_loop(self):
|
|
187
|
+
# THE RESIDUAL: brand-new monitor, 44-turn loop already in the transcript.
|
|
188
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
189
|
+
self.assertEqual(mon.tool_call_history, [])
|
|
190
|
+
self.proxy._seed_tool_history_from_request(mon, self.convo(44))
|
|
191
|
+
should, reason = mon.should_force_stuck_break()
|
|
192
|
+
self.assertTrue(should, "a reset monitor must not erase a live loop")
|
|
193
|
+
self.assertIn("identical tool call", reason)
|
|
194
|
+
|
|
195
|
+
def test_it_only_extends_never_double_counts(self):
|
|
196
|
+
# The incremental path appends one fingerprint per request; seeding must
|
|
197
|
+
# not stack on top of that and inflate the streak.
|
|
198
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
199
|
+
for _ in range(6):
|
|
200
|
+
mon.record_tool_calls(tool_names=["Bash"], fingerprint="Bash|x")
|
|
201
|
+
before = list(mon.tool_call_history)
|
|
202
|
+
self.proxy._seed_tool_history_from_request(mon, self.convo(2))
|
|
203
|
+
self.assertEqual(mon.tool_call_history, before)
|
|
204
|
+
|
|
205
|
+
def test_varied_history_is_reconstructed_without_tripping(self):
|
|
206
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
207
|
+
msgs = [{"role": "user", "content": "go"}]
|
|
208
|
+
for i, cmd in enumerate(["ls", "npm test", "git status", "npm run build", "ls -la"]):
|
|
209
|
+
msgs.append({"role": "assistant", "content": [
|
|
210
|
+
{"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": cmd}}]})
|
|
211
|
+
self.proxy._seed_tool_history_from_request(mon, msgs)
|
|
212
|
+
# 5 assistant turns, 4 seeded: the last is deliberately left for the
|
|
213
|
+
# incremental path to append, so the current turn is not counted twice.
|
|
214
|
+
self.assertEqual(len(mon.tool_call_history), 4)
|
|
215
|
+
should, _ = mon.should_force_stuck_break()
|
|
216
|
+
self.assertFalse(should)
|
|
217
|
+
|
|
218
|
+
def test_it_is_bounded(self):
|
|
219
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
220
|
+
self.proxy._seed_tool_history_from_request(mon, self.convo(200))
|
|
221
|
+
self.assertLessEqual(len(mon.tool_call_history), 30)
|
|
222
|
+
|
|
223
|
+
def test_malformed_input_is_survivable(self):
|
|
224
|
+
mon = self.proxy.SessionMonitor(context_window=100000)
|
|
225
|
+
for bad in (None, "not-a-list", [], [None, 3, {"role": "assistant"}],
|
|
226
|
+
[{"role": "assistant", "content": "plain text"}]):
|
|
227
|
+
self.proxy._seed_tool_history_from_request(mon, bad)
|
|
228
|
+
self.assertEqual(mon.tool_call_history, [])
|