drydock-cli 3.0.24__tar.gz → 3.0.26__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 (53) hide show
  1. {drydock_cli-3.0.24/drydock_cli.egg-info → drydock_cli-3.0.26}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/providers.py +35 -10
  4. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tools/__init__.py +10 -1
  5. {drydock_cli-3.0.24 → drydock_cli-3.0.26/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock_cli.egg-info/SOURCES.txt +2 -0
  7. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/pyproject.toml +1 -1
  8. drydock_cli-3.0.26/tests/test_streaming_newlines.py +70 -0
  9. drydock_cli-3.0.26/tests/test_tool_arg_parsing.py +45 -0
  10. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/LICENSE +0 -0
  11. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/NOTICE +0 -0
  12. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/README.md +0 -0
  13. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/__main__.py +0 -0
  14. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/agent.py +0 -0
  15. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/bash_safety.py +0 -0
  16. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/cli.py +0 -0
  17. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/compaction.py +0 -0
  18. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/config.py +0 -0
  19. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/detect.py +0 -0
  20. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/guards.py +0 -0
  21. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/loop_detect.py +0 -0
  22. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tool_registry.py +0 -0
  23. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tui/__init__.py +0 -0
  24. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tui/app.py +0 -0
  25. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tui/approval.py +0 -0
  26. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tui/messages.py +0 -0
  27. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tui/widgets.py +0 -0
  28. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock/tuning.py +0 -0
  29. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock_cli.egg-info/dependency_links.txt +0 -0
  30. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock_cli.egg-info/entry_points.txt +0 -0
  31. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock_cli.egg-info/requires.txt +0 -0
  32. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/drydock_cli.egg-info/top_level.txt +0 -0
  33. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/setup.cfg +0 -0
  34. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_approval.py +0 -0
  35. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_back_command.py +0 -0
  36. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_bash_safety.py +0 -0
  37. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_cli_agents.py +0 -0
  38. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_compaction.py +0 -0
  39. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_config.py +0 -0
  40. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_detect.py +0 -0
  41. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_empty_response.py +0 -0
  42. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_guards_and_tools.py +0 -0
  43. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_hallucinated_tools.py +0 -0
  44. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_leaked_tool_call.py +0 -0
  45. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_loop_detect.py +0 -0
  46. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_plan_autocontinue.py +0 -0
  47. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_providers_unreachable.py +0 -0
  48. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_stop.py +0 -0
  49. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_subagent.py +0 -0
  50. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_todo.py +0 -0
  51. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_tools_undo.py +0 -0
  52. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/tests/test_tui.py +0 -0
  53. {drydock_cli-3.0.24 → drydock_cli-3.0.26}/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.24
3
+ Version: 3.0.26
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.24"
10
+ __version__ = "3.0.26"
@@ -86,6 +86,33 @@ class AssistantTurn:
86
86
  had_leaked_call: bool = False # model emitted a tool call as text, not a call
87
87
 
88
88
 
89
+ # ── Tool-call argument parsing ────────────────────────────────────────────
90
+
91
+ def _parse_tool_args(raw: str) -> dict:
92
+ """Parse a model's tool-call arguments tolerantly.
93
+
94
+ strict=False is the fix that matters: a Bash command built with a heredoc
95
+ (`cat <<EOF ...`) carries LITERAL newlines, which strict JSON rejects with
96
+ "Invalid control character". The call then degraded to {"_raw": ...} with no
97
+ usable args, Bash failed, and the model's corrections re-wrapped into nested
98
+ {"_raw": "{\"_raw\": ...}"} — a fatal loop. strict=False accepts control
99
+ chars inside strings (recovering the common multiline case); we also unwrap
100
+ a few layers of nested {"_raw": ...} the model may have echoed back.
101
+ """
102
+ if not raw:
103
+ return {}
104
+ for _ in range(4): # peel nested _raw layers, then parse
105
+ try:
106
+ v = json.loads(raw, strict=False)
107
+ except (json.JSONDecodeError, TypeError):
108
+ return {"_raw": raw}
109
+ if isinstance(v, dict) and set(v) == {"_raw"} and isinstance(v["_raw"], str):
110
+ raw = v["_raw"]
111
+ continue
112
+ return v if isinstance(v, dict) else {"_raw": raw}
113
+ return {"_raw": raw}
114
+
115
+
89
116
  # ── Message format conversion ─────────────────────────────────────────────
90
117
 
91
118
  def messages_to_openai(messages: list, system: str) -> list:
@@ -226,7 +253,12 @@ def stream(
226
253
  if delta.content:
227
254
  # Strip any leaked thinking-token markers (best-effort per chunk).
228
255
  chunk_text = strip_thinking_tokens(delta.content)
229
- if chunk_text.strip():
256
+ # Keep ANY non-empty chunk — including whitespace-only ones. A
257
+ # `.strip()` guard here used to DROP newline-only chunks (the blank
258
+ # line a model streams between markdown blocks arrives as its own
259
+ # chunk), mashing "para### Header" together. `if chunk_text:` still
260
+ # skips chunks that stripping emptied (e.g. a lone thinking marker).
261
+ if chunk_text:
230
262
  text += chunk_text
231
263
  yield TextChunk(chunk_text)
232
264
 
@@ -250,10 +282,7 @@ def stream(
250
282
  tool_calls = []
251
283
  for idx in sorted(tool_buf):
252
284
  v = tool_buf[idx]
253
- try:
254
- inp = json.loads(v["args"]) if v["args"] else {}
255
- except json.JSONDecodeError:
256
- inp = {"_raw": v["args"]}
285
+ inp = _parse_tool_args(v["args"])
257
286
  tool_calls.append({
258
287
  "id": v["id"] or f"call_{idx}",
259
288
  "name": v["name"],
@@ -283,11 +312,7 @@ def _complete_nonstreaming(
283
312
 
284
313
  tool_calls = []
285
314
  for i, tc in enumerate(getattr(msg, "tool_calls", None) or []):
286
- raw_args = tc.function.arguments or ""
287
- try:
288
- inp = json.loads(raw_args) if raw_args else {}
289
- except json.JSONDecodeError:
290
- inp = {"_raw": raw_args}
315
+ inp = _parse_tool_args(tc.function.arguments or "")
291
316
  tool_calls.append({
292
317
  "id": tc.id or f"call_{i}",
293
318
  "name": tc.function.name,
@@ -428,7 +428,16 @@ def tool_edit(params: dict, config: dict) -> str:
428
428
 
429
429
 
430
430
  def tool_bash(params: dict, config: dict) -> str:
431
- cmd = params["command"]
431
+ cmd = params.get("command")
432
+ if not cmd:
433
+ if "_raw" in params:
434
+ return (
435
+ "Error: your tool arguments were not valid JSON so the command "
436
+ "could not be read. Resend as ONE JSON object "
437
+ '{"command": "..."} with the whole command as a single string '
438
+ "(escape any newline as \\n; avoid raw line breaks in the JSON)."
439
+ )
440
+ return "Error: Bash needs a non-empty 'command'."
432
441
  timeout = params.get("timeout", 30)
433
442
  reason = bash_safety.dangerous_command(cmd)
434
443
  if reason is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.24
3
+ Version: 3.0.26
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
@@ -42,8 +42,10 @@ tests/test_loop_detect.py
42
42
  tests/test_plan_autocontinue.py
43
43
  tests/test_providers_unreachable.py
44
44
  tests/test_stop.py
45
+ tests/test_streaming_newlines.py
45
46
  tests/test_subagent.py
46
47
  tests/test_todo.py
48
+ tests/test_tool_arg_parsing.py
47
49
  tests/test_tools_undo.py
48
50
  tests/test_tui.py
49
51
  tests/test_tuning.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.24"
10
+ version = "3.0.26"
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"
@@ -0,0 +1,70 @@
1
+ """Streaming must preserve whitespace-only (newline) chunks.
2
+
3
+ A `.strip()` guard in the streaming loop used to DROP a chunk that was only
4
+ "\n\n" — the blank line a model streams between markdown blocks — so a long
5
+ response rendered as "paragraph### Header" mashed together (operator-reported).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import drydock.providers as P
10
+ from drydock.providers import AssistantTurn, TextChunk, stream
11
+
12
+
13
+ class _Delta:
14
+ def __init__(self, c):
15
+ self.content = c
16
+ self.tool_calls = None
17
+
18
+
19
+ class _Choice:
20
+ def __init__(self, c):
21
+ self.delta = _Delta(c)
22
+ self.finish_reason = None
23
+
24
+
25
+ class _Chunk:
26
+ def __init__(self, c):
27
+ self.choices = [_Choice(c)]
28
+ self.usage = None
29
+
30
+
31
+ def test_streaming_preserves_newline_only_chunks(monkeypatch):
32
+ chunks = [
33
+ _Chunk("Intro paragraph."),
34
+ _Chunk("\n\n"), # ← blank line between blocks (was dropped)
35
+ _Chunk("### Header"),
36
+ _Chunk("\n\n"),
37
+ _Chunk("body text"),
38
+ ]
39
+ monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
40
+
41
+ full = ""
42
+ streamed = []
43
+ for ev in stream(
44
+ model="gemma4", system="s",
45
+ messages=[{"role": "user", "content": "review"}],
46
+ tool_schemas=[], # no tools → text-only → streaming path even for gemma
47
+ config={"model": "gemma4", "provider": "vllm"},
48
+ ):
49
+ if isinstance(ev, TextChunk):
50
+ streamed.append(ev.text)
51
+ elif isinstance(ev, AssistantTurn):
52
+ full = ev.text
53
+
54
+ assert "\n\n" in "".join(streamed) # newline chunks survived streaming
55
+ assert "Intro paragraph.\n\n### Header" in full # blocks not mashed
56
+ assert "### Header\n\nbody text" in full
57
+
58
+
59
+ def test_streaming_still_drops_chunk_emptied_by_stripping(monkeypatch):
60
+ # A chunk that is ONLY a thinking marker strips to "" and is skipped (so we
61
+ # don't emit empty text), but real whitespace is kept.
62
+ chunks = [_Chunk("hello"), _Chunk("<|channel>x<channel|>"), _Chunk("\n"), _Chunk("world")]
63
+ monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
64
+ full = ""
65
+ for ev in stream(model="gemma4", system="s",
66
+ messages=[{"role": "user", "content": "hi"}],
67
+ tool_schemas=[], config={"model": "gemma4", "provider": "vllm"}):
68
+ if isinstance(ev, AssistantTurn):
69
+ full = ev.text
70
+ assert full == "hello\nworld" # marker-only chunk gone, newline kept
@@ -0,0 +1,45 @@
1
+ """Tolerant parsing of a model's tool-call arguments.
2
+
3
+ Gemma builds Bash commands with heredocs (`cat <<EOF ...`) whose arguments
4
+ carry LITERAL newlines. Strict json.loads rejects those ("Invalid control
5
+ character"), the call degraded to {"_raw": ...} with no `command`, Bash failed,
6
+ and the model's retries nested {"_raw": "{\\"_raw\\": ...}"} into a fatal loop
7
+ (seen live on crack-7z-hash). _parse_tool_args fixes both."""
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from drydock.providers import _parse_tool_args
13
+ from drydock.tools import tool_bash
14
+
15
+
16
+ def test_heredoc_with_literal_newlines_recovers_command():
17
+ raw = '{"command": "cat <<EOF > p.txt\npassword\n123456\nEOF"}'
18
+ out = _parse_tool_args(raw)
19
+ assert out.get("command", "").startswith("cat <<EOF > p.txt")
20
+ assert "\n" in out["command"] # newlines preserved
21
+
22
+
23
+ def test_nested_raw_is_unwrapped():
24
+ triple = json.dumps({"_raw": json.dumps({"_raw": json.dumps({"command": "ls -la"})})})
25
+ assert _parse_tool_args(triple) == {"command": "ls -la"}
26
+
27
+
28
+ def test_normal_and_empty_and_broken():
29
+ assert _parse_tool_args('{"command":"echo hi"}') == {"command": "echo hi"}
30
+ assert _parse_tool_args("") == {}
31
+ assert _parse_tool_args("{not json") == {"_raw": "{not json"}
32
+
33
+
34
+ def test_bash_gives_recovery_hint_on_unparsed_args():
35
+ out = tool_bash({"_raw": "garbage"}, {})
36
+ assert "not valid JSON" in out and '{"command"' in out
37
+
38
+
39
+ def test_bash_runs_recovered_heredoc(tmp_path):
40
+ # End-to-end: a heredoc command recovered by _parse_tool_args actually runs.
41
+ raw = ('{"command": "cat <<EOF > note.txt\nhello\nworld\nEOF"}')
42
+ params = _parse_tool_args(raw)
43
+ out = tool_bash(params, {"cwd": str(tmp_path)})
44
+ assert "Error" not in out
45
+ assert (tmp_path / "note.txt").read_text() == "hello\nworld\n"
File without changes
File without changes
File without changes
File without changes