@miller-tech/uap 1.210.5 → 1.210.6

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.210.5",
3
+ "version": "1.210.6",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "start": "node dist/bin/cli.js",
22
22
  "test": "vitest",
23
23
  "test:ci": "vitest run",
24
- "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs",
24
+ "test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll",
25
25
  "test:coverage": "vitest --coverage",
26
26
  "bench": "vitest --config vitest.bench.config.ts",
27
27
  "lint": "eslint src --ext .ts",
@@ -396,6 +396,14 @@ PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3")
396
396
  # strategy, so this fires on the fingerprint alone, independent of outcome.
397
397
  # PROXY_REPEAT_CALL_THRESHOLD=0 disables.
398
398
  PROXY_REPEAT_CALL_THRESHOLD = int(os.environ.get("PROXY_REPEAT_CALL_THRESHOLD", "4"))
399
+ # How many CONSECUTIVE wait/poll turns stay exempt from the loop guards before
400
+ # the guards resume. The exemption is call-side and outcome-blind — a poll of a
401
+ # healthy run and a poll of a wedged one are byte-identical from here — so
402
+ # without a cap a dead job polled forever would be bounded only by the client's
403
+ # own timeout. At the 45s follow interval this is ~30 minutes of waiting before
404
+ # the proxy insists on a checkpoint, comfortably longer than a normal build turn
405
+ # and far shorter than a stuck mission. 0 disables the cap.
406
+ PROXY_WAIT_POLL_MAX_STREAK = int(os.environ.get("PROXY_WAIT_POLL_MAX_STREAK", "40"))
399
407
  # Marker so the injected directive can address a SUCCEEDING loop correctly
400
408
  # rather than telling the model to stop retrying "a failing action".
401
409
  _REPEAT_CALL_REASON = "identical tool call"
