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.
Files changed (76) hide show
  1. {drydock_cli-3.0.57/drydock_cli.egg-info → drydock_cli-3.0.58}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tools/__init__.py +59 -22
  3. {drydock_cli-3.0.57 → drydock_cli-3.0.58/drydock_cli.egg-info}/PKG-INFO +1 -1
  4. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/SOURCES.txt +1 -0
  5. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/pyproject.toml +1 -1
  6. drydock_cli-3.0.58/tests/test_bash_output_bounding.py +37 -0
  7. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_failure_loop.py +23 -0
  8. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/LICENSE +0 -0
  9. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/NOTICE +0 -0
  10. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/README.md +0 -0
  11. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/cli.py +0 -0
  16. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/compaction.py +0 -0
  17. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/config.py +0 -0
  18. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/detect.py +0 -0
  19. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/gittools.py +0 -0
  20. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/graphrag.py +0 -0
  21. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/guards.py +0 -0
  22. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/loop_detect.py +0 -0
  23. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/mcp.py +0 -0
  24. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/providers.py +0 -0
  25. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/skills.py +0 -0
  26. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tool_registry.py +0 -0
  27. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/__init__.py +0 -0
  28. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/app.py +0 -0
  29. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/approval.py +0 -0
  30. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/messages.py +0 -0
  31. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tui/widgets.py +0 -0
  32. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/tuning.py +0 -0
  33. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock/web.py +0 -0
  34. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/dependency_links.txt +0 -0
  35. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/entry_points.txt +0 -0
  36. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/requires.txt +0 -0
  37. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/drydock_cli.egg-info/top_level.txt +0 -0
  38. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/setup.cfg +0 -0
  39. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_approval.py +0 -0
  40. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_back_command.py +0 -0
  41. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_process_group.py +0 -0
  42. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_safety.py +0 -0
  43. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_bash_timeout_network.py +0 -0
  44. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_cli_agents.py +0 -0
  45. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_compact_command.py +0 -0
  46. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_compaction.py +0 -0
  47. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_config.py +0 -0
  48. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_config_migration.py +0 -0
  49. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_context_limit_config.py +0 -0
  50. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_detect.py +0 -0
  51. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_dispatch.py +0 -0
  52. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_empty_response.py +0 -0
  53. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_first_run_setup.py +0 -0
  54. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_gittools.py +0 -0
  55. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_graphrag.py +0 -0
  56. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_guards_and_tools.py +0 -0
  57. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_hallucinated_tools.py +0 -0
  58. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_leaked_tool_call.py +0 -0
  59. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_loop_detect.py +0 -0
  60. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_mcp.py +0 -0
  61. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_oneshot_unreachable.py +0 -0
  62. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_plan_autocontinue.py +0 -0
  63. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_providers_unreachable.py +0 -0
  64. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_read_index.py +0 -0
  65. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_runaway_repetition.py +0 -0
  66. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_skills.py +0 -0
  67. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_stop.py +0 -0
  68. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_streaming_newlines.py +0 -0
  69. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_subagent.py +0 -0
  70. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_todo.py +0 -0
  71. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tool_arg_parsing.py +0 -0
  72. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tools_undo.py +0 -0
  73. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tui.py +0 -0
  74. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_tuning.py +0 -0
  75. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/tests/test_vision_input.py +0 -0
  76. {drydock_cli-3.0.57 → drydock_cli-3.0.58}/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.58
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,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 True:
733
- try:
734
- out, _ = proc.communicate(timeout=0.5)
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
- 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 ""
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.57
3
+ Version: 3.0.58
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.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