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