@miller-tech/uap 1.220.7 → 1.220.9
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/src/policies/enforcers/rtk_wrap.py +41 -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 +121 -0
- package/tools/agents/tests/test_repeat_guard.py +216 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.220.
|
|
3
|
+
"version": "1.220.9",
|
|
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_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline 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_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap 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_stuck_break_hard 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",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_conditional_destructive_verbs tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline 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_repeat_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_rtk_wrap 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_stuck_break_hard 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
|
|
@@ -60,6 +60,35 @@ MACHINE_FORMS = {
|
|
|
60
60
|
"stash": ("list",),
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
# rtk's docker handlers only recognise the BARE subcommand. Any flag on
|
|
64
|
+
# `docker ps` or `docker logs` makes rtk print "[rtk: parse failed, running
|
|
65
|
+
# raw]" ahead of otherwise-correct raw output. The DATA is fine -- but that
|
|
66
|
+
# line reads as a tool error, and a model that cannot act on it retries: it
|
|
67
|
+
# drove a live ERROR-LOOP on 2026-08-25 ("same failure x3,
|
|
68
|
+
# sig='[rtk: parse failed, running raw]'"). `rtk proxy` runs the same command
|
|
69
|
+
# unfiltered with no such line, and is still counted in the savings ledger.
|
|
70
|
+
#
|
|
71
|
+
# Measured against rtk 0.27.0 on 2026-08-25 -- listed rather than guessed,
|
|
72
|
+
# because every entry here is friction for a call that would have been fine:
|
|
73
|
+
# docker ps ok
|
|
74
|
+
# docker ps -a parse failed
|
|
75
|
+
# docker ps --filter name=uap parse failed
|
|
76
|
+
# docker ps --format '{{.Names}}' parse failed
|
|
77
|
+
# docker images ok
|
|
78
|
+
# docker logs --tail 1 <id> parse failed
|
|
79
|
+
# docker inspect <id> ok
|
|
80
|
+
# So the trigger is the SUBCOMMAND plus any flag at all, not a specific flag.
|
|
81
|
+
DOCKER_FLAG_INTOLERANT = ("ps", "logs")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def docker_wants_proxy(tokens: list[str]) -> bool:
|
|
85
|
+
"""Is this a docker call rtk will fail to parse (bare form only)?"""
|
|
86
|
+
args = [t for t in tokens if t.split("/")[-1] != "docker"]
|
|
87
|
+
sub_cmd = next((t for t in args if not t.startswith("-")), "")
|
|
88
|
+
if sub_cmd not in DOCKER_FLAG_INTOLERANT:
|
|
89
|
+
return False
|
|
90
|
+
return any(t.startswith("-") for t in args)
|
|
91
|
+
|
|
63
92
|
|
|
64
93
|
def wants_machine_output(tokens: list[str]) -> bool:
|
|
65
94
|
"""Is this git invocation asking for output something will parse?"""
|
|
@@ -122,6 +151,16 @@ def main() -> None:
|
|
|
122
151
|
# skipping every rtk-led statement is what let it through the gate.
|
|
123
152
|
if bin_name == "rtk":
|
|
124
153
|
rest = [t for t in tokens[1:] if not t.startswith("-")]
|
|
154
|
+
if rest[:1] == ["docker"] and docker_wants_proxy(tokens[1:]):
|
|
155
|
+
emit(
|
|
156
|
+
False,
|
|
157
|
+
"rtk-wrap: rtk only parses the bare form of this docker "
|
|
158
|
+
"subcommand; with any flag it prefixes "
|
|
159
|
+
"'[rtk: parse failed, running raw]' to the output, which "
|
|
160
|
+
"reads as an error and has driven retry loops. Use: "
|
|
161
|
+
"rtk proxy " + " ".join(tokens[1:]),
|
|
162
|
+
bin="rtk",
|
|
163
|
+
)
|
|
125
164
|
if rest[:1] == ["git"] and wants_machine_output(tokens[1:]):
|
|
126
165
|
emit(
|
|
127
166
|
False,
|
|
@@ -141,6 +180,8 @@ def main() -> None:
|
|
|
141
180
|
if bin_name == "git" and wants_machine_output(tokens):
|
|
142
181
|
# Still through rtk, so the call is still tracked -- just unfiltered.
|
|
143
182
|
wrapper = "rtk proxy"
|
|
183
|
+
elif bin_name == "docker" and docker_wants_proxy(tokens):
|
|
184
|
+
wrapper = "rtk proxy"
|
|
144
185
|
if bin_name in PMS:
|
|
145
186
|
sub = ""
|
|
146
187
|
for nxt in tokens[1:]:
|
|
Binary file
|
|
Binary file
|
|
@@ -11005,6 +11005,85 @@ def _maybe_extract_text_tool_calls(
|
|
|
11005
11005
|
return openai_resp
|
|
11006
11006
|
|
|
11007
11007
|
|
|
11008
|
+
# ---------------------------------------------------------------------------
|
|
11009
|
+
# Mid-stream degenerate-repetition guard
|
|
11010
|
+
# ---------------------------------------------------------------------------
|
|
11011
|
+
# `_detect_and_truncate_degenerate_repetition` (below) is the POST-HOC sibling
|
|
11012
|
+
# of this guard: it repairs a finished non-streaming response. It cannot help a
|
|
11013
|
+
# STREAMING client, and by the time it runs the GPU time is already spent.
|
|
11014
|
+
#
|
|
11015
|
+
# Measured 2026-08-25: a rail running --repeat-penalty 1.0 with DRY disabled
|
|
11016
|
+
# emitted ONE sentence 640 times (151,628 chars) until it hit the 32,768-token
|
|
11017
|
+
# n_predict cap -- ~11 minutes of GPU for a turn that ended reason="length",
|
|
11018
|
+
# after which opencode logged "exiting loop" and abandoned the session having
|
|
11019
|
+
# done nothing. 130 such generations were in one client's history, 2-5/day for
|
|
11020
|
+
# months. Sampler-level control (repeat-penalty >1, --dry-multiplier) is the
|
|
11021
|
+
# primary fix; this is the backstop for when it is misconfigured again.
|
|
11022
|
+
#
|
|
11023
|
+
# Cost is bounded by construction: the check runs at most once per
|
|
11024
|
+
# PROXY_REPEAT_GUARD_CHECK_EVERY characters and only ever inspects a
|
|
11025
|
+
# PROXY_REPEAT_GUARD_TAIL-sized tail, never the whole accumulated response.
|
|
11026
|
+
PROXY_REPEAT_GUARD = os.environ.get("PROXY_REPEAT_GUARD", "1") not in ("0", "false", "False")
|
|
11027
|
+
PROXY_REPEAT_GUARD_MIN_CHARS = int(os.environ.get("PROXY_REPEAT_GUARD_MIN_CHARS", "2000"))
|
|
11028
|
+
PROXY_REPEAT_GUARD_CHECK_EVERY = int(os.environ.get("PROXY_REPEAT_GUARD_CHECK_EVERY", "1000"))
|
|
11029
|
+
PROXY_REPEAT_GUARD_TAIL = int(os.environ.get("PROXY_REPEAT_GUARD_TAIL", "8000"))
|
|
11030
|
+
PROXY_REPEAT_GUARD_LINE_REPEATS = int(os.environ.get("PROXY_REPEAT_GUARD_LINE_REPEATS", "8"))
|
|
11031
|
+
PROXY_REPEAT_GUARD_BLOCK_REPEATS = int(os.environ.get("PROXY_REPEAT_GUARD_BLOCK_REPEATS", "4"))
|
|
11032
|
+
PROXY_REPEAT_GUARD_MIN_UNIT = int(os.environ.get("PROXY_REPEAT_GUARD_MIN_UNIT", "24"))
|
|
11033
|
+
PROXY_REPEAT_GUARD_MAX_PERIOD = int(os.environ.get("PROXY_REPEAT_GUARD_MAX_PERIOD", "600"))
|
|
11034
|
+
|
|
11035
|
+
|
|
11036
|
+
def _detect_degenerate_repeat(tail: str) -> str | None:
|
|
11037
|
+
"""Return the repeated unit if `tail` ENDS in a degenerate loop, else None.
|
|
11038
|
+
|
|
11039
|
+
Both detectors are anchored at the END of the text, so a legitimately
|
|
11040
|
+
repetitive passage earlier in a response (a changelog, a table, a list of
|
|
11041
|
+
similar imports) cannot trip them -- only output that is still repeating
|
|
11042
|
+
at the moment of the check does.
|
|
11043
|
+
|
|
11044
|
+
* line mode -- the same non-blank line N times in a row. This is the
|
|
11045
|
+
shape real runaways take here: one sentence, "\\n\\n", forever.
|
|
11046
|
+
* block mode -- the same character block N times in a row, for loops
|
|
11047
|
+
that never emit a newline and so are invisible to line mode.
|
|
11048
|
+
|
|
11049
|
+
A unit must be at least MIN_UNIT characters AND contain an alphanumeric
|
|
11050
|
+
character, so horizontal rules ("----"), fence markers, bracket runs and
|
|
11051
|
+
indentation are never flagged. Callers pass a bounded tail; this function
|
|
11052
|
+
does not trim, so its cost is the caller's choice.
|
|
11053
|
+
"""
|
|
11054
|
+
if not tail:
|
|
11055
|
+
return None
|
|
11056
|
+
|
|
11057
|
+
# --- line mode ---------------------------------------------------------
|
|
11058
|
+
lines = [ln.strip() for ln in tail.splitlines()]
|
|
11059
|
+
lines = [ln for ln in lines if ln]
|
|
11060
|
+
if len(lines) >= PROXY_REPEAT_GUARD_LINE_REPEATS:
|
|
11061
|
+
last = lines[-1]
|
|
11062
|
+
if len(last) >= PROXY_REPEAT_GUARD_MIN_UNIT and any(c.isalnum() for c in last):
|
|
11063
|
+
run = 1
|
|
11064
|
+
for prev in reversed(lines[:-1]):
|
|
11065
|
+
if prev != last:
|
|
11066
|
+
break
|
|
11067
|
+
run += 1
|
|
11068
|
+
if run >= PROXY_REPEAT_GUARD_LINE_REPEATS:
|
|
11069
|
+
return last
|
|
11070
|
+
|
|
11071
|
+
# --- block mode --------------------------------------------------------
|
|
11072
|
+
reps = PROXY_REPEAT_GUARD_BLOCK_REPEATS
|
|
11073
|
+
if reps >= 2:
|
|
11074
|
+
max_period = min(PROXY_REPEAT_GUARD_MAX_PERIOD, len(tail) // reps)
|
|
11075
|
+
for period in range(PROXY_REPEAT_GUARD_MIN_UNIT, max_period + 1):
|
|
11076
|
+
unit = tail[-period:]
|
|
11077
|
+
if not any(c.isalnum() for c in unit):
|
|
11078
|
+
continue
|
|
11079
|
+
if all(
|
|
11080
|
+
tail[-period * (k + 1) : -period * k] == unit
|
|
11081
|
+
for k in range(1, reps)
|
|
11082
|
+
):
|
|
11083
|
+
return unit
|
|
11084
|
+
return None
|
|
11085
|
+
|
|
11086
|
+
|
|
11008
11087
|
def _detect_and_truncate_degenerate_repetition(
|
|
11009
11088
|
openai_resp: dict,
|
|
11010
11089
|
) -> tuple[dict, bool]:
|
|
@@ -11646,6 +11725,14 @@ async def stream_anthropic_response(
|
|
|
11646
11725
|
text_chunks: list[str] = [] # accumulate text for logging
|
|
11647
11726
|
reasoning_chunks: list[str] = [] # accumulate reasoning for fallback
|
|
11648
11727
|
|
|
11728
|
+
# Mid-stream repetition guard state. `_rg_tail` is a bounded rolling window
|
|
11729
|
+
# so the check never walks the whole accumulated response.
|
|
11730
|
+
_rg_tail: deque[str] = deque()
|
|
11731
|
+
_rg_tail_len = 0
|
|
11732
|
+
_rg_total = 0
|
|
11733
|
+
_rg_checked_at = 0
|
|
11734
|
+
_rg_tripped = ""
|
|
11735
|
+
|
|
11649
11736
|
# Real token counts from upstream's final usage chunk (llama-server /
|
|
11650
11737
|
# OpenAI emit it on the last data frame). The per-delta output_tokens
|
|
11651
11738
|
# counter is a chunk count that misses tool-call deltas entirely; prefer
|
|
@@ -11685,6 +11772,33 @@ async def stream_anthropic_response(
|
|
|
11685
11772
|
f"data: {json.dumps({'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': delta['content']}})}\n\n"
|
|
11686
11773
|
)
|
|
11687
11774
|
|
|
11775
|
+
# Degenerate-repetition guard: stop a runaway while it is still
|
|
11776
|
+
# running instead of paying out the full n-predict budget. The
|
|
11777
|
+
# deltas already forwarded stand; finish_reason below marks the
|
|
11778
|
+
# text untrustworthy, matching the post-hoc detector's contract.
|
|
11779
|
+
if PROXY_REPEAT_GUARD:
|
|
11780
|
+
_rg_tail.append(delta["content"])
|
|
11781
|
+
_rg_tail_len += len(delta["content"])
|
|
11782
|
+
_rg_total += len(delta["content"])
|
|
11783
|
+
while _rg_tail_len > PROXY_REPEAT_GUARD_TAIL and len(_rg_tail) > 1:
|
|
11784
|
+
_rg_tail_len -= len(_rg_tail.popleft())
|
|
11785
|
+
if (
|
|
11786
|
+
_rg_total >= PROXY_REPEAT_GUARD_MIN_CHARS
|
|
11787
|
+
and _rg_total - _rg_checked_at >= PROXY_REPEAT_GUARD_CHECK_EVERY
|
|
11788
|
+
):
|
|
11789
|
+
_rg_checked_at = _rg_total
|
|
11790
|
+
_unit = _detect_degenerate_repeat("".join(_rg_tail))
|
|
11791
|
+
if _unit:
|
|
11792
|
+
_rg_tripped = _unit
|
|
11793
|
+
logger.warning(
|
|
11794
|
+
"REPEAT GUARD: aborting runaway generation after %d chars "
|
|
11795
|
+
"— unit of %d chars repeating: %.120r",
|
|
11796
|
+
_rg_total,
|
|
11797
|
+
len(_unit),
|
|
11798
|
+
_unit,
|
|
11799
|
+
)
|
|
11800
|
+
break
|
|
11801
|
+
|
|
11688
11802
|
# Handle tool_calls deltas
|
|
11689
11803
|
if delta.get("tool_calls"):
|
|
11690
11804
|
for tc_delta in delta["tool_calls"]:
|
|
@@ -11769,6 +11883,13 @@ async def stream_anthropic_response(
|
|
|
11769
11883
|
# connection un-closed → CLOSE-WAIT leak. Detaching guarantees it runs.
|
|
11770
11884
|
_detach_aclose(openai_stream)
|
|
11771
11885
|
|
|
11886
|
+
# A guard-aborted turn is NOT a complete answer. Report max_tokens so the
|
|
11887
|
+
# client treats the text as truncated (and retries where it supports that)
|
|
11888
|
+
# rather than accepting a wall of repeated text as the final response —
|
|
11889
|
+
# the same contract `_detect_and_truncate_degenerate_repetition` uses.
|
|
11890
|
+
if _rg_tripped:
|
|
11891
|
+
finish_reason = "max_tokens"
|
|
11892
|
+
|
|
11772
11893
|
# Close any open tool call blocks (skip if XML recovery already emitted them)
|
|
11773
11894
|
xml_recovered = tool_calls_by_index.pop("_xml_recovered", False)
|
|
11774
11895
|
if tool_calls_by_index and not xml_recovered:
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mid-stream degenerate-repetition guard.
|
|
3
|
+
|
|
4
|
+
Regression cover for the 2026-08-25 runaway: a rail running
|
|
5
|
+
--repeat-penalty 1.0 with DRY disabled emitted ONE sentence 640 times
|
|
6
|
+
(151,628 chars) until it hit the 32,768-token n-predict cap, ~11 minutes of
|
|
7
|
+
GPU for a turn that produced nothing. The post-hoc detector
|
|
8
|
+
(_detect_and_truncate_degenerate_repetition) only repairs NON-streaming
|
|
9
|
+
responses, so a streaming client like opencode was never protected.
|
|
10
|
+
|
|
11
|
+
The false-positive tests are the load-bearing half: a guard that aborts real
|
|
12
|
+
answers is worse than the runaway it prevents.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import importlib.util
|
|
17
|
+
import json
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_proxy_module():
|
|
23
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
24
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
25
|
+
assert spec is not None and spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
proxy = _load_proxy_module()
|
|
32
|
+
|
|
33
|
+
# The exact sentence captured from the live runaway.
|
|
34
|
+
RUNAWAY_LINE = (
|
|
35
|
+
"The Docker build fails because the workspace root `Cargo.toml` doesn't "
|
|
36
|
+
"include the `pg-server` bin target. Let me check the workspace structure:"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestDetectDegenerateRepeat(unittest.TestCase):
|
|
41
|
+
def test_detects_the_captured_runaway_shape(self):
|
|
42
|
+
"""One sentence + blank line, over and over — the real failure."""
|
|
43
|
+
tail = (RUNAWAY_LINE + "\n\n") * 20
|
|
44
|
+
self.assertEqual(proxy._detect_degenerate_repeat(tail), RUNAWAY_LINE)
|
|
45
|
+
|
|
46
|
+
def test_detects_loop_with_no_newlines(self):
|
|
47
|
+
"""Block mode: a loop inside one long line is invisible to line mode."""
|
|
48
|
+
unit = "and then the value is recomputed again, "
|
|
49
|
+
tail = "prefix text. " + unit * 12
|
|
50
|
+
found = proxy._detect_degenerate_repeat(tail)
|
|
51
|
+
self.assertIsNotNone(found)
|
|
52
|
+
# Whatever period it locks onto must itself be the repeating unit.
|
|
53
|
+
self.assertTrue(unit.strip(", ") in found or found in unit * 2)
|
|
54
|
+
|
|
55
|
+
def test_ignores_repetition_that_has_stopped(self):
|
|
56
|
+
"""Anchored at the END: a repetitive passage the model moved on from
|
|
57
|
+
is not a runaway, and aborting there would truncate a real answer."""
|
|
58
|
+
tail = (RUNAWAY_LINE + "\n\n") * 20 + (
|
|
59
|
+
"\n\nRight — the bin target is missing from the workspace members "
|
|
60
|
+
"list. Adding it to Cargo.toml now, then rebuilding to confirm the "
|
|
61
|
+
"image picks up the new binary.\n"
|
|
62
|
+
)
|
|
63
|
+
self.assertIsNone(proxy._detect_degenerate_repeat(tail))
|
|
64
|
+
|
|
65
|
+
def test_ignores_short_and_punctuation_only_runs(self):
|
|
66
|
+
"""Rules, fences and bracket runs legitimately repeat in real output."""
|
|
67
|
+
for tail in (
|
|
68
|
+
"----\n" * 30,
|
|
69
|
+
"```\n" * 30,
|
|
70
|
+
"}\n" * 30,
|
|
71
|
+
" \n" * 30,
|
|
72
|
+
("ok\n") * 30, # non-blank but under MIN_UNIT
|
|
73
|
+
):
|
|
74
|
+
with self.subTest(tail=tail[:12]):
|
|
75
|
+
self.assertIsNone(proxy._detect_degenerate_repeat(tail))
|
|
76
|
+
|
|
77
|
+
def test_ignores_similar_but_distinct_lines(self):
|
|
78
|
+
"""Generated code repeats structure, not whole identical lines."""
|
|
79
|
+
tail = "".join(
|
|
80
|
+
f' assert_eq!(rows[{i}].get::<_, i64>("id"), {i} as i64);\n'
|
|
81
|
+
for i in range(30)
|
|
82
|
+
)
|
|
83
|
+
self.assertIsNone(proxy._detect_degenerate_repeat(tail))
|
|
84
|
+
|
|
85
|
+
def test_ignores_empty_input(self):
|
|
86
|
+
self.assertIsNone(proxy._detect_degenerate_repeat(""))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _FakeUpstreamStream:
|
|
90
|
+
"""Minimal streamed httpx.Response: yields OpenAI SSE lines."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, contents, finish_reason="stop"):
|
|
93
|
+
self._contents = list(contents)
|
|
94
|
+
self._finish_reason = finish_reason
|
|
95
|
+
self.closed = False
|
|
96
|
+
self.lines_served = 0
|
|
97
|
+
|
|
98
|
+
async def aiter_lines(self):
|
|
99
|
+
for chunk in self._contents:
|
|
100
|
+
self.lines_served += 1
|
|
101
|
+
payload = {"choices": [{"delta": {"content": chunk}, "index": 0}]}
|
|
102
|
+
yield "data: " + json.dumps(payload)
|
|
103
|
+
final = {"choices": [{"delta": {}, "finish_reason": self._finish_reason}]}
|
|
104
|
+
yield "data: " + json.dumps(final)
|
|
105
|
+
yield "data: [DONE]"
|
|
106
|
+
|
|
107
|
+
async def aclose(self):
|
|
108
|
+
self.closed = True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _drain(upstream):
|
|
112
|
+
monitor = proxy.SessionMonitor(context_window=131072)
|
|
113
|
+
|
|
114
|
+
async def run():
|
|
115
|
+
out = []
|
|
116
|
+
async for frame in proxy.stream_anthropic_response(
|
|
117
|
+
upstream, "test-model", monitor, {"messages": [], "tools": []}
|
|
118
|
+
):
|
|
119
|
+
out.append(frame)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
return asyncio.run(run())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text_deltas(frames):
|
|
126
|
+
texts = []
|
|
127
|
+
for frame in frames:
|
|
128
|
+
for line in frame.splitlines():
|
|
129
|
+
if not line.startswith("data: "):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
obj = json.loads(line[6:])
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
if obj.get("type") == "content_block_delta":
|
|
136
|
+
delta = obj.get("delta", {})
|
|
137
|
+
if delta.get("type") == "text_delta":
|
|
138
|
+
texts.append(delta["text"])
|
|
139
|
+
return texts
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _stop_reason(frames):
|
|
143
|
+
for frame in frames:
|
|
144
|
+
for line in frame.splitlines():
|
|
145
|
+
if not line.startswith("data: "):
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
obj = json.loads(line[6:])
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
continue
|
|
151
|
+
if obj.get("type") == "message_delta":
|
|
152
|
+
return (obj.get("delta") or {}).get("stop_reason")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestStreamRepeatGuard(unittest.TestCase):
|
|
157
|
+
def test_aborts_runaway_before_the_budget_is_spent(self):
|
|
158
|
+
"""The whole point: stop generating, not just clean up afterwards."""
|
|
159
|
+
chunks = [RUNAWAY_LINE + "\n\n"] * 400
|
|
160
|
+
upstream = _FakeUpstreamStream(chunks)
|
|
161
|
+
frames = _drain(upstream)
|
|
162
|
+
|
|
163
|
+
served = upstream.lines_served
|
|
164
|
+
self.assertLess(
|
|
165
|
+
served, 100, f"guard did not abort early: {served}/400 chunks consumed"
|
|
166
|
+
)
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
_stop_reason(frames),
|
|
169
|
+
"max_tokens",
|
|
170
|
+
"an aborted runaway must not be reported as a complete answer",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def test_normal_response_streams_through_untouched(self):
|
|
174
|
+
"""No false abort, and every delta still reaches the client."""
|
|
175
|
+
chunks = [
|
|
176
|
+
"Checking the workspace layout.\n\n",
|
|
177
|
+
"The `pg-server` crate is present but not listed under "
|
|
178
|
+
"`[workspace] members`, so `cargo build --workspace` never "
|
|
179
|
+
"builds its binary.\n\n",
|
|
180
|
+
"Adding it to the members list and rebuilding.\n",
|
|
181
|
+
]
|
|
182
|
+
upstream = _FakeUpstreamStream(chunks)
|
|
183
|
+
frames = _drain(upstream)
|
|
184
|
+
|
|
185
|
+
self.assertEqual(_text_deltas(frames), chunks)
|
|
186
|
+
self.assertEqual(_stop_reason(frames), "end_turn")
|
|
187
|
+
|
|
188
|
+
def test_long_legitimate_answer_is_not_aborted(self):
|
|
189
|
+
"""Well past the guard's minimum length, with repeated structure."""
|
|
190
|
+
chunks = [
|
|
191
|
+
f"Step {i}: verify that migration {i:03d} applies cleanly and the "
|
|
192
|
+
f"resulting schema matches the fixture checked in at "
|
|
193
|
+
f"tests/fixtures/schema_{i:03d}.sql.\n\n"
|
|
194
|
+
for i in range(80)
|
|
195
|
+
]
|
|
196
|
+
upstream = _FakeUpstreamStream(chunks)
|
|
197
|
+
frames = _drain(upstream)
|
|
198
|
+
|
|
199
|
+
self.assertEqual(len(_text_deltas(frames)), 80)
|
|
200
|
+
self.assertEqual(_stop_reason(frames), "end_turn")
|
|
201
|
+
|
|
202
|
+
def test_guard_can_be_disabled(self):
|
|
203
|
+
chunks = [RUNAWAY_LINE + "\n\n"] * 60
|
|
204
|
+
original = proxy.PROXY_REPEAT_GUARD
|
|
205
|
+
proxy.PROXY_REPEAT_GUARD = False
|
|
206
|
+
try:
|
|
207
|
+
upstream = _FakeUpstreamStream(chunks)
|
|
208
|
+
frames = _drain(upstream)
|
|
209
|
+
self.assertEqual(len(_text_deltas(frames)), 60)
|
|
210
|
+
self.assertEqual(_stop_reason(frames), "end_turn")
|
|
211
|
+
finally:
|
|
212
|
+
proxy.PROXY_REPEAT_GUARD = original
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
unittest.main()
|