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.
- {drydock_cli-3.0.15/drydock_cli.egg-info → drydock_cli-3.0.16}/PKG-INFO +1 -1
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/agent.py +56 -2
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tools/__init__.py +78 -2
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tuning.py +11 -8
- {drydock_cli-3.0.15 → drydock_cli-3.0.16/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/pyproject.toml +1 -1
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_hallucinated_tools.py +3 -2
- drydock_cli-3.0.16/tests/test_plan_autocontinue.py +80 -0
- drydock_cli-3.0.16/tests/test_subagent.py +58 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/LICENSE +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/NOTICE +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/README.md +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/cli.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/config.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/detect.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/guards.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/providers.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/setup.cfg +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_config.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.15 → drydock_cli-3.0.16}/tests/test_tuning.py +0 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
"
|
|
90
|
-
"
|
|
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
|
|
110
|
-
"
|
|
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
|
|
|
@@ -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.
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|