drydock-cli 3.0.30__tar.gz → 3.0.32__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.30/drydock_cli.egg-info → drydock_cli-3.0.32}/PKG-INFO +1 -1
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/agent.py +30 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/pyproject.toml +1 -1
- drydock_cli-3.0.32/tests/test_failure_loop.py +77 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/LICENSE +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/NOTICE +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/README.md +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/cli.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/config.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/detect.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/guards.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/providers.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/setup.cfg +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_config.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.30 → drydock_cli-3.0.32}/tests/test_tuning.py +0 -0
|
@@ -115,6 +115,15 @@ def run(
|
|
|
115
115
|
leaked_call_retries = 0
|
|
116
116
|
plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
|
|
117
117
|
empty_response_nudges = 0 # consecutive "you returned nothing" nudges
|
|
118
|
+
# Safety valve for a degenerate loop: the SAME tool call run over and over
|
|
119
|
+
# with the SAME result — success OR failure (seen failing a Write 160×, and
|
|
120
|
+
# re-running an identical passing `pytest` 92× for 25 min). The advisory
|
|
121
|
+
# loop-note is ignored by weak models, so after a cap we end the turn. Real
|
|
122
|
+
# iterative fixing changes the args, and polling yields a changing result —
|
|
123
|
+
# both reset the streak, so only a truly pointless loop trips it.
|
|
124
|
+
identical_repeat_streak = 0
|
|
125
|
+
last_call_sig = None
|
|
126
|
+
IDENTICAL_REPEAT_CAP = 8
|
|
118
127
|
run_iteration = 0 # stream calls within THIS run() (resets per user message)
|
|
119
128
|
loop_tracker = LoopTracker()
|
|
120
129
|
|
|
@@ -288,6 +297,15 @@ def run(
|
|
|
288
297
|
)
|
|
289
298
|
else:
|
|
290
299
|
result = execute(tc["name"], tc["input"], config)
|
|
300
|
+
# Track consecutive byte-identical calls — same name, args AND raw
|
|
301
|
+
# result (captured before annotate prepends its note, which changes
|
|
302
|
+
# each call) — for the safety valve below. A differing result
|
|
303
|
+
# (polling) or differing args (real iteration) resets the streak.
|
|
304
|
+
sig = (tc["name"], str(tc["input"]), result)
|
|
305
|
+
if sig == last_call_sig:
|
|
306
|
+
identical_repeat_streak += 1
|
|
307
|
+
else:
|
|
308
|
+
identical_repeat_streak, last_call_sig = 1, sig
|
|
291
309
|
# Guide (never block) on exact-repeat tool calls: prepend an
|
|
292
310
|
# advisory note when the same call is made again.
|
|
293
311
|
result = loop_tracker.annotate(tc["name"], tc["input"], result)
|
|
@@ -302,6 +320,18 @@ def run(
|
|
|
302
320
|
"content": result,
|
|
303
321
|
})
|
|
304
322
|
|
|
323
|
+
# Safety valve: the same call has run identically (same args AND result)
|
|
324
|
+
# too many times in a row — repeating it changes nothing. End the turn
|
|
325
|
+
# and hand control back rather than burning turns toward MAX_TOOL_TURNS.
|
|
326
|
+
if identical_repeat_streak >= IDENTICAL_REPEAT_CAP:
|
|
327
|
+
yield TextChunk(
|
|
328
|
+
f"\n[Stopped: the same {last_call_sig[0]} call ran "
|
|
329
|
+
f"{identical_repeat_streak}× in a row with the same result — "
|
|
330
|
+
"repeating it changes nothing. Control is back to you; tell me "
|
|
331
|
+
"how you'd like to proceed.]\n"
|
|
332
|
+
)
|
|
333
|
+
break
|
|
334
|
+
|
|
305
335
|
# Safe point (all tool results appended): honor a STOP requested while
|
|
306
336
|
# this turn's tools were running, before spending another LLM call.
|
|
307
337
|
if _stopped():
|
|
@@ -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.32"
|
|
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,77 @@
|
|
|
1
|
+
"""Safety valve: a byte-identical tool call that keeps FAILING must end the
|
|
2
|
+
turn after a small cap, not spin toward MAX_TOOL_TURNS (a real session failed
|
|
3
|
+
the same Write 160×)."""
|
|
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_identical_failure_streak_ends_turn(monkeypatch):
|
|
12
|
+
calls = {"n": 0}
|
|
13
|
+
|
|
14
|
+
def counting(**kw):
|
|
15
|
+
calls["n"] += 1
|
|
16
|
+
# Same failing Write every time (empty file_path → Error). id varies but
|
|
17
|
+
# the input (the signature) is identical.
|
|
18
|
+
return iter([AssistantTurn(
|
|
19
|
+
"", [{"id": str(calls["n"]), "name": "Write",
|
|
20
|
+
"input": {"file_path": "", "content": "x"}}], 1, 1)])
|
|
21
|
+
|
|
22
|
+
monkeypatch.setattr(agent_mod, "stream", counting)
|
|
23
|
+
st = AgentState()
|
|
24
|
+
list(run("build it", st, {"model": "m"}, "sys"))
|
|
25
|
+
assert calls["n"] <= 12 # stopped near the cap (8), nowhere near 200
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_changing_args_do_not_trip_valve(monkeypatch):
|
|
29
|
+
# Legit iterative fixing (different args each time) must NOT trip the valve;
|
|
30
|
+
# here each call succeeds (writes a different file), then the model finishes.
|
|
31
|
+
seq = [
|
|
32
|
+
AssistantTurn("", [{"id": "1", "name": "Write",
|
|
33
|
+
"input": {"file_path": "a.txt", "content": "1"}}], 1, 1),
|
|
34
|
+
AssistantTurn("", [{"id": "2", "name": "Write",
|
|
35
|
+
"input": {"file_path": "b.txt", "content": "2"}}], 1, 1),
|
|
36
|
+
AssistantTurn("done", [], 1, 1),
|
|
37
|
+
]
|
|
38
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
39
|
+
st = AgentState()
|
|
40
|
+
import tempfile
|
|
41
|
+
list(run("go", st, {"model": "m", "cwd": tempfile.mkdtemp()}, "sys"))
|
|
42
|
+
assert any(m.get("role") == "assistant" and m.get("content") == "done"
|
|
43
|
+
for m in st.messages)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_identical_SUCCESS_streak_ends_turn(monkeypatch):
|
|
47
|
+
# The code-red case: a SUCCESSFUL command re-run identically (same args AND
|
|
48
|
+
# same output) — a passing `pytest` 92×. Must also stop, not just failures.
|
|
49
|
+
import tempfile
|
|
50
|
+
calls = {"n": 0}
|
|
51
|
+
|
|
52
|
+
def counting(**kw):
|
|
53
|
+
calls["n"] += 1
|
|
54
|
+
return iter([AssistantTurn(
|
|
55
|
+
"", [{"id": str(calls["n"]), "name": "Bash",
|
|
56
|
+
"input": {"command": "echo hi"}}], 1, 1)])
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(agent_mod, "stream", counting)
|
|
59
|
+
st = AgentState()
|
|
60
|
+
list(run("go", st, {"model": "m", "cwd": tempfile.mkdtemp()}, "sys"))
|
|
61
|
+
assert calls["n"] <= 12 # identical success → stopped at the cap, not 200
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_polling_with_changing_result_not_tripped(monkeypatch):
|
|
65
|
+
# Same command, DIFFERENT result each time (a poll) must NOT be stopped —
|
|
66
|
+
# the result changes, so the streak resets. Model finishes on its own.
|
|
67
|
+
import tempfile
|
|
68
|
+
seq = []
|
|
69
|
+
for i in range(3):
|
|
70
|
+
seq.append(AssistantTurn(
|
|
71
|
+
"", [{"id": str(i), "name": "Bash",
|
|
72
|
+
"input": {"command": f"echo {i}"}}], 1, 1)) # different output
|
|
73
|
+
seq.append(AssistantTurn("done", [], 1, 1))
|
|
74
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
75
|
+
st = AgentState()
|
|
76
|
+
list(run("go", st, {"model": "m", "cwd": tempfile.mkdtemp()}, "sys"))
|
|
77
|
+
assert any(m.get("content") == "done" for m in st.messages)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|