drydock-cli 3.0.52__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.
- {drydock_cli-3.0.52/drydock_cli.egg-info → drydock_cli-3.0.54}/PKG-INFO +1 -1
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tools/__init__.py +88 -12
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/app.py +39 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.0.52 → drydock_cli-3.0.54/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/pyproject.toml +1 -1
- drydock_cli-3.0.54/tests/test_dispatch.py +62 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tui.py +37 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/LICENSE +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/NOTICE +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/README.md +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/agent.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/cli.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/config.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/detect.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/guards.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/providers.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/skills.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock/web.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/setup.cfg +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_config.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.52 → drydock_cli-3.0.54}/tests/test_web_tools.py +0 -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
|
|
791
|
-
"""
|
|
792
|
-
|
|
793
|
-
|
|
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, "
|
|
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", "
|
|
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
|
|
|
@@ -153,7 +153,10 @@ class DrydockApp(App):
|
|
|
153
153
|
self._abort_inflight()
|
|
154
154
|
dropped = len(self._queue)
|
|
155
155
|
self._queue.clear()
|
|
156
|
+
looping = self._repeat is not None
|
|
157
|
+
self._repeat = None # Esc/stop also ends an active /loop
|
|
156
158
|
note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
|
|
159
|
+
note += " loop ended." if looping else ""
|
|
157
160
|
self._info(note)
|
|
158
161
|
self._refresh_status()
|
|
159
162
|
|
|
@@ -251,6 +254,7 @@ class DrydockApp(App):
|
|
|
251
254
|
# A plain dict survives run()'s dict(config) shallow copy by reference.
|
|
252
255
|
self.config["_abort"] = {}
|
|
253
256
|
self._queue: list[str] = [] # prompts submitted while a turn is running
|
|
257
|
+
self._repeat: dict | None = None # active /loop: {prompt, remaining, total}
|
|
254
258
|
self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
|
|
255
259
|
self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
|
|
256
260
|
# Live "working" line state.
|
|
@@ -489,10 +493,13 @@ class DrydockApp(App):
|
|
|
489
493
|
" /graphrag build/query a knowledge base from your docs\n"
|
|
490
494
|
" /graphrag build <path> · /graphrag status · /graphrag clear\n"
|
|
491
495
|
" /skills list your reusable /<name> skills\n"
|
|
496
|
+
" /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
|
|
492
497
|
" /clear reset the conversation\n"
|
|
493
498
|
" /quit exit\n"
|
|
494
499
|
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
495
500
|
)
|
|
501
|
+
elif cmd == "/loop":
|
|
502
|
+
self._cmd_loop(arg)
|
|
496
503
|
elif cmd == "/skills":
|
|
497
504
|
self._cmd_skills()
|
|
498
505
|
elif cmd[1:] in self._skills:
|
|
@@ -500,6 +507,29 @@ class DrydockApp(App):
|
|
|
500
507
|
else:
|
|
501
508
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
502
509
|
|
|
510
|
+
def _cmd_loop(self, arg: str) -> None:
|
|
511
|
+
"""/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
|
|
512
|
+
re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
|
|
513
|
+
parts = arg.split(maxsplit=1)
|
|
514
|
+
if len(parts) < 2 or not parts[0].isdigit():
|
|
515
|
+
self._info(
|
|
516
|
+
"usage: /loop <count> <prompt> e.g. /loop 5 fix the next "
|
|
517
|
+
"failing test and run pytest\n(count 1–50; Esc stops the loop)"
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
count = max(1, min(int(parts[0]), 50))
|
|
521
|
+
prompt = parts[1].strip()
|
|
522
|
+
if not prompt:
|
|
523
|
+
self._info("usage: /loop <count> <prompt>")
|
|
524
|
+
return
|
|
525
|
+
if self._busy:
|
|
526
|
+
self._info("A turn is already running — stop it (Esc) before starting a loop.")
|
|
527
|
+
return
|
|
528
|
+
self._repeat = {"prompt": prompt, "remaining": count, "total": count}
|
|
529
|
+
self._info(f"↻ looping {count}× (Esc to stop): {prompt}")
|
|
530
|
+
self._mount(UserMessage(prompt))
|
|
531
|
+
self._begin(prompt)
|
|
532
|
+
|
|
503
533
|
def _cmd_skills(self) -> None:
|
|
504
534
|
if not self._skills:
|
|
505
535
|
self._info(
|
|
@@ -780,6 +810,15 @@ class DrydockApp(App):
|
|
|
780
810
|
if self._queue:
|
|
781
811
|
self._begin(self._queue.pop(0))
|
|
782
812
|
return
|
|
813
|
+
# /loop: re-run the prompt until the iteration count is exhausted (Esc/
|
|
814
|
+
# stop clears self._repeat). Queued user prompts take priority (above).
|
|
815
|
+
if self._repeat and self._repeat["remaining"] > 1 and not self._cancel.is_set():
|
|
816
|
+
self._repeat["remaining"] -= 1
|
|
817
|
+
done = self._repeat["total"] - self._repeat["remaining"] + 1
|
|
818
|
+
self._info(f"↻ loop iteration {done}/{self._repeat['total']}")
|
|
819
|
+
self._begin(self._repeat["prompt"])
|
|
820
|
+
return
|
|
821
|
+
self._repeat = None
|
|
783
822
|
self._refresh_status()
|
|
784
823
|
self.query_one("#prompt", PromptArea).focus()
|
|
785
824
|
|
|
@@ -153,7 +153,7 @@ class PromptHistory:
|
|
|
153
153
|
# completes the prefix.
|
|
154
154
|
SLASH_COMMANDS = [
|
|
155
155
|
"/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
|
|
156
|
-
"/compact", "/graphrag", "/clear", "/quit",
|
|
156
|
+
"/compact", "/graphrag", "/skills", "/loop", "/clear", "/quit",
|
|
157
157
|
]
|
|
158
158
|
|
|
159
159
|
|
|
@@ -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.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({}, {})
|
|
@@ -348,3 +348,40 @@ def test_new_user_turn_clears_stale_plan():
|
|
|
348
348
|
assert str(panel.render()) == "" # panel cleared
|
|
349
349
|
|
|
350
350
|
asyncio.run(main())
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_loop_command_iterates_and_stops():
|
|
354
|
+
"""/loop <n> <prompt> re-runs the prompt n times, then clears; Esc ends it."""
|
|
355
|
+
async def main():
|
|
356
|
+
app = DrydockApp({"model": "gemma4", "provider": "vllm", "cwd": "/tmp"})
|
|
357
|
+
async with app.run_test() as pilot:
|
|
358
|
+
await pilot.pause()
|
|
359
|
+
begins = []
|
|
360
|
+
app._begin = lambda text: begins.append(text) # stub out the real agent
|
|
361
|
+
|
|
362
|
+
# bad usage → no loop
|
|
363
|
+
app._handle_slash("/loop notanumber")
|
|
364
|
+
assert app._repeat is None and not begins
|
|
365
|
+
|
|
366
|
+
# /loop 3 <prompt> → starts, first iteration begun
|
|
367
|
+
app._handle_slash("/loop 3 do the thing")
|
|
368
|
+
assert app._repeat and app._repeat["total"] == 3
|
|
369
|
+
assert begins == ["do the thing"]
|
|
370
|
+
|
|
371
|
+
# each finished turn re-begins until the count is exhausted
|
|
372
|
+
from drydock.tui.messages import AgentFinished
|
|
373
|
+
app.on_agent_finished(AgentFinished()) # iter 2
|
|
374
|
+
app.on_agent_finished(AgentFinished()) # iter 3
|
|
375
|
+
assert len(begins) == 3
|
|
376
|
+
app.on_agent_finished(AgentFinished()) # done → loop cleared
|
|
377
|
+
assert app._repeat is None
|
|
378
|
+
assert len(begins) == 3 # no 4th run
|
|
379
|
+
|
|
380
|
+
# Esc/stop ends an active loop
|
|
381
|
+
app._begin = lambda text: begins.append(text)
|
|
382
|
+
app._handle_slash("/loop 5 again")
|
|
383
|
+
app._busy = True
|
|
384
|
+
app.action_stop()
|
|
385
|
+
assert app._repeat is None
|
|
386
|
+
|
|
387
|
+
asyncio.run(main())
|
|
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
|