drydock-cli 3.0.125__tar.gz → 3.0.127__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.125/drydock_cli.egg-info → drydock_cli-3.0.127}/PKG-INFO +1 -1
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/agent.py +52 -22
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/config.py +3 -0
- drydock_cli-3.0.127/drydock/events.py +68 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/app.py +3 -0
- drydock_cli-3.0.127/drydock/verification.py +75 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/pyproject.toml +1 -1
- drydock_cli-3.0.127/tests/test_events.py +58 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_verification_gate.py +32 -0
- drydock_cli-3.0.125/drydock/verification.py +0 -34
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/LICENSE +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/NOTICE +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/README.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/cci.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/cli.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/detect.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/extract.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/guards.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/poam.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/providers.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/recipes.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/skills.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/stig.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/suggest.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/task_state.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock/web.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/setup.cfg +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_config.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_recipes.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_suggest.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_task_state.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.125 → drydock_cli-3.0.127}/tests/test_xccdf.py +0 -0
|
@@ -48,7 +48,8 @@ from drydock.compaction import (
|
|
|
48
48
|
)
|
|
49
49
|
from drydock.loop_detect import LoopTracker
|
|
50
50
|
from drydock.task_state import TaskState
|
|
51
|
-
from drydock.verification import looks_like_verification
|
|
51
|
+
from drydock.verification import looks_like_verification, parse_evidence
|
|
52
|
+
from drydock.events import emit as _emit
|
|
52
53
|
from drydock.tuning import (
|
|
53
54
|
filter_tool_schemas,
|
|
54
55
|
hallucinated_tool_message,
|
|
@@ -83,6 +84,7 @@ class AgentState:
|
|
|
83
84
|
turn_count: int = 0
|
|
84
85
|
current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
|
|
85
86
|
task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
|
|
87
|
+
events: object | None = None # optional EventLog for a durable execution trace
|
|
86
88
|
|
|
87
89
|
|
|
88
90
|
def drop_last_turn(messages: list) -> bool:
|
|
@@ -121,6 +123,9 @@ def run(
|
|
|
121
123
|
# outside the transcript. The original objective is authoritative for the task.
|
|
122
124
|
if not state.task.is_set() and isinstance(user_message, str) and user_message.strip():
|
|
123
125
|
state.task = TaskState.from_objective(user_message)
|
|
126
|
+
_emit(state, "task_start", objective=state.task.objective,
|
|
127
|
+
acceptance_criteria=state.task.acceptance_criteria)
|
|
128
|
+
_emit(state, "user_message", chars=len(user_message or ""))
|
|
124
129
|
# Keep the objective + acceptance criteria in the SYSTEM PROMPT every turn, so
|
|
125
130
|
# they survive compaction (which only touches the message transcript, never the
|
|
126
131
|
# system prompt) — the model can't drift off the goal on a long task.
|
|
@@ -154,8 +159,8 @@ def run(
|
|
|
154
159
|
return cancel is not None and cancel.is_set()
|
|
155
160
|
tool_call_count = 0
|
|
156
161
|
session_has_edited = False
|
|
157
|
-
|
|
158
|
-
verify_gate_nudges = 0
|
|
162
|
+
last_verification = None # VerificationEvidence of the most recent check (gate)
|
|
163
|
+
verify_gate_nudges = 0 # bounded "verify before you finish" nudges
|
|
159
164
|
leaked_call_retries = 0
|
|
160
165
|
plan_continue_nudges = 0 # consecutive "you stopped mid-plan" nudges
|
|
161
166
|
empty_response_nudges = 0 # consecutive "you returned nothing" nudges
|
|
@@ -281,6 +286,9 @@ def run(
|
|
|
281
286
|
state.total_output_tokens += assistant_turn.output_tokens
|
|
282
287
|
state.last_input_tokens = assistant_turn.input_tokens
|
|
283
288
|
yield TurnDone(assistant_turn.input_tokens, assistant_turn.output_tokens)
|
|
289
|
+
_emit(state, "turn", in_tok=assistant_turn.input_tokens,
|
|
290
|
+
out_tok=assistant_turn.output_tokens,
|
|
291
|
+
tool_calls=len(assistant_turn.tool_calls or []))
|
|
284
292
|
|
|
285
293
|
# No tool calls = conversation complete — UNLESS the model emitted a
|
|
286
294
|
# tool call as text (a Gemma quirk the API can't structure). In that
|
|
@@ -339,29 +347,42 @@ def run(
|
|
|
339
347
|
# If the agent CHANGED files but never ran a test/check/its own code,
|
|
340
348
|
# don't accept completion — make it verify first. Bounded so it can't
|
|
341
349
|
# wedge; once it runs any check (ran_verification) the gate is satisfied.
|
|
342
|
-
|
|
350
|
+
# A text-only "done" after editing is accepted only when a check has
|
|
351
|
+
# PASSED. Never ran one → nudge to VERIFY. Ran one that FAILED → the work
|
|
352
|
+
# isn't done → nudge to REPAIR. Bounded so it can't wedge.
|
|
353
|
+
_needs_gate = (
|
|
343
354
|
config.get("verify_gate", True)
|
|
344
355
|
and allow is None # main task only — not scoped sub-agents
|
|
345
356
|
and session_has_edited
|
|
346
|
-
and not ran_verification
|
|
347
357
|
and verify_gate_nudges < 2
|
|
348
|
-
|
|
358
|
+
and (last_verification is None or last_verification.status == "fail")
|
|
359
|
+
)
|
|
360
|
+
if _needs_gate:
|
|
349
361
|
verify_gate_nudges += 1
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
"
|
|
353
|
-
|
|
362
|
+
if last_verification is None:
|
|
363
|
+
state.task.phase = "verify"
|
|
364
|
+
_emit(state, "verify_gate", kind="unverified", nudge=verify_gate_nudges)
|
|
365
|
+
msg = (
|
|
354
366
|
"[SYSTEM] You changed files but have not VERIFIED the work. "
|
|
355
|
-
"
|
|
356
|
-
"
|
|
357
|
-
"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
"Run a concrete check now: the task's own test/eval/build "
|
|
368
|
+
"(pytest, test.sh, make, npm test, …) or run the code you "
|
|
369
|
+
"produced, and confirm it meets EVERY requirement."
|
|
370
|
+
)
|
|
371
|
+
else: # a check ran and FAILED
|
|
372
|
+
state.task.phase = "repair"
|
|
373
|
+
_emit(state, "verify_gate", kind="failed", nudge=verify_gate_nudges,
|
|
374
|
+
summary=last_verification.summary)
|
|
375
|
+
msg = (
|
|
376
|
+
f"[SYSTEM] Your verification FAILED ({last_verification.summary}). "
|
|
377
|
+
"The task is NOT complete. Read the failure, fix the cause, then "
|
|
378
|
+
"re-run the SAME check until it passes."
|
|
379
|
+
)
|
|
380
|
+
state.messages.append({"role": "user", "content": msg})
|
|
362
381
|
continue
|
|
363
|
-
if session_has_edited and
|
|
382
|
+
if session_has_edited and last_verification and last_verification.status == "pass":
|
|
364
383
|
state.task.phase = "complete"
|
|
384
|
+
_emit(state, "done", phase=state.task.phase, edited=session_has_edited,
|
|
385
|
+
verified=bool(last_verification and last_verification.status == "pass"))
|
|
365
386
|
break
|
|
366
387
|
|
|
367
388
|
# Execute each tool call
|
|
@@ -369,10 +390,6 @@ def run(
|
|
|
369
390
|
tool_call_count += 1
|
|
370
391
|
if tc["name"] in ("Edit", "Write"):
|
|
371
392
|
session_has_edited = True
|
|
372
|
-
elif tc["name"] == "Bash" and looks_like_verification(
|
|
373
|
-
(tc.get("input") or {}).get("command", "")
|
|
374
|
-
):
|
|
375
|
-
ran_verification = True # the agent actually ran a test/check/its code
|
|
376
393
|
|
|
377
394
|
# STOP pressed: don't run the remaining tools, but still record a
|
|
378
395
|
# paired result for each (the assistant message already lists all
|
|
@@ -424,6 +441,19 @@ def run(
|
|
|
424
441
|
# advisory note when the same call is made again.
|
|
425
442
|
result = loop_tracker.annotate(tc["name"], tc["input"], result)
|
|
426
443
|
|
|
444
|
+
# Verification evidence: if this Bash call was a test/check/exec, parse
|
|
445
|
+
# its result so the completion gate knows whether it PASSED, not just ran.
|
|
446
|
+
if tc["name"] == "Bash":
|
|
447
|
+
_vcmd = (tc.get("input") or {}).get("command", "")
|
|
448
|
+
if looks_like_verification(_vcmd):
|
|
449
|
+
last_verification = parse_evidence(_vcmd, result)
|
|
450
|
+
_emit(state, "verification", status=last_verification.status,
|
|
451
|
+
exit_code=last_verification.exit_code)
|
|
452
|
+
|
|
453
|
+
_emit(state, "tool", name=tc["name"],
|
|
454
|
+
input=str(tc.get("input"))[:200],
|
|
455
|
+
result_chars=len(str(result)),
|
|
456
|
+
error=str(result)[:80].lower().startswith("error"))
|
|
427
457
|
yield ToolEnd(tc["name"], result)
|
|
428
458
|
|
|
429
459
|
# Append tool result
|
|
@@ -52,6 +52,9 @@ DEFAULTS: dict[str, object] = {
|
|
|
52
52
|
# files but never ran a test/check/its own code, nudge it to verify first
|
|
53
53
|
# instead of accepting completion. Bounded. Set false to disable.
|
|
54
54
|
"verify_gate": True,
|
|
55
|
+
# Durable per-session execution trace (events.py) written as JSONL to
|
|
56
|
+
# ~/.drydock/events/ — inspect/diagnose/replay a task. Set false to disable.
|
|
57
|
+
"event_log": True,
|
|
55
58
|
"theme": "harbor",
|
|
56
59
|
# Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
|
|
57
60
|
# Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Durable execution event log (Agent-Buildout PRD, Epic: events).
|
|
2
|
+
|
|
3
|
+
Every task run appends structured events to a per-session JSONL file so a task can
|
|
4
|
+
be inspected, diagnosed, replayed, and later resumed. Append-only, one event per
|
|
5
|
+
line: ``{"seq", "ts", "type", ...}``. DEFENSIVE — a logging failure must never crash
|
|
6
|
+
the agent, so every I/O path swallows its own errors. All logic original to Drydock.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventLog:
|
|
16
|
+
"""Append-only JSONL sink for execution events. Cheap to call; never raises."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path):
|
|
19
|
+
self.path = Path(path)
|
|
20
|
+
self._seq = 0
|
|
21
|
+
try:
|
|
22
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def emit(self, type: str, **data) -> None:
|
|
27
|
+
self._seq += 1
|
|
28
|
+
rec = {"seq": self._seq, "ts": round(time.time(), 3), "type": type}
|
|
29
|
+
rec.update(data)
|
|
30
|
+
try:
|
|
31
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
32
|
+
f.write(json.dumps(rec, default=str, ensure_ascii=False) + "\n")
|
|
33
|
+
except (OSError, TypeError, ValueError):
|
|
34
|
+
pass # logging must never break a run
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def read(path) -> list[dict]:
|
|
38
|
+
"""Parse an event-log JSONL into a list of event dicts (in order). Skips any
|
|
39
|
+
unparseable line; returns [] if the file is missing."""
|
|
40
|
+
out: list[dict] = []
|
|
41
|
+
try:
|
|
42
|
+
text = Path(path).read_text("utf-8", errors="ignore")
|
|
43
|
+
except OSError:
|
|
44
|
+
return out
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if not line:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
out.append(json.loads(line))
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
continue
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def emit(state, type: str, **data) -> None:
|
|
57
|
+
"""Emit to the run's event log if it has one — a no-op otherwise. Lets the agent
|
|
58
|
+
loop log unconditionally without a log everywhere."""
|
|
59
|
+
log = getattr(state, "events", None)
|
|
60
|
+
if log is not None:
|
|
61
|
+
log.emit(type, **data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_event_log_path():
|
|
65
|
+
"""A fresh per-session JSONL path under ~/.drydock/events/."""
|
|
66
|
+
import time
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
return Path.home() / ".drydock" / "events" / f"session-{int(time.time())}.jsonl"
|
|
@@ -259,6 +259,9 @@ class DrydockApp(App):
|
|
|
259
259
|
super().__init__()
|
|
260
260
|
self.config = config
|
|
261
261
|
self.state = AgentState()
|
|
262
|
+
if config.get("event_log", True):
|
|
263
|
+
from drydock.events import EventLog, default_event_log_path
|
|
264
|
+
self.state.events = EventLog(config.get("event_log_path") or default_event_log_path())
|
|
262
265
|
self.system = self._build_system(config.get("model"))
|
|
263
266
|
from drydock.skills import load_skills
|
|
264
267
|
self._skills = load_skills(config.get("cwd") or ".")
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
# Explicit test / lint / type-check / build commands.
|
|
17
|
+
_VERIFY_KEYWORDS = re.compile(
|
|
18
|
+
r"\b(pytest|py\.test|unittest|nosetests?|tox|test\.sh|run_tests|"
|
|
19
|
+
r"make(\s+\w+)?|npm\s+(test|run)|yarn\s+(test|run)|pnpm\s+(test|run)|jest|mocha|vitest|"
|
|
20
|
+
r"cargo\s+(test|check|build|run|clippy)|go\s+(test|build|run|vet)|"
|
|
21
|
+
r"ruff|flake8|pylint|mypy|pyright|eslint|tsc|prettier|"
|
|
22
|
+
r"ctest|cmake|gradle|mvn|rspec|phpunit|dotnet\s+test|bats)\b",
|
|
23
|
+
re.I,
|
|
24
|
+
)
|
|
25
|
+
# Executing something — running the produced script/binary counts as checking it.
|
|
26
|
+
_EXEC = re.compile(r"(?:^|[\s;&|(])(?:python3?|node|deno|bun|bash|sh|ruby|perl|"
|
|
27
|
+
r"java|javac|gcc|g\+\+|clang|\./|\.\\)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def looks_like_verification(cmd) -> bool:
|
|
31
|
+
"""True if a shell command constitutes verifying work — running tests/checks/
|
|
32
|
+
builds, or executing the produced code. Best-effort; never raises."""
|
|
33
|
+
if not cmd or not isinstance(cmd, str):
|
|
34
|
+
return False
|
|
35
|
+
return bool(_VERIFY_KEYWORDS.search(cmd) or _EXEC.search(cmd))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class VerificationEvidence:
|
|
40
|
+
"""Structured outcome of a verification command — did the check PASS, not just
|
|
41
|
+
run (PRD verification/evidence). status: 'pass' | 'fail' | 'unknown'."""
|
|
42
|
+
command: str
|
|
43
|
+
status: str
|
|
44
|
+
tests_passed: int | None = None
|
|
45
|
+
tests_failed: int | None = None
|
|
46
|
+
exit_code: int | None = None
|
|
47
|
+
summary: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# tool_bash appends "[exit code: N]" ONLY on non-zero exit; absent => exit 0.
|
|
51
|
+
_EXIT = re.compile(r"\[exit code:\s*(-?\d+)\]")
|
|
52
|
+
# pytest/unittest-style tallies: "3 passed", "2 failed", "1 error".
|
|
53
|
+
_TALLY = re.compile(r"(\d+)\s+(passed|failed|errors?|skipped|xfailed|xpassed)\b", re.I)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_evidence(command, result_text) -> VerificationEvidence:
|
|
57
|
+
"""Classify a verification command's result as pass/fail. Exit code is the
|
|
58
|
+
primary signal (drydock reports non-zero); pytest-style tallies refine it.
|
|
59
|
+
Best-effort; never raises."""
|
|
60
|
+
cmd = command if isinstance(command, str) else ""
|
|
61
|
+
text = result_text if isinstance(result_text, str) else str(result_text or "")
|
|
62
|
+
m = _EXIT.search(text)
|
|
63
|
+
exit_code = int(m.group(1)) if m else 0
|
|
64
|
+
passed = failed = None
|
|
65
|
+
tally = {}
|
|
66
|
+
for n, k in _TALLY.findall(text):
|
|
67
|
+
tally[k.lower().rstrip("s")] = tally.get(k.lower().rstrip("s"), 0) + int(n)
|
|
68
|
+
if tally:
|
|
69
|
+
passed = tally.get("passed")
|
|
70
|
+
failed = tally.get("failed", 0) + tally.get("error", 0)
|
|
71
|
+
status = "fail" if ((failed and failed > 0) or exit_code != 0) else "pass"
|
|
72
|
+
parts = [f"exit {exit_code}"]
|
|
73
|
+
if passed is not None or failed:
|
|
74
|
+
parts.append(f"{passed or 0} passed, {failed or 0} failed")
|
|
75
|
+
return VerificationEvidence(cmd, status, passed, failed, exit_code, "; ".join(parts)[:200])
|
|
@@ -12,6 +12,7 @@ drydock/cli.py
|
|
|
12
12
|
drydock/compaction.py
|
|
13
13
|
drydock/config.py
|
|
14
14
|
drydock/detect.py
|
|
15
|
+
drydock/events.py
|
|
15
16
|
drydock/extract.py
|
|
16
17
|
drydock/gittools.py
|
|
17
18
|
drydock/graphrag.py
|
|
@@ -83,6 +84,7 @@ tests/test_dispatch.py
|
|
|
83
84
|
tests/test_e2e_connected.py
|
|
84
85
|
tests/test_edit_replace_all.py
|
|
85
86
|
tests/test_empty_response.py
|
|
87
|
+
tests/test_events.py
|
|
86
88
|
tests/test_extract.py
|
|
87
89
|
tests/test_failure_loop.py
|
|
88
90
|
tests/test_first_run_setup.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.127"
|
|
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,58 @@
|
|
|
1
|
+
"""Durable event log (Agent-Buildout PRD, Epic: events) — the run appends a JSONL
|
|
2
|
+
execution trace that can be read back for inspection/replay."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from drydock import agent
|
|
9
|
+
from drydock.events import EventLog
|
|
10
|
+
from drydock.providers import AssistantTurn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_emit_and_read_roundtrip():
|
|
14
|
+
p = os.path.join(tempfile.mkdtemp(), "e.jsonl")
|
|
15
|
+
log = EventLog(p)
|
|
16
|
+
log.emit("task_start", objective="do X", acceptance_criteria=["a", "b"])
|
|
17
|
+
log.emit("tool", name="Write", result_chars=10)
|
|
18
|
+
evs = EventLog.read(p)
|
|
19
|
+
assert [e["type"] for e in evs] == ["task_start", "tool"]
|
|
20
|
+
assert evs[0]["seq"] == 1 and evs[1]["seq"] == 2 and "ts" in evs[0]
|
|
21
|
+
assert evs[0]["acceptance_criteria"] == ["a", "b"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_read_missing_file_is_empty():
|
|
25
|
+
assert EventLog.read("/no/such/file.jsonl") == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_emit_never_raises_on_bad_path():
|
|
29
|
+
EventLog("/root/cannot/write/here.jsonl").emit("x", a=1) # no exception
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_run_writes_full_trace():
|
|
33
|
+
p = os.path.join(tempfile.mkdtemp(), "run.jsonl")
|
|
34
|
+
st = agent.AgentState(); st.events = EventLog(p)
|
|
35
|
+
turns = [
|
|
36
|
+
AssistantTurn("", [{"id": "1", "name": "Write",
|
|
37
|
+
"input": {"file_path": "/tmp/dd_e.txt", "content": "x"}}], 10, 5),
|
|
38
|
+
AssistantTurn("", [{"id": "2", "name": "Bash", "input": {"command": "python -c '1'"}}], 8, 4),
|
|
39
|
+
AssistantTurn("done, verified", [], 6, 3),
|
|
40
|
+
]
|
|
41
|
+
i = {"n": 0}
|
|
42
|
+
def fake(model, system, messages, tool_schemas, config):
|
|
43
|
+
t = turns[min(i["n"], len(turns) - 1)]; i["n"] += 1; yield t
|
|
44
|
+
orig = agent.stream; agent.stream = fake
|
|
45
|
+
try:
|
|
46
|
+
list(agent.run("Do the task.\n1. write out.txt", st,
|
|
47
|
+
{"model": "gemma4", "max_tokens": 8192, "context_limit": 65536}, "SYS"))
|
|
48
|
+
finally:
|
|
49
|
+
agent.stream = orig
|
|
50
|
+
evs = EventLog.read(p)
|
|
51
|
+
types = [e["type"] for e in evs]
|
|
52
|
+
assert types[0] == "task_start" and "turn" in types and "tool" in types and types[-1] == "done"
|
|
53
|
+
assert next(e for e in evs if e["type"] == "done")["phase"] == "complete"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_disabled_no_events_attr():
|
|
57
|
+
st = agent.AgentState()
|
|
58
|
+
assert st.events is None # off by default on a bare AgentState
|
|
@@ -69,3 +69,35 @@ def test_disabled():
|
|
|
69
69
|
done = AssistantTurn("done", [], 5, 5)
|
|
70
70
|
st, _ = _drive({**CFG, "verify_gate": False}, [write, done])
|
|
71
71
|
assert not any("VERIFIED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_evidence_pass_fail():
|
|
75
|
+
from drydock.verification import parse_evidence
|
|
76
|
+
assert parse_evidence("pytest", "5 passed in 0.1s").status == "pass"
|
|
77
|
+
assert parse_evidence("pytest", "2 failed, 3 passed\n\n[exit code: 1]").status == "fail"
|
|
78
|
+
assert parse_evidence("python x.py", "boom\n\n[exit code: 1]").status == "fail"
|
|
79
|
+
ev = parse_evidence("pytest", "2 failed, 3 passed\n\n[exit code: 1]")
|
|
80
|
+
assert ev.tests_passed == 3 and ev.tests_failed == 2 and ev.exit_code == 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_failed_check_triggers_repair_not_complete():
|
|
84
|
+
# write, then run a FAILING check, then claim done -> gate nudges to REPAIR
|
|
85
|
+
write = AssistantTurn("", [{"id": "1", "name": "Write",
|
|
86
|
+
"input": {"file_path": "/tmp/dd_vg_f.txt", "content": "x"}}], 5, 5)
|
|
87
|
+
fail = AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
88
|
+
"input": {"command": "python -c 'import sys; sys.exit(1)'"}}], 5, 5)
|
|
89
|
+
done = AssistantTurn("all done", [], 5, 5)
|
|
90
|
+
st, n = _drive(CFG, [write, fail, done, done])
|
|
91
|
+
assert st.task.phase == "repair"
|
|
92
|
+
assert any("FAILED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_passing_check_accepts_completion():
|
|
96
|
+
write = AssistantTurn("", [{"id": "1", "name": "Write",
|
|
97
|
+
"input": {"file_path": "/tmp/dd_vg_p.txt", "content": "x"}}], 5, 5)
|
|
98
|
+
ok = AssistantTurn("", [{"id": "2", "name": "Bash",
|
|
99
|
+
"input": {"command": "python -c 'print(1)'"}}], 5, 5)
|
|
100
|
+
done = AssistantTurn("done, verified", [], 5, 5)
|
|
101
|
+
st, _ = _drive(CFG, [write, ok, done])
|
|
102
|
+
assert st.task.phase == "complete"
|
|
103
|
+
assert not any("FAILED" in m.get("content", "") for m in st.messages if m.get("role") == "user")
|
|
@@ -1,34 +0,0 @@
|
|
|
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))
|
|
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
|
|
File without changes
|