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.
Files changed (62) hide show
  1. {drydock_cli-3.0.46/drydock_cli.egg-info → drydock_cli-3.0.47}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/detect.py +1 -1
  3. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/loop_detect.py +43 -0
  4. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/providers.py +34 -4
  5. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/app.py +3 -3
  6. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tuning.py +27 -0
  7. {drydock_cli-3.0.46 → drydock_cli-3.0.47/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/SOURCES.txt +1 -0
  9. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/pyproject.toml +1 -1
  10. drydock_cli-3.0.47/tests/test_runaway_repetition.py +128 -0
  11. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/LICENSE +0 -0
  12. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/NOTICE +0 -0
  13. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/README.md +0 -0
  14. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/agent.py +0 -0
  17. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/cli.py +0 -0
  19. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/compaction.py +0 -0
  20. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/config.py +0 -0
  21. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/guards.py +0 -0
  22. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tool_registry.py +0 -0
  23. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tools/__init__.py +0 -0
  24. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/__init__.py +0 -0
  25. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/approval.py +0 -0
  26. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/messages.py +0 -0
  27. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock/tui/widgets.py +0 -0
  28. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/dependency_links.txt +0 -0
  29. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/entry_points.txt +0 -0
  30. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/requires.txt +0 -0
  31. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/drydock_cli.egg-info/top_level.txt +0 -0
  32. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/setup.cfg +0 -0
  33. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_approval.py +0 -0
  34. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_back_command.py +0 -0
  35. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_process_group.py +0 -0
  36. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_safety.py +0 -0
  37. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_bash_timeout_network.py +0 -0
  38. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_cli_agents.py +0 -0
  39. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_compact_command.py +0 -0
  40. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_compaction.py +0 -0
  41. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_config.py +0 -0
  42. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_config_migration.py +0 -0
  43. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_detect.py +0 -0
  44. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_empty_response.py +0 -0
  45. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_failure_loop.py +0 -0
  46. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_first_run_setup.py +0 -0
  47. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_guards_and_tools.py +0 -0
  48. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_hallucinated_tools.py +0 -0
  49. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_leaked_tool_call.py +0 -0
  50. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_loop_detect.py +0 -0
  51. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_oneshot_unreachable.py +0 -0
  52. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_plan_autocontinue.py +0 -0
  53. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_providers_unreachable.py +0 -0
  54. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_stop.py +0 -0
  55. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_streaming_newlines.py +0 -0
  56. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_subagent.py +0 -0
  57. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_todo.py +0 -0
  58. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tool_arg_parsing.py +0 -0
  59. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tools_undo.py +0 -0
  60. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tui.py +0 -0
  61. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_tuning.py +0 -0
  62. {drydock_cli-3.0.46 → drydock_cli-3.0.47}/tests/test_vision_input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.46
3
+ Version: 3.0.47
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.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://host:port/v1> then /model <model-name>\n"
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.tuning import strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
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 the
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 = strip_thinking_tokens(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://host:8000/v1)
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://host:port/v1> · "
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://host:port/v1>")
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.46
3
+ Version: 3.0.47
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -48,6 +48,7 @@ tests/test_loop_detect.py
48
48
  tests/test_oneshot_unreachable.py
49
49
  tests/test_plan_autocontinue.py
50
50
  tests/test_providers_unreachable.py
51
+ tests/test_runaway_repetition.py
51
52
  tests/test_stop.py
52
53
  tests/test_streaming_newlines.py
53
54
  tests/test_subagent.py
@@ -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.46"
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