drydock-cli 3.0.166__tar.gz → 3.0.170__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 (181) hide show
  1. {drydock_cli-3.0.166/drydock_cli.egg-info → drydock_cli-3.0.170}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/agent.py +55 -3
  3. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/config.py +14 -0
  4. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/loop_detect.py +28 -0
  5. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tool_select.py +14 -9
  6. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tools/__init__.py +17 -2
  7. {drydock_cli-3.0.166 → drydock_cli-3.0.170/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/pyproject.toml +1 -1
  10. drydock_cli-3.0.170/tests/test_degenerate_argument.py +78 -0
  11. drydock_cli-3.0.170/tests/test_effort_governor.py +81 -0
  12. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_resume_restore.py +31 -0
  13. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_select.py +23 -0
  14. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_web_tools.py +28 -0
  15. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/LICENSE +0 -0
  16. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/NOTICE +0 -0
  17. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/README.md +0 -0
  18. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/__init__.py +0 -0
  19. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/__main__.py +0 -0
  20. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/advisor.py +0 -0
  21. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/bash_safety.py +0 -0
  22. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/budget.py +0 -0
  23. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/__init__.py +0 -0
  24. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-data.md +0 -0
  25. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-debug.md +0 -0
  26. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-finetune.md +0 -0
  27. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-metrics.md +0 -0
  28. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-rl.md +0 -0
  29. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/ml-train.md +0 -0
  30. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  31. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/nist-csf.md +0 -0
  32. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/rmf-categorize.md +0 -0
  33. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/rmf-control.md +0 -0
  34. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/rmf-poam.md +0 -0
  35. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/rmf-review.md +0 -0
  36. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/stig-assess.md +0 -0
  37. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/builtin_skills/stig-remediate.md +0 -0
  38. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/cci.py +0 -0
  39. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/cli.py +0 -0
  40. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/compaction.py +0 -0
  41. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/detect.py +0 -0
  42. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/events.py +0 -0
  43. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/extract.py +0 -0
  44. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/gittools.py +0 -0
  45. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/graphrag.py +0 -0
  46. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/guards.py +0 -0
  47. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/mcp.py +0 -0
  48. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/phases.py +0 -0
  49. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/poam.py +0 -0
  50. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/progress.py +0 -0
  51. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/providers.py +0 -0
  52. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/recipes.py +0 -0
  53. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/recovery.py +0 -0
  54. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/resume.py +0 -0
  55. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/rmf.py +0 -0
  56. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/rmf_graph.py +0 -0
  57. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/skills.py +0 -0
  58. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/stig.py +0 -0
  59. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/subagents.py +0 -0
  60. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/suggest.py +0 -0
  61. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/task_state.py +0 -0
  62. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tool_policy.py +0 -0
  63. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tool_registry.py +0 -0
  64. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tool_result.py +0 -0
  65. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tool_validate.py +0 -0
  66. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/trajectory.py +0 -0
  67. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tui/__init__.py +0 -0
  68. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tui/app.py +0 -0
  69. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tui/approval.py +0 -0
  70. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tui/messages.py +0 -0
  71. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tui/widgets.py +0 -0
  72. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/tuning.py +0 -0
  73. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/verification.py +0 -0
  74. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock/web.py +0 -0
  75. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock_cli.egg-info/dependency_links.txt +0 -0
  76. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock_cli.egg-info/entry_points.txt +0 -0
  77. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock_cli.egg-info/requires.txt +0 -0
  78. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/drydock_cli.egg-info/top_level.txt +0 -0
  79. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/setup.cfg +0 -0
  80. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_advisor.py +0 -0
  81. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_approval.py +0 -0
  82. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_back_command.py +0 -0
  83. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_background.py +0 -0
  84. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_binary_output.py +0 -0
  85. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_crossplatform.py +0 -0
  86. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_output_bounding.py +0 -0
  87. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_process_group.py +0 -0
  88. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_safety.py +0 -0
  89. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_sanitize.py +0 -0
  90. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_shell.py +0 -0
  91. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_stdin.py +0 -0
  92. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_stop_partial.py +0 -0
  93. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_timeout_network.py +0 -0
  94. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_bash_timeout_param.py +0 -0
  95. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_budget.py +0 -0
  96. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_cci.py +0 -0
  97. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_cli_agents.py +0 -0
  98. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_compact_command.py +0 -0
  99. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_compaction.py +0 -0
  100. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_config.py +0 -0
  101. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_config_migration.py +0 -0
  102. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_context_limit_config.py +0 -0
  103. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_context_limit_issue25.py +0 -0
  104. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_criteria_coverage.py +0 -0
  105. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_cycling_detection.py +0 -0
  106. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_detect.py +0 -0
  107. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_dispatch.py +0 -0
  108. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_e2e_connected.py +0 -0
  109. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_edit_replace_all.py +0 -0
  110. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_empty_response.py +0 -0
  111. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_events.py +0 -0
  112. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_extract.py +0 -0
  113. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_failure_loop.py +0 -0
  114. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_first_run_setup.py +0 -0
  115. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_gittools.py +0 -0
  116. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_glob_edit_edges.py +0 -0
  117. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_graphify_example.py +0 -0
  118. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_graphrag.py +0 -0
  119. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_graphrag_quoted_path.py +0 -0
  120. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_graphrag_sqlite.py +0 -0
  121. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_grep_and_read_robust.py +0 -0
  122. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_guards_and_tools.py +0 -0
  123. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_hallucinated_tools.py +0 -0
  124. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_leaked_tool_call.py +0 -0
  125. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_loop_detect.py +0 -0
  126. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_mcp.py +0 -0
  127. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_model_registry.py +0 -0
  128. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_oneshot_unreachable.py +0 -0
  129. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_overthink_interrupt.py +0 -0
  130. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_phases.py +0 -0
  131. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_plan_autocontinue.py +0 -0
  132. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_poam.py +0 -0
  133. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_progress.py +0 -0
  134. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_providers_unreachable.py +0 -0
  135. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_read_index.py +0 -0
  136. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_recipes.py +0 -0
  137. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_recovery.py +0 -0
  138. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_recovery_config.py +0 -0
  139. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_recovery_integration.py +0 -0
  140. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_repeated_outcome.py +0 -0
  141. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_repetition_interrupt.py +0 -0
  142. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_resume_events.py +0 -0
  143. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_resume_snapshot.py +0 -0
  144. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_rmf.py +0 -0
  145. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_rmf_graph.py +0 -0
  146. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_rmf_stig_graph.py +0 -0
  147. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_rolling_plan.py +0 -0
  148. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_runaway_repetition.py +0 -0
  149. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_screenshot.py +0 -0
  150. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_server_probe.py +0 -0
  151. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_skills.py +0 -0
  152. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_sqlite_events.py +0 -0
  153. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_stall_retry.py +0 -0
  154. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_stig.py +0 -0
  155. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_stop.py +0 -0
  156. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_streaming_newlines.py +0 -0
  157. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_subagent.py +0 -0
  158. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_subagents.py +0 -0
  159. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_suggest.py +0 -0
  160. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_system_prompt_help.py +0 -0
  161. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_task_state.py +0 -0
  162. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_timeline.py +0 -0
  163. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_todo.py +0 -0
  164. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_arg_coercion.py +0 -0
  165. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_arg_coercion_more.py +0 -0
  166. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_arg_parsing.py +0 -0
  167. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_canonical.py +0 -0
  168. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_policy.py +0 -0
  169. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_result.py +0 -0
  170. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tool_validate.py +0 -0
  171. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tools_undo.py +0 -0
  172. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_trajectory.py +0 -0
  173. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tui.py +0 -0
  174. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_tuning.py +0 -0
  175. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_verification_gate.py +0 -0
  176. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_viewimage.py +0 -0
  177. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_vision_input.py +0 -0
  178. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_windows_shell.py +0 -0
  179. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_worker_subagent.py +0 -0
  180. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/tests/test_write_content_coerce.py +0 -0
  181. {drydock_cli-3.0.166 → drydock_cli-3.0.170}/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.166
3
+ Version: 3.0.170
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
@@ -5,6 +5,7 @@ DryDock v3 — clean, provider-agnostic, no model-specific hacks.
5
5
  from __future__ import annotations
6
6
 
7
7
  import os
8
+ import time as _time
8
9
  from dataclasses import dataclass, field
9
10
  from typing import Generator
10
11
 
@@ -47,7 +48,7 @@ from drydock.compaction import (
47
48
  maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
48
49
  extract_server_n_ctx,
49
50
  )
50
- from drydock.loop_detect import LoopTracker
51
+ from drydock.loop_detect import LoopTracker, degenerate_argument
51
52
  from drydock.task_state import TaskState
52
53
  from drydock.verification import (
53
54
  looks_like_verification,
@@ -213,6 +214,13 @@ def run(
213
214
  verif_fail_counts: dict[str, int] = {} # failure-summary -> times seen (Epic J3)
214
215
  verification_text = "" # concatenated verification commands+results (coverage)
215
216
  coverage_nudges = 0 # bounded "your checks never touched X" nudges
217
+ # Time-aware effort governor (bench taxonomy: think-bound runs burn a 30-min
218
+ # window on 3-9 turns of reasoning). Once ANY turn's LLM call overruns the
219
+ # soft cap, every later turn in this request runs decisive: low effort, the
220
+ # forcing suffix, and a tight token cap. Sticky per request — persistent
221
+ # slowness doesn't flip-flop; a new user message resets it.
222
+ time_pressed = False
223
+ turn_soft_cap = float(config.get("turn_seconds_soft_cap", 240) or 0)
216
224
  recovery = RecoveryController(
217
225
  suppression_iterations=config.get("recovery_suppression_iterations",
218
226
  SUPPRESSION_ITERATIONS),
@@ -252,6 +260,15 @@ def run(
252
260
  retries = 0
253
261
  stall_retries = 0
254
262
  decisive = False # over-think interrupt: force a short, single-action turn
263
+ if time_pressed:
264
+ # A prior turn overran the soft cap — stay decisive for the request.
265
+ decisive = True
266
+ turn_config = dict(turn_config)
267
+ turn_config["reasoning_effort"] = "low"
268
+ cur = int(turn_config.get("max_tokens", 8192) or 8192)
269
+ turn_config["max_tokens"] = min(cur, _DECISIVE_MAX_TOKENS)
270
+ state.current_effort = "low"
271
+ _turn_t0 = _time.monotonic()
255
272
  while retries < 2:
256
273
  try:
257
274
  available = schemas()
@@ -266,6 +283,7 @@ def run(
266
283
  phase=str(state.task.phase),
267
284
  task_text=state.task.objective,
268
285
  max_tools=turn_config.get("max_tools", DEFAULT_MAX_TOOLS),
286
+ pin_tools=turn_config.get("pin_tools") or [],
269
287
  )
270
288
  for event in stream(
271
289
  model=turn_config["model"],
@@ -280,6 +298,19 @@ def run(
280
298
  yield event
281
299
  elif isinstance(event, AssistantTurn):
282
300
  assistant_turn = event
301
+ # Time-aware effort governor: this turn's wall time decides how
302
+ # hard the NEXT turns may think. Trigger once per request.
303
+ if (turn_soft_cap > 0 and not time_pressed
304
+ and _time.monotonic() - _turn_t0 > turn_soft_cap):
305
+ time_pressed = True
306
+ _emit(state, "effort_governor", engaged=True,
307
+ turn_seconds=round(_time.monotonic() - _turn_t0, 1),
308
+ cap=turn_soft_cap)
309
+ yield TextChunk(
310
+ f"\n[turn took {int(_time.monotonic() - _turn_t0)}s — "
311
+ "switching to decisive, low-effort steps for the rest of "
312
+ "this task to protect the time budget.]\n"
313
+ )
283
314
  break # success
284
315
  except StallRetry as _sr:
285
316
  # Stalled/over-thought past stall_retry_secs (wall-time), OR collapsed
@@ -632,9 +663,27 @@ def run(
632
663
  # (its result changes → different pair each time).
633
664
  _same_outcome = loop_tracker.outcome_count(tc["name"], tc["input"], _raw_result)
634
665
 
666
+ # Escalating-argument loop (full166 bench, build-pmars): a repeated
667
+ # unit amplifying INSIDE the argument (mv x_temp -> x_temp_temp ->
668
+ # ...) makes every call unique and every mv "productive", blinding
669
+ # repeat/cycle/streak detection. The degenerate argument itself is
670
+ # the tell — flag it, warn the model, and score it as a repeated
671
+ # outcome so the stall streak escalates normally.
672
+ _degen_src = ""
673
+ if isinstance(tc.get("input"), dict):
674
+ _degen_src = str(tc["input"].get("command") or tc["input"].get("file_path") or "")
675
+ _degen = degenerate_argument(_degen_src)
676
+ if _degen:
677
+ result = (
678
+ f"[NOTE: this argument contains '{_degen}' repeated many times "
679
+ f"— you are compounding the same rename/step instead of making "
680
+ f"progress. STOP amplifying it; go back to the original name/"
681
+ f"path and take a different approach.]\n" + result
682
+ )
683
+
635
684
  # Verification evidence: if this Bash call was a test/check/exec, parse
636
685
  # its result so the completion gate knows whether it PASSED, not just ran.
637
- _repeated_outcome = False
686
+ _repeated_outcome = bool(_degen)
638
687
  if tc["name"] == "Bash":
639
688
  _vcmd = (tc.get("input") or {}).get("command", "")
640
689
  if looks_like_verification(_vcmd):
@@ -674,7 +723,10 @@ def run(
674
723
  # information" means the same call returned the SAME result again
675
724
  # — a poll whose output changes each time is novel information and
676
725
  # must not accrue stall (found suppressing legit polling).
677
- changed_state=bool(tool_result and tool_result.changed_state),
726
+ # A degenerate-argument call gets NO state-change credit — the
727
+ # compounding rename "changes" the repo every time while going
728
+ # nowhere; crediting it would reset the stall streak forever.
729
+ changed_state=bool(tool_result and tool_result.changed_state) and not _degen,
678
730
  repeat_count=_same_outcome,
679
731
  failing_tests_before=_failing_before if _failing_after is not None else None,
680
732
  failing_tests_after=_failing_after,
@@ -73,10 +73,24 @@ DEFAULTS: dict[str, object] = {
73
73
  # even when interleaved actions look productive. Polling is exempt (its
74
74
  # result changes each time).
75
75
  "recovery_same_outcome_threshold": 6,
76
+ # Time-aware effort governor: once any single LLM turn takes longer than this
77
+ # many seconds, the rest of the request runs decisive (low reasoning effort,
78
+ # tight token cap) to protect the time budget — a think-bound task otherwise
79
+ # burns a whole window on a handful of giant reasoning turns. 0 = off.
80
+ "turn_seconds_soft_cap": 240,
76
81
  # Verified-trajectory export (RSI training-data collection). When set to a path,
77
82
  # drydock writes the full task trajectory (system prompt + transcript) there at
78
83
  # task end; a benchmark harness keeps only verifier-passing ones. Empty = off.
79
84
  "trajectory_file": "",
85
+ # Tool names dynamic tool selection must NEVER trim (tool_select.py), on top
86
+ # of the built-in core coding set — e.g. ["WebSearch", "WebFetch"] keeps the
87
+ # web tools surfaced even on tasks whose text never mentions the web. Names
88
+ # not in the registry are ignored.
89
+ "pin_tools": [],
90
+ # URL substrings the web tools refuse: WebSearch drops matching results,
91
+ # WebFetch declines matching URLs (with a plain message, never an error).
92
+ # Used to keep benchmark/solution sites out of harvested training runs.
93
+ "web_denylist": [],
80
94
  "theme": "harbor",
81
95
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
82
96
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -129,6 +129,34 @@ def runaway_repetition_len(
129
129
  return best
130
130
 
131
131
 
132
+ def degenerate_argument(text, *, min_unit: int = 4, min_reps: int = 4) -> str | None:
133
+ """The repeated unit if `text` contains a substring of >= min_unit chars
134
+ consecutively repeated >= min_reps times — the signature of an
135
+ ESCALATING-ARGUMENT loop (observed: a model renaming a directory
136
+ `x_temp` -> `x_temp_temp` -> ... until the path held `_temp` 8+ times; every
137
+ command was unique and every mv "changed state", so repeat/cycle/streak
138
+ detection were all blind to it). Returns None for healthy text. Checked
139
+ against tool COMMANDS/PATHS only — content bodies legitimately repeat."""
140
+ if not text or not isinstance(text, str) or len(text) < min_unit * min_reps:
141
+ return None
142
+ n = len(text)
143
+ # For each candidate unit length, slide and count consecutive repeats.
144
+ # Linear slide is fine: commands/paths are short (O(64·n) worst case).
145
+ for p in range(min_unit, min(64, n // min_reps) + 1):
146
+ for i in range(0, n - p * min_reps + 1):
147
+ unit = text[i:i + p]
148
+ if not unit.strip():
149
+ continue
150
+ reps = 1
151
+ j = i + p
152
+ while j + p <= n and text[j:j + p] == unit:
153
+ reps += 1
154
+ j += p
155
+ if reps >= min_reps:
156
+ return unit
157
+ return None
158
+
159
+
132
160
  class LoopTracker:
133
161
  """Counts identical tool calls across a run and produces advisory notes."""
134
162
 
@@ -49,10 +49,11 @@ _EXTERNAL_MUTATION = frozenset({
49
49
  _DISCOVERY_PHASES = frozenset({"understand", "discover", "plan"})
50
50
 
51
51
 
52
- def _priority(name: str, phase: str, task: str) -> int:
53
- """Higher = keep. CORE tools sort above everything; the rest rank by phase
54
- preference and task relevance so the cap drops the least-relevant first."""
55
- if name in CORE_TOOLS:
52
+ def _priority(name: str, phase: str, task: str, pinned: frozenset[str]) -> int:
53
+ """Higher = keep. CORE and pinned tools sort above everything; the rest rank
54
+ by phase preference and task relevance so the cap drops the least-relevant
55
+ first."""
56
+ if name in CORE_TOOLS or name in pinned:
56
57
  return 1000
57
58
  score = 50 # neutral default — unknown/uncategorised tools are still kept if room
58
59
  phase = (phase or "").lower()
@@ -79,19 +80,23 @@ def select_tools(
79
80
  phase: str = "",
80
81
  task_text: str = "",
81
82
  max_tools: int = DEFAULT_MAX_TOOLS,
83
+ pin_tools: list[str] | None = None,
82
84
  ) -> list[dict]:
83
85
  """Return the subset of `schemas` to expose this turn (PRD Epic F).
84
86
 
85
- Keeps every core coding tool, prefers read/search while exploring, and
86
- surfaces specialised families only when the task text mentions them —
87
- trimming to `max_tools` only when the full set exceeds it. Order-preserving
88
- for the kept tools so the model sees a stable list. Never raises."""
87
+ Keeps every core coding tool plus any `pin_tools` (config: user-chosen names
88
+ that must never be trimmed, e.g. the web tools for a research-heavy run),
89
+ prefers read/search while exploring, and surfaces specialised families only
90
+ when the task text mentions them — trimming to `max_tools` only when the
91
+ full set exceeds it. Order-preserving for the kept tools so the model sees
92
+ a stable list. Never raises."""
89
93
  if not schemas or max_tools <= 0 or len(schemas) <= max_tools:
90
94
  return schemas
91
95
 
96
+ pinned = frozenset(pin_tools or ())
92
97
  ranked = sorted(
93
98
  schemas,
94
- key=lambda s: _priority(s.get("name", ""), phase, task_text),
99
+ key=lambda s: _priority(s.get("name", ""), phase, task_text, pinned),
95
100
  reverse=True,
96
101
  )
97
102
  keep_names = {s.get("name") for s in ranked[:max_tools]}
@@ -1863,8 +1863,16 @@ def tool_graphadd(params: dict, config: dict) -> str:
1863
1863
  "satisfies (`control`,`rule` — a STIG rule satisfies a NIST control).")
1864
1864
 
1865
1865
 
1866
+ def _web_denied(url: str, config: dict) -> bool:
1867
+ deny = config.get("web_denylist") or []
1868
+ low = (url or "").lower()
1869
+ return any(str(pat).lower() in low for pat in deny if pat)
1870
+
1871
+
1866
1872
  def tool_websearch(params: dict, config: dict) -> str:
1867
- """Search the internet (DuckDuckGo). Read-only; clean message when offline."""
1873
+ """Search the internet (DuckDuckGo). Read-only; clean message when offline.
1874
+ Results matching config `web_denylist` are dropped before the model sees
1875
+ them (kept out of harvested trajectories)."""
1868
1876
  from drydock import web
1869
1877
 
1870
1878
  query = _as_str_arg(params.get("query")).strip()
@@ -1878,7 +1886,12 @@ def tool_websearch(params: dict, config: dict) -> str:
1878
1886
  results = web.search(query, k=max(1, min(k, 10)))
1879
1887
  except web.WebError as e:
1880
1888
  return f"Web search unavailable: {e}. You appear to be offline — answer from your own knowledge."
1881
- return web.format_search(query, results)
1889
+ kept = [r for r in results if not _web_denied(r.get("url", ""), config)]
1890
+ dropped = len(results) - len(kept)
1891
+ out = web.format_search(query, kept)
1892
+ if dropped:
1893
+ out += f"\n({dropped} result(s) withheld by site policy.)"
1894
+ return out
1882
1895
 
1883
1896
 
1884
1897
  def tool_webfetch(params: dict, config: dict) -> str:
@@ -1888,6 +1901,8 @@ def tool_webfetch(params: dict, config: dict) -> str:
1888
1901
  url = _as_str_arg(params.get("url")).strip()
1889
1902
  if not url:
1890
1903
  return "Error: `WebFetch` needs a `url`."
1904
+ if _web_denied(url, config):
1905
+ return "That site is not available in this session. Solve the task from other sources."
1891
1906
  try:
1892
1907
  mc = int(params.get("max_chars") or 6000)
1893
1908
  except (TypeError, ValueError):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.166
3
+ Version: 3.0.170
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
@@ -96,10 +96,12 @@ tests/test_context_limit_config.py
96
96
  tests/test_context_limit_issue25.py
97
97
  tests/test_criteria_coverage.py
98
98
  tests/test_cycling_detection.py
99
+ tests/test_degenerate_argument.py
99
100
  tests/test_detect.py
100
101
  tests/test_dispatch.py
101
102
  tests/test_e2e_connected.py
102
103
  tests/test_edit_replace_all.py
104
+ tests/test_effort_governor.py
103
105
  tests/test_empty_response.py
104
106
  tests/test_events.py
105
107
  tests/test_extract.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.166"
10
+ version = "3.0.170"
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,78 @@
1
+ """Regression for the full166 build-pmars finding: an ESCALATING-ARGUMENT loop
2
+ (mv x_temp -> x_temp_temp -> ...) makes every call unique and every mv look
3
+ productive — the amplifying unit inside the argument is the only tell."""
4
+ from __future__ import annotations
5
+
6
+ import tempfile
7
+
8
+ import drydock.agent as agent_mod
9
+ from drydock.agent import AgentState, run
10
+ from drydock.loop_detect import degenerate_argument
11
+ from drydock.providers import AssistantTurn
12
+
13
+
14
+ def test_detects_the_pmars_spiral():
15
+ path = "/app/pmars_source" + "_temp" * 9
16
+ assert degenerate_argument(f"mv {path} /app/x") == "_temp"
17
+
18
+
19
+ def test_healthy_commands_pass():
20
+ for cmd in (
21
+ "mv /app/pmars_source_temp /app/pmars", # one _temp is fine
22
+ "python3 -m pytest tests/test_loop_detect.py -q",
23
+ "ls -la /app && cat /app/README.md",
24
+ "grep -rn 'def main' src/",
25
+ "echo hello world",
26
+ ):
27
+ assert degenerate_argument(cmd) is None, cmd
28
+
29
+
30
+ def test_whitespace_units_ignored():
31
+ assert degenerate_argument("echo 'a" + " " * 20 + "b'") is None
32
+
33
+
34
+ def _renaming_spiral(base):
35
+ """Model that alternates mv (arg grows a _temp each time) with ls — the
36
+ exact build-pmars pattern: every command unique, every mv 'productive'."""
37
+ def stream(**kw):
38
+ n = stream.n
39
+ stream.n += 1
40
+ if n % 2 == 0:
41
+ suffix = "_temp" * (4 + n // 2)
42
+ tc = {"id": str(n), "name": "Bash",
43
+ "input": {"command": f"mv {base} {base}{suffix} 2>/dev/null; true"}}
44
+ else:
45
+ tc = {"id": str(n), "name": "Bash", "input": {"command": f"ls {base} 2>/dev/null; true"}}
46
+ return iter([AssistantTurn("", [tc], 1, 1)])
47
+ stream.n = 0
48
+ return stream
49
+
50
+
51
+ def test_spiral_is_stopped_by_escalation(monkeypatch):
52
+ d = tempfile.mkdtemp()
53
+ monkeypatch.setattr(agent_mod, "stream", _renaming_spiral(f"{d}/src"))
54
+ st = AgentState()
55
+ list(run("extract the source and build it", st,
56
+ {"model": "m", "cwd": d, "verify_gate": False, "max_turns": 60}, "sys"))
57
+ # The spiral must be cut well short of the 60-turn ceiling, with the
58
+ # degenerate-argument note visible to the model along the way.
59
+ assert st.turn_count < 40, f"spiral ran {st.turn_count} turns"
60
+ tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
61
+ assert any("repeated many times" in c for c in tool_msgs)
62
+
63
+
64
+ def test_healthy_distinct_work_unaffected(monkeypatch):
65
+ d = tempfile.mkdtemp()
66
+ seq = [
67
+ AssistantTurn("", [{"id": "1", "name": "Bash",
68
+ "input": {"command": f"mkdir -p {d}/a && touch {d}/a/x"}}], 1, 1),
69
+ AssistantTurn("", [{"id": "2", "name": "Bash",
70
+ "input": {"command": f"mv {d}/a {d}/b"}}], 1, 1),
71
+ AssistantTurn("done", [], 1, 1),
72
+ ]
73
+ monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
74
+ st = AgentState()
75
+ list(run("reorganize", st, {"model": "m", "cwd": d, "verify_gate": False}, "sys"))
76
+ assert any(m.get("content") == "done" for m in st.messages)
77
+ tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
78
+ assert not any("repeated many times" in c for c in tool_msgs)
@@ -0,0 +1,81 @@
1
+ """Tests for the time-aware effort governor: a turn overrunning the soft cap
2
+ flips the rest of the request to decisive (low effort, tight token cap, forcing
3
+ suffix). Sticky per request; cap=0 disables."""
4
+ from __future__ import annotations
5
+
6
+ import tempfile
7
+ import time
8
+
9
+ import drydock.agent as agent_mod
10
+ from drydock.agent import _DECISIVE_MAX_TOKENS, AgentState, run
11
+ from drydock.providers import AssistantTurn
12
+
13
+
14
+ def _slow_then_fast(captured):
15
+ """Turn 1 sleeps past the cap; later turns record the config they were
16
+ called with."""
17
+ def stream(**kw):
18
+ n = stream.n
19
+ stream.n += 1
20
+ captured.append({
21
+ "effort": kw.get("config", {}).get("reasoning_effort"),
22
+ "max_tokens": kw.get("config", {}).get("max_tokens"),
23
+ "decisive_suffix": "DECISIVE" in (kw.get("system") or "").upper()
24
+ or "single" in (kw.get("system") or "").lower(),
25
+ })
26
+ if n == 0:
27
+ time.sleep(0.05) # overruns the tiny test cap
28
+ return iter([AssistantTurn(
29
+ "", [{"id": "1", "name": "Bash", "input": {"command": "echo hi"}}], 1, 1)])
30
+ if n == 1:
31
+ return iter([AssistantTurn(
32
+ "", [{"id": "2", "name": "Bash", "input": {"command": "echo again"}}], 1, 1)])
33
+ return iter([AssistantTurn("done", [], 1, 1)])
34
+ stream.n = 0
35
+ return stream
36
+
37
+
38
+ def test_overrun_turn_flips_request_to_decisive(monkeypatch):
39
+ captured: list = []
40
+ monkeypatch.setattr(agent_mod, "stream", _slow_then_fast(captured))
41
+ st = AgentState()
42
+ list(run("do things", st,
43
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
44
+ "turn_seconds_soft_cap": 0.01}, "sys"))
45
+ assert len(captured) >= 3
46
+ # turn 1 ran normally; turns 2+ are decisive
47
+ assert captured[1]["effort"] == "low"
48
+ assert captured[1]["max_tokens"] == _DECISIVE_MAX_TOKENS
49
+ assert captured[2]["effort"] == "low"
50
+ # the run still completes normally
51
+ assert any(m.get("content") == "done" for m in st.messages)
52
+
53
+
54
+ def test_cap_zero_disables_governor(monkeypatch):
55
+ captured: list = []
56
+ monkeypatch.setattr(agent_mod, "stream", _slow_then_fast(captured))
57
+ st = AgentState()
58
+ list(run("do things", st,
59
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
60
+ "turn_seconds_soft_cap": 0}, "sys"))
61
+ # no decisive flip: later turns keep whatever effort the normal logic picked
62
+ assert captured[1]["max_tokens"] != _DECISIVE_MAX_TOKENS
63
+
64
+
65
+ def test_fast_turns_never_trigger(monkeypatch):
66
+ captured: list = []
67
+
68
+ def fast(**kw):
69
+ n = fast.n; fast.n += 1
70
+ captured.append({"max_tokens": kw.get("config", {}).get("max_tokens")})
71
+ if n == 0:
72
+ return iter([AssistantTurn(
73
+ "", [{"id": "1", "name": "Bash", "input": {"command": "echo hi"}}], 1, 1)])
74
+ return iter([AssistantTurn("done", [], 1, 1)])
75
+ fast.n = 0
76
+ monkeypatch.setattr(agent_mod, "stream", fast)
77
+ st = AgentState()
78
+ list(run("quick", st,
79
+ {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
80
+ "turn_seconds_soft_cap": 240}, "sys"))
81
+ assert captured[1]["max_tokens"] != _DECISIVE_MAX_TOKENS
@@ -54,6 +54,37 @@ def test_unresolved_external_mutation_is_unsafe(tmp_path):
54
54
  assert any("verify before retrying" in w for w in info.warnings)
55
55
 
56
56
 
57
+ def test_restore_falls_back_to_snapshotted_events_path(tmp_path):
58
+ # Snapshot records the session's event-trace path; restore() without an
59
+ # explicit event_trace must still find the unresolved calls through it.
60
+ ev = EventLog(tmp_path / "t.jsonl")
61
+ ev.emit("tool_started", name="Bash", effect="local_mutation")
62
+ st = AgentState()
63
+ st.task = TaskState.from_objective("x")
64
+ st.messages = [{"role": "user", "content": "x"}]
65
+ st.events = ev
66
+ path = tmp_path / "s.json"
67
+ resume.save_snapshot(st, {"model": "gemma4", "cwd": "/repo", "session_id": "sess"}, path)
68
+ info = resume.restore(path)
69
+ assert len(info.unresolved) == 1
70
+ assert info.unresolved[0]["name"] == "Bash"
71
+
72
+
73
+ def test_explicit_event_trace_overrides_snapshotted_path(tmp_path):
74
+ ev_snap = EventLog(tmp_path / "snap.jsonl")
75
+ ev_snap.emit("tool_started", name="Read", effect="read_only")
76
+ st = AgentState()
77
+ st.task = TaskState.from_objective("x")
78
+ st.messages = [{"role": "user", "content": "x"}]
79
+ st.events = ev_snap
80
+ path = tmp_path / "s.json"
81
+ resume.save_snapshot(st, {"model": "gemma4", "cwd": "/repo", "session_id": "sess"}, path)
82
+ ev_other = EventLog(tmp_path / "other.jsonl")
83
+ ev_other.emit("tool_started", name="Write", effect="local_mutation")
84
+ info = resume.restore(path, ev_other.path)
85
+ assert [u["name"] for u in info.unresolved] == ["Write"]
86
+
87
+
57
88
  def test_apply_to_state_restores_session(tmp_path):
58
89
  path = _snapshot(tmp_path, [{"role": "user", "content": "build a thing"},
59
90
  {"role": "assistant", "content": "working"}])
@@ -84,3 +84,26 @@ def test_empty_and_degenerate_inputs():
84
84
  assert select_tools([], max_tools=12) == []
85
85
  s = _schemas(["Read", "Bash"])
86
86
  assert select_tools(s, max_tools=0) == s # cap<=0 disables trimming
87
+
88
+
89
+ def test_pin_tools_survive_trimming_on_unrelated_task():
90
+ # Web tools are normally first out on a non-web task; pinning keeps them.
91
+ out = select_tools(_schemas(ALL), phase="implement",
92
+ task_text="fix the failing unit test in parser.py",
93
+ max_tools=10, pin_tools=["WebSearch", "WebFetch"])
94
+ names = _names(out)
95
+ assert "WebSearch" in names and "WebFetch" in names
96
+ assert CORE_TOOLS <= names
97
+
98
+
99
+ def test_unpinned_web_tools_still_trimmed_on_unrelated_task():
100
+ out = select_tools(_schemas(ALL), phase="implement",
101
+ task_text="fix the failing unit test in parser.py",
102
+ max_tools=10)
103
+ assert "WebSearch" not in _names(out)
104
+
105
+
106
+ def test_pin_tools_unknown_names_ignored():
107
+ out = select_tools(_schemas(ALL), max_tools=10,
108
+ pin_tools=["NoSuchTool"])
109
+ assert "NoSuchTool" not in _names(out)
@@ -103,3 +103,31 @@ def test_fetch_truncates_long_text(monkeypatch):
103
103
 
104
104
  def test_format_search_empty():
105
105
  assert "No web results" in web.format_search("rare query", [])
106
+
107
+
108
+ def test_websearch_denylist_drops_matching_results(monkeypatch):
109
+ from drydock.tools import tool_websearch
110
+ monkeypatch.setattr(web, "search", lambda q, k=5: [
111
+ {"url": "https://github.com/laude-institute/terminal-bench/x", "title": "sol", "snippet": ""},
112
+ {"url": "https://docs.python.org/3/", "title": "docs", "snippet": ""},
113
+ ])
114
+ out = tool_websearch({"query": "q"}, {"web_denylist": ["laude-institute", "terminal-bench"]})
115
+ assert "docs.python.org" in out
116
+ assert "laude-institute" not in out
117
+ assert "withheld" in out
118
+
119
+
120
+ def test_webfetch_denylist_declines_without_error():
121
+ from drydock.tools import tool_webfetch
122
+ out = tool_webfetch({"url": "https://github.com/laude-institute/terminal-bench/solution.sh"},
123
+ {"web_denylist": ["terminal-bench"]})
124
+ assert "not available" in out
125
+ assert "Error" not in out
126
+
127
+
128
+ def test_web_tools_no_denylist_config_unaffected(monkeypatch):
129
+ from drydock.tools import tool_websearch
130
+ monkeypatch.setattr(web, "search", lambda q, k=5: [
131
+ {"url": "https://example.com", "title": "t", "snippet": "s"}])
132
+ out = tool_websearch({"query": "q"}, {})
133
+ assert "example.com" in out and "withheld" not in out
File without changes
File without changes
File without changes