drydock-cli 3.0.113__tar.gz → 3.0.115__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.113/drydock_cli.egg-info → drydock_cli-3.0.115}/PKG-INFO +1 -1
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/agent.py +14 -10
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/providers.py +28 -4
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tools/__init__.py +87 -1
- {drydock_cli-3.0.113 → drydock_cli-3.0.115/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/pyproject.toml +1 -1
- drydock_cli-3.0.115/tests/test_repetition_interrupt.py +68 -0
- drydock_cli-3.0.115/tests/test_screenshot.py +59 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/LICENSE +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/NOTICE +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/README.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/cci.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/cli.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/config.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/detect.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/extract.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/guards.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/poam.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/skills.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/stig.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/web.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/setup.cfg +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_config.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_xccdf.py +0 -0
|
@@ -33,7 +33,7 @@ def _plan_has_unfinished(config: dict) -> bool:
|
|
|
33
33
|
todo = config.get("_todo")
|
|
34
34
|
return bool(todo) and any(status != "done" for _, status in todo)
|
|
35
35
|
|
|
36
|
-
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry
|
|
36
|
+
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry, RepetitionDetected
|
|
37
37
|
from drydock.tool_registry import schemas, execute
|
|
38
38
|
from drydock.tools import register_all
|
|
39
39
|
|
|
@@ -194,24 +194,28 @@ def run(
|
|
|
194
194
|
elif isinstance(event, AssistantTurn):
|
|
195
195
|
assistant_turn = event
|
|
196
196
|
break # success
|
|
197
|
-
except StallRetry:
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
197
|
+
except StallRetry as _sr:
|
|
198
|
+
# Stalled/over-thought past stall_retry_secs (wall-time), OR collapsed
|
|
199
|
+
# into a pure-repetition loop (RepetitionDetected, content-based). A
|
|
200
|
+
# first wall-time retry re-issues as-is (a transient hang clears); a
|
|
201
|
+
# persistent stall OR any repetition loop goes straight to decisive
|
|
202
|
+
# mode — a forcing suffix + hard token cap that make a long reasoning
|
|
203
|
+
# turn impossible. Repetition escalates immediately (re-issuing a loop
|
|
204
|
+
# just loops again). Bounded; on exhaustion end cleanly.
|
|
205
|
+
is_rep = isinstance(_sr, RepetitionDetected)
|
|
203
206
|
stall_retries += 1
|
|
204
207
|
if stall_retries > 3:
|
|
205
|
-
yield TextChunk("\n[model kept stalling/
|
|
208
|
+
yield TextChunk("\n[model kept stalling/looping — giving up on this step.]\n")
|
|
206
209
|
assistant_turn = None
|
|
207
210
|
break
|
|
208
|
-
if stall_retries >= 2 and not decisive:
|
|
211
|
+
if (is_rep or stall_retries >= 2) and not decisive:
|
|
209
212
|
decisive = True
|
|
210
213
|
turn_config = dict(turn_config)
|
|
211
214
|
cur = int(turn_config.get("max_tokens", 8192) or 8192)
|
|
212
215
|
turn_config["max_tokens"] = min(cur, _DECISIVE_MAX_TOKENS)
|
|
213
216
|
turn_config["reasoning_effort"] = "low"
|
|
214
|
-
|
|
217
|
+
why = "output looping" if is_rep else "taking too long"
|
|
218
|
+
yield TextChunk(f"\n[{why} — forcing a decisive, single-action step...]\n")
|
|
215
219
|
else:
|
|
216
220
|
yield TextChunk("\n[model server stalled — retrying...]\n")
|
|
217
221
|
continue
|
|
@@ -30,6 +30,14 @@ class StallRetry(Exception):
|
|
|
30
30
|
abandons the wedged request and re-issues it; a fresh generation usually
|
|
31
31
|
doesn't stall. Opt-in via config `stall_retry_secs` (0 = off)."""
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
class RepetitionDetected(StallRetry):
|
|
35
|
+
"""CONTENT trigger for the over-think interrupt: the model collapsed into a
|
|
36
|
+
pure-repetition loop (same short unit ×6+, 600+ chars) and produced no usable
|
|
37
|
+
action. Unlike the wall-time StallRetry this fires on the *content*, so it
|
|
38
|
+
never trips on productive-but-long reasoning — only genuine spinning. A plain
|
|
39
|
+
re-issue would just loop again, so the agent goes straight to decisive mode."""
|
|
40
|
+
|
|
33
41
|
from drydock.loop_detect import runaway_repetition_len
|
|
34
42
|
from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
|
|
35
43
|
|
|
@@ -540,6 +548,11 @@ def stream(
|
|
|
540
548
|
_rep_checked_at = len(text)
|
|
541
549
|
run = runaway_repetition_len(text)
|
|
542
550
|
if run:
|
|
551
|
+
# With the over-think interrupt on, a genuine loop escalates
|
|
552
|
+
# to a decisive retry (recover) instead of returning a
|
|
553
|
+
# truncated turn. Otherwise keep the advisory trim+stop.
|
|
554
|
+
if stall_secs:
|
|
555
|
+
raise RepetitionDetected(0)
|
|
543
556
|
text = (
|
|
544
557
|
text[: len(text) - run]
|
|
545
558
|
+ "\n[… output began repeating — stopped by drydock]"
|
|
@@ -599,10 +612,6 @@ def _complete_nonstreaming(
|
|
|
599
612
|
# the generic special-token pass would eat the tool_call markers.
|
|
600
613
|
text, had_leak = strip_leaked_tool_calls(msg.content or "")
|
|
601
614
|
thinking, text = extract_thinking(text)
|
|
602
|
-
if thinking:
|
|
603
|
-
yield ReasoningChunk(thinking)
|
|
604
|
-
if text.strip():
|
|
605
|
-
yield TextChunk(text)
|
|
606
615
|
|
|
607
616
|
tool_calls = []
|
|
608
617
|
for i, tc in enumerate(getattr(msg, "tool_calls", None) or []):
|
|
@@ -613,6 +622,21 @@ def _complete_nonstreaming(
|
|
|
613
622
|
"input": inp,
|
|
614
623
|
})
|
|
615
624
|
|
|
625
|
+
# Repetition trigger for the over-think interrupt (gated by the same
|
|
626
|
+
# stall_secs feature). Non-streaming gemma turns are where the over-think
|
|
627
|
+
# lives; if the turn collapsed into a pure loop and produced NO usable action,
|
|
628
|
+
# go decisive rather than feeding the garbage turn into the conversation. Only
|
|
629
|
+
# fires on genuine repetition, so productive-but-long reasoning is never hit.
|
|
630
|
+
if stall_secs and not tool_calls and (
|
|
631
|
+
runaway_repetition_len(thinking or "") or runaway_repetition_len(text or "")
|
|
632
|
+
):
|
|
633
|
+
raise RepetitionDetected(0)
|
|
634
|
+
|
|
635
|
+
if thinking:
|
|
636
|
+
yield ReasoningChunk(thinking)
|
|
637
|
+
if text.strip():
|
|
638
|
+
yield TextChunk(text)
|
|
639
|
+
|
|
616
640
|
usage = getattr(resp, "usage", None)
|
|
617
641
|
in_tok = usage.prompt_tokens if usage else 0
|
|
618
642
|
out_tok = usage.completion_tokens if usage else 0
|
|
@@ -244,6 +244,22 @@ SCHEMAS = [
|
|
|
244
244
|
"required": ["path"],
|
|
245
245
|
},
|
|
246
246
|
},
|
|
247
|
+
{
|
|
248
|
+
"name": "Screenshot",
|
|
249
|
+
"description": "CAPTURE the current screen and SEE it with your vision — use "
|
|
250
|
+
"when the user asks you to look at their screen, review what's "
|
|
251
|
+
"displayed, debug a GUI, or read something shown on screen. Takes "
|
|
252
|
+
"the screenshot and shows it to you directly (no need to call "
|
|
253
|
+
"ViewImage after). Works on Windows (PowerShell), macOS, and "
|
|
254
|
+
"Linux with a display. Optionally pass `path` to also save the PNG.",
|
|
255
|
+
"input_schema": {
|
|
256
|
+
"type": "object",
|
|
257
|
+
"properties": {
|
|
258
|
+
"path": {"type": "string",
|
|
259
|
+
"description": "Optional file to save the PNG to. Omit to use a temp file."},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
247
263
|
{
|
|
248
264
|
"name": "Write",
|
|
249
265
|
"description": "Write content to a file, creating parent directories as needed.",
|
|
@@ -711,6 +727,74 @@ def tool_viewimage(params: dict, config: dict) -> str:
|
|
|
711
727
|
"to you. Describe it, read any text in it, or use it to answer the task.")
|
|
712
728
|
|
|
713
729
|
|
|
730
|
+
def _capture_screen(out: str) -> str | None:
|
|
731
|
+
"""Grab the whole (virtual) screen to PNG `out`. Returns an error string, or
|
|
732
|
+
None on success. Per-OS: Windows uses PowerShell + System.Drawing; macOS uses
|
|
733
|
+
screencapture; Linux tries the common grabbers in turn."""
|
|
734
|
+
import shutil
|
|
735
|
+
import subprocess
|
|
736
|
+
try:
|
|
737
|
+
if _IS_WINDOWS:
|
|
738
|
+
ps = (
|
|
739
|
+
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; "
|
|
740
|
+
"$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; "
|
|
741
|
+
"$b=New-Object System.Drawing.Bitmap $s.Width,$s.Height; "
|
|
742
|
+
"$g=[System.Drawing.Graphics]::FromImage($b); "
|
|
743
|
+
"$g.CopyFromScreen($s.Left,$s.Top,0,0,$b.Size); "
|
|
744
|
+
f"$b.Save('{out}',[System.Drawing.Imaging.ImageFormat]::Png); "
|
|
745
|
+
"$g.Dispose(); $b.Dispose()"
|
|
746
|
+
)
|
|
747
|
+
exe = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
|
748
|
+
subprocess.run([exe, "-NoProfile", "-NonInteractive", "-Command", ps],
|
|
749
|
+
capture_output=True, timeout=30)
|
|
750
|
+
elif sys.platform == "darwin":
|
|
751
|
+
subprocess.run(["screencapture", "-x", out], capture_output=True, timeout=30)
|
|
752
|
+
else: # Linux/BSD — try grabbers in order of ubiquity (X11 + wayland)
|
|
753
|
+
grabbers = (["scrot", "-o", out], ["import", "-window", "root", out],
|
|
754
|
+
["gnome-screenshot", "-f", out], ["grim", out],
|
|
755
|
+
["spectacle", "-b", "-n", "-o", out], ["maim", out])
|
|
756
|
+
tried = [g[0] for g in grabbers if shutil.which(g[0])]
|
|
757
|
+
if not tried:
|
|
758
|
+
return ("no screenshot tool found. Install one of: scrot, imagemagick "
|
|
759
|
+
"(import), gnome-screenshot, grim (wayland), spectacle, maim.")
|
|
760
|
+
for g in grabbers:
|
|
761
|
+
if shutil.which(g[0]):
|
|
762
|
+
subprocess.run(g, capture_output=True, timeout=30)
|
|
763
|
+
if os.path.isfile(out) and os.path.getsize(out):
|
|
764
|
+
break
|
|
765
|
+
except subprocess.TimeoutExpired:
|
|
766
|
+
return "screen capture timed out (30s)."
|
|
767
|
+
except Exception as e: # noqa: BLE001 — report, never crash the loop
|
|
768
|
+
return f"screen capture failed: {e}"
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def tool_screenshot(params: dict, config: dict) -> str:
|
|
773
|
+
"""Capture the screen to a PNG and make it visible to the vision model — the
|
|
774
|
+
returned absolute path is auto-attached by the API boundary, same as ViewImage."""
|
|
775
|
+
import tempfile
|
|
776
|
+
|
|
777
|
+
raw = _as_str_arg(params.get("path")).strip()
|
|
778
|
+
out = os.path.abspath(_resolve_path(raw, config) if raw
|
|
779
|
+
else os.path.join(tempfile.gettempdir(), "drydock_screenshot.png"))
|
|
780
|
+
try:
|
|
781
|
+
if os.path.dirname(out):
|
|
782
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
783
|
+
except OSError:
|
|
784
|
+
pass
|
|
785
|
+
err = _capture_screen(out)
|
|
786
|
+
if err:
|
|
787
|
+
return (f"Error: {err} (On a headless server there is no display to capture.)")
|
|
788
|
+
if not os.path.isfile(out) or not os.path.getsize(out):
|
|
789
|
+
return "Error: screen capture produced no image (no display, or the grabber failed)."
|
|
790
|
+
size = os.path.getsize(out)
|
|
791
|
+
if size > _MAX_VIEW_IMAGE_BYTES:
|
|
792
|
+
return (f"Captured the screen to {out} but it is {size // 1_000_000}MB — too "
|
|
793
|
+
"large to view (limit 20MB). The file is saved; open it manually.")
|
|
794
|
+
return (f"Captured the screen to {out} (PNG, {size // 1024 or 1}KB) — it is now "
|
|
795
|
+
"visible to you. Describe what's on screen or use it to answer the task.")
|
|
796
|
+
|
|
797
|
+
|
|
714
798
|
def tool_read(params: dict, config: dict) -> str:
|
|
715
799
|
fp = _resolve_path(_as_str_arg(params.get("file_path")), config)
|
|
716
800
|
# Reading an image as text yields binary garbage — point at the vision tool.
|
|
@@ -1801,6 +1885,7 @@ def tool_build_knowledge(params: dict, config: dict) -> str:
|
|
|
1801
1885
|
|
|
1802
1886
|
_TOOLS = [
|
|
1803
1887
|
("Read", tool_read, True),
|
|
1888
|
+
("Screenshot", tool_screenshot, True),
|
|
1804
1889
|
("Write", tool_write, False),
|
|
1805
1890
|
("Edit", tool_edit, False),
|
|
1806
1891
|
("Bash", tool_bash, False),
|
|
@@ -1829,6 +1914,7 @@ def register_all():
|
|
|
1829
1914
|
name = schema["name"]
|
|
1830
1915
|
func = {
|
|
1831
1916
|
"Read": tool_read, "ViewImage": tool_viewimage,
|
|
1917
|
+
"Screenshot": tool_screenshot,
|
|
1832
1918
|
"Write": tool_write, "Edit": tool_edit,
|
|
1833
1919
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
1834
1920
|
"todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
|
|
@@ -1843,7 +1929,7 @@ def register_all():
|
|
|
1843
1929
|
# Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
|
|
1844
1930
|
# GitCommit + GraphAdd write).
|
|
1845
1931
|
read_only = name in (
|
|
1846
|
-
"Read", "ViewImage", "Glob", "Grep", "task", "Dispatch", "Consult",
|
|
1932
|
+
"Read", "ViewImage", "Screenshot", "Glob", "Grep", "task", "Dispatch", "Consult",
|
|
1847
1933
|
"Knowledge", "GraphQuery", "StigRules", "StigRule",
|
|
1848
1934
|
"WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
|
|
1849
1935
|
)
|
|
@@ -93,10 +93,12 @@ tests/test_plan_autocontinue.py
|
|
|
93
93
|
tests/test_poam.py
|
|
94
94
|
tests/test_providers_unreachable.py
|
|
95
95
|
tests/test_read_index.py
|
|
96
|
+
tests/test_repetition_interrupt.py
|
|
96
97
|
tests/test_rmf.py
|
|
97
98
|
tests/test_rmf_graph.py
|
|
98
99
|
tests/test_rmf_stig_graph.py
|
|
99
100
|
tests/test_runaway_repetition.py
|
|
101
|
+
tests/test_screenshot.py
|
|
100
102
|
tests/test_server_probe.py
|
|
101
103
|
tests/test_skills.py
|
|
102
104
|
tests/test_stall_retry.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.115"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Content-based repetition trigger for the over-think interrupt: a non-streaming
|
|
2
|
+
turn that collapses into a pure loop AND produces no action escalates straight to
|
|
3
|
+
decisive mode. It must NOT fire on varied reasoning, on a turn with a tool call,
|
|
4
|
+
or when the interrupt is disabled (stall_secs=0)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from drydock import agent, providers
|
|
10
|
+
from drydock.providers import AssistantTurn, RepetitionDetected, _complete_nonstreaming
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Fn:
|
|
14
|
+
def __init__(self, name, args): self.name = name; self.arguments = args
|
|
15
|
+
class _TC:
|
|
16
|
+
def __init__(self, name, args): self.id = "c1"; self.function = _Fn(name, args)
|
|
17
|
+
class _Msg:
|
|
18
|
+
def __init__(self, content, tcs=None): self.content = content; self.tool_calls = tcs or []
|
|
19
|
+
class _Choice:
|
|
20
|
+
def __init__(self, msg): self.message = msg
|
|
21
|
+
class _Resp:
|
|
22
|
+
def __init__(self, content, tcs=None): self.choices = [_Choice(_Msg(content, tcs))]; self.usage = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _patch(monkeypatch, content, tcs=None):
|
|
26
|
+
monkeypatch.setattr(providers, "_create_abortable", lambda *a, **k: _Resp(content, tcs))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_pure_loop_no_action_raises(monkeypatch):
|
|
30
|
+
_patch(monkeypatch, "loop " * 300) # 1500 chars of one unit
|
|
31
|
+
with pytest.raises(RepetitionDetected):
|
|
32
|
+
list(_complete_nonstreaming(None, {}, stall_secs=300))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_varied_reasoning_does_not_raise(monkeypatch):
|
|
36
|
+
_patch(monkeypatch, "First check the file, then run grep, then write the result and verify it.")
|
|
37
|
+
out = list(_complete_nonstreaming(None, {}, stall_secs=300))
|
|
38
|
+
assert any(isinstance(o, AssistantTurn) for o in out) # completed normally
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_loop_but_has_toolcall_does_not_raise(monkeypatch):
|
|
42
|
+
_patch(monkeypatch, "loop " * 300, [_TC("Bash", '{"command":"ls"}')])
|
|
43
|
+
out = list(_complete_nonstreaming(None, {}, stall_secs=300))
|
|
44
|
+
turn = [o for o in out if isinstance(o, AssistantTurn)][0]
|
|
45
|
+
assert turn.tool_calls and turn.tool_calls[0]["name"] == "Bash" # action preserved
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_disabled_when_stall_secs_zero(monkeypatch):
|
|
49
|
+
_patch(monkeypatch, "loop " * 300)
|
|
50
|
+
out = list(_complete_nonstreaming(None, {}, stall_secs=0)) # feature off
|
|
51
|
+
assert any(isinstance(o, AssistantTurn) for o in out)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_agent_goes_decisive_immediately_on_repetition(monkeypatch):
|
|
55
|
+
calls = []
|
|
56
|
+
def fake_stream(model, system, messages, tool_schemas, config):
|
|
57
|
+
calls.append({"system": system, "max_tokens": config.get("max_tokens")})
|
|
58
|
+
if len(calls) == 1:
|
|
59
|
+
raise RepetitionDetected(0)
|
|
60
|
+
yield AssistantTurn("done", [], 10, 5)
|
|
61
|
+
monkeypatch.setattr(agent, "stream", fake_stream)
|
|
62
|
+
st = agent.AgentState()
|
|
63
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "stall_retry_secs": 300, "context_limit": 65536}
|
|
64
|
+
list(agent.run("go", st, cfg, "SYS"))
|
|
65
|
+
# first call raised repetition → SECOND call is already decisive (no plain retry)
|
|
66
|
+
assert len(calls) == 2
|
|
67
|
+
assert agent._DECISIVE_SUFFIX in calls[1]["system"]
|
|
68
|
+
assert calls[1]["max_tokens"] <= agent._DECISIVE_MAX_TOKENS
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Screenshot tool: capture the screen to PNG and make it visible to the vision
|
|
2
|
+
model (the returned path auto-attaches, same path as ViewImage). Cross-platform:
|
|
3
|
+
PowerShell on Windows, screencapture on macOS, common grabbers on Linux."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from drydock import tools, tool_registry
|
|
11
|
+
from drydock.providers import detect_image_paths
|
|
12
|
+
from drydock.tuning import filter_tool_schemas
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_registered_and_available_to_gemma():
|
|
16
|
+
names = [s["name"] for s in tool_registry.schemas()]
|
|
17
|
+
gnames = [s["name"] for s in filter_tool_schemas(tool_registry.schemas(), "gemma4")]
|
|
18
|
+
assert "Screenshot" in names and "Screenshot" in gnames
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_graceful_when_no_grabber(monkeypatch):
|
|
22
|
+
monkeypatch.setattr(tools, "_IS_WINDOWS", False)
|
|
23
|
+
monkeypatch.setattr("sys.platform", "linux")
|
|
24
|
+
monkeypatch.setattr("shutil.which", lambda _n: None) # no grabbers installed
|
|
25
|
+
out = tools.tool_screenshot({}, {"cwd": os.getcwd()})
|
|
26
|
+
assert out.startswith("Error") and "screenshot tool" in out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_success_returns_attachable_path():
|
|
30
|
+
png = tempfile.mktemp(suffix=".png")
|
|
31
|
+
|
|
32
|
+
def fake_cap(o):
|
|
33
|
+
open(o, "wb").write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 200)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
with mock.patch.object(tools, "_capture_screen", fake_cap):
|
|
37
|
+
out = tools.tool_screenshot({"path": png}, {"cwd": os.getcwd()})
|
|
38
|
+
assert "visible to you" in out
|
|
39
|
+
assert detect_image_paths(out) == [os.path.abspath(png)] # auto-attaches
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_windows_builds_powershell_capture():
|
|
43
|
+
calls = {}
|
|
44
|
+
with mock.patch.object(tools, "_IS_WINDOWS", True), \
|
|
45
|
+
mock.patch("shutil.which", side_effect=lambda n: r"C:\pwsh.exe" if n == "pwsh" else None), \
|
|
46
|
+
mock.patch("subprocess.run", side_effect=lambda a, **k: calls.setdefault("argv", a)):
|
|
47
|
+
assert tools._capture_screen(r"C:\shot.png") is None
|
|
48
|
+
argv = calls["argv"]
|
|
49
|
+
assert argv[0].endswith("pwsh.exe") and argv[1] == "-NoProfile"
|
|
50
|
+
assert "CopyFromScreen" in argv[-1] and "ImageFormat]::Png" in argv[-1]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_macos_uses_screencapture(monkeypatch):
|
|
54
|
+
calls = {}
|
|
55
|
+
monkeypatch.setattr(tools, "_IS_WINDOWS", False)
|
|
56
|
+
monkeypatch.setattr("sys.platform", "darwin")
|
|
57
|
+
monkeypatch.setattr("subprocess.run", lambda a, **k: calls.setdefault("argv", a))
|
|
58
|
+
tools._capture_screen("/tmp/x.png")
|
|
59
|
+
assert calls["argv"][0] == "screencapture"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|