drydock-cli 3.0.53__tar.gz → 3.0.54__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 (72) hide show
  1. {drydock_cli-3.0.53/drydock_cli.egg-info → drydock_cli-3.0.54}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tools/__init__.py +88 -12
  3. {drydock_cli-3.0.53 → drydock_cli-3.0.54/drydock_cli.egg-info}/PKG-INFO +1 -1
  4. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock_cli.egg-info/SOURCES.txt +1 -0
  5. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/pyproject.toml +1 -1
  6. drydock_cli-3.0.54/tests/test_dispatch.py +62 -0
  7. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/LICENSE +0 -0
  8. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/NOTICE +0 -0
  9. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/README.md +0 -0
  10. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/__init__.py +0 -0
  11. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/__main__.py +0 -0
  12. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/agent.py +0 -0
  13. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/bash_safety.py +0 -0
  14. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/cli.py +0 -0
  15. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/compaction.py +0 -0
  16. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/config.py +0 -0
  17. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/detect.py +0 -0
  18. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/gittools.py +0 -0
  19. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/graphrag.py +0 -0
  20. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/guards.py +0 -0
  21. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/loop_detect.py +0 -0
  22. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/providers.py +0 -0
  23. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/skills.py +0 -0
  24. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tool_registry.py +0 -0
  25. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tui/__init__.py +0 -0
  26. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tui/app.py +0 -0
  27. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tui/approval.py +0 -0
  28. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tui/messages.py +0 -0
  29. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tui/widgets.py +0 -0
  30. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/tuning.py +0 -0
  31. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock/web.py +0 -0
  32. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock_cli.egg-info/dependency_links.txt +0 -0
  33. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock_cli.egg-info/entry_points.txt +0 -0
  34. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock_cli.egg-info/requires.txt +0 -0
  35. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/drydock_cli.egg-info/top_level.txt +0 -0
  36. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/setup.cfg +0 -0
  37. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_approval.py +0 -0
  38. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_back_command.py +0 -0
  39. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_bash_process_group.py +0 -0
  40. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_bash_safety.py +0 -0
  41. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_bash_timeout_network.py +0 -0
  42. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_cli_agents.py +0 -0
  43. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_compact_command.py +0 -0
  44. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_compaction.py +0 -0
  45. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_config.py +0 -0
  46. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_config_migration.py +0 -0
  47. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_context_limit_config.py +0 -0
  48. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_detect.py +0 -0
  49. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_empty_response.py +0 -0
  50. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_failure_loop.py +0 -0
  51. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_first_run_setup.py +0 -0
  52. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_gittools.py +0 -0
  53. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_graphrag.py +0 -0
  54. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_guards_and_tools.py +0 -0
  55. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_hallucinated_tools.py +0 -0
  56. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_leaked_tool_call.py +0 -0
  57. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_loop_detect.py +0 -0
  58. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_oneshot_unreachable.py +0 -0
  59. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_plan_autocontinue.py +0 -0
  60. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_providers_unreachable.py +0 -0
  61. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_runaway_repetition.py +0 -0
  62. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_skills.py +0 -0
  63. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_stop.py +0 -0
  64. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_streaming_newlines.py +0 -0
  65. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_subagent.py +0 -0
  66. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_todo.py +0 -0
  67. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_tool_arg_parsing.py +0 -0
  68. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_tools_undo.py +0 -0
  69. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_tui.py +0 -0
  70. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_tuning.py +0 -0
  71. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/tests/test_vision_input.py +0 -0
  72. {drydock_cli-3.0.53 → drydock_cli-3.0.54}/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.53
3
+ Version: 3.0.54
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
@@ -161,6 +161,34 @@ SCHEMAS = [
161
161
  "required": ["prompt"],
162
162
  },
163
163
  },
