drydock-cli 3.0.15__tar.gz → 3.0.17__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 (48) hide show
  1. {drydock_cli-3.0.15/drydock_cli.egg-info → drydock_cli-3.0.17}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/agent.py +56 -2
  4. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tools/__init__.py +88 -6
  5. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tuning.py +11 -8
  6. {drydock_cli-3.0.15 → drydock_cli-3.0.17/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock_cli.egg-info/SOURCES.txt +3 -0
  8. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/pyproject.toml +1 -1
  9. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_hallucinated_tools.py +3 -2
  10. drydock_cli-3.0.17/tests/test_plan_autocontinue.py +80 -0
  11. drydock_cli-3.0.17/tests/test_subagent.py +58 -0
  12. drydock_cli-3.0.17/tests/test_todo.py +38 -0
  13. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/LICENSE +0 -0
  14. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/NOTICE +0 -0
  15. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/README.md +0 -0
  16. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/cli.py +0 -0
  19. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/compaction.py +0 -0
  20. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/config.py +0 -0
  21. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/detect.py +0 -0
  22. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/guards.py +0 -0
  23. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/loop_detect.py +0 -0
  24. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/providers.py +0 -0
  25. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tool_registry.py +0 -0
  26. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tui/__init__.py +0 -0
  27. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tui/app.py +0 -0
  28. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tui/approval.py +0 -0
  29. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tui/messages.py +0 -0
  30. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock/tui/widgets.py +0 -0
  31. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock_cli.egg-info/dependency_links.txt +0 -0
  32. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock_cli.egg-info/entry_points.txt +0 -0
  33. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock_cli.egg-info/requires.txt +0 -0
  34. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/drydock_cli.egg-info/top_level.txt +0 -0
  35. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/setup.cfg +0 -0
  36. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_approval.py +0 -0
  37. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_back_command.py +0 -0
  38. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_bash_safety.py +0 -0
  39. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_compaction.py +0 -0
  40. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_config.py +0 -0
  41. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_detect.py +0 -0
  42. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_guards_and_tools.py +0 -0
  43. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_leaked_tool_call.py +0 -0
  44. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_loop_detect.py +0 -0
  45. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_providers_unreachable.py +0 -0
  46. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_tools_undo.py +0 -0
  47. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_tui.py +0 -0
  48. {drydock_cli-3.0.15 → drydock_cli-3.0.17}/tests/test_tuning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.15
3
+ Version: 3.0.17
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
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.15"
10
+ __version__ = "3.0.17"
@@ -4,9 +4,21 @@ DryDock v3 — clean, provider-agnostic, no model-specific hacks.
4
4
  """
5
5
  from __future__ import annotations
6
6
 
7
+ import os
7
8
  from dataclasses import dataclass, field
8
9
  from typing import Generator
9
10
 
11
+ # Max CONSECUTIVE text-only stalls (counter resets on any real tool call) we
12
+ # nudge through when the model leaves its own plan unfinished. Bounds a true
13
+ # stall-loop while letting a long, productive plan run as far as it needs.
14
+ PLAN_CONTINUE_CAP = 3
15
+
16
+
17
+ def _plan_has_unfinished(config: dict) -> bool:
18
+ """True if the model laid out a `todo` plan that still has non-done items."""
19
+ todo = config.get("_todo")
20
+ return bool(todo) and any(status != "done" for _, status in todo)
21
+
10
22
  from drydock.providers import stream, AssistantTurn, TextChunk
11
23
  from drydock.tool_registry import schemas, execute
12
24
  from drydock.tools import register_all
@@ -87,9 +99,14 @@ def run(
87
99
 
88
100
  max_turns = config.get("max_turns", 200)
89
101
  max_tool_calls = config.get("max_tool_calls", 0) # 0 = unlimited
102
+ # When set (sub-agents pass this), the model is offered ONLY these tools and
103
+ # a call to anything else is refused — never executed. Keeps a read-only
104
+ # sub-agent read-only and stops it from recursing into `task`.
105
+ allow = config.get("tool_allowlist")
90
106
  tool_call_count = 0
91
107
  session_has_edited = False
92
108
  leaked_call_retries = 0
109
+ plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
93
110
  run_iteration = 0 # stream calls within THIS run() (resets per user message)
94
111
  loop_tracker = LoopTracker()
95
112
 
@@ -122,11 +139,14 @@ def run(
122
139
  retries = 0
123
140
  while retries < 2:
124
141
  try:
142
+ available = schemas()
143
+ if allow is not None:
144
+ available = [s for s in available if s.get("name") in allow]
125
145
  for event in stream(
126
146
  model=turn_config["model"],
127
147
  system=system_prompt,
128
148
  messages=state.messages,
129
- tool_schemas=filter_tool_schemas(schemas(), turn_config.get("model")),
149
+ tool_schemas=filter_tool_schemas(available, turn_config.get("model")),
130
150
  config=turn_config,
131
151
  ):
132
152
  if isinstance(event, TextChunk):
@@ -177,6 +197,26 @@ def run(
177
197
  ),
178
198
  })
179
199
  continue
200
+ # Don't stall mid-plan: if the model laid out a todo and still has
201
+ # unfinished steps, nudge it to keep going instead of waiting for
202
+ # the user to say "proceed". Capped (consecutive) + env-gated so it
203
+ # can never wedge; resets to 0 on any real tool call below.
204
+ if (
205
+ _plan_has_unfinished(config)
206
+ and plan_continue_nudges < PLAN_CONTINUE_CAP
207
+ and not os.environ.get("DRYDOCK_PLAN_AUTOCONTINUE_DISABLE")
208
+ ):
209
+ plan_continue_nudges += 1
210
+ state.messages.append({
211
+ "role": "user",
212
+ "content": (
213
+ "[SYSTEM] Your plan still has unfinished steps. Keep "
214
+ "going now — do the next step with a tool call; do NOT "
215
+ "stop to ask whether to proceed. If every step is truly "
216
+ "done, call `todo` once more marking them all [x]."
217
+ ),
218
+ })
219
+ continue
180
220
  break
181
221
 
182
222
  # Execute each tool call
@@ -200,7 +240,16 @@ def run(
200
240
  # Redirect hallucinated tool names to a benign hint instead of a
201
241
  # "tool not found" error the model would loop on.
202
242
  halluc = hallucinated_tool_message(tc["name"])
203
- result = halluc if halluc is not None else execute(tc["name"], tc["input"], config)
243
+ if halluc is not None:
244
+ result = halluc
245
+ elif allow is not None and tc["name"] not in allow:
246
+ result = (
247
+ f"[The '{tc['name']}' tool is not available here. You may use "
248
+ f"only: {', '.join(allow)}. Use one of those, or reply with "
249
+ "your final summary.]"
250
+ )
251
+ else:
252
+ result = execute(tc["name"], tc["input"], config)
204
253
  # Guide (never block) on exact-repeat tool calls: prepend an
205
254
  # advisory note when the same call is made again.
206
255
  result = loop_tracker.annotate(tc["name"], tc["input"], result)
@@ -215,6 +264,11 @@ def run(
215
264
  "content": result,
216
265
  })
217
266
 
267
+ # Real progress this turn — reset the consecutive stall-nudge counter so
268
+ # a long, productive plan can run as far as it needs (the cap only
269
+ # bounds back-to-back stalls).
270
+ plan_continue_nudges = 0
271
+
218
272
  # Nudge: if past 15 tool calls without any edits, inject gentle guidance
219
273
  if tool_call_count == 15 and not session_has_edited and config.get("force_first_tool"):
220
274
  state.messages.append({
@@ -134,6 +134,31 @@ SCHEMAS = [
134
134
  "required": ["tasks"],
135
135
  },
136
136
  },
137
+ {
138
+ "name": "task",
139
+ "description": (
140
+ "Delegate a focused, READ-ONLY investigation to a sub-agent that "
141
+ "runs in its OWN fresh context and can only Read/Glob/Grep/Bash. It "
142
+ "explores, then returns a concise summary — keeping a big search out "
143
+ "of your context. Use it for a self-contained question like 'where "
144
+ "is auth handled?' or 'how does module X work?'. It CANNOT edit "
145
+ "files, so act on its summary yourself. Give it one clear task."
146
+ ),
147
+ "input_schema": {
148
+ "type": "object",
149
+ "properties": {
150
+ "prompt": {
151
+ "type": "string",
152
+ "description": "The investigation task, stated fully and self-contained.",
153
+ },
154
+ "description": {
155
+ "type": "string",
156
+ "description": "Optional 3-5 word label for the task.",
157
+ },
158
+ },
159
+ "required": ["prompt"],
160
+ },
161
+ },
137
162
  ]
138
163
 
139
164
  # ── Tool implementations ──────────────────────────────────────────────────
@@ -471,10 +496,16 @@ _TODO_DONE = {"x", "X", "✓"}
471
496
  _TODO_DOING = {"~", "-", "/", "*"}
472
497
 
473
498
 
474
- def parse_todo(tasks: str) -> list[tuple[str, str]]:
475
- """Parse a plain-text checklist into (text, status) pairs. Each non-empty
476
- line is one task, optionally prefixed with [x] done / [~] in_progress /
477
- [ ] pending. Deliberately forgiving (a bare line counts as pending)."""
499
+ def parse_todo(tasks) -> list[tuple[str, str]]:
500
+ """Parse a checklist into (text, status) pairs. Each non-empty line is one
501
+ task, optionally prefixed with [x] done / [~] in_progress / [ ] pending.
502
+ Deliberately forgiving: a bare line counts as pending, and `tasks` may be a
503
+ newline-joined string OR a list of lines (the model sometimes sends a JSON
504
+ array instead of one string — handle both rather than crash the TUI)."""
505
+ if isinstance(tasks, (list, tuple)):
506
+ tasks = "\n".join(str(t) for t in tasks)
507
+ elif tasks is not None and not isinstance(tasks, str):
508
+ tasks = str(tasks)
478
509
  out: list[tuple[str, str]] = []
479
510
  for raw in (tasks or "").splitlines():
480
511
  line = raw.strip().lstrip("-*•").strip()
@@ -504,6 +535,55 @@ def tool_todo(params: dict, config: dict) -> str:
504
535
  return f"Plan updated: {len(items)} tasks ({done} done, {doing} in progress)."
505
536
 
506
537
 
538
+ # ── Read-only sub-agent (the `task` tool) ─────────────────────────────────
539
+
540
+ # Tools a sub-agent may use. Excludes Write/Edit/todo (no silent mutations the
541
+ # parent can't see) and `task` itself (no recursion). Bash is included for real
542
+ # exploration (ls/cat/grep/find/git log) and is bounded by the same bash_safety.
543
+ SUBAGENT_TOOLS = ("Read", "Glob", "Grep", "Bash")
544
+
545
+ _SUBAGENT_SYSTEM = (
546
+ "You are a focused exploration sub-agent inside Drydock. You have read-only "
547
+ "tools: Read, Glob, Grep, and Bash (use Bash only to INSPECT — ls, cat, "
548
+ "grep, find, git log — never to modify files). Investigate the task you are "
549
+ "given, then STOP and reply with a concise, factual summary of what you "
550
+ "found: concrete file:line references and the key code, not narration. Do "
551
+ "NOT try to edit or create files — the main agent acts on your findings."
552
+ )
553
+
554
+
555
+ def tool_task(params: dict, config: dict) -> str:
556
+ """Spawn a read-only sub-agent with a FRESH context for a focused
557
+ exploration task, returning only its final summary. Keeps big searches out
558
+ of the main agent's context. Cannot recurse (no `task` tool) and cannot
559
+ write (no Write/Edit), so it can never corrupt the parent's work."""
560
+ prompt = (params.get("prompt") or params.get("description") or "").strip()
561
+ if not prompt:
562
+ return "Error: `task` needs a `prompt` describing what to investigate."
563
+ # Lazy import to avoid a tools <-> agent import cycle.
564
+ from drydock.agent import run as agent_run, AgentState, TurnDone
565
+
566
+ sub_state = AgentState()
567
+ sub_config = dict(config)
568
+ sub_config["tool_allowlist"] = list(SUBAGENT_TOOLS)
569
+ sub_config["force_first_tool"] = False
570
+ sub_config["max_turns"] = 24 # bound the helper hard
571
+ sub_config["max_tool_calls"] = 20
572
+ sub_config.pop("_todo", None) # the sub-agent keeps no checklist of its own
573
+ sub_config.pop("_plan_autocontinue", None)
574
+ steps = 0
575
+ try:
576
+ for ev in agent_run(prompt, sub_state, sub_config, _SUBAGENT_SYSTEM):
577
+ if isinstance(ev, TurnDone):
578
+ steps += 1
579
+ except Exception as e: # a sub-agent must never crash the parent turn
580
+ return f"[sub-agent error: {e}]"
581
+ for msg in reversed(sub_state.messages):
582
+ if msg.get("role") == "assistant" and (msg.get("content") or "").strip():
583
+ return msg["content"].strip()
584
+ return f"[sub-agent finished {steps} step(s) with no summary]"
585
+
586
+
507
587
  # ── Register all tools ────────────────────────────────────────────────────
508
588
 
509
589
  _TOOLS = [
@@ -514,6 +594,7 @@ _TOOLS = [
514
594
  ("Glob", tool_glob, True),
515
595
  ("Grep", tool_grep, True),
516
596
  ("todo", tool_todo, False),
597
+ ("task", tool_task, True),
517
598
  ]
518
599
 
519
600
  def register_all():
@@ -522,9 +603,10 @@ def register_all():
522
603
  func = {
523
604
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
524
605
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
525
- "todo": tool_todo,
606
+ "todo": tool_todo, "task": tool_task,
526
607
  }[name]
527
- read_only = name in ("Read", "Glob", "Grep")
608
+ # `task` is read-only w.r.t. the parent's files (it can't Write/Edit).
609
+ read_only = name in ("Read", "Glob", "Grep", "task")
528
610
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
529
611
 
530
612
  register_all()
@@ -24,10 +24,12 @@ GEMMA_DISABLED_TOOLS: frozenset[str] = frozenset({
24
24
  # `todo` is re-enabled (v3.0.13): the new single-string checklist tool is
25
25
  # far simpler than the fork's nested task_* tools that looped/validation-
26
26
  # errored on Gemma, and repeat-pruning now starves any call-loop.
27
+ # `task` is re-enabled (v3.0.16) as a SINGLE read-only sub-agent call that
28
+ # returns a summary — not the fork's stateful task graph. It can't recurse
29
+ # (no `task` in its own toolset) and is hard-capped, so it can't loop.
27
30
  "ask_user_question",
28
31
  "task_create",
29
32
  "task_update",
30
- "task",
31
33
  "invoke_skill",
32
34
  "tool_search",
33
35
  })
@@ -85,9 +87,10 @@ _DEFAULT_SYSTEM_PROMPT = (
85
87
  "inspect what you need, make the change, verify it, and prefer doing over "
86
88
  "explaining. When the task is complete, stop and give a short summary.\n"
87
89
  "For a task with several distinct steps, call the `todo` tool once at the "
88
- "start to lay out the plan, then call it again to flip a step to done and "
89
- "the next to in-progress as you finish each don't write a TODO.md file "
90
- "for this."
90
+ "start to lay out the plan, then START WORKING THROUGH IT IMMEDIATELY do "
91
+ "NOT stop to ask whether to proceed. Call `todo` again to flip a step to "
92
+ "done and the next to in-progress as you finish each, and only stop when "
93
+ "every step is done. Don't write a TODO.md file for this."
91
94
  )
92
95
 
93
96
  # Short, imperative prompt. Small local models do better with "act now" than
@@ -106,8 +109,10 @@ _GEMMA_SYSTEM_PROMPT = (
106
109
  "check your work. One clear action per step. Stop when the task is done "
107
110
  "and give a one-line summary.\n"
108
111
  "If the task has several steps, FIRST call the `todo` tool with the plan "
109
- "(one task per line, '[ ]'/'[~]'/'[x]'), then call `todo` again to update "
110
- "it as you finish each step. Do not write a TODO.md file for this."
112
+ "(one task per line, '[ ]'/'[~]'/'[x]'), then IMMEDIATELY start doing the "
113
+ "steps do NOT stop to ask whether to proceed. Call `todo` again to update "
114
+ "it as you finish each step, and only stop when every step is done. Do not "
115
+ "write a TODO.md file for this."
111
116
  )
112
117
 
113
118
 
@@ -147,8 +152,6 @@ _HALLUCINATED_TOOLS: dict[str, str] = {
147
152
  "ralph_repo_index": "No such tool. Use Glob to list files and Grep to search them.",
148
153
  "read_mcp_resource": "No MCP resources here. Use Read for files, Grep/Glob to search.",
149
154
  "list_mcp_resources": "No MCP resources here. Use Glob to list files.",
150
- "todo": "No todo tool. Just do the work with the real tools.",
151
- "task": "No task tool. Do the work directly with Read/Write/Edit/Bash.",
152
155
  }
153
156
 
154
157
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.15
3
+ Version: 3.0.17
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
@@ -37,7 +37,10 @@ tests/test_guards_and_tools.py
37
37
  tests/test_hallucinated_tools.py
38
38
  tests/test_leaked_tool_call.py
39
39
  tests/test_loop_detect.py
40
+ tests/test_plan_autocontinue.py
40
41
  tests/test_providers_unreachable.py
42
+ tests/test_subagent.py
43
+ tests/test_todo.py
41
44
  tests/test_tools_undo.py
42
45
  tests/test_tui.py
43
46
  tests/test_tuning.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.15"
10
+ version = "3.0.17"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -8,12 +8,13 @@ from drydock.tuning import hallucinated_tool_message
8
8
 
9
9
 
10
10
  def test_known_hallucinated_names_get_redirect():
11
- for name in ["exit_plan_mode", "lsp", "read_mcp_resource", "todo", "ralph_repo_index"]:
11
+ for name in ["exit_plan_mode", "lsp", "read_mcp_resource", "ralph_repo_index"]:
12
12
  assert hallucinated_tool_message(name) is not None
13
13
 
14
14
 
15
15
  def test_real_tools_are_not_redirected():
16
- for name in ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]:
16
+ # `todo` and `task` are real tools now — they must NOT be redirected.
17
+ for name in ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "todo", "task"]:
17
18
  assert hallucinated_tool_message(name) is None
