drydock-cli 3.1.13__tar.gz → 3.1.14__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.13/drydock_cli.egg-info → drydock_cli-3.1.14}/PKG-INFO +1 -1
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/agent.py +48 -1
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/compaction.py +11 -2
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/providers.py +5 -3
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/app.py +71 -20
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tuning.py +16 -4
- {drydock_cli-3.1.13 → drydock_cli-3.1.14/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/pyproject.toml +1 -1
- drydock_cli-3.1.14/tests/test_edit_thrash_and_compaction.py +123 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tui.py +36 -3
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/LICENSE +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/NOTICE +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/README.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/__init__.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/__main__.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/advisor.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/budget.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/document-canvas.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-assess.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-cap.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-evidence.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/fiar-readiness.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/nist-csf.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/cci.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/cli.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/config.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/detect.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/doccanvas.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/events.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/extract.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/fiar.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/gittools.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/graphrag.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/guards.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/mcp.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/phases.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/poam.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/progress.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/ratchet.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/recipes.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/recovery.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/resume.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/rmf.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/skills.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/stig.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/subagents.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/suggest.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/task_state.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_policy.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_result.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_select.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tool_validate.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/trajectory.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/verification.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock/web.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/setup.cfg +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_advisor.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_approval.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_back_command.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_budget.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cci.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_compaction.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_config.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_context_limit_issue25.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_criteria_coverage.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_cycling_detection.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_degenerate_argument.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_detect.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_doccanvas.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_effort_governor.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_events.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_extract.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_fiar.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_gittools.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_graphrag_sqlite.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_mcp.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_model_registry.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_phases.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_poam.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_progress.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_ratchet.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_read_index.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recipes.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery_config.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_recovery_integration.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_repeated_outcome.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_events.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_restore.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_resume_snapshot.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_rolling_plan.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_skills.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_sqlite_events.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stig.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_stop.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_subagent.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_subagents.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_suggest.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_task_state.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_timeline.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_todo.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_canonical.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_policy.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_result.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_select.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tool_validate.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_trajectory.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_tuning.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_verification_gate.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_worker_subagent.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.1.13 → drydock_cli-3.1.14}/tests/test_xccdf.py +0 -0
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
import time as _time
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from dataclasses import dataclass, field
|
|
10
11
|
from typing import Generator
|
|
11
12
|
|
|
@@ -124,6 +125,24 @@ def drop_last_turn(messages: list) -> bool:
|
|
|
124
125
|
return False
|
|
125
126
|
|
|
126
127
|
|
|
128
|
+
def _numbered_snapshot(path: str, max_lines: int = 200, max_chars: int = 8000) -> str:
|
|
129
|
+
"""Line-numbered current content of *path* for the failed-edit thrash breaker,
|
|
130
|
+
so the model can copy an exact span instead of re-guessing. Bounded and
|
|
131
|
+
never raises — an unreadable path returns an explanatory line."""
|
|
132
|
+
try:
|
|
133
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
except Exception as e: # noqa: BLE001
|
|
135
|
+
return f"(could not read {path}: {e}. Use Read to inspect it.)"
|
|
136
|
+
lines = text.splitlines()
|
|
137
|
+
body = lines[:max_lines]
|
|
138
|
+
out = "\n".join(f"{i + 1:>5}\t{ln}" for i, ln in enumerate(body))
|
|
139
|
+
if len(lines) > max_lines:
|
|
140
|
+
out += f"\n[... {len(lines) - max_lines} more lines — use Read for the rest ...]"
|
|
141
|
+
if len(out) > max_chars:
|
|
142
|
+
out = out[:max_chars] + "\n[... truncated — use Read for the rest ...]"
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
127
146
|
# ── Agent loop ────────────────────────────────────────────────────────────
|
|
128
147
|
|
|
129
148
|
def run(
|
|
@@ -212,6 +231,7 @@ def run(
|
|
|
212
231
|
) # scores each action; flags a stalled run
|
|
213
232
|
prev_failing = None # failing-test count from the previous verification (delta)
|
|
214
233
|
verif_fail_counts: dict[str, int] = {} # failure-summary -> times seen (Epic J3)
|
|
234
|
+
edit_fail_counts: dict[str, int] = {} # file_path -> consecutive "old_string not found" misses
|
|
215
235
|
verification_text = "" # concatenated verification commands+results (coverage)
|
|
216
236
|
coverage_nudges = 0 # bounded "your checks never touched X" nudges
|
|
217
237
|
# Time-aware effort governor (bench taxonomy: think-bound runs burn a 30-min
|
|
@@ -691,9 +711,36 @@ def run(
|
|
|
691
711
|
f"path and take a different approach.]\n" + result
|
|
692
712
|
)
|
|
693
713
|
|
|
714
|
+
# Failed-edit thrash breaker: a weak model that guesses a WRONG
|
|
715
|
+
# old_string gets "old_string not found", then re-guesses a DIFFERENT
|
|
716
|
+
# wrong old_string — so exact-repeat detection (keyed on identical
|
|
717
|
+
# input) never fires and it loops "over and over" (seen live on
|
|
718
|
+
# gemma/vLLM). Track "not found" misses per FILE regardless of the
|
|
719
|
+
# old_string; after a few, stop hinting and HARD-inject the file's
|
|
720
|
+
# current content (line-numbered) so the next edit can only copy an
|
|
721
|
+
# exact span. Also feed the stall machinery so recovery escalates.
|
|
722
|
+
_edit_thrash = False
|
|
723
|
+
if tc["name"] in ("Edit", "edit") and isinstance(tc.get("input"), dict):
|
|
724
|
+
_efp = str(tc["input"].get("file_path") or "")
|
|
725
|
+
if "old_string not found" in (result or ""):
|
|
726
|
+
edit_fail_counts[_efp] = edit_fail_counts.get(_efp, 0) + 1
|
|
727
|
+
if edit_fail_counts[_efp] >= 3:
|
|
728
|
+
_edit_thrash = True
|
|
729
|
+
_snap = _numbered_snapshot(_efp)
|
|
730
|
+
result = (
|
|
731
|
+
f"[STOP: {edit_fail_counts[_efp]} edits to {_efp} have failed "
|
|
732
|
+
f"with 'old_string not found' — you keep guessing text that is "
|
|
733
|
+
f"not in the file. Do NOT guess again. The file's CURRENT "
|
|
734
|
+
f"content is below, line-numbered. Copy an EXACT span (WITHOUT "
|
|
735
|
+
f"the line-number prefixes) as old_string, or take a different "
|
|
736
|
+
f"approach.]\n{_snap}\n\n" + (result or "")
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
edit_fail_counts.pop(_efp, None) # a clean edit resets that file
|
|
740
|
+
|
|
694
741
|
# Verification evidence: if this Bash call was a test/check/exec, parse
|
|
695
742
|
# its result so the completion gate knows whether it PASSED, not just ran.
|
|
696
|
-
_repeated_outcome = bool(_degen)
|
|
743
|
+
_repeated_outcome = bool(_degen) or _edit_thrash
|
|
697
744
|
if tc["name"] == "Bash":
|
|
698
745
|
_vcmd = (tc.get("input") or {}).get("command", "")
|
|
699
746
|
if looks_like_verification(_vcmd):
|
|
@@ -110,15 +110,24 @@ def _truncate_tool_call_args(messages: list, max_len: int) -> None:
|
|
|
110
110
|
inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def compact(messages: list, context_limit: int = 131072
|
|
113
|
+
def compact(messages: list, context_limit: int = 131072,
|
|
114
|
+
target_frac: float = 0.45) -> list:
|
|
114
115
|
"""Compact messages to fit within context limit.
|
|
115
116
|
|
|
116
117
|
Strategy:
|
|
117
118
|
1. First pass: truncate long tool results to 800 chars
|
|
118
119
|
2. Second pass: drop oldest tool results if still over limit
|
|
119
120
|
3. Always keep: first user message, last 8 messages
|
|
121
|
+
|
|
122
|
+
target_frac is the fraction of the window to compact DOWN to. It must be
|
|
123
|
+
meaningfully below maybe_compact's TRIGGER (0.60): compacting to the same
|
|
124
|
+
0.60 we trigger at frees almost nothing, so context stays pinned just over
|
|
125
|
+
the line and re-compacts every turn while never gaining real headroom — the
|
|
126
|
+
"it doesn't compact when it needs to" symptom. Targeting 0.45 leaves durable
|
|
127
|
+
headroom so a long /loop (history accumulating across iterations) doesn't
|
|
128
|
+
creep back over the wall on the very next turn.
|
|
120
129
|
"""
|
|
121
|
-
target = int(context_limit *
|
|
130
|
+
target = int(context_limit * target_frac) # durable headroom below the 0.60 trigger
|
|
122
131
|
|
|
123
132
|
# Pass 1: Truncate long tool results
|
|
124
133
|
for m in messages:
|
|
@@ -562,9 +562,11 @@ def stream(
|
|
|
562
562
|
oai_messages = messages_to_openai(messages, system)
|
|
563
563
|
|
|
564
564
|
has_tools = bool(tool_schemas)
|
|
565
|
-
# Gemma
|
|
566
|
-
#
|
|
567
|
-
|
|
565
|
+
# Gemma (and any local server whose model name we can't trust) corrupts
|
|
566
|
+
# tool-call JSON when streamed; force non-streaming on tool turns. Passing
|
|
567
|
+
# provider closes the gap where a Gemma behind vLLM is served under a name
|
|
568
|
+
# lacking 'gemma'. config["force_stream"] overrides for debugging.
|
|
569
|
+
do_stream = config.get("force_stream") or use_streaming(model, has_tools, provider)
|
|
568
570
|
|
|
569
571
|
kwargs = {
|
|
570
572
|
"model": model,
|
|
@@ -890,28 +890,52 @@ class DrydockApp(App):
|
|
|
890
890
|
self._info("\n".join(lines))
|
|
891
891
|
|
|
892
892
|
def _cmd_loop(self, arg: str) -> None:
|
|
893
|
-
"""/loop
|
|
894
|
-
|
|
893
|
+
"""/loop [count|*] <prompt> — re-submit <prompt> after each turn finishes.
|
|
894
|
+
|
|
895
|
+
<count> 1–1000 runs that many iterations; `*` (or omitting the count
|
|
896
|
+
entirely) runs UNBOUNDED until Esc/`/stop`. The loop also stops early on
|
|
897
|
+
its own when the model reports the task done (replies LOOP_DONE) or when
|
|
898
|
+
an iteration makes no progress (identical actions + reply as the prior)."""
|
|
895
899
|
parts = arg.split(maxsplit=1)
|
|
896
|
-
|
|
900
|
+
first = parts[0] if parts else ""
|
|
901
|
+
if first == "*":
|
|
902
|
+
total = None # unbounded
|
|
903
|
+
prompt = (parts[1] if len(parts) > 1 else "").strip()
|
|
904
|
+
elif first.isdigit():
|
|
905
|
+
total = max(1, min(int(first), 1000))
|
|
906
|
+
prompt = (parts[1] if len(parts) > 1 else "").strip()
|
|
907
|
+
else:
|
|
908
|
+
total = None # no count given → run until stopped
|
|
909
|
+
prompt = arg.strip()
|
|
910
|
+
if not prompt:
|
|
897
911
|
self._info(
|
|
898
|
-
"usage: /loop
|
|
899
|
-
"failing test and run pytest\n
|
|
912
|
+
"usage: /loop [count|*] <prompt> e.g. /loop 5 fix the next "
|
|
913
|
+
"failing test and run pytest\n"
|
|
914
|
+
"(count 1–1000, or `*`/no count = until Esc/stop)"
|
|
900
915
|
)
|
|
901
916
|
return
|
|
902
|
-
count = max(1, min(int(parts[0]), 50))
|
|
903
|
-
prompt = parts[1].strip()
|
|
904
|
-
if not prompt:
|
|
905
|
-
self._info("usage: /loop <count> <prompt>")
|
|
906
|
-
return
|
|
907
917
|
if self._busy:
|
|
908
918
|
self._info("A turn is already running — stop it (Esc) before starting a loop.")
|
|
909
919
|
return
|
|
910
|
-
self._repeat = {"prompt": prompt, "
|
|
911
|
-
|
|
920
|
+
self._repeat = {"prompt": prompt, "total": total, "done": 1, "fingerprint": None}
|
|
921
|
+
label = f"{total}×" if total else "until Esc"
|
|
922
|
+
self._info(f"↻ looping {label} (Esc to stop): {prompt}")
|
|
912
923
|
self._mount(UserMessage(prompt))
|
|
913
924
|
self._begin(prompt)
|
|
914
925
|
|
|
926
|
+
def _loop_prompt(self, rep: dict) -> str:
|
|
927
|
+
"""The prompt for a continuation iteration — the base task plus a small
|
|
928
|
+
iteration-awareness header so the model knows it is iterating and can end
|
|
929
|
+
the loop honestly instead of re-doing finished work."""
|
|
930
|
+
n = rep["done"]
|
|
931
|
+
of = f"/{rep['total']}" if rep["total"] else ""
|
|
932
|
+
return (
|
|
933
|
+
f"{rep['prompt']}\n\n"
|
|
934
|
+
f"[loop iteration {n}{of} — continue the task from the CURRENT state. "
|
|
935
|
+
f"If everything the task asks for is already complete, reply exactly "
|
|
936
|
+
f"LOOP_DONE and take no tool actions.]"
|
|
937
|
+
)
|
|
938
|
+
|
|
915
939
|
def _cmd_ratchet(self, arg: str) -> None:
|
|
916
940
|
"""/ratchet <goal> [--verify "<cmd>"] [--rounds N] [--fitness auto|exitcode|<regex>]
|
|
917
941
|
|
|
@@ -1737,14 +1761,41 @@ class DrydockApp(App):
|
|
|
1737
1761
|
if self._ratchet and not self._cancel.is_set():
|
|
1738
1762
|
self.run_worker(self._ratchet_step, thread=True)
|
|
1739
1763
|
return
|
|
1740
|
-
# /loop: re-run the prompt until the
|
|
1741
|
-
# stop clears self._repeat). Queued user prompts take priority
|
|
1742
|
-
if self._repeat and
|
|
1743
|
-
self._repeat
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1764
|
+
# /loop: re-run the prompt until the count is exhausted OR the loop stops
|
|
1765
|
+
# itself (Esc/stop clears self._repeat). Queued user prompts take priority.
|
|
1766
|
+
if self._repeat and not self._cancel.is_set():
|
|
1767
|
+
rep = self._repeat
|
|
1768
|
+
unbounded = rep["total"] is None
|
|
1769
|
+
more = unbounded or rep["done"] < rep["total"]
|
|
1770
|
+
if more:
|
|
1771
|
+
# Early stop 1: the model reported the task complete.
|
|
1772
|
+
if "LOOP_DONE" in self._last_assistant_text():
|
|
1773
|
+
self._info("✓ loop stopped — model reported the task complete (LOOP_DONE).")
|
|
1774
|
+
self._repeat = None
|
|
1775
|
+
else:
|
|
1776
|
+
# Early stop 2: no progress — this iteration produced the same
|
|
1777
|
+
# actions AND the same reply as the previous one, so repeating
|
|
1778
|
+
# will only reproduce it (the "repeats the same actions over and
|
|
1779
|
+
# over" case). Fingerprint = (tool names used, final reply).
|
|
1780
|
+
fp = (tuple(sorted(self._turn_tools)), self._last_assistant_text())
|
|
1781
|
+
if fp == rep.get("fingerprint") and (fp[0] or fp[1]):
|
|
1782
|
+
self._info("✓ loop stopped — no progress (identical actions "
|
|
1783
|
+
"and reply as the previous iteration).")
|
|
1784
|
+
self._repeat = None
|
|
1785
|
+
else:
|
|
1786
|
+
rep["fingerprint"] = fp
|
|
1787
|
+
rep["done"] += 1
|
|
1788
|
+
of = f"/{rep['total']}" if rep["total"] else ""
|
|
1789
|
+
self._info(f"↻ loop iteration {rep['done']}{of}")
|
|
1790
|
+
# Compact BEFORE the next iteration so accumulated history
|
|
1791
|
+
# from prior iterations can't creep over the window.
|
|
1792
|
+
try:
|
|
1793
|
+
from drydock.compaction import maybe_compact
|
|
1794
|
+
maybe_compact(self.state, self.config)
|
|
1795
|
+
except Exception: # noqa: BLE001 — compaction must never break the loop
|
|
1796
|
+
pass
|
|
1797
|
+
self._begin(self._loop_prompt(rep))
|
|
1798
|
+
return
|
|
1748
1799
|
self._repeat = None
|
|
1749
1800
|
self._refresh_status()
|
|
1750
1801
|
self._update_suggestion()
|
|
@@ -186,7 +186,7 @@ SLASH_SPECS: list[SlashSpec] = [
|
|
|
186
186
|
SlashSpec("/doc", "<sub>", "drive the Document Canvas tools by hand",
|
|
187
187
|
"open <path> · new <name> ..."),
|
|
188
188
|
SlashSpec("/skills", "[new <name> <prompt>]", "list skills, or create a reusable one"),
|
|
189
|
-
SlashSpec("/loop", "
|
|
189
|
+
SlashSpec("/loop", "[count|*] <prompt>", "repeat a prompt (1–1000, or * = until Esc)"),
|
|
190
190
|
SlashSpec("/ratchet", "<goal>", "persist verified progress across rounds until tests pass",
|
|
191
191
|
'--verify "<cmd>" to override the auto-detected verifier'),
|
|
192
192
|
SlashSpec("/mcp", "", "list connected MCP servers and their tools"),
|
|
@@ -224,13 +224,25 @@ def hallucinated_tool_message(name: str) -> str | None:
|
|
|
224
224
|
return _HALLUCINATED_TOOLS.get(name)
|
|
225
225
|
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
# Local OpenAI-compatible servers whose streamed tool-call JSON we don't trust:
|
|
228
|
+
# the model family can't be inferred from the served name (vLLM/Ollama/LM Studio
|
|
229
|
+
# routinely report a checkpoint path, a custom --served-model-name, or 'default'),
|
|
230
|
+
# and streamed tool-call deltas from local models are the corruption we avoid.
|
|
231
|
+
_LOCAL_TOOL_STREAM_UNSAFE = {"vllm", "ollama", "lmstudio"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def use_streaming(model: str | None, has_tools: bool, provider: str | None = None) -> bool:
|
|
228
235
|
"""Whether to stream tokens for this turn.
|
|
229
236
|
|
|
230
|
-
Gemma corrupts tool-call JSON when streamed, so any turn
|
|
231
|
-
|
|
237
|
+
Gemma corrupts tool-call JSON when streamed, so any tool turn to Gemma must be
|
|
238
|
+
non-streaming. Crucially, the decision must NOT rely on the model NAME alone: a
|
|
239
|
+
local server often reports a name with no 'gemma' in it (a served alias / path),
|
|
240
|
+
so a Gemma behind vLLM would slip through name-matching, stream its tool turns,
|
|
241
|
+
and emit the mangled `old_string` that makes Edit loop. So force non-streaming
|
|
242
|
+
on ANY tool turn to a local OpenAI-compatible provider, regardless of the
|
|
243
|
+
reported name. Text-only turns still stream everywhere (incremental UX kept).
|
|
232
244
|
"""
|
|
233
|
-
if has_tools and is_gemma(model):
|
|
245
|
+
if has_tools and (is_gemma(model) or (provider or "") in _LOCAL_TOOL_STREAM_UNSAFE):
|
|
234
246
|
return False
|
|
235
247
|
return True
|
|
236
248
|
|
|
@@ -110,6 +110,7 @@ tests/test_dispatch.py
|
|
|
110
110
|
tests/test_doccanvas.py
|
|
111
111
|
tests/test_e2e_connected.py
|
|
112
112
|
tests/test_edit_replace_all.py
|
|
113
|
+
tests/test_edit_thrash_and_compaction.py
|
|
113
114
|
tests/test_effort_governor.py
|
|
114
115
|
tests/test_empty_response.py
|
|
115
116
|
tests/test_events.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.14"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Regression tests for the four /loop + edit-robustness fixes:
|
|
2
|
+
- compaction now leaves DURABLE headroom (compacts below the trigger)
|
|
3
|
+
- _numbered_snapshot renders a bounded, line-numbered file view
|
|
4
|
+
- the failed-edit thrash breaker fires on repeated same-file misses
|
|
5
|
+
"""
|
|
6
|
+
from drydock import compaction
|
|
7
|
+
from drydock.agent import _numbered_snapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _msgs_over(limit_tokens: int):
|
|
11
|
+
"""A realistic long history: a first user msg + many assistant/tool turns whose
|
|
12
|
+
accumulated tool results blow past the limit (the /loop-accumulation shape)."""
|
|
13
|
+
chunk = "y" * 4000 # ~1333 tokens each
|
|
14
|
+
msgs = [{"role": "user", "content": "start the task"}]
|
|
15
|
+
for i in range(40):
|
|
16
|
+
msgs.append({"role": "assistant", "content": "",
|
|
17
|
+
"tool_calls": [{"id": str(i), "name": "Bash",
|
|
18
|
+
"input": {"command": f"run step {i}"}}]})
|
|
19
|
+
msgs.append({"role": "tool", "content": f"output {i} " + chunk})
|
|
20
|
+
msgs.append({"role": "assistant", "content": "done"})
|
|
21
|
+
return msgs
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_compact_leaves_headroom_below_trigger():
|
|
25
|
+
"""compact() must reduce BELOW the 0.60 maybe_compact trigger, not down to it —
|
|
26
|
+
otherwise it frees ~nothing and re-fires every turn (the 'doesn't compact' bug)."""
|
|
27
|
+
limit = 10_000
|
|
28
|
+
msgs = _msgs_over(limit)
|
|
29
|
+
assert compaction.estimate_tokens(msgs) > int(limit * 0.60)
|
|
30
|
+
out = compaction.compact(msgs, limit)
|
|
31
|
+
after = compaction.estimate_tokens(out)
|
|
32
|
+
# default target_frac is 0.45 → must sit at/below 45%, comfortably under 60%
|
|
33
|
+
assert after <= int(limit * 0.45) + 5, (after, int(limit * 0.45))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_compact_target_frac_is_configurable():
|
|
37
|
+
limit = 10_000
|
|
38
|
+
out = compaction.compact(_msgs_over(limit), limit, target_frac=0.30)
|
|
39
|
+
assert compaction.estimate_tokens(out) <= int(limit * 0.30) + 5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_maybe_compact_triggers_at_60pct():
|
|
43
|
+
"""maybe_compact still uses a 0.60 trigger against the MAX of estimate and the
|
|
44
|
+
server's real last_input_tokens."""
|
|
45
|
+
class S:
|
|
46
|
+
messages = [{"role": "user", "content": "hi"}]
|
|
47
|
+
last_input_tokens = 7000 # > 60% of 10k
|
|
48
|
+
s = S()
|
|
49
|
+
compaction.maybe_compact(s, {"context_limit": 10_000})
|
|
50
|
+
# trigger fired (messages object handed to compact and returned) — no crash,
|
|
51
|
+
# and a below-trigger state is a no-op:
|
|
52
|
+
class S2:
|
|
53
|
+
messages = [{"role": "user", "content": "hi"}]
|
|
54
|
+
last_input_tokens = 100
|
|
55
|
+
before = list(S2.messages)
|
|
56
|
+
compaction.maybe_compact(S2(), {"context_limit": 10_000})
|
|
57
|
+
assert S2.messages == before
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_numbered_snapshot_basic(tmp_path):
|
|
61
|
+
f = tmp_path / "a.py"
|
|
62
|
+
f.write_text("line one\nline two\nline three\n")
|
|
63
|
+
snap = _numbered_snapshot(str(f))
|
|
64
|
+
assert " 1\tline one" in snap
|
|
65
|
+
assert " 3\tline three" in snap
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_numbered_snapshot_caps_lines(tmp_path):
|
|
69
|
+
f = tmp_path / "big.py"
|
|
70
|
+
f.write_text("\n".join(f"row{i}" for i in range(500)))
|
|
71
|
+
snap = _numbered_snapshot(str(f), max_lines=50)
|
|
72
|
+
assert "more lines" in snap
|
|
73
|
+
assert "row49" in snap and "row400" not in snap
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_numbered_snapshot_missing_file():
|
|
77
|
+
snap = _numbered_snapshot("/no/such/file/xyz")
|
|
78
|
+
assert "could not read" in snap.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_edit_thrash_counter_logic():
|
|
82
|
+
"""The breaker's core rule: count 'old_string not found' per file regardless
|
|
83
|
+
of the (varying) old_string, fire on the 3rd, and a clean edit resets it.
|
|
84
|
+
Mirrors the agent-loop bookkeeping so the invariant is pinned even though the
|
|
85
|
+
full loop needs a live model."""
|
|
86
|
+
edit_fail_counts: dict[str, int] = {}
|
|
87
|
+
|
|
88
|
+
def step(fp: str, result: str) -> bool:
|
|
89
|
+
thrash = False
|
|
90
|
+
if "old_string not found" in result:
|
|
91
|
+
edit_fail_counts[fp] = edit_fail_counts.get(fp, 0) + 1
|
|
92
|
+
if edit_fail_counts[fp] >= 3:
|
|
93
|
+
thrash = True
|
|
94
|
+
else:
|
|
95
|
+
edit_fail_counts.pop(fp, None)
|
|
96
|
+
return thrash
|
|
97
|
+
|
|
98
|
+
# three DIFFERENT wrong old_strings on the same file → fires on the 3rd
|
|
99
|
+
assert step("/a.py", "Error: old_string not found in /a.py ...") is False
|
|
100
|
+
assert step("/a.py", "Error: old_string not found in /a.py ...") is False
|
|
101
|
+
assert step("/a.py", "Error: old_string not found in /a.py ...") is True
|
|
102
|
+
# a different file is tracked independently
|
|
103
|
+
assert step("/b.py", "Error: old_string not found in /b.py ...") is False
|
|
104
|
+
# a clean edit resets the file's counter
|
|
105
|
+
assert step("/a.py", "Edited /a.py") is False
|
|
106
|
+
assert "/a.py" not in edit_fail_counts
|
|
107
|
+
assert step("/a.py", "Error: old_string not found in /a.py ...") is False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_use_streaming_forces_nonstream_for_local_tool_turns():
|
|
111
|
+
"""A Gemma behind vLLM served under a non-'gemma' name must NOT stream tool
|
|
112
|
+
turns (the latent corruption trap). Text-only turns still stream."""
|
|
113
|
+
from drydock.tuning import use_streaming
|
|
114
|
+
# non-'gemma' served name on a local provider → tool turns non-streamed
|
|
115
|
+
assert use_streaming("Q5_K_M", has_tools=True, provider="vllm") is False
|
|
116
|
+
assert use_streaming("default", has_tools=True, provider="ollama") is False
|
|
117
|
+
assert use_streaming("model", has_tools=True, provider="lmstudio") is False
|
|
118
|
+
# text-only turns still stream everywhere
|
|
119
|
+
assert use_streaming("Q5_K_M", has_tools=False, provider="vllm") is True
|
|
120
|
+
# name-based gemma match still works regardless of provider
|
|
121
|
+
assert use_streaming("gemma4", has_tools=True, provider=None) is False
|
|
122
|
+
# a remote provider (openai) with tools is NOT force-non-streamed by provider
|
|
123
|
+
assert use_streaming("gpt-4o", has_tools=True, provider="openai") is True
|
|
@@ -438,11 +438,13 @@ def test_loop_command_iterates_and_stops():
|
|
|
438
438
|
begins = []
|
|
439
439
|
app._begin = lambda text: begins.append(text) # stub out the real agent
|
|
440
440
|
|
|
441
|
-
#
|
|
442
|
-
app._handle_slash("/loop
|
|
441
|
+
# empty prompt → usage error, no loop
|
|
442
|
+
app._handle_slash("/loop")
|
|
443
|
+
assert app._repeat is None and not begins
|
|
444
|
+
app._handle_slash("/loop *") # count but no prompt
|
|
443
445
|
assert app._repeat is None and not begins
|
|
444
446
|
|
|
445
|
-
# /loop 3 <prompt> → starts, first iteration begun
|
|
447
|
+
# /loop 3 <prompt> → starts, first iteration begun with the bare prompt
|
|
446
448
|
app._handle_slash("/loop 3 do the thing")
|
|
447
449
|
assert app._repeat and app._repeat["total"] == 3
|
|
448
450
|
assert begins == ["do the thing"]
|
|
@@ -456,7 +458,38 @@ def test_loop_command_iterates_and_stops():
|
|
|
456
458
|
assert app._repeat is None
|
|
457
459
|
assert len(begins) == 3 # no 4th run
|
|
458
460
|
|
|
461
|
+
# unbounded: `*` runs past any count until stopped
|
|
462
|
+
begins.clear()
|
|
463
|
+
app._handle_slash("/loop * keep going")
|
|
464
|
+
assert app._repeat and app._repeat["total"] is None
|
|
465
|
+
for _ in range(6):
|
|
466
|
+
app.on_agent_finished(AgentFinished())
|
|
467
|
+
assert app._repeat is not None # never self-exhausts
|
|
468
|
+
assert len(begins) == 7 # initial + 6 continuations
|
|
469
|
+
|
|
470
|
+
# no count given → also unbounded, whole arg is the prompt
|
|
471
|
+
app._handle_slash("/stop")
|
|
472
|
+
begins.clear()
|
|
473
|
+
app._handle_slash("/loop just do it")
|
|
474
|
+
assert app._repeat and app._repeat["total"] is None
|
|
475
|
+
assert begins == ["just do it"]
|
|
476
|
+
|
|
477
|
+
# count is capped at 1000
|
|
478
|
+
app._handle_slash("/stop")
|
|
479
|
+
app._handle_slash("/loop 999999 huge")
|
|
480
|
+
assert app._repeat and app._repeat["total"] == 1000
|
|
481
|
+
|
|
482
|
+
# LOOP_DONE reply stops the loop early
|
|
483
|
+
app._handle_slash("/stop")
|
|
484
|
+
begins.clear()
|
|
485
|
+
app._handle_slash("/loop 10 finish up")
|
|
486
|
+
app.state.messages = [{"role": "assistant", "content": "all set. LOOP_DONE"}]
|
|
487
|
+
app.on_agent_finished(AgentFinished())
|
|
488
|
+
assert app._repeat is None
|
|
489
|
+
assert len(begins) == 1 # stopped before a 2nd iteration
|
|
490
|
+
|
|
459
491
|
# Esc/stop ends an active loop
|
|
492
|
+
app.state.messages = []
|
|
460
493
|
app._begin = lambda text: begins.append(text)
|
|
461
494
|
app._handle_slash("/loop 5 again")
|
|
462
495
|
app._busy = True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|