drydock-cli 3.0.46__tar.gz → 3.0.47__tar.gz
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.
- {drydock_cli-3.0.46/drydock_cli.egg-info → drydock_cli-3.0.47}/PKG-INFO +1 -1
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/detect.py +1 -1
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/loop_detect.py +43 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/providers.py +34 -4
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/app.py +3 -3
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tuning.py +27 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/pyproject.toml +1 -1
- drydock_cli-3.0.47/tests/test_runaway_repetition.py +128 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/LICENSE +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/NOTICE +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/README.md +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/agent.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/cli.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/config.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/guards.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/setup.cfg +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_config.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_vision_input.py +0 -0
|
@@ -63,7 +63,7 @@ def onboarding_message(found: list[dict]) -> str:
|
|
|
63
63
|
return (
|
|
64
64
|
"No local LLM detected (" + ports + "). Point Drydock at your model "
|
|
65
65
|
"right here:\n"
|
|
66
|
-
" /model url <http://
|
|
66
|
+
" /model url <http://localhost:8000/v1> then /model <model-name>\n"
|
|
67
67
|
"(saved to ~/.drydock/config.toml). Or start a local server "
|
|
68
68
|
"(llama.cpp / vLLM / Ollama / LM Studio) and restart."
|
|
69
69
|
)
|
|
@@ -86,6 +86,49 @@ def path_thrash_note(path: str, count: int) -> str | None:
|
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def runaway_repetition_len(
|
|
90
|
+
text: str,
|
|
91
|
+
*,
|
|
92
|
+
window: int = 2000,
|
|
93
|
+
min_run: int = 600,
|
|
94
|
+
max_unit: int = 80,
|
|
95
|
+
min_reps: int = 6,
|
|
96
|
+
) -> int:
|
|
97
|
+
"""Length (in chars) of a runaway repeated SUFFIX of *text*, else 0.
|
|
98
|
+
|
|
99
|
+
A weak local model sometimes collapses mid-generation into emitting the same
|
|
100
|
+
short unit hundreds of times (observed: gemma4 streamed ``295:`` ~1365× on
|
|
101
|
+
make-mips-interpreter). The tool-call loop guard never sees this — it's
|
|
102
|
+
streamed assistant TEXT, not a tool call — so the turn balloons unchecked.
|
|
103
|
+
|
|
104
|
+
This detects that one failure mode and nothing else. It is deliberately
|
|
105
|
+
CONSERVATIVE: it requires a long pure-repetition run (>= ``min_run`` chars
|
|
106
|
+
AND >= ``min_reps`` repeats of one <= ``max_unit``-char unit) so that
|
|
107
|
+
legitimate repetition — a ``---`` rule, a short bulleted list, an ASCII
|
|
108
|
+
table — never trips it. Pure-whitespace units (the blank line between
|
|
109
|
+
markdown blocks) are ignored. Returns the run length so the caller can trim
|
|
110
|
+
exactly the repeated tail; 0 means "looks fine, keep going".
|
|
111
|
+
"""
|
|
112
|
+
if len(text) < min_run:
|
|
113
|
+
return 0
|
|
114
|
+
tail = text[-window:]
|
|
115
|
+
n = len(tail)
|
|
116
|
+
best = 0
|
|
117
|
+
for p in range(1, min(max_unit, n // min_reps) + 1):
|
|
118
|
+
unit = tail[-p:]
|
|
119
|
+
if not unit.strip():
|
|
120
|
+
continue # ignore blank-line / whitespace units
|
|
121
|
+
reps = 0
|
|
122
|
+
i = n
|
|
123
|
+
while i - p >= 0 and tail[i - p:i] == unit:
|
|
124
|
+
reps += 1
|
|
125
|
+
i -= p
|
|
126
|
+
run = reps * p
|
|
127
|
+
if reps >= min_reps and run >= min_run:
|
|
128
|
+
best = max(best, run)
|
|
129
|
+
return best
|
|
130
|
+
|
|
131
|
+
|
|
89
132
|
class LoopTracker:
|
|
90
133
|
"""Counts identical tool calls across a run and produces advisory notes."""
|
|
91
134
|
|
|
@@ -23,7 +23,8 @@ _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_pre
|
|
|
23
23
|
class _StopRequested(Exception):
|
|
24
24
|
"""Internal: STOP was pressed during a blocking LLM call."""
|
|
25
25
|
|
|
26
|
-
from drydock.
|
|
26
|
+
from drydock.loop_detect import runaway_repetition_len
|
|
27
|
+
from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
|
|
27
28
|
|
|
28
29
|
# ── Provider registry ─────────────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -167,6 +168,14 @@ def _stream_chunks(response, cancel):
|
|
|
167
168
|
class TextChunk:
|
|
168
169
|
text: str
|
|
169
170
|
|
|
171
|
+
@dataclass
|
|
172
|
+
class ReasoningChunk:
|
|
173
|
+
"""Thinking tokens emitted by the model before its answer (Gemma 4 only).
|
|
174
|
+
Yielded once per non-streaming turn, before any TextChunk, when the model
|
|
175
|
+
included a <|channel>…<channel|> block in its response.
|
|
176
|
+
"""
|
|
177
|
+
text: str
|
|
178
|
+
|
|
170
179
|
@dataclass
|
|
171
180
|
class AssistantTurn:
|
|
172
181
|
text: str
|
|
@@ -405,6 +414,11 @@ def stream(
|
|
|
405
414
|
text = ""
|
|
406
415
|
tool_buf: dict = {} # index → {id, name, args}
|
|
407
416
|
in_tok = out_tok = 0
|
|
417
|
+
# Runaway-repetition guard: a weak model can collapse into streaming one
|
|
418
|
+
# short unit hundreds of times (gemma4: `295:` ×1365). Advisory, never
|
|
419
|
+
# raises — on detection we trim the repeated tail, emit a note, and stop
|
|
420
|
+
# reading (the orphaned producer drains in the background, like STOP).
|
|
421
|
+
_rep_checked_at = 0
|
|
408
422
|
|
|
409
423
|
try:
|
|
410
424
|
response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
|
|
@@ -432,6 +446,20 @@ def stream(
|
|
|
432
446
|
if chunk_text:
|
|
433
447
|
text += chunk_text
|
|
434
448
|
yield TextChunk(chunk_text)
|
|
449
|
+
# Throttled: only scan once text is long enough and every ~200
|
|
450
|
+
# new chars, so the common path pays almost nothing.
|
|
451
|
+
if len(text) - _rep_checked_at >= 200:
|
|
452
|
+
_rep_checked_at = len(text)
|
|
453
|
+
run = runaway_repetition_len(text)
|
|
454
|
+
if run:
|
|
455
|
+
text = (
|
|
456
|
+
text[: len(text) - run]
|
|
457
|
+
+ "\n[… output began repeating — stopped by drydock]"
|
|
458
|
+
)
|
|
459
|
+
yield TextChunk(
|
|
460
|
+
"\n[stopped — model output began repeating itself]"
|
|
461
|
+
)
|
|
462
|
+
break
|
|
435
463
|
|
|
436
464
|
if delta.tool_calls:
|
|
437
465
|
for tc in delta.tool_calls:
|
|
@@ -479,10 +507,12 @@ def _complete_nonstreaming(
|
|
|
479
507
|
msg = choice.message
|
|
480
508
|
|
|
481
509
|
# Order matters: remove <|tool_call> blobs FIRST (sets had_leak for the
|
|
482
|
-
# retry nudge), THEN strip channel/special-token markers — otherwise
|
|
483
|
-
# generic special-token pass would eat the tool_call markers.
|
|
510
|
+
# retry nudge), THEN extract+strip channel/special-token markers — otherwise
|
|
511
|
+
# the generic special-token pass would eat the tool_call markers.
|
|
484
512
|
text, had_leak = strip_leaked_tool_calls(msg.content or "")
|
|
485
|
-
text =
|
|
513
|
+
thinking, text = extract_thinking(text)
|
|
514
|
+
if thinking:
|
|
515
|
+
yield ReasoningChunk(thinking)
|
|
486
516
|
if text.strip():
|
|
487
517
|
yield TextChunk(text)
|
|
488
518
|
|
|
@@ -530,7 +530,7 @@ class DrydockApp(App):
|
|
|
530
530
|
"""Model + endpoint setup. Subcommands persist so they survive restart:
|
|
531
531
|
/model show model, provider, endpoint
|
|
532
532
|
/model <name> set the model name
|
|
533
|
-
/model url <base_url> set the server URL (e.g. http://
|
|
533
|
+
/model url <base_url> set the server URL (e.g. http://localhost:8000/v1)
|
|
534
534
|
/model provider <name> vllm | ollama | lmstudio | openai
|
|
535
535
|
"""
|
|
536
536
|
arg = (arg or "").strip()
|
|
@@ -541,7 +541,7 @@ class DrydockApp(App):
|
|
|
541
541
|
f"model: {self.config.get('model')}\n"
|
|
542
542
|
f"provider: {prov}\n"
|
|
543
543
|
f"endpoint: {url}\n"
|
|
544
|
-
"Set up: /model <name> · /model url <http://
|
|
544
|
+
"Set up: /model <name> · /model url <http://localhost:8000/v1> · "
|
|
545
545
|
"/model provider <vllm|ollama|lmstudio|openai>"
|
|
546
546
|
)
|
|
547
547
|
return
|
|
@@ -550,7 +550,7 @@ class DrydockApp(App):
|
|
|
550
550
|
val = parts[1].strip() if len(parts) > 1 else ""
|
|
551
551
|
if sub == "url":
|
|
552
552
|
if not val:
|
|
553
|
-
self._info("usage: /model url <http://
|
|
553
|
+
self._info("usage: /model url <http://localhost:8000/v1>")
|
|
554
554
|
return
|
|
555
555
|
self.config["base_url"] = val
|
|
556
556
|
self._persist_config()
|
|
@@ -121,6 +121,33 @@ def is_gemma(model: str | None) -> bool:
|
|
|
121
121
|
return bool(model) and "gemma" in model.lower()
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def extract_thinking(text: str) -> tuple[str, str]:
|
|
125
|
+
"""Return (thinking_content, cleaned_text).
|
|
126
|
+
|
|
127
|
+
Extracts the content of Gemma's <|channel>…<channel|> blocks so callers
|
|
128
|
+
can surface it in the UI, then strips the markers from the returned text.
|
|
129
|
+
Returns ("", text) when no thinking block is present.
|
|
130
|
+
"""
|
|
131
|
+
if not text or "<|channel>" not in text:
|
|
132
|
+
return "", text
|
|
133
|
+
spans = _THINKING_RE.findall(text)
|
|
134
|
+
# Each match includes the delimiters — strip them to get bare thought text.
|
|
135
|
+
thoughts = []
|
|
136
|
+
for span in spans:
|
|
137
|
+
inner = span
|
|
138
|
+
if inner.startswith("<|channel>"):
|
|
139
|
+
inner = inner[len("<|channel>"):]
|
|
140
|
+
for closer in ("<channel|>", "<tool_call|>"):
|
|
141
|
+
if inner.endswith(closer):
|
|
142
|
+
inner = inner[: -len(closer)]
|
|
143
|
+
break
|
|
144
|
+
if inner.strip():
|
|
145
|
+
thoughts.append(inner.strip())
|
|
146
|
+
thinking = "\n\n".join(thoughts)
|
|
147
|
+
cleaned = strip_thinking_tokens(text)
|
|
148
|
+
return thinking, cleaned
|
|
149
|
+
|
|
150
|
+
|
|
124
151
|
def strip_thinking_tokens(text: str) -> str:
|
|
125
152
|
"""Remove Gemma's leaked channel/thinking markers and stray special tokens.
|
|
126
153
|
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.47"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The streamed-text runaway-repetition guard (loop_detect.runaway_repetition_len).
|
|
2
|
+
|
|
3
|
+
Detects the rare failure where a weak local model collapses into repeating one
|
|
4
|
+
short unit hundreds of times (gemma4 emitted `295:` ~1365× on make-mips). Must
|
|
5
|
+
fire on real collapses but NEVER on legitimate repeated content.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from drydock.loop_detect import runaway_repetition_len
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── fires on real collapses ────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
def test_detects_repeated_line_collapse():
|
|
15
|
+
text = "Here is the output:\n" + "295:\n" * 1365
|
|
16
|
+
run = runaway_repetition_len(text)
|
|
17
|
+
assert run > 0
|
|
18
|
+
# The non-repeating prefix must survive a trim of exactly `run` chars.
|
|
19
|
+
assert text[: len(text) - run].startswith("Here is the output:")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_detects_repeated_token_without_newlines():
|
|
23
|
+
text = "blah " + "ha" * 1000
|
|
24
|
+
assert runaway_repetition_len(text) > 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_detects_repeated_word_unit():
|
|
28
|
+
text = "intro\n" + "the same thing over and over " * 60
|
|
29
|
+
assert runaway_repetition_len(text) > 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── does NOT fire on legitimate content ────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def test_short_repetition_is_fine():
|
|
35
|
+
# A handful of identical short lines is normal, not a collapse.
|
|
36
|
+
assert runaway_repetition_len("foo\n" * 10) == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_horizontal_rule_is_fine():
|
|
40
|
+
text = "## Section\n" + "-" * 80 + "\nbody text here, perfectly normal."
|
|
41
|
+
assert runaway_repetition_len(text) == 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_normal_prose_is_fine():
|
|
45
|
+
text = (
|
|
46
|
+
"I fixed the bug by reordering the messages so a user turn never "
|
|
47
|
+
"follows a tool result. I added a regression test and ran the suite; "
|
|
48
|
+
"all 252 tests pass. Let me know if you want anything else.\n"
|
|
49
|
+
) * 3
|
|
50
|
+
assert runaway_repetition_len(text) == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_blank_lines_do_not_trip_it():
|
|
54
|
+
# Many blank lines (whitespace-only unit) must be ignored.
|
|
55
|
+
assert runaway_repetition_len("text\n" + "\n" * 500) == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_empty_and_short_text():
|
|
59
|
+
assert runaway_repetition_len("") == 0
|
|
60
|
+
assert runaway_repetition_len("short") == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_code_with_repeated_short_lines_is_fine():
|
|
64
|
+
# Real code often repeats short lines (e.g. closing braces); a dozen is fine.
|
|
65
|
+
text = "def f():\n" + " pass\n" * 12
|
|
66
|
+
assert runaway_repetition_len(text) == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── integration: the real stream() path trims + stops a collapse ────────────
|
|
70
|
+
|
|
71
|
+
import drydock.providers as P # noqa: E402
|
|
72
|
+
from drydock.providers import AssistantTurn, TextChunk, stream # noqa: E402
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Delta:
|
|
76
|
+
def __init__(self, c):
|
|
77
|
+
self.content = c
|
|
78
|
+
self.tool_calls = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Choice:
|
|
82
|
+
def __init__(self, c):
|
|
83
|
+
self.delta = _Delta(c)
|
|
84
|
+
self.finish_reason = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Chunk:
|
|
88
|
+
def __init__(self, c):
|
|
89
|
+
self.choices = [_Choice(c)]
|
|
90
|
+
self.usage = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_stream_trims_and_stops_a_collapse(monkeypatch):
|
|
94
|
+
# A normal opener, then the model collapses into '295:\n' a thousand times.
|
|
95
|
+
chunks = [_Chunk("Here's the trace:\n")] + [_Chunk("295:\n") for _ in range(1000)]
|
|
96
|
+
monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
|
|
97
|
+
|
|
98
|
+
full = ""
|
|
99
|
+
streamed = []
|
|
100
|
+
for ev in stream(
|
|
101
|
+
model="gemma4", system="s",
|
|
102
|
+
messages=[{"role": "user", "content": "go"}],
|
|
103
|
+
tool_schemas=[], config={"model": "gemma4", "provider": "vllm"},
|
|
104
|
+
):
|
|
105
|
+
if isinstance(ev, TextChunk):
|
|
106
|
+
streamed.append(ev.text)
|
|
107
|
+
elif isinstance(ev, AssistantTurn):
|
|
108
|
+
full = ev.text
|
|
109
|
+
|
|
110
|
+
# Stored turn keeps the real prefix, is trimmed (NOT all 1000 reps), and
|
|
111
|
+
# carries the stop marker.
|
|
112
|
+
assert full.startswith("Here's the trace:")
|
|
113
|
+
assert "stopped by drydock" in full
|
|
114
|
+
assert full.count("295:") < 500 # the runaway tail was trimmed off
|
|
115
|
+
assert any("began repeating" in s for s in streamed) # user saw the notice
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_stream_leaves_normal_output_untouched(monkeypatch):
|
|
119
|
+
chunks = [_Chunk("All "), _Chunk("done — "), _Chunk("tests pass.")]
|
|
120
|
+
monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
|
|
121
|
+
full = ""
|
|
122
|
+
for ev in stream(model="gemma4", system="s",
|
|
123
|
+
messages=[{"role": "user", "content": "go"}],
|
|
124
|
+
tool_schemas=[], config={"model": "gemma4", "provider": "vllm"}):
|
|
125
|
+
if isinstance(ev, AssistantTurn):
|
|
126
|
+
full = ev.text
|
|
127
|
+
assert full == "All done — tests pass."
|
|
128
|
+
assert "stopped by drydock" not in full
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|