18
19
 
19
20
 
@@ -0,0 +1,80 @@
1
+ """The agent must not stall mid-plan: when the model lays out a `todo` and then
2
+ stops with text only, it should be nudged to keep going (capped + env-gated),
3
+ not return control to the user as if the work were done."""
4
+ from __future__ import annotations
5
+
6
+ import drydock.agent as agent_mod
7
+ from drydock.agent import AgentState, run
8
+ from drydock.providers import AssistantTurn
9
+
10
+
11
+ def _drive(turns, config):
12
+ """Run the loop over a fixed list of model turns; the todo state is set in
13
+ config to simulate a plan that already has unfinished steps."""
14
+ seq = list(turns)
15
+ monkey = lambda **kw: iter([seq.pop(0)])
16
+ return seq, monkey
17
+
18
+
19
+ def test_unfinished_plan_gets_continue_nudge(monkeypatch):
20
+ # Model stops with text only while a plan has unfinished steps → it must be
21
+ # nudged once; then it does the work (marks the todo done, which runs the
22
+ # real tool and clears the unfinished state) and wraps up without re-nudging.
23
+ seq = [
24
+ AssistantTurn("Here is my plan. Shall I proceed?", [], 1, 1),
25
+ AssistantTurn("", [{"id": "1", "name": "todo",
26
+ "input": {"tasks": "[x] step one\n[x] step two"}}], 1, 1),
27
+ AssistantTurn("all done", [], 1, 1),
28
+ ]
29
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
30
+ st = AgentState()
31
+ cfg = {"model": "m", "_todo": [("step one", "in_progress"), ("step two", "pending")]}
32
+ list(run("build it", st, cfg, "sys"))
33
+ nudges = [m for m in st.messages
34
+ if m["role"] == "user" and "unfinished steps" in m["content"]]
35
+ assert len(nudges) == 1
36
+
37
+
38
+ def test_finished_plan_does_not_nudge(monkeypatch):
39
+ # Every step done → no nudge, the turn ends normally.
40
+ seq = [AssistantTurn("finished everything", [], 1, 1)]
41
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
42
+ st = AgentState()
43
+ cfg = {"model": "m", "_todo": [("step one", "done"), ("step two", "done")]}
44
+ list(run("build it", st, cfg, "sys"))
45
+ assert not [m for m in st.messages
46
+ if m["role"] == "user" and "unfinished steps" in m["content"]]
47
+
48
+
49
+ def test_no_plan_does_not_nudge(monkeypatch):
50
+ # Plain chat with no todo → never nudged.
51
+ seq = [AssistantTurn("hi there", [], 1, 1)]
52
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
53
+ st = AgentState()
54
+ list(run("hello", st, {"model": "m"}, "sys"))
55
+ assert not [m for m in st.messages
56
+ if m["role"] == "user" and "unfinished steps" in m["content"]]
57
+
58
+
59
+ def test_consecutive_stalls_are_capped(monkeypatch):
60
+ # If the model keeps stalling without progress, nudges stop at the cap so
61
+ # the loop can never wedge.
62
+ seq = [AssistantTurn("still asking to proceed", [], 1, 1) for _ in range(10)]
63
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
64
+ st = AgentState()
65
+ cfg = {"model": "m", "_todo": [("step", "pending")]}
66
+ list(run("go", st, cfg, "sys"))
67
+ nudges = [m for m in st.messages
68
+ if m["role"] == "user" and "unfinished steps" in m["content"]]
69
+ assert len(nudges) == agent_mod.PLAN_CONTINUE_CAP
70
+
71
+
72
+ def test_env_disable_turns_off_autocontinue(monkeypatch):
73
+ monkeypatch.setenv("DRYDOCK_PLAN_AUTOCONTINUE_DISABLE", "1")
74
+ seq = [AssistantTurn("shall I proceed?", [], 1, 1)]
75
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
76
+ st = AgentState()
77
+ cfg = {"model": "m", "_todo": [("step", "pending")]}
78
+ list(run("go", st, cfg, "sys"))
79
+ assert not [m for m in st.messages
80
+ if m["role"] == "user" and "unfinished steps" in m["content"]]
@@ -0,0 +1,58 @@
1
+ """Tests for the read-only `task` sub-agent tool.
2
+
3
+ The sub-agent runs a fresh agent loop with a restricted toolset. These tests
4
+ drive it through a fake `stream` (no real model) to verify the contract:
5
+ * it returns the sub-agent's final text as the tool result;
6
+ * it is offered ONLY the read-only tools (no Write/Edit/todo/task);
7
+ * a call to a disallowed tool is REFUSED, never executed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import drydock.agent as agent_mod
12
+ from drydock.providers import AssistantTurn
13
+ from drydock.tools import tool_task, SUBAGENT_TOOLS
14
+
15
+
16
+ def test_task_returns_subagent_summary(monkeypatch):
17
+ # Sub-agent does one Grep, then summarizes.
18
+ seq = [
19
+ AssistantTurn("", [{"id": "1", "name": "Grep", "input": {"pattern": "x"}}], 1, 1),
20
+ AssistantTurn("auth lives in auth.py:42", [], 1, 1),
21
+ ]
22
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
23
+ out = tool_task({"prompt": "where is auth handled?"}, {"model": "m"})
24
+ assert out == "auth lives in auth.py:42"
25
+
26
+
27
+ def test_task_offers_only_readonly_tools(monkeypatch):
28
+ seen = {}
29
+
30
+ def fake_stream(model, system, messages, tool_schemas, config):
31
+ seen["names"] = {s["name"] for s in tool_schemas}
32
+ yield AssistantTurn("done", [], 1, 1)
33
+
34
+ monkeypatch.setattr(agent_mod, "stream", fake_stream)
35
+ tool_task({"prompt": "look around"}, {"model": "m"})
36
+ assert seen["names"] == set(SUBAGENT_TOOLS)
37
+ assert "Write" not in seen["names"] and "Edit" not in seen["names"]
38
+ assert "task" not in seen["names"] # no recursion
39
+
40
+
41
+ def test_task_refuses_disallowed_tool(monkeypatch, tmp_path):
42
+ # Even if the sub-agent model hallucinates an Edit, it must be REFUSED, not
43
+ # executed — the file stays untouched.
44
+ fp = tmp_path / "f.txt"
45
+ fp.write_text("original\n")
46
+ seq = [
47
+ AssistantTurn("", [{"id": "1", "name": "Edit", "input": {
48
+ "file_path": str(fp), "old_string": "original", "new_string": "HACKED"}}], 1, 1),
49
+ AssistantTurn("could not edit; here is what I found", [], 1, 1),
50
+ ]
51
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
52
+ out = tool_task({"prompt": "try to edit"}, {"model": "m", "cwd": str(tmp_path)})
53
+ assert fp.read_text() == "original\n" # untouched
54
+ assert "could not edit" in out
55
+
56
+
57
+ def test_task_requires_prompt():
58
+ assert "needs a `prompt`" in tool_task({}, {"model": "m"})
@@ -0,0 +1,38 @@
1
+ """Tests for the `todo` checklist tool and its forgiving parser."""
2
+ from __future__ import annotations
3
+
4
+ from drydock.tools import parse_todo, tool_todo
5
+
6
+
7
+ def test_parse_todo_basic_statuses():
8
+ items = parse_todo("[x] done one\n[~] doing two\n[ ] pending three")
9
+ assert items == [
10
+ ("done one", "done"),
11
+ ("doing two", "in_progress"),
12
+ ("pending three", "pending"),
13
+ ]
14
+
15
+
16
+ def test_parse_todo_accepts_list_input():
17
+ # The model sometimes sends a JSON array instead of one newline string —
18
+ # this must NOT crash (it took down the whole TUI before).
19
+ items = parse_todo([
20
+ "[~] Explore existing tests and run them",
21
+ "[ ] Add edge-case tests",
22
+ "[ ] Verify all tests pass",
23
+ ])
24
+ assert [s for _, s in items] == ["in_progress", "pending", "pending"]
25
+ assert items[0][0] == "Explore existing tests and run them"
26
+
27
+
28
+ def test_parse_todo_none_and_empty():
29
+ assert parse_todo(None) == []
30
+ assert parse_todo("") == []
31
+ assert parse_todo([]) == []
32
+
33
+
34
+ def test_tool_todo_accepts_list(tmp_path):
35
+ cfg: dict = {}
36
+ out = tool_todo({"tasks": ["[ ] a", "[x] b"]}, cfg)
37
+ assert "2 tasks" in out and "1 done" in out
38
+ assert len(cfg["_todo"]) == 2
File without changes
File without changes
File without changes
File without changes