@miller-tech/uap 1.210.5 → 1.210.7
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/cli/schema-diff.d.ts.map +1 -1
- package/dist/cli/schema-diff.js +43 -0
- package/dist/cli/schema-diff.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/schema_diff_gate.py +25 -1
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +184 -8
- package/tools/agents/tests/test_cycle_break_wait_poll.py +293 -0
- package/tools/agents/tests/test_schema_diff_gate.py +54 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""A tool that BLOCKS on a long job must not be loop-broken for repeating.
|
|
2
|
+
|
|
3
|
+
Incident (2026-08-17): an agent was following a healthy `deliver` run — heartbeat
|
|
4
|
+
8s old, run checkpoint advancing, genuinely compiling a Rust crate. `deliver` in
|
|
5
|
+
follow mode blocks up to its wait budget and returns "still running", so the
|
|
6
|
+
correct behaviour is to call it again; every poll therefore carries identical
|
|
7
|
+
arguments and an identical fingerprint.
|
|
8
|
+
|
|
9
|
+
Three separate guards read those repeats as a spin:
|
|
10
|
+
|
|
11
|
+
1. should_force_stuck_break — fires FIRST, at 4 identical calls, judged on
|
|
12
|
+
the fingerprint alone, and injects "STOP ... do NOT repeat it".
|
|
13
|
+
2. the cycle path — enters review, increments review cycles, and
|
|
14
|
+
at the review-cycle limit forces a "wrap up" finalize turn.
|
|
15
|
+
3. cycle-break narrowing — excluded the deliver tool outright, after which
|
|
16
|
+
the agent could no longer observe the work it was waiting on and fell
|
|
17
|
+
through to Bash, cycling on THAT instead. Median turn spacing collapsed
|
|
18
|
+
from the 45s poll interval to 5s.
|
|
19
|
+
|
|
20
|
+
Unlike a cycling Bash ("vary the command"), there is no different argument to
|
|
21
|
+
vary toward: the job is simply not finished yet. The wait is bounded by the job.
|
|
22
|
+
"""
|
|
23
|
+
import importlib.util
|
|
24
|
+
import os
|
|
25
|
+
import unittest
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_proxy(wait_tools=None):
|
|
32
|
+
# Explicit, so a project .uap/proxy.env cannot inject PROXY_WAIT_POLL_TOOLS
|
|
33
|
+
# and silently invalidate the default-list assertions when this module is
|
|
34
|
+
# run directly rather than through the npm script.
|
|
35
|
+
os.environ["UAP_PROXY_ENV_AUTOLOAD"] = "0"
|
|
36
|
+
if wait_tools is None:
|
|
37
|
+
os.environ.pop("PROXY_WAIT_POLL_TOOLS", None)
|
|
38
|
+
else:
|
|
39
|
+
os.environ["PROXY_WAIT_POLL_TOOLS"] = wait_tools
|
|
40
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_waitpoll", proxy_path)
|
|
41
|
+
mod = importlib.util.module_from_spec(spec)
|
|
42
|
+
spec.loader.exec_module(mod)
|
|
43
|
+
return mod
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool(name):
|
|
47
|
+
return {"type": "function", "function": {"name": name, "parameters": {}}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The toolset from the live incident, trimmed to the relevant members.
|
|
51
|
+
TOOLS = [tool(n) for n in ("uap-router_deliver", "bash", "read", "edit", "glob")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_PRIOR_AUTOLOAD = os.environ.get("UAP_PROXY_ENV_AUTOLOAD")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tearDownModule():
|
|
58
|
+
# Restore rather than leak into any module loaded later in this process —
|
|
59
|
+
# cross-test env leakage from files like this one has caused
|
|
60
|
+
# order-dependent failures before.
|
|
61
|
+
if _PRIOR_AUTOLOAD is None:
|
|
62
|
+
os.environ.pop("UAP_PROXY_ENV_AUTOLOAD", None)
|
|
63
|
+
else:
|
|
64
|
+
os.environ["UAP_PROXY_ENV_AUTOLOAD"] = _PRIOR_AUTOLOAD
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Base(unittest.TestCase):
|
|
68
|
+
def tearDown(self):
|
|
69
|
+
os.environ.pop("PROXY_WAIT_POLL_TOOLS", None)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _names(tools):
|
|
73
|
+
return [t["function"]["name"] for t in tools]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WaitPollNotNarrowedTest(_Base):
|
|
77
|
+
def test_the_polled_tool_survives_the_cycle_break(self):
|
|
78
|
+
ap = load_proxy()
|
|
79
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["uap-router_deliver"], [])
|
|
80
|
+
self.assertIn("uap-router_deliver", self._names(narrowed))
|
|
81
|
+
|
|
82
|
+
def test_a_genuinely_spinning_tool_is_still_narrowed(self):
|
|
83
|
+
ap = load_proxy()
|
|
84
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["edit"], [])
|
|
85
|
+
self.assertNotIn("edit", self._names(narrowed))
|
|
86
|
+
|
|
87
|
+
def test_matching_is_exact_not_substring(self):
|
|
88
|
+
# A tool whose name merely CONTAINS an exempt name must still be
|
|
89
|
+
# narrowed — otherwise the exemption silently widens to anything
|
|
90
|
+
# someone names "deliver_report".
|
|
91
|
+
ap = load_proxy()
|
|
92
|
+
tools = [tool("deliver_report"), tool("bash"), tool("edit")]
|
|
93
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(tools, ["deliver_report"], [])
|
|
94
|
+
self.assertNotIn("deliver_report", self._names(narrowed))
|
|
95
|
+
|
|
96
|
+
def test_the_polled_tool_is_never_auto_banned(self):
|
|
97
|
+
ap = load_proxy()
|
|
98
|
+
self.assertFalse(ap._should_auto_ban("uap-router_deliver", cycle_count=99, ban_at=3))
|
|
99
|
+
self.assertTrue(ap._should_auto_ban("edit", cycle_count=99, ban_at=3))
|
|
100
|
+
|
|
101
|
+
def test_an_explicit_session_ban_is_still_honoured(self):
|
|
102
|
+
ap = load_proxy()
|
|
103
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, [], ["uap-router_deliver"])
|
|
104
|
+
self.assertNotIn("uap-router_deliver", self._names(narrowed))
|
|
105
|
+
|
|
106
|
+
def test_the_set_is_configurable(self):
|
|
107
|
+
ap = load_proxy(wait_tools="my_wait_tool")
|
|
108
|
+
self.assertIn("my_wait_tool", ap._WAIT_POLL_TOOLS)
|
|
109
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(
|
|
110
|
+
[tool("my_wait_tool"), tool("edit")], ["my_wait_tool"], []
|
|
111
|
+
)
|
|
112
|
+
self.assertIn("my_wait_tool", self._names(narrowed))
|
|
113
|
+
|
|
114
|
+
def test_matching_is_case_insensitive(self):
|
|
115
|
+
ap = load_proxy()
|
|
116
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(
|
|
117
|
+
[tool("UAP-Router_Deliver"), tool("edit")], ["UAP-Router_Deliver"], []
|
|
118
|
+
)
|
|
119
|
+
self.assertIn("UAP-Router_Deliver", self._names(narrowed))
|
|
120
|
+
|
|
121
|
+
def test_exploration_hatch_still_exempt(self):
|
|
122
|
+
ap = load_proxy()
|
|
123
|
+
narrowed, _ = ap._narrow_tools_for_cycle_break(TOOLS, ["bash"], [])
|
|
124
|
+
self.assertIn("bash", self._names(narrowed))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeliverWireNamesTest(_Base):
|
|
128
|
+
def test_both_mcp_config_keys_are_covered(self):
|
|
129
|
+
# Names arrive prefixed with the MCP server's CONFIG KEY, and this repo
|
|
130
|
+
# ships two: `uap-router` (.mcp.json / opencode.json) and `router`
|
|
131
|
+
# (setup-mcp-router.ts, used by `uap setup`). Covering one exempts only
|
|
132
|
+
# half the fleet.
|
|
133
|
+
ap = load_proxy()
|
|
134
|
+
for name in ("uap-router_deliver", "mcp__uap-router__deliver",
|
|
135
|
+
"router_deliver", "mcp__router__deliver", "deliver"):
|
|
136
|
+
self.assertIn(name, ap._WAIT_POLL_TOOLS, name)
|
|
137
|
+
|
|
138
|
+
def test_deliver_counts_as_a_write_under_every_wire_name(self):
|
|
139
|
+
# _WRITE_TOOL_CLASS with only the bare name let the no-write streak
|
|
140
|
+
# climb through a healthy delivery, escalating recon-convergence
|
|
141
|
+
# mid-wait ("write your deliverable now") while the deliverable was
|
|
142
|
+
# being written by the run being waited on.
|
|
143
|
+
ap = load_proxy()
|
|
144
|
+
for name in ap._DELIVER_TOOL_NAMES:
|
|
145
|
+
self.assertIn(name, ap._WRITE_TOOL_CLASS, name)
|
|
146
|
+
|
|
147
|
+
def test_wait_poll_and_write_classes_deliberately_overlap(self):
|
|
148
|
+
# Other tool classes in this module are pairwise disjoint; this pair is
|
|
149
|
+
# not, and the intent is pinned here rather than left implicit.
|
|
150
|
+
ap = load_proxy()
|
|
151
|
+
self.assertTrue(ap._WAIT_POLL_TOOLS & ap._WRITE_TOOL_CLASS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StuckBreakTest(_Base):
|
|
155
|
+
"""The guard that fires FIRST — 4 identical calls, outcome-blind."""
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _monitor(ap, history):
|
|
159
|
+
m = ap.SessionMonitor(context_window=131072)
|
|
160
|
+
m.tool_call_history = list(history)
|
|
161
|
+
return m
|
|
162
|
+
|
|
163
|
+
def test_a_healthy_poll_does_not_force_a_stuck_break(self):
|
|
164
|
+
ap = load_proxy()
|
|
165
|
+
m = self._monitor(ap, ["uap-router_deliver:abc"] * 8)
|
|
166
|
+
forced, reason = m.should_force_stuck_break()
|
|
167
|
+
self.assertFalse(forced, reason)
|
|
168
|
+
|
|
169
|
+
def test_a_genuinely_repeated_call_still_forces_a_break(self):
|
|
170
|
+
# The 44-turn `git diff --stat` loop this guard exists for.
|
|
171
|
+
ap = load_proxy()
|
|
172
|
+
m = self._monitor(ap, ["bash:diffstat"] * 8)
|
|
173
|
+
forced, _ = m.should_force_stuck_break()
|
|
174
|
+
self.assertTrue(forced)
|
|
175
|
+
|
|
176
|
+
def test_a_wait_mixed_with_a_spin_still_breaks(self):
|
|
177
|
+
# all-not-any: a turn calling deliver alongside a spinning tool is
|
|
178
|
+
# still a spin and must stay breakable.
|
|
179
|
+
ap = load_proxy()
|
|
180
|
+
m = self._monitor(ap, ["uap-router_deliver:abc|bash:x"] * 8)
|
|
181
|
+
forced, _ = m.should_force_stuck_break()
|
|
182
|
+
self.assertTrue(forced)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class StateMachineEndToEndTest(_Base):
|
|
186
|
+
"""Drive real polls through the state machine — the test that catches the
|
|
187
|
+
guard the helper-level tests miss.
|
|
188
|
+
|
|
189
|
+
The first cut of this fix exempted narrowing, the stuck-break and the cycle
|
|
190
|
+
trip, and STILL force-finalized a healthy wait: the stagnation signal is
|
|
191
|
+
keyed on repeats (`latest_fingerprint == last_fingerprint`) and its only
|
|
192
|
+
reset needs a turn WITHOUT a tool_result, which a poll always has. It
|
|
193
|
+
climbed one per poll, entered review at 9, and hit the review-cycle limit at
|
|
194
|
+
16 with "wrap up ... what is blocking further progress" — mid-build.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
FP = "uap-router_deliver:abc"
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _poll(ap, monitor, fingerprint, msgs):
|
|
201
|
+
"""One agent turn: record the call, then run the state machine.
|
|
202
|
+
|
|
203
|
+
`msgs` GROWS across turns. A constant-length message list reads as a
|
|
204
|
+
compaction boundary ("message count collapsed"), which resets all
|
|
205
|
+
anti-spin state every turn and would make this harness silently prove
|
|
206
|
+
nothing.
|
|
207
|
+
"""
|
|
208
|
+
monitor.record_tool_calls(["deliver"], fingerprint=fingerprint)
|
|
209
|
+
ap._update_tool_state_stagnation(
|
|
210
|
+
monitor, latest_tool_fingerprint=fingerprint, last_user_has_tool_result=True
|
|
211
|
+
)
|
|
212
|
+
msgs.append({"role": "assistant", "content": "calling"})
|
|
213
|
+
msgs.append({"role": "user", "content": "STILL RUNNING"})
|
|
214
|
+
body = {"messages": list(msgs), "tools": [tool("uap-router_deliver")]}
|
|
215
|
+
# A poll always carries the previous poll's "still running" tool_result,
|
|
216
|
+
# which is precisely why the stagnation reset never fires for it.
|
|
217
|
+
return ap._resolve_state_machine_tool_choice(
|
|
218
|
+
body, monitor, has_tool_results=True, last_user_has_tool_result=True
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_a_healthy_wait_is_never_force_finalized(self):
|
|
222
|
+
ap = load_proxy()
|
|
223
|
+
m = ap.SessionMonitor(context_window=131072)
|
|
224
|
+
msgs = [{"role": "user", "content": "start"}]
|
|
225
|
+
for i in range(30):
|
|
226
|
+
self._poll(ap, m, self.FP, msgs)
|
|
227
|
+
self.assertNotEqual(
|
|
228
|
+
m.tool_turn_phase, "finalize",
|
|
229
|
+
f"forced a finalize on poll {i + 1} of a healthy wait",
|
|
230
|
+
)
|
|
231
|
+
self.assertEqual(m.tool_state_stagnation_streak, 0)
|
|
232
|
+
self.assertEqual(m.tool_state_review_cycles, 0)
|
|
233
|
+
|
|
234
|
+
def test_a_wedged_job_is_still_bounded(self):
|
|
235
|
+
# The exemption is call-side and outcome-blind, so it MUST be capped:
|
|
236
|
+
# a dead run polled forever would otherwise be bounded only by the
|
|
237
|
+
# client's own timeout.
|
|
238
|
+
ap = load_proxy()
|
|
239
|
+
ap.PROXY_WAIT_POLL_MAX_STREAK = 5
|
|
240
|
+
m = ap.SessionMonitor(context_window=131072)
|
|
241
|
+
msgs = [{"role": "user", "content": "start"}]
|
|
242
|
+
for _ in range(40):
|
|
243
|
+
self._poll(ap, m, self.FP, msgs)
|
|
244
|
+
self.assertGreater(
|
|
245
|
+
m.tool_state_stagnation_streak, 0,
|
|
246
|
+
"past the cap the normal guards must resume",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def test_a_real_spin_still_stagnates(self):
|
|
250
|
+
ap = load_proxy()
|
|
251
|
+
m = ap.SessionMonitor(context_window=131072)
|
|
252
|
+
msgs = [{"role": "user", "content": "start"}]
|
|
253
|
+
for _ in range(10):
|
|
254
|
+
self._poll(ap, m, "bash:samecmd", msgs)
|
|
255
|
+
self.assertGreater(m.tool_state_stagnation_streak, 0)
|
|
256
|
+
|
|
257
|
+
def test_the_streak_resets_when_the_agent_does_something_else(self):
|
|
258
|
+
ap = load_proxy()
|
|
259
|
+
m = ap.SessionMonitor(context_window=131072)
|
|
260
|
+
msgs = [{"role": "user", "content": "start"}]
|
|
261
|
+
for _ in range(5):
|
|
262
|
+
self._poll(ap, m, self.FP, msgs)
|
|
263
|
+
self.assertEqual(m.wait_poll_streak, 5)
|
|
264
|
+
self._poll(ap, m, "edit:file", msgs)
|
|
265
|
+
self.assertEqual(m.wait_poll_streak, 0)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class HelperTest(_Base):
|
|
269
|
+
def test_fingerprint_names_are_unpacked(self):
|
|
270
|
+
ap = load_proxy()
|
|
271
|
+
self.assertEqual(
|
|
272
|
+
ap._fingerprint_tool_names("uap-router_deliver:abc123|bash:def"),
|
|
273
|
+
{"uap-router_deliver", "bash"},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def test_is_wait_poll_only_requires_all(self):
|
|
277
|
+
ap = load_proxy()
|
|
278
|
+
self.assertTrue(ap._is_wait_poll_only({"uap-router_deliver"}))
|
|
279
|
+
self.assertFalse(ap._is_wait_poll_only({"uap-router_deliver", "bash"}))
|
|
280
|
+
self.assertFalse(ap._is_wait_poll_only(set()))
|
|
281
|
+
|
|
282
|
+
def test_hint_names_drop_wait_tools_but_keep_spinners(self):
|
|
283
|
+
# The load-bearing mixed case: the hint must still fire, naming only
|
|
284
|
+
# the tool that is actually spinning.
|
|
285
|
+
ap = load_proxy()
|
|
286
|
+
self.assertEqual(
|
|
287
|
+
ap._spinning_cycling_names(["uap-router_deliver", "read"]), ["read"]
|
|
288
|
+
)
|
|
289
|
+
self.assertEqual(ap._spinning_cycling_names(["uap-router_deliver"]), [])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
unittest.main()
|
|
@@ -113,3 +113,57 @@ class SchemaDiffGateTest(unittest.TestCase):
|
|
|
113
113
|
|
|
114
114
|
if __name__ == "__main__":
|
|
115
115
|
unittest.main()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MergeVerbatimTest(unittest.TestCase):
|
|
119
|
+
"""2026-08-16 pay2u #3153 incident: a conflict-resolution merge staged
|
|
120
|
+
migration files that arrived VERBATIM from the already-merged base branch,
|
|
121
|
+
and the gate demanded a schema-diff re-pass for content this branch never
|
|
122
|
+
authored. merge_verbatim() must exempt staged files whose blob equals the
|
|
123
|
+
MERGE_HEAD version — and must NOT exempt files the merge actually edited."""
|
|
124
|
+
|
|
125
|
+
def setUp(self):
|
|
126
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="schema-gate-merge-")
|
|
127
|
+
self.root = Path(self._tmp.name)
|
|
128
|
+
g = lambda *a: subprocess.run(
|
|
129
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t", *a],
|
|
130
|
+
cwd=self.root, check=True, capture_output=True,
|
|
131
|
+
)
|
|
132
|
+
g("init", "-q", "-b", "main")
|
|
133
|
+
(self.root / "migrations").mkdir()
|
|
134
|
+
(self.root / "base.txt").write_text("base")
|
|
135
|
+
g("add", "-A")
|
|
136
|
+
g("commit", "-q", "-m", "init")
|
|
137
|
+
# Feature branch diverges without touching migrations.
|
|
138
|
+
g("checkout", "-q", "-b", "feature")
|
|
139
|
+
(self.root / "feature.txt").write_text("feature work")
|
|
140
|
+
g("add", "-A")
|
|
141
|
+
g("commit", "-q", "-m", "feature")
|
|
142
|
+
# Main gains a watched migration (reviewed there).
|
|
143
|
+
g("checkout", "-q", "main")
|
|
144
|
+
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id int);")
|
|
145
|
+
g("add", "-A")
|
|
146
|
+
g("commit", "-q", "-m", "migration on main")
|
|
147
|
+
# Merge main INTO feature: migration arrives verbatim, merge left open
|
|
148
|
+
# (no commit) so MERGE_HEAD exists and the file is staged.
|
|
149
|
+
g("checkout", "-q", "feature")
|
|
150
|
+
subprocess.run(
|
|
151
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t", "merge", "--no-commit", "--no-ff", "main"],
|
|
152
|
+
cwd=self.root, check=True, capture_output=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def tearDown(self):
|
|
156
|
+
self._tmp.cleanup()
|
|
157
|
+
|
|
158
|
+
def test_verbatim_incoming_migration_is_exempt(self):
|
|
159
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
160
|
+
self.assertTrue(allowed, f"verbatim merge-incoming migration should not gate: {reason}")
|
|
161
|
+
|
|
162
|
+
def test_merge_edited_migration_still_gates(self):
|
|
163
|
+
# Editing the migration during the merge makes it THIS branch's change.
|
|
164
|
+
(self.root / "migrations" / "001_add_table.sql").write_text("CREATE TABLE t (id bigint);")
|
|
165
|
+
subprocess.run(["git", "add", "migrations/001_add_table.sql"], cwd=self.root, check=True)
|
|
166
|
+
_, allowed, reason = run_gate("git-commit", {"command": "git commit"}, self.root)
|
|
167
|
+
self.assertFalse(allowed, "a migration edited during the merge must still gate")
|
|
168
|
+
self.assertIn("schema-diff", reason)
|
|
169
|
+
|