drydock-cli 3.0.15__tar.gz → 3.0.16__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 (47) hide show
  1. {drydock_cli-3.0.15/drydock_cli.egg-info → drydock_cli-3.0.16}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/agent.py +56 -2
  4. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tools/__init__.py +78 -2
  5. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tuning.py +11 -8
  6. {drydock_cli-3.0.15 → drydock_cli-3.0.16/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/SOURCES.txt +2 -0
  8. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/pyproject.toml +1 -1
  9. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_hallucinated_tools.py +3 -2
  10. drydock_cli-3.0.16/tests/test_plan_autocontinue.py +80 -0
  11. drydock_cli-3.0.16/tests/test_subagent.py +58 -0
  12. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/LICENSE +0 -0
  13. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/NOTICE +0 -0
  14. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/README.md +0 -0
  15. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/cli.py +0 -0
  18. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/compaction.py +0 -0
  19. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/config.py +0 -0
  20. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/detect.py +0 -0
  21. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/guards.py +0 -0
  22. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/loop_detect.py +0 -0
  23. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/providers.py +0 -0
  24. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tool_registry.py +0 -0
  25. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/__init__.py +0 -0
  26. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/app.py +0 -0
  27. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/approval.py +0 -0
  28. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/messages.py +0 -0
  29. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/widgets.py +0 -0
  30. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/dependency_links.txt +0 -0
  31. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/entry_points.txt +0 -0
  32. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/requires.txt +0 -0
  33. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/top_level.txt +0 -0
  34. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/setup.cfg +0 -0
  35. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_approval.py +0 -0
  36. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_back_command.py +0 -0
  37. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_bash_safety.py +0 -0
  38. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_compaction.py +0 -0
  39. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_config.py +0 -0
  40. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_detect.py +0 -0
  41. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_guards_and_tools.py +0 -0
  42. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_leaked_tool_call.py +0 -0
  43. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_loop_detect.py +0 -0
  44. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_providers_unreachable.py +0 -0
  45. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_tools_undo.py +0 -0
  46. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_tui.py +0 -0
  47. {drydock_cli-3.0.15 → drydock_cli-3.0.16}/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.16
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.16"
@@ -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 ──────────────────────────────────────────────────
@@ -504,6 +529,55 @@ def tool_todo(params: dict, config: dict) -> str:
504
529
  return f"Plan updated: {len(items)} tasks ({done} done, {doing} in progress)."
505
530
 
506
531
 
532
+ # ── Read-only sub-agent (the `task` tool) ─────────────────────────────────
533
+
534
+ # Tools a sub-agent may use. Excludes Write/Edit/todo (no silent mutations the
535
+ # parent can't see) and `task` itself (no recursion). Bash is included for real
536
+ # exploration (ls/cat/grep/find/git log) and is bounded by the same bash_safety.
537
+ SUBAGENT_TOOLS = ("Read", "Glob", "Grep", "Bash")
538
+
539
+ _SUBAGENT_SYSTEM = (
540
+ "You are a focused exploration sub-agent inside Drydock. You have read-only "
541
+ "tools: Read, Glob, Grep, and Bash (use Bash only to INSPECT — ls, cat, "
542
+ "grep, find, git log — never to modify files). Investigate the task you are "
543
+ "given, then STOP and reply with a concise, factual summary of what you "
544
+ "found: concrete file:line references and the key code, not narration. Do "
545
+ "NOT try to edit or create files — the main agent acts on your findings."
546
+ )
547
+
548
+
549
+ def tool_task(params: dict, config: dict) -> str:
550
+ """Spawn a read-only sub-agent with a FRESH context for a focused
551
+ exploration task, returning only its final summary. Keeps big searches out
552
+ of the main agent's context. Cannot recurse (no `task` tool) and cannot
553
+ write (no Write/Edit), so it can never corrupt the parent's work."""
554
+ prompt = (params.get("prompt") or params.get("description") or "").strip()
555
+ if not prompt:
556
+ return "Error: `task` needs a `prompt` describing what to investigate."
557
+ # Lazy import to avoid a tools <-> agent import cycle.
558
+ from drydock.agent import run as agent_run, AgentState, TurnDone
559
+
560
+ sub_state = AgentState()
561
+ sub_config = dict(config)
562
+ sub_config["tool_allowlist"] = list(SUBAGENT_TOOLS)
563
+ sub_config["force_first_tool"] = False
564
+ sub_config["max_turns"] = 24 # bound the helper hard
565
+ sub_config["max_tool_calls"] = 20
566
+ sub_config.pop("_todo", None) # the sub-agent keeps no checklist of its own
567
+ sub_config.pop("_plan_autocontinue", None)
568
+ steps = 0
569
+ try:
570
+ for ev in agent_run(prompt, sub_state, sub_config, _SUBAGENT_SYSTEM):
571
+ if isinstance(ev, TurnDone):
572
+ steps += 1
573
+ except Exception as e: # a sub-agent must never crash the parent turn
574
+ return f"[sub-agent error: {e}]"
575
+ for msg in reversed(sub_state.messages):
576
+ if msg.get("role") == "assistant" and (msg.get("content") or "").strip():
577
+ return msg["content"].strip()
578
+ return f"[sub-agent finished {steps} step(s) with no summary]"
579
+
580
+
507
581
  # ── Register all tools ────────────────────────────────────────────────────
508
582
 
509
583
  _TOOLS = [
@@ -514,6 +588,7 @@ _TOOLS = [
514
588
  ("Glob", tool_glob, True),
515
589
  ("Grep", tool_grep, True),
516
590
  ("todo", tool_todo, False),
591
+ ("task", tool_task, True),
517
592
  ]
518
593
 
519
594
  def register_all():
@@ -522,9 +597,10 @@ def register_all():
522
597
  func = {
523
598
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
524
599
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
525
- "todo": tool_todo,
600
+ "todo": tool_todo, "task": tool_task,
526
601
  }[name]
527
- read_only = name in ("Read", "Glob", "Grep")
602
+ # `task` is read-only w.r.t. the parent's files (it can't Write/Edit).
603
+ read_only = name in ("Read", "Glob", "Grep", "task")
528
604
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
529
605
 
530
606
  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.16
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,9 @@ 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
41
43
  tests/test_tools_undo.py
42
44
  tests/test_tui.py
43
45
  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.16"
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"})
File without changes
File without changes
File without changes
File without changes