@miller-tech/uap 1.119.0 → 1.119.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 +1 -1
- package/src/policies/enforcers/__pycache__/_common.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 +69 -15
- package/tools/agents/tests/test_recon_deliver_gate.py +64 -0
- package/tools/agents/tests/test_stuck_break_reattach.py +67 -0
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -380,6 +380,13 @@ _WRITE_TOOL_CLASS = frozenset({
|
|
|
380
380
|
# directives into a loop that was mid-build — observed live at
|
|
381
381
|
# no_write_streak=62 with the model then emitting files as plain text.
|
|
382
382
|
"write_file", "edit_file", "save_file",
|
|
383
|
+
# A: `deliver` is the ONLY write path when delivery-enforcement gates
|
|
384
|
+
# direct Edit/Write. Counting it as a write means (a) a deliver call
|
|
385
|
+
# resets the no-write streak instead of looking like more exploration,
|
|
386
|
+
# and (b) the recon-convergence restore loop re-injects `deliver` when
|
|
387
|
+
# narrowing dropped it, so a gated "route through deliver" directive is
|
|
388
|
+
# actually satisfiable.
|
|
389
|
+
"deliver",
|
|
383
390
|
})
|
|
384
391
|
|
|
385
392
|
# Open-ended exploration tools the agent uses to make a DIFFERENT move once a
|
|
@@ -614,6 +621,15 @@ def _maybe_normalize_toolcall_paths(anthropic_resp: dict, request_body: dict) ->
|
|
|
614
621
|
PROXY_RECON_CONVERGENCE_THRESHOLD = int(
|
|
615
622
|
os.environ.get("PROXY_RECON_CONVERGENCE_THRESHOLD", "40")
|
|
616
623
|
)
|
|
624
|
+
# Fix C: the hard tier historically fired at 2x the base threshold (80 turns
|
|
625
|
+
# at the default 40) -- far too late; the model burns ~40 extra turns reading
|
|
626
|
+
# before the stronger "STOP, write now / release tool_choice" directive kicks
|
|
627
|
+
# in. This multiplier makes the hard-tier onset configurable. 1.5x (60 turns)
|
|
628
|
+
# escalates sooner while still leaving the firm tier a working window. Clamped
|
|
629
|
+
# to >= 1.0 so the hard tier can never precede the firm tier.
|
|
630
|
+
PROXY_RECON_HARD_MULTIPLIER = max(1.0, float(
|
|
631
|
+
os.environ.get("PROXY_RECON_HARD_MULTIPLIER", "1.5")
|
|
632
|
+
))
|
|
617
633
|
# Fix E: the recon hard tier (streak >= 2x threshold) fires a directive + flips
|
|
618
634
|
# tool_choice to 'auto', but `consecutive_no_write_turns` resets to 0 whenever
|
|
619
635
|
# the model emits any write tool — so a loop that periodically writes sawtooths
|
|
@@ -4304,11 +4320,24 @@ def _resolve_state_machine_tool_choice(
|
|
|
4304
4320
|
|
|
4305
4321
|
|
|
4306
4322
|
def _writes_are_gated(openai_body: dict) -> bool:
|
|
4307
|
-
"""True when
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4323
|
+
"""True when the harness is BLOCKING direct writes (delivery-enforcement),
|
|
4324
|
+
so a "write the file" directive is futile and the model must route through
|
|
4325
|
+
the deliver tool instead. Aligns the recon directive with the gate to break
|
|
4326
|
+
the read-forever / can't-write deadlock.
|
|
4327
|
+
|
|
4328
|
+
Two detection paths:
|
|
4329
|
+
* reactive -- a recent tool_result shows a write was actually blocked
|
|
4330
|
+
(last 8 messages); and
|
|
4331
|
+
* proactive (A) -- the harness gate banner is present anywhere in the
|
|
4332
|
+
context (system / injected-user prompt). A session stuck reading
|
|
4333
|
+
forever NEVER attempts a doomed write, so the reactive path never
|
|
4334
|
+
fires and the deliver redirect never triggers -- the loop is
|
|
4335
|
+
permanent. The proactive path fires the redirect on turn 1 of the
|
|
4336
|
+
streak. Phrases matched are stable harness strings, not model output.
|
|
4337
|
+
"""
|
|
4338
|
+
msgs = openai_body.get("messages") or []
|
|
4339
|
+
# Reactive: a recent turn was actually blocked by the gate.
|
|
4340
|
+
for m in msgs[-8:]:
|
|
4312
4341
|
c = m.get("content")
|
|
4313
4342
|
text = c if isinstance(c, str) else (json.dumps(c) if c else "")
|
|
4314
4343
|
low = text.lower()
|
|
@@ -4316,6 +4345,24 @@ def _writes_are_gated(openai_body: dict) -> bool:
|
|
|
4316
4345
|
"blocked" in low and "deliver" in low and "tool" in low
|
|
4317
4346
|
):
|
|
4318
4347
|
return True
|
|
4348
|
+
# Proactive (A): the gate banner is present in context (esp. the system /
|
|
4349
|
+
# UserPromptSubmit-injected prompt announcing "route through deliver").
|
|
4350
|
+
# Bounded scan: the banner lives in the system prompt(s) or the recent
|
|
4351
|
+
# window, never deep in history -- so we only serialize/lower those, not
|
|
4352
|
+
# the whole (66k-token) transcript on this stuck-path hot request.
|
|
4353
|
+
system_msgs = [m for m in msgs if m.get("role") == "system"]
|
|
4354
|
+
for m in system_msgs + msgs[-8:]:
|
|
4355
|
+
if m.get("role") not in ("system", "user"):
|
|
4356
|
+
continue
|
|
4357
|
+
c = m.get("content")
|
|
4358
|
+
text = c if isinstance(c, str) else (json.dumps(c) if c else "")
|
|
4359
|
+
low = text.lower()
|
|
4360
|
+
if (
|
|
4361
|
+
"gated and will be blocked" in low
|
|
4362
|
+
or "route through deliver" in low
|
|
4363
|
+
or ("direct edit/write" in low and "gated" in low)
|
|
4364
|
+
):
|
|
4365
|
+
return True
|
|
4319
4366
|
return False
|
|
4320
4367
|
|
|
4321
4368
|
|
|
@@ -4341,11 +4388,14 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
|
|
|
4341
4388
|
"operator the single blocking question in one sentence. Take a DIFFERENT "
|
|
4342
4389
|
"action now."
|
|
4343
4390
|
)
|
|
4344
|
-
msgs = openai_body.get("messages")
|
|
4391
|
+
msgs = openai_body.get("messages")
|
|
4392
|
+
if not isinstance(msgs, list):
|
|
4393
|
+
msgs = []
|
|
4345
4394
|
if msgs and msgs[0].get("role") == "system":
|
|
4346
4395
|
msgs[0]["content"] = (msgs[0].get("content") or "") + directive
|
|
4347
4396
|
else:
|
|
4348
4397
|
msgs.insert(0, {"role": "system", "content": directive.strip()})
|
|
4398
|
+
openai_body["messages"] = msgs # reattach in case messages was empty/absent
|
|
4349
4399
|
logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
|
|
4350
4400
|
|
|
4351
4401
|
|
|
@@ -4416,7 +4466,8 @@ def _maybe_inject_recon_convergence(
|
|
|
4416
4466
|
observed failure mode of an agentic recon task wandering for hundreds
|
|
4417
4467
|
of turns and never converging to the synthesis/write step. Two
|
|
4418
4468
|
escalation tiers: a firm "switch to synthesis" directive, then a hard
|
|
4419
|
-
"STOP, write it now" once the streak
|
|
4469
|
+
"STOP, write it now" once the streak crosses PROXY_RECON_HARD_MULTIPLIER
|
|
4470
|
+
x threshold (default 1.5x).
|
|
4420
4471
|
|
|
4421
4472
|
`full_tools` is the request's tool list *before* `_narrow_tools_for_request`
|
|
4422
4473
|
pruned it. When the directive fires, any write/deliverable tool that
|
|
@@ -4433,7 +4484,7 @@ def _maybe_inject_recon_convergence(
|
|
|
4433
4484
|
# Report the *raw* (pre-prune) utilization — post-prune util understates the
|
|
4434
4485
|
# blow-up (~30%) and makes the directive's "context is at X%" misleading.
|
|
4435
4486
|
util = max(monitor.get_utilization(), monitor.get_raw_utilization())
|
|
4436
|
-
hard = streak >=
|
|
4487
|
+
hard = streak >= PROXY_RECON_HARD_MULTIPLIER * PROXY_RECON_CONVERGENCE_THRESHOLD
|
|
4437
4488
|
escalate = False
|
|
4438
4489
|
if hard:
|
|
4439
4490
|
monitor.recon_hard_fires += 1 # Fix E: monotonic, never reset
|
|
@@ -4509,13 +4560,16 @@ def _maybe_inject_recon_convergence(
|
|
|
4509
4560
|
# writing it rebuilds the streak and re-fires — bounded, not permanent.
|
|
4510
4561
|
monitor.consecutive_no_write_turns = 0
|
|
4511
4562
|
else:
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4563
|
+
# Fix C (hard) + Fix B (firm): drop the structural "must call a tool"
|
|
4564
|
+
# coercion at BOTH directive tiers. The state machine forces
|
|
4565
|
+
# tool_choice='required' during the active agentic loop; paired with a
|
|
4566
|
+
# "switch to synthesis / write your deliverable now" directive that is a
|
|
4567
|
+
# trap -- the model cannot terminate and, when it does not pick the
|
|
4568
|
+
# write tool, emits another read, so the streak climbs unbounded.
|
|
4569
|
+
# Releasing to 'auto' lets it write, call deliver, or stop. (Previously
|
|
4570
|
+
# only the hard tier released; the firm tier left the coercion on, which
|
|
4571
|
+
# is where the observed read-forever loop lived.)
|
|
4572
|
+
if openai_body.get("tool_choice") == "required" or hard:
|
|
4519
4573
|
openai_body["tool_choice"] = "auto"
|
|
4520
4574
|
openai_body.pop("grammar", None)
|
|
4521
4575
|
# Re-inject any write/deliverable tool that narrowing dropped, so the
|
|
@@ -41,5 +41,69 @@ class ReconDirectiveTest(unittest.TestCase):
|
|
|
41
41
|
self.assertNotIn("being BLOCKED by policy", d)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
|
|
45
|
+
class ProactiveGateDetectionTest(unittest.TestCase):
|
|
46
|
+
"""A: gate detected from the harness banner BEFORE any write is attempted.
|
|
47
|
+
|
|
48
|
+
A read-forever session never triggers the reactive (blocked tool_result)
|
|
49
|
+
path, so the deliver redirect must fire from the gate banner in context."""
|
|
50
|
+
|
|
51
|
+
def test_system_banner_route_through_deliver(self):
|
|
52
|
+
body = {"messages": [
|
|
53
|
+
{"role": "system", "content": "Writing code — route through deliver. Use the deliver tool."},
|
|
54
|
+
{"role": "user", "content": "read the next file"},
|
|
55
|
+
]}
|
|
56
|
+
self.assertTrue(ap._writes_are_gated(body))
|
|
57
|
+
|
|
58
|
+
def test_gated_and_will_be_blocked_banner(self):
|
|
59
|
+
body = {"messages": [
|
|
60
|
+
{"role": "user", "content": "direct Edit/Write on source files is gated and will be blocked"},
|
|
61
|
+
]}
|
|
62
|
+
self.assertTrue(ap._writes_are_gated(body))
|
|
63
|
+
|
|
64
|
+
def test_no_banner_and_no_block_is_ungated(self):
|
|
65
|
+
body = {"messages": [
|
|
66
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
67
|
+
{"role": "user", "content": "read the next file"},
|
|
68
|
+
]}
|
|
69
|
+
self.assertFalse(ap._writes_are_gated(body))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DeliverIsWriteToolTest(unittest.TestCase):
|
|
73
|
+
"""A: deliver is the only write path under a gate; it must count as a write."""
|
|
74
|
+
|
|
75
|
+
def test_deliver_in_write_tool_class(self):
|
|
76
|
+
self.assertIn("deliver", ap._WRITE_TOOL_CLASS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FirmTierReleasesToolChoiceTest(unittest.TestCase):
|
|
80
|
+
"""B: the firm tier must release the 'required' coercion so the model can
|
|
81
|
+
actually write / call deliver / stop instead of being forced into a read."""
|
|
82
|
+
|
|
83
|
+
def test_firm_tier_releases_required(self):
|
|
84
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
85
|
+
m.consecutive_no_write_turns = ap.PROXY_RECON_CONVERGENCE_THRESHOLD # firm
|
|
86
|
+
body = {"messages": [{"role": "user", "content": "reading"}],
|
|
87
|
+
"tools": [{"name": "Write"}], "tool_choice": "required"}
|
|
88
|
+
ap._maybe_inject_recon_convergence(body, m)
|
|
89
|
+
self.assertEqual(body.get("tool_choice"), "auto")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HardMultiplierTest(unittest.TestCase):
|
|
93
|
+
"""C: hard-tier onset is governed by the configurable multiplier (default
|
|
94
|
+
1.5x, earlier than the old hard-coded 2x)."""
|
|
95
|
+
|
|
96
|
+
def test_multiplier_invariant(self):
|
|
97
|
+
self.assertGreaterEqual(ap.PROXY_RECON_HARD_MULTIPLIER, 1.0)
|
|
98
|
+
|
|
99
|
+
def test_hard_tier_directive_at_multiplier(self):
|
|
100
|
+
m = ap.SessionMonitor(context_window=132096)
|
|
101
|
+
streak = int(ap.PROXY_RECON_HARD_MULTIPLIER * ap.PROXY_RECON_CONVERGENCE_THRESHOLD) + 1
|
|
102
|
+
m.consecutive_no_write_turns = streak
|
|
103
|
+
body = {"messages": [{"role": "user", "content": "reading"}], "tools": [{"name": "Write"}]}
|
|
104
|
+
ap._maybe_inject_recon_convergence(body, m)
|
|
105
|
+
self.assertIn("STOP", body["messages"][-1]["content"])
|
|
106
|
+
|
|
107
|
+
|
|
44
108
|
if __name__ == "__main__":
|
|
45
109
|
unittest.main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression: the STUCK-BREAK injector built `msgs = openai_body.get("messages")
|
|
3
|
+
or []`, which returns a DETACHED empty list when messages is [], so the injected
|
|
4
|
+
directive never landed in the outbound request. Real requests always carry
|
|
5
|
+
messages, but the injector must be robust — it now reattaches the list.
|
|
6
|
+
Mirrors the fix already applied to _maybe_inject_deferral_break.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_proxy_module():
|
|
15
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
16
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
17
|
+
assert spec is not None and spec.loader is not None
|
|
18
|
+
module = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(module)
|
|
20
|
+
return module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
proxy = _load_proxy_module()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _firing_monitor():
|
|
27
|
+
mon = proxy.SessionMonitor()
|
|
28
|
+
# Trip the self-reported-stuck signal so the injector fires.
|
|
29
|
+
mon.self_stuck_streak = proxy.PROXY_STUCK_TEXT_THRESHOLD + 1
|
|
30
|
+
return mon
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestStuckBreakReattach(unittest.TestCase):
|
|
34
|
+
def test_empty_messages_list_still_receives_directive(self):
|
|
35
|
+
mon = _firing_monitor()
|
|
36
|
+
body = {"tool_choice": "required", "messages": []}
|
|
37
|
+
proxy._maybe_inject_stuck_break(body, mon)
|
|
38
|
+
self.assertTrue(body["messages"], "directive must land in the request")
|
|
39
|
+
self.assertEqual(body["messages"][0]["role"], "system")
|
|
40
|
+
self.assertIn("STOP", body["messages"][0]["content"])
|
|
41
|
+
self.assertEqual(body["tool_choice"], "auto") # coercion released
|
|
42
|
+
|
|
43
|
+
def test_absent_messages_key_is_created(self):
|
|
44
|
+
mon = _firing_monitor()
|
|
45
|
+
body = {"tool_choice": "required"} # no "messages" key at all
|
|
46
|
+
proxy._maybe_inject_stuck_break(body, mon)
|
|
47
|
+
self.assertIn("messages", body)
|
|
48
|
+
self.assertIn("STOP", body["messages"][0]["content"])
|
|
49
|
+
|
|
50
|
+
def test_existing_system_message_is_appended(self):
|
|
51
|
+
mon = _firing_monitor()
|
|
52
|
+
body = {"messages": [{"role": "system", "content": "SYS"}]}
|
|
53
|
+
proxy._maybe_inject_stuck_break(body, mon)
|
|
54
|
+
self.assertEqual(len(body["messages"]), 1)
|
|
55
|
+
self.assertTrue(body["messages"][0]["content"].startswith("SYS"))
|
|
56
|
+
self.assertIn("STOP", body["messages"][0]["content"])
|
|
57
|
+
|
|
58
|
+
def test_does_not_fire_when_not_stuck(self):
|
|
59
|
+
mon = proxy.SessionMonitor() # no streak
|
|
60
|
+
body = {"tool_choice": "required", "messages": []}
|
|
61
|
+
proxy._maybe_inject_stuck_break(body, mon)
|
|
62
|
+
self.assertEqual(body["messages"], [])
|
|
63
|
+
self.assertEqual(body["tool_choice"], "required")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
unittest.main()
|