drydock-cli 3.0.17__tar.gz → 3.0.19__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.17/drydock_cli.egg-info → drydock_cli-3.0.19}/PKG-INFO +1 -1
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/agent.py +14 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/cli.py +6 -25
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/compaction.py +50 -7
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/app.py +36 -4
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/widgets.py +11 -1
- {drydock_cli-3.0.17 → drydock_cli-3.0.19/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/pyproject.toml +1 -1
- drydock_cli-3.0.19/tests/test_cli_agents.py +34 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_compaction.py +34 -0
- drydock_cli-3.0.19/tests/test_stop.py +64 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tui.py +7 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/LICENSE +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/NOTICE +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/README.md +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/config.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/detect.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/guards.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/providers.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/setup.cfg +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_config.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.17 → drydock_cli-3.0.19}/tests/test_tuning.py +0 -0
|
@@ -103,6 +103,13 @@ def run(
|
|
|
103
103
|
# a call to anything else is refused — never executed. Keeps a read-only
|
|
104
104
|
# sub-agent read-only and stops it from recursing into `task`.
|
|
105
105
|
allow = config.get("tool_allowlist")
|
|
106
|
+
# User STOP signal (a threading.Event the TUI sets on Escape / "/stop").
|
|
107
|
+
# Checked only at SAFE points — top of the loop and after a turn's tool
|
|
108
|
+
# results are all appended — so a stop never leaves an assistant tool_call
|
|
109
|
+
# without its matching tool result (which would corrupt the history).
|
|
110
|
+
cancel = config.get("_cancel")
|
|
111
|
+
def _stopped() -> bool:
|
|
112
|
+
return cancel is not None and cancel.is_set()
|
|
106
113
|
tool_call_count = 0
|
|
107
114
|
session_has_edited = False
|
|
108
115
|
leaked_call_retries = 0
|
|
@@ -111,6 +118,8 @@ def run(
|
|
|
111
118
|
loop_tracker = LoopTracker()
|
|
112
119
|
|
|
113
120
|
while state.turn_count < max_turns:
|
|
121
|
+
if _stopped():
|
|
122
|
+
break
|
|
114
123
|
state.turn_count += 1
|
|
115
124
|
run_iteration += 1
|
|
116
125
|
assistant_turn: AssistantTurn | None = None
|
|
@@ -264,6 +273,11 @@ def run(
|
|
|
264
273
|
"content": result,
|
|
265
274
|
})
|
|
266
275
|
|
|
276
|
+
# Safe point (all tool results appended): honor a STOP requested while
|
|
277
|
+
# this turn's tools were running, before spending another LLM call.
|
|
278
|
+
if _stopped():
|
|
279
|
+
break
|
|
280
|
+
|
|
267
281
|
# Real progress this turn — reset the consecutive stall-nudge counter so
|
|
268
282
|
# a long, productive plan can run as far as it needs (the cap only
|
|
269
283
|
# bounds back-to-back stalls).
|
|
@@ -59,27 +59,6 @@ def load_project_instructions() -> str:
|
|
|
59
59
|
return ""
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def ensure_agents_md() -> None:
|
|
63
|
-
"""Auto-create AGENTS.md if no project instructions exist."""
|
|
64
|
-
for name in ("AGENTS.md", "DRYDOCK.md", ".drydock.md", "CLAUDE.md"):
|
|
65
|
-
if (Path.cwd() / name).exists():
|
|
66
|
-
return
|
|
67
|
-
(Path.cwd() / "AGENTS.md").write_text(
|
|
68
|
-
"# Project Instructions\n\n"
|
|
69
|
-
"DO NOT ask for confirmation. ACT IMMEDIATELY.\n"
|
|
70
|
-
"If there is a PRD.md, implement it. If there is code, work on it.\n\n"
|
|
71
|
-
"## Workflow\n"
|
|
72
|
-
"1. Read requirements or explore existing code\n"
|
|
73
|
-
"2. Create/edit files with Write or Edit\n"
|
|
74
|
-
"3. Test with Bash\n"
|
|
75
|
-
"4. Fix errors and verify\n\n"
|
|
76
|
-
"## Rules\n"
|
|
77
|
-
"- Use absolute imports for Python packages\n"
|
|
78
|
-
"- Always create __init__.py and __main__.py\n"
|
|
79
|
-
"- Test with python3 -m package_name\n"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
62
|
# ── REPL ──────────────────────────────────────────────────────────────────
|
|
84
63
|
|
|
85
64
|
def print_colored(text: str, color: str = "") -> None:
|
|
@@ -245,10 +224,12 @@ def main():
|
|
|
245
224
|
"onboarding": onboarding,
|
|
246
225
|
}
|
|
247
226
|
|
|
248
|
-
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
227
|
+
# Load a user's OWN AGENTS.md/DRYDOCK.md as background context if present.
|
|
228
|
+
# Drydock never CREATES one: auto-writing an AGENTS.md littered every
|
|
229
|
+
# directory the user ran drydock in, and its "ACT IMMEDIATELY, implement
|
|
230
|
+
# the PRD" content fought the system prompt and risked turning a plain
|
|
231
|
+
# greeting into a runaway build. The TUI reads this from config; CLI modes
|
|
232
|
+
# call load_project_instructions directly (see run_interactive/run_oneshot).
|
|
252
233
|
config["project_instructions"] = load_project_instructions()
|
|
253
234
|
|
|
254
235
|
if args.prompt:
|
|
@@ -35,18 +35,52 @@ def is_context_length_error(err: str) -> bool:
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def estimate_tokens(messages: list) -> int:
|
|
38
|
-
"""Rough token estimate: chars / 3.5
|
|
38
|
+
"""Rough token estimate: chars / 3.5.
|
|
39
|
+
|
|
40
|
+
CRUCIAL: a tool call's arguments live in a nested ``input`` dict — e.g. a
|
|
41
|
+
full-file Write puts the whole file in tool_calls[].input.content. Counting
|
|
42
|
+
only top-level strings missed that entirely, so a 40k-char Write read as ~0
|
|
43
|
+
tokens, compaction never fired, and the real request blew past the context
|
|
44
|
+
window. Count nested strings so the estimate reflects what's actually sent.
|
|
45
|
+
"""
|
|
39
46
|
total = 0
|
|
40
47
|
for m in messages:
|
|
41
48
|
content = m.get("content", "")
|
|
42
49
|
if isinstance(content, str):
|
|
43
50
|
total += len(content)
|
|
44
|
-
for tc in m.get("tool_calls", []):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
for tc in m.get("tool_calls", []) or []:
|
|
52
|
+
total += _count_chars(tc)
|
|
53
|
+
# /3.0, not /3.5: code is symbol-dense and tokenizes to MORE tokens than
|
|
54
|
+
# prose, so a conservative (slightly high) estimate keeps us off the wall.
|
|
55
|
+
return int(total / 3.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _count_chars(v) -> int:
|
|
59
|
+
"""Total length of every string nested in v (str / dict / list)."""
|
|
60
|
+
if isinstance(v, str):
|
|
61
|
+
return len(v)
|
|
62
|
+
if isinstance(v, dict):
|
|
63
|
+
return sum(_count_chars(x) for x in v.values())
|
|
64
|
+
if isinstance(v, (list, tuple)):
|
|
65
|
+
return sum(_count_chars(x) for x in v)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _truncate_tool_call_args(messages: list, max_len: int) -> None:
|
|
70
|
+
"""Shrink oversized string arguments inside OLD assistant tool calls (a
|
|
71
|
+
full-file Write/Edit is a top bloat source and nothing else truncates it).
|
|
72
|
+
The most recent tool-call message is left intact; truncating only the
|
|
73
|
+
argument *values* keeps each call's id, so tool-result pairing stays valid."""
|
|
74
|
+
last = max((i for i, m in enumerate(messages) if m.get("tool_calls")), default=-1)
|
|
75
|
+
for i, m in enumerate(messages):
|
|
76
|
+
if i == last:
|
|
77
|
+
continue
|
|
78
|
+
for tc in m.get("tool_calls") or []:
|
|
79
|
+
inp = tc.get("input") if isinstance(tc, dict) else None
|
|
80
|
+
if isinstance(inp, dict):
|
|
81
|
+
for k, v in inp.items():
|
|
82
|
+
if isinstance(v, str) and len(v) > max_len:
|
|
83
|
+
inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
|
|
50
84
|
|
|
51
85
|
|
|
52
86
|
def compact(messages: list, context_limit: int = 131072) -> list:
|
|
@@ -92,6 +126,10 @@ def compact(messages: list, context_limit: int = 131072) -> list:
|
|
|
92
126
|
if current <= target:
|
|
93
127
|
break
|
|
94
128
|
|
|
129
|
+
# Pass 3: shrink big old tool-call arguments (full-file Write/Edit bodies).
|
|
130
|
+
if estimate_tokens(messages) > target:
|
|
131
|
+
_truncate_tool_call_args(messages, max_len=1500)
|
|
132
|
+
|
|
95
133
|
return messages
|
|
96
134
|
|
|
97
135
|
|
|
@@ -135,6 +173,11 @@ def emergency_compact(messages: list, context_limit: int = 131072) -> list:
|
|
|
135
173
|
if isinstance(content, str) and len(content) > 500:
|
|
136
174
|
messages[i]["content"] = content[:300] + "\n[... truncated ...]"
|
|
137
175
|
|
|
176
|
+
# Pass 4: shrink big tool-CALL arguments — a full-file Write/Edit puts the
|
|
177
|
+
# whole file in tool_calls[].input and nothing above touches it. This was
|
|
178
|
+
# THE leak that let the real request stay over the window after compaction.
|
|
179
|
+
_truncate_tool_call_args(messages, max_len=300)
|
|
180
|
+
|
|
138
181
|
return messages
|
|
139
182
|
|
|
140
183
|
|
|
@@ -8,6 +8,7 @@ messages (see tui/messages.py). Nautical theme, original branding.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import random
|
|
11
|
+
import threading
|
|
11
12
|
import time
|
|
12
13
|
|
|
13
14
|
from textual import work
|
|
@@ -119,6 +120,10 @@ class DrydockApp(App):
|
|
|
119
120
|
BINDINGS = [
|
|
120
121
|
Binding("ctrl+c", "copy_selection", "Copy", priority=True),
|
|
121
122
|
Binding("ctrl+d", "quit", "Quit", priority=True),
|
|
123
|
+
# Escape stops a running turn (the agent ends cleanly, session kept) —
|
|
124
|
+
# the way out of a runaway task without quitting the whole app. "/stop"
|
|
125
|
+
# does the same. priority so it isn't swallowed by the focused prompt.
|
|
126
|
+
Binding("escape", "stop", "Stop the running turn", priority=True),
|
|
122
127
|
Binding("ctrl+o", "toggle_tools", "Expand/collapse details"),
|
|
123
128
|
# Scroll the transcript from the keyboard (focus stays on the prompt,
|
|
124
129
|
# and SSH sessions often don't forward the mouse wheel). priority=True
|
|
@@ -129,6 +134,21 @@ class DrydockApp(App):
|
|
|
129
134
|
Binding("ctrl+end", "scroll_bottom", "Bottom", priority=True, show=False),
|
|
130
135
|
]
|
|
131
136
|
|
|
137
|
+
def action_stop(self) -> None:
|
|
138
|
+
"""STOP the running turn: signal the agent loop to end at its next safe
|
|
139
|
+
point, drop any queued prompts, and hand control back. The session
|
|
140
|
+
(history, files) is preserved — this is not a quit."""
|
|
141
|
+
if not self._busy:
|
|
142
|
+
return
|
|
143
|
+
self._cancel.set()
|
|
144
|
+
dropped = len(self._queue)
|
|
145
|
+
self._queue.clear()
|
|
146
|
+
note = "⏹ stopping after the current step…"
|
|
147
|
+
if dropped:
|
|
148
|
+
note += f" (discarded {dropped} queued)"
|
|
149
|
+
self._info(note)
|
|
150
|
+
self._refresh_status()
|
|
151
|
+
|
|
132
152
|
def action_scroll_up(self) -> None:
|
|
133
153
|
self._scroll.scroll_page_up()
|
|
134
154
|
|
|
@@ -185,6 +205,11 @@ class DrydockApp(App):
|
|
|
185
205
|
self._current_assistant: AssistantMessage | None = None
|
|
186
206
|
self._last_card: ToolCard | None = None
|
|
187
207
|
self._busy = False
|
|
208
|
+
# STOP signal: Escape / "/stop" sets it; the agent loop checks it at safe
|
|
209
|
+
# points and ends the turn cleanly (session preserved). Lives in config
|
|
210
|
+
# so run() — and any sub-agent that copies config — can see it.
|
|
211
|
+
self._cancel = threading.Event()
|
|
212
|
+
self.config["_cancel"] = self._cancel
|
|
188
213
|
self._queue: list[str] = [] # prompts submitted while a turn is running
|
|
189
214
|
self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
|
|
190
215
|
self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
|
|
@@ -210,9 +235,9 @@ class DrydockApp(App):
|
|
|
210
235
|
return "deny"
|
|
211
236
|
|
|
212
237
|
def _build_system(self, model: str | None) -> str:
|
|
213
|
-
# The TUI
|
|
214
|
-
#
|
|
215
|
-
#
|
|
238
|
+
# The TUI honors a user's OWN AGENTS.md/DRYDOCK.md like the CLI does —
|
|
239
|
+
# cli.main() loads it into config["project_instructions"] as background
|
|
240
|
+
# context (drydock never auto-creates one).
|
|
216
241
|
return system_prompt_for_model(model) + self.config.get("project_instructions", "")
|
|
217
242
|
|
|
218
243
|
# ── layout ────────────────────────────────────────────────────────────
|
|
@@ -318,6 +343,7 @@ class DrydockApp(App):
|
|
|
318
343
|
def _begin(self, text: str) -> None:
|
|
319
344
|
"""Start an agent turn for an already-displayed user prompt."""
|
|
320
345
|
self._current_assistant = None
|
|
346
|
+
self._cancel.clear() # fresh turn — clear any prior STOP
|
|
321
347
|
self._busy = True
|
|
322
348
|
self._work_start = time.monotonic()
|
|
323
349
|
self._work_word = random.choice(_WORKING_WORDS)
|
|
@@ -336,6 +362,11 @@ class DrydockApp(App):
|
|
|
336
362
|
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
337
363
|
if cmd in ("/quit", "/exit", "/q"):
|
|
338
364
|
self.exit()
|
|
365
|
+
elif cmd in ("/stop", "/cancel"):
|
|
366
|
+
if self._busy:
|
|
367
|
+
self.action_stop()
|
|
368
|
+
else:
|
|
369
|
+
self._info("Nothing is running.")
|
|
339
370
|
elif cmd == "/clear":
|
|
340
371
|
self.state = AgentState()
|
|
341
372
|
self._scroll.remove_children()
|
|
@@ -377,10 +408,11 @@ class DrydockApp(App):
|
|
|
377
408
|
" /cwd [path] show or change the working directory\n"
|
|
378
409
|
" /undo revert the last file write/edit\n"
|
|
379
410
|
" /back rewind the last turn from the model's context\n"
|
|
411
|
+
" /stop stop the running turn (or press Esc)\n"
|
|
380
412
|
" /status session model, cwd, turns, tokens\n"
|
|
381
413
|
" /clear reset the conversation\n"
|
|
382
414
|
" /quit exit\n"
|
|
383
|
-
"Type a task and press Enter. ↑/↓ recall history · Ctrl+O expands tools."
|
|
415
|
+
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
384
416
|
)
|
|
385
417
|
else:
|
|
386
418
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
@@ -16,12 +16,22 @@ _FAILURE_PREFIXES = ("Error", "REFUSED")
|
|
|
16
16
|
# "[NOTE: …]" lines (see loop_detect.annotate). Strip them before judging
|
|
17
17
|
# success, or a failed Edit behind a "[NOTE: write #9…]" reads as ✓.
|
|
18
18
|
_LEADING_NOTES = re.compile(r"^\s*(?:\[NOTE:[^\]]*\]\s*)+")
|
|
19
|
+
# Bash appends "[exit code: N]" only when a command FAILED (non-zero return).
|
|
20
|
+
# Without this, a failed shell command (e.g. `command not found`, a compile
|
|
21
|
+
# error, a failing test) renders as a green ✓ — which hides exactly the kind
|
|
22
|
+
# of failure the operator relies on the TUI to surface.
|
|
23
|
+
_BASH_EXIT = re.compile(r"\[exit code: (\d+)\]")
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
def result_is_ok(result: str) -> bool:
|
|
22
27
|
"""Whether a tool result should render as success (✓) vs failure (✗)."""
|
|
23
28
|
cleaned = _LEADING_NOTES.sub("", result or "")
|
|
24
|
-
|
|
29
|
+
if cleaned.lstrip().startswith(_FAILURE_PREFIXES):
|
|
30
|
+
return False
|
|
31
|
+
m = _BASH_EXIT.search(cleaned)
|
|
32
|
+
if m and m.group(1) != "0":
|
|
33
|
+
return False
|
|
34
|
+
return True
|
|
25
35
|
|
|
26
36
|
|
|
27
37
|
_TOOL_BODY_MAX = 8000
|
|
@@ -30,6 +30,7 @@ drydock_cli.egg-info/top_level.txt
|
|
|
30
30
|
tests/test_approval.py
|
|
31
31
|
tests/test_back_command.py
|
|
32
32
|
tests/test_bash_safety.py
|
|
33
|
+
tests/test_cli_agents.py
|
|
33
34
|
tests/test_compaction.py
|
|
34
35
|
tests/test_config.py
|
|
35
36
|
tests/test_detect.py
|
|
@@ -39,6 +40,7 @@ tests/test_leaked_tool_call.py
|
|
|
39
40
|
tests/test_loop_detect.py
|
|
40
41
|
tests/test_plan_autocontinue.py
|
|
41
42
|
tests/test_providers_unreachable.py
|
|
43
|
+
tests/test_stop.py
|
|
42
44
|
tests/test_subagent.py
|
|
43
45
|
tests/test_todo.py
|
|
44
46
|
tests/test_tools_undo.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.19"
|
|
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"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Drydock must LOAD a user's own AGENTS.md/DRYDOCK.md as background context,
|
|
2
|
+
but must NEVER auto-create one (it littered every working directory and its
|
|
3
|
+
'ACT IMMEDIATELY, implement the PRD' content fought the system prompt)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from drydock import cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _in_dir(tmp_path, fn):
|
|
12
|
+
cwd = os.getcwd()
|
|
13
|
+
os.chdir(tmp_path)
|
|
14
|
+
try:
|
|
15
|
+
return fn()
|
|
16
|
+
finally:
|
|
17
|
+
os.chdir(cwd)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_no_agents_md_is_created(tmp_path):
|
|
21
|
+
# An empty project must stay empty after loading instructions.
|
|
22
|
+
text = _in_dir(tmp_path, cli.load_project_instructions)
|
|
23
|
+
assert text == ""
|
|
24
|
+
assert list(tmp_path.iterdir()) == [] # nothing written
|
|
25
|
+
assert not hasattr(cli, "ensure_agents_md") # the auto-creator is gone
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_existing_agents_md_loaded_as_background(tmp_path):
|
|
29
|
+
(tmp_path / "AGENTS.md").write_text("Use 4-space indent. Prefer pathlib.")
|
|
30
|
+
text = _in_dir(tmp_path, cli.load_project_instructions)
|
|
31
|
+
assert "Use 4-space indent" in text
|
|
32
|
+
# Framed as background context, NOT a command to act now.
|
|
33
|
+
assert "background context" in text.lower()
|
|
34
|
+
assert "not a" in text.lower() and "act now" in text.lower()
|
|
@@ -15,6 +15,40 @@ from drydock.compaction import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def test_estimate_counts_tool_call_input_args():
|
|
19
|
+
# A full-file Write hides its bulk in tool_calls[].input.content. The
|
|
20
|
+
# estimate MUST see it (this was the bug: it read as ~0 tokens).
|
|
21
|
+
big = "x" * 40000
|
|
22
|
+
msgs = [{"role": "assistant", "content": "",
|
|
23
|
+
"tool_calls": [{"id": "1", "name": "Write",
|
|
24
|
+
"input": {"file_path": "a.py", "content": big}}]}]
|
|
25
|
+
assert estimate_tokens(msgs) > 10000 # ~40000/3.0, not ~0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_emergency_compact_shrinks_big_write_arg_below_limit():
|
|
29
|
+
# Reproduce the operator's crash shape: history dominated by an OLD
|
|
30
|
+
# full-file Write arg. Emergency compaction must get the estimate under the
|
|
31
|
+
# real 64k window (it returned the SAME oversized request before).
|
|
32
|
+
limit = 65536
|
|
33
|
+
big = "y" * 250000 # ~83k est tokens — over the window
|
|
34
|
+
msgs = [
|
|
35
|
+
{"role": "user", "content": "refactor cli.py"},
|
|
36
|
+
{"role": "assistant", "content": "",
|
|
37
|
+
"tool_calls": [{"id": "1", "name": "Write",
|
|
38
|
+
"input": {"file_path": "cli.py", "content": big}}]},
|
|
39
|
+
{"role": "tool", "tool_call_id": "1", "name": "Write", "content": "ok"},
|
|
40
|
+
{"role": "user", "content": "now run tests"},
|
|
41
|
+
{"role": "assistant", "content": "",
|
|
42
|
+
"tool_calls": [{"id": "2", "name": "Bash", "input": {"command": "pytest"}}]},
|
|
43
|
+
{"role": "tool", "tool_call_id": "2", "name": "Bash", "content": "passed"},
|
|
44
|
+
]
|
|
45
|
+
assert estimate_tokens(msgs) > limit
|
|
46
|
+
emergency_compact(msgs, limit)
|
|
47
|
+
assert estimate_tokens(msgs) < limit # actually under the window now
|
|
48
|
+
# The recent Bash tool_call (last) is preserved intact.
|
|
49
|
+
assert msgs[4]["tool_calls"][0]["input"]["command"] == "pytest"
|
|
50
|
+
|
|
51
|
+
|
|
18
52
|
def test_is_context_length_error_matches_provider_phrasings():
|
|
19
53
|
for msg in [
|
|
20
54
|
"This model's maximum context length is 131072 tokens",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""STOP/interrupt: a threading.Event in config["_cancel"] lets the TUI halt a
|
|
2
|
+
running turn at a safe point. The agent must (a) not start a turn when stop is
|
|
3
|
+
already set, and (b) end after the current turn's tools rather than looping —
|
|
4
|
+
without leaving an assistant tool_call missing its tool result."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
import drydock.agent as agent_mod
|
|
10
|
+
from drydock.agent import AgentState, run
|
|
11
|
+
from drydock.providers import AssistantTurn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_no_turn_when_already_stopped(monkeypatch):
|
|
15
|
+
calls = {"n": 0}
|
|
16
|
+
|
|
17
|
+
def fake_stream(**kw):
|
|
18
|
+
calls["n"] += 1
|
|
19
|
+
yield AssistantTurn("hi", [], 1, 1)
|
|
20
|
+
|
|
21
|
+
monkeypatch.setattr(agent_mod, "stream", fake_stream)
|
|
22
|
+
ev = threading.Event()
|
|
23
|
+
ev.set()
|
|
24
|
+
st = AgentState()
|
|
25
|
+
list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
|
|
26
|
+
assert calls["n"] == 0 # loop broke before any model call
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_stop_ends_after_current_turn(monkeypatch):
|
|
30
|
+
# The model would loop forever (always returns a tool call); a stop set
|
|
31
|
+
# during the first turn must end the run after that turn's tools.
|
|
32
|
+
calls = {"n": 0}
|
|
33
|
+
ev = threading.Event()
|
|
34
|
+
|
|
35
|
+
def fake_stream(**kw):
|
|
36
|
+
calls["n"] += 1
|
|
37
|
+
ev.set() # user hits STOP while this turn runs
|
|
38
|
+
yield AssistantTurn(
|
|
39
|
+
"", [{"id": str(calls["n"]), "name": "Read", "input": {"file_path": "nope"}}], 1, 1
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(agent_mod, "stream", fake_stream)
|
|
43
|
+
st = AgentState()
|
|
44
|
+
list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
|
|
45
|
+
assert calls["n"] == 1 # exactly one turn, then stopped
|
|
46
|
+
|
|
47
|
+
# History is still valid: every assistant tool_call has a matching tool msg.
|
|
48
|
+
call_ids = [
|
|
49
|
+
tc["id"]
|
|
50
|
+
for msg in st.messages
|
|
51
|
+
if msg.get("role") == "assistant"
|
|
52
|
+
for tc in (msg.get("tool_calls") or [])
|
|
53
|
+
]
|
|
54
|
+
tool_ids = [m["tool_call_id"] for m in st.messages if m["role"] == "tool"]
|
|
55
|
+
assert sorted(call_ids) == sorted(tool_ids)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_no_cancel_event_runs_normally(monkeypatch):
|
|
59
|
+
# Absent a _cancel event, behaviour is unchanged.
|
|
60
|
+
seq = [AssistantTurn("done", [], 1, 1)]
|
|
61
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
62
|
+
st = AgentState()
|
|
63
|
+
list(run("go", st, {"model": "m"}, "sys"))
|
|
64
|
+
assert any(m.get("role") == "assistant" for m in st.messages)
|
|
@@ -123,6 +123,13 @@ def test_result_is_ok_marks_failures():
|
|
|
123
123
|
assert not result_is_ok("Error: nope")
|
|
124
124
|
assert not result_is_ok("REFUSED: this command reformats a filesystem...")
|
|
125
125
|
assert not result_is_ok(" Error: leading whitespace still counts")
|
|
126
|
+
# A failed shell command (Bash appends "[exit code: N]" only on failure)
|
|
127
|
+
# must render ✗, not a green ✓ — otherwise the TUI hides real failures.
|
|
128
|
+
assert not result_is_ok("bash: sqlite3: command not found\n[exit code: 127]")
|
|
129
|
+
assert not result_is_ok("test.c:1: error\n[exit code: 1]")
|
|
130
|
+
# Successful command output (no exit-code marker) stays ✓, even if it
|
|
131
|
+
# happens to mention the word 'error' in normal output.
|
|
132
|
+
assert result_is_ok("0 errors, 0 warnings\nBuild succeeded")
|
|
126
133
|
|
|
127
134
|
|
|
128
135
|
def test_summarize_inputs_prefers_meaningful_keys():
|
|
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
|