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.
Files changed (76) hide show
  1. {drydock_cli-3.0.57/drydock_cli.egg-info → drydock_cli-3.0.59}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tools/__init__.py +85 -22
  3. {drydock_cli-3.0.57 → drydock_cli-3.0.59/drydock_cli.egg-info}/PKG-INFO +1 -1
  4. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/SOURCES.txt +1 -0
  5. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/pyproject.toml +1 -1
  6. drydock_cli-3.0.59/tests/test_bash_output_bounding.py +49 -0
  7. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_failure_loop.py +23 -0
  8. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/LICENSE +0 -0
  9. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/NOTICE +0 -0
  10. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/README.md +0 -0
  11. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/cli.py +0 -0
  16. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/compaction.py +0 -0
  17. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/config.py +0 -0
  18. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/detect.py +0 -0
  19. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/gittools.py +0 -0
  20. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/graphrag.py +0 -0
  21. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/guards.py +0 -0
  22. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/loop_detect.py +0 -0
  23. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/mcp.py +0 -0
  24. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/providers.py +0 -0
  25. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/skills.py +0 -0
  26. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tool_registry.py +0 -0
  27. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/__init__.py +0 -0
  28. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/app.py +0 -0
  29. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/approval.py +0 -0
  30. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/messages.py +0 -0
  31. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tui/widgets.py +0 -0
  32. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/tuning.py +0 -0
  33. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock/web.py +0 -0
  34. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/dependency_links.txt +0 -0
  35. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/entry_points.txt +0 -0
  36. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/requires.txt +0 -0
  37. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/drydock_cli.egg-info/top_level.txt +0 -0
  38. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/setup.cfg +0 -0
  39. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_approval.py +0 -0
  40. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_back_command.py +0 -0
  41. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_process_group.py +0 -0
  42. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_safety.py +0 -0
  43. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_bash_timeout_network.py +0 -0
  44. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_cli_agents.py +0 -0
  45. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_compact_command.py +0 -0
  46. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_compaction.py +0 -0
  47. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_config.py +0 -0
  48. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_config_migration.py +0 -0
  49. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_context_limit_config.py +0 -0
  50. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_detect.py +0 -0
  51. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_dispatch.py +0 -0
  52. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_empty_response.py +0 -0
  53. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_first_run_setup.py +0 -0
  54. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_gittools.py +0 -0
  55. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_graphrag.py +0 -0
  56. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_guards_and_tools.py +0 -0
  57. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_hallucinated_tools.py +0 -0
  58. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_leaked_tool_call.py +0 -0
  59. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_loop_detect.py +0 -0
  60. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_mcp.py +0 -0
  61. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_oneshot_unreachable.py +0 -0
  62. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_plan_autocontinue.py +0 -0
  63. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_providers_unreachable.py +0 -0
  64. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_read_index.py +0 -0
  65. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_runaway_repetition.py +0 -0
  66. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_skills.py +0 -0
  67. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_stop.py +0 -0
  68. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_streaming_newlines.py +0 -0
  69. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_subagent.py +0 -0
  70. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_todo.py +0 -0
  71. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tool_arg_parsing.py +0 -0
  72. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tools_undo.py +0 -0
  73. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tui.py +0 -0
  74. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_tuning.py +0 -0
  75. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_vision_input.py +0 -0
  76. {drydock_cli-3.0.57 → drydock_cli-3.0.59}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.57
3
+ Version: 3.0.59
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
@@ -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 True:
733
- try:
734
- out, _ = proc.communicate(timeout=0.5)
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
- except subprocess.TimeoutExpired:
737
- if cancel is not None and cancel.is_set():
738
- kill_process_group(proc)
739
- proc.communicate()
740
- return "[stopped by user]"
741
- if time.monotonic() - start > timeout:
742
- kill_process_group(proc)
743
- proc.communicate()
744
- bigger = min(timeout * 4, 1800)
745
- msg = (
746
- f"Error: command timed out after {timeout}s. If it is "
747
- f"legitimately slow (a big query, build, download, or "
748
- f"test run), retry with a larger timeout — pass "
749
- f"timeout: {bigger}. Otherwise it may be hung."
750
- )
751
- if _is_network_command(cmd):
752
- msg += _OFFLINE_HINT
753
- return msg
754
- output = out or ""
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.57
3
+ Version: 3.0.59
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
@@ -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.57"
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