drydock-cli 3.0.162__tar.gz → 3.0.166__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.0.162/drydock_cli.egg-info → drydock_cli-3.0.166}/PKG-INFO +1 -1
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/agent.py +71 -3
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/config.py +5 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/loop_detect.py +24 -6
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/recovery.py +28 -2
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_result.py +6 -1
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/verification.py +42 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/SOURCES.txt +3 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/pyproject.toml +1 -1
- drydock_cli-3.0.166/tests/test_criteria_coverage.py +122 -0
- drydock_cli-3.0.166/tests/test_cycling_detection.py +73 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery.py +14 -0
- drydock_cli-3.0.166/tests/test_repeated_outcome.py +54 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_result.py +12 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/LICENSE +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/NOTICE +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/README.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/budget.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/nist-csf.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/cci.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/cli.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/detect.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/events.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/extract.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/guards.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/phases.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/poam.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/progress.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/providers.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/recipes.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/resume.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/skills.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/stig.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/subagents.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/suggest.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/task_state.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_policy.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_select.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tool_validate.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/trajectory.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock/web.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/setup.cfg +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_budget.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_config.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_context_limit_issue25.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_events.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_graphrag_sqlite.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_model_registry.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_phases.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_progress.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recipes.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery_config.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_recovery_integration.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_events.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_restore.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_resume_snapshot.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_rolling_plan.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_sqlite_events.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_subagents.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_suggest.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_task_state.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_timeline.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_canonical.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_policy.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_select.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tool_validate.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_trajectory.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_verification_gate.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_worker_subagent.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.162 → drydock_cli-3.0.166}/tests/test_xccdf.py +0 -0
|
@@ -49,7 +49,12 @@ from drydock.compaction import (
|
|
|
49
49
|
)
|
|
50
50
|
from drydock.loop_detect import LoopTracker
|
|
51
51
|
from drydock.task_state import TaskState
|
|
52
|
-
from drydock.verification import
|
|
52
|
+
from drydock.verification import (
|
|
53
|
+
looks_like_verification,
|
|
54
|
+
parse_evidence,
|
|
55
|
+
extract_artifacts,
|
|
56
|
+
uncovered_artifacts,
|
|
57
|
+
)
|
|
53
58
|
from drydock.progress import ProgressTracker, assess_action, NO_PROGRESS_WINDOW
|
|
54
59
|
from drydock.recovery import RecoveryController, SUPPRESSION_ITERATIONS
|
|
55
60
|
from drydock.loop_detect import tool_signature
|
|
@@ -205,6 +210,9 @@ def run(
|
|
|
205
210
|
window=config.get("recovery_no_progress_window", NO_PROGRESS_WINDOW),
|
|
206
211
|
) # scores each action; flags a stalled run
|
|
207
212
|
prev_failing = None # failing-test count from the previous verification (delta)
|
|
213
|
+
verif_fail_counts: dict[str, int] = {} # failure-summary -> times seen (Epic J3)
|
|
214
|
+
verification_text = "" # concatenated verification commands+results (coverage)
|
|
215
|
+
coverage_nudges = 0 # bounded "your checks never touched X" nudges
|
|
208
216
|
recovery = RecoveryController(
|
|
209
217
|
suppression_iterations=config.get("recovery_suppression_iterations",
|
|
210
218
|
SUPPRESSION_ITERATIONS),
|
|
@@ -446,6 +454,25 @@ def run(
|
|
|
446
454
|
state.messages.append({"role": "user", "content": msg})
|
|
447
455
|
continue
|
|
448
456
|
if session_has_edited and last_verification and last_verification.status == "pass":
|
|
457
|
+
# Criteria coverage (bench finding: exit-0 self-checks that never
|
|
458
|
+
# touch the files the task is judged on). If the objective names
|
|
459
|
+
# concrete artifacts and NONE of this session's verifications ever
|
|
460
|
+
# referenced some of them, nudge ONCE with exactly what's missing.
|
|
461
|
+
# Advisory + bounded: a second completion claim is accepted.
|
|
462
|
+
_artifacts = extract_artifacts(
|
|
463
|
+
state.task.objective + "\n" + "\n".join(state.task.acceptance_criteria))
|
|
464
|
+
_missing = uncovered_artifacts(_artifacts, verification_text)
|
|
465
|
+
if _missing and coverage_nudges < 1:
|
|
466
|
+
coverage_nudges += 1
|
|
467
|
+
_emit(state, "verify_gate", kind="uncovered", missing=_missing[:6])
|
|
468
|
+
state.messages.append({"role": "user", "content": (
|
|
469
|
+
"[SYSTEM] Your check passed, but no verification this session "
|
|
470
|
+
f"has touched: {', '.join(_missing[:6])} — and the task will be "
|
|
471
|
+
"judged on them. Run a check that directly validates the stated "
|
|
472
|
+
"requirement(s) against those files (diff/compare/execute as "
|
|
473
|
+
"appropriate), then finish."
|
|
474
|
+
)})
|
|
475
|
+
continue
|
|
449
476
|
# A check ran and PASSED — verification evidence exists, so the
|
|
450
477
|
# controller approves completion (from VERIFY, its only legal
|
|
451
478
|
# predecessor). This is the one path to COMPLETE.
|
|
@@ -595,16 +622,41 @@ def run(
|
|
|
595
622
|
identical_repeat_streak, last_call_sig = 1, sig
|
|
596
623
|
# Guide (never block) on exact-repeat tool calls: prepend an
|
|
597
624
|
# advisory note when the same call is made again.
|
|
625
|
+
_raw_result = result # outcome identity = the un-annotated body
|
|
598
626
|
result = loop_tracker.annotate(tc["name"], tc["input"], result)
|
|
627
|
+
# Cycling detection (gov162 bench, large-scale-text-editing): the same
|
|
628
|
+
# exact (call, result) pair recurring — NON-consecutively — is a loop
|
|
629
|
+
# even when interleaved actions look productive (an identical macro
|
|
630
|
+
# rewritten 22× between exit-0 test runs). annotate() just recorded
|
|
631
|
+
# this pair, so the count includes the current call. Polling is exempt
|
|
632
|
+
# (its result changes → different pair each time).
|
|
633
|
+
_same_outcome = loop_tracker.outcome_count(tc["name"], tc["input"], _raw_result)
|
|
599
634
|
|
|
600
635
|
# Verification evidence: if this Bash call was a test/check/exec, parse
|
|
601
636
|
# its result so the completion gate knows whether it PASSED, not just ran.
|
|
637
|
+
_repeated_outcome = False
|
|
602
638
|
if tc["name"] == "Bash":
|
|
603
639
|
_vcmd = (tc.get("input") or {}).get("command", "")
|
|
604
640
|
if looks_like_verification(_vcmd):
|
|
605
641
|
last_verification = parse_evidence(_vcmd, result)
|
|
642
|
+
# Accumulate what the checks touched (command + result text),
|
|
643
|
+
# capped, for criteria-coverage at completion time.
|
|
644
|
+
if len(verification_text) < 200_000:
|
|
645
|
+
verification_text += f"\n{_vcmd}\n{result[:2000]}"
|
|
606
646
|
_emit(state, "verification", status=last_verification.status,
|
|
607
647
|
exit_code=last_verification.exit_code)
|
|
648
|
+
# Repeated-outcome detection (PRD Epic J3): the SAME failing
|
|
649
|
+
# verification outcome recurring — regardless of which command
|
|
650
|
+
# produced it (pytest vs python -m pytest vs a reworded check)
|
|
651
|
+
# — is a stall signal even though no exact call repeats. Keyed
|
|
652
|
+
# on the parsed failure summary, so a CHANGING failure (real
|
|
653
|
+
# progress) never counts. Found in the wild: a bench task ran
|
|
654
|
+
# 25 failing verifications in 38 turns with zero recovery
|
|
655
|
+
# because every command differed slightly.
|
|
656
|
+
if last_verification.status == "fail":
|
|
657
|
+
_vkey = last_verification.summary or f"exit:{last_verification.exit_code}"
|
|
658
|
+
verif_fail_counts[_vkey] = verif_fail_counts.get(_vkey, 0) + 1
|
|
659
|
+
_repeated_outcome = verif_fail_counts[_vkey] >= 2
|
|
608
660
|
|
|
609
661
|
# Progress evaluation (PRD Epic L): score what this action actually
|
|
610
662
|
# achieved and slide it through a window so a stalled run — repeated
|
|
@@ -618,10 +670,15 @@ def run(
|
|
|
618
670
|
_failing_after = last_verification.tests_failed
|
|
619
671
|
prev_failing = _failing_after
|
|
620
672
|
assessment = progress_tracker.record(assess_action(
|
|
673
|
+
# OUTCOME-aware repeat count: "repeated action without new
|
|
674
|
+
# information" means the same call returned the SAME result again
|
|
675
|
+
# — a poll whose output changes each time is novel information and
|
|
676
|
+
# must not accrue stall (found suppressing legit polling).
|
|
621
677
|
changed_state=bool(tool_result and tool_result.changed_state),
|
|
622
|
-
repeat_count=
|
|
678
|
+
repeat_count=_same_outcome,
|
|
623
679
|
failing_tests_before=_failing_before if _failing_after is not None else None,
|
|
624
680
|
failing_tests_after=_failing_after,
|
|
681
|
+
repeated_outcome=_repeated_outcome,
|
|
625
682
|
))
|
|
626
683
|
_emit(state, "progress", score=assessment.progress_score,
|
|
627
684
|
types=assessment.progress_types,
|
|
@@ -633,10 +690,21 @@ def run(
|
|
|
633
690
|
# result; stage 3 suppresses THIS looping signature next time; stage 5
|
|
634
691
|
# asks the loop to stop honestly. The offender is the current call —
|
|
635
692
|
# only suppressed when the window says it's a no-progress loop.
|
|
693
|
+
_rec_stage = progress_tracker.recommended_stage()
|
|
694
|
+
# A call whose exact (call, result) pair has recurred past the
|
|
695
|
+
# threshold is cycling regardless of the global streak (interleaved
|
|
696
|
+
# "productive" actions kept resetting it) — floor the recommendation
|
|
697
|
+
# at stage 3 so THIS signature gets suppressed. Cycles alternate with
|
|
698
|
+
# other actions, so arm a window wide enough to outlast the period
|
|
699
|
+
# (the default window expires exactly when a period-2 cycle returns).
|
|
700
|
+
_cycling = _same_outcome >= config.get("recovery_same_outcome_threshold", 6)
|
|
701
|
+
if _cycling:
|
|
702
|
+
_rec_stage = max(_rec_stage or 0, 3)
|
|
636
703
|
directive = recovery.escalate(
|
|
637
|
-
|
|
704
|
+
_rec_stage,
|
|
638
705
|
offender_signature=recovery_sig,
|
|
639
706
|
iteration=run_iteration,
|
|
707
|
+
suppress_window=(recovery.suppression_iterations + 3) if _cycling else None,
|
|
640
708
|
)
|
|
641
709
|
if directive.active:
|
|
642
710
|
# Count real interventions (reflection and beyond, not the mild
|
|
@@ -68,6 +68,11 @@ DEFAULTS: dict[str, object] = {
|
|
|
68
68
|
"recovery_no_progress_window": 5,
|
|
69
69
|
"recovery_suppression_iterations": 2,
|
|
70
70
|
"max_recovery_attempts": 0,
|
|
71
|
+
# An exact (call, result) pair recurring this many times — consecutively or
|
|
72
|
+
# not — is treated as cycling and that signature is suppressed (stage 3),
|
|
73
|
+
# even when interleaved actions look productive. Polling is exempt (its
|
|
74
|
+
# result changes each time).
|
|
75
|
+
"recovery_same_outcome_threshold": 6,
|
|
71
76
|
# Verified-trajectory export (RSI training-data collection). When set to a path,
|
|
72
77
|
# drydock writes the full task trajectory (system prompt + transcript) there at
|
|
73
78
|
# task end; a benchmark harness keeps only verifier-passing ones. Empty = off.
|
|
@@ -149,6 +149,15 @@ class LoopTracker:
|
|
|
149
149
|
annotate() has already recorded the call."""
|
|
150
150
|
return self._counts.get(tool_signature(name, inputs), 0)
|
|
151
151
|
|
|
152
|
+
def outcome_count(self, name: str, inputs: dict, result: str) -> int:
|
|
153
|
+
"""How many times this exact (call, result) pair has been seen so far,
|
|
154
|
+
WITHOUT recording. Non-consecutive repeats count — the signal that a call
|
|
155
|
+
is cycling (an identical Write re-issued between other actions) even when
|
|
156
|
+
it never repeats back-to-back. Polling is naturally exempt: its result
|
|
157
|
+
changes, so each poll is a different pair."""
|
|
158
|
+
key = f"{tool_signature(name, inputs)}\x00{hash(result or '')}"
|
|
159
|
+
return self._outcome_counts.get(key, 0)
|
|
160
|
+
|
|
152
161
|
def record_outcome(self, name: str, inputs: dict, result: str) -> int:
|
|
153
162
|
"""Record that this exact call produced this exact result body; return how
|
|
154
163
|
many times that (call, outcome) pair has now been seen. A repeated-OUTCOME
|
|
@@ -177,13 +186,22 @@ class LoopTracker:
|
|
|
177
186
|
count = self.record(name, inputs)
|
|
178
187
|
failed = (result or "").lstrip().startswith(("Error", "REFUSED"))
|
|
179
188
|
outcome_count = self.record_outcome(name, inputs, result)
|
|
180
|
-
|
|
189
|
+
# Outcome-aware repeat handling: a repeated call whose RESULT is also
|
|
190
|
+
# repeating is a loop; a repeated call whose result CHANGES is polling —
|
|
191
|
+
# legit, and must get neither notes nor pruning (pruning it to a constant
|
|
192
|
+
# stub both loses the fresh output AND makes the poll look like a loop
|
|
193
|
+
# downstream). Failed repeats keep their fix-it notes on call identity
|
|
194
|
+
# alone (the error text is the fix, sameness of body not required).
|
|
195
|
+
if failed:
|
|
196
|
+
note = loop_note(name, count, failed=True)
|
|
197
|
+
else:
|
|
198
|
+
note = loop_note(name, outcome_count, failed=False)
|
|
181
199
|
path_note = self.record_path_write(name, inputs)
|
|
182
|
-
# Prune the BODY of a repeated *successful*
|
|
183
|
-
#
|
|
184
|
-
# and
|
|
185
|
-
# repeat yields nothing new.
|
|
186
|
-
if
|
|
200
|
+
# Prune the BODY of a repeated *successful, unchanged* outcome (3rd+ time
|
|
201
|
+
# this exact call returned this exact body): the model already has this
|
|
202
|
+
# content, and re-feeding it both wastes context and lets it mindlessly
|
|
203
|
+
# re-call. Replace the body with a stub so the repeat yields nothing new.
|
|
204
|
+
if not failed and outcome_count >= 3:
|
|
187
205
|
result = (
|
|
188
206
|
"(identical to your earlier call to this tool — body omitted. "
|
|
189
207
|
"Re-running it returns nothing new; act on the content you "
|
|
@@ -61,6 +61,10 @@ class RecoveryController:
|
|
|
61
61
|
self.suppression_iterations = max(1, suppression_iterations)
|
|
62
62
|
# signature -> iteration index at which the suppression expires
|
|
63
63
|
self._suppressed: dict[str, int] = {}
|
|
64
|
+
# signature -> how many times it has EVER been suppressed this run; a
|
|
65
|
+
# second arming means suppression didn't change the model's behavior —
|
|
66
|
+
# per the PRD that's the stage-5 condition (controlled, honest stop).
|
|
67
|
+
self._suppress_counts: dict[str, int] = {}
|
|
64
68
|
|
|
65
69
|
# --- suppression bookkeeping ---
|
|
66
70
|
|
|
@@ -91,6 +95,7 @@ class RecoveryController:
|
|
|
91
95
|
*,
|
|
92
96
|
offender_signature: str | None,
|
|
93
97
|
iteration: int,
|
|
98
|
+
suppress_window: int | None = None,
|
|
94
99
|
) -> RecoveryDirective:
|
|
95
100
|
"""Advance to `recommended_stage` (never downgrade mid-plateau) and return
|
|
96
101
|
the directive for the current action. `offender_signature` is the exact
|
|
@@ -119,8 +124,29 @@ class RecoveryController:
|
|
|
119
124
|
"action you will avoid repeating.]"
|
|
120
125
|
))
|
|
121
126
|
if stage == 3:
|
|
122
|
-
if offender_signature:
|
|
123
|
-
|
|
127
|
+
if offender_signature and offender_signature not in self._suppressed:
|
|
128
|
+
# A FRESH arming (not a refresh of a live window). A second fresh
|
|
129
|
+
# arming of the same signature means the first suppression ran its
|
|
130
|
+
# course — guidance included — and the model came straight back to
|
|
131
|
+
# the same call with the same result. Stage 4 failed implicitly;
|
|
132
|
+
# stop honestly rather than suppress-expire-repeat forever
|
|
133
|
+
# (PRD K1.6).
|
|
134
|
+
self._suppress_counts[offender_signature] = \
|
|
135
|
+
self._suppress_counts.get(offender_signature, 0) + 1
|
|
136
|
+
if self._suppress_counts[offender_signature] >= 2:
|
|
137
|
+
self.stage = 5
|
|
138
|
+
return RecoveryDirective(stage=5, terminate=True, note=(
|
|
139
|
+
"[Recovery ceiling reached: this exact action kept "
|
|
140
|
+
"repeating even after being suppressed once — continuing "
|
|
141
|
+
"will not make progress. Stop and report honestly what "
|
|
142
|
+
"was accomplished and what remains; do NOT claim the "
|
|
143
|
+
"task is complete if it is not verified.]"
|
|
144
|
+
))
|
|
145
|
+
# A caller may widen the window (`suppress_window`) — an action
|
|
146
|
+
# cycling with period N evades the default window when N >= it
|
|
147
|
+
# (armed at k, retried at k+N, already expired).
|
|
148
|
+
self._suppressed[offender_signature] = iteration + (
|
|
149
|
+
suppress_window or self.suppression_iterations)
|
|
124
150
|
return RecoveryDirective(stage=3, suppress=bool(offender_signature), note=(
|
|
125
151
|
"[This repeated action is now temporarily suppressed because it "
|
|
126
152
|
"has not been making progress. Take a genuinely different step.]"
|
|
@@ -39,9 +39,14 @@ class ToolResult:
|
|
|
39
39
|
|
|
40
40
|
_EXIT = re.compile(r"\[exit code:\s*(-?\d+)\]")
|
|
41
41
|
_MUTATING_BASH = re.compile(
|
|
42
|
+
# Word-y mutating commands need the trailing \b; output redirection (>, >>)
|
|
43
|
+
# must NOT — `echo hi > file` has no word char right after '>', so a shared
|
|
44
|
+
# \b silently missed every space-separated redirect (found via a bench trace
|
|
45
|
+
# where 38 heredoc/redirect writes all scored as "nothing changed").
|
|
42
46
|
r"(?:^|[\s;&|])(?:rm|mv|cp|mkdir|touch|tee|sed\s+-i|dd|chmod|chown|ln|"
|
|
43
47
|
r"git\s+(?:commit|add|apply|checkout|reset|rm|mv)|pip\s+install|npm\s+i|"
|
|
44
|
-
r"apt|make(?:\s|$)|cargo\s+build
|
|
48
|
+
r"apt|make(?:\s|$)|cargo\s+build)\b"
|
|
49
|
+
r"|(?:^|[\s;&|])>>?", re.I,
|
|
45
50
|
)
|
|
46
51
|
# Error phrases that usually mean "try again might work" (transient).
|
|
47
52
|
_RETRYABLE = re.compile(
|
|
@@ -35,6 +35,48 @@ def looks_like_verification(cmd) -> bool:
|
|
|
35
35
|
return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# ── Criteria-tied evidence (bench finding: exit-0 self-checks ≠ acceptance
|
|
39
|
+
# criteria). Three separate traces showed the model "verifying" with checks that
|
|
40
|
+
# passed but never touched the files the objective is actually judged on (e.g. a
|
|
41
|
+
# vim macro run 23× cleanly while expected.csv was never diffed). These helpers
|
|
42
|
+
# let the gate name exactly which stated artifacts the checks never covered.
|
|
43
|
+
|
|
44
|
+
# File-ish tokens: a path or name with a real extension (/app/expected.csv,
|
|
45
|
+
# apply_macros.vim, test.sh). Excludes bare version numbers and trailing dots.
|
|
46
|
+
_ARTIFACT = re.compile(r"(?:/[\w.\-/]+/)?([\w\-]+\.[A-Za-z]{1,6})\b")
|
|
47
|
+
# Extensions that are prose, not artifacts ("e.g.", "i.e.", "v1.0", "No.1").
|
|
48
|
+
_NON_ARTIFACT_EXT = frozenset({"g", "e", "i", "etc", "vs", "no"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_artifacts(text) -> list[str]:
|
|
52
|
+
"""File basenames named in an objective / acceptance criteria — the concrete
|
|
53
|
+
things a verification should touch. Order-preserving, deduped, best-effort;
|
|
54
|
+
[] when the text names no files (the gate then behaves as before)."""
|
|
55
|
+
if not text or not isinstance(text, str):
|
|
56
|
+
return []
|
|
57
|
+
out: list[str] = []
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
for m in _ARTIFACT.finditer(text):
|
|
60
|
+
base = m.group(1)
|
|
61
|
+
ext = base.rsplit(".", 1)[-1].lower()
|
|
62
|
+
if ext in _NON_ARTIFACT_EXT or ext.isdigit():
|
|
63
|
+
continue
|
|
64
|
+
if base.lower() not in seen:
|
|
65
|
+
seen.add(base.lower())
|
|
66
|
+
out.append(base)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def uncovered_artifacts(artifacts, verification_text) -> list[str]:
|
|
71
|
+
"""The stated artifacts that `verification_text` (the concatenated commands +
|
|
72
|
+
results of every verification run so far) never references. What comes back
|
|
73
|
+
is exactly what the completion nudge should name."""
|
|
74
|
+
if not artifacts:
|
|
75
|
+
return []
|
|
76
|
+
low = (verification_text or "").lower()
|
|
77
|
+
return [a for a in artifacts if a.lower() not in low]
|
|
78
|
+
|
|
79
|
+
|
|
38
80
|
@dataclass
|
|
39
81
|
class VerificationEvidence:
|
|
40
82
|
"""Structured outcome of a verification command — did the check PASS, not just
|
|
@@ -94,6 +94,8 @@ tests/test_config.py
|
|
|
94
94
|
tests/test_config_migration.py
|
|
95
95
|
tests/test_context_limit_config.py
|
|
96
96
|
tests/test_context_limit_issue25.py
|
|
97
|
+
tests/test_criteria_coverage.py
|
|
98
|
+
tests/test_cycling_detection.py
|
|
97
99
|
tests/test_detect.py
|
|
98
100
|
tests/test_dispatch.py
|
|
99
101
|
tests/test_e2e_connected.py
|
|
@@ -128,6 +130,7 @@ tests/test_recipes.py
|
|
|
128
130
|
tests/test_recovery.py
|
|
129
131
|
tests/test_recovery_config.py
|
|
130
132
|
tests/test_recovery_integration.py
|
|
133
|
+
tests/test_repeated_outcome.py
|
|
131
134
|
tests/test_repetition_interrupt.py
|
|
132
135
|
tests/test_resume_events.py
|
|
133
136
|
tests/test_resume_restore.py
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.166"
|
|
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,122 @@
|
|
|
1
|
+
"""Tests for criteria-tied verification (task #22 / bench finding: exit-0
|
|
2
|
+
self-checks that never touch the files the task is judged on). Advisory: one
|
|
3
|
+
bounded nudge naming the uncovered artifacts, then completion proceeds."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import drydock.agent as agent_mod
|
|
9
|
+
from drydock.agent import AgentState, run
|
|
10
|
+
from drydock.providers import AssistantTurn
|
|
11
|
+
from drydock.verification import extract_artifacts, uncovered_artifacts
|
|
12
|
+
|
|
13
|
+
LSTE_OBJECTIVE = (
|
|
14
|
+
"Transform /app/input.csv (1 million rows) to match /app/expected.csv exactly "
|
|
15
|
+
"using keystroke-efficient Vim macros. Save your script as /app/apply_macros.vim."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_extract_artifacts_from_objective():
|
|
20
|
+
arts = extract_artifacts(LSTE_OBJECTIVE)
|
|
21
|
+
assert "input.csv" in arts
|
|
22
|
+
assert "expected.csv" in arts
|
|
23
|
+
assert "apply_macros.vim" in arts
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_extract_ignores_prose_dots():
|
|
27
|
+
arts = extract_artifacts("Fix the bug, e.g. the crash in v1.2 — i.e. the parser. No.1 priority.")
|
|
28
|
+
assert arts == []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_uncovered_artifacts():
|
|
32
|
+
arts = ["input.csv", "expected.csv", "apply_macros.vim"]
|
|
33
|
+
checks = "cp /app/test_input.csv t.csv\nvim -Es t.csv -S /app/apply_macros.vim\n[ok]"
|
|
34
|
+
missing = uncovered_artifacts(arts, checks)
|
|
35
|
+
assert missing == ["expected.csv"] # never diffed — the LSTE blind spot
|
|
36
|
+
assert uncovered_artifacts([], "anything") == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write(path, content="x"):
|
|
40
|
+
return {"name": "Write", "input": {"file_path": path, "content": content}}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_uncovered_check_gets_one_nudge_then_covered_completes(monkeypatch):
|
|
44
|
+
d = tempfile.mkdtemp()
|
|
45
|
+
with open(f"{d}/expected.csv", "w") as f:
|
|
46
|
+
f.write("A\n")
|
|
47
|
+
turns = [
|
|
48
|
+
AssistantTurn("", [dict(id="1", **_write(f"{d}/out.csv", "A\n"))], 1, 1),
|
|
49
|
+
# verification that touches NOTHING named in the objective
|
|
50
|
+
AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
51
|
+
"input": {"command": "python3 -c 'print(1)'"}}], 1, 1),
|
|
52
|
+
AssistantTurn("done", [], 1, 1), # claim -> should get coverage nudge
|
|
53
|
+
# now a check that touches expected.csv
|
|
54
|
+
AssistantTurn("", [{"id": "3", "name": "Bash",
|
|
55
|
+
"input": {"command": f"python3 -c 'print(open(\"{d}/expected.csv\").read())'"}}], 1, 1),
|
|
56
|
+
AssistantTurn("done for real", [], 1, 1), # accepted
|
|
57
|
+
]
|
|
58
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
|
|
59
|
+
st = AgentState()
|
|
60
|
+
list(run(f"Make out.csv match {d}/expected.csv exactly.", st,
|
|
61
|
+
{"model": "m", "cwd": d}, "sys"))
|
|
62
|
+
nudges = [m["content"] for m in st.messages
|
|
63
|
+
if m.get("role") == "user" and "has touched" in str(m.get("content"))]
|
|
64
|
+
assert len(nudges) == 1
|
|
65
|
+
assert "expected.csv" in nudges[0]
|
|
66
|
+
assert any(m.get("content") == "done for real" for m in st.messages)
|
|
67
|
+
assert str(st.task.phase) == "complete"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_nudge_is_bounded_second_claim_accepted(monkeypatch):
|
|
71
|
+
# The model ignores the nudge and claims done again -> advisory, accepted.
|
|
72
|
+
d = tempfile.mkdtemp()
|
|
73
|
+
turns = [
|
|
74
|
+
AssistantTurn("", [dict(id="1", **_write(f"{d}/a.py"))], 1, 1),
|
|
75
|
+
AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
76
|
+
"input": {"command": "python3 -c 'print(1)'"}}], 1, 1),
|
|
77
|
+
AssistantTurn("done", [], 1, 1), # nudged (goal.csv never touched)
|
|
78
|
+
AssistantTurn("still done", [], 1, 1), # accepted anyway
|
|
79
|
+
]
|
|
80
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
|
|
81
|
+
st = AgentState()
|
|
82
|
+
list(run("Produce goal.csv from the data.", st, {"model": "m", "cwd": d}, "sys"))
|
|
83
|
+
assert any(m.get("content") == "still done" for m in st.messages)
|
|
84
|
+
assert str(st.task.phase) == "complete"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_no_artifacts_in_objective_no_nudge(monkeypatch):
|
|
88
|
+
# An objective naming no files behaves exactly as before: pass -> complete.
|
|
89
|
+
d = tempfile.mkdtemp()
|
|
90
|
+
turns = [
|
|
91
|
+
AssistantTurn("", [dict(id="1", **_write(f"{d}/x.py", "print(1)"))], 1, 1),
|
|
92
|
+
AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
93
|
+
"input": {"command": f"python3 {d}/x.py"}}], 1, 1),
|
|
94
|
+
AssistantTurn("all done", [], 1, 1),
|
|
95
|
+
]
|
|
96
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
|
|
97
|
+
st = AgentState()
|
|
98
|
+
list(run("write a script that prints 1 and run it", st, {"model": "m", "cwd": d}, "sys"))
|
|
99
|
+
nudges = [m for m in st.messages
|
|
100
|
+
if m.get("role") == "user" and "has touched" in str(m.get("content"))]
|
|
101
|
+
assert nudges == []
|
|
102
|
+
assert str(st.task.phase) == "complete"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_covered_checks_complete_without_nudge(monkeypatch):
|
|
106
|
+
# Checks that DO touch the named artifact -> no nudge at all.
|
|
107
|
+
d = tempfile.mkdtemp()
|
|
108
|
+
with open(f"{d}/expected.csv", "w") as f:
|
|
109
|
+
f.write("A\n")
|
|
110
|
+
turns = [
|
|
111
|
+
AssistantTurn("", [dict(id="1", **_write(f"{d}/out.csv", "A\n"))], 1, 1),
|
|
112
|
+
AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
113
|
+
"input": {"command": f"python3 -c 'print(open(\"{d}/expected.csv\").read() == open(\"{d}/out.csv\").read())'"}}], 1, 1),
|
|
114
|
+
AssistantTurn("matches", [], 1, 1),
|
|
115
|
+
]
|
|
116
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([turns.pop(0)]))
|
|
117
|
+
st = AgentState()
|
|
118
|
+
list(run(f"Make out.csv match {d}/expected.csv exactly.", st, {"model": "m", "cwd": d}, "sys"))
|
|
119
|
+
nudges = [m for m in st.messages
|
|
120
|
+
if m.get("role") == "user" and "has touched" in str(m.get("content"))]
|
|
121
|
+
assert nudges == []
|
|
122
|
+
assert str(st.task.phase) == "complete"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Regression for the gov162 large-scale-text-editing finding: the same exact
|
|
2
|
+
(call, result) pair recurring NON-consecutively is a loop, even when interleaved
|
|
3
|
+
actions look productive (identical macro rewritten 22x between exit-0 test runs
|
|
4
|
+
whose +2 scores kept resetting the stall streak). Polling stays exempt."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
import drydock.agent as agent_mod
|
|
10
|
+
from drydock.agent import AgentState, run
|
|
11
|
+
from drydock.loop_detect import LoopTracker
|
|
12
|
+
from drydock.providers import AssistantTurn
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_outcome_count_getter():
|
|
16
|
+
t = LoopTracker()
|
|
17
|
+
t.record_outcome("Write", {"file_path": "m.vim", "content": "x"}, "ok")
|
|
18
|
+
t.record_outcome("Write", {"file_path": "m.vim", "content": "x"}, "ok")
|
|
19
|
+
assert t.outcome_count("Write", {"file_path": "m.vim", "content": "x"}, "ok") == 2
|
|
20
|
+
# different result -> different pair
|
|
21
|
+
assert t.outcome_count("Write", {"file_path": "m.vim", "content": "x"}, "other") == 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cycling_model(macro_path):
|
|
25
|
+
"""The gov162 pattern: identical Write alternating with VARYING, succeeding
|
|
26
|
+
Bash commands — the interleaved +2s reset the global stall streak, so only
|
|
27
|
+
per-signature cycling detection can catch it."""
|
|
28
|
+
def stream(**kw):
|
|
29
|
+
n = stream.n
|
|
30
|
+
stream.n += 1
|
|
31
|
+
if n % 2 == 0:
|
|
32
|
+
tc = {"id": str(n), "name": "Write",
|
|
33
|
+
"input": {"file_path": macro_path, "content": "call setreg('a', \"gUU\")\n"}}
|
|
34
|
+
else:
|
|
35
|
+
# a different, harmless, succeeding command each time
|
|
36
|
+
tc = {"id": str(n), "name": "Bash",
|
|
37
|
+
"input": {"command": f"echo run {n} && touch t{n}.txt"}}
|
|
38
|
+
return iter([AssistantTurn("", [tc], 1, 1)])
|
|
39
|
+
stream.n = 0
|
|
40
|
+
return stream
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_nonconsecutive_identical_pair_gets_suppressed(monkeypatch):
|
|
44
|
+
macro = tempfile.NamedTemporaryFile(suffix=".vim", delete=False).name
|
|
45
|
+
monkeypatch.setattr(agent_mod, "stream", _cycling_model(macro))
|
|
46
|
+
st = AgentState()
|
|
47
|
+
cfg = {"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
|
|
48
|
+
"max_turns": 40}
|
|
49
|
+
list(run("transform the file with a macro", st, cfg, "sys"))
|
|
50
|
+
# After the same Write+result pair recurs past the threshold, stage 3 fires
|
|
51
|
+
# and the signature is suppressed — visible as the redirect message in a
|
|
52
|
+
# tool result.
|
|
53
|
+
tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
|
|
54
|
+
assert any("temporarily unavailable" in c for c in tool_msgs), \
|
|
55
|
+
"cycling Write was never suppressed"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_polling_with_changing_results_never_suppressed(monkeypatch):
|
|
59
|
+
# Same command, DIFFERENT result each time (a poll): each (call, result)
|
|
60
|
+
# pair is unique, so cycling detection must stay quiet.
|
|
61
|
+
seq = []
|
|
62
|
+
for i in range(10):
|
|
63
|
+
seq.append(AssistantTurn(
|
|
64
|
+
"", [{"id": str(i), "name": "Bash",
|
|
65
|
+
"input": {"command": "date +%N"}}], 1, 1)) # changing output
|
|
66
|
+
seq.append(AssistantTurn("done polling", [], 1, 1))
|
|
67
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
68
|
+
st = AgentState()
|
|
69
|
+
list(run("watch the clock", st,
|
|
70
|
+
{"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False}, "sys"))
|
|
71
|
+
tool_msgs = [m.get("content", "") for m in st.messages if m.get("role") == "tool"]
|
|
72
|
+
assert not any("temporarily unavailable" in c for c in tool_msgs)
|
|
73
|
+
assert any(m.get("content") == "done polling" for m in st.messages)
|
|
@@ -83,6 +83,20 @@ def test_escalation_is_monotonic_until_progress():
|
|
|
83
83
|
assert rc.stage == 0
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def test_second_fresh_suppression_of_same_signature_terminates():
|
|
87
|
+
# PRD K1.6: the first suppression ran its course and the model came straight
|
|
88
|
+
# back to the same call — suppress-expire-repeat forever is dishonest; stop.
|
|
89
|
+
rc = RecoveryController(suppression_iterations=2)
|
|
90
|
+
d1 = rc.escalate(3, offender_signature="sig", iteration=1)
|
|
91
|
+
assert d1.stage == 3 and not d1.terminate
|
|
92
|
+
# window refresh while still suppressed does NOT count as a second arming
|
|
93
|
+
d2 = rc.escalate(3, offender_signature="sig", iteration=2)
|
|
94
|
+
assert not d2.terminate
|
|
95
|
+
rc.tick(10) # suppression expires
|
|
96
|
+
d3 = rc.escalate(3, offender_signature="sig", iteration=10) # fresh re-arm
|
|
97
|
+
assert d3.stage == 5 and d3.terminate is True
|
|
98
|
+
|
|
99
|
+
|
|
86
100
|
def test_directive_dataclass_defaults():
|
|
87
101
|
d = RecoveryDirective()
|
|
88
102
|
assert d.stage == 0 and d.note is None and not d.suppress and not d.terminate
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Regression for the dna-insert bench finding (PRD Epic J3): the SAME failing
|
|
2
|
+
verification outcome recurring through DIFFERENT commands must accrue toward
|
|
3
|
+
recovery — no exact call repeats, so repeat_count alone never fires."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import drydock.agent as agent_mod
|
|
9
|
+
from drydock.agent import AgentState, run
|
|
10
|
+
from drydock.providers import AssistantTurn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _varying_failing_verifications():
|
|
14
|
+
"""Model that re-runs a failing check with a slightly different command each
|
|
15
|
+
time (comment varies) — same failing outcome, no byte-identical repeats."""
|
|
16
|
+
def stream(**kw):
|
|
17
|
+
n = stream.n
|
|
18
|
+
stream.n += 1
|
|
19
|
+
cmd = f'python3 -m pytest nonexistent_{0}.py # attempt {n}'
|
|
20
|
+
return iter([AssistantTurn(
|
|
21
|
+
"", [{"id": str(n), "name": "Bash", "input": {"command": cmd}}], 1, 1)])
|
|
22
|
+
stream.n = 0
|
|
23
|
+
return stream
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_same_failing_outcome_different_commands_triggers_recovery(monkeypatch):
|
|
27
|
+
monkeypatch.setattr(agent_mod, "stream", _varying_failing_verifications())
|
|
28
|
+
st = AgentState()
|
|
29
|
+
list(run("make the tests pass", st,
|
|
30
|
+
{"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False,
|
|
31
|
+
"max_turns": 60}, "sys"))
|
|
32
|
+
# The identical failing outcome (same exit/summary through varying commands)
|
|
33
|
+
# must drive recovery well short of the 60-turn ceiling.
|
|
34
|
+
assert st.turn_count < 30, f"ran {st.turn_count} turns — repeated outcome never fired"
|
|
35
|
+
assert st.recovery_stage >= 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_changing_failure_does_not_count_as_repeated_outcome(monkeypatch):
|
|
39
|
+
# Failures that CHANGE (different summary each time) are progress signals,
|
|
40
|
+
# not stalls — a healthy fix-test-fix loop must not trip J3.
|
|
41
|
+
seq = []
|
|
42
|
+
for i in range(1, 5):
|
|
43
|
+
# each command exits with a DIFFERENT code -> different summary key
|
|
44
|
+
seq.append(AssistantTurn(
|
|
45
|
+
"", [{"id": str(i), "name": "Bash",
|
|
46
|
+
"input": {"command": f'python3 -m pytest x.py; exit {i} # run'}}], 1, 1))
|
|
47
|
+
seq.append(AssistantTurn("done for now", [], 1, 1))
|
|
48
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
49
|
+
st = AgentState()
|
|
50
|
+
list(run("check things", st,
|
|
51
|
+
{"model": "m", "cwd": tempfile.mkdtemp(), "verify_gate": False}, "sys"))
|
|
52
|
+
# ran to its natural end without recovery escalating past advisory
|
|
53
|
+
assert any(m.get("content") == "done for now" for m in st.messages)
|
|
54
|
+
assert st.recovery_stage <= 1
|
|
@@ -40,6 +40,18 @@ def test_classify_changed_state_and_retryable():
|
|
|
40
40
|
assert r.status == "error" and r.retryable and r.error_code == "transient"
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def test_classify_redirect_writes_are_mutations():
|
|
44
|
+
# Regression (gov162 make-mips-interpreter trace): the shared \b after '>'
|
|
45
|
+
# missed every space-separated redirect, so 38 heredoc/redirect writes all
|
|
46
|
+
# scored as "nothing changed".
|
|
47
|
+
assert classify("Bash", {"command": "echo hi > out.txt"}, "", 1).changed_state
|
|
48
|
+
assert classify("Bash", {"command": "cat <<EOF > prog.py\nx\nEOF"}, "", 1).changed_state
|
|
49
|
+
assert classify("Bash", {"command": "python gen.py >> log.txt"}, "", 1).changed_state
|
|
50
|
+
# fd-redirection is NOT a file write; plain reads stay non-mutating
|
|
51
|
+
assert not classify("Bash", {"command": "diff a b"}, "", 1).changed_state
|
|
52
|
+
assert not classify("Bash", {"command": "cat file.txt"}, "x", 1).changed_state
|
|
53
|
+
|
|
54
|
+
|
|
43
55
|
def test_error_prefix_string():
|
|
44
56
|
r = classify("Edit", {}, "Error: old_string not found", 1)
|
|
45
57
|
assert r.status == "error" and not r.changed_state # failed edit didn't mutate
|
|
File without changes
|