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.
Files changed (124) hide show
  1. {drydock_cli-3.0.113/drydock_cli.egg-info → drydock_cli-3.0.115}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/agent.py +14 -10
  3. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/providers.py +28 -4
  4. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tools/__init__.py +87 -1
  5. {drydock_cli-3.0.113 → drydock_cli-3.0.115/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/SOURCES.txt +2 -0
  7. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/pyproject.toml +1 -1
  8. drydock_cli-3.0.115/tests/test_repetition_interrupt.py +68 -0
  9. drydock_cli-3.0.115/tests/test_screenshot.py +59 -0
  10. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/LICENSE +0 -0
  11. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/NOTICE +0 -0
  12. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/README.md +0 -0
  13. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/__init__.py +0 -0
  14. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/__main__.py +0 -0
  15. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/advisor.py +0 -0
  16. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/__init__.py +0 -0
  18. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-categorize.md +0 -0
  19. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-control.md +0 -0
  20. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-poam.md +0 -0
  21. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-review.md +0 -0
  22. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-assess.md +0 -0
  23. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-remediate.md +0 -0
  24. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/cci.py +0 -0
  25. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/cli.py +0 -0
  26. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/compaction.py +0 -0
  27. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/config.py +0 -0
  28. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/detect.py +0 -0
  29. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/extract.py +0 -0
  30. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/gittools.py +0 -0
  31. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/graphrag.py +0 -0
  32. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/guards.py +0 -0
  33. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/loop_detect.py +0 -0
  34. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/mcp.py +0 -0
  35. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/poam.py +0 -0
  36. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/rmf.py +0 -0
  37. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/rmf_graph.py +0 -0
  38. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/skills.py +0 -0
  39. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/stig.py +0 -0
  40. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tool_registry.py +0 -0
  41. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/__init__.py +0 -0
  42. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/app.py +0 -0
  43. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/approval.py +0 -0
  44. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/messages.py +0 -0
  45. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tui/widgets.py +0 -0
  46. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/tuning.py +0 -0
  47. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock/web.py +0 -0
  48. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/dependency_links.txt +0 -0
  49. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/entry_points.txt +0 -0
  50. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/requires.txt +0 -0
  51. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/drydock_cli.egg-info/top_level.txt +0 -0
  52. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/setup.cfg +0 -0
  53. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_advisor.py +0 -0
  54. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_approval.py +0 -0
  55. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_back_command.py +0 -0
  56. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_background.py +0 -0
  57. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_binary_output.py +0 -0
  58. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_crossplatform.py +0 -0
  59. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_output_bounding.py +0 -0
  60. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_process_group.py +0 -0
  61. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_safety.py +0 -0
  62. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_sanitize.py +0 -0
  63. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_shell.py +0 -0
  64. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_stdin.py +0 -0
  65. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_stop_partial.py +0 -0
  66. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_timeout_network.py +0 -0
  67. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_bash_timeout_param.py +0 -0
  68. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_cci.py +0 -0
  69. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_cli_agents.py +0 -0
  70. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_compact_command.py +0 -0
  71. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_compaction.py +0 -0
  72. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_config.py +0 -0
  73. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_config_migration.py +0 -0
  74. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_context_limit_config.py +0 -0
  75. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_detect.py +0 -0
  76. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_dispatch.py +0 -0
  77. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_e2e_connected.py +0 -0
  78. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_edit_replace_all.py +0 -0
  79. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_empty_response.py +0 -0
  80. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_extract.py +0 -0
  81. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_failure_loop.py +0 -0
  82. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_first_run_setup.py +0 -0
  83. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_gittools.py +0 -0
  84. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_glob_edit_edges.py +0 -0
  85. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphify_example.py +0 -0
  86. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphrag.py +0 -0
  87. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_graphrag_quoted_path.py +0 -0
  88. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_grep_and_read_robust.py +0 -0
  89. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_guards_and_tools.py +0 -0
  90. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_hallucinated_tools.py +0 -0
  91. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_leaked_tool_call.py +0 -0
  92. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_loop_detect.py +0 -0
  93. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_mcp.py +0 -0
  94. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_oneshot_unreachable.py +0 -0
  95. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_overthink_interrupt.py +0 -0
  96. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_plan_autocontinue.py +0 -0
  97. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_poam.py +0 -0
  98. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_providers_unreachable.py +0 -0
  99. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_read_index.py +0 -0
  100. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf.py +0 -0
  101. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf_graph.py +0 -0
  102. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_rmf_stig_graph.py +0 -0
  103. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_runaway_repetition.py +0 -0
  104. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_server_probe.py +0 -0
  105. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_skills.py +0 -0
  106. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stall_retry.py +0 -0
  107. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stig.py +0 -0
  108. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_stop.py +0 -0
  109. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_streaming_newlines.py +0 -0
  110. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_subagent.py +0 -0
  111. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_system_prompt_help.py +0 -0
  112. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_todo.py +0 -0
  113. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion.py +0 -0
  114. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion_more.py +0 -0
  115. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tool_arg_parsing.py +0 -0
  116. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tools_undo.py +0 -0
  117. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tui.py +0 -0
  118. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_tuning.py +0 -0
  119. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_viewimage.py +0 -0
  120. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_vision_input.py +0 -0
  121. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_web_tools.py +0 -0
  122. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_windows_shell.py +0 -0
  123. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_write_content_coerce.py +0 -0
  124. {drydock_cli-3.0.113 → drydock_cli-3.0.115}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.113
3
+ Version: 3.0.115
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
@@ -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
- # The call stalled or over-thought past stall_retry_secs. First
199
- # retry re-issues as-is (handles a transient server hang cheaply);
200
- # if it KEEPS happening it's over-thinking, so escalate to decisive
201
- # mode a forcing suffix + a hard token cap that make a long
202
- # reasoning turn impossible. Bounded; on exhaustion end cleanly.
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/over-thinking — giving up on this step.]\n")
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
- yield TextChunk("\n[taking too long forcing a decisive, single-action step...]\n")
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.113
3
+ Version: 3.0.115
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
@@ -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.113"
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