drydock-cli 3.0.18__tar.gz → 3.0.20__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 (50) hide show
  1. {drydock_cli-3.0.18/drydock_cli.egg-info → drydock_cli-3.0.20}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/agent.py +14 -0
  4. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/compaction.py +50 -7
  5. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tui/app.py +34 -1
  6. {drydock_cli-3.0.18 → drydock_cli-3.0.20/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock_cli.egg-info/SOURCES.txt +1 -0
  8. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/pyproject.toml +1 -1
  9. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_compaction.py +34 -0
  10. drydock_cli-3.0.20/tests/test_stop.py +64 -0
  11. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/LICENSE +0 -0
  12. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/NOTICE +0 -0
  13. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/README.md +0 -0
  14. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/__main__.py +0 -0
  15. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/bash_safety.py +0 -0
  16. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/cli.py +0 -0
  17. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/config.py +0 -0
  18. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/detect.py +0 -0
  19. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/guards.py +0 -0
  20. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/loop_detect.py +0 -0
  21. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/providers.py +0 -0
  22. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tool_registry.py +0 -0
  23. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tools/__init__.py +0 -0
  24. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tui/__init__.py +0 -0
  25. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tui/approval.py +0 -0
  26. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tui/messages.py +0 -0
  27. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tui/widgets.py +0 -0
  28. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock/tuning.py +0 -0
  29. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock_cli.egg-info/dependency_links.txt +0 -0
  30. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock_cli.egg-info/entry_points.txt +0 -0
  31. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock_cli.egg-info/requires.txt +0 -0
  32. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/drydock_cli.egg-info/top_level.txt +0 -0
  33. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/setup.cfg +0 -0
  34. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_approval.py +0 -0
  35. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_back_command.py +0 -0
  36. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_bash_safety.py +0 -0
  37. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_cli_agents.py +0 -0
  38. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_config.py +0 -0
  39. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_detect.py +0 -0
  40. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_guards_and_tools.py +0 -0
  41. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_hallucinated_tools.py +0 -0
  42. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_leaked_tool_call.py +0 -0
  43. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_loop_detect.py +0 -0
  44. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_plan_autocontinue.py +0 -0
  45. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_providers_unreachable.py +0 -0
  46. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_subagent.py +0 -0
  47. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_todo.py +0 -0
  48. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_tools_undo.py +0 -0
  49. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_tui.py +0 -0
  50. {drydock_cli-3.0.18 → drydock_cli-3.0.20}/tests/test_tuning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.18
3
+ Version: 3.0.20
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.18"
10
+ __version__ = "3.0.20"
@@ -103,6 +103,13 @@ def run(
103
103
  # a call to anything else is refused — never executed. Keeps a read-only
104
104
  # sub-agent read-only and stops it from recursing into `task`.
105
105
  allow = config.get("tool_allowlist")
106
+ # User STOP signal (a threading.Event the TUI sets on Escape / "/stop").
107
+ # Checked only at SAFE points — top of the loop and after a turn's tool
108
+ # results are all appended — so a stop never leaves an assistant tool_call
109
+ # without its matching tool result (which would corrupt the history).
110
+ cancel = config.get("_cancel")
111
+ def _stopped() -> bool:
112
+ return cancel is not None and cancel.is_set()
106
113
  tool_call_count = 0
107
114
  session_has_edited = False
108
115
  leaked_call_retries = 0
@@ -111,6 +118,8 @@ def run(
111
118
  loop_tracker = LoopTracker()
112
119
 
113
120
  while state.turn_count < max_turns:
121
+ if _stopped():
122
+ break
114
123
  state.turn_count += 1
115
124
  run_iteration += 1
116
125
  assistant_turn: AssistantTurn | None = None
@@ -264,6 +273,11 @@ def run(
264
273
  "content": result,
265
274
  })
266
275
 
276
+ # Safe point (all tool results appended): honor a STOP requested while
277
+ # this turn's tools were running, before spending another LLM call.
278
+ if _stopped():
279
+ break
280
+
267
281
  # Real progress this turn — reset the consecutive stall-nudge counter so
268
282
  # a long, productive plan can run as far as it needs (the cap only
269
283
  # bounds back-to-back stalls).
@@ -35,18 +35,52 @@ def is_context_length_error(err: str) -> bool:
35
35
 
36
36
 
37
37
  def estimate_tokens(messages: list) -> int:
38
- """Rough token estimate: chars / 3.5"""
38
+ """Rough token estimate: chars / 3.5.
39
+
40
+ CRUCIAL: a tool call's arguments live in a nested ``input`` dict — e.g. a
41
+ full-file Write puts the whole file in tool_calls[].input.content. Counting
42
+ only top-level strings missed that entirely, so a 40k-char Write read as ~0
43
+ tokens, compaction never fired, and the real request blew past the context
44
+ window. Count nested strings so the estimate reflects what's actually sent.
45
+ """
39
46
  total = 0
40
47
  for m in messages:
41
48
  content = m.get("content", "")
42
49
  if isinstance(content, str):
43
50
  total += len(content)
44
- for tc in m.get("tool_calls", []):
45
- if isinstance(tc, dict):
46
- for v in tc.values():
47
- if isinstance(v, str):
48
- total += len(v)
49
- return int(total / 3.5)
51
+ for tc in m.get("tool_calls", []) or []:
52
+ total += _count_chars(tc)
53
+ # /3.0, not /3.5: code is symbol-dense and tokenizes to MORE tokens than
54
+ # prose, so a conservative (slightly high) estimate keeps us off the wall.
55
+ return int(total / 3.0)
56
+
57
+
58
+ def _count_chars(v) -> int:
59
+ """Total length of every string nested in v (str / dict / list)."""
60
+ if isinstance(v, str):
61
+ return len(v)
62
+ if isinstance(v, dict):
63
+ return sum(_count_chars(x) for x in v.values())
64
+ if isinstance(v, (list, tuple)):
65
+ return sum(_count_chars(x) for x in v)
66
+ return 0
67
+
68
+
69
+ def _truncate_tool_call_args(messages: list, max_len: int) -> None:
70
+ """Shrink oversized string arguments inside OLD assistant tool calls (a
71
+ full-file Write/Edit is a top bloat source and nothing else truncates it).
72
+ The most recent tool-call message is left intact; truncating only the
73
+ argument *values* keeps each call's id, so tool-result pairing stays valid."""
74
+ last = max((i for i, m in enumerate(messages) if m.get("tool_calls")), default=-1)
75
+ for i, m in enumerate(messages):
76
+ if i == last:
77
+ continue
78
+ for tc in m.get("tool_calls") or []:
79
+ inp = tc.get("input") if isinstance(tc, dict) else None
80
+ if isinstance(inp, dict):
81
+ for k, v in inp.items():
82
+ if isinstance(v, str) and len(v) > max_len:
83
+ inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
50
84
 
51
85
 
52
86
  def compact(messages: list, context_limit: int = 131072) -> list:
@@ -92,6 +126,10 @@ def compact(messages: list, context_limit: int = 131072) -> list:
92
126
  if current <= target:
93
127
  break
94
128
 
129
+ # Pass 3: shrink big old tool-call arguments (full-file Write/Edit bodies).
130
+ if estimate_tokens(messages) > target:
131
+ _truncate_tool_call_args(messages, max_len=1500)
132
+
95
133
  return messages
96
134
 
97
135
 
@@ -135,6 +173,11 @@ def emergency_compact(messages: list, context_limit: int = 131072) -> list:
135
173
  if isinstance(content, str) and len(content) > 500:
136
174
  messages[i]["content"] = content[:300] + "\n[... truncated ...]"
137
175
 
176
+ # Pass 4: shrink big tool-CALL arguments — a full-file Write/Edit puts the
177
+ # whole file in tool_calls[].input and nothing above touches it. This was
178
+ # THE leak that let the real request stay over the window after compaction.
179
+ _truncate_tool_call_args(messages, max_len=300)
180
+
138
181
  return messages
139
182
 
140
183
 
@@ -8,6 +8,7 @@ messages (see tui/messages.py). Nautical theme, original branding.
8
8
  from __future__ import annotations
9
9
 
10
10
  import random
11
+ import threading
11
12
  import time
12
13
 
13
14
  from textual import work
@@ -119,6 +120,10 @@ class DrydockApp(App):
119
120
  BINDINGS = [
120
121
  Binding("ctrl+c", "copy_selection", "Copy", priority=True),
121
122
  Binding("ctrl+d", "quit", "Quit", priority=True),
123
+ # Escape stops a running turn (the agent ends cleanly, session kept) —
124
+ # the way out of a runaway task without quitting the whole app. "/stop"
125
+ # does the same. priority so it isn't swallowed by the focused prompt.
126
+ Binding("escape", "stop", "Stop the running turn", priority=True),
122
127
  Binding("ctrl+o", "toggle_tools", "Expand/collapse details"),
123
128
  # Scroll the transcript from the keyboard (focus stays on the prompt,
124
129
  # and SSH sessions often don't forward the mouse wheel). priority=True
@@ -129,6 +134,21 @@ class DrydockApp(App):
129
134
  Binding("ctrl+end", "scroll_bottom", "Bottom", priority=True, show=False),
130
135
  ]
131
136
 
137
+ def action_stop(self) -> None:
138
+ """STOP the running turn: signal the agent loop to end at its next safe
139
+ point, drop any queued prompts, and hand control back. The session
140
+ (history, files) is preserved — this is not a quit."""
141
+ if not self._busy:
142
+ return
143
+ self._cancel.set()
144
+ dropped = len(self._queue)
145
+ self._queue.clear()
146
+ note = "⏹ stopping after the current step…"
147
+ if dropped:
148
+ note += f" (discarded {dropped} queued)"
149
+ self._info(note)
150
+ self._refresh_status()
151
+
132
152
  def action_scroll_up(self) -> None:
133
153
  self._scroll.scroll_page_up()
134
154
 
@@ -185,6 +205,11 @@ class DrydockApp(App):
185
205
  self._current_assistant: AssistantMessage | None = None
186
206
  self._last_card: ToolCard | None = None
187
207
  self._busy = False
208
+ # STOP signal: Escape / "/stop" sets it; the agent loop checks it at safe
209
+ # points and ends the turn cleanly (session preserved). Lives in config
210
+ # so run() — and any sub-agent that copies config — can see it.
211
+ self._cancel = threading.Event()
212
+ self.config["_cancel"] = self._cancel
188
213
  self._queue: list[str] = [] # prompts submitted while a turn is running
189
214
  self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
190
215
  self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
@@ -318,6 +343,7 @@ class DrydockApp(App):
318
343
  def _begin(self, text: str) -> None:
319
344
  """Start an agent turn for an already-displayed user prompt."""
320
345
  self._current_assistant = None
346
+ self._cancel.clear() # fresh turn — clear any prior STOP
321
347
  self._busy = True
322
348
  self._work_start = time.monotonic()
323
349
  self._work_word = random.choice(_WORKING_WORDS)
@@ -336,12 +362,18 @@ class DrydockApp(App):
336
362
  arg = parts[1].strip() if len(parts) > 1 else ""
337
363
  if cmd in ("/quit", "/exit", "/q"):
338
364
  self.exit()
365
+ elif cmd in ("/stop", "/cancel"):
366
+ if self._busy:
367
+ self.action_stop()
368
+ else:
369
+ self._info("Nothing is running.")
339
370
  elif cmd == "/clear":
340
371
  self.state = AgentState()
341
372
  self._scroll.remove_children()
342
373
  self._current_assistant = None
343
374
  self.config.pop("_todo", None)
344
375
  self.query_one("#todo", Static).update("") # clear the pinned plan
376
+ self._ctx_tokens = 0 # reset the context gauge — history is empty now
345
377
  self._refresh_status()
346
378
  elif cmd == "/model":
347
379
  self._cmd_model(arg)
@@ -377,10 +409,11 @@ class DrydockApp(App):
377
409
  " /cwd [path] show or change the working directory\n"
378
410
  " /undo revert the last file write/edit\n"
379
411
  " /back rewind the last turn from the model's context\n"
412
+ " /stop stop the running turn (or press Esc)\n"
380
413
  " /status session model, cwd, turns, tokens\n"
381
414
  " /clear reset the conversation\n"
382
415
  " /quit exit\n"
383
- "Type a task and press Enter. ↑/↓ recall history · Ctrl+O expands tools."
416
+ "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
384
417
  )
385
418
  else:
386
419
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.18
3
+ Version: 3.0.20
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
@@ -40,6 +40,7 @@ tests/test_leaked_tool_call.py
40
40
  tests/test_loop_detect.py
41
41
  tests/test_plan_autocontinue.py
42
42
  tests/test_providers_unreachable.py
43
+ tests/test_stop.py
43
44
  tests/test_subagent.py
44
45
  tests/test_todo.py
45
46
  tests/test_tools_undo.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.18"
10
+ version = "3.0.20"
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"
@@ -15,6 +15,40 @@ from drydock.compaction import (
15
15
  )
16
16
 
17
17
 
18
+ def test_estimate_counts_tool_call_input_args():
19
+ # A full-file Write hides its bulk in tool_calls[].input.content. The
20
+ # estimate MUST see it (this was the bug: it read as ~0 tokens).
21
+ big = "x" * 40000
22
+ msgs = [{"role": "assistant", "content": "",
23
+ "tool_calls": [{"id": "1", "name": "Write",
24
+ "input": {"file_path": "a.py", "content": big}}]}]
25
+ assert estimate_tokens(msgs) > 10000 # ~40000/3.0, not ~0
26
+
27
+
28
+ def test_emergency_compact_shrinks_big_write_arg_below_limit():
29
+ # Reproduce the operator's crash shape: history dominated by an OLD
30
+ # full-file Write arg. Emergency compaction must get the estimate under the
31
+ # real 64k window (it returned the SAME oversized request before).
32
+ limit = 65536
33
+ big = "y" * 250000 # ~83k est tokens — over the window
34
+ msgs = [
35
+ {"role": "user", "content": "refactor cli.py"},
36
+ {"role": "assistant", "content": "",
37
+ "tool_calls": [{"id": "1", "name": "Write",
38
+ "input": {"file_path": "cli.py", "content": big}}]},
39
+ {"role": "tool", "tool_call_id": "1", "name": "Write", "content": "ok"},
40
+ {"role": "user", "content": "now run tests"},
41
+ {"role": "assistant", "content": "",
42
+ "tool_calls": [{"id": "2", "name": "Bash", "input": {"command": "pytest"}}]},
43
+ {"role": "tool", "tool_call_id": "2", "name": "Bash", "content": "passed"},
44
+ ]
45
+ assert estimate_tokens(msgs) > limit
46
+ emergency_compact(msgs, limit)
47
+ assert estimate_tokens(msgs) < limit # actually under the window now
48
+ # The recent Bash tool_call (last) is preserved intact.
49
+ assert msgs[4]["tool_calls"][0]["input"]["command"] == "pytest"
50
+
51
+
18
52
  def test_is_context_length_error_matches_provider_phrasings():
19
53
  for msg in [
20
54
  "This model's maximum context length is 131072 tokens",
@@ -0,0 +1,64 @@
1
+ """STOP/interrupt: a threading.Event in config["_cancel"] lets the TUI halt a
2
+ running turn at a safe point. The agent must (a) not start a turn when stop is
3
+ already set, and (b) end after the current turn's tools rather than looping —
4
+ without leaving an assistant tool_call missing its tool result."""
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+
9
+ import drydock.agent as agent_mod
10
+ from drydock.agent import AgentState, run
11
+ from drydock.providers import AssistantTurn
12
+
13
+
14
+ def test_no_turn_when_already_stopped(monkeypatch):
15
+ calls = {"n": 0}
16
+
17
+ def fake_stream(**kw):
18
+ calls["n"] += 1
19
+ yield AssistantTurn("hi", [], 1, 1)
20
+
21
+ monkeypatch.setattr(agent_mod, "stream", fake_stream)
22
+ ev = threading.Event()
23
+ ev.set()
24
+ st = AgentState()
25
+ list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
26
+ assert calls["n"] == 0 # loop broke before any model call
27
+
28
+
29
+ def test_stop_ends_after_current_turn(monkeypatch):
30
+ # The model would loop forever (always returns a tool call); a stop set
31
+ # during the first turn must end the run after that turn's tools.
32
+ calls = {"n": 0}
33
+ ev = threading.Event()
34
+
35
+ def fake_stream(**kw):
36
+ calls["n"] += 1
37
+ ev.set() # user hits STOP while this turn runs
38
+ yield AssistantTurn(
39
+ "", [{"id": str(calls["n"]), "name": "Read", "input": {"file_path": "nope"}}], 1, 1
40
+ )
41
+
42
+ monkeypatch.setattr(agent_mod, "stream", fake_stream)
43
+ st = AgentState()
44
+ list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
45
+ assert calls["n"] == 1 # exactly one turn, then stopped
46
+
47
+ # History is still valid: every assistant tool_call has a matching tool msg.
48
+ call_ids = [
49
+ tc["id"]
50
+ for msg in st.messages
51
+ if msg.get("role") == "assistant"
52
+ for tc in (msg.get("tool_calls") or [])
53
+ ]
54
+ tool_ids = [m["tool_call_id"] for m in st.messages if m["role"] == "tool"]
55
+ assert sorted(call_ids) == sorted(tool_ids)
56
+
57
+
58
+ def test_no_cancel_event_runs_normally(monkeypatch):
59
+ # Absent a _cancel event, behaviour is unchanged.
60
+ seq = [AssistantTurn("done", [], 1, 1)]
61
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
62
+ st = AgentState()
63
+ list(run("go", st, {"model": "m"}, "sys"))
64
+ assert any(m.get("role") == "assistant" for m in st.messages)
File without changes
File without changes
File without changes
File without changes