164
+ {
165
+ "name": "Dispatch",
166
+ "description": (
167
+ "Run SEVERAL read-only investigation sub-agents at once (in "
168
+ "parallel), each in its own fresh context with Read/Glob/Grep/Bash. "
169
+ "Use it to answer multiple INDEPENDENT questions concurrently, e.g. "
170
+ "'where is auth handled?', 'how does the DB layer work?', 'what tests "
171
+ "exist?'. Returns each agent's summary. They cannot write or recurse."
172
+ ),
173
+ "input_schema": {
174
+ "type": "object",
175
+ "properties": {
176
+ "tasks": {
177
+ "type": "array",
178
+ "description": "Up to 6 independent investigation tasks.",
179
+ "items": {
180
+ "type": "object",
181
+ "properties": {
182
+ "prompt": {"type": "string", "description": "The self-contained task."},
183
+ "label": {"type": "string", "description": "Optional short label."},
184
+ },
185
+ "required": ["prompt"],
186
+ },
187
+ },
188
+ },
189
+ "required": ["tasks"],
190
+ },
191
+ },
164
192
  {
165
193
  "name": "GitStatus",
166
194
  "description": (
@@ -787,15 +815,10 @@ _SUBAGENT_SYSTEM = (
787
815
  )
788
816
 
789
817
 
790
- def tool_task(params: dict, config: dict) -> str:
791
- """Spawn a read-only sub-agent with a FRESH context for a focused
792
- exploration task, returning only its final summary. Keeps big searches out
793
- of the main agent's context. Cannot recurse (no `task` tool) and cannot
794
- write (no Write/Edit), so it can never corrupt the parent's work."""
795
- prompt = (params.get("prompt") or params.get("description") or "").strip()
796
- if not prompt:
797
- return "Error: `task` needs a `prompt` describing what to investigate."
798
- # Lazy import to avoid a tools <-> agent import cycle.
818
+ def _run_subagent(prompt: str, config: dict) -> str:
819
+ """Run one read-only sub-agent to completion and return its final summary.
820
+ Shared by `task` (one) and `Dispatch` (many in parallel). Hard-capped; never
821
+ raises (a sub-agent must not crash the parent turn)."""
799
822
  from drydock.agent import run as agent_run, AgentState, TurnDone
800
823
 
801
824
  sub_state = AgentState()
@@ -806,6 +829,9 @@ def tool_task(params: dict, config: dict) -> str:
806
829
  sub_config["max_tool_calls"] = 20
807
830
  sub_config.pop("_todo", None) # the sub-agent keeps no checklist of its own
808
831
  sub_config.pop("_plan_autocontinue", None)
832
+ # Own abort holder so parallel sub-agents don't clobber each other's (or the
833
+ # parent's) in-flight client/proc handles in the shared dict.
834
+ sub_config["_abort"] = {}
809
835
  steps = 0
810
836
  try:
811
837
  for ev in agent_run(prompt, sub_state, sub_config, _SUBAGENT_SYSTEM):
@@ -819,6 +845,54 @@ def tool_task(params: dict, config: dict) -> str:
819
845
  return f"[sub-agent finished {steps} step(s) with no summary]"
820
846
 
821
847
 
848
+ def tool_task(params: dict, config: dict) -> str:
849
+ """Spawn a read-only sub-agent with a FRESH context for a focused
850
+ exploration task, returning only its final summary. Keeps big searches out
851
+ of the main agent's context. Cannot recurse (no `task` tool) and cannot
852
+ write (no Write/Edit), so it can never corrupt the parent's work."""
853
+ prompt = (params.get("prompt") or params.get("description") or "").strip()
854
+ if not prompt:
855
+ return "Error: `task` needs a `prompt` describing what to investigate."
856
+ return _run_subagent(prompt, config)
857
+
858
+
859
+ def tool_dispatch(params: dict, config: dict) -> str:
860
+ """Run SEVERAL read-only sub-agents concurrently and return all summaries.
861
+ Each gets its own fresh context + Read/Glob/Grep/Bash (no recursion, no
862
+ writes). Use it to investigate independent questions at once."""
863
+ import concurrent.futures
864
+
865
+ raw = params.get("tasks") or params.get("agents") or []
866
+ if isinstance(raw, dict):
867
+ raw = [raw]
868
+ norm: list[dict] = []
869
+ for t in raw:
870
+ if isinstance(t, str) and t.strip():
871
+ norm.append({"prompt": t.strip(), "label": ""})
872
+ elif isinstance(t, dict):
873
+ p = (t.get("prompt") or t.get("description") or "").strip()
874
+ if p:
875
+ norm.append({"prompt": p, "label": (t.get("label") or t.get("description") or "").strip()})
876
+ if not norm:
877
+ return "Error: `Dispatch` needs a `tasks` list, each item a prompt (string) or {prompt, label}."
878
+ norm = norm[:6] # cap fan-out
879
+
880
+ results: list[str] = [""] * len(norm)
881
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(norm))) as ex:
882
+ futs = {ex.submit(_run_subagent, t["prompt"], config): i for i, t in enumerate(norm)}
883
+ for fut in concurrent.futures.as_completed(futs):
884
+ i = futs[fut]
885
+ try:
886
+ results[i] = fut.result()
887
+ except Exception as e: # noqa: BLE001
888
+ results[i] = f"[agent error: {e}]"
889
+ parts = [f"Dispatched {len(norm)} sub-agent(s) in parallel:"]
890
+ for i, t in enumerate(norm):
891
+ label = t["label"] or f"agent {i + 1}"
892
+ parts.append(f"\n=== {label} ===\n{results[i]}")
893
+ return "\n".join(parts)
894
+
895
+
822
896
  def _git_cwd(config: dict) -> str:
