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.
- {drydock_cli-3.1.36/drydock_cli.egg-info → drydock_cli-3.1.38}/PKG-INFO +1 -1
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/agent.py +37 -2
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/cli.py +9 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/compaction.py +13 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/providers.py +111 -18
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/swarm.py +25 -5
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/app.py +20 -7
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tuning.py +25 -6
- {drydock_cli-3.1.36 → drydock_cli-3.1.38/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/pyproject.toml +1 -1
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_providers_unreachable.py +51 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_swarm.py +28 -0
- drydock_cli-3.1.38/tests/test_text_only_model.py +78 -0
- drydock_cli-3.1.38/tests/test_truncated_turn.py +45 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tuning.py +11 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/LICENSE +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/NOTICE +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/README.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/__init__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/__main__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/advisor.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/asr.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/bottleneck.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/budget.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/document-canvas.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/nist-csf.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/capacity.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/cci.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/__init__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/attention.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/channels.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/comms/events.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/config.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/detect.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/doccanvas.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/eratchet.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/events.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/extract.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/gittools.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/graphrag.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/groundtruth.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/guards.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/jobs.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mcp.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission_cli.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/mission_run.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/pdfredbox.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/phases.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/poam.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/predictions.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/progress.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/ratchet.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/recipes.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/recovery.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/resume.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/rmf.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/skills.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/stig.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/subagents.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/suggest.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/task_state.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_policy.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_result.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_select.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tool_validate.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/trajectory.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/verification.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock/web.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/setup.cfg +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_advisor.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_approval.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_asr.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_back_command.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_bottleneck.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_budget.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_capacity.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cci.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_comms.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_compaction.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_config.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_context_limit_issue25.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_criteria_coverage.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_cycling_detection.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_declared_deps.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_degenerate_argument.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_detect.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_doccanvas.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_edit_thrash_and_compaction.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_effort_governor.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_eratchet.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_events.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_extract.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_gittools.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_graphrag_sqlite.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_groundtruth_predictions.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_jobs.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_knowledge_tool_available.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_main_api_key.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mcp.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission_cli.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_mission_run.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_model_registry.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_pdfredbox.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_phases.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_poam.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_progress.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_ratchet.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_ratchet_offer.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_read_index.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recipes.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery_config.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_recovery_integration.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_repeated_outcome.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_events.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_restore.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_resume_snapshot.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_rolling_plan.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_skills.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_sqlite_events.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stig.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_stop.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_subagent.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_subagents.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_suggest.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_task_state.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_timeline.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_todo.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_canonical.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_policy.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_result.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_select.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tool_validate.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_trajectory.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_tui.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_verification_gate.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_worker_subagent.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.1.36 → drydock_cli-3.1.38}/tests/test_xccdf.py +0 -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":
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
can surface it in the UI, then strips the
|
|
165
|
-
Returns ("", text) when no thinking block is
|
|
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:
|
|
@@ -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.
|
|
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"
|