@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.220.7",
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",
@@ -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:]:
@@ -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()