@@ -729,6 +737,18 @@ _READ_ONLY_TOOL_CLASS = frozenset({
729
737
  # open-ended set of tools (Bash, WebFetch, Agent, ...) that cannot be
730
738
  # enumerated, but "the agent produced a write" is a small, stable signal.
731
739
  # Names are matched case-insensitively (callers lower() before lookup).
740
+ # Every wire name `deliver` arrives under. Tool names reach this proxy verbatim
741
+ # from the client, prefixed with the MCP server's CONFIG KEY — and this repo
742
+ # ships two: `.mcp.json`/`opencode.json` register it as `uap-router`, while
743
+ # `uap setup` (setup-mcp-router.ts, for Claude/Factory/VSCode/Cursor) registers
744
+ # it as `router`. Enumerating only one of them silently exempts half the fleet,
745
+ # which is why this is ONE constant rather than three hand-maintained lists.
746
+ _DELIVER_TOOL_NAMES = frozenset({
747
+ "deliver",
748
+ "uap-router_deliver", "uap-router__deliver", "mcp__uap-router__deliver",
749
+ "router_deliver", "mcp__router__deliver",
750
+ })
751
+
732
752
  _WRITE_TOOL_CLASS = frozenset({
733
753
  "write", "edit", "multiedit", "notebookedit",
734
754
  "str_replace", "str_replace_editor", "str_replace_based_edit_tool",
@@ -746,7 +766,13 @@ _WRITE_TOOL_CLASS = frozenset({
746
766
  # and (b) the recon-convergence restore loop re-injects `deliver` when
747
767
  # narrowing dropped it, so a gated "route through deliver" directive is
748
768
  # actually satisfiable.
749
- "deliver",
769
+ #
770
+ # The PREFIXED forms matter as much as the bare one: the wire name is
771
+ # `uap-router_deliver`, so with only "deliver" here the no-write streak kept
772
+ # climbing through a healthy delivery and recon-convergence escalated
773
+ # mid-wait — stripping reads and demanding "write your deliverable now"
774
+ # while the deliverable was being written by the run it was waiting on.
775
+ *_DELIVER_TOOL_NAMES,
750
776
  })
751
777
 
752
778
  # Open-ended exploration tools the agent uses to make a DIFFERENT move once a
@@ -763,6 +789,60 @@ _EXPLORATION_ESCAPE_TOOLS = frozenset({
763
789
  "agent", "task", "dispatch_agent",
764
790
  })
765
791
 
792
+ # NEVER strip these either, for the same reason and a sharper one: they BLOCK on
793
+ # a long-running job and return "still running" when their wait budget expires,
794
+ # so the correct agent behaviour is to call them again. That is a WAIT, not a
795
+ # spin — but it is indistinguishable from one by fingerprint, since every poll
796
+ # carries identical arguments.
797
+ #
798
+ # Observed live 2026-08-17: an agent following a healthy `deliver` run (heartbeat
799
+ # 8s old, checkpoint advancing) polled it as designed. The cycle-breaker read the
800
+ # repeats as a loop, excluded the deliver tool, and the agent — now unable to
801
+ # observe the work it was waiting on — fell through to Bash and cycled on THAT
802
+ # instead. Median turn spacing collapsed from the 45s poll interval to 5s. The
803
+ # loop the breaker "found" was the one it created.
804
+ #
805
+ # Stripping the tool cannot help here: unlike a cycling Bash, there is no
806
+ # different argument to vary toward. The job is simply not finished yet.
807
+ _WAIT_POLL_TOOLS = frozenset(
808
+ n.strip().lower()
809
+ for n in os.environ.get(
810
+ "PROXY_WAIT_POLL_TOOLS",
811
+ ",".join(sorted(_DELIVER_TOOL_NAMES | {"await_run", "wait"})),
812
+ ).split(",")
813
+ if n.strip()
814
+ )
815
+
816
+
817
+ def _fingerprint_tool_names(fingerprint: str) -> set:
818
+ """Tool names inside a tool-call fingerprint.
819
+
820
+ Fingerprints are "name" or "name:arghash", joined by "|" for a multi-call
821
+ turn — the same shape the cycle path already unpacks when it builds
822
+ cycling_tool_names.
823
+ """
824
+ names = set()
825
+ for part in (fingerprint or "").split("|"):
826
+ part = part.strip()
827
+ if part:
828
+ names.add(part.split(":")[0].lower())
829
+ return names
830
+
831
+
832
+ def _spinning_cycling_names(cycling_tool_names) -> list:
833
+ """The cycling names that are genuinely spinning — wait/poll tools removed."""
834
+ return [n for n in (cycling_tool_names or []) if str(n).lower() not in _WAIT_POLL_TOOLS]
835
+
836
+
837
+ def _is_wait_poll_only(names) -> bool:
838
+ """True when EVERY name given is a wait/poll tool (and there is at least one).
839
+
840
+ Deliberately all-not-any: a turn that calls `deliver` alongside a genuinely
841
+ spinning tool is still a spin, and must stay breakable.
842
+ """
843
+ lowered = {str(n).lower() for n in names if str(n).strip()}
844
+ return bool(lowered) and lowered <= _WAIT_POLL_TOOLS
845
+
766
846
 
767
847
  def _narrow_tools_for_cycle_break(tools, cycling_tool_names, session_banned_tools):
768
848
  """Drop cycling + session-banned tools from the toolset, expanding to the
@@ -788,8 +868,10 @@ def _narrow_tools_for_cycle_break(tools, cycling_tool_names, session_banned_tool
788
868
  cycling_exclude |= read_only_lower
789
869
  # Never let the cycling path narrow away the exploration escape hatch — that
790
870
  # is exactly the filesystem-exploration capability the cycle-break is trying
791
- # to redirect the agent toward.
871
+ # to redirect the agent toward. Wait/poll tools are spared for the stronger
872
+ # reason that their repeats are not a loop at all (see _WAIT_POLL_TOOLS).
792
873
  cycling_exclude -= _EXPLORATION_ESCAPE_TOOLS
874
+ cycling_exclude -= _WAIT_POLL_TOOLS
793
875
  exclude_set = cycling_exclude | banned_lower
794
876
 
795
877
  def _name(t):
@@ -824,6 +906,11 @@ def _should_auto_ban(name, cycle_count, ban_at):
824
906
  """
825
907
  if name.lower() in _EXPLORATION_ESCAPE_TOOLS:
826
908
  return False
909
+ # A wait/poll tool accrues cycle counts fastest of all — every poll of a
910
+ # long job looks identical — so without this it is the FIRST tool banned,
911
+ # permanently, for doing exactly what it is for.
912
+ if name.lower() in _WAIT_POLL_TOOLS:
913
+ return False
827
914
  return cycle_count >= ban_at
828
915
 
829
916
  PROXY_GUARDRAIL_RETRY = os.environ.get("PROXY_GUARDRAIL_RETRY", "on").lower() not in {
@@ -1784,6 +1871,9 @@ class SessionMonitor:
1784
1871
  tool_state_forced_budget_remaining: int = 0
1785
1872
  tool_state_auto_budget_remaining: int = 0
1786
1873
  tool_state_stagnation_streak: int = 0
1874
+ # Consecutive turns whose tool calls were ALL wait/poll tools. Bounds the
1875
+ # loop-guard exemption so a wedged job cannot be polled forever.
1876
+ wait_poll_streak: int = 0
1787
1877
  tool_state_transitions: int = 0
1788
1878
  tool_state_review_cycles: int = 0
1789
1879
  tool_state_unproductive_exhaustion_streak: int = 0
@@ -1974,6 +2064,12 @@ class SessionMonitor:
1974
2064
  and self.coordination_repeat_streak >= PROXY_COORDINATION_EARLY_BAN
1975
2065
  ):
1976
2066
  for n in coord_names:
2067
+ # This path adds to session_banned_tools directly, bypassing
2068
+ # _should_auto_ban — and an explicit ban IS honoured even for
2069
+ # exempt tools, so a wait/poll tool reaching here would be
2070
+ # stripped for the rest of the session.
2071
+ if n.lower() in _WAIT_POLL_TOOLS:
2072
+ continue
1977
2073
  if n not in self.session_banned_tools:
1978
2074
  self.session_banned_tools.add(n)
1979
2075
  logger.warning(
@@ -2054,6 +2150,20 @@ class SessionMonitor:
2054
2150
  else:
2055
2151
  self.rate_limited_api_streak = 0
2056
2152
 
2153
+ def wait_poll_exempt(self) -> bool:
2154
+ """True when this turn is a bounded wait on a long-running job.
2155
+
2156
+ Bounded: `wait_poll_streak` is capped by PROXY_WAIT_POLL_MAX_STREAK, so
2157
+ a wedged job — indistinguishable from a healthy one at the call site —
2158
+ eventually falls back under the normal guards.
2159
+ """
2160
+ last_fp = self.tool_call_history[-1] if self.tool_call_history else ""
2161
+ if not _is_wait_poll_only(_fingerprint_tool_names(last_fp)):
2162
+ return False
2163
+ if PROXY_WAIT_POLL_MAX_STREAK <= 0:
2164
+ return True
2165
+ return self.wait_poll_streak <= PROXY_WAIT_POLL_MAX_STREAK
2166
+
2057
2167
  def should_force_stuck_break(self) -> tuple[bool, str]:
2058
2168
  """True + reason when a terminal break should be forced this turn."""
2059
2169
  if not PROXY_STUCK_BREAK:
@@ -2069,7 +2179,16 @@ class SessionMonitor:
2069
2179
  if PROXY_REPEAT_CALL_THRESHOLD > 0:
2070
2180
  looping, count = self.detect_tool_loop(window=PROXY_REPEAT_CALL_THRESHOLD)
2071
2181
  if looping and count >= PROXY_REPEAT_CALL_THRESHOLD:
2072
- return True, f"{_REPEAT_CALL_REASON} x{count}"
2182
+ # ...unless the repeated call is a WAIT. This branch is
2183
+ # outcome-blind by design (see above), which is right for a
2184
+ # `git diff --stat` loop and wrong for a tool that blocks on a
2185
+ # long job and returns "still running": every poll is identical
2186
+ # by construction, so this fires ~4 polls in — earlier than the
2187
+ # cycle path — and tells the agent "you already have that
2188
+ # output, do NOT repeat it" while the job is still running.
2189
+ # The wait is bounded by the job, not by the agent.
2190
+ if not self.wait_poll_exempt():
2191
+ return True, f"{_REPEAT_CALL_REASON} x{count}"
2073
2192
  return False, ""
2074
2193
 
2075
2194
  def note_deferral_signal(self, text: str, had_tool_call: bool) -> None:
@@ -5414,6 +5533,26 @@ def _update_tool_state_stagnation(
5414
5533
  repeated = latest_tool_fingerprint == monitor.last_tool_fingerprint
5415
5534
  recently_seen = latest_tool_fingerprint in monitor.tool_call_history[-4:-1]
5416
5535
 
5536
+ # THE choke point for the wait/poll exemption. This signal is keyed on
5537
+ # repeats alone — `repeated` above is a fingerprint comparison — and a poll
5538
+ # of a long job repeats by construction, while its "still running" reply is
5539
+ # a tool_result, so the only reset guard never fires either. Left unchecked
5540
+ # the streak climbs one per poll straight through a healthy wait and the
5541
+ # state machine enters review, accrues review cycles, and force-finalizes
5542
+ # with "wrap up ... what is blocking further progress" — mid-build.
5543
+ #
5544
+ # Exempting HERE rather than at each consumer covers the stagnation
5545
+ # finalize, the review-cycle limit and the phase flip in one place.
5546
+ if _is_wait_poll_only(_fingerprint_tool_names(latest_tool_fingerprint)):
5547
+ monitor.wait_poll_streak += 1
5548
+ if PROXY_WAIT_POLL_MAX_STREAK <= 0 or monitor.wait_poll_streak <= PROXY_WAIT_POLL_MAX_STREAK:
5549
+ monitor.last_tool_fingerprint = latest_tool_fingerprint
5550
+ return
5551
+ # Past the cap the guards resume: a wedged job looks exactly like a
5552
+ # healthy one from here, so the exemption must not be unbounded.
5553
+ else:
5554
+ monitor.wait_poll_streak = 0
5555
+
5417
5556
  if repeated or recently_seen:
5418
5557
  monitor.tool_state_stagnation_streak += 1
5419
5558
  else:
@@ -5576,6 +5715,24 @@ def _resolve_state_machine_tool_choice(
5576
5715
  # kept as a strong signal (read target repeated 3+ times). Low-repeat
5577
5716
  # cycles detected by detect_tool_cycle get filtered here.
5578
5717
  cycle_trip = cycle_looping and cycle_repeat >= PROXY_CYCLE_TRIGGER_REPEAT
5718
+ # A cycle made up ENTIRELY of wait/poll calls is a wait, not a spin.
5719
+ # Letting it trip still costs the session even with the tool exempted
5720
+ # from narrowing: entering review increments tool_state_review_cycles,
5721
+ # and at PROXY_TOOL_STATE_REVIEW_CYCLE_LIMIT the machine forces a
5722
+ # finalize turn telling the agent to "wrap up" — mid-wait.
5723
+ #
5724
+ # Judged over the SAME window the detector used: a shorter slice lets an
5725
+ # A/B oscillation between a wait tool and a real one be suppressed
5726
+ # whenever the last turns happen to be waits. And only `cycle_trip` is
5727
+ # cleared, never `cycle_looping` — that flag also carries the
5728
+ # duplicate-read-target signal, which is not fingerprint-derived, and
5729
+ # clearing it flips a downstream branch into counting the turn as
5730
+ # unproductive (arming a different finalize).
5731
+ _cycle_window = max(2, PROXY_TOOL_STATE_CYCLE_WINDOW)
5732
+ if cycle_trip and _is_wait_poll_only(
5733
+ _fingerprint_tool_names("|".join(monitor.tool_call_history[-_cycle_window:]))
5734
+ ):
5735
+ cycle_trip = False
5579
5736
  if cycle_trip or stagnating:
5580
5737
  reason = "cycle_detected" if cycle_looping else "stagnation"
5581
5738
  monitor.set_tool_turn_phase("review", reason=reason)
@@ -5635,7 +5792,15 @@ def _resolve_state_machine_tool_choice(
5635
5792
 
5636
5793
  if monitor.tool_state_forced_budget_remaining <= 0:
5637
5794
  monitor.set_tool_turn_phase("review", reason="forced_budget_exhausted")
5638
- if cycle_looping or stagnating:
5795
+ if monitor.wait_poll_exempt():
5796
+ # A bounded wait is neither a cycle nor an unproductive burn:
5797
+ # both arms below lead to a forced finalize (review-cycle limit
5798
+ # at 3, unproductive-exhaustion at 2), and reaching either
5799
+ # mid-wait tells the agent to wrap up a job that is still
5800
+ # running. Bounded by PROXY_WAIT_POLL_MAX_STREAK, so a wedged
5801
+ # run falls back into normal accounting.
5802
+ monitor.tool_state_unproductive_exhaustion_streak = 0
5803
+ elif cycle_looping or stagnating:
5639
5804
  monitor.tool_state_review_cycles += 1
5640
5805
  monitor.tool_state_unproductive_exhaustion_streak = 0
5641
5806
  else:
@@ -6986,9 +7151,16 @@ def build_openai_request(
6986
7151
  if (
6987
7152
  monitor.tool_turn_phase == "review"
6988
7153
  and state_reason in {"cycle_detected", "stagnation"}
6989
- and monitor.cycling_tool_names
7154
+ # A hint naming ONLY wait/poll tools would read "stop calling
7155
+ # deliver, produce your final answer now" while the job it is
7156
+ # waiting on is still running — an instruction to abandon the
7157
+ # wait and claim a result the agent does not have yet. Hint on
7158
+ # the genuinely-spinning tools, or say nothing.
7159
+ and _spinning_cycling_names(monitor.cycling_tool_names)
6990
7160
  ):
6991
- cycling_names = ", ".join(monitor.cycling_tool_names)
7161
+ cycling_names = ", ".join(
7162
+ _spinning_cycling_names(monitor.cycling_tool_names)
7163
+ )
6992
7164
  cycles = monitor.tool_state_review_cycles
6993
7165
  if cycles <= 1:
6994
7166
  cycle_hint = (
@@ -7030,8 +7202,12 @@ def build_openai_request(
7030
7202
  )
7031
7203
  if narrowed:
7032
7204
  openai_body["tools"] = narrowed
7033
- # Only log on first activation or phase transitions to reduce noise
7034
- if state_reason in {"cycle_detected", "stagnation"}:
7205
+ # Only log on first activation or phase transitions to reduce noise.
7206
+ # `len(narrowed) < original_count` keeps an exempt-only cycle
7207
+ # from logging "narrowed tools from 5 to 5", which reads as a
7208
+ # cycle-break having happened when nothing was excluded —
7209
+ # exactly the line that would mislead triage of this incident.
7210
+ if state_reason in {"cycle_detected", "stagnation"} and len(narrowed) < original_count:
7035
7211
  logger.warning(
7036
7212
  "CYCLE BREAK: narrowed tools from %d to %d (excluded %s, read_only_class=%s)",
7037
7213
  original_count,
@@ -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()