drydock-cli 3.0.114__tar.gz → 3.0.116__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 (126) hide show
  1. {drydock_cli-3.0.114/drydock_cli.egg-info → drydock_cli-3.0.116}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/agent.py +24 -10
  3. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/config.py +5 -0
  4. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/providers.py +28 -4
  5. drydock_cli-3.0.116/drydock/recipes.py +166 -0
  6. {drydock_cli-3.0.114 → drydock_cli-3.0.116/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock_cli.egg-info/SOURCES.txt +3 -0
  8. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/pyproject.toml +1 -1
  9. drydock_cli-3.0.116/tests/test_recipes.py +55 -0
  10. drydock_cli-3.0.116/tests/test_repetition_interrupt.py +68 -0
  11. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/LICENSE +0 -0
  12. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/NOTICE +0 -0
  13. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/README.md +0 -0
  14. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/advisor.py +0 -0
  17. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/__init__.py +0 -0
  19. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/rmf-categorize.md +0 -0
  20. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/rmf-control.md +0 -0
  21. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/rmf-poam.md +0 -0
  22. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/rmf-review.md +0 -0
  23. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/stig-assess.md +0 -0
  24. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/builtin_skills/stig-remediate.md +0 -0
  25. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/cci.py +0 -0
  26. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/cli.py +0 -0
  27. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/compaction.py +0 -0
  28. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/detect.py +0 -0
  29. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/extract.py +0 -0
  30. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/gittools.py +0 -0
  31. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/graphrag.py +0 -0
  32. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/guards.py +0 -0
  33. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/loop_detect.py +0 -0
  34. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/mcp.py +0 -0
  35. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/poam.py +0 -0
  36. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/rmf.py +0 -0
  37. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/rmf_graph.py +0 -0
  38. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/skills.py +0 -0
  39. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/stig.py +0 -0
  40. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tool_registry.py +0 -0
  41. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tools/__init__.py +0 -0
  42. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tui/__init__.py +0 -0
  43. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tui/app.py +0 -0
  44. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tui/approval.py +0 -0
  45. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tui/messages.py +0 -0
  46. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tui/widgets.py +0 -0
  47. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/tuning.py +0 -0
  48. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock/web.py +0 -0
  49. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock_cli.egg-info/dependency_links.txt +0 -0
  50. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock_cli.egg-info/entry_points.txt +0 -0
  51. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock_cli.egg-info/requires.txt +0 -0
  52. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/drydock_cli.egg-info/top_level.txt +0 -0
  53. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/setup.cfg +0 -0
  54. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_advisor.py +0 -0
  55. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_approval.py +0 -0
  56. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_back_command.py +0 -0
  57. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_background.py +0 -0
  58. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_binary_output.py +0 -0
  59. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_crossplatform.py +0 -0
  60. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_output_bounding.py +0 -0
  61. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_process_group.py +0 -0
  62. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_safety.py +0 -0
  63. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_sanitize.py +0 -0
  64. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_shell.py +0 -0
  65. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_stdin.py +0 -0
  66. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_stop_partial.py +0 -0
  67. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_timeout_network.py +0 -0
  68. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_bash_timeout_param.py +0 -0
  69. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_cci.py +0 -0
  70. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_cli_agents.py +0 -0
  71. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_compact_command.py +0 -0
  72. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_compaction.py +0 -0
  73. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_config.py +0 -0
  74. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_config_migration.py +0 -0
  75. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_context_limit_config.py +0 -0
  76. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_detect.py +0 -0
  77. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_dispatch.py +0 -0
  78. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_e2e_connected.py +0 -0
  79. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_edit_replace_all.py +0 -0
  80. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_empty_response.py +0 -0
  81. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_extract.py +0 -0
  82. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_failure_loop.py +0 -0
  83. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_first_run_setup.py +0 -0
  84. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_gittools.py +0 -0
  85. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_glob_edit_edges.py +0 -0
  86. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_graphify_example.py +0 -0
  87. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_graphrag.py +0 -0
  88. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_graphrag_quoted_path.py +0 -0
  89. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_grep_and_read_robust.py +0 -0
  90. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_guards_and_tools.py +0 -0
  91. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_hallucinated_tools.py +0 -0
  92. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_leaked_tool_call.py +0 -0
  93. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_loop_detect.py +0 -0
  94. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_mcp.py +0 -0
  95. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_oneshot_unreachable.py +0 -0
  96. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_overthink_interrupt.py +0 -0
  97. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_plan_autocontinue.py +0 -0
  98. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_poam.py +0 -0
  99. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_providers_unreachable.py +0 -0
  100. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_read_index.py +0 -0
  101. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_rmf.py +0 -0
  102. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_rmf_graph.py +0 -0
  103. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_rmf_stig_graph.py +0 -0
  104. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_runaway_repetition.py +0 -0
  105. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_screenshot.py +0 -0
  106. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_server_probe.py +0 -0
  107. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_skills.py +0 -0
  108. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_stall_retry.py +0 -0
  109. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_stig.py +0 -0
  110. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_stop.py +0 -0
  111. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_streaming_newlines.py +0 -0
  112. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_subagent.py +0 -0
  113. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_system_prompt_help.py +0 -0
  114. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_todo.py +0 -0
  115. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tool_arg_coercion.py +0 -0
  116. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tool_arg_coercion_more.py +0 -0
  117. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tool_arg_parsing.py +0 -0
  118. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tools_undo.py +0 -0
  119. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tui.py +0 -0
  120. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_tuning.py +0 -0
  121. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_viewimage.py +0 -0
  122. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_vision_input.py +0 -0
  123. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_web_tools.py +0 -0
  124. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_windows_shell.py +0 -0
  125. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/tests/test_write_content_coerce.py +0 -0
  126. {drydock_cli-3.0.114 → drydock_cli-3.0.116}/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.114
3
+ Version: 3.0.116
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
 
@@ -114,6 +114,16 @@ def run(
114
114
  """
115
115
  state.messages.append({"role": "user", "content": user_message})
116
116
 
117
+ # Recipe retrieval: give the model the TECHNIQUE this task needs (forensics,
118
+ # git-history rewrite, numpy-2.0 fix, cert gen, …) by appending the relevant
119
+ # bundled recipes to the system prompt. Keyword-matched, so only fitting ones
120
+ # are added; none matched = no change. Gated by config `recipes`.
121
+ if config.get("recipes", True):
122
+ from drydock.recipes import recipe_context
123
+ extra = recipe_context(user_message)
124
+ if extra:
125
+ system_prompt = system_prompt + extra
126
+
117
127
  max_turns = config.get("max_turns", 200)
118
128
  max_tool_calls = config.get("max_tool_calls", 0) # 0 = unlimited
119
129
  # When set (sub-agents pass this), the model is offered ONLY these tools and
@@ -194,24 +204,28 @@ def run(
194
204
  elif isinstance(event, AssistantTurn):
195
205
  assistant_turn = event
196
206
  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.
207
+ except StallRetry as _sr:
208
+ # Stalled/over-thought past stall_retry_secs (wall-time), OR collapsed
209
+ # into a pure-repetition loop (RepetitionDetected, content-based). A
210
+ # first wall-time retry re-issues as-is (a transient hang clears); a
211
+ # persistent stall OR any repetition loop goes straight to decisive
212
+ # mode a forcing suffix + hard token cap that make a long reasoning
213
+ # turn impossible. Repetition escalates immediately (re-issuing a loop
214
+ # just loops again). Bounded; on exhaustion end cleanly.
215
+ is_rep = isinstance(_sr, RepetitionDetected)
203
216
  stall_retries += 1
204
217
  if stall_retries > 3:
205
- yield TextChunk("\n[model kept stalling/over-thinking — giving up on this step.]\n")
218
+ yield TextChunk("\n[model kept stalling/looping — giving up on this step.]\n")
206
219
  assistant_turn = None
207
220
  break
208
- if stall_retries >= 2 and not decisive:
221
+ if (is_rep or stall_retries >= 2) and not decisive:
209
222
  decisive = True
210
223
  turn_config = dict(turn_config)
211
224
  cur = int(turn_config.get("max_tokens", 8192) or 8192)
212
225
  turn_config["max_tokens"] = min(cur, _DECISIVE_MAX_TOKENS)
213
226
  turn_config["reasoning_effort"] = "low"
214
- yield TextChunk("\n[taking too long forcing a decisive, single-action step...]\n")
227
+ why = "output looping" if is_rep else "taking too long"
228
+ yield TextChunk(f"\n[{why} — forcing a decisive, single-action step...]\n")
215
229
  else:
216
230
  yield TextChunk("\n[model server stalled — retrying...]\n")
217
231
  continue
@@ -39,6 +39,11 @@ DEFAULTS: dict[str, object] = {
39
39
  # generation usually isn't stalled — a known gemma/llama.cpp hang). 0 = off.
40
40
  # Set to e.g. 600 on a stall-prone local server. Bounded to a few retries.
41
41
  "stall_retry_secs": 0,
42
+ # Inject bundled technique recipes (drydock/recipes.py) relevant to the task
43
+ # into the system prompt, so a local model has the *method* a task needs
44
+ # instead of guessing. Retrieval is keyword-overlap; only relevant recipes are
45
+ # added (none = nothing injected). Set false to disable.
46
+ "recipes": True,
42
47
  "theme": "harbor",
43
48
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
44
49
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -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
@@ -0,0 +1,166 @@
1
+ """Bundled technique recipes — compact "how to do X" references retrieved by task
2
+ text and injected into context, so a fixed local model has the *technique* a task
3
+ needs instead of guessing. Retrieval is keyword-overlap (stdlib, no embeddings):
4
+ cheap, deterministic, and good enough for a small curated set.
5
+
6
+ All content original to Drydock. Gated by config `recipes` (default on).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # (title, keywords, body). Keep bodies tight and COMMAND-first — the model needs
13
+ # the move, not an essay. Grounded in tbench failure categories we observed.
14
+ RECIPES: list[tuple[str, list[str], str]] = [
15
+ ("Recover deleted / hidden / fragmented data from files",
16
+ ["recover", "deleted", "forensic", "forensics", "password", "strings", "binary",
17
+ "carve", "fragment", "hidden", "launchcode", "undelete", "disk", "raw"],
18
+ "Deleted or embedded content often still sits in a file/blob as raw bytes.\n"
19
+ "1. Search binary-safe for the pattern across everything:\n"
20
+ " grep -aroE 'PATTERN' /path (grep -a treats binary as text)\n"
21
+ "2. Pull printable strings and grep them — the target may be split across a\n"
22
+ " non-printable byte, so `strings` shows it on TWO adjacent lines:\n"
23
+ " strings BIGFILE | grep -A1 'MARKER'\n"
24
+ " Then CONCATENATE the fragments (strip the break) to rebuild the value.\n"
25
+ "3. Inspect bytes to understand structure: xxd FILE | less (or od -A x -t x1z).\n"
26
+ "If a value has a known shape (e.g. 23 chars, starts 8XD ends W54), build the\n"
27
+ "exact ERE: 8XD[A-Z0-9]{17}W54 — count the middle length precisely."),
28
+
29
+ ("Remove a secret / file from ALL git history",
30
+ ["git", "history", "secret", "sanitize", "remove", "rewrite", "filter", "purge",
31
+ "credential", "commit", "bfg", "filter-branch", "filter-repo"],
32
+ "Deleting a file in a new commit does NOT remove it from history. Rewrite it:\n"
33
+ " git filter-repo --path SECRET --invert-paths # preferred if installed\n"
34
+ " git filter-repo --replace-text <(echo 'SECRET==>REDACTED')\n"
35
+ "If filter-repo is absent, use the built-in (slower):\n"
36
+ " git filter-branch --force --index-filter \\\n"
37
+ " 'git rm --cached --ignore-unmatch PATH' --prune-empty --tag-name-filter cat -- --all\n"
38
+ "Then: rm -rf .git/refs/original; git reflog expire --expire=now --all; git gc --prune=now.\n"
39
+ "Verify it's gone from every commit: git log --all --oneline -- PATH (should be empty),\n"
40
+ "and grep history: git rev-list --all | xargs -I{} git grep -I SECRET {} 2>/dev/null."),
41
+
42
+ ("Fix NumPy 2.0 incompatibility",
43
+ ["numpy", "np", "compat", "compatibility", "attributeerror", "deprecated",
44
+ "alias", "float", "int", "bool", "migrate", "2.0"],
45
+ "NumPy 2.0 removed the deprecated scalar aliases and renamed a few APIs:\n"
46
+ " np.float -> float (or np.float64) np.int -> int (or np.int64)\n"
47
+ " np.bool -> bool np.object -> object np.str -> str\n"
48
+ " np.NaN -> np.nan np.Inf -> np.inf np.product -> np.prod\n"
49
+ " np.NINF -> -np.inf np.float_ -> np.float64 np.unicode_ -> np.str_\n"
50
+ " np.in1d -> np.isin np.round_ -> np.round\n"
51
+ "Find them: grep -rnE 'np\\.(float|int|bool|object|str|NaN|Inf|product|in1d)\\b' .\n"
52
+ "For C/Cython ext: rebuild against the installed NumPy headers and pin\n"
53
+ "'oldest-supported-numpy' at build time; the ABI is forward-compatible from 2.0."),
54
+
55
+ ("Build & install a Python C/Cython extension from source",
56
+ ["cython", "extension", "compile", "build", "setup.py", "pyx", "c extension",
57
+ "build_ext", "install", "source", "pip install"],
58
+ " pip install -e . # build + editable-install using pyproject/setup\n"
59
+ " python setup.py build_ext --inplace # just compile the .so next to sources\n"
60
+ " pip install . # normal install\n"
61
+ "If Cython isn't run: pip install cython numpy first, then rebuild. To force a\n"
62
+ "clean rebuild: rm -rf build/ **/*.so **/*.c(generated) and repeat. Check the\n"
63
+ "extension imports: python -c 'import pkg.mod'. Read the FIRST compiler error —\n"
64
+ "usually a missing header (apt/pip a -dev package) or a NumPy API mismatch."),
65
+
66
+ ("Create & verify a self-signed certificate with openssl",
67
+ ["openssl", "certificate", "cert", "self-signed", "tls", "ssl", "x509", "key",
68
+ "pem", "csr"],
69
+ "One-shot self-signed cert + key (no prompts):\n"
70
+ " openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \\\n"
71
+ " -days 365 -subj '/CN=dev-internal.company.local'\n"
72
+ "Read fields back (scriptable, no extra deps):\n"
73
+ " openssl x509 -in cert.pem -noout -subject # CN=...\n"
74
+ " openssl x509 -in cert.pem -noout -enddate # notAfter=...\n"
75
+ " openssl x509 -in cert.pem -noout -dates -subject\n"
76
+ "Prefer shelling out to `openssl` over a Python crypto lib — the grader's env\n"
77
+ "may not have `cryptography` installed."),
78
+
79
+ ("Parse logs / extract fields from text",
80
+ ["log", "parse", "extract", "field", "grep", "awk", "sed", "regex", "count",
81
+ "lines", "match", "column"],
82
+ " grep -c 'ERROR' file # count matching LINES\n"
83
+ " grep -oE 'PATTERN' file | wc -l # count matching OCCURRENCES\n"
84
+ " awk -F',' '{print $3}' file # 3rd CSV field\n"
85
+ " awk '{sum+=$1} END{print sum}' # sum a column\n"
86
+ " sed -n '10,20p' file # lines 10-20\n"
87
+ " grep -oE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+' f # emails\n"
88
+ "Use grep -P for lookaround if available; -a for binary-safe; sort | uniq -c to tally."),
89
+
90
+ ("Work with JSON / CSV / SQLite data files",
91
+ ["json", "csv", "sqlite", "data", "parse", "query", "database", "db", "jq",
92
+ "pandas", "table", "schema"],
93
+ "JSON: jq '.key[]' f.json | python -c 'import json;d=json.load(open(\"f\"))'\n"
94
+ "CSV: python -c 'import csv;[print(r) for r in csv.DictReader(open(\"f.csv\"))]'\n"
95
+ "SQLite: sqlite3 db '.schema' sqlite3 db 'SELECT * FROM t LIMIT 5;'\n"
96
+ " sqlite3 -header -csv db 'SELECT ...' > out.csv\n"
97
+ "Prefer stdlib (csv, json, sqlite3) — always present. For big data, stream row\n"
98
+ "by row instead of loading all into memory."),
99
+
100
+ ("Always verify against the task's own check before finishing",
101
+ ["test", "verify", "check", "eval", "grader", "pass", "requirement", "make",
102
+ "pytest", "assert", "expected", "output"],
103
+ "Before you declare done:\n"
104
+ "1. Re-read the task and list EVERY requirement (exact filenames, paths, formats,\n"
105
+ " values, exit codes). Check each literally — a near-miss (5/6) still fails.\n"
106
+ "2. If the task ships a checker, RUN it and fix until green:\n"
107
+ " ls *test* *eval* Makefile 2>/dev/null; bash test.sh; python eval.py; make test\n"
108
+ "3. Anything you write must run in a CLEAN env — don't depend on a package you\n"
109
+ " just pip-installed unless the task says to; prefer stdlib / the task's tools.\n"
110
+ "4. Print the produced file/output and eyeball it against the spec."),
111
+
112
+ ("Search / optimization over parameters (grid, random, bisect)",
113
+ ["search", "optimize", "distribution", "parameter", "fit", "minimize",
114
+ "grid", "bisect", "tune", "best", "objective"],
115
+ "To find a parameter/value that satisfies a target:\n"
116
+ "- Monotonic target -> BISECTION: lo,hi; while hi-lo>eps: mid=(lo+hi)/2; move the\n"
117
+ " bound based on f(mid) vs target. (also `bisect` module for sorted lookup)\n"
118
+ "- Unknown landscape -> coarse GRID scan, then refine around the best point.\n"
119
+ "- Fitting a distribution: compute the empirical moments/quantiles and match, or\n"
120
+ " scipy.optimize.minimize on the negative log-likelihood. Validate with a KS test.\n"
121
+ "Always define the objective explicitly and print it each iteration so you can see\n"
122
+ "it converge instead of guessing."),
123
+
124
+ ("Numerical sampling methods (rejection, inverse-CDF, adaptive)",
125
+ ["sampling", "sample", "rejection", "adaptive", "distribution", "random",
126
+ "monte", "carlo", "pdf", "cdf", "envelope"],
127
+ "Draw from a density f you can evaluate:\n"
128
+ "- INVERSE-CDF: if F^-1 is known, x = F^-1(U), U~Uniform(0,1).\n"
129
+ "- REJECTION: pick envelope g with f(x) <= M*g(x); sample x~g, accept if\n"
130
+ " U*M*g(x) <= f(x). Efficiency = 1/M, so keep M tight.\n"
131
+ "- ADAPTIVE rejection (log-concave f): build a piecewise-linear upper hull of\n"
132
+ " log f from tangent lines at support points; sample from the hull's exp, accept/\n"
133
+ " reject, and ADD each rejected point to the hull so it tightens over time.\n"
134
+ "Seed the RNG for reproducibility and validate the sample mean/var against f."),
135
+ ]
136
+
137
+ _WORD = re.compile(r"[a-z0-9.]+")
138
+
139
+
140
+ def retrieve_recipes(task_text: str, k: int = 2) -> list[tuple[str, str]]:
141
+ """Return up to k (title, body) recipes most relevant to the task, by keyword
142
+ overlap. Empty if nothing scores — never inject irrelevant noise."""
143
+ if not task_text:
144
+ return []
145
+ words = set(_WORD.findall(task_text.lower()))
146
+ scored = []
147
+ for title, keywords, body in RECIPES:
148
+ score = sum(1 for kw in keywords if kw in words)
149
+ # also credit multiword keywords found as substrings (e.g. "pip install")
150
+ score += sum(1 for kw in keywords if " " in kw and kw in task_text.lower())
151
+ if score:
152
+ scored.append((score, title, body))
153
+ scored.sort(key=lambda x: -x[0])
154
+ return [(t, b) for _, t, b in scored[:k]]
155
+
156
+
157
+ def recipe_context(task_text: str, k: int = 2) -> str:
158
+ """A system-prompt appendix of the recipes relevant to this task, or ''."""
159
+ hits = retrieve_recipes(task_text, k)
160
+ if not hits:
161
+ return ""
162
+ blocks = "\n\n".join(f"### {title}\n{body}" for title, body in hits)
163
+ return (
164
+ "\n\nRELEVANT TECHNIQUE RECIPES (reference for THIS task — use if applicable):\n"
165
+ + blocks
166
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.114
3
+ Version: 3.0.116
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
@@ -20,6 +20,7 @@ drydock/loop_detect.py
20
20
  drydock/mcp.py
21
21
  drydock/poam.py
22
22
  drydock/providers.py
23
+ drydock/recipes.py
23
24
  drydock/rmf.py
24
25
  drydock/rmf_graph.py
25
26
  drydock/skills.py
@@ -93,6 +94,8 @@ tests/test_plan_autocontinue.py
93
94
  tests/test_poam.py
94
95
  tests/test_providers_unreachable.py
95
96
  tests/test_read_index.py
97
+ tests/test_recipes.py
98
+ tests/test_repetition_interrupt.py
96
99
  tests/test_rmf.py
97
100
  tests/test_rmf_graph.py
98
101
  tests/test_rmf_stig_graph.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.114"
10
+ version = "3.0.116"
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,55 @@
1
+ """Recipe retrieval + injection: the right technique recipe reaches the model for a
2
+ task, nothing is injected for irrelevant chat, and it's gated by config `recipes`."""
3
+ from __future__ import annotations
4
+
5
+ from drydock import agent
6
+ from drydock.providers import AssistantTurn
7
+ from drydock.recipes import recipe_context, retrieve_recipes
8
+
9
+
10
+ def _titles(text, k=2):
11
+ return [t for t, _ in retrieve_recipes(text, k)]
12
+
13
+
14
+ def test_matches_expected_recipe():
15
+ assert "Recover" in _titles("recover the deleted password forensic launchcode")[0]
16
+ assert "git" in _titles("remove the secret from git history sanitize")[0].lower()
17
+ assert any("NumPy" in t for t in _titles("fix numpy 2.0 compatibility cython build"))
18
+ assert "openssl" in _titles("self-signed certificate openssl x509")[0].lower()
19
+
20
+
21
+ def test_no_injection_for_chat():
22
+ assert retrieve_recipes("hello, how are you today?") == []
23
+ assert recipe_context("just say hi") == ""
24
+
25
+
26
+ def test_recipe_context_wraps_hits():
27
+ ctx = recipe_context("recover deleted password from binary file")
28
+ assert "TECHNIQUE RECIPES" in ctx and "strings" in ctx
29
+
30
+
31
+ def _run_capture(cfg, msg):
32
+ seen = {}
33
+ def fake_stream(model, system, messages, tool_schemas, config):
34
+ seen["system"] = system
35
+ yield AssistantTurn("done", [], 5, 5)
36
+ import drydock.agent as A
37
+ orig = A.stream
38
+ A.stream = fake_stream
39
+ try:
40
+ list(agent.run(msg, agent.AgentState(), cfg, "BASE-PROMPT"))
41
+ finally:
42
+ A.stream = orig
43
+ return seen["system"]
44
+
45
+
46
+ def test_injected_when_enabled():
47
+ cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "recipes": True}
48
+ sysp = _run_capture(cfg, "recover the deleted password from launchcode.txt forensic")
49
+ assert "TECHNIQUE RECIPES" in sysp and "strings" in sysp
50
+
51
+
52
+ def test_not_injected_when_disabled():
53
+ cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "recipes": False}
54
+ sysp = _run_capture(cfg, "recover the deleted password from launchcode.txt forensic")
55
+ assert "TECHNIQUE RECIPES" not in sysp
@@ -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
File without changes
File without changes
File without changes
File without changes