drydock-cli 3.0.27__tar.gz → 3.0.28__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.27/drydock_cli.egg-info → drydock_cli-3.0.28}/PKG-INFO +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/agent.py +12 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/providers.py +93 -6
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tools/__init__.py +36 -15
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tui/app.py +39 -8
- {drydock_cli-3.0.27 → drydock_cli-3.0.28/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/pyproject.toml +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_stop.py +25 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_streaming_newlines.py +2 -2
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/LICENSE +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/NOTICE +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/README.md +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/cli.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/config.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/guards.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock_cli.egg-info/SOURCES.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/setup.cfg +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_config.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.28}/tests/test_tuning.py +0 -0
|
@@ -251,6 +251,18 @@ def run(
|
|
|
251
251
|
if tc["name"] in ("Edit", "Write"):
|
|
252
252
|
session_has_edited = True
|
|
253
253
|
|
|
254
|
+
# STOP pressed: don't run the remaining tools, but still record a
|
|
255
|
+
# paired result for each (the assistant message already lists all
|
|
256
|
+
# tool_calls — leaving one without a tool result corrupts history).
|
|
257
|
+
if _stopped():
|
|
258
|
+
state.messages.append({
|
|
259
|
+
"role": "tool",
|
|
260
|
+
"tool_call_id": tc["id"],
|
|
261
|
+
"name": tc["name"],
|
|
262
|
+
"content": "[skipped — stopped by user]",
|
|
263
|
+
})
|
|
264
|
+
continue
|
|
265
|
+
|
|
254
266
|
# Check tool call limit
|
|
255
267
|
if max_tool_calls > 0 and tool_call_count > max_tool_calls:
|
|
256
268
|
state.messages.append({
|
|
@@ -7,11 +7,22 @@ DryDock v3 — clean rewrite, no model-specific tool call parsers.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import concurrent.futures
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
12
13
|
from dataclasses import dataclass
|
|
13
14
|
from typing import Generator
|
|
14
15
|
|
|
16
|
+
# Run blocking LLM calls here so STOP can ABANDON one mid-decode: closing the
|
|
17
|
+
# HTTP client does NOT interrupt an in-flight blocking read, so instead we wait
|
|
18
|
+
# on a future and, when the user cancels, return immediately and let the
|
|
19
|
+
# orphaned request finish (and be discarded) in the background.
|
|
20
|
+
_LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _StopRequested(Exception):
|
|
24
|
+
"""Internal: STOP was pressed during a blocking LLM call."""
|
|
25
|
+
|
|
15
26
|
from drydock.tuning import strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
|
|
16
27
|
|
|
17
28
|
# ── Provider registry ─────────────────────────────────────────────────────
|
|
@@ -71,6 +82,57 @@ def _safe_create(client, kwargs: dict, base_url: str, provider: str):
|
|
|
71
82
|
raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
|
|
72
83
|
|
|
73
84
|
|
|
85
|
+
def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel):
|
|
86
|
+
"""Like _safe_create, but runs off-thread and polls a cancel Event so STOP
|
|
87
|
+
can abandon a blocked decode (raising _StopRequested). The orphaned request
|
|
88
|
+
runs to completion in the pool thread and its result is dropped."""
|
|
89
|
+
import openai
|
|
90
|
+
|
|
91
|
+
fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
return fut.result(timeout=0.2)
|
|
95
|
+
except concurrent.futures.TimeoutError:
|
|
96
|
+
if cancel is not None and cancel.is_set():
|
|
97
|
+
raise _StopRequested
|
|
98
|
+
except openai.APIConnectionError as e:
|
|
99
|
+
raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _stream_chunks(response, cancel):
|
|
103
|
+
"""Yield streamed chunks, but pull them via a background thread + queue so a
|
|
104
|
+
STOP can abandon a read that's blocked waiting for the first token (the
|
|
105
|
+
thinking phase). On cancel we just stop reading; the orphaned producer
|
|
106
|
+
drains in the background."""
|
|
107
|
+
import queue as _queue
|
|
108
|
+
|
|
109
|
+
q: _queue.Queue = _queue.Queue()
|
|
110
|
+
_DONE = object()
|
|
111
|
+
|
|
112
|
+
def _produce():
|
|
113
|
+
try:
|
|
114
|
+
for ch in response:
|
|
115
|
+
q.put(ch)
|
|
116
|
+
except Exception as e: # noqa: BLE001 — surface to the consumer
|
|
117
|
+
q.put(e)
|
|
118
|
+
finally:
|
|
119
|
+
q.put(_DONE)
|
|
120
|
+
|
|
121
|
+
_LLM_POOL.submit(_produce)
|
|
122
|
+
while True:
|
|
123
|
+
if cancel is not None and cancel.is_set():
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
item = q.get(timeout=0.2)
|
|
127
|
+
except _queue.Empty:
|
|
128
|
+
continue
|
|
129
|
+
if item is _DONE:
|
|
130
|
+
return
|
|
131
|
+
if isinstance(item, Exception):
|
|
132
|
+
raise item
|
|
133
|
+
yield item
|
|
134
|
+
|
|
135
|
+
|
|
74
136
|
# ── Event types ───────────────────────────────────────────────────────────
|
|
75
137
|
|
|
76
138
|
@dataclass
|
|
@@ -196,6 +258,13 @@ def stream(
|
|
|
196
258
|
max_retries=0,
|
|
197
259
|
timeout=httpx.Timeout(600.0, connect=10.0),
|
|
198
260
|
)
|
|
261
|
+
# Expose the client so the TUI's STOP (Esc / Ctrl+C) can close it mid-call —
|
|
262
|
+
# closing the connection aborts an in-flight blocking decode immediately
|
|
263
|
+
# instead of waiting out the whole generation.
|
|
264
|
+
cancel = config.get("_cancel")
|
|
265
|
+
# Stash in the SHARED abort holder (a mutable dict that survives run()'s
|
|
266
|
+
# dict(config) shallow-copy) so the TUI's STOP can reach this exact client.
|
|
267
|
+
config.setdefault("_abort", {})["client"] = client
|
|
199
268
|
|
|
200
269
|
oai_messages = messages_to_openai(messages, system)
|
|
201
270
|
|
|
@@ -231,16 +300,30 @@ def stream(
|
|
|
231
300
|
if not do_stream:
|
|
232
301
|
# Non-streaming: one request, parse the complete message. This is the
|
|
233
302
|
# reliable path for local models whose tool-call JSON breaks under
|
|
234
|
-
# streaming.
|
|
235
|
-
|
|
303
|
+
# streaming. If STOP closes the client mid-call the blocking request
|
|
304
|
+
# raises — swallow that as a clean cancel rather than an error.
|
|
305
|
+
try:
|
|
306
|
+
yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel)
|
|
307
|
+
except _StopRequested:
|
|
308
|
+
return # STOP — abandon the in-flight request, clean stop
|
|
309
|
+
except Exception:
|
|
310
|
+
if cancel is not None and cancel.is_set():
|
|
311
|
+
return
|
|
312
|
+
raise
|
|
313
|
+
finally:
|
|
314
|
+
config.get("_abort", {}).pop("client", None)
|
|
236
315
|
return
|
|
237
316
|
|
|
238
317
|
text = ""
|
|
239
318
|
tool_buf: dict = {} # index → {id, name, args}
|
|
240
319
|
in_tok = out_tok = 0
|
|
241
320
|
|
|
242
|
-
|
|
243
|
-
|
|
321
|
+
try:
|
|
322
|
+
response = _create_abortable(client, kwargs, base_url, provider, cancel)
|
|
323
|
+
except _StopRequested:
|
|
324
|
+
config.get("_abort", {}).pop("client", None)
|
|
325
|
+
return
|
|
326
|
+
for chunk in _stream_chunks(response, cancel):
|
|
244
327
|
if not chunk.choices:
|
|
245
328
|
if hasattr(chunk, "usage") and chunk.usage:
|
|
246
329
|
in_tok = chunk.usage.prompt_tokens or in_tok
|
|
@@ -279,6 +362,9 @@ def stream(
|
|
|
279
362
|
in_tok = chunk.usage.prompt_tokens or in_tok
|
|
280
363
|
out_tok = chunk.usage.completion_tokens or out_tok
|
|
281
364
|
|
|
365
|
+
config.get("_abort", {}).pop("client", None) # generation done — drop the abort handle
|
|
366
|
+
if cancel is not None and cancel.is_set():
|
|
367
|
+
return # stopped mid-stream — don't emit a partial turn
|
|
282
368
|
tool_calls = []
|
|
283
369
|
for idx in sorted(tool_buf):
|
|
284
370
|
v = tool_buf[idx]
|
|
@@ -293,12 +379,13 @@ def stream(
|
|
|
293
379
|
|
|
294
380
|
|
|
295
381
|
def _complete_nonstreaming(
|
|
296
|
-
client, kwargs: dict, base_url: str = "", provider: str = ""
|
|
382
|
+
client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None
|
|
297
383
|
) -> Generator:
|
|
298
384
|
"""Single non-streaming completion. Yields one TextChunk (if any text)
|
|
299
385
|
then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
|
|
386
|
+
Runs off-thread so STOP can abandon a blocked decode.
|
|
300
387
|
"""
|
|
301
|
-
resp =
|
|
388
|
+
resp = _create_abortable(client, kwargs, base_url, provider, cancel)
|
|
302
389
|
choice = resp.choices[0]
|
|
303
390
|
msg = choice.message
|
|
304
391
|
|
|
@@ -10,6 +10,7 @@ import re
|
|
|
10
10
|
import difflib
|
|
11
11
|
import glob as _glob
|
|
12
12
|
import subprocess
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from drydock.tool_registry import ToolDef, register
|
|
@@ -457,26 +458,46 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
457
458
|
f"REFUSED: you declined to approve this command "
|
|
458
459
|
f"({approval_reason}).\nCommand: {cmd.strip()}"
|
|
459
460
|
)
|
|
461
|
+
# Run via Popen (not subprocess.run) so STOP can kill it mid-execution:
|
|
462
|
+
# the handle is stashed in config["_abort"]["proc"] for action_stop to .kill().
|
|
463
|
+
# We poll communicate() in short slices, checking the cancel Event and the
|
|
464
|
+
# overall timeout between slices.
|
|
465
|
+
cancel = config.get("_cancel")
|
|
466
|
+
proc = None
|
|
460
467
|
try:
|
|
461
|
-
|
|
462
|
-
cmd, shell=True,
|
|
463
|
-
|
|
468
|
+
proc = subprocess.Popen(
|
|
469
|
+
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
470
|
+
text=True, cwd=config.get("cwd"),
|
|
464
471
|
)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
472
|
+
config.setdefault("_abort", {})["proc"] = proc
|
|
473
|
+
start = time.monotonic()
|
|
474
|
+
while True:
|
|
475
|
+
try:
|
|
476
|
+
out, _ = proc.communicate(timeout=0.5)
|
|
477
|
+
break
|
|
478
|
+
except subprocess.TimeoutExpired:
|
|
479
|
+
if cancel is not None and cancel.is_set():
|
|
480
|
+
proc.kill()
|
|
481
|
+
proc.communicate()
|
|
482
|
+
return "[stopped by user]"
|
|
483
|
+
if time.monotonic() - start > timeout:
|
|
484
|
+
proc.kill()
|
|
485
|
+
proc.communicate()
|
|
486
|
+
bigger = min(timeout * 4, 600)
|
|
487
|
+
return (
|
|
488
|
+
f"Error: command timed out after {timeout}s. If it is "
|
|
489
|
+
f"legitimately slow (a big query, build, download, or "
|
|
490
|
+
f"test run), retry with a larger timeout — pass "
|
|
491
|
+
f"timeout: {bigger}. Otherwise it may be hung."
|
|
492
|
+
)
|
|
493
|
+
output = out or ""
|
|
494
|
+
if proc.returncode != 0:
|
|
495
|
+
output += f"\n[exit code: {proc.returncode}]"
|
|
470
496
|
return output.strip() or "(no output)"
|
|
471
|
-
except subprocess.TimeoutExpired:
|
|
472
|
-
bigger = min(timeout * 4, 600)
|
|
473
|
-
return (
|
|
474
|
-
f"Error: command timed out after {timeout}s. If it is legitimately "
|
|
475
|
-
f"slow (a big query, build, download, or test run), retry with a "
|
|
476
|
-
f"larger timeout — pass timeout: {bigger}. Otherwise it may be hung."
|
|
477
|
-
)
|
|
478
497
|
except Exception as e:
|
|
479
498
|
return f"Error: {e}"
|
|
499
|
+
finally:
|
|
500
|
+
config.get("_abort", {}).pop("proc", None)
|
|
480
501
|
|
|
481
502
|
|
|
482
503
|
def tool_glob(params: dict, config: dict) -> str:
|
|
@@ -135,20 +135,40 @@ class DrydockApp(App):
|
|
|
135
135
|
]
|
|
136
136
|
|
|
137
137
|
def action_stop(self) -> None:
|
|
138
|
-
"""STOP the running turn
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
"""STOP the running turn NOW (Esc or, while busy, a single Ctrl+C):
|
|
139
|
+
signal the loop AND forcibly abort the in-flight LLM call and any
|
|
140
|
+
running command so it stops immediately rather than after the current
|
|
141
|
+
step. Session (history, files) is preserved — this is not a quit."""
|
|
141
142
|
if not self._busy:
|
|
142
143
|
return
|
|
143
144
|
self._cancel.set()
|
|
145
|
+
self._abort_inflight()
|
|
144
146
|
dropped = len(self._queue)
|
|
145
147
|
self._queue.clear()
|
|
146
|
-
note = "⏹
|
|
147
|
-
if dropped:
|
|
148
|
-
note += f" (discarded {dropped} queued)"
|
|
148
|
+
note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
|
|
149
149
|
self._info(note)
|
|
150
150
|
self._refresh_status()
|
|
151
151
|
|
|
152
|
+
def _abort_inflight(self) -> None:
|
|
153
|
+
"""Forcibly interrupt blocking work so STOP is immediate: close the LLM
|
|
154
|
+
HTTP client (aborts a long decode) and kill any running command. The
|
|
155
|
+
handles live in the shared config["_abort"] holder (a mutable dict that
|
|
156
|
+
survives run()'s dict(config) copy); missing ones just mean nothing is
|
|
157
|
+
in that state right now."""
|
|
158
|
+
abort = self.config.get("_abort") or {}
|
|
159
|
+
client = abort.get("client")
|
|
160
|
+
if client is not None:
|
|
161
|
+
try:
|
|
162
|
+
client.close()
|
|
163
|
+
except Exception: # noqa: BLE001
|
|
164
|
+
pass
|
|
165
|
+
proc = abort.get("proc")
|
|
166
|
+
if proc is not None:
|
|
167
|
+
try:
|
|
168
|
+
proc.kill()
|
|
169
|
+
except Exception: # noqa: BLE001
|
|
170
|
+
pass
|
|
171
|
+
|
|
152
172
|
def action_scroll_up(self) -> None:
|
|
153
173
|
self._scroll.scroll_page_up()
|
|
154
174
|
|
|
@@ -186,7 +206,12 @@ class DrydockApp(App):
|
|
|
186
206
|
self.notify(f"Copied {len(selected)} chars to clipboard", timeout=2)
|
|
187
207
|
self._ctrl_c_armed = False
|
|
188
208
|
return
|
|
189
|
-
#
|
|
209
|
+
# While a turn is running, a single Ctrl+C STOPS it (like Esc) — the
|
|
210
|
+
# interrupt key when you need to halt the TUI mid-task.
|
|
211
|
+
if self._busy:
|
|
212
|
+
self.action_stop()
|
|
213
|
+
return
|
|
214
|
+
# Idle, no selection → double-Ctrl+C to exit.
|
|
190
215
|
if self._ctrl_c_armed:
|
|
191
216
|
self.exit()
|
|
192
217
|
return
|
|
@@ -210,6 +235,10 @@ class DrydockApp(App):
|
|
|
210
235
|
# so run() — and any sub-agent that copies config — can see it.
|
|
211
236
|
self._cancel = threading.Event()
|
|
212
237
|
self.config["_cancel"] = self._cancel
|
|
238
|
+
# Shared abort-handle holder: the provider stashes the live LLM client
|
|
239
|
+
# and the bash tool its subprocess here, so STOP can force-abort them.
|
|
240
|
+
# A plain dict survives run()'s dict(config) shallow copy by reference.
|
|
241
|
+
self.config["_abort"] = {}
|
|
213
242
|
self._queue: list[str] = [] # prompts submitted while a turn is running
|
|
214
243
|
self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
|
|
215
244
|
self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
|
|
@@ -283,8 +312,10 @@ class DrydockApp(App):
|
|
|
283
312
|
pct = min(100, round(used / limit * 100)) if limit else 0
|
|
284
313
|
ctx = f"ctx {used:,}/{limit // 1000}k ({pct}%)"
|
|
285
314
|
flag = "⚓ working" if self._busy else "⚓ ready"
|
|
315
|
+
# Busy → show the interrupt keys; idle → the quit hint.
|
|
316
|
+
keys = "Esc/Ctrl+C stop" if self._busy else "Ctrl+C×2 quit"
|
|
286
317
|
return (
|
|
287
|
-
f"{flag} · {model} ·
|
|
318
|
+
f"{flag} · {model} · {keys} · PgUp/PgDn scroll · "
|
|
288
319
|
f"Ctrl+O details · {ctx}"
|
|
289
320
|
)
|
|
290
321
|
|
|
@@ -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.28"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.12"
|
|
@@ -62,3 +62,28 @@ def test_no_cancel_event_runs_normally(monkeypatch):
|
|
|
62
62
|
st = AgentState()
|
|
63
63
|
list(run("go", st, {"model": "m"}, "sys"))
|
|
64
64
|
assert any(m.get("role") == "assistant" for m in st.messages)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_create_abortable_raises_on_cancel():
|
|
68
|
+
"""_create_abortable runs the blocking call off-thread and abandons it when
|
|
69
|
+
the cancel Event fires — so STOP doesn't wait out a long decode."""
|
|
70
|
+
import threading
|
|
71
|
+
import time
|
|
72
|
+
import pytest
|
|
73
|
+
from drydock.providers import _create_abortable, _StopRequested
|
|
74
|
+
|
|
75
|
+
cancel = threading.Event()
|
|
76
|
+
|
|
77
|
+
class _SlowClient:
|
|
78
|
+
class chat:
|
|
79
|
+
class completions:
|
|
80
|
+
@staticmethod
|
|
81
|
+
def create(**kw):
|
|
82
|
+
time.sleep(30) # simulate a long decode (orphaned on cancel)
|
|
83
|
+
return "never"
|
|
84
|
+
|
|
85
|
+
threading.Timer(0.3, cancel.set).start()
|
|
86
|
+
t0 = time.monotonic()
|
|
87
|
+
with pytest.raises(_StopRequested):
|
|
88
|
+
_create_abortable(_SlowClient(), {}, "", "", cancel)
|
|
89
|
+
assert time.monotonic() - t0 < 5 # returned promptly, didn't wait 30s
|
|
@@ -36,7 +36,7 @@ def test_streaming_preserves_newline_only_chunks(monkeypatch):
|
|
|
36
36
|
_Chunk("\n\n"),
|
|
37
37
|
_Chunk("body text"),
|
|
38
38
|
]
|
|
39
|
-
monkeypatch.setattr(P, "
|
|
39
|
+
monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
|
|
40
40
|
|
|
41
41
|
full = ""
|
|
42
42
|
streamed = []
|
|
@@ -60,7 +60,7 @@ def test_streaming_still_drops_chunk_emptied_by_stripping(monkeypatch):
|
|
|
60
60
|
# A chunk that is ONLY a thinking marker strips to "" and is skipped (so we
|
|
61
61
|
# don't emit empty text), but real whitespace is kept.
|
|
62
62
|
chunks = [_Chunk("hello"), _Chunk("<|channel>x<channel|>"), _Chunk("\n"), _Chunk("world")]
|
|
63
|
-
monkeypatch.setattr(P, "
|
|
63
|
+
monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
|
|
64
64
|
full = ""
|
|
65
65
|
for ev in stream(model="gemma4", system="s",
|
|
66
66
|
messages=[{"role": "user", "content": "hi"}],
|
|
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
|