drydock-cli 3.0.57__tar.gz → 3.0.59__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.57/drydock_cli.egg-info → drydock_cli-3.0.59}/PKG-INFO +1 -1
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tools/__init__.py +85 -22
- {drydock_cli-3.0.57 → drydock_cli-3.0.59/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/pyproject.toml +1 -1
- drydock_cli-3.0.59/tests/test_bash_output_bounding.py +49 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_failure_loop.py +23 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/LICENSE +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/NOTICE +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/README.md +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/agent.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/cli.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/guards.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/providers.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/skills.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/web.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/setup.cfg +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_web_tools.py +0 -0
|
@@ -11,9 +11,40 @@ import difflib
|
|
|
11
11
|
import glob as _glob
|
|
12
12
|
import signal
|
|
13
13
|
import subprocess
|
|
14
|
+
import threading
|
|
14
15
|
import time
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
18
|
+
# Hard ceiling on bytes read from a command's output. communicate() buffers ALL
|
|
19
|
+
# stdout in RAM before we ever truncate for context, so a runaway/infinite-output
|
|
20
|
+
# command (`yes`, `cat /dev/urandom`, a massive build log) could balloon memory
|
|
21
|
+
# to gigabytes within the timeout. We stream with a byte cap and kill the command
|
|
22
|
+
# once it's hit — bounding RAM regardless of how much it tries to produce.
|
|
23
|
+
_MAX_BASH_OUTPUT_BYTES = 256 * 1024 # 256 KB — plenty of context, safe for RAM
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _collapse_repeated_lines(text: str, run: int = 20) -> str:
|
|
27
|
+
"""Collapse a run of >= `run` IDENTICAL consecutive lines into one line + a
|
|
28
|
+
count. Repetitive output (`yes`, a spinning progress log) tokenizes densely —
|
|
29
|
+
32 KB of "y\\n" is ~24k tokens — so even after byte-capping it can eat a big
|
|
30
|
+
slice of the context window. Collapsing makes it cheap without losing the
|
|
31
|
+
signal. Non-repetitive output (a normal build log) is left untouched."""
|
|
32
|
+
lines = text.split("\n")
|
|
33
|
+
out: list[str] = []
|
|
34
|
+
i, n = 0, len(lines)
|
|
35
|
+
while i < n:
|
|
36
|
+
j = i
|
|
37
|
+
while j < n and lines[j] == lines[i]:
|
|
38
|
+
j += 1
|
|
39
|
+
count = j - i
|
|
40
|
+
if count >= run:
|
|
41
|
+
out.append(lines[i])
|
|
42
|
+
out.append(f"[... {count - 1} more identical lines collapsed ...]")
|
|
43
|
+
else:
|
|
44
|
+
out.extend(lines[i:j])
|
|
45
|
+
i = j
|
|
46
|
+
return "\n".join(out)
|
|
47
|
+
|
|
17
48
|
from drydock.tool_registry import ToolDef, register
|
|
18
49
|
from drydock.guards import (
|
|
19
50
|
conflict_marker_refusal,
|
|
@@ -728,30 +759,62 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
728
759
|
text=True, cwd=config.get("cwd"), start_new_session=True,
|
|
729
760
|
)
|
|
730
761
|
config.setdefault("_abort", {})["proc"] = proc
|
|
762
|
+
# Read output in a daemon thread with a HARD byte cap, so memory can't
|
|
763
|
+
# balloon on a runaway-output command. The thread stops (and we kill the
|
|
764
|
+
# process) once the cap is hit; the main loop polls cancel + timeout.
|
|
765
|
+
chunks: list[str] = []
|
|
766
|
+
total = [0]
|
|
767
|
+
capped = threading.Event()
|
|
768
|
+
|
|
769
|
+
def _drain():
|
|
770
|
+
assert proc.stdout is not None
|
|
771
|
+
while True:
|
|
772
|
+
block = proc.stdout.read(8192)
|
|
773
|
+
if not block:
|
|
774
|
+
break
|
|
775
|
+
chunks.append(block)
|
|
776
|
+
total[0] += len(block)
|
|
777
|
+
if total[0] >= _MAX_BASH_OUTPUT_BYTES:
|
|
778
|
+
capped.set()
|
|
779
|
+
break
|
|
780
|
+
|
|
781
|
+
reader = threading.Thread(target=_drain, daemon=True)
|
|
782
|
+
reader.start()
|
|
731
783
|
start = time.monotonic()
|
|
732
|
-
while
|
|
733
|
-
|
|
734
|
-
|
|
784
|
+
while reader.is_alive():
|
|
785
|
+
reader.join(0.3)
|
|
786
|
+
if capped.is_set():
|
|
787
|
+
kill_process_group(proc) # stop it producing more
|
|
735
788
|
break
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
789
|
+
if cancel is not None and cancel.is_set():
|
|
790
|
+
kill_process_group(proc)
|
|
791
|
+
proc.wait()
|
|
792
|
+
return "[stopped by user]"
|
|
793
|
+
if time.monotonic() - start > timeout:
|
|
794
|
+
kill_process_group(proc)
|
|
795
|
+
proc.wait()
|
|
796
|
+
bigger = min(timeout * 4, 1800)
|
|
797
|
+
msg = (
|
|
798
|
+
f"Error: command timed out after {timeout}s. If it is "
|
|
799
|
+
f"legitimately slow (a big query, build, download, or "
|
|
800
|
+
f"test run), retry with a larger timeout — pass "
|
|
801
|
+
f"timeout: {bigger}. Otherwise it may be hung."
|
|
802
|
+
)
|
|
803
|
+
if _is_network_command(cmd):
|
|
804
|
+
msg += _OFFLINE_HINT
|
|
805
|
+
return msg
|
|
806
|
+
proc.wait()
|
|
807
|
+
# Collapse repetitive runs FIRST (turns 256 KB of "y\n" into ~2 lines),
|
|
808
|
+
# then note if we hit the byte cap. Bounds both RAM (the cap) and context
|
|
809
|
+
# tokens (the collapse).
|
|
810
|
+
output = _collapse_repeated_lines("".join(chunks))
|
|
811
|
+
if capped.is_set():
|
|
812
|
+
return (
|
|
813
|
+
output.rstrip()
|
|
814
|
+
+ f"\n[output truncated at {_MAX_BASH_OUTPUT_BYTES // 1024} KB — "
|
|
815
|
+
"the command produced more; redirect to a file and inspect it in "
|
|
816
|
+
"pieces (head/tail/grep) instead of dumping it all]"
|
|
817
|
+
)
|
|
755
818
|
if proc.returncode != 0:
|
|
756
819
|
output += f"\n[exit code: {proc.returncode}]"
|
|
757
820
|
# Offline environments make downloads fail forever; the model tends
|
|
@@ -34,6 +34,7 @@ drydock_cli.egg-info/requires.txt
|
|
|
34
34
|
drydock_cli.egg-info/top_level.txt
|
|
35
35
|
tests/test_approval.py
|
|
36
36
|
tests/test_back_command.py
|
|
37
|
+
tests/test_bash_output_bounding.py
|
|
37
38
|
tests/test_bash_process_group.py
|
|
38
39
|
tests/test_bash_safety.py
|
|
39
40
|
tests/test_bash_timeout_network.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.59"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Critical VRAM/RAM protection (PRD §3.B): tool_bash must bound a command's
|
|
2
|
+
output DURING capture so a runaway/infinite-output command can't balloon memory
|
|
3
|
+
before the context-truncation cap applies."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from drydock.tools import tool_bash, _MAX_BASH_OUTPUT_BYTES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_infinite_output_is_capped_and_fast(tmp_path):
|
|
12
|
+
cfg = {"cwd": str(tmp_path)}
|
|
13
|
+
t0 = time.monotonic()
|
|
14
|
+
out = tool_bash({"command": "yes"}, cfg) # infinite stream
|
|
15
|
+
elapsed = time.monotonic() - t0
|
|
16
|
+
assert "truncated at" in out # capped, not dumped
|
|
17
|
+
assert len(out) < _MAX_BASH_OUTPUT_BYTES + 500 # bounded memory
|
|
18
|
+
assert elapsed < 20 # killed promptly, no hang
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_large_finite_output_is_capped(tmp_path):
|
|
22
|
+
cfg = {"cwd": str(tmp_path)}
|
|
23
|
+
# ~1 MB of output → must come back truncated near the cap, not 1 MB.
|
|
24
|
+
out = tool_bash({"command": "head -c 1000000 /dev/zero | tr '\\0' 'a'"}, cfg)
|
|
25
|
+
assert len(out) < _MAX_BASH_OUTPUT_BYTES + 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_normal_output_untouched(tmp_path):
|
|
29
|
+
cfg = {"cwd": str(tmp_path)}
|
|
30
|
+
out = tool_bash({"command": "echo hello world"}, cfg)
|
|
31
|
+
assert out == "hello world" and "truncated" not in out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_exit_code_still_reported(tmp_path):
|
|
35
|
+
cfg = {"cwd": str(tmp_path)}
|
|
36
|
+
out = tool_bash({"command": "echo oops; exit 3"}, cfg)
|
|
37
|
+
assert "oops" in out and "exit code: 3" in out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_repetitive_output_is_collapsed(tmp_path):
|
|
41
|
+
# PRD "protect context size": a repetitive dump must not eat the window.
|
|
42
|
+
out = tool_bash({"command": "yes | head -c 2000000"}, {"cwd": str(tmp_path)})
|
|
43
|
+
assert len(out) < 500 # ~24k chars before the collapse
|
|
44
|
+
assert "identical lines collapsed" in out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_collapse_preserves_distinct_lines(tmp_path):
|
|
48
|
+
out = tool_bash({"command": "printf 'a\\nb\\nc\\n'"}, {"cwd": str(tmp_path)})
|
|
49
|
+
assert out == "a\nb\nc" # nothing collapsed
|
|
@@ -75,3 +75,26 @@ def test_polling_with_changing_result_not_tripped(monkeypatch):
|
|
|
75
75
|
st = AgentState()
|
|
76
76
|
list(run("go", st, {"model": "m", "cwd": tempfile.mkdtemp()}, "sys"))
|
|
77
77
|
assert any(m.get("content") == "done" for m in st.messages)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_infinite_distinct_tool_calls_terminate_at_max_turns(monkeypatch):
|
|
81
|
+
"""PRD §3.D: a model that keeps calling tools (DIFFERENT, succeeding calls)
|
|
82
|
+
and never yields 'Task Complete' must be forcibly stopped at the hard
|
|
83
|
+
max-iteration ceiling — protecting VRAM/context from an unbounded loop."""
|
|
84
|
+
calls = {"n": 0}
|
|
85
|
+
|
|
86
|
+
def never_done(**kw):
|
|
87
|
+
calls["n"] += 1
|
|
88
|
+
# A unique, succeeding command each turn → the identical-repeat valve
|
|
89
|
+
# never trips, so only the hard max_turns cap can end it.
|
|
90
|
+
return iter([AssistantTurn(
|
|
91
|
+
"", [{"id": str(calls["n"]), "name": "Bash",
|
|
92
|
+
"input": {"command": f"echo step {calls['n']}"}}], 1, 1)])
|
|
93
|
+
|
|
94
|
+
monkeypatch.setattr(agent_mod, "stream", never_done)
|
|
95
|
+
st = AgentState()
|
|
96
|
+
import tempfile
|
|
97
|
+
# Low cap so the test is fast; the real default is 200.
|
|
98
|
+
list(run("loop forever", st, {"model": "m", "max_turns": 12,
|
|
99
|
+
"cwd": tempfile.mkdtemp()}, "sys"))
|
|
100
|
+
assert calls["n"] == 12 # ran exactly to the ceiling, then terminated
|
|
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
|
|
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
|