@miller-tech/uap 1.20.50 → 1.20.51

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.20.50",
3
+ "version": "1.20.51",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3288,7 +3288,11 @@ def _resolve_state_machine_tool_choice(
3288
3288
  return None, "unknown_phase"
3289
3289
 
3290
3290
 
3291
- def _maybe_inject_recon_convergence(openai_body: dict, monitor: "SessionMonitor") -> None:
3291
+ def _maybe_inject_recon_convergence(
3292
+ openai_body: dict,
3293
+ monitor: "SessionMonitor",
3294
+ full_tools: list[dict] | None = None,
3295
+ ) -> None:
3292
3296
  """Nudge a session stuck in prolonged exploration toward its deliverable.
3293
3297
 
3294
3298
  Fires when `consecutive_no_write_turns` crosses
@@ -3298,6 +3302,13 @@ def _maybe_inject_recon_convergence(openai_body: dict, monitor: "SessionMonitor"
3298
3302
  of turns and never converging to the synthesis/write step. Two
3299
3303
  escalation tiers: a firm "switch to synthesis" directive, then a hard
3300
3304
  "STOP, write it now" once the streak is 2x over threshold.
3305
+
3306
+ `full_tools` is the request's tool list *before* `_narrow_tools_for_request`
3307
+ pruned it. When the directive fires, any write/deliverable tool that
3308
+ narrowing dropped is re-injected into `openai_body["tools"]` — narrowing
3309
+ scores tools against the (exploration-heavy) recon prompt and runs before
3310
+ this guardrail, so it routinely strips the very write tool the directive
3311
+ tells the model to use, leaving the directive impossible to satisfy.
3301
3312
  """
3302
3313
  if PROXY_RECON_CONVERGENCE_THRESHOLD <= 0:
3303
3314
  return
@@ -3327,9 +3338,28 @@ def _maybe_inject_recon_convergence(openai_body: dict, monitor: "SessionMonitor"
3327
3338
  msgs = openai_body.get("messages", [])
3328
3339
  msgs.append({"role": "user", "content": directive})
3329
3340
  openai_body["messages"] = msgs
3341
+
3342
+ # Re-inject any write/deliverable tool that narrowing dropped, so the
3343
+ # "write your deliverable" directive is actually satisfiable. Without
3344
+ # this the model is told to write but has no write tool to call, picks
3345
+ # another read tool, and the streak climbs unbounded.
3346
+ restored: list[str] = []
3347
+ if full_tools:
3348
+ present = {
3349
+ (t.get("function", {}).get("name", "") or "").lower()
3350
+ for t in openai_body.get("tools", [])
3351
+ }
3352
+ for tool in full_tools:
3353
+ name = (tool.get("function", {}).get("name", "") or "")
3354
+ if name.lower() in _WRITE_TOOL_CLASS and name.lower() not in present:
3355
+ openai_body.setdefault("tools", []).append(tool)
3356
+ present.add(name.lower())
3357
+ restored.append(name)
3358
+
3330
3359
  logger.warning(
3331
- "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, ctx=%.0f%%)",
3332
- tier, streak, util * 100,
3360
+ "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, ctx=%.0f%%, "
3361
+ "restored_write_tools=%s)",
3362
+ tier, streak, util * 100, restored or "none",
3333
3363
  )
3334
3364
 
3335
3365
 
@@ -3575,10 +3605,14 @@ def build_openai_request(
3575
3605
  )
3576
3606
 
3577
3607
  # Convert Anthropic tools to OpenAI function-calling tools
3608
+ full_openai_tools: list[dict] = []
3578
3609
  if has_tools:
3579
3610
  openai_body["tools"] = _convert_anthropic_tools_to_openai(
3580
3611
  anthropic_body.get("tools", [])
3581
3612
  )
3613
+ # Keep the full (pre-narrowing) list so the recon-convergence
3614
+ # guardrail can restore a write tool that narrowing dropped.
3615
+ full_openai_tools = openai_body["tools"]
3582
3616
  openai_body["tools"] = _narrow_tools_for_request(
3583
3617
  anthropic_body, openai_body["tools"]
3584
3618
  )
@@ -3842,8 +3876,9 @@ def build_openai_request(
3842
3876
 
3843
3877
  # Recon-convergence guardrail (B1) — runs on every built request so a
3844
3878
  # session wandering in exploration without producing a write is nudged
3845
- # toward its deliverable regardless of tool-turn phase.
3846
- _maybe_inject_recon_convergence(openai_body, monitor)
3879
+ # toward its deliverable regardless of tool-turn phase. Passed the full
3880
+ # pre-narrowing toolset so it can restore a dropped write tool.
3881
+ _maybe_inject_recon_convergence(openai_body, monitor, full_openai_tools)
3847
3882
 
3848
3883
  return openai_body
3849
3884
 
@@ -5479,6 +5479,84 @@ class TestReconConvergence(unittest.TestCase):
5479
5479
  proxy._maybe_inject_recon_convergence(body, m)
5480
5480
  self.assertEqual(len(body["messages"]), 1)
5481
5481
 
5482
+ @staticmethod
5483
+ def _tool(name: str) -> dict:
5484
+ return {"type": "function", "function": {"name": name, "description": f"{name} tool"}}
5485
+
5486
+ def test_dropped_write_tool_is_restored_when_directive_fires(self):
5487
+ """The core fix: if narrowing left no write tool in the request,
5488
+ a firing directive re-injects it from the full pre-narrowing set."""
5489
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5490
+ m = proxy.SessionMonitor(context_window=131072)
5491
+ m.consecutive_no_write_turns = 45
5492
+ # narrowed toolset — exploration tools only, no write tool
5493
+ body = {
5494
+ "messages": [{"role": "user", "content": "go"}],
5495
+ "tools": [self._tool("Read"), self._tool("Grep"), self._tool("Bash")],
5496
+ }
5497
+ # full pre-narrowing set DID include a write tool
5498
+ full = body["tools"] + [self._tool("Edit")]
5499
+ proxy._maybe_inject_recon_convergence(body, m, full)
5500
+ names = [t["function"]["name"] for t in body["tools"]]
5501
+ self.assertIn("Edit", names)
5502
+
5503
+ def test_present_write_tool_not_duplicated(self):
5504
+ """If a write tool already survived narrowing, it is not added twice."""
5505
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5506
+ m = proxy.SessionMonitor(context_window=131072)
5507
+ m.consecutive_no_write_turns = 45
5508
+ body = {
5509
+ "messages": [{"role": "user", "content": "go"}],
5510
+ "tools": [self._tool("Read"), self._tool("Edit")],
5511
+ }
5512
+ full = list(body["tools"])
5513
+ proxy._maybe_inject_recon_convergence(body, m, full)
5514
+ names = [t["function"]["name"] for t in body["tools"]]
5515
+ self.assertEqual(names.count("Edit"), 1)
5516
+
5517
+ def test_no_write_tool_anywhere_is_safe(self):
5518
+ """A recon agent whose toolset has no write tool at all: nothing to
5519
+ restore, no crash."""
5520
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5521
+ m = proxy.SessionMonitor(context_window=131072)
5522
+ m.consecutive_no_write_turns = 45
5523
+ body = {
5524
+ "messages": [{"role": "user", "content": "go"}],
5525
+ "tools": [self._tool("Read"), self._tool("Bash")],
5526
+ }
5527
+ proxy._maybe_inject_recon_convergence(body, m, list(body["tools"]))
5528
+ names = [t["function"]["name"] for t in body["tools"]]
5529
+ self.assertEqual(names, ["Read", "Bash"])
5530
+
5531
+ def test_full_tools_omitted_is_safe(self):
5532
+ """Called without full_tools (default None) — directive still fires,
5533
+ no tool restoration attempted, no crash."""
5534
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5535
+ m = proxy.SessionMonitor(context_window=131072)
5536
+ m.consecutive_no_write_turns = 45
5537
+ body = {
5538
+ "messages": [{"role": "user", "content": "go"}],
5539
+ "tools": [self._tool("Read")],
5540
+ }
5541
+ proxy._maybe_inject_recon_convergence(body, m)
5542
+ self.assertEqual(len(body["messages"]), 2)
5543
+ self.assertEqual([t["function"]["name"] for t in body["tools"]], ["Read"])
5544
+
5545
+ def test_no_restore_below_threshold(self):
5546
+ """Below threshold the directive does not fire, so no write tool is
5547
+ restored even if narrowing dropped one."""
5548
+ proxy.PROXY_RECON_CONVERGENCE_THRESHOLD = 40
5549
+ m = proxy.SessionMonitor(context_window=131072)
5550
+ m.consecutive_no_write_turns = 39
5551
+ body = {
5552
+ "messages": [{"role": "user", "content": "go"}],
5553
+ "tools": [self._tool("Read")],
5554
+ }
5555
+ full = body["tools"] + [self._tool("Write")]
5556
+ proxy._maybe_inject_recon_convergence(body, m, full)
5557
+ names = [t["function"]["name"] for t in body["tools"]]
5558
+ self.assertEqual(names, ["Read"])
5559
+
5482
5560
 
5483
5561
  class TestPrunerRework(unittest.TestCase):
5484
5562
  """Tests for the reworked context pruner (B2 + B3): contiguous