drydock-cli 3.0.104__tar.gz → 3.0.106__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.104/drydock_cli.egg-info → drydock_cli-3.0.106}/PKG-INFO +1 -1
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/agent.py +15 -1
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/config.py +5 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/providers.py +36 -9
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tools/__init__.py +38 -5
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tuning.py +12 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/pyproject.toml +1 -1
- drydock_cli-3.0.106/tests/test_bash_crossplatform.py +49 -0
- drydock_cli-3.0.106/tests/test_stall_retry.py +53 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/LICENSE +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/NOTICE +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/README.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/cci.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/cli.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/detect.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/extract.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/guards.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/poam.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/skills.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/stig.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock/web.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/setup.cfg +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_config.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.104 → drydock_cli-3.0.106}/tests/test_xccdf.py +0 -0
|
@@ -19,7 +19,7 @@ def _plan_has_unfinished(config: dict) -> bool:
|
|
|
19
19
|
todo = config.get("_todo")
|
|
20
20
|
return bool(todo) and any(status != "done" for _, status in todo)
|
|
21
21
|
|
|
22
|
-
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk
|
|
22
|
+
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry
|
|
23
23
|
from drydock.tool_registry import schemas, execute
|
|
24
24
|
from drydock.tools import register_all
|
|
25
25
|
|
|
@@ -159,6 +159,7 @@ def run(
|
|
|
159
159
|
|
|
160
160
|
# Stream from LLM — with retry on context-length 400 error
|
|
161
161
|
retries = 0
|
|
162
|
+
stall_retries = 0
|
|
162
163
|
while retries < 2:
|
|
163
164
|
try:
|
|
164
165
|
available = schemas()
|
|
@@ -178,6 +179,19 @@ def run(
|
|
|
178
179
|
elif isinstance(event, AssistantTurn):
|
|
179
180
|
assistant_turn = event
|
|
180
181
|
break # success
|
|
182
|
+
except StallRetry:
|
|
183
|
+
# The local server hung mid-generation (config stall_retry_secs).
|
|
184
|
+
# Re-issue the same request — a fresh generation usually isn't
|
|
185
|
+
# stalled. Bounded so a truly-dead server can't loop forever; on
|
|
186
|
+
# exhaustion end the turn cleanly. Stalls have their OWN budget,
|
|
187
|
+
# separate from the context-error `retries`.
|
|
188
|
+
stall_retries += 1
|
|
189
|
+
if stall_retries > 3:
|
|
190
|
+
yield TextChunk("\n[model server kept stalling — giving up on this step.]\n")
|
|
191
|
+
assistant_turn = None
|
|
192
|
+
break
|
|
193
|
+
yield TextChunk("\n[model server stalled — retrying...]\n")
|
|
194
|
+
continue
|
|
181
195
|
except Exception as e:
|
|
182
196
|
err = str(e)
|
|
183
197
|
if is_context_length_error(err):
|
|
@@ -34,6 +34,11 @@ DEFAULTS: dict[str, object] = {
|
|
|
34
34
|
# real window BEFORE compacting (the server 400s) — set this to your -c
|
|
35
35
|
# value. Default 65536 matches the bundled gemma4 server (-c 65536).
|
|
36
36
|
"context_limit": 65536,
|
|
37
|
+
# Auto-retry a hung model call: if the server produces NOTHING for this many
|
|
38
|
+
# seconds, drydock abandons the wedged request and re-issues it (a fresh
|
|
39
|
+
# generation usually isn't stalled — a known gemma/llama.cpp hang). 0 = off.
|
|
40
|
+
# Set to e.g. 600 on a stall-prone local server. Bounded to a few retries.
|
|
41
|
+
"stall_retry_secs": 0,
|
|
37
42
|
"theme": "harbor",
|
|
38
43
|
# Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
|
|
39
44
|
# Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
|
|
@@ -23,6 +23,13 @@ _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
|
+
|
|
27
|
+
class StallRetry(Exception):
|
|
28
|
+
"""The model server produced no progress for `stall_retry_secs` — the local
|
|
29
|
+
server has hung mid-generation (a known gemma/llama.cpp failure). The agent
|
|
30
|
+
abandons the wedged request and re-issues it; a fresh generation usually
|
|
31
|
+
doesn't stall. Opt-in via config `stall_retry_secs` (0 = off)."""
|
|
32
|
+
|
|
26
33
|
from drydock.loop_detect import runaway_repetition_len
|
|
27
34
|
from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
|
|
28
35
|
|
|
@@ -138,19 +145,26 @@ def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s:
|
|
|
138
145
|
|
|
139
146
|
|
|
140
147
|
def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
|
|
141
|
-
timeout_s: float = 600.0):
|
|
148
|
+
timeout_s: float = 600.0, stall_secs: float = 0.0):
|
|
142
149
|
"""Like _safe_create, but runs off-thread and polls a cancel Event so STOP
|
|
143
150
|
can abandon a blocked decode (raising _StopRequested). The orphaned request
|
|
144
|
-
runs to completion in the pool thread and its result is dropped.
|
|
151
|
+
runs to completion in the pool thread and its result is dropped.
|
|
152
|
+
|
|
153
|
+
If stall_secs > 0 and the request produces nothing in that long, raise
|
|
154
|
+
StallRetry so the caller can abandon a hung server and re-issue."""
|
|
145
155
|
import openai
|
|
156
|
+
import time as _time
|
|
146
157
|
|
|
147
158
|
fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
|
|
159
|
+
start = _time.monotonic()
|
|
148
160
|
while True:
|
|
149
161
|
try:
|
|
150
162
|
return fut.result(timeout=0.2)
|
|
151
163
|
except concurrent.futures.TimeoutError:
|
|
152
164
|
if cancel is not None and cancel.is_set():
|
|
153
165
|
raise _StopRequested
|
|
166
|
+
if stall_secs and (_time.monotonic() - start) > stall_secs:
|
|
167
|
+
raise StallRetry(stall_secs) # orphan the wedged request; caller retries
|
|
154
168
|
# APITimeoutError subclasses APIConnectionError — catch it FIRST.
|
|
155
169
|
except openai.APITimeoutError as e:
|
|
156
170
|
raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
|
|
@@ -158,12 +172,17 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
|
|
|
158
172
|
raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
|
|
159
173
|
|
|
160
174
|
|
|
161
|
-
def _stream_chunks(response, cancel):
|
|
175
|
+
def _stream_chunks(response, cancel, stall_secs: float = 0.0):
|
|
162
176
|
"""Yield streamed chunks, but pull them via a background thread + queue so a
|
|
163
177
|
STOP can abandon a read that's blocked waiting for the first token (the
|
|
164
178
|
thinking phase). On cancel we just stop reading; the orphaned producer
|
|
165
|
-
drains in the background.
|
|
179
|
+
drains in the background.
|
|
180
|
+
|
|
181
|
+
If stall_secs > 0 and no chunk arrives for that long, raise StallRetry — a
|
|
182
|
+
hung server. (A slow-but-alive stream still emits chunks, which reset the
|
|
183
|
+
timer, so this fires only on a true stall.)"""
|
|
166
184
|
import queue as _queue
|
|
185
|
+
import time as _time
|
|
167
186
|
|
|
168
187
|
q: _queue.Queue = _queue.Queue()
|
|
169
188
|
_DONE = object()
|
|
@@ -178,13 +197,17 @@ def _stream_chunks(response, cancel):
|
|
|
178
197
|
q.put(_DONE)
|
|
179
198
|
|
|
180
199
|
_LLM_POOL.submit(_produce)
|
|
200
|
+
last = _time.monotonic()
|
|
181
201
|
while True:
|
|
182
202
|
if cancel is not None and cancel.is_set():
|
|
183
203
|
return
|
|
184
204
|
try:
|
|
185
205
|
item = q.get(timeout=0.2)
|
|
186
206
|
except _queue.Empty:
|
|
207
|
+
if stall_secs and (_time.monotonic() - last) > stall_secs:
|
|
208
|
+
raise StallRetry(stall_secs)
|
|
187
209
|
continue
|
|
210
|
+
last = _time.monotonic()
|
|
188
211
|
if item is _DONE:
|
|
189
212
|
return
|
|
190
213
|
if isinstance(item, Exception):
|
|
@@ -410,6 +433,10 @@ def stream(
|
|
|
410
433
|
read_timeout = float(config.get("request_timeout") or 1800.0)
|
|
411
434
|
except (TypeError, ValueError):
|
|
412
435
|
read_timeout = 1800.0
|
|
436
|
+
try:
|
|
437
|
+
stall_secs = float(config.get("stall_retry_secs") or 0) # 0 = disabled
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
stall_secs = 0.0
|
|
413
440
|
client = OpenAI(
|
|
414
441
|
api_key=api_key,
|
|
415
442
|
base_url=base_url,
|
|
@@ -461,7 +488,7 @@ def stream(
|
|
|
461
488
|
# streaming. If STOP closes the client mid-call the blocking request
|
|
462
489
|
# raises — swallow that as a clean cancel rather than an error.
|
|
463
490
|
try:
|
|
464
|
-
yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout)
|
|
491
|
+
yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout, stall_secs)
|
|
465
492
|
except _StopRequested:
|
|
466
493
|
return # STOP — abandon the in-flight request, clean stop
|
|
467
494
|
except Exception:
|
|
@@ -482,11 +509,11 @@ def stream(
|
|
|
482
509
|
_rep_checked_at = 0
|
|
483
510
|
|
|
484
511
|
try:
|
|
485
|
-
response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
|
|
512
|
+
response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout, stall_secs)
|
|
486
513
|
except _StopRequested:
|
|
487
514
|
config.get("_abort", {}).pop("client", None)
|
|
488
515
|
return
|
|
489
|
-
for chunk in _stream_chunks(response, cancel):
|
|
516
|
+
for chunk in _stream_chunks(response, cancel, stall_secs):
|
|
490
517
|
if not chunk.choices:
|
|
491
518
|
if hasattr(chunk, "usage") and chunk.usage:
|
|
492
519
|
in_tok = chunk.usage.prompt_tokens or in_tok
|
|
@@ -557,13 +584,13 @@ def stream(
|
|
|
557
584
|
|
|
558
585
|
def _complete_nonstreaming(
|
|
559
586
|
client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None,
|
|
560
|
-
timeout_s: float = 600.0,
|
|
587
|
+
timeout_s: float = 600.0, stall_secs: float = 0.0,
|
|
561
588
|
) -> Generator:
|
|
562
589
|
"""Single non-streaming completion. Yields one TextChunk (if any text)
|
|
563
590
|
then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
|
|
564
591
|
Runs off-thread so STOP can abandon a blocked decode.
|
|
565
592
|
"""
|
|
566
|
-
resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s)
|
|
593
|
+
resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s, stall_secs)
|
|
567
594
|
choice = resp.choices[0]
|
|
568
595
|
msg = choice.message
|
|
569
596
|
|
|
@@ -82,14 +82,20 @@ def _detect_bash() -> str | None:
|
|
|
82
82
|
found = shutil.which("bash")
|
|
83
83
|
if found:
|
|
84
84
|
return found
|
|
85
|
-
|
|
85
|
+
# Common absolute locations, incl. Git-for-Windows and WSL bash on Windows.
|
|
86
|
+
for p in ("/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash",
|
|
87
|
+
r"C:\Program Files\Git\bin\bash.exe",
|
|
88
|
+
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
89
|
+
r"C:\Windows\System32\bash.exe"):
|
|
86
90
|
if os.path.exists(p):
|
|
87
91
|
return p
|
|
88
92
|
return None
|
|
89
93
|
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
#
|
|
95
|
+
_IS_WINDOWS = os.name == "nt"
|
|
96
|
+
# Resolved once at import (in whatever environment drydock runs — host, the task
|
|
97
|
+
# container, Linux, or Windows). None → bash unavailable, fall back to the
|
|
98
|
+
# platform's default shell (cmd.exe on Windows, /bin/sh on POSIX).
|
|
93
99
|
_BASH_SHELL = _detect_bash()
|
|
94
100
|
|
|
95
101
|
|
|
@@ -928,6 +934,18 @@ def kill_process_group(proc) -> None:
|
|
|
928
934
|
if the group can't be resolved (e.g. process already gone)."""
|
|
929
935
|
if proc is None:
|
|
930
936
|
return
|
|
937
|
+
if _IS_WINDOWS:
|
|
938
|
+
# No process groups / SIGKILL on Windows — taskkill /T kills the whole
|
|
939
|
+
# child tree (the shell + everything it spawned).
|
|
940
|
+
try:
|
|
941
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
942
|
+
capture_output=True, timeout=10)
|
|
943
|
+
except Exception: # noqa: BLE001
|
|
944
|
+
try:
|
|
945
|
+
proc.kill()
|
|
946
|
+
except Exception: # noqa: BLE001
|
|
947
|
+
pass
|
|
948
|
+
return
|
|
931
949
|
try:
|
|
932
950
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
933
951
|
except (ProcessLookupError, PermissionError, OSError):
|
|
@@ -1016,9 +1034,19 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
1016
1034
|
# overall timeout between slices.
|
|
1017
1035
|
cancel = config.get("_cancel")
|
|
1018
1036
|
proc = None
|
|
1037
|
+
# Invoke bash explicitly as [bash, "-c", cmd] rather than shell=True +
|
|
1038
|
+
# executable — cross-platform correctness: on Windows shell=True builds
|
|
1039
|
+
# `{executable} /c {cmd}` (cmd.exe syntax), so a real bash.exe (Git Bash/WSL)
|
|
1040
|
+
# would be called as `bash /c ...` and fail. `[bash, "-c", cmd]` runs the same
|
|
1041
|
+
# on Linux and Windows. Without bash, fall back to the platform default shell
|
|
1042
|
+
# (cmd.exe on Windows, /bin/sh on POSIX) via shell=True.
|
|
1043
|
+
if _BASH_SHELL:
|
|
1044
|
+
popen_cmd, use_shell = [_BASH_SHELL, "-c", cmd], False
|
|
1045
|
+
else:
|
|
1046
|
+
popen_cmd, use_shell = cmd, True
|
|
1019
1047
|
try:
|
|
1020
1048
|
proc = subprocess.Popen(
|
|
1021
|
-
|
|
1049
|
+
popen_cmd, shell=use_shell,
|
|
1022
1050
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
1023
1051
|
# stdin=DEVNULL so a command that reads stdin gets immediate EOF
|
|
1024
1052
|
# (correct for a non-interactive tool) instead of inheriting the
|
|
@@ -1033,7 +1061,12 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
1033
1061
|
# which killed the thread and handed the agent "(no output)", losing
|
|
1034
1062
|
# even the text parts of mixed output.
|
|
1035
1063
|
text=True, encoding="utf-8", errors="replace",
|
|
1036
|
-
cwd=config.get("cwd"),
|
|
1064
|
+
cwd=config.get("cwd"),
|
|
1065
|
+
# Own process group / job so kill_process_group takes down the whole
|
|
1066
|
+
# tree: setsid on POSIX; a new process group on Windows (getattr keeps
|
|
1067
|
+
# the Windows-only flag out of the POSIX code path — it resolves to 0).
|
|
1068
|
+
start_new_session=not _IS_WINDOWS,
|
|
1069
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
|
1037
1070
|
)
|
|
1038
1071
|
config.setdefault("_abort", {})["proc"] = proc
|
|
1039
1072
|
# Read output in a daemon thread with a HARD byte cap, so memory can't
|
|
@@ -91,6 +91,12 @@ _DEFAULT_SYSTEM_PROMPT = (
|
|
|
91
91
|
"NOT stop to ask whether to proceed. Call `todo` again to flip a step to "
|
|
92
92
|
"done and the next to in-progress as you finish each, and only stop when "
|
|
93
93
|
"every step is done. Don't write a TODO.md file for this.\n"
|
|
94
|
+
"Before you finish, RE-READ the task and confirm EVERY requirement is met — "
|
|
95
|
+
"exact filenames, paths, formats, and values matter, so check them literally. "
|
|
96
|
+
"If the task ships a test/eval/check script (eval.py, test.sh, a make target), "
|
|
97
|
+
"RUN it and fix failures until it passes before you stop. Prefer acting — write "
|
|
98
|
+
"files and run commands — over long planning; don't produce a big plan without "
|
|
99
|
+
"taking a concrete step.\n"
|
|
94
100
|
"You have VISION: when the user references an image file path (.png/.jpg/"
|
|
95
101
|
".jpeg/.gif/.webp/.bmp on disk — a screenshot, mockup, diagram, or photo), it "
|
|
96
102
|
"is attached and you can SEE it; you can also call the `ViewImage` tool to "
|
|
@@ -121,6 +127,12 @@ _GEMMA_SYSTEM_PROMPT = (
|
|
|
121
127
|
"steps — do NOT stop to ask whether to proceed. Call `todo` again to update "
|
|
122
128
|
"it as you finish each step, and only stop when every step is done. Do not "
|
|
123
129
|
"write a TODO.md file for this.\n"
|
|
130
|
+
"Before you finish, RE-READ the task and confirm EVERY requirement is met — "
|
|
131
|
+
"exact filenames, paths, formats, and values matter, so check them literally. "
|
|
132
|
+
"If the task ships a test/eval/check script (eval.py, test.sh, a make target), "
|
|
133
|
+
"RUN it and fix failures until it passes before you stop. Prefer acting — write "
|
|
134
|
+
"files and run commands — over long planning; don't produce a big plan without "
|
|
135
|
+
"taking a concrete step.\n"
|
|
124
136
|
"You have VISION: when the user's message references an image file path "
|
|
125
137
|
"(.png/.jpg/.jpeg/.gif/.webp/.bmp that exists on disk — a screenshot, mockup, "
|
|
126
138
|
"diagram, or photo), that image is attached and you can SEE it. You can also "
|
|
@@ -51,6 +51,7 @@ tests/test_approval.py
|
|
|
51
51
|
tests/test_back_command.py
|
|
52
52
|
tests/test_bash_background.py
|
|
53
53
|
tests/test_bash_binary_output.py
|
|
54
|
+
tests/test_bash_crossplatform.py
|
|
54
55
|
tests/test_bash_output_bounding.py
|
|
55
56
|
tests/test_bash_process_group.py
|
|
56
57
|
tests/test_bash_safety.py
|
|
@@ -96,6 +97,7 @@ tests/test_rmf_stig_graph.py
|
|
|
96
97
|
tests/test_runaway_repetition.py
|
|
97
98
|
tests/test_server_probe.py
|
|
98
99
|
tests/test_skills.py
|
|
100
|
+
tests/test_stall_retry.py
|
|
99
101
|
tests/test_stig.py
|
|
100
102
|
tests/test_stop.py
|
|
101
103
|
tests/test_streaming_newlines.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.
|
|
10
|
+
version = "3.0.106"
|
|
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,49 @@
|
|
|
1
|
+
"""tool_bash must run on Linux AND Windows. The Windows blocker was shell=True +
|
|
2
|
+
executable=bash → Popen builds `bash /c cmd` (cmd.exe syntax) which a real
|
|
3
|
+
bash.exe rejects. Fix: invoke `[bash, "-c", cmd]` explicitly; use taskkill on
|
|
4
|
+
Windows for the process-tree kill; setsid only on POSIX."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from drydock import tools
|
|
11
|
+
from drydock.tools import _detect_bash, _IS_WINDOWS, kill_process_group, tool_bash
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_platform_flag_matches_os():
|
|
15
|
+
assert _IS_WINDOWS == (os.name == "nt")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_detect_bash_returns_path_or_none():
|
|
19
|
+
r = _detect_bash()
|
|
20
|
+
assert r is None or (isinstance(r, str) and "bash" in r.lower())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_bash_invoked_as_argv_not_shell_c(monkeypatch):
|
|
24
|
+
"""When bash is available, Popen is called with [bash, '-c', cmd] and
|
|
25
|
+
shell=False — never shell=True+executable (which breaks on Windows)."""
|
|
26
|
+
if not tools._BASH_SHELL:
|
|
27
|
+
return
|
|
28
|
+
seen = {}
|
|
29
|
+
real = subprocess.Popen
|
|
30
|
+
|
|
31
|
+
def spy(popen_cmd, *a, **kw):
|
|
32
|
+
seen["cmd"] = popen_cmd
|
|
33
|
+
seen["shell"] = kw.get("shell")
|
|
34
|
+
return real(popen_cmd, *a, **kw)
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(subprocess, "Popen", spy)
|
|
37
|
+
tool_bash({"command": "echo hi", "timeout": 5}, {"cwd": "/tmp"})
|
|
38
|
+
assert seen["shell"] is False
|
|
39
|
+
assert isinstance(seen["cmd"], list) and seen["cmd"][1] == "-c"
|
|
40
|
+
assert seen["cmd"][0].lower().endswith(("bash", "bash.exe"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_kill_process_group_none_is_safe():
|
|
44
|
+
kill_process_group(None) # must not raise
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_command_still_runs_here():
|
|
48
|
+
out = tool_bash({"command": "echo cross_platform_ok", "timeout": 5}, {"cwd": "/tmp"})
|
|
49
|
+
assert "cross_platform_ok" in out
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Auto-retry on server stall (config stall_retry_secs, default 0=off): a hung
|
|
2
|
+
model call (server produces nothing for stall_secs) raises StallRetry so the
|
|
3
|
+
agent abandons the wedged request and re-issues it. Plus the self-verify /
|
|
4
|
+
run-test-script / act prompt guidance."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from drydock.providers import StallRetry, _create_abortable
|
|
10
|
+
from drydock import config
|
|
11
|
+
from drydock.tuning import system_prompt_for_model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _HangingClient:
|
|
15
|
+
class chat:
|
|
16
|
+
class completions:
|
|
17
|
+
@staticmethod
|
|
18
|
+
def create(**kw):
|
|
19
|
+
time.sleep(60) # simulate a wedged server
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_stall_retry_raised_on_hang():
|
|
23
|
+
t0 = time.monotonic()
|
|
24
|
+
raised = False
|
|
25
|
+
try:
|
|
26
|
+
_create_abortable(_HangingClient(), {}, "url", "vllm", None,
|
|
27
|
+
timeout_s=600, stall_secs=1)
|
|
28
|
+
except StallRetry:
|
|
29
|
+
raised = True
|
|
30
|
+
assert raised and (time.monotonic() - t0) < 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_stall_retry_disabled_by_default():
|
|
34
|
+
assert config.DEFAULTS.get("stall_retry_secs") == 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_no_stall_when_secs_zero_returns_quickly():
|
|
38
|
+
# stall_secs=0 → never raises StallRetry; a fast create returns normally
|
|
39
|
+
class Fast:
|
|
40
|
+
class chat:
|
|
41
|
+
class completions:
|
|
42
|
+
@staticmethod
|
|
43
|
+
def create(**kw):
|
|
44
|
+
return "ok"
|
|
45
|
+
assert _create_abortable(Fast(), {}, "u", "vllm", None, stall_secs=0) == "ok"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_prompts_have_verify_and_act_guidance():
|
|
49
|
+
for m in ("gemma4", "some-other-model"):
|
|
50
|
+
p = system_prompt_for_model(m)
|
|
51
|
+
assert "RE-READ the task" in p
|
|
52
|
+
assert "eval.py" in p and "test.sh" in p # run-the-test-script
|
|
53
|
+
assert "Prefer acting" in p # act, don't over-plan
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|