drydock-cli 3.0.105__tar.gz → 3.0.107__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.105/drydock_cli.egg-info → drydock_cli-3.0.107}/PKG-INFO +1 -1
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/agent.py +39 -2
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/config.py +5 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/providers.py +36 -9
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tuning.py +20 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/pyproject.toml +1 -1
- drydock_cli-3.0.107/tests/test_overthink_interrupt.py +47 -0
- drydock_cli-3.0.107/tests/test_stall_retry.py +53 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/LICENSE +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/NOTICE +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/README.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/cci.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/cli.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/detect.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/extract.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/guards.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/poam.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/skills.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/stig.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/web.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/setup.cfg +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_config.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_xccdf.py +0 -0
|
@@ -13,13 +13,27 @@ from typing import Generator
|
|
|
13
13
|
# stall-loop while letting a long, productive plan run as far as it needs.
|
|
14
14
|
PLAN_CONTINUE_CAP = 3
|
|
15
15
|
|
|
16
|
+
# Over-think interrupt: when a call keeps stalling/over-thinking (repeated
|
|
17
|
+
# StallRetry), re-issue in "decisive mode" — a forcing suffix + a hard token cap
|
|
18
|
+
# so the model physically cannot burn thousands of reasoning tokens without
|
|
19
|
+
# acting. The model that produced this loop (gemma) generates 5-15 min / 5k+
|
|
20
|
+
# token "thinking" turns that stall_retry alone can't break (tokens flow); this
|
|
21
|
+
# forces a short, single-action turn instead.
|
|
22
|
+
_DECISIVE_MAX_TOKENS = 1500
|
|
23
|
+
_DECISIVE_SUFFIX = (
|
|
24
|
+
"\n\nURGENT — you have spent too long reasoning without acting. STOP "
|
|
25
|
+
"analyzing. Your reply must be SHORT and take exactly ONE concrete action "
|
|
26
|
+
"right now: emit a single tool call (run a command or write a file) with "
|
|
27
|
+
"little or no explanation. Do NOT lay out a plan; act."
|
|
28
|
+
)
|
|
29
|
+
|
|
16
30
|
|
|
17
31
|
def _plan_has_unfinished(config: dict) -> bool:
|
|
18
32
|
"""True if the model laid out a `todo` plan that still has non-done items."""
|
|
19
33
|
todo = config.get("_todo")
|
|
20
34
|
return bool(todo) and any(status != "done" for _, status in todo)
|
|
21
35
|
|
|
22
|
-
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk
|
|
36
|
+
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry
|
|
23
37
|
from drydock.tool_registry import schemas, execute
|
|
24
38
|
from drydock.tools import register_all
|
|
25
39
|
|
|
@@ -159,6 +173,8 @@ def run(
|
|
|
159
173
|
|
|
160
174
|
# Stream from LLM — with retry on context-length 400 error
|
|
161
175
|
retries = 0
|
|
176
|
+
stall_retries = 0
|
|
177
|
+
decisive = False # over-think interrupt: force a short, single-action turn
|
|
162
178
|
while retries < 2:
|
|
163
179
|
try:
|
|
164
180
|
available = schemas()
|
|
@@ -166,7 +182,7 @@ def run(
|
|
|
166
182
|
available = [s for s in available if s.get("name") in allow]
|
|
167
183
|
for event in stream(
|
|
168
184
|
model=turn_config["model"],
|
|
169
|
-
system=system_prompt,
|
|
185
|
+
system=(system_prompt + _DECISIVE_SUFFIX) if decisive else system_prompt,
|
|
170
186
|
messages=state.messages,
|
|
171
187
|
tool_schemas=filter_tool_schemas(available, turn_config.get("model")),
|
|
172
188
|
config=turn_config,
|
|
@@ -178,6 +194,27 @@ def run(
|
|
|
178
194
|
elif isinstance(event, AssistantTurn):
|
|
179
195
|
assistant_turn = event
|
|
180
196
|
break # success
|
|
197
|
+
except StallRetry:
|
|
198
|
+
# The call stalled or over-thought past stall_retry_secs. First
|
|
199
|
+
# retry re-issues as-is (handles a transient server hang cheaply);
|
|
200
|
+
# if it KEEPS happening it's over-thinking, so escalate to decisive
|
|
201
|
+
# mode — a forcing suffix + a hard token cap that make a long
|
|
202
|
+
# reasoning turn impossible. Bounded; on exhaustion end cleanly.
|
|
203
|
+
stall_retries += 1
|
|
204
|
+
if stall_retries > 3:
|
|
205
|
+
yield TextChunk("\n[model kept stalling/over-thinking — giving up on this step.]\n")
|
|
206
|
+
assistant_turn = None
|
|
207
|
+
break
|
|
208
|
+
if stall_retries >= 2 and not decisive:
|
|
209
|
+
decisive = True
|
|
210
|
+
turn_config = dict(turn_config)
|
|
211
|
+
cur = int(turn_config.get("max_tokens", 8192) or 8192)
|
|
212
|
+
turn_config["max_tokens"] = min(cur, _DECISIVE_MAX_TOKENS)
|
|
213
|
+
turn_config["reasoning_effort"] = "low"
|
|
214
|
+
yield TextChunk("\n[taking too long — forcing a decisive, single-action step...]\n")
|
|
215
|
+
else:
|
|
216
|
+
yield TextChunk("\n[model server stalled — retrying...]\n")
|
|
217
|
+
continue
|
|
181
218
|
except Exception as e:
|
|
182
219
|
err = str(e)
|
|
183
220
|
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
|
|
|
@@ -91,6 +91,16 @@ _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"
|
|
100
|
+
"A script you write must run with ONLY what is already installed — prefer "
|
|
101
|
+
"the standard library or shelling out to the task's own tools (openssl, git, "
|
|
102
|
+
"sqlite3); do NOT depend on a package you just pip-installed unless the task "
|
|
103
|
+
"explicitly says to (the grader runs in a clean environment).\n"
|
|
94
104
|
"You have VISION: when the user references an image file path (.png/.jpg/"
|
|
95
105
|
".jpeg/.gif/.webp/.bmp on disk — a screenshot, mockup, diagram, or photo), it "
|
|
96
106
|
"is attached and you can SEE it; you can also call the `ViewImage` tool to "
|
|
@@ -121,6 +131,16 @@ _GEMMA_SYSTEM_PROMPT = (
|
|
|
121
131
|
"steps — do NOT stop to ask whether to proceed. Call `todo` again to update "
|
|
122
132
|
"it as you finish each step, and only stop when every step is done. Do not "
|
|
123
133
|
"write a TODO.md file for this.\n"
|
|
134
|
+
"Before you finish, RE-READ the task and confirm EVERY requirement is met — "
|
|
135
|
+
"exact filenames, paths, formats, and values matter, so check them literally. "
|
|
136
|
+
"If the task ships a test/eval/check script (eval.py, test.sh, a make target), "
|
|
137
|
+
"RUN it and fix failures until it passes before you stop. Prefer acting — write "
|
|
138
|
+
"files and run commands — over long planning; don't produce a big plan without "
|
|
139
|
+
"taking a concrete step.\n"
|
|
140
|
+
"A script you write must run with ONLY what is already installed — prefer "
|
|
141
|
+
"the standard library or shelling out to the task's own tools (openssl, git, "
|
|
142
|
+
"sqlite3); do NOT depend on a package you just pip-installed unless the task "
|
|
143
|
+
"explicitly says to (the grader runs in a clean environment).\n"
|
|
124
144
|
"You have VISION: when the user's message references an image file path "
|
|
125
145
|
"(.png/.jpg/.jpeg/.gif/.webp/.bmp that exists on disk — a screenshot, mockup, "
|
|
126
146
|
"diagram, or photo), that image is attached and you can SEE it. You can also "
|
|
@@ -87,6 +87,7 @@ tests/test_leaked_tool_call.py
|
|
|
87
87
|
tests/test_loop_detect.py
|
|
88
88
|
tests/test_mcp.py
|
|
89
89
|
tests/test_oneshot_unreachable.py
|
|
90
|
+
tests/test_overthink_interrupt.py
|
|
90
91
|
tests/test_plan_autocontinue.py
|
|
91
92
|
tests/test_poam.py
|
|
92
93
|
tests/test_providers_unreachable.py
|
|
@@ -97,6 +98,7 @@ tests/test_rmf_stig_graph.py
|
|
|
97
98
|
tests/test_runaway_repetition.py
|
|
98
99
|
tests/test_server_probe.py
|
|
99
100
|
tests/test_skills.py
|
|
101
|
+
tests/test_stall_retry.py
|
|
100
102
|
tests/test_stig.py
|
|
101
103
|
tests/test_stop.py
|
|
102
104
|
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.107"
|
|
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,47 @@
|
|
|
1
|
+
"""Over-think interrupt: when a call keeps stalling/over-thinking (repeated
|
|
2
|
+
StallRetry), the agent escalates to 'decisive mode' — a forcing suffix on the
|
|
3
|
+
system prompt + a hard max_tokens cap — so the model can't burn a 5k-token
|
|
4
|
+
reasoning turn without acting. First retry re-issues as-is; 2nd+ goes decisive."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from drydock import agent
|
|
8
|
+
from drydock.agent import AgentState, _DECISIVE_SUFFIX, _DECISIVE_MAX_TOKENS
|
|
9
|
+
from drydock.providers import StallRetry, AssistantTurn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _drive(monkeypatch, stalls):
|
|
13
|
+
calls = []
|
|
14
|
+
|
|
15
|
+
def fake_stream(model, system, messages, tool_schemas, config):
|
|
16
|
+
calls.append({"system": system, "max_tokens": config.get("max_tokens"),
|
|
17
|
+
"effort": config.get("reasoning_effort")})
|
|
18
|
+
if len(calls) <= stalls:
|
|
19
|
+
raise StallRetry(1)
|
|
20
|
+
yield AssistantTurn(text="done", tool_calls=[], input_tokens=10, output_tokens=5)
|
|
21
|
+
|
|
22
|
+
monkeypatch.setattr(agent, "stream", fake_stream)
|
|
23
|
+
state = AgentState()
|
|
24
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "stall_retry_secs": 1, "context_limit": 65536}
|
|
25
|
+
list(agent.run("do it", state, cfg, "SYS"))
|
|
26
|
+
return calls
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_first_retry_not_decisive_second_is(monkeypatch):
|
|
30
|
+
calls = _drive(monkeypatch, stalls=2) # stall twice, succeed on 3rd
|
|
31
|
+
assert len(calls) == 3
|
|
32
|
+
assert _DECISIVE_SUFFIX not in calls[0]["system"] # attempt 1: as-is
|
|
33
|
+
assert _DECISIVE_SUFFIX not in calls[1]["system"] # attempt 2 (retry 1): as-is
|
|
34
|
+
assert _DECISIVE_SUFFIX in calls[2]["system"] # attempt 3 (retry 2): decisive
|
|
35
|
+
assert calls[2]["max_tokens"] <= _DECISIVE_MAX_TOKENS # hard token cap
|
|
36
|
+
assert calls[2]["effort"] == "low"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_gives_up_after_bounded_retries(monkeypatch):
|
|
40
|
+
# stalls forever → bounded (>3) → ends cleanly, doesn't loop forever
|
|
41
|
+
calls = _drive(monkeypatch, stalls=99)
|
|
42
|
+
assert len(calls) <= 5 # 1 initial + up to ~3 retries then give up
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_no_stall_no_decisive(monkeypatch):
|
|
46
|
+
calls = _drive(monkeypatch, stalls=0) # succeeds first try
|
|
47
|
+
assert len(calls) == 1 and _DECISIVE_SUFFIX not in calls[0]["system"]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|