@miller-tech/uap 1.210.4 → 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.4",
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",
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 {
@@ -1453,12 +1540,48 @@ def _apply_json_response_grammar(openai_body: dict, anthropic_body: dict) -> Non
1453
1540
  if openai_body.get("tools") or openai_body.get("grammar"):
1454
1541
  return
1455
1542
  openai_body["grammar"] = JSON_RESPONSE_GBNF
1456
- openai_body["enable_thinking"] = False
1457
- ctk = openai_body.setdefault("chat_template_kwargs", {})
1458
- ctk["enable_thinking"] = False
1543
+ _set_thinking(openai_body, False)
1459
1544
  logger.info("JSON-RESPONSE grammar applied (evaluator verdict turn)")
1460
1545
 
1461
1546
 
1547
+ def _set_thinking(body: dict, enabled: bool) -> None:
1548
+ """Turn upstream reasoning on/off, in BOTH places llama.cpp reads.
1549
+
1550
+ The top-level `enable_thinking` field alone does NOTHING on a `--jinja`
1551
+ server: the Qwen chat template reads `chat_template_kwargs`, and this one is
1552
+ launched with `--chat-template-kwargs {"enable_thinking": true}`, so an
1553
+ unqualified top-level flag is silently overridden by the template default.
1554
+
1555
+ Measured on the live server, identical tool-call prompt:
1556
+ top-level only -> 703 chars of reasoning, 196 tokens
1557
+ chat_template_kwargs -> 0 chars, 27 tokens
1558
+ and on a planning-flavoured prompt with max_tokens=700, reasoning consumed
1559
+ the ENTIRE budget and no tool call was emitted at all.
1560
+
1561
+ The OPERATOR switches — PROXY_DISABLE_THINKING_ALWAYS and
1562
+ PROXY_DISABLE_THINKING_ON_TOOL_TURNS, plus the tool-turn breakers — set only
1563
+ the top-level field, so they were no-ops against a jinja server: they
1564
+ existed, logged themselves as active, and changed nothing. The paths that
1565
+ already set both (the JSON-verdict grammar, the empty-max_tokens retry, and
1566
+ the prefill/continuation turn) worked, which is why evaluator turns were
1567
+ unaffected and this went unnoticed elsewhere.
1568
+
1569
+ Use this for a switch that expresses INTENT ("this turn must not reason").
1570
+ The Anthropic protocol-default translation deliberately does NOT use it —
1571
+ see the comment there; making a default authoritative would change serving
1572
+ policy for every client rather than fix a broken control.
1573
+ """
1574
+ body["enable_thinking"] = enabled
1575
+ # REPLACE the nested dict rather than mutate it: callers build retry bodies
1576
+ # with `dict(openai_body)`, a SHALLOW copy that still shares this dict, so
1577
+ # mutating in place would silently disable thinking on the original request
1578
+ # too. Copying keeps any other template kwargs the server was launched with.
1579
+ existing = body.get("chat_template_kwargs")
1580
+ ctk = dict(existing) if isinstance(existing, dict) else {}
1581
+ ctk["enable_thinking"] = enabled
1582
+ body["chat_template_kwargs"] = ctk
1583
+
1584
+
1462
1585
  def _apply_thinking_grammar(request_body: dict) -> None:
1463
1586
  """Apply the structured-thinking GBNF grammar to non-tool turns.
1464
1587
 
@@ -1547,6 +1670,13 @@ def _apply_profile_overrides(
1547
1670
  if "stop_sequences" in profile:
1548
1671
  updated["stop_sequences"] = profile["stop_sequences"]
1549
1672
  if "enable_thinking" in profile:
1673
+ # Left as a plain assignment: `updated` is the ANTHROPIC-shaped body,
1674
+ # and build_openai_request constructs the upstream body from a fresh
1675
+ # literal that never reads this key — so this has never reached the
1676
+ # wire, and writing a llama.cpp template kwarg into an Anthropic body
1677
+ # would only add a field that passthrough requests must not carry.
1678
+ # Wiring profiles to thinking is a separate change from making the
1679
+ # operator switches work.
1550
1680
  updated["enable_thinking"] = profile["enable_thinking"]
1551
1681
 
1552
1682
  tool_call_batching = profile.get("tool_call_batching") or {}
@@ -1741,6 +1871,9 @@ class SessionMonitor:
1741
1871
  tool_state_forced_budget_remaining: int = 0
1742
1872
  tool_state_auto_budget_remaining: int = 0
1743
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
1744
1877
  tool_state_transitions: int = 0
1745
1878
  tool_state_review_cycles: int = 0
1746
1879
  tool_state_unproductive_exhaustion_streak: int = 0
@@ -1931,6 +2064,12 @@ class SessionMonitor:
1931
2064
  and self.coordination_repeat_streak >= PROXY_COORDINATION_EARLY_BAN
1932
2065
  ):
1933
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
1934
2073
  if n not in self.session_banned_tools:
1935
2074
  self.session_banned_tools.add(n)
1936
2075
  logger.warning(
@@ -2011,6 +2150,20 @@ class SessionMonitor:
2011
2150
  else:
2012
2151
  self.rate_limited_api_streak = 0
2013
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
+
2014
2167
  def should_force_stuck_break(self) -> tuple[bool, str]:
2015
2168
  """True + reason when a terminal break should be forced this turn."""
2016
2169
  if not PROXY_STUCK_BREAK:
@@ -2026,7 +2179,16 @@ class SessionMonitor:
2026
2179
  if PROXY_REPEAT_CALL_THRESHOLD > 0:
2027
2180
  looping, count = self.detect_tool_loop(window=PROXY_REPEAT_CALL_THRESHOLD)
2028
2181
  if looping and count >= PROXY_REPEAT_CALL_THRESHOLD:
2029
- 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}"
2030
2192
  return False, ""
2031
2193
 
2032
2194
  def note_deferral_signal(self, text: str, had_tool_call: bool) -> None:
@@ -5371,6 +5533,26 @@ def _update_tool_state_stagnation(
5371
5533
  repeated = latest_tool_fingerprint == monitor.last_tool_fingerprint
5372
5534
  recently_seen = latest_tool_fingerprint in monitor.tool_call_history[-4:-1]
5373
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
+
5374
5556
  if repeated or recently_seen:
5375
5557
  monitor.tool_state_stagnation_streak += 1
5376
5558
  else:
@@ -5533,6 +5715,24 @@ def _resolve_state_machine_tool_choice(
5533
5715
  # kept as a strong signal (read target repeated 3+ times). Low-repeat
5534
5716
  # cycles detected by detect_tool_cycle get filtered here.
5535
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
5536
5736
  if cycle_trip or stagnating:
5537
5737
  reason = "cycle_detected" if cycle_looping else "stagnation"
5538
5738
  monitor.set_tool_turn_phase("review", reason=reason)
@@ -5592,7 +5792,15 @@ def _resolve_state_machine_tool_choice(
5592
5792
 
5593
5793
  if monitor.tool_state_forced_budget_remaining <= 0:
5594
5794
  monitor.set_tool_turn_phase("review", reason="forced_budget_exhausted")
5595
- 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:
5596
5804
  monitor.tool_state_review_cycles += 1
5597
5805
  monitor.tool_state_unproductive_exhaustion_streak = 0
5598
5806
  else:
@@ -6453,14 +6661,26 @@ def build_openai_request(
6453
6661
  # consume the client's max_tokens budget on internal reasoning, leaving
6454
6662
  # nothing for the visible answer.
6455
6663
  anthropic_thinking = anthropic_body.get("thinking")
6664
+ # PROTOCOL DEFAULT — deliberately top-level only, NOT via _set_thinking.
6665
+ #
6666
+ # Anthropic's default is "thinking off unless asked", so this branch sets
6667
+ # off for almost every request. Against a jinja server the top-level field
6668
+ # is inert, which is why the upstream has been reasoning by default all
6669
+ # along. Routing this through _set_thinking would make it authoritative and
6670
+ # silently flip EVERY client that never asked for thinking — Claude Code,
6671
+ # opencode, benchmarks, all OpenAI-compat traffic — from the server's
6672
+ # configured default to off. That is a serving-policy change, not a bug fix.
6673
+ #
6674
+ # The same reasoning applies to the "enabled" case in reverse: making it
6675
+ # authoritative would let a client turn reasoning ON against a server
6676
+ # deliberately launched with it off. The measured bug was one-directional
6677
+ # (an operator's OFF switch didn't stick); the fix stays one-directional.
6678
+ # Operators who want thinking off across the board have an explicit switch
6679
+ # below (PROXY_DISABLE_THINKING_ALWAYS), and it now works.
6456
6680
  if isinstance(anthropic_thinking, dict):
6457
6681
  ttype = (anthropic_thinking.get("type") or "").lower()
6458
- if ttype == "enabled":
6459
- openai_body["enable_thinking"] = True
6460
- else:
6461
- openai_body["enable_thinking"] = False
6682
+ openai_body["enable_thinking"] = ttype == "enabled"
6462
6683
  else:
6463
- # Match Anthropic default: thinking off unless explicitly requested.
6464
6684
  openai_body["enable_thinking"] = False
6465
6685
 
6466
6686
  # Global thinking-off (G): apply to every request, not just tool turns.
@@ -6468,7 +6688,7 @@ def build_openai_request(
6468
6688
  # Per-path tool-turn handling below (DISABLE_THINKING_ON_TOOL_TURNS) is
6469
6689
  # additive — ALWAYS supersedes when set.
6470
6690
  if PROXY_DISABLE_THINKING_ALWAYS:
6471
- openai_body["enable_thinking"] = False
6691
+ _set_thinking(openai_body, False)
6472
6692
 
6473
6693
  # Inject agentic protocol instructions only for tool-enabled turns.
6474
6694
  # Use minimal supplement for qwen models to reduce prompt leak surface.
@@ -6788,7 +7008,7 @@ def build_openai_request(
6788
7008
  monitor.catastrophic_ctx_streak,
6789
7009
  )
6790
7010
  if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
6791
- openai_body["enable_thinking"] = False
7011
+ _set_thinking(openai_body, False)
6792
7012
  if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
6793
7013
  openai_body["speculative.n_max"] = 0
6794
7014
  return openai_body
@@ -6813,7 +7033,7 @@ def build_openai_request(
6813
7033
  )
6814
7034
  # Skip all further tool_choice logic — no tools this turn
6815
7035
  if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
6816
- openai_body["enable_thinking"] = False
7036
+ _set_thinking(openai_body, False)
6817
7037
  if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
6818
7038
  openai_body["speculative.n_max"] = 0
6819
7039
  return openai_body
@@ -6863,7 +7083,7 @@ def build_openai_request(
6863
7083
  PROXY_HARD_FINALIZE_TURNS,
6864
7084
  )
6865
7085
  if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
6866
- openai_body["enable_thinking"] = False
7086
+ _set_thinking(openai_body, False)
6867
7087
  if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
6868
7088
  openai_body["speculative.n_max"] = 0
6869
7089
  return openai_body
@@ -6931,9 +7151,16 @@ def build_openai_request(
6931
7151
  if (
6932
7152
  monitor.tool_turn_phase == "review"
6933
7153
  and state_reason in {"cycle_detected", "stagnation"}
6934
- 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)
6935
7160
  ):
6936
- cycling_names = ", ".join(monitor.cycling_tool_names)
7161
+ cycling_names = ", ".join(
7162
+ _spinning_cycling_names(monitor.cycling_tool_names)
7163
+ )
6937
7164
  cycles = monitor.tool_state_review_cycles
6938
7165
  if cycles <= 1:
6939
7166
  cycle_hint = (
@@ -6975,8 +7202,12 @@ def build_openai_request(
6975
7202
  )
6976
7203
  if narrowed:
6977
7204
  openai_body["tools"] = narrowed
6978
- # Only log on first activation or phase transitions to reduce noise
6979
- 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:
6980
7211
  logger.warning(
6981
7212
  "CYCLE BREAK: narrowed tools from %d to %d (excluded %s, read_only_class=%s)",
6982
7213
  original_count,
@@ -7046,7 +7277,7 @@ def build_openai_request(
7046
7277
 
7047
7278
 
7048
7279
  if PROXY_DISABLE_THINKING_ALWAYS or PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
7049
- openai_body["enable_thinking"] = False
7280
+ _set_thinking(openai_body, False)
7050
7281
  logger.info(
7051
7282
  "Thinking disabled (always=%s tool_turns=%s)",
7052
7283
  PROXY_DISABLE_THINKING_ALWAYS,
@@ -9408,10 +9639,7 @@ async def _apply_empty_maxtokens_recovery(
9408
9639
  if not _is_empty_maxtokens_response(openai_resp):
9409
9640
  return openai_resp
9410
9641
  retry_body = dict(openai_body)
9411
- retry_body["enable_thinking"] = False
9412
- ctk = dict(retry_body.get("chat_template_kwargs") or {})
9413
- ctk["enable_thinking"] = False
9414
- retry_body["chat_template_kwargs"] = ctk
9642
+ _set_thinking(retry_body, False)
9415
9643
  requested = int(openai_body.get("max_tokens") or PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
9416
9644
  retry_body["max_tokens"] = min(requested, PROXY_EMPTY_MAXTOKENS_RETRY_MAX_TOKENS)
9417
9645
  logger.warning(
@@ -9577,7 +9805,7 @@ def _build_malformed_retry_body(
9577
9805
  )
9578
9806
 
9579
9807
  if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
9580
- retry_body["enable_thinking"] = False
9808
+ _set_thinking(retry_body, False)
9581
9809
 
9582
9810
  # Option 3: Proactively strip grammar from retry when tools are present and
9583
9811
  # grammar+tools is known to be incompatible. Prevents the 400 error
@@ -2043,6 +2043,17 @@ class TestToolTurnControls(unittest.TestCase):
2043
2043
  )
2044
2044
  self.assertIn("enable_thinking", openai)
2045
2045
  self.assertFalse(openai["enable_thinking"])
2046
+ # THE assertion whose absence let the switch be a no-op for months:
2047
+ # a `--jinja` server reads chat_template_kwargs and ignores the
2048
+ # top-level field entirely, so asserting only the line above passes
2049
+ # while the upstream keeps reasoning. Measured on the live server:
2050
+ # top-level only -> 703 chars of reasoning; this key -> 0.
2051
+ self.assertIs(
2052
+ openai.get("chat_template_kwargs", {}).get("enable_thinking"),
2053
+ False,
2054
+ "tool-turn thinking switch must reach chat_template_kwargs, "
2055
+ "or a --jinja upstream ignores it",
2056
+ )
2046
2057
  finally:
2047
2058
  setattr(proxy, "PROXY_DISABLE_THINKING_ON_TOOL_TURNS", old_disable)
2048
2059
 
@@ -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()
@@ -0,0 +1,155 @@
1
+ """Turning reasoning off must reach the place llama.cpp actually reads it.
2
+
3
+ Measured on the live server (Qwen3.6-35B-A3B, launched `--jinja
4
+ --chat-template-kwargs {"enable_thinking": true}`), identical tool-call prompt:
5
+
6
+ top-level "enable_thinking": false -> 703 chars of reasoning, 196 tokens
7
+ chat_template_kwargs.enable_thinking -> 0 chars, 27 tokens
8
+
9
+ and on a planning-flavoured prompt with max_tokens=700, reasoning consumed the
10
+ ENTIRE budget and NO tool call was emitted — the "stuck planning" symptom.
11
+
12
+ Every thinking switch in the proxy set only the top-level field, so
13
+ PROXY_DISABLE_THINKING_ALWAYS, PROXY_DISABLE_THINKING_ON_TOOL_TURNS and the
14
+ Anthropic `thinking: {type: disabled}` parameter were all no-ops against a jinja
15
+ server: the controls existed, logged themselves as active, and changed nothing.
16
+ """
17
+ import importlib.util
18
+ import os
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
23
+
24
+
25
+ _PRIOR_AUTOLOAD = os.environ.get("UAP_PROXY_ENV_AUTOLOAD")
26
+
27
+
28
+ def tearDownModule():
29
+ # Restore rather than leak into any test sharing this process.
30
+ if _PRIOR_AUTOLOAD is None:
31
+ os.environ.pop("UAP_PROXY_ENV_AUTOLOAD", None)
32
+ else:
33
+ os.environ["UAP_PROXY_ENV_AUTOLOAD"] = _PRIOR_AUTOLOAD
34
+
35
+
36
+ def load_proxy(**env):
37
+ # Explicit, so a project .uap/proxy.env cannot leak in when this module is
38
+ # run directly rather than through the npm script.
39
+ os.environ["UAP_PROXY_ENV_AUTOLOAD"] = "0"
40
+ for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
41
+ os.environ.pop(k, None)
42
+ os.environ.update({k: v for k, v in env.items()})
43
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_thinking", proxy_path)
44
+ mod = importlib.util.module_from_spec(spec)
45
+ spec.loader.exec_module(mod)
46
+ return mod
47
+
48
+
49
+ class SetThinkingTest(unittest.TestCase):
50
+ def tearDown(self):
51
+ for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
52
+ os.environ.pop(k, None)
53
+
54
+ def test_disabling_reaches_chat_template_kwargs(self):
55
+ # THE BUG: the top-level field alone is overridden by the server's
56
+ # --chat-template-kwargs default, so reasoning kept flowing.
57
+ ap = load_proxy()
58
+ body = {}
59
+ ap._set_thinking(body, False)
60
+ self.assertIs(body["chat_template_kwargs"]["enable_thinking"], False)
61
+
62
+ def test_top_level_is_still_set_for_servers_that_read_it(self):
63
+ # Belt and braces: non-jinja / older builds read the top-level field.
64
+ ap = load_proxy()
65
+ body = {}
66
+ ap._set_thinking(body, False)
67
+ self.assertIs(body["enable_thinking"], False)
68
+
69
+ def test_enabling_reaches_both_places_too(self):
70
+ ap = load_proxy()
71
+ body = {}
72
+ ap._set_thinking(body, True)
73
+ self.assertIs(body["enable_thinking"], True)
74
+ self.assertIs(body["chat_template_kwargs"]["enable_thinking"], True)
75
+
76
+ def test_existing_chat_template_kwargs_are_preserved(self):
77
+ # The server is launched with other template kwargs; clobbering the dict
78
+ # would drop them.
79
+ ap = load_proxy()
80
+ body = {"chat_template_kwargs": {"preserve_thinking": False, "custom": 1}}
81
+ ap._set_thinking(body, False)
82
+ self.assertEqual(body["chat_template_kwargs"]["custom"], 1)
83
+ self.assertIs(body["chat_template_kwargs"]["preserve_thinking"], False)
84
+ self.assertIs(body["chat_template_kwargs"]["enable_thinking"], False)
85
+
86
+ def test_a_non_dict_kwargs_value_does_not_raise(self):
87
+ # Defensive: a malformed client body must not 500 the turn.
88
+ ap = load_proxy()
89
+ body = {"chat_template_kwargs": "nonsense"}
90
+ ap._set_thinking(body, False)
91
+ self.assertIs(body["enable_thinking"], False)
92
+
93
+ def test_a_shallow_retry_copy_does_not_disable_thinking_on_the_original(self):
94
+ # The helper's whole rationale: retry bodies are built as
95
+ # `dict(openai_body)`, which SHARES this nested dict. Mutating it in
96
+ # place would disable thinking on the in-flight request too.
97
+ ap = load_proxy()
98
+ original = {"chat_template_kwargs": {"enable_thinking": True}}
99
+ retry = dict(original)
100
+ ap._set_thinking(retry, False)
101
+ self.assertIs(original["chat_template_kwargs"]["enable_thinking"], True)
102
+ self.assertIs(retry["chat_template_kwargs"]["enable_thinking"], False)
103
+
104
+
105
+ class SwitchesReachTheTemplateTest(unittest.TestCase):
106
+ """The switches, exercised through the real request builder."""
107
+
108
+ def tearDown(self):
109
+ for k in ("PROXY_DISABLE_THINKING_ALWAYS", "PROXY_DISABLE_THINKING_ON_TOOL_TURNS"):
110
+ os.environ.pop(k, None)
111
+
112
+ @staticmethod
113
+ def _build(ap, tools=False):
114
+ body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]}
115
+ if tools:
116
+ body["tools"] = [
117
+ {"name": "Read", "description": "Read file", "input_schema": {"type": "object"}}
118
+ ]
119
+ return ap.build_openai_request(body, ap.SessionMonitor(context_window=262144))
120
+
121
+ def test_always_switch_reaches_chat_template_kwargs(self):
122
+ ap = load_proxy(PROXY_DISABLE_THINKING_ALWAYS="on")
123
+ built = self._build(ap)
124
+ self.assertIs(built["chat_template_kwargs"]["enable_thinking"], False)
125
+
126
+ def test_protocol_default_does_NOT_force_the_template(self):
127
+ # Deliberate: Anthropic defaults thinking off, and making that
128
+ # authoritative would flip every client that never asked for it from
129
+ # the server's configured default to off. A serving-policy change is
130
+ # not a bug fix; operators have an explicit switch for that.
131
+ ap = load_proxy()
132
+ built = self._build(ap)
133
+ self.assertIs(built["enable_thinking"], False)
134
+ self.assertNotIn(
135
+ "enable_thinking",
136
+ built.get("chat_template_kwargs", {}),
137
+ "the protocol default must not override the server's configured default",
138
+ )
139
+
140
+ def test_a_client_cannot_turn_thinking_ON_against_the_server_default(self):
141
+ # The measured bug was one-directional (an OFF switch that didn't
142
+ # stick); the fix stays one-directional.
143
+ ap = load_proxy()
144
+ body = {
145
+ "model": "test",
146
+ "messages": [{"role": "user", "content": "hi"}],
147
+ "thinking": {"type": "enabled"},
148
+ }
149
+ built = ap.build_openai_request(body, ap.SessionMonitor(context_window=262144))
150
+ self.assertIs(built["enable_thinking"], True)
151
+ self.assertNotIn("enable_thinking", built.get("chat_template_kwargs", {}))
152
+
153
+
154
+ if __name__ == "__main__":
155
+ unittest.main()