drydock-cli 3.0.57__tar.gz → 3.0.58__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.58}/PKG-INFO +1 -1
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tools/__init__.py +59 -22
- {drydock_cli-3.0.57 → drydock_cli-3.0.58/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/pyproject.toml +1 -1
- drydock_cli-3.0.58/tests/test_bash_output_bounding.py +37 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_failure_loop.py +23 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/LICENSE +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/NOTICE +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/README.md +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/agent.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/cli.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/guards.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/providers.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/skills.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/web.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/setup.cfg +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_web_tools.py +0 -0
|
@@ -11,9 +11,17 @@ 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
|
+
|
|
17
25
|
from drydock.tool_registry import ToolDef, register
|
|
18
26
|
from drydock.guards import (
|
|
19
27
|
conflict_marker_refusal,
|
|
@@ -728,30 +736,59 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
728
736
|
text=True, cwd=config.get("cwd"), start_new_session=True,
|
|
729
737
|
)
|
|
730
738
|
config.setdefault("_abort", {})["proc"] = proc
|
|
739
|
+
# Read output in a daemon thread with a HARD byte cap, so memory can't
|
|
740
|
+
# balloon on a runaway-output command. The thread stops (and we kill the
|
|
741
|
+
# process) once the cap is hit; the main loop polls cancel + timeout.
|
|
742
|
+
chunks: list[str] = []
|
|
743
|
+
total = [0]
|
|
744
|
+
capped = threading.Event()
|
|
745
|
+
|
|
746
|
+
def _drain():
|
|
747
|
+
assert proc.stdout is not None
|
|
748
|
+
while True:
|
|
749
|
+
block = proc.stdout.read(8192)
|
|
750
|
+
if not block:
|
|
751
|
+
break
|
|
752
|
+
chunks.append(block)
|
|
753
|
+
total[0] += len(block)
|
|
754
|
+
if total[0] >= _MAX_BASH_OUTPUT_BYTES:
|
|
755
|
+
capped.set()
|
|
756
|
+
break
|
|
757
|
+
|
|
758
|
+
reader = threading.Thread(target=_drain, daemon=True)
|
|
759
|
+
reader.start()
|
|
731
760
|
start = time.monotonic()
|
|
732
|
-
while
|
|
733
|
-
|
|
734
|
-
|
|
761
|
+
while reader.is_alive():
|
|
762
|
+
reader.join(0.3)
|
|
763
|
+
if capped.is_set():
|
|
764
|
+
kill_process_group(proc) # stop it producing more
|
|
735
765
|
break
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
output =
|
|
766
|
+
if cancel is not None and cancel.is_set():
|
|
767
|
+
kill_process_group(proc)
|
|
768
|
+
proc.wait()
|
|
769
|
+
return "[stopped by user]"
|
|
770
|
+
if time.monotonic() - start > timeout:
|
|
771
|
+
kill_process_group(proc)
|
|
772
|
+
proc.wait()
|
|
773
|
+
bigger = min(timeout * 4, 1800)
|
|
774
|
+
msg = (
|
|
775
|
+
f"Error: command timed out after {timeout}s. If it is "
|
|
776
|
+
f"legitimately slow (a big query, build, download, or "
|
|
777
|
+
f"test run), retry with a larger timeout — pass "
|
|
778
|
+
f"timeout: {bigger}. Otherwise it may be hung."
|
|
779
|
+
)
|
|
780
|
+
if _is_network_command(cmd):
|
|
781
|
+
msg += _OFFLINE_HINT
|
|
782
|
+
return msg
|
|
783
|
+
proc.wait()
|
|
784
|
+
output = "".join(chunks)
|
|
785
|
+
if capped.is_set():
|
|
786
|
+
return (
|
|
787
|
+
output[:_MAX_BASH_OUTPUT_BYTES]
|
|
788
|
+
+ f"\n[output truncated at {_MAX_BASH_OUTPUT_BYTES // 1024} KB — "
|
|
789
|
+
"the command produced more; redirect to a file and inspect it in "
|
|
790
|
+
"pieces (head/tail/grep) instead of dumping it all]"
|
|
791
|
+
)
|
|
755
792
|
if proc.returncode != 0:
|
|
756
793
|
output += f"\n[exit code: {proc.returncode}]"
|
|
757
794
|
# 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.58"
|
|
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,37 @@
|
|
|
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
|
|
@@ -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
|