drydock-cli 3.0.122__tar.gz → 3.0.124__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.122/drydock_cli.egg-info → drydock_cli-3.0.124}/PKG-INFO +1 -1
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/agent.py +47 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/config.py +8 -0
- drydock_cli-3.0.124/drydock/task_state.py +84 -0
- drydock_cli-3.0.124/drydock/verification.py +34 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/SOURCES.txt +4 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/pyproject.toml +1 -1
- drydock_cli-3.0.124/tests/test_task_state.py +69 -0
- drydock_cli-3.0.124/tests/test_verification_gate.py +71 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/LICENSE +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/NOTICE +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/README.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/cci.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/cli.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/detect.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/extract.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/guards.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/poam.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/providers.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/recipes.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/skills.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/stig.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/suggest.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock/web.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/setup.cfg +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_config.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_recipes.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_suggest.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.122 → drydock_cli-3.0.124}/tests/test_xccdf.py +0 -0
|
@@ -47,6 +47,8 @@ from drydock.compaction import (
|
|
|
47
47
|
maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
|
|
48
48
|
)
|
|
49
49
|
from drydock.loop_detect import LoopTracker
|
|
50
|
+
from drydock.task_state import TaskState
|
|
51
|
+
from drydock.verification import looks_like_verification
|
|
50
52
|
from drydock.tuning import (
|
|
51
53
|
filter_tool_schemas,
|
|
52
54
|
hallucinated_tool_message,
|
|
@@ -80,6 +82,7 @@ class AgentState:
|
|
|
80
82
|
last_input_tokens: int = 0 # prompt tokens the SERVER counted on the last call
|
|
81
83
|
turn_count: int = 0
|
|
82
84
|
current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
|
|
85
|
+
task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
|
|
83
86
|
|
|
84
87
|
|
|
85
88
|
def drop_last_turn(messages: list) -> bool:
|
|
@@ -114,6 +117,18 @@ def run(
|
|
|
114
117
|
"""
|
|
115
118
|
state.messages.append({"role": "user", "content": user_message})
|
|
116
119
|
|
|
120
|
+
# Capture the structured objective the FIRST time a task arrives, so it lives
|
|
121
|
+
# outside the transcript. The original objective is authoritative for the task.
|
|
122
|
+
if not state.task.is_set() and isinstance(user_message, str) and user_message.strip():
|
|
123
|
+
state.task = TaskState.from_objective(user_message)
|
|
124
|
+
# Keep the objective + acceptance criteria in the SYSTEM PROMPT every turn, so
|
|
125
|
+
# they survive compaction (which only touches the message transcript, never the
|
|
126
|
+
# system prompt) — the model can't drift off the goal on a long task.
|
|
127
|
+
if config.get("task_anchor", True) and state.task.is_set():
|
|
128
|
+
anchor = state.task.anchor_text()
|
|
129
|
+
if anchor:
|
|
130
|
+
system_prompt = system_prompt + "\n\n" + anchor
|
|
131
|
+
|
|
117
132
|
# Recipe retrieval: give the model the TECHNIQUE this task needs (forensics,
|
|
118
133
|
# git-history rewrite, numpy-2.0 fix, cert gen, …) by appending the relevant
|
|
119
134
|
# bundled recipes to the system prompt. Keyword-matched, so only fitting ones
|
|
@@ -139,6 +154,8 @@ def run(
|
|
|
139
154
|
return cancel is not None and cancel.is_set()
|
|
140
155
|
tool_call_count = 0
|
|
141
156
|
session_has_edited = False
|
|
157
|
+
ran_verification = False # a test/check/exec command ran (verification gate)
|
|
158
|
+
verify_gate_nudges = 0 # bounded "verify before you finish" nudges
|
|
142
159
|
leaked_call_retries = 0
|
|
143
160
|
plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
|
|
144
161
|
empty_response_nudges = 0 # consecutive "you returned nothing" nudges
|
|
@@ -318,6 +335,32 @@ def run(
|
|
|
318
335
|
),
|
|
319
336
|
})
|
|
320
337
|
continue
|
|
338
|
+
# VERIFICATION GATE (PRD Epic B): a text-only "done" is not evidence.
|
|
339
|
+
# If the agent CHANGED files but never ran a test/check/its own code,
|
|
340
|
+
# don't accept completion — make it verify first. Bounded so it can't
|
|
341
|
+
# wedge; once it runs any check (ran_verification) the gate is satisfied.
|
|
342
|
+
if (
|
|
343
|
+
config.get("verify_gate", True)
|
|
344
|
+
and session_has_edited
|
|
345
|
+
and not ran_verification
|
|
346
|
+
and verify_gate_nudges < 2
|
|
347
|
+
):
|
|
348
|
+
verify_gate_nudges += 1
|
|
349
|
+
state.task.phase = "verify"
|
|
350
|
+
state.messages.append({
|
|
351
|
+
"role": "user",
|
|
352
|
+
"content": (
|
|
353
|
+
"[SYSTEM] You changed files but have not VERIFIED the work. "
|
|
354
|
+
"Before finishing, run a concrete check: the task's own test/"
|
|
355
|
+
"eval/build (pytest, test.sh, make, npm test, …) or run the "
|
|
356
|
+
"code/script you produced and confirm the output is correct "
|
|
357
|
+
"and meets EVERY requirement. Do that now with a tool call; "
|
|
358
|
+
"if it fails, fix it and re-check."
|
|
359
|
+
),
|
|
360
|
+
})
|
|
361
|
+
continue
|
|
362
|
+
if session_has_edited and ran_verification:
|
|
363
|
+
state.task.phase = "complete"
|
|
321
364
|
break
|
|
322
365
|
|
|
323
366
|
# Execute each tool call
|
|
@@ -325,6 +368,10 @@ def run(
|
|
|
325
368
|
tool_call_count += 1
|
|
326
369
|
if tc["name"] in ("Edit", "Write"):
|
|
327
370
|
session_has_edited = True
|
|
371
|
+
elif tc["name"] == "Bash" and looks_like_verification(
|
|
372
|
+
(tc.get("input") or {}).get("command", "")
|
|
373
|
+
):
|
|
374
|
+
ran_verification = True # the agent actually ran a test/check/its code
|
|
328
375
|
|
|
329
376
|
# STOP pressed: don't run the remaining tools, but still record a
|
|
330
377
|
# paired result for each (the assistant message already lists all
|
|
@@ -44,6 +44,14 @@ DEFAULTS: dict[str, object] = {
|
|
|
44
44
|
# instead of guessing. Retrieval is keyword-overlap; only relevant recipes are
|
|
45
45
|
# added (none = nothing injected). Set false to disable.
|
|
46
46
|
"recipes": True,
|
|
47
|
+
# Keep the structured objective + acceptance criteria (task_state.py) in the
|
|
48
|
+
# system prompt every turn so they survive context compaction — the model can't
|
|
49
|
+
# drift off the original goal on a long task. Set false to disable.
|
|
50
|
+
"task_anchor": True,
|
|
51
|
+
# Verification gate (PRD Epic B): if the model claims "done" after changing
|
|
52
|
+
# files but never ran a test/check/its own code, nudge it to verify first
|
|
53
|
+
# instead of accepting completion. Bounded. Set false to disable.
|
|
54
|
+
"verify_gate": True,
|
|
47
55
|
"theme": "harbor",
|
|
48
56
|
# Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
|
|
49
57
|
# Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Structured task state — the authoritative objective + acceptance criteria kept
|
|
2
|
+
OUTSIDE the chat transcript, so they survive context compaction and the model never
|
|
3
|
+
loses sight of the goal on a long task.
|
|
4
|
+
|
|
5
|
+
First increment toward the Agent-Buildout PRD (Epic A: Structured Task State /
|
|
6
|
+
Principle 4.2 "structured state is authoritative"). Deliberately small — a dataclass
|
|
7
|
+
the agent loop populates once and re-injects as a compact anchor after compaction —
|
|
8
|
+
so it drops into the current loop without a rewrite. All logic original to Drydock.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
# Requirement-like lines: numbered (1. / 1)) or bulleted (- * •).
|
|
16
|
+
_LIST_ITEM = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$", re.M)
|
|
17
|
+
# Imperative "must/should/exactly/required" sentences (fallback when there's no list).
|
|
18
|
+
_IMPERATIVE = re.compile(r"\b(must|should|exactly|required|ensure|has to|needs? to)\b", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_acceptance_criteria(objective: str, limit: int = 10) -> list[str]:
|
|
22
|
+
"""Pull discrete acceptance criteria out of the objective: numbered/bulleted
|
|
23
|
+
lines first, else imperative must/should sentences. Best-effort; never raises."""
|
|
24
|
+
if not objective or not isinstance(objective, str):
|
|
25
|
+
return []
|
|
26
|
+
items = [m.group(1).strip() for m in _LIST_ITEM.finditer(objective)]
|
|
27
|
+
if not items:
|
|
28
|
+
for s in re.split(r"(?<=[.!?])\s+", objective):
|
|
29
|
+
s = s.strip()
|
|
30
|
+
if s and len(s) < 240 and _IMPERATIVE.search(s):
|
|
31
|
+
items.append(s)
|
|
32
|
+
# de-dupe preserving order
|
|
33
|
+
seen: set[str] = set()
|
|
34
|
+
out = []
|
|
35
|
+
for it in items:
|
|
36
|
+
k = it.lower()
|
|
37
|
+
if k not in seen:
|
|
38
|
+
seen.add(k)
|
|
39
|
+
out.append(it)
|
|
40
|
+
return out[:limit]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TaskState:
|
|
45
|
+
"""Authoritative task facts, independent of the message transcript."""
|
|
46
|
+
objective: str = ""
|
|
47
|
+
acceptance_criteria: list[str] = field(default_factory=list)
|
|
48
|
+
phase: str = "understand"
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_objective(cls, objective: str) -> TaskState:
|
|
52
|
+
obj = (objective or "").strip() if isinstance(objective, str) else str(objective or "").strip()
|
|
53
|
+
return cls(objective=obj, acceptance_criteria=extract_acceptance_criteria(obj))
|
|
54
|
+
|
|
55
|
+
def is_set(self) -> bool:
|
|
56
|
+
return bool(self.objective)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
"""Serializable WITHOUT the transcript — the task can be reconstructed from this."""
|
|
60
|
+
return {
|
|
61
|
+
"objective": self.objective,
|
|
62
|
+
"acceptance_criteria": list(self.acceptance_criteria),
|
|
63
|
+
"phase": self.phase,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, d: dict) -> TaskState:
|
|
68
|
+
return cls(
|
|
69
|
+
objective=d.get("objective", ""),
|
|
70
|
+
acceptance_criteria=list(d.get("acceptance_criteria", [])),
|
|
71
|
+
phase=d.get("phase", "understand"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def anchor_text(self) -> str:
|
|
75
|
+
"""A compact, durable reminder re-injected after compaction so a summarized
|
|
76
|
+
transcript can never lose the original objective + criteria. '' if unset."""
|
|
77
|
+
if not self.objective:
|
|
78
|
+
return ""
|
|
79
|
+
head = self.objective if len(self.objective) <= 700 else self.objective[:700] + " …"
|
|
80
|
+
lines = [f"[TASK — do not lose sight of the original objective]\n{head}"]
|
|
81
|
+
if self.acceptance_criteria:
|
|
82
|
+
lines.append("Acceptance criteria (all must be met):")
|
|
83
|
+
lines += [f" - {c}" for c in self.acceptance_criteria]
|
|
84
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Verification gating (Agent-Buildout PRD, Epic B / verification):
|
|
2
|
+
|
|
3
|
+
"A text-only model response is not evidence that a task is complete."
|
|
4
|
+
|
|
5
|
+
The agent loop uses this to decide, when the model claims "done", whether it
|
|
6
|
+
actually VERIFIED — i.e. ran a test, a linter/type-check, a build, or executed the
|
|
7
|
+
code it produced. If it changed files but ran nothing, the loop nudges it to verify
|
|
8
|
+
instead of accepting completion. Deliberately small (a command classifier + the loop
|
|
9
|
+
gate) so it drops into the current loop without a rewrite. Logic original to Drydock.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Explicit test / lint / type-check / build commands.
|
|
16
|
+
_VERIFY_KEYWORDS = re.compile(
|
|
17
|
+
r"\b(pytest|py\.test|unittest|nosetests?|tox|test\.sh|run_tests|"
|
|
18
|
+
r"make(\s+\w+)?|npm\s+(test|run)|yarn\s+(test|run)|pnpm\s+(test|run)|jest|mocha|vitest|"
|
|
19
|
+
r"cargo\s+(test|check|build|run|clippy)|go\s+(test|build|run|vet)|"
|
|
20
|
+
r"ruff|flake8|pylint|mypy|pyright|eslint|tsc|prettier|"
|
|
21
|
+
r"ctest|cmake|gradle|mvn|rspec|phpunit|dotnet\s+test|bats)\b",
|
|
22
|
+
re.I,
|
|
23
|
+
)
|
|
24
|
+
# Executing something — running the produced script/binary counts as checking it.
|
|
25
|
+
_EXEC = re.compile(r"(?:^|[\s;&|(])(?:python3?|node|deno|bun|bash|sh|ruby|perl|"
|
|
26
|
+
r"java|javac|gcc|g\+\+|clang|\./|\.\\)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def looks_like_verification(cmd) -> bool:
|
|
30
|
+
"""True if a shell command constitutes verifying work — running tests/checks/
|
|
31
|
+
builds, or executing the produced code. Best-effort; never raises."""
|
|
32
|
+
if not cmd or not isinstance(cmd, str):
|
|
33
|
+
return False
|
|
34
|
+
return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
|
|
@@ -26,8 +26,10 @@ drydock/rmf_graph.py
|
|
|
26
26
|
drydock/skills.py
|
|
27
27
|
drydock/stig.py
|
|
28
28
|
drydock/suggest.py
|
|
29
|
+
drydock/task_state.py
|
|
29
30
|
drydock/tool_registry.py
|
|
30
31
|
drydock/tuning.py
|
|
32
|
+
drydock/verification.py
|
|
31
33
|
drydock/web.py
|
|
32
34
|
drydock/builtin_skills/__init__.py
|
|
33
35
|
drydock/builtin_skills/ml-data.md
|
|
@@ -117,6 +119,7 @@ tests/test_streaming_newlines.py
|
|
|
117
119
|
tests/test_subagent.py
|
|
118
120
|
tests/test_suggest.py
|
|
119
121
|
tests/test_system_prompt_help.py
|
|
122
|
+
tests/test_task_state.py
|
|
120
123
|
tests/test_todo.py
|
|
121
124
|
tests/test_tool_arg_coercion.py
|
|
122
125
|
tests/test_tool_arg_coercion_more.py
|
|
@@ -124,6 +127,7 @@ tests/test_tool_arg_parsing.py
|
|
|
124
127
|
tests/test_tools_undo.py
|
|
125
128
|
tests/test_tui.py
|
|
126
129
|
tests/test_tuning.py
|
|
130
|
+
tests/test_verification_gate.py
|
|
127
131
|
tests/test_viewimage.py
|
|
128
132
|
tests/test_vision_input.py
|
|
129
133
|
tests/test_web_tools.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.124"
|
|
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,69 @@
|
|
|
1
|
+
"""Structured task state (Agent-Buildout PRD Epic A): the objective + acceptance
|
|
2
|
+
criteria live outside the transcript, are serializable, and survive compaction."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock import agent
|
|
6
|
+
from drydock.providers import AssistantTurn
|
|
7
|
+
from drydock.task_state import TaskState, extract_acceptance_criteria
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_extracts_numbered_and_bulleted_criteria():
|
|
11
|
+
obj = "Build a parser. Requirements:\n1. Read /app/in.txt\n2. Write /app/out.json\n- Exit 0"
|
|
12
|
+
c = extract_acceptance_criteria(obj)
|
|
13
|
+
assert c == ["Read /app/in.txt", "Write /app/out.json", "Exit 0"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_extracts_imperative_when_no_list():
|
|
17
|
+
c = extract_acceptance_criteria("The script must print the CN and it should exit 0.")
|
|
18
|
+
assert any("must print" in x for x in c)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_from_objective_and_serializable_without_transcript():
|
|
22
|
+
ts = TaskState.from_objective("Do X.\n1. A\n2. B")
|
|
23
|
+
assert ts.is_set() and ts.acceptance_criteria == ["A", "B"] and ts.phase == "understand"
|
|
24
|
+
d = ts.to_dict() # A1.1: serializable without the transcript
|
|
25
|
+
assert d["objective"] and d["acceptance_criteria"] == ["A", "B"]
|
|
26
|
+
assert TaskState.from_dict(d).objective == ts.objective # reconstructable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_anchor_text_includes_objective_and_criteria():
|
|
30
|
+
a = TaskState.from_objective("Fix the bug.\n1. tests pass\n2. no regressions").anchor_text()
|
|
31
|
+
assert "Fix the bug" in a and "tests pass" in a and "no regressions" in a
|
|
32
|
+
assert TaskState().anchor_text() == "" # empty when unset
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_capture(cfg, msg, state=None):
|
|
36
|
+
seen = {}
|
|
37
|
+
def fake_stream(model, system, messages, tool_schemas, config):
|
|
38
|
+
seen["system"] = system
|
|
39
|
+
yield AssistantTurn("done", [], 5, 5)
|
|
40
|
+
orig = agent.stream; agent.stream = fake_stream
|
|
41
|
+
try:
|
|
42
|
+
st = state or agent.AgentState()
|
|
43
|
+
list(agent.run(msg, st, cfg, "BASE"))
|
|
44
|
+
return seen["system"], st
|
|
45
|
+
finally:
|
|
46
|
+
agent.stream = orig
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_objective_captured_and_anchored_in_system_prompt():
|
|
50
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
|
|
51
|
+
sysp, st = _run_capture(cfg, "Recover the password.\n1. it is 23 chars\n2. write it to out.txt")
|
|
52
|
+
assert st.task.is_set() and st.task.acceptance_criteria == ["it is 23 chars", "write it to out.txt"]
|
|
53
|
+
assert "TASK" in sysp and "23 chars" in sysp # anchor present in system prompt
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_anchor_survives_a_compacted_follow_up_turn():
|
|
57
|
+
# A2: after the transcript is replaced (compaction), the objective is still in the
|
|
58
|
+
# system prompt on the next turn because it lives in TaskState, not the messages.
|
|
59
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
|
|
60
|
+
_, st = _run_capture(cfg, "Original objective X.\n1. criterion one")
|
|
61
|
+
st.messages = [{"role": "user", "content": "(transcript summarized away)"}] # simulate compaction
|
|
62
|
+
sysp2, _ = _run_capture(cfg, "keep going", state=st)
|
|
63
|
+
assert "Original objective X" in sysp2 and "criterion one" in sysp2
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_disabled_when_task_anchor_false():
|
|
67
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": False}
|
|
68
|
+
sysp, _ = _run_capture(cfg, "Do the thing.\n1. a criterion")
|
|
69
|
+
assert "TASK" not in sysp
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Verification gating (Agent-Buildout PRD Epic B): a text-only 'done' after editing
|
|
2
|
+
files is not accepted until the agent runs a check. Model can't self-declare done."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock import agent
|
|
6
|
+
from drydock.providers import AssistantTurn
|
|
7
|
+
from drydock.verification import looks_like_verification as v
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_classifier():
|
|
11
|
+
for cmd in ("pytest -q", "python -m pytest tests/", "bash test.sh", "make test",
|
|
12
|
+
"npm test", "cargo test", "ruff check .", "mypy src", "python solve.py",
|
|
13
|
+
"./run_check", "node index.js"):
|
|
14
|
+
assert v(cmd), cmd
|
|
15
|
+
for cmd in ("ls -la", "cat file.txt", "grep foo bar", "echo hi", "git status", ""):
|
|
16
|
+
assert not v(cmd), cmd
|
|
17
|
+
assert not v(None) and not v(123)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _drive(cfg, turns):
|
|
21
|
+
"""turns: list of AssistantTurn to yield in order; returns the messages seen."""
|
|
22
|
+
calls = {"i": 0}
|
|
23
|
+
def fake_stream(model, system, messages, tool_schemas, config):
|
|
24
|
+
i = calls["i"]; calls["i"] += 1
|
|
25
|
+
yield turns[min(i, len(turns) - 1)]
|
|
26
|
+
orig = agent.stream; agent.stream = fake_stream
|
|
27
|
+
try:
|
|
28
|
+
st = agent.AgentState()
|
|
29
|
+
list(agent.run("do it", st, cfg, "SYS"))
|
|
30
|
+
return st, calls["i"]
|
|
31
|
+
finally:
|
|
32
|
+
agent.stream = orig
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
CFG = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "verify_gate": True}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_gate_fires_after_edit_without_verification():
|
|
39
|
+
# turn 1: Write a file (no tool result needed — execute() runs the real tool);
|
|
40
|
+
# turn 2: text-only "done". Gate should nudge -> a 3rd call happens.
|
|
41
|
+
write = AssistantTurn("", [{"id": "1", "name": "Write",
|
|
42
|
+
"input": {"file_path": "/tmp/dd_vg.txt", "content": "x"}}], 5, 5)
|
|
43
|
+
done = AssistantTurn("All done!", [], 5, 5)
|
|
44
|
+
st, n_calls = _drive(CFG, [write, done, done])
|
|
45
|
+
# the gate injected a verify nudge → the loop asked the model again (>2 calls)
|
|
46
|
+
assert n_calls >= 3
|
|
47
|
+
assert any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_no_gate_when_verification_ran():
|
|
51
|
+
write = AssistantTurn("", [{"id": "1", "name": "Write",
|
|
52
|
+
"input": {"file_path": "/tmp/dd_vg2.txt", "content": "x"}}], 5, 5)
|
|
53
|
+
check = AssistantTurn("", [{"id": "2", "name": "Bash", "input": {"command": "python -c 'print(1)'"}}], 5, 5)
|
|
54
|
+
done = AssistantTurn("Done, verified.", [], 5, 5)
|
|
55
|
+
st, _ = _drive(CFG, [write, check, done])
|
|
56
|
+
assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
57
|
+
assert st.task.phase == "complete"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_no_gate_when_no_edits():
|
|
61
|
+
# pure Q&A: no files changed → nothing to verify → accept immediately
|
|
62
|
+
st, _ = _drive(CFG, [AssistantTurn("The answer is 42.", [], 5, 5)])
|
|
63
|
+
assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_disabled():
|
|
67
|
+
write = AssistantTurn("", [{"id": "1", "name": "Write",
|
|
68
|
+
"input": {"file_path": "/tmp/dd_vg3.txt", "content": "x"}}], 5, 5)
|
|
69
|
+
done = AssistantTurn("done", [], 5, 5)
|
|
70
|
+
st, _ = _drive({**CFG, "verify_gate": False}, [write, done])
|
|
71
|
+
assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
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
|
|
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
|
|
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
|