823
897
  return config.get("cwd") or os.getcwd()
824
898
 
@@ -940,6 +1014,7 @@ _TOOLS = [
940
1014
  ("Grep", tool_grep, True),
941
1015
  ("todo", tool_todo, False),
942
1016
  ("task", tool_task, True),
1017
+ ("Dispatch", tool_dispatch, True),
943
1018
  ("Knowledge", tool_knowledge, True),
944
1019
  ("WebSearch", tool_websearch, True),
945
1020
  ("WebFetch", tool_webfetch, True),
@@ -955,7 +1030,8 @@ def register_all():
955
1030
  func = {
956
1031
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
957
1032
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
958
- "todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
1033
+ "todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
1034
+ "Knowledge": tool_knowledge,
959
1035
  "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
960
1036
  "GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
961
1037
  "GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
@@ -963,8 +1039,8 @@ def register_all():
963
1039
  # Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
964
1040
  # GitCommit writes a local, reversible commit).
965
1041
  read_only = name in (
966
- "Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
967
- "GitStatus", "GitDiff", "GitLog",
1042
+ "Read", "Glob", "Grep", "task", "Dispatch", "Knowledge",
1043
+ "WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
968
1044
  )
969
1045
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
970
1046
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.53
3
+ Version: 3.0.54
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
@@ -43,6 +43,7 @@ tests/test_config.py
43
43
  tests/test_config_migration.py
44
44
  tests/test_context_limit_config.py
45
45
  tests/test_detect.py
46
+ tests/test_dispatch.py
46
47
  tests/test_empty_response.py
47
48
  tests/test_failure_loop.py
48
49
  tests/test_first_run_setup.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.53"
10
+ version = "3.0.54"
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,62 @@
1
+ """Multi-agent: the Dispatch tool fans out several read-only sub-agents in
2
+ parallel and returns all summaries. The sub-agent runner is mocked so tests are
3
+ deterministic and don't need the LLM."""
4
+ from __future__ import annotations
5
+
6
+ import time
7
+
8
+ import drydock.tools as T
9
+
10
+
11
+ def test_dispatch_runs_each_task_and_labels(monkeypatch):
12
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
13
+ out = T.tool_dispatch({"tasks": [
14
+ {"prompt": "find auth", "label": "auth"},
15
+ {"prompt": "find db"},
16
+ ]}, {})
17
+ assert "=== auth ===" in out and "R:find auth" in out
18
+ assert "=== agent 2 ===" in out and "R:find db" in out
19
+
20
+
21
+ def test_dispatch_accepts_string_tasks(monkeypatch):
22
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
23
+ out = T.tool_dispatch({"tasks": ["a", "b", "c"]}, {})
24
+ assert out.count("R:") == 3
25
+
26
+
27
+ def test_dispatch_needs_tasks():
28
+ assert "needs a `tasks`" in T.tool_dispatch({"tasks": []}, {})
29
+ assert "needs a `tasks`" in T.tool_dispatch({}, {})
30
+
31
+
32
+ def test_dispatch_caps_fanout(monkeypatch):
33
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: "x")
34
+ out = T.tool_dispatch({"tasks": [f"t{i}" for i in range(20)]}, {})
35
+ assert "Dispatched 6 sub-agent" in out # capped at 6
36
+
37
+
38
+ def test_dispatch_isolates_subagent_errors(monkeypatch):
39
+ def runner(p, c):
40
+ if "boom" in p:
41
+ raise RuntimeError("kaboom")
42
+ return f"R:{p}"
43
+ monkeypatch.setattr(T, "_run_subagent", runner)
44
+ out = T.tool_dispatch({"tasks": ["ok", "boom"]}, {})
45
+ assert "R:ok" in out and "agent error" in out # one fails, the other survives
46
+
47
+
48
+ def test_dispatch_runs_concurrently(monkeypatch):
49
+ # 4 tasks that each sleep 0.2s should finish in well under 0.8s (serial time)
50
+ def slow(p, c):
51
+ time.sleep(0.2)
52
+ return "done"
53
+ monkeypatch.setattr(T, "_run_subagent", slow)
54
+ t0 = time.monotonic()
55
+ T.tool_dispatch({"tasks": ["a", "b", "c", "d"]}, {})
56
+ assert time.monotonic() - t0 < 0.6 # parallel, not 0.8s serial
57
+
58
+
59
+ def test_task_still_works_via_shared_runner(monkeypatch):
60
+ monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"summary:{p}")
61
+ assert T.tool_task({"prompt": "explore X"}, {}) == "summary:explore X"
62
+ assert "needs a `prompt`" in T.tool_task({}, {})
File without changes
File without changes
File without changes
File without changes