@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.119.0",
3
+ "version": "1.119.2",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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 recent tool results show the harness is BLOCKING direct writes
4308
- (delivery-enforcement), so a "write the file" directive is futile and the
4309
- model must route through the deliver tool instead. #1: aligns the recon
4310
- directive with the gate to break the read-forever / can't-write deadlock."""
4311
- for m in (openai_body.get("messages") or [])[-8:]:
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") or []
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 is 2x over threshold.
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 >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD
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
- if hard:
4513
- # Fix C: at the hard tier, drop the structural requirement to call a
4514
- # tool. Earlier logic forced tool_choice='required' for the active
4515
- # agentic loop, which directly contradicts "produce your deliverable
4516
- # NOW / do not run anything else" the model is forbidden from
4517
- # terminating and must emit yet another tool call, so the streak
4518
- # climbs unbounded. Releasing to 'auto' lets it actually write/stop.
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()