drydock-cli 3.1.36__tar.gz → 3.1.38__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 (221) hide show
  1. {drydock_cli-3.1.36/drydock_cli.egg-info → drydock_cli-3.1.38}/PKG-INFO +1 -1
  2. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/agent.py +37 -2
  3. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/cli.py +9 -0
  4. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/compaction.py +13 -0
  5. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/providers.py +111 -18
  6. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/swarm.py +25 -5
  7. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/app.py +20 -7
  8. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tuning.py +25 -6
  9. {drydock_cli-3.1.36 → drydock_cli-3.1.38/drydock_cli.egg-info}/PKG-INFO +1 -1
  10. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/SOURCES.txt +2 -0
  11. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/pyproject.toml +1 -1
  12. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_providers_unreachable.py +51 -0
  13. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_swarm.py +28 -0
  14. drydock_cli-3.1.38/tests/test_text_only_model.py +78 -0
  15. drydock_cli-3.1.38/tests/test_truncated_turn.py +45 -0
  16. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tuning.py +11 -0
  17. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/LICENSE +0 -0
  18. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/NOTICE +0 -0
  19. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/README.md +0 -0
  20. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/__init__.py +0 -0
  21. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/__main__.py +0 -0
  22. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/advisor.py +0 -0
  23. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/asr.py +0 -0
  24. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/bash_safety.py +0 -0
  25. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/bottleneck.py +0 -0
  26. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/budget.py +0 -0
  27. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/__init__.py +0 -0
  28. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/document-canvas.md +0 -0
  29. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-data.md +0 -0
  30. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-debug.md +0 -0
  31. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-finetune.md +0 -0
  32. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-metrics.md +0 -0
  33. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-rl.md +0 -0
  34. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-train.md +0 -0
  35. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  36. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/nist-csf.md +0 -0
  37. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-categorize.md +0 -0
  38. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-control.md +0 -0
  39. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-poam.md +0 -0
  40. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-review.md +0 -0
  41. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/stig-assess.md +0 -0
  42. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/stig-remediate.md +0 -0
  43. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/capacity.py +0 -0
  44. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/cci.py +0 -0
  45. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/__init__.py +0 -0
  46. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/attention.py +0 -0
  47. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/channels.py +0 -0
  48. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/events.py +0 -0
  49. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/config.py +0 -0
  50. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/detect.py +0 -0
  51. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/doccanvas.py +0 -0
  52. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/eratchet.py +0 -0
  53. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/events.py +0 -0
  54. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/extract.py +0 -0
  55. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/gittools.py +0 -0
  56. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/graphrag.py +0 -0
  57. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/groundtruth.py +0 -0
  58. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/guards.py +0 -0
  59. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/jobs.py +0 -0
  60. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/loop_detect.py +0 -0
  61. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mcp.py +0 -0
  62. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission.py +0 -0
  63. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission_cli.py +0 -0
  64. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission_run.py +0 -0
  65. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/pdfredbox.py +0 -0
  66. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/phases.py +0 -0
  67. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/poam.py +0 -0
  68. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/predictions.py +0 -0
  69. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/progress.py +0 -0
  70. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/ratchet.py +0 -0
  71. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/recipes.py +0 -0
  72. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/recovery.py +0 -0
  73. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/resume.py +0 -0
  74. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/rmf.py +0 -0
  75. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/rmf_graph.py +0 -0
  76. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/skills.py +0 -0
  77. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/stig.py +0 -0
  78. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/subagents.py +0 -0
  79. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/suggest.py +0 -0
  80. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/task_state.py +0 -0
  81. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_policy.py +0 -0
  82. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_registry.py +0 -0
  83. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_result.py +0 -0
  84. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_select.py +0 -0
  85. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_validate.py +0 -0
  86. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tools/__init__.py +0 -0
  87. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/trajectory.py +0 -0
  88. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/__init__.py +0 -0
  89. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/approval.py +0 -0
  90. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/messages.py +0 -0
  91. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/widgets.py +0 -0
  92. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/verification.py +0 -0
  93. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/web.py +0 -0
  94. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/dependency_links.txt +0 -0
  95. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/entry_points.txt +0 -0
  96. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/requires.txt +0 -0
  97. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/top_level.txt +0 -0
  98. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/setup.cfg +0 -0
  99. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_advisor.py +0 -0
  100. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_approval.py +0 -0
  101. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_asr.py +0 -0
  102. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_back_command.py +0 -0
  103. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_background.py +0 -0
  104. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_binary_output.py +0 -0
  105. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_crossplatform.py +0 -0
  106. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_output_bounding.py +0 -0
  107. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_process_group.py +0 -0
  108. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_safety.py +0 -0
  109. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_sanitize.py +0 -0
  110. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_shell.py +0 -0
  111. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_stdin.py +0 -0
  112. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_stop_partial.py +0 -0
  113. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_timeout_network.py +0 -0
  114. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_timeout_param.py +0 -0
  115. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bottleneck.py +0 -0
  116. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_budget.py +0 -0
  117. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_capacity.py +0 -0
  118. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cci.py +0 -0
  119. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cli_agents.py +0 -0
  120. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_comms.py +0 -0
  121. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_compact_command.py +0 -0
  122. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_compaction.py +0 -0
  123. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_config.py +0 -0
  124. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_config_migration.py +0 -0
  125. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_context_limit_config.py +0 -0
  126. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_context_limit_issue25.py +0 -0
  127. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_criteria_coverage.py +0 -0
  128. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cycling_detection.py +0 -0
  129. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_declared_deps.py +0 -0
  130. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_degenerate_argument.py +0 -0
  131. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_detect.py +0 -0
  132. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_dispatch.py +0 -0
  133. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_doccanvas.py +0 -0
  134. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_e2e_connected.py +0 -0
  135. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_edit_replace_all.py +0 -0
  136. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_edit_thrash_and_compaction.py +0 -0
  137. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_effort_governor.py +0 -0
  138. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_empty_response.py +0 -0
  139. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_eratchet.py +0 -0
  140. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_events.py +0 -0
  141. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_extract.py +0 -0
  142. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_failure_loop.py +0 -0
  143. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_first_run_setup.py +0 -0
  144. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_gittools.py +0 -0
  145. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_glob_edit_edges.py +0 -0
  146. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphify_example.py +0 -0
  147. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag.py +0 -0
  148. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag_quoted_path.py +0 -0
  149. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag_sqlite.py +0 -0
  150. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_grep_and_read_robust.py +0 -0
  151. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_groundtruth_predictions.py +0 -0
  152. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_guards_and_tools.py +0 -0
  153. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_hallucinated_tools.py +0 -0
  154. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_jobs.py +0 -0
  155. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_knowledge_tool_available.py +0 -0
  156. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_leaked_tool_call.py +0 -0
  157. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_loop_detect.py +0 -0
  158. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_main_api_key.py +0 -0
  159. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mcp.py +0 -0
  160. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission.py +0 -0
  161. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission_cli.py +0 -0
  162. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission_run.py +0 -0
  163. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_model_registry.py +0 -0
  164. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_oneshot_unreachable.py +0 -0
  165. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_overthink_interrupt.py +0 -0
  166. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_pdfredbox.py +0 -0
  167. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_phases.py +0 -0
  168. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_plan_autocontinue.py +0 -0
  169. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_poam.py +0 -0
  170. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_progress.py +0 -0
  171. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_ratchet.py +0 -0
  172. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_ratchet_offer.py +0 -0
  173. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_read_index.py +0 -0
  174. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recipes.py +0 -0
  175. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery.py +0 -0
  176. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery_config.py +0 -0
  177. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery_integration.py +0 -0
  178. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_repeated_outcome.py +0 -0
  179. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_repetition_interrupt.py +0 -0
  180. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_events.py +0 -0
  181. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_restore.py +0 -0
  182. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_snapshot.py +0 -0
  183. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf.py +0 -0
  184. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf_graph.py +0 -0
  185. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf_stig_graph.py +0 -0
  186. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rolling_plan.py +0 -0
  187. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_runaway_repetition.py +0 -0
  188. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_screenshot.py +0 -0
  189. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_server_probe.py +0 -0
  190. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_skills.py +0 -0
  191. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_sqlite_events.py +0 -0
  192. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stall_retry.py +0 -0
  193. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stig.py +0 -0
  194. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stop.py +0 -0
  195. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_streaming_newlines.py +0 -0
  196. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_subagent.py +0 -0
  197. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_subagents.py +0 -0
  198. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_suggest.py +0 -0
  199. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_system_prompt_help.py +0 -0
  200. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_task_state.py +0 -0
  201. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_timeline.py +0 -0
  202. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_todo.py +0 -0
  203. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_coercion.py +0 -0
  204. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_coercion_more.py +0 -0
  205. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_parsing.py +0 -0
  206. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_canonical.py +0 -0
  207. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_policy.py +0 -0
  208. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_result.py +0 -0
  209. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_select.py +0 -0
  210. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_validate.py +0 -0
  211. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tools_undo.py +0 -0
  212. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_trajectory.py +0 -0
  213. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tui.py +0 -0
  214. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_verification_gate.py +0 -0
  215. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_viewimage.py +0 -0
  216. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_vision_input.py +0 -0
  217. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_web_tools.py +0 -0
  218. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_windows_shell.py +0 -0
  219. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_worker_subagent.py +0 -0
  220. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_write_content_coerce.py +0 -0
  221. {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.36
3
+ Version: 3.1.38
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
@@ -47,6 +47,7 @@ from drydock.tools import register_all
47
47
  register_all()
48
48
  from drydock.compaction import (
49
49
  maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
50
+ is_text_only_model_error,
50
51
  extract_server_n_ctx,
51
52
  )
52
53
  from drydock.loop_detect import LoopTracker, degenerate_argument
@@ -216,6 +217,7 @@ def run(
216
217
  leaked_call_retries = 0
217
218
  plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
218
219
  empty_response_nudges = 0 # consecutive "you returned nothing" nudges
220
+ truncated_nudges = 0 # "you hit the output limit without acting" nudges
219
221
  # Safety valve for a degenerate loop: the SAME tool call run over and over
220
222
  # with the SAME result — success OR failure (seen failing a Write 160×, and
221
223
  # re-running an identical passing `pytest` 92× for 25 min). The advisory
@@ -409,6 +411,17 @@ def run(
409
411
  if retries >= 2:
410
412
  raise
411
413
  yield TextChunk("\n[context limit hit — compacting and retrying...]\n")
414
+ elif is_text_only_model_error(err):
415
+ # The model can't take images at all. Stop attaching them for this
416
+ # endpoint and retry the step as text — a vision-less model can
417
+ # still do the task (read the file with tools), it just can't SEE it.
418
+ from drydock.providers import mark_text_only
419
+ if not mark_text_only(config):
420
+ raise
421
+ yield TextChunk(
422
+ "\n[This model is text-only — sending image references as plain "
423
+ "text from now on and retrying...]\n"
424
+ )
412
425
  elif is_image_load_error(err):
413
426
  # Corrupt/truncated/unsupported image the server couldn't decode —
414
427
  # end the turn cleanly rather than dumping the raw 400.
@@ -424,10 +437,15 @@ def run(
424
437
  if assistant_turn is None:
425
438
  break
426
439
 
427
- # Record assistant message
440
+ # Record assistant message. A turn cut off at max_tokens with no tool call is
441
+ # (almost always) runaway planning — keep only its tail so it doesn't eat the
442
+ # context window on every later request.
443
+ _content = assistant_turn.text
444
+ if assistant_turn.truncated and not assistant_turn.tool_calls and len(_content) > 2000:
445
+ _content = "[…long planning cut off at the output limit…]\n" + _content[-1500:]
428
446
  state.messages.append({
429
447
  "role": "assistant",
430
- "content": assistant_turn.text,
448
+ "content": _content,
431
449
  "tool_calls": assistant_turn.tool_calls,
432
450
  })
433
451
 
@@ -444,6 +462,22 @@ def run(
444
462
  # case nudge it to use the real function interface and retry, instead
445
463
  # of ending the turn with nothing done. Capped so it can never spin.
446
464
  if not assistant_turn.tool_calls:
465
+ # Hit the output-token limit before acting (long planning / a whole-file write
466
+ # that didn't fit). That is not "done" — nudge it to act in smaller steps.
467
+ if assistant_turn.truncated and truncated_nudges < 3:
468
+ truncated_nudges += 1
469
+ _emit(state, "truncated_turn", nudge=truncated_nudges)
470
+ yield TextChunk("\n[output limit reached before any action — nudging to act in smaller steps]\n")
471
+ state.messages.append({
472
+ "role": "user",
473
+ "content": (
474
+ "[SYSTEM] Your reply hit the output-length limit before you took any "
475
+ "action, so nothing was done. Do not re-plan. Act NOW with one tool "
476
+ "call, and keep each call small: implement or edit ONE function or "
477
+ "section at a time (Edit), then continue with the next."
478
+ ),
479
+ })
480
+ continue
447
481
  if assistant_turn.had_leaked_call and leaked_call_retries < 2:
448
482
  leaked_call_retries += 1
449
483
  state.messages.append({
@@ -918,6 +952,7 @@ def run(
918
952
  # bounds back-to-back stalls).
919
953
  plan_continue_nudges = 0
920
954
  empty_response_nudges = 0
955
+ truncated_nudges = 0
921
956
 
922
957
  # Nudge: if past 15 tool calls without any edits, inject gentle guidance
923
958
  if tool_call_count == 15 and not session_has_edited and config.get("force_first_tool"):
@@ -507,6 +507,15 @@ def main():
507
507
  # the PRD" content fought the system prompt and risked turning a plain
508
508
  # greeting into a runaway build. The TUI reads this from config; CLI modes
509
509
  # call load_project_instructions directly (see run_interactive/run_oneshot).
510
+ # A context_limit above the server's real window overflows it mid-task and the
511
+ # ctx gauge lies. Unless the user pinned --context-limit, ask the server once and
512
+ # adopt its window when smaller (never grow past config). Best-effort, short timeout.
513
+ if not args.context_limit and config.get("base_url"):
514
+ from drydock.providers import probe_server_context
515
+ n_ctx = probe_server_context(config["base_url"], timeout=2.0)
516
+ if n_ctx and n_ctx < int(config.get("context_limit") or 65536):
517
+ config["context_limit"] = n_ctx
518
+
510
519
  config["project_instructions"] = load_project_instructions()
511
520
  # In a git project, drop a discoverable per-project prompt template at
512
521
  # <project>/.drydock/system_prompt.md (inert until edited). Then load the
@@ -51,6 +51,19 @@ def extract_server_n_ctx(err: str) -> int | None:
51
51
  return None
52
52
 
53
53
 
54
+ def is_text_only_model_error(err: str) -> bool:
55
+ """Whether a provider 400 says the MODEL can't take images at all (a text-only
56
+ model, e.g. vLLM "X is not a multimodal model", llama.cpp "image input is not
57
+ supported" when no --mmproj is loaded). Distinct from a bad image: the fix is to
58
+ drop attachments and retry, not to ask the user for a different file."""
59
+ e = (err or "").lower()
60
+ return (
61
+ "not a multimodal model" in e
62
+ or "image input is not supported" in e
63
+ or ("does not support" in e and ("image" in e or "multimodal" in e or "vision" in e))
64
+ )
65
+
66
+
54
67
  def is_image_load_error(err: str) -> bool:
55
68
  """Whether a provider 400 is the server failing to decode an attached image
56
69
  (corrupt / truncated / unsupported), so the agent ends the turn with a clean
@@ -17,7 +17,10 @@ from typing import Generator
17
17
  # HTTP client does NOT interrupt an in-flight blocking read, so instead we wait
18
18
  # on a future and, when the user cancels, return immediately and let the
19
19
  # orphaned request finish (and be discarded) in the background.
20
- _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
20
+ # Sized for in-process swarms: every in-flight request holds a thread (a streamed one holds
21
+ # a second for its reader), and abandoned requests keep theirs until they finish — 8 threads
22
+ # made an 8-agent swarm in one TUI queue behind itself.
23
+ _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=64, thread_name_prefix="llm")
21
24
 
22
25
 
23
26
  class _StopRequested(Exception):
@@ -39,7 +42,9 @@ class RepetitionDetected(StallRetry):
39
42
  re-issue would just loop again, so the agent goes straight to decisive mode."""
40
43
 
41
44
  from drydock.loop_detect import runaway_repetition_len
42
- from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
45
+ from drydock.tuning import (
46
+ extract_thinking, split_think_tags, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming,
47
+ )
43
48
 
44
49
  # ── Provider registry ─────────────────────────────────────────────────────
45
50
 
@@ -137,19 +142,57 @@ def _friendly_timeout(base_url: str, timeout_s: float) -> str:
137
142
  )
138
143
 
139
144
 
145
+ def _friendly_connect_timeout(base_url: str) -> str:
146
+ return (
147
+ f"Could not open a connection to the model server at {base_url} in time "
148
+ f"(connect timeout, retried). The server may be overloaded or the network slow.\n"
149
+ f" Your last message was not lost — just send it again."
150
+ )
151
+
152
+
153
+ def _is_connect_timeout(e: BaseException) -> bool:
154
+ """An SDK APITimeoutError covers BOTH connect and read timeouts; tell them apart."""
155
+ import httpx
156
+
157
+ cur: BaseException | None = e
158
+ for _ in range(5):
159
+ if cur is None:
160
+ break
161
+ if isinstance(cur, (httpx.ConnectTimeout, httpx.PoolTimeout)):
162
+ return True
163
+ cur = cur.__cause__ or cur.__context__
164
+ return False
165
+
166
+
167
+ _CONNECT_RETRIES = 2 # transient connect timeouts (busy server / slow port proxy)
168
+ _CONNECT_BACKOFF_S = (2.0, 5.0)
169
+
170
+
171
+ def _timeout_error(e: BaseException, base_url: str, timeout_s: float) -> "LLMUnreachable":
172
+ if _is_connect_timeout(e):
173
+ return LLMUnreachable(_friendly_connect_timeout(base_url))
174
+ return LLMUnreachable(_friendly_timeout(base_url, timeout_s))
175
+
176
+
140
177
  def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s: float = 600.0):
141
178
  """Call chat.completions.create, mapping a connection failure to a clean
142
179
  LLMUnreachable instead of a raw traceback / 12-minute hang."""
143
180
  import openai
144
181
 
145
- try:
146
- return client.chat.completions.create(**kwargs)
147
- # APITimeoutError subclasses APIConnectionError — catch it FIRST so a slow
148
- # (but alive) server gets the accurate "timed out" message, not "down".
149
- except openai.APITimeoutError as e:
150
- raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
151
- except openai.APIConnectionError as e:
152
- raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
182
+ import time as _time
183
+
184
+ for attempt in range(_CONNECT_RETRIES + 1):
185
+ try:
186
+ return client.chat.completions.create(**kwargs)
187
+ # APITimeoutError subclasses APIConnectionError — catch it FIRST so a slow
188
+ # (but alive) server gets the accurate "timed out" message, not "down".
189
+ except openai.APITimeoutError as e:
190
+ if _is_connect_timeout(e) and attempt < _CONNECT_RETRIES:
191
+ _time.sleep(_CONNECT_BACKOFF_S[attempt])
192
+ continue
193
+ raise _timeout_error(e, base_url, timeout_s) from e
194
+ except openai.APIConnectionError as e:
195
+ raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
153
196
 
154
197
 
155
198
  def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
@@ -165,6 +208,7 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
165
208
 
166
209
  fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
167
210
  start = _time.monotonic()
211
+ connect_retries = 0
168
212
  while True:
169
213
  try:
170
214
  return fut.result(timeout=0.2)
@@ -175,7 +219,17 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
175
219
  raise StallRetry(stall_secs) # orphan the wedged request; caller retries
176
220
  # APITimeoutError subclasses APIConnectionError — catch it FIRST.
177
221
  except openai.APITimeoutError as e:
178
- raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
222
+ if _is_connect_timeout(e) and connect_retries < _CONNECT_RETRIES:
223
+ # transient: the server/proxy was slow to ACCEPT — re-issue (cancellable wait)
224
+ wait_until = _time.monotonic() + _CONNECT_BACKOFF_S[connect_retries]
225
+ connect_retries += 1
226
+ while _time.monotonic() < wait_until:
227
+ if cancel is not None and cancel.is_set():
228
+ raise _StopRequested
229
+ _time.sleep(0.1)
230
+ fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
231
+ continue
232
+ raise _timeout_error(e, base_url, timeout_s) from e
179
233
  except openai.APIConnectionError as e:
180
234
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
181
235
 
@@ -244,6 +298,7 @@ class AssistantTurn:
244
298
  input_tokens: int
245
299
  output_tokens: int
246
300
  had_leaked_call: bool = False # model emitted a tool call as text, not a call
301
+ truncated: bool = False # generation stopped at max_tokens (finish_reason == "length")
247
302
 
248
303
 
249
304
  # ── Tool-call argument parsing ────────────────────────────────────────────
@@ -430,6 +485,31 @@ def _image_url_block(path: str) -> dict | None:
430
485
  return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}
431
486
 
432
487
 
488
+ # (base_url, model) pairs whose server rejected image input as text-only. Process-wide
489
+ # so every later request (and turn) sends plain text instead of re-hitting the 400.
490
+ _TEXT_ONLY: set[tuple[str, str]] = set()
491
+
492
+
493
+ def _vision_key(config: dict) -> tuple[str, str]:
494
+ prov = PROVIDERS.get(config.get("provider", "vllm"), PROVIDERS["vllm"])
495
+ base_url = config.get("base_url") or prov.get("base_url", "http://localhost:8000/v1")
496
+ return (str(base_url).rstrip("/"), str(config.get("model") or ""))
497
+
498
+
499
+ def mark_text_only(config: dict) -> bool:
500
+ """Record that this endpoint's model can't take images. Returns True if newly marked."""
501
+ key = _vision_key(config)
502
+ if key in _TEXT_ONLY:
503
+ return False
504
+ _TEXT_ONLY.add(key)
505
+ return True
506
+
507
+
508
+ def vision_enabled(config: dict) -> bool:
509
+ """False when vision is disabled in config or the server proved text-only."""
510
+ return config.get("vision", True) is not False and _vision_key(config) not in _TEXT_ONLY
511
+
512
+
433
513
  def _content_with_images(content):
434
514
  """Turn text that references on-disk image paths into a multimodal content
435
515
  list ([text, image_url…]); text without resolvable images passes through
@@ -451,15 +531,17 @@ def _content_with_images(content):
451
531
  _user_content_with_images = _content_with_images
452
532
 
453
533
 
454
- def messages_to_openai(messages: list, system: str) -> list:
455
- """Convert neutral messages to OpenAI API format."""
534
+ def messages_to_openai(messages: list, system: str, vision: bool = True) -> list:
535
+ """Convert neutral messages to OpenAI API format. vision=False sends image
536
+ references as plain text (text-only model)."""
456
537
  # value type is mixed (str content, multimodal list content, tool_calls list)
457
538
  result: list[dict] = [{"role": "system", "content": system}]
458
539
  for m in messages:
459
540
  role = m["role"]
460
541
  if role == "user":
461
542
  result.append({"role": "user",
462
- "content": _user_content_with_images(m["content"])})
543
+ "content": (_user_content_with_images(m["content"])
544
+ if vision else m["content"])})
463
545
  elif role == "assistant":
464
546
  msg = {"role": "assistant", "content": m.get("content") or None}
465
547
  tcs = m.get("tool_calls", [])
@@ -482,7 +564,7 @@ def messages_to_openai(messages: list, system: str) -> list:
482
564
  # to look at — attach it so the model SEES it (servers read images
483
565
  # from tool messages too, verified). Only ViewImage, so an unrelated
484
566
  # tool mentioning a .png path never balloons the request.
485
- if m.get("name") == "ViewImage" and isinstance(content, str):
567
+ if vision and m.get("name") == "ViewImage" and isinstance(content, str):
486
568
  content = _content_with_images(content)
487
569
  result.append({
488
570
  "role": "tool",
@@ -559,7 +641,7 @@ def stream(
559
641
  # dict(config) shallow-copy) so the TUI's STOP can reach this exact client.
560
642
  config.setdefault("_abort", {})["client"] = client
561
643
 
562
- oai_messages = messages_to_openai(messages, system)
644
+ oai_messages = messages_to_openai(messages, system, vision=vision_enabled(config))
563
645
 
564
646
  has_tools = bool(tool_schemas)
565
647
  # Gemma (and any local server whose model name we can't trust) corrupts
@@ -621,6 +703,7 @@ def stream(
621
703
  return
622
704
 
623
705
  text = ""
706
+ truncated = False
624
707
  tool_buf: dict = {} # index → {id, name, args}
625
708
  in_tok = out_tok = 0
626
709
  # Runaway-repetition guard: a weak model can collapse into streaming one
@@ -643,10 +726,15 @@ def stream(
643
726
 
644
727
  choice = chunk.choices[0]
645
728
  delta = choice.delta
729
+ if getattr(choice, "finish_reason", None) == "length":
730
+ truncated = True
646
731
 
647
732
  if delta.content:
648
733
  # Strip any leaked thinking-token markers (best-effort per chunk).
649
734
  chunk_text = strip_thinking_tokens(delta.content)
735
+ # <think>/</think> tags usually arrive as whole tokens; keep them in the
736
+ # buffer (split off at the end) but don't display the bare markers.
737
+ shown = chunk_text.replace("<think>", "").replace("</think>", "")
650
738
  # Keep ANY non-empty chunk — including whitespace-only ones. A
651
739
  # `.strip()` guard here used to DROP newline-only chunks (the blank
652
740
  # line a model streams between markdown blocks arrives as its own
@@ -654,7 +742,8 @@ def stream(
654
742
  # skips chunks that stripping emptied (e.g. a lone thinking marker).
655
743
  if chunk_text:
656
744
  text += chunk_text
657
- yield TextChunk(chunk_text)
745
+ if shown:
746
+ yield TextChunk(shown)
658
747
  # Throttled: only scan once text is long enough and every ~200
659
748
  # new chars, so the common path pays almost nothing.
660
749
  if len(text) - _rep_checked_at >= 200:
@@ -705,7 +794,10 @@ def stream(
705
794
  "input": inp,
706
795
  })
707
796
 
708
- yield AssistantTurn(text, tool_calls, in_tok, out_tok)
797
+ # A template-opened <think> leaves "reasoning…</think>answer" in the content —
798
+ # keep only the answer in the recorded turn (it was already shown while streaming).
799
+ _, text = split_think_tags(text)
800
+ yield AssistantTurn(text, tool_calls, in_tok, out_tok, truncated=truncated)
709
801
 
710
802
 
711
803
  def _complete_nonstreaming(
@@ -759,4 +851,5 @@ def _complete_nonstreaming(
759
851
  yield AssistantTurn(
760
852
  text, tool_calls, in_tok, out_tok,
761
853
  had_leaked_call=had_leak and not tool_calls,
854
+ truncated=getattr(choice, "finish_reason", None) == "length",
762
855
  )
@@ -764,12 +764,14 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
764
764
  runner: AgentRunner = default_agent_runner, verify: VerifyFn | None = None,
765
765
  on_event: "Callable[[str, dict], None] | None" = None,
766
766
  share: bool = False, waves: int = 2,
767
- swarm_id: str | None = None) -> SwarmResult:
767
+ swarm_id: str | None = None, cancel=None) -> SwarmResult:
768
768
  """Coordinate a parallel-strategy swarm end to end (§24 Parallel, the MVP default):
769
769
  decompose into N diversity-injected Builders (§15), fan them out concurrently over one
770
770
  shared inference server (§22), verify each candidate independently (§18), and converge on
771
771
  the best by evidence (§20). Never raises on an individual worker — failures are contained
772
- (§32). `runner`/`verify` are injectable for testing without a live model."""
772
+ (§32). `runner`/`verify` are injectable for testing without a live model. `cancel` (a
773
+ threading.Event) stops the swarm: running agents end their turn loop, queued ones never
774
+ start, and verification is skipped — whatever was produced is still judged."""
773
775
  from concurrent.futures import ThreadPoolExecutor
774
776
 
775
777
  repo = repo_root(cwd)
@@ -777,6 +779,12 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
777
779
  raise ValueError("swarm needs a git repository (run `git init` first) for worktree "
778
780
  "isolation")
779
781
  bc = base_config or {}
782
+ if cancel is not None:
783
+ # workers inherit this as their agent-loop stop signal (agent.run checks _cancel)
784
+ base_config = bc = {**bc, "_cancel": cancel}
785
+
786
+ def _cancelled() -> bool:
787
+ return cancel is not None and cancel.is_set()
780
788
  # Auto-size to the server's real concurrency (§21/§22): hammer a big box, tone down a
781
789
  # small one. N = min(hardware concurrency, task-demand, budget) — never maximized.
782
790
  detected = 0
@@ -830,10 +838,19 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
830
838
 
831
839
  # 2. fan out workers over the shared server (§22). Each is isolated + safe. `extra_sys`
832
840
  # carries peers' notes (blackboard consumption, §10) for waves after the first.
841
+ # Workers are full coding agents: give them drydock's own coding prompt (tool use,
842
+ # verify-before-done, act-don't-plan), then the swarm role + diversity angle on top.
843
+ # Without it a worker got only the 3-line role text and tended to plan until it ran
844
+ # out of output tokens instead of editing.
845
+ from drydock.tuning import system_prompt_for_model
846
+ base_sys = system_prompt_for_model(str(bc.get("model") or ""), worker=True) + "\n\n"
847
+
833
848
  def _spawn(i: int, extra_sys: str) -> WorkerOutcome:
849
+ if _cancelled():
850
+ return WorkerOutcome(agent=f"agent-{i + 1}", ok=False, error="cancelled")
834
851
  angle, sysprompt = angles[i]
835
852
  out = run_worker(bb, repo, base_ref, f"agent-{i + 1}", objective,
836
- role="builder", system_prompt=sysprompt + extra_sys,
853
+ role="builder", system_prompt=base_sys + sysprompt + extra_sys,
837
854
  base_config=base_config, max_turns=max_turns,
838
855
  max_tool_calls=max_tool_calls, runner=runner)
839
856
  _ev("worker_done", agent=out.agent, ok=out.ok, files=out.files_changed,
@@ -842,7 +859,7 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
842
859
 
843
860
  def _verify_unscored() -> None:
844
861
  """Independently score any candidate not yet verified (§18)."""
845
- if verify is None:
862
+ if verify is None or _cancelled():
846
863
  return
847
864
  for c in bb.candidates():
848
865
  if c.commit and c.tests_total == 0:
@@ -858,7 +875,7 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
858
875
  # notes the next wave reads are already scored.
859
876
  per = -(-n // max(2, waves)) # ceil(n / waves)
860
877
  i0, w = 0, 0
861
- while i0 < n:
878
+ while i0 < n and not _cancelled():
862
879
  idxs = list(range(i0, min(i0 + per, n)))
863
880
  i0 += per
864
881
  notes = _peer_notes(bb.candidates()) if w > 0 else ""
@@ -878,6 +895,9 @@ def run_swarm(cwd: str | Path, objective: str, *, agents: "int | str" = 4,
878
895
  _verify_unscored()
879
896
 
880
897
  # 4. judge on evidence (§20) and check convergence (§19).
898
+ if _cancelled():
899
+ bb.emit("SWARM_CANCELLED")
900
+ _ev("cancelled")
881
901
  final = bb.candidates()
882
902
  winner = judge(final)
883
903
  converged = bool(winner and winner.tests_total > 0 and winner.tests_passed == winner.tests_total)
@@ -181,9 +181,15 @@ class DrydockApp(App):
181
181
  if self._erx is not None:
182
182
  self._erx["cancel"].set()
183
183
  self._erx = None
184
+ swarm_cancel = (self._swarm or {}).get("cancel")
185
+ swarm_active = swarm_cancel is not None
186
+ if swarm_cancel is not None:
187
+ swarm_cancel.set()
184
188
  if not self._busy:
185
189
  if erx_active:
186
190
  self._info("⏹ eratchet stopping…")
191
+ if swarm_active:
192
+ self._info("⏹ swarm stopping — agents finish their current step…")
187
193
  return
188
194
  self._cancel.set()
189
195
  self._abort_inflight()
@@ -414,9 +420,11 @@ class DrydockApp(App):
414
420
  used = self._ctx_tokens
415
421
  pct = min(100, round(used / limit * 100)) if limit else 0
416
422
  ctx = f"ctx {used:,}/{limit // 1000}k ({pct}%)"
417
- flag = "⚓ working" if self._busy else "⚓ ready"
423
+ # A swarm / eratchet runs off-thread without setting _busy — still show it as running.
424
+ bg = "swarm" if self._swarm else ("eratchet" if self._erx else "")
425
+ flag = "⚓ working" if self._busy else (f"⚓ {bg} running" if bg else "⚓ ready")
418
426
  # Busy → show the interrupt keys; idle → the quit hint.
419
- keys = "Esc/Ctrl+C stop" if self._busy else "Ctrl+C×2 quit"
427
+ keys = "Esc/Ctrl+C stop" if (self._busy or bg) else "Ctrl+C×2 quit"
420
428
  # Show the task phase once it's past the default (understand).
421
429
  ph = getattr(self.state, "task", None)
422
430
  phase = f" · {ph.phase}" if (ph and ph.is_set() and ph.phase != "understand") else ""
@@ -515,7 +523,7 @@ class DrydockApp(App):
515
523
  # actual attach happens at the API boundary in providers).
516
524
  from drydock import providers
517
525
  imgs = providers.detect_image_paths(text)
518
- if imgs:
526
+ if imgs and providers.vision_enabled(self.config):
519
527
  import os as _os
520
528
  names = ", ".join(_os.path.basename(p) for p in imgs)
521
529
  self._info(f"📎 attached {len(imgs)} image(s) for the model to see: {names}")
@@ -1230,17 +1238,21 @@ class DrydockApp(App):
1230
1238
  harness escalating on its own (no user command); `verify_cmd` pins the checker
1231
1239
  (e.g. the one the single agent kept failing). agents="auto" sizes to the server's
1232
1240
  detected concurrency (hammer a big box, tone down a small one)."""
1233
- self._swarm = {"active": True}
1241
+ import threading
1242
+
1243
+ cancel = threading.Event()
1244
+ self._swarm = {"active": True, "cancel": cancel}
1245
+ self._refresh_status()
1234
1246
  head = ("⚙ the harness is escalating to a parallel swarm" if auto
1235
1247
  else f"⇶ /swarm launching: {objective!r}")
1236
1248
  count = f"{agents}" if isinstance(agents, int) else "auto-sized"
1237
1249
  self._info(f"{head} — {count} agents exploring in isolated worktrees "
1238
1250
  "(in-process, your working tree is left alone). Esc to stop.")
1239
1251
  self.run_worker(
1240
- lambda: self._swarm_worker(cwd, objective, agents, verify_cmd), thread=True)
1252
+ lambda: self._swarm_worker(cwd, objective, agents, verify_cmd, cancel), thread=True)
1241
1253
 
1242
1254
  def _swarm_worker(self, cwd: str, objective: str, agents: "int | str",
1243
- verify_cmd: str | None = None) -> None:
1255
+ verify_cmd: str | None = None, cancel=None) -> None:
1244
1256
  """Off-thread: drive run_swarm, streaming milestones into this session's transcript."""
1245
1257
  from drydock import swarm as swarmmod
1246
1258
 
@@ -1267,7 +1279,8 @@ class DrydockApp(App):
1267
1279
  # share=True: later-wave agents read peers' verified attempts off the blackboard
1268
1280
  # and compare notes, instead of exploring fully blind (§10).
1269
1281
  res = swarmmod.run_swarm(cwd, objective, agents=agents, base_config=self.config,
1270
- verify_cmd=verify_cmd, on_event=on_event, share=True)
1282
+ verify_cmd=verify_cmd, on_event=on_event, share=True,
1283
+ cancel=cancel)
1271
1284
  if res.converged and res.winner is not None:
1272
1285
  tail = (f"\nApply it: git cherry-pick {res.winner.commit}")
1273
1286
  elif res.winner is not None:
@@ -157,13 +157,31 @@ def is_gemma(model: str | None) -> bool:
157
157
  return bool(model) and "gemma" in model.lower()
158
158
 
159
159
 
160
+ def split_think_tags(text: str) -> tuple[str, str]:
161
+ """Split <think>…</think> reasoning (Qwen/Nemotron/DeepSeek style) off the answer.
162
+
163
+ Chat templates often open <think> in the PROMPT, so the completion carries only
164
+ the closing tag: everything before the last </think> is reasoning. Returns
165
+ ("", text) when there is no closing tag."""
166
+ if not text or "</think>" not in text:
167
+ return "", text
168
+ thinking, answer = text.rsplit("</think>", 1)
169
+ thinking = thinking.replace("<think>", "").strip()
170
+ return thinking, answer.lstrip()
171
+
172
+
160
173
  def extract_thinking(text: str) -> tuple[str, str]:
161
174
  """Return (thinking_content, cleaned_text).
162
175
 
163
- Extracts the content of Gemma's <|channel>…<channel|> blocks so callers
164
- can surface it in the UI, then strips the markers from the returned text.
165
- Returns ("", text) when no thinking block is present.
176
+ Extracts the content of Gemma's <|channel>…<channel|> blocks (or a
177
+ <think>…</think> span) so callers can surface it in the UI, then strips the
178
+ markers from the returned text. Returns ("", text) when no thinking block is
179
+ present.
166
180
  """
181
+ if text and "</think>" in text:
182
+ thinking, answer = split_think_tags(text)
183
+ inner, answer = extract_thinking(answer)
184
+ return "\n\n".join(t for t in (thinking, inner) if t), answer
167
185
  if not text or "<|channel>" not in text:
168
186
  return "", text
169
187
  spans = _THINKING_RE.findall(text)
@@ -310,10 +328,11 @@ def _shell_env_note() -> str:
310
328
  )
311
329
 
312
330
 
313
- def system_prompt_for_model(model: str | None) -> str:
314
- """Return the system prompt best suited to the model."""
331
+ def system_prompt_for_model(model: str | None, *, worker: bool = False) -> str:
332
+ """Return the system prompt best suited to the model. worker=True drops the
333
+ user-facing slash-command help (a background swarm worker never talks to the user)."""
315
334
  base = _GEMMA_SYSTEM_PROMPT if is_gemma(model) else _DEFAULT_SYSTEM_PROMPT
316
- return base + _DRYDOCK_COMMANDS_HELP + _shell_env_note()
335
+ return base + ("" if worker else _DRYDOCK_COMMANDS_HELP) + _shell_env_note()
317
336
 
318
337
 
319
338
  def thinking_level_for_turn(turn_count: int, is_user_turn: bool) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.1.36
3
+ Version: 3.1.38
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
@@ -193,6 +193,7 @@ tests/test_suggest.py
193
193
  tests/test_swarm.py
194
194
  tests/test_system_prompt_help.py
195
195
  tests/test_task_state.py
196
+ tests/test_text_only_model.py
196
197
  tests/test_timeline.py
197
198
  tests/test_todo.py
198
199
  tests/test_tool_arg_coercion.py
@@ -205,6 +206,7 @@ tests/test_tool_select.py
205
206
  tests/test_tool_validate.py
206
207
  tests/test_tools_undo.py
207
208
  tests/test_trajectory.py
209
+ tests/test_truncated_turn.py
208
210
  tests/test_tui.py
209
211
  tests/test_tuning.py
210
212
  tests/test_verification_gate.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.1.36"
10
+ version = "3.1.38"
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"