@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 +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +184 -8
- package/tools/agents/tests/test_cycle_break_wait_poll.py +293 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.210.
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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()
|