drydock-cli 3.0.23__tar.gz → 3.0.25__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.23/drydock_cli.egg-info → drydock_cli-3.0.25}/PKG-INFO +1 -1
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/agent.py +18 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/providers.py +29 -9
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tools/__init__.py +10 -1
- {drydock_cli-3.0.23 → drydock_cli-3.0.25/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/pyproject.toml +1 -1
- drydock_cli-3.0.25/tests/test_empty_response.py +42 -0
- drydock_cli-3.0.25/tests/test_tool_arg_parsing.py +45 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/LICENSE +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/NOTICE +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/README.md +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/cli.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/config.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/detect.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/guards.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/setup.cfg +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_config.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.23 → drydock_cli-3.0.25}/tests/test_tuning.py +0 -0
|
@@ -114,6 +114,7 @@ def run(
|
|
|
114
114
|
session_has_edited = False
|
|
115
115
|
leaked_call_retries = 0
|
|
116
116
|
plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
|
|
117
|
+
empty_response_nudges = 0 # consecutive "you returned nothing" nudges
|
|
117
118
|
run_iteration = 0 # stream calls within THIS run() (resets per user message)
|
|
118
119
|
loop_tracker = LoopTracker()
|
|
119
120
|
|
|
@@ -226,6 +227,22 @@ def run(
|
|
|
226
227
|
),
|
|
227
228
|
})
|
|
228
229
|
continue
|
|
230
|
+
# A completely EMPTY response (no text, no tool call, no leaked call)
|
|
231
|
+
# is a non-answer, not a deliberate "done" — a weak model sometimes
|
|
232
|
+
# returns nothing on a hard task and the turn dead-ends silently.
|
|
233
|
+
# Nudge ONCE to produce output. Narrow (only empty text) and capped,
|
|
234
|
+
# so unlike a blanket auto-continue it can't loop on real answers.
|
|
235
|
+
if not (assistant_turn.text or "").strip() and empty_response_nudges < 1:
|
|
236
|
+
empty_response_nudges += 1
|
|
237
|
+
state.messages.append({
|
|
238
|
+
"role": "user",
|
|
239
|
+
"content": (
|
|
240
|
+
"[SYSTEM] Your last response was empty. Produce your "
|
|
241
|
+
"result now: either call a tool to do the work, or reply "
|
|
242
|
+
"with text. Do not return an empty message."
|
|
243
|
+
),
|
|
244
|
+
})
|
|
245
|
+
continue
|
|
229
246
|
break
|
|
230
247
|
|
|
231
248
|
# Execute each tool call
|
|
@@ -282,6 +299,7 @@ def run(
|
|
|
282
299
|
# a long, productive plan can run as far as it needs (the cap only
|
|
283
300
|
# bounds back-to-back stalls).
|
|
284
301
|
plan_continue_nudges = 0
|
|
302
|
+
empty_response_nudges = 0
|
|
285
303
|
|
|
286
304
|
# Nudge: if past 15 tool calls without any edits, inject gentle guidance
|
|
287
305
|
if tool_call_count == 15 and not session_has_edited and config.get("force_first_tool"):
|
|
@@ -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:
|
|
@@ -250,10 +277,7 @@ def stream(
|
|
|
250
277
|
tool_calls = []
|
|
251
278
|
for idx in sorted(tool_buf):
|
|
252
279
|
v = tool_buf[idx]
|
|
253
|
-
|
|
254
|
-
inp = json.loads(v["args"]) if v["args"] else {}
|
|
255
|
-
except json.JSONDecodeError:
|
|
256
|
-
inp = {"_raw": v["args"]}
|
|
280
|
+
inp = _parse_tool_args(v["args"])
|
|
257
281
|
tool_calls.append({
|
|
258
282
|
"id": v["id"] or f"call_{idx}",
|
|
259
283
|
"name": v["name"],
|
|
@@ -283,11 +307,7 @@ def _complete_nonstreaming(
|
|
|
283
307
|
|
|
284
308
|
tool_calls = []
|
|
285
309
|
for i, tc in enumerate(getattr(msg, "tool_calls", None) or []):
|
|
286
|
-
|
|
287
|
-
try:
|
|
288
|
-
inp = json.loads(raw_args) if raw_args else {}
|
|
289
|
-
except json.JSONDecodeError:
|
|
290
|
-
inp = {"_raw": raw_args}
|
|
310
|
+
inp = _parse_tool_args(tc.function.arguments or "")
|
|
291
311
|
tool_calls.append({
|
|
292
312
|
"id": tc.id or f"call_{i}",
|
|
293
313
|
"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
|
|
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:
|
|
@@ -34,6 +34,7 @@ tests/test_cli_agents.py
|
|
|
34
34
|
tests/test_compaction.py
|
|
35
35
|
tests/test_config.py
|
|
36
36
|
tests/test_detect.py
|
|
37
|
+
tests/test_empty_response.py
|
|
37
38
|
tests/test_guards_and_tools.py
|
|
38
39
|
tests/test_hallucinated_tools.py
|
|
39
40
|
tests/test_leaked_tool_call.py
|
|
@@ -43,6 +44,7 @@ tests/test_providers_unreachable.py
|
|
|
43
44
|
tests/test_stop.py
|
|
44
45
|
tests/test_subagent.py
|
|
45
46
|
tests/test_todo.py
|
|
47
|
+
tests/test_tool_arg_parsing.py
|
|
46
48
|
tests/test_tools_undo.py
|
|
47
49
|
tests/test_tui.py
|
|
48
50
|
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.
|
|
10
|
+
version = "3.0.25"
|
|
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,42 @@
|
|
|
1
|
+
"""A completely empty assistant response (no text, no tool call, no leaked
|
|
2
|
+
call) should be nudged ONCE to produce output rather than dead-ending the user
|
|
3
|
+
with a silent stop — but it must not loop on real (non-empty) answers."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import drydock.agent as agent_mod
|
|
7
|
+
from drydock.agent import AgentState, run
|
|
8
|
+
from drydock.providers import AssistantTurn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_empty_response_gets_one_nudge(monkeypatch):
|
|
12
|
+
seq = [
|
|
13
|
+
AssistantTurn("", [], 1, 1), # empty → nudge
|
|
14
|
+
AssistantTurn("here is the answer", [], 1, 1), # then a real reply
|
|
15
|
+
]
|
|
16
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
17
|
+
st = AgentState()
|
|
18
|
+
list(run("do it", st, {"model": "m"}, "sys"))
|
|
19
|
+
nudges = [m for m in st.messages
|
|
20
|
+
if m["role"] == "user" and "response was empty" in m["content"]]
|
|
21
|
+
assert len(nudges) == 1
|
|
22
|
+
assert not seq # both turns consumed
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_empty_nudge_capped_at_one(monkeypatch):
|
|
26
|
+
# Persistently empty → nudged once, then the loop gives control back.
|
|
27
|
+
seq = [AssistantTurn("", [], 1, 1) for _ in range(6)]
|
|
28
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
29
|
+
st = AgentState()
|
|
30
|
+
list(run("do it", st, {"model": "m"}, "sys"))
|
|
31
|
+
nudges = [m for m in st.messages
|
|
32
|
+
if m["role"] == "user" and "response was empty" in m["content"]]
|
|
33
|
+
assert len(nudges) == 1 # not a spin
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_nonempty_text_is_not_nudged(monkeypatch):
|
|
37
|
+
seq = [AssistantTurn("all done", [], 1, 1)]
|
|
38
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
39
|
+
st = AgentState()
|
|
40
|
+
list(run("hi", st, {"model": "m"}, "sys"))
|
|
41
|
+
assert not [m for m in st.messages
|
|
42
|
+
if m["role"] == "user" and "response was empty" in m["content"]]
|
|
@@ -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
|
|
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
|