drydock-cli 3.0.32__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 (63) hide show
  1. {drydock_cli-3.0.32/drydock_cli.egg-info → drydock_cli-3.0.47}/PKG-INFO +2 -2
  2. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/agent.py +1 -1
  4. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/cli.py +52 -8
  5. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/config.py +30 -5
  6. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/detect.py +1 -1
  7. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/loop_detect.py +43 -0
  8. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/providers.py +134 -15
  9. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tools/__init__.py +76 -8
  10. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/app.py +53 -7
  11. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/widgets.py +1 -1
  12. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tuning.py +27 -0
  13. {drydock_cli-3.0.32 → drydock_cli-3.0.47/drydock_cli.egg-info}/PKG-INFO +2 -2
  14. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/SOURCES.txt +8 -1
  15. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/pyproject.toml +2 -2
  16. drydock_cli-3.0.47/tests/test_bash_process_group.py +57 -0
  17. drydock_cli-3.0.47/tests/test_bash_timeout_network.py +52 -0
  18. drydock_cli-3.0.47/tests/test_compact_command.py +47 -0
  19. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_config.py +6 -6
  20. drydock_cli-3.0.47/tests/test_config_migration.py +123 -0
  21. drydock_cli-3.0.47/tests/test_oneshot_unreachable.py +39 -0
  22. drydock_cli-3.0.47/tests/test_providers_unreachable.py +95 -0
  23. drydock_cli-3.0.47/tests/test_runaway_repetition.py +128 -0
  24. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tui.py +40 -6
  25. drydock_cli-3.0.47/tests/test_vision_input.py +84 -0
  26. drydock_cli-3.0.32/tests/test_providers_unreachable.py +0 -51
  27. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/LICENSE +0 -0
  28. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/NOTICE +0 -0
  29. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/README.md +0 -0
  30. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/__main__.py +0 -0
  31. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/bash_safety.py +0 -0
  32. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/compaction.py +0 -0
  33. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/guards.py +0 -0
  34. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tool_registry.py +0 -0
  35. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/__init__.py +0 -0
  36. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/approval.py +0 -0
  37. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/messages.py +0 -0
  38. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/dependency_links.txt +0 -0
  39. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/entry_points.txt +0 -0
  40. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/requires.txt +0 -0
  41. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/top_level.txt +0 -0
  42. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/setup.cfg +0 -0
  43. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_approval.py +0 -0
  44. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_back_command.py +0 -0
  45. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_bash_safety.py +0 -0
  46. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_cli_agents.py +0 -0
  47. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_compaction.py +0 -0
  48. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_detect.py +0 -0
  49. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_empty_response.py +0 -0
  50. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_failure_loop.py +0 -0
  51. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_first_run_setup.py +0 -0
  52. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_guards_and_tools.py +0 -0
  53. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_hallucinated_tools.py +0 -0
  54. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_leaked_tool_call.py +0 -0
  55. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_loop_detect.py +0 -0
  56. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_plan_autocontinue.py +0 -0
  57. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_stop.py +0 -0
  58. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_streaming_newlines.py +0 -0
  59. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_subagent.py +0 -0
  60. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_todo.py +0 -0
  61. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tool_arg_parsing.py +0 -0
  62. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tools_undo.py +0 -0
  63. {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tuning.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.32
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
7
7
  Project-URL: Homepage, https://github.com/fbobe321/drydock
8
8
  Project-URL: Repository, https://github.com/fbobe321/drydock
9
9
  Project-URL: Issues, https://github.com/fbobe321/drydock/issues
10
- Requires-Python: >=3.12
10
+ Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  License-File: NOTICE
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.32"
10
+ __version__ = "3.0.40"
@@ -323,7 +323,7 @@ def run(
323
323
  # Safety valve: the same call has run identically (same args AND result)
324
324
  # too many times in a row — repeating it changes nothing. End the turn
325
325
  # and hand control back rather than burning turns toward MAX_TOOL_TURNS.
326
- if identical_repeat_streak >= IDENTICAL_REPEAT_CAP:
326
+ if identical_repeat_streak >= IDENTICAL_REPEAT_CAP and last_call_sig:
327
327
  yield TextChunk(
328
328
  f"\n[Stopped: the same {last_call_sig[0]} call ran "
329
329
  f"{identical_repeat_streak}× in a row with the same result — "
@@ -116,17 +116,35 @@ def run_interactive(config: dict) -> None:
116
116
 
117
117
  def run_oneshot(prompt: str, config: dict) -> None:
118
118
  """One-shot mode: run a single prompt and exit."""
119
+ from drydock.providers import LLMUnreachable
120
+
119
121
  system = SYSTEM_PROMPT + load_project_instructions()
120
122
  state = AgentState()
121
123
 
122
- for event in run(prompt, state, config, system):
123
- if isinstance(event, TextChunk):
124
- print(event.text, end="", flush=True)
125
- elif isinstance(event, ToolStart):
126
- print(f" [{event.name}]", file=sys.stderr, flush=True)
127
- elif isinstance(event, ToolEnd):
128
- pass
129
- print()
124
+ try:
125
+ for event in run(prompt, state, config, system):
126
+ if isinstance(event, TextChunk):
127
+ print(event.text, end="", flush=True)
128
+ elif isinstance(event, ToolStart):
129
+ # Include an input summary so the trace (captured to drydock.log
130
+ # under -p) shows WHAT each tool did, not just its name. Without
131
+ # this, a timed-out run is an opaque wall of "[Bash]" with no way
132
+ # to tell a genuine 100-command exploration from a tight loop.
133
+ print(f" [{event.name}] {_summarize(event.inputs)}",
134
+ file=sys.stderr, flush=True)
135
+ elif isinstance(event, ToolEnd):
136
+ # One-line outcome so failures/repeats are visible in the trace.
137
+ preview = " ".join(str(event.result).split())[:100]
138
+ print(f" -> {preview}", file=sys.stderr, flush=True)
139
+ print()
140
+ except LLMUnreachable as e:
141
+ # The model server is down, the URL is wrong, or a turn overran the read
142
+ # timeout. The TUI and --cli paths already surface this; one-shot mode
143
+ # (-p, used by automation/harnesses) had no handler, so it crashed with a
144
+ # raw traceback that buried the actionable message. Print the remediation
145
+ # cleanly to stderr and exit non-zero so callers can detect the failure.
146
+ print(f"\nError: {e}", file=sys.stderr, flush=True)
147
+ sys.exit(2)
130
148
 
131
149
 
132
150
  def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
@@ -145,6 +163,25 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
145
163
  state.turn_count = 0
146
164
  print_colored(" Conversation cleared.", "green")
147
165
  return True
166
+ elif cmd == "/compact":
167
+ from drydock.compaction import compact, estimate_tokens
168
+
169
+ if not state.messages:
170
+ print_colored(" Nothing to compact.", "dim")
171
+ return True
172
+ before = estimate_tokens(state.messages)
173
+ limit = config.get("context_limit", 65536) or 65536
174
+ state.messages = compact(state.messages, limit)
175
+ after = estimate_tokens(state.messages)
176
+ saved = before - after
177
+ if saved > 0:
178
+ print_colored(
179
+ f" Compacted: ~{before:,} → ~{after:,} tokens (freed ~{saved:,}).",
180
+ "green",
181
+ )
182
+ else:
183
+ print_colored(f" Already compact (~{before:,} tokens).", "dim")
184
+ return True
148
185
  elif cmd == "/status":
149
186
  print_colored(f" Turns: {state.turn_count}", "cyan")
150
187
  print_colored(f" Messages: {len(state.messages)}", "cyan")
@@ -213,6 +250,12 @@ def main():
213
250
  parser.add_argument("--max-tool-calls", type=int, default=0, help="Max tool calls (0=unlimited)")
214
251
  parser.add_argument("--force-first-tool", action="store_true", help="Force tool_choice=required on first turn")
215
252
  parser.add_argument("--cli", action="store_true", help="Plain readline mode instead of the TUI")
253
+ parser.add_argument(
254
+ "--dangerously-skip-permissions",
255
+ dest="dangerously_skip_permissions",
256
+ action="store_true",
257
+ help="Auto-approve all tool calls without prompting (for CI/cron use)",
258
+ )
216
259
  args = parser.parse_args()
217
260
 
218
261
  from drydock import config as cfgmod
@@ -251,6 +294,7 @@ def main():
251
294
  "context_limit": 65536,
252
295
  "max_tool_calls": args.max_tool_calls,
253
296
  "force_first_tool": args.force_first_tool,
297
+ "_approve_all": args.dangerously_skip_permissions,
254
298
  "cwd": os.getcwd(),
255
299
  "history_path": str(Path.home() / ".drydock" / "history"),
256
300
  "onboarding": onboarding,
@@ -21,7 +21,11 @@ from pathlib import Path
21
21
  DEFAULTS: dict[str, object] = {
22
22
  "model": "gemma4",
23
23
  "provider": "vllm",
24
- "base_url": "",
24
+ # Concrete (not "") so a freshly-written config SHOWS the endpoint and the
25
+ # user can edit it / point at another box. Empty fell back to the provider
26
+ # default invisibly, which left base_url absent from the file. For a non-vllm
27
+ # provider, override base_url (or use --base-url / the first-run prompt).
28
+ "base_url": "http://localhost:8000/v1",
25
29
  "max_tokens": 8192, # 4096 truncated large file writes mid-JSON (→ _raw fail)
26
30
  "temperature": 0.2,
27
31
  "theme": "harbor",
@@ -93,10 +97,14 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
93
97
 
94
98
  A NON-existent file is created once with pure DEFAULTS (no CLI flags — a
95
99
  transient --base-url for one run must not be baked in permanently). An
96
- EXISTING file is never modified: missing keys are backfilled only in the
97
- returned dict, never written back. This is deliberately conservative —
98
- the file may be shared or hand-edited, so we never reorder, reformat, or
99
- drop keys we don't recognize.
100
+ EXISTING v3 file is never modified: missing keys are backfilled only in the
101
+ returned dict, never written back. This is deliberately conservative — the
102
+ file may be shared or hand-edited, so we never reorder, reformat, or drop
103
+ keys we don't recognize.
104
+
105
+ A LEGACY/foreign file (a v2 config, or a malformed one) is migrated: backed
106
+ up to <name>.bak once, then replaced with a fresh DEFAULTS file so the user
107
+ has an editable v3 config with a visible endpoint.
100
108
  """
101
109
  path = path or default_config_path()
102
110
  if not path.exists():
@@ -104,4 +112,21 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
104
112
  file_cfg = {}
105
113
  else:
106
114
  file_cfg = load_file(path)
115
+ # Legacy/foreign-config migration. A file with NONE of our keys is either
116
+ # a v2 mistral-vibe config (nested [[providers]]/[[models]]/active_model)
117
+ # or malformed/empty — in every case the user can't edit it to set
118
+ # base_url, model, etc., and v2's nested tables can't round-trip through
119
+ # our flat emitter. Back it up once and write a fresh, complete v3 file so
120
+ # the endpoint is visible and editable (the v2->v3 upgrade path). A real
121
+ # v3 file (>=1 recognized key) is left untouched — we never reformat it or
122
+ # drop keys we don't recognize.
123
+ if file_cfg and not any(k in DEFAULTS for k in file_cfg):
124
+ backup = path.with_name(path.name + ".bak")
125
+ try:
126
+ if not backup.exists():
127
+ backup.write_bytes(path.read_bytes())
128
+ except OSError:
129
+ pass
130
+ save_file(dict(DEFAULTS), path)
131
+ file_cfg = {}
107
132
  return merge(file_cfg, cli_overrides)
@@ -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
 
@@ -71,18 +72,43 @@ def _friendly_unreachable(base_url: str, provider: str) -> str:
71
72
  )
72
73
 
73
74
 
74
- def _safe_create(client, kwargs: dict, base_url: str, provider: str):
75
+ def _friendly_timeout(base_url: str, timeout_s: float) -> str:
76
+ """The server IS reachable but a single generation overran the read timeout.
77
+ Common with slow local models doing long reasoning turns (a non-streaming
78
+ response only arrives once the whole generation finishes). Distinct from
79
+ 'unreachable' so the user fixes the right thing."""
80
+ mins = timeout_s / 60.0
81
+ return (
82
+ f"The model server at {base_url} is reachable, but the request exceeded "
83
+ f"the {timeout_s:.0f}s ({mins:.0f} min) read timeout before finishing.\n"
84
+ f" This usually means the model is generating a very long response "
85
+ f"(e.g. a big reasoning turn on a slow local model), not that the server "
86
+ f"is down.\n"
87
+ f" 1. Raise the limit: set request_timeout (seconds) in "
88
+ f"~/.drydock/config.toml.\n"
89
+ f" 2. Or shorten turns: lower the server's reasoning budget "
90
+ f"(llama.cpp --reasoning-budget).\n"
91
+ f" Your last message was not lost — just send it again."
92
+ )
93
+
94
+
95
+ def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s: float = 600.0):
75
96
  """Call chat.completions.create, mapping a connection failure to a clean
76
97
  LLMUnreachable instead of a raw traceback / 12-minute hang."""
77
98
  import openai
78
99
 
79
100
  try:
80
101
  return client.chat.completions.create(**kwargs)
102
+ # APITimeoutError subclasses APIConnectionError — catch it FIRST so a slow
103
+ # (but alive) server gets the accurate "timed out" message, not "down".
104
+ except openai.APITimeoutError as e:
105
+ raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
81
106
  except openai.APIConnectionError as e:
82
107
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
83
108
 
84
109
 
85
- def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel):
110
+ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
111
+ timeout_s: float = 600.0):
86
112
  """Like _safe_create, but runs off-thread and polls a cancel Event so STOP
87
113
  can abandon a blocked decode (raising _StopRequested). The orphaned request
88
114
  runs to completion in the pool thread and its result is dropped."""
@@ -95,6 +121,9 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
95
121
  except concurrent.futures.TimeoutError:
96
122
  if cancel is not None and cancel.is_set():
97
123
  raise _StopRequested
124
+ # APITimeoutError subclasses APIConnectionError — catch it FIRST.
125
+ except openai.APITimeoutError as e:
126
+ raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
98
127
  except openai.APIConnectionError as e:
99
128
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
100
129
 
@@ -139,6 +168,14 @@ def _stream_chunks(response, cancel):
139
168
  class TextChunk:
140
169
  text: str
141
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
+
142
179
  @dataclass
143
180
  class AssistantTurn:
144
181
  text: str
@@ -177,13 +214,67 @@ def _parse_tool_args(raw: str) -> dict:
177
214
 
178
215
  # ── Message format conversion ─────────────────────────────────────────────
179
216
 
217
+ _IMAGE_MIME = {
218
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
219
+ ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
220
+ }
221
+
222
+
223
+ def _user_content_with_images(content):
224
+ """Vision support: if the user's text references image file paths that exist
225
+ on disk, attach them as OpenAI multimodal image_url blocks (works with any
226
+ --mmproj-enabled server). Text-only prompts pass through unchanged as a plain
227
+ string, so display / loop-detection / compaction / token-counting (which all
228
+ assume string content) are untouched — the multimodal list is built ONLY here,
229
+ at the API boundary."""
230
+ if not isinstance(content, str) or "." not in content:
231
+ return content
232
+ import base64
233
+ import os
234
+ import re
235
+
236
+ exts = "png|jpe?g|gif|webp|bmp"
237
+ cands = re.findall(
238
+ rf'"([^"]+?\.(?:{exts}))"|\'([^\']+?\.(?:{exts}))\'|(\S+\.(?:{exts}))',
239
+ content, re.IGNORECASE,
240
+ )
241
+ seen: list[str] = []
242
+ for tup in cands:
243
+ raw = next((x for x in tup if x), None)
244
+ if not raw:
245
+ continue
246
+ # the greedy \S+ branch grabs surrounding markdown/punctuation, e.g. a
247
+ # path in backticks `/app/code.png`, parens, or with trailing .,;: —
248
+ # strip it so os.path.isfile sees the real path (else vision silently
249
+ # never attaches).
250
+ raw = raw.strip("`'\"()[]{}<>").rstrip(".,;:!?")
251
+ p = os.path.expanduser(raw)
252
+ if os.path.isfile(p) and p not in seen:
253
+ seen.append(p)
254
+ if not seen:
255
+ return content
256
+ blocks: list[dict] = [{"type": "text", "text": content}]
257
+ for p in seen:
258
+ mime = _IMAGE_MIME.get(os.path.splitext(p)[1].lower(), "image/png")
259
+ try:
260
+ with open(p, "rb") as f:
261
+ b64 = base64.b64encode(f.read()).decode()
262
+ except OSError:
263
+ continue
264
+ blocks.append({"type": "image_url",
265
+ "image_url": {"url": f"data:{mime};base64,{b64}"}})
266
+ return blocks if len(blocks) > 1 else content
267
+
268
+
180
269
  def messages_to_openai(messages: list, system: str) -> list:
181
270
  """Convert neutral messages to OpenAI API format."""
182
- result = [{"role": "system", "content": system}]
271
+ # value type is mixed (str content, multimodal list content, tool_calls list)
272
+ result: list[dict] = [{"role": "system", "content": system}]
183
273
  for m in messages:
184
274
  role = m["role"]
185
275
  if role == "user":
186
- result.append({"role": "user", "content": m["content"]})
276
+ result.append({"role": "user",
277
+ "content": _user_content_with_images(m["content"])})
187
278
  elif role == "assistant":
188
279
  msg = {"role": "assistant", "content": m.get("content") or None}
189
280
  tcs = m.get("tool_calls", [])
@@ -250,13 +341,19 @@ def stream(
250
341
  )
251
342
 
252
343
  # Fail fast on a dead endpoint: a 10s connect timeout and no retries surface
253
- # an unreachable server in seconds. Generation itself can still take minutes
254
- # (long read timeout).
344
+ # an unreachable server in seconds. Generation itself can take many minutes
345
+ # a slow local model doing a long reasoning turn (non-streaming, so the whole
346
+ # response only lands once it finishes) can exceed 10 min. Default the read
347
+ # timeout to 30 min and let the operator raise it via config request_timeout.
348
+ try:
349
+ read_timeout = float(config.get("request_timeout") or 1800.0)
350
+ except (TypeError, ValueError):
351
+ read_timeout = 1800.0
255
352
  client = OpenAI(
256
353
  api_key=api_key,
257
354
  base_url=base_url,
258
355
  max_retries=0,
259
- timeout=httpx.Timeout(600.0, connect=10.0),
356
+ timeout=httpx.Timeout(read_timeout, connect=10.0),
260
357
  )
261
358
  # Expose the client so the TUI's STOP (Esc / Ctrl+C) can close it mid-call —
262
359
  # closing the connection aborts an in-flight blocking decode immediately
@@ -303,7 +400,7 @@ def stream(
303
400
  # streaming. If STOP closes the client mid-call the blocking request
304
401
  # raises — swallow that as a clean cancel rather than an error.
305
402
  try:
306
- yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel)
403
+ yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout)
307
404
  except _StopRequested:
308
405
  return # STOP — abandon the in-flight request, clean stop
309
406
  except Exception:
@@ -317,9 +414,14 @@ def stream(
317
414
  text = ""
318
415
  tool_buf: dict = {} # index → {id, name, args}
319
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
320
422
 
321
423
  try:
322
- response = _create_abortable(client, kwargs, base_url, provider, cancel)
424
+ response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
323
425
  except _StopRequested:
324
426
  config.get("_abort", {}).pop("client", None)
325
427
  return
@@ -344,6 +446,20 @@ def stream(
344
446
  if chunk_text:
345
447
  text += chunk_text
346
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
347
463
 
348
464
  if delta.tool_calls:
349
465
  for tc in delta.tool_calls:
@@ -379,21 +495,24 @@ def stream(
379
495
 
380
496
 
381
497
  def _complete_nonstreaming(
382
- client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None
498
+ client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None,
499
+ timeout_s: float = 600.0,
383
500
  ) -> Generator:
384
501
  """Single non-streaming completion. Yields one TextChunk (if any text)
385
502
  then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
386
503
  Runs off-thread so STOP can abandon a blocked decode.
387
504
  """
388
- resp = _create_abortable(client, kwargs, base_url, provider, cancel)
505
+ resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s)
389
506
  choice = resp.choices[0]
390
507
  msg = choice.message
391
508
 
392
509
  # Order matters: remove <|tool_call> blobs FIRST (sets had_leak for the
393
- # retry nudge), THEN strip channel/special-token markers — otherwise the
394
- # 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.
395
512
  text, had_leak = strip_leaked_tool_calls(msg.content or "")
396
- text = strip_thinking_tokens(text)
513
+ thinking, text = extract_thinking(text)
514
+ if thinking:
515
+ yield ReasoningChunk(thinking)
397
516
  if text.strip():
398
517
  yield TextChunk(text)
399
518
 
@@ -9,6 +9,7 @@ import os
9
9
  import re
10
10
  import difflib
11
11
  import glob as _glob
12
+ import signal
12
13
  import subprocess
13
14
  import time
14
15
  from pathlib import Path
@@ -83,7 +84,7 @@ SCHEMAS = [
83
84
  "type": "object",
84
85
  "properties": {
85
86
  "command": {"type": "string"},
86
- "timeout": {"type": "integer", "description": "Seconds (default 30)"},
87
+ "timeout": {"type": "integer", "description": "Seconds (default 120; bump to 1800 for builds/training/cracking)"},
87
88
  },
88
89
  "required": ["command"],
89
90
  },
@@ -438,6 +439,57 @@ def tool_edit(params: dict, config: dict) -> str:
438
439
  return f"Error editing {fp}: {e}"
439
440
 
440
441
 
442
+ def kill_process_group(proc) -> None:
443
+ """Kill a Popen launched with start_new_session AND all its descendants by
444
+ signalling the whole process group. proc.kill() alone only kills the direct
445
+ shell, orphaning children (which keep running and hold the stdout pipe open,
446
+ hanging communicate() and freezing the TUI on STOP). Falls back to proc.kill()
447
+ if the group can't be resolved (e.g. process already gone)."""
448
+ if proc is None:
449
+ return
450
+ try:
451
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
452
+ except (ProcessLookupError, PermissionError, OSError):
453
+ try:
454
+ proc.kill()
455
+ except Exception: # noqa: BLE001
456
+ pass
457
+
458
+
459
+ _OFFLINE_HINT = (
460
+ "\n[Note: this looks like a network/download command and the environment "
461
+ "appears to be OFFLINE — downloads will keep failing no matter how many "
462
+ "times you retry. Do NOT repeat the fetch. Use only files/data already "
463
+ "present in the project; if the required data genuinely isn't here, say so "
464
+ "and stop rather than looping on the download.]"
465
+ )
466
+ # Commands that reach the network (so an offline failure is expected, not a bug).
467
+ _NETWORK_CMD_RE = re.compile(
468
+ r"\b(pip3?\s+install|pip\s+download|uv\s+(pip\s+)?(install|add)|conda\s+install|"
469
+ r"npm\s+(install|i|ci)|yarn\s+add|wget|curl\s+[^|]*https?://|git\s+clone|"
470
+ r"apt(-get)?\s+(install|update)|hf\s+download|huggingface-cli\s+download|"
471
+ r"load_dataset|hf_hub_download|snapshot_download|from_pretrained)\b",
472
+ re.IGNORECASE,
473
+ )
474
+ # Output signatures of a name-resolution / connectivity failure.
475
+ _NETWORK_FAIL_RE = re.compile(
476
+ r"(could not resolve|name or service not known|temporary failure in name "
477
+ r"resolution|network is unreachable|no route to host|connection (timed out|"
478
+ r"refused)|failed to establish a new connection|max retries exceeded|"
479
+ r"getaddrinfo|no address associated|could not find a version|offline mode "
480
+ r"is enabled|connectionerror|read timed out)",
481
+ re.IGNORECASE,
482
+ )
483
+
484
+
485
+ def _is_network_command(cmd: str) -> bool:
486
+ return bool(_NETWORK_CMD_RE.search(cmd or ""))
487
+
488
+
489
+ def _looks_like_network_failure(output: str) -> bool:
490
+ return bool(_NETWORK_FAIL_RE.search(output or ""))
491
+
492
+
441
493
  def tool_bash(params: dict, config: dict) -> str:
442
494
  cmd = params.get("command")
443
495
  if not cmd:
@@ -449,7 +501,11 @@ def tool_bash(params: dict, config: dict) -> str:
449
501
  "(escape any newline as \\n; avoid raw line breaks in the JSON)."
450
502
  )
451
503
  return "Error: Bash needs a non-empty 'command'."
452
- timeout = params.get("timeout", 30)
504
+ # Default 120s (was 30s): real coding tasks compile, train, run test suites,
505
+ # and crack hashes — a 30s wall killed legitimate long work before it could
506
+ # finish (terminal-bench: 16 tasks died at 30s on builds/training). Bounded,
507
+ # and the model can pass a larger `timeout` for genuinely heavy commands.
508
+ timeout = params.get("timeout", 120)
453
509
  reason = bash_safety.dangerous_command(cmd)
454
510
  if reason is not None:
455
511
  return bash_safety.refusal_message(cmd, reason)
@@ -469,7 +525,12 @@ def tool_bash(params: dict, config: dict) -> str:
469
525
  f"({approval_reason}).\nCommand: {cmd.strip()}"
470
526
  )
471
527
  # Run via Popen (not subprocess.run) so STOP can kill it mid-execution:
472
- # the handle is stashed in config["_abort"]["proc"] for action_stop to .kill().
528
+ # the handle is stashed in config["_abort"]["proc"] for action_stop to kill.
529
+ # start_new_session=True puts the shell in its OWN process group so we can
530
+ # kill the WHOLE tree (kill_process_group): a bare proc.kill() only kills the
531
+ # /bin/sh shell, orphaning its children (e.g. a brute-force script and its
532
+ # subprocesses) which keep running AND keep the stdout pipe open — so the
533
+ # follow-up communicate() blocks forever and the TUI is stuck "working".
473
534
  # We poll communicate() in short slices, checking the cancel Event and the
474
535
  # overall timeout between slices.
475
536
  cancel = config.get("_cancel")
@@ -477,7 +538,7 @@ def tool_bash(params: dict, config: dict) -> str:
477
538
  try:
478
539
  proc = subprocess.Popen(
479
540
  cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
480
- text=True, cwd=config.get("cwd"),
541
+ text=True, cwd=config.get("cwd"), start_new_session=True,
481
542
  )
482
543
  config.setdefault("_abort", {})["proc"] = proc
483
544
  start = time.monotonic()
@@ -487,22 +548,29 @@ def tool_bash(params: dict, config: dict) -> str:
487
548
  break
488
549
  except subprocess.TimeoutExpired:
489
550
  if cancel is not None and cancel.is_set():
490
- proc.kill()
551
+ kill_process_group(proc)
491
552
  proc.communicate()
492
553
  return "[stopped by user]"
493
554
  if time.monotonic() - start > timeout:
494
- proc.kill()
555
+ kill_process_group(proc)
495
556
  proc.communicate()
496
- bigger = min(timeout * 4, 600)
497
- return (
557
+ bigger = min(timeout * 4, 1800)
558
+ msg = (
498
559
  f"Error: command timed out after {timeout}s. If it is "
499
560
  f"legitimately slow (a big query, build, download, or "
500
561
  f"test run), retry with a larger timeout — pass "
501
562
  f"timeout: {bigger}. Otherwise it may be hung."
502
563
  )
564
+ if _is_network_command(cmd):
565
+ msg += _OFFLINE_HINT
566
+ return msg
503
567
  output = out or ""
504
568
  if proc.returncode != 0:
505
569
  output += f"\n[exit code: {proc.returncode}]"
570
+ # Offline environments make downloads fail forever; the model tends
571
+ # to retry the same fetch in a loop. Tell it to stop and work local.
572
+ if _is_network_command(cmd) and _looks_like_network_failure(output):
573
+ output += _OFFLINE_HINT
506
574
  return output.strip() or "(no output)"
507
575
  except Exception as e:
508
576
  return f"Error: {e}"