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.
Files changed (120) hide show
  1. {drydock_cli-3.0.105/drydock_cli.egg-info → drydock_cli-3.0.107}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/agent.py +39 -2
  3. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/config.py +5 -0
  4. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/providers.py +36 -9
  5. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tuning.py +20 -0
  6. {drydock_cli-3.0.105 → drydock_cli-3.0.107/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/SOURCES.txt +2 -0
  8. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/pyproject.toml +1 -1
  9. drydock_cli-3.0.107/tests/test_overthink_interrupt.py +47 -0
  10. drydock_cli-3.0.107/tests/test_stall_retry.py +53 -0
  11. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/LICENSE +0 -0
  12. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/NOTICE +0 -0
  13. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/README.md +0 -0
  14. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/advisor.py +0 -0
  17. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/__init__.py +0 -0
  19. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-categorize.md +0 -0
  20. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-control.md +0 -0
  21. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-poam.md +0 -0
  22. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/rmf-review.md +0 -0
  23. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/stig-assess.md +0 -0
  24. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/builtin_skills/stig-remediate.md +0 -0
  25. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/cci.py +0 -0
  26. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/cli.py +0 -0
  27. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/compaction.py +0 -0
  28. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/detect.py +0 -0
  29. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/extract.py +0 -0
  30. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/gittools.py +0 -0
  31. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/graphrag.py +0 -0
  32. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/guards.py +0 -0
  33. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/loop_detect.py +0 -0
  34. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/mcp.py +0 -0
  35. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/poam.py +0 -0
  36. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/rmf.py +0 -0
  37. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/rmf_graph.py +0 -0
  38. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/skills.py +0 -0
  39. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/stig.py +0 -0
  40. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tool_registry.py +0 -0
  41. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tools/__init__.py +0 -0
  42. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/__init__.py +0 -0
  43. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/app.py +0 -0
  44. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/approval.py +0 -0
  45. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/messages.py +0 -0
  46. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/tui/widgets.py +0 -0
  47. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock/web.py +0 -0
  48. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/dependency_links.txt +0 -0
  49. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/entry_points.txt +0 -0
  50. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/requires.txt +0 -0
  51. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/drydock_cli.egg-info/top_level.txt +0 -0
  52. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/setup.cfg +0 -0
  53. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_advisor.py +0 -0
  54. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_approval.py +0 -0
  55. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_back_command.py +0 -0
  56. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_background.py +0 -0
  57. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_binary_output.py +0 -0
  58. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_crossplatform.py +0 -0
  59. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_output_bounding.py +0 -0
  60. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_process_group.py +0 -0
  61. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_safety.py +0 -0
  62. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_sanitize.py +0 -0
  63. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_shell.py +0 -0
  64. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_stdin.py +0 -0
  65. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_stop_partial.py +0 -0
  66. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_timeout_network.py +0 -0
  67. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_bash_timeout_param.py +0 -0
  68. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_cci.py +0 -0
  69. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_cli_agents.py +0 -0
  70. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_compact_command.py +0 -0
  71. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_compaction.py +0 -0
  72. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_config.py +0 -0
  73. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_config_migration.py +0 -0
  74. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_context_limit_config.py +0 -0
  75. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_detect.py +0 -0
  76. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_dispatch.py +0 -0
  77. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_e2e_connected.py +0 -0
  78. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_edit_replace_all.py +0 -0
  79. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_empty_response.py +0 -0
  80. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_extract.py +0 -0
  81. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_failure_loop.py +0 -0
  82. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_first_run_setup.py +0 -0
  83. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_gittools.py +0 -0
  84. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_glob_edit_edges.py +0 -0
  85. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_graphify_example.py +0 -0
  86. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_graphrag.py +0 -0
  87. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_grep_and_read_robust.py +0 -0
  88. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_guards_and_tools.py +0 -0
  89. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_hallucinated_tools.py +0 -0
  90. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_leaked_tool_call.py +0 -0
  91. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_loop_detect.py +0 -0
  92. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_mcp.py +0 -0
  93. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_oneshot_unreachable.py +0 -0
  94. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_plan_autocontinue.py +0 -0
  95. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_poam.py +0 -0
  96. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_providers_unreachable.py +0 -0
  97. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_read_index.py +0 -0
  98. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf.py +0 -0
  99. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf_graph.py +0 -0
  100. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_rmf_stig_graph.py +0 -0
  101. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_runaway_repetition.py +0 -0
  102. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_server_probe.py +0 -0
  103. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_skills.py +0 -0
  104. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_stig.py +0 -0
  105. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_stop.py +0 -0
  106. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_streaming_newlines.py +0 -0
  107. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_subagent.py +0 -0
  108. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_system_prompt_help.py +0 -0
  109. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_todo.py +0 -0
  110. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_coercion.py +0 -0
  111. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_coercion_more.py +0 -0
  112. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tool_arg_parsing.py +0 -0
  113. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tools_undo.py +0 -0
  114. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tui.py +0 -0
  115. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_tuning.py +0 -0
  116. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_viewimage.py +0 -0
  117. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_vision_input.py +0 -0
  118. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_web_tools.py +0 -0
  119. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_write_content_coerce.py +0 -0
  120. {drydock_cli-3.0.105 → drydock_cli-3.0.107}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.105
3
+ Version: 3.0.107
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
@@ -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 "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.105
3
+ Version: 3.0.107
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
@@ -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.105"
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