drydock-cli 3.0.129__tar.gz → 3.0.132__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.129/drydock_cli.egg-info → drydock_cli-3.0.132}/PKG-INFO +1 -1
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/agent.py +22 -8
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/task_state.py +58 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tool_registry.py +21 -4
- drydock_cli-3.0.132/drydock/tool_result.py +101 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/SOURCES.txt +3 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/pyproject.toml +1 -1
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_leaked_tool_call.py +5 -2
- drydock_cli-3.0.132/tests/test_rolling_plan.py +49 -0
- drydock_cli-3.0.132/tests/test_tool_result.py +50 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/LICENSE +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/NOTICE +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/README.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/cci.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/cli.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/config.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/detect.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/events.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/extract.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/guards.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/poam.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/providers.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/recipes.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/skills.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/stig.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/suggest.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/verification.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/web.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/setup.cfg +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_config.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_events.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_recipes.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_screenshot.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_suggest.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_task_state.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_verification_gate.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_xccdf.py +0 -0
|
@@ -34,7 +34,7 @@ def _plan_has_unfinished(config: dict) -> bool:
|
|
|
34
34
|
return bool(todo) and any(status != "done" for _, status in todo)
|
|
35
35
|
|
|
36
36
|
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry, RepetitionDetected
|
|
37
|
-
from drydock.tool_registry import schemas,
|
|
37
|
+
from drydock.tool_registry import schemas, execute_structured
|
|
38
38
|
from drydock.tools import register_all
|
|
39
39
|
|
|
40
40
|
# Register the built-in tools as a side effect of importing the agent. This is
|
|
@@ -49,7 +49,7 @@ from drydock.compaction import (
|
|
|
49
49
|
from drydock.loop_detect import LoopTracker
|
|
50
50
|
from drydock.task_state import TaskState
|
|
51
51
|
from drydock.verification import looks_like_verification, parse_evidence
|
|
52
|
-
from drydock.events import emit as _emit
|
|
52
|
+
from drydock.events import EventLog, emit as _emit
|
|
53
53
|
from drydock.tuning import (
|
|
54
54
|
filter_tool_schemas,
|
|
55
55
|
hallucinated_tool_message,
|
|
@@ -84,7 +84,7 @@ class AgentState:
|
|
|
84
84
|
turn_count: int = 0
|
|
85
85
|
current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
|
|
86
86
|
task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
|
|
87
|
-
events:
|
|
87
|
+
events: "EventLog | None" = None # optional durable execution trace
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def drop_last_turn(messages: list) -> bool:
|
|
@@ -422,14 +422,17 @@ def run(
|
|
|
422
422
|
halluc = hallucinated_tool_message(tc["name"])
|
|
423
423
|
if halluc is not None:
|
|
424
424
|
result = halluc
|
|
425
|
+
tool_result = None
|
|
425
426
|
elif allow is not None and tc["name"] not in allow:
|
|
426
427
|
result = (
|
|
427
428
|
f"[The '{tc['name']}' tool is not available here. You may use "
|
|
428
429
|
f"only: {', '.join(allow)}. Use one of those, or reply with "
|
|
429
430
|
"your final summary.]"
|
|
430
431
|
)
|
|
432
|
+
tool_result = None
|
|
431
433
|
else:
|
|
432
|
-
|
|
434
|
+
tool_result = execute_structured(tc["name"], tc["input"], config)
|
|
435
|
+
result = tool_result.text
|
|
433
436
|
# Track consecutive byte-identical calls — same name, args AND raw
|
|
434
437
|
# result (captured before annotate prepends its note, which changes
|
|
435
438
|
# each call) — for the safety valve below. A differing result
|
|
@@ -452,10 +455,21 @@ def run(
|
|
|
452
455
|
_emit(state, "verification", status=last_verification.status,
|
|
453
456
|
exit_code=last_verification.exit_code)
|
|
454
457
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
458
|
+
# Sync the rolling plan when the model updates its checklist, keeping
|
|
459
|
+
# stable step ids + capping pending steps; record each revision.
|
|
460
|
+
if tc["name"] == "todo" and isinstance(config.get("_todo"), list):
|
|
461
|
+
if state.task.plan.update_from_items(config["_todo"]):
|
|
462
|
+
_emit(state, "plan", version=state.task.plan.version,
|
|
463
|
+
steps=[(s["id"], s["status"]) for s in state.task.plan.steps])
|
|
464
|
+
|
|
465
|
+
if tool_result is not None:
|
|
466
|
+
_emit(state, "tool", input=str(tc.get("input"))[:200],
|
|
467
|
+
**tool_result.to_event())
|
|
468
|
+
else:
|
|
469
|
+
_emit(state, "tool", name=tc["name"],
|
|
470
|
+
input=str(tc.get("input"))[:200],
|
|
471
|
+
result_chars=len(str(result)),
|
|
472
|
+
status="ok")
|
|
459
473
|
yield ToolEnd(tc["name"], result)
|
|
460
474
|
|
|
461
475
|
# Append tool result
|
|
@@ -40,12 +40,64 @@ def extract_acceptance_criteria(objective: str, limit: int = 10) -> list[str]:
|
|
|
40
40
|
return out[:limit]
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _step_id(text: str) -> str:
|
|
44
|
+
"""Stable short id for a plan step, derived from its text (so the SAME step
|
|
45
|
+
keeps its id across plan revisions). Deterministic — no randomness/time."""
|
|
46
|
+
h = 0
|
|
47
|
+
for ch in text.strip().lower():
|
|
48
|
+
h = (h * 131 + ord(ch)) & 0xFFFFFFFF
|
|
49
|
+
return f"s{h:08x}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RollingPlan:
|
|
54
|
+
"""A short, updateable plan (PRD Epic C): one ACTIVE step + a few pending ones,
|
|
55
|
+
each with a stable id. Rebuilt from the checklist the model maintains; ids are
|
|
56
|
+
text-derived so an unchanged step keeps its id across revisions."""
|
|
57
|
+
steps: list[dict] = field(default_factory=list) # {id, text, status}
|
|
58
|
+
version: int = 0
|
|
59
|
+
max_pending: int = 4
|
|
60
|
+
|
|
61
|
+
def update_from_items(self, items: list[tuple[str, str]]) -> bool:
|
|
62
|
+
"""Sync from (text, status) checklist pairs. Caps pending steps (keeps the
|
|
63
|
+
active + first max_pending pending). Returns True if the plan CHANGED."""
|
|
64
|
+
steps = [{"id": _step_id(t), "text": t, "status": s} for t, s in items if t]
|
|
65
|
+
# active = first in_progress, else first pending
|
|
66
|
+
active = next((s for s in steps if s["status"] == "in_progress"), None)
|
|
67
|
+
if active is None:
|
|
68
|
+
active = next((s for s in steps if s["status"] == "pending"), None)
|
|
69
|
+
kept, pending_kept = [], 0
|
|
70
|
+
for s in steps:
|
|
71
|
+
if s["status"] == "done" or s is active:
|
|
72
|
+
kept.append(s)
|
|
73
|
+
elif s["status"] == "pending" and pending_kept < self.max_pending:
|
|
74
|
+
kept.append(s); pending_kept += 1
|
|
75
|
+
elif s["status"] == "in_progress":
|
|
76
|
+
kept.append(s)
|
|
77
|
+
changed = [(s["id"], s["status"]) for s in kept] != [(s["id"], s["status"]) for s in self.steps]
|
|
78
|
+
if changed:
|
|
79
|
+
self.steps = kept
|
|
80
|
+
self.version += 1
|
|
81
|
+
return changed
|
|
82
|
+
|
|
83
|
+
def active_step(self) -> dict | None:
|
|
84
|
+
return next((s for s in self.steps if s["status"] == "in_progress"), None) \
|
|
85
|
+
or next((s for s in self.steps if s["status"] == "pending"), None)
|
|
86
|
+
|
|
87
|
+
def remaining(self) -> int:
|
|
88
|
+
return sum(1 for s in self.steps if s["status"] != "done")
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {"steps": list(self.steps), "version": self.version}
|
|
92
|
+
|
|
93
|
+
|
|
43
94
|
@dataclass
|
|
44
95
|
class TaskState:
|
|
45
96
|
"""Authoritative task facts, independent of the message transcript."""
|
|
46
97
|
objective: str = ""
|
|
47
98
|
acceptance_criteria: list[str] = field(default_factory=list)
|
|
48
99
|
phase: str = "understand"
|
|
100
|
+
plan: RollingPlan = field(default_factory=RollingPlan)
|
|
49
101
|
|
|
50
102
|
@classmethod
|
|
51
103
|
def from_objective(cls, objective: str) -> TaskState:
|
|
@@ -61,14 +113,20 @@ class TaskState:
|
|
|
61
113
|
"objective": self.objective,
|
|
62
114
|
"acceptance_criteria": list(self.acceptance_criteria),
|
|
63
115
|
"phase": self.phase,
|
|
116
|
+
"plan": self.plan.to_dict(),
|
|
64
117
|
}
|
|
65
118
|
|
|
66
119
|
@classmethod
|
|
67
120
|
def from_dict(cls, d: dict) -> TaskState:
|
|
121
|
+
plan = RollingPlan()
|
|
122
|
+
pd = d.get("plan") or {}
|
|
123
|
+
plan.steps = list(pd.get("steps", []))
|
|
124
|
+
plan.version = pd.get("version", 0)
|
|
68
125
|
return cls(
|
|
69
126
|
objective=d.get("objective", ""),
|
|
70
127
|
acceptance_criteria=list(d.get("acceptance_criteria", [])),
|
|
71
128
|
phase=d.get("phase", "understand"),
|
|
129
|
+
plan=plan,
|
|
72
130
|
)
|
|
73
131
|
|
|
74
132
|
def anchor_text(self) -> str:
|
|
@@ -30,17 +30,34 @@ def schemas() -> list[dict]:
|
|
|
30
30
|
return [t.schema for t in _registry.values()]
|
|
31
31
|
|
|
32
32
|
def execute(name: str, params: dict, config: dict, max_output: int = 32000) -> str:
|
|
33
|
-
"""Execute a tool by name. Truncates large output.
|
|
33
|
+
"""Execute a tool by name. Truncates large output. Returns the text string
|
|
34
|
+
(backward-compatible); use execute_structured() for the ToolResult."""
|
|
35
|
+
return execute_structured(name, params, config, max_output).text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def execute_structured(name: str, params: dict, config: dict, max_output: int = 32000):
|
|
39
|
+
"""Execute a tool and return a structured ToolResult (status/diagnostics/
|
|
40
|
+
retryable/duration + the text), classified from the tool's string output +
|
|
41
|
+
timing. The individual tools still return plain strings — this WRAPS them."""
|
|
42
|
+
import time as _time
|
|
43
|
+
|
|
44
|
+
from drydock.tool_result import ToolResult, classify
|
|
45
|
+
|
|
34
46
|
tool = get(name)
|
|
35
47
|
if tool is None:
|
|
36
|
-
return
|
|
48
|
+
return ToolResult(name=name, status="not_found", error_code="not_found",
|
|
49
|
+
text=f"Error: tool '{name}' not found.")
|
|
50
|
+
start = _time.monotonic()
|
|
37
51
|
try:
|
|
38
52
|
result = tool.func(params, config)
|
|
39
53
|
except Exception as e:
|
|
40
|
-
|
|
54
|
+
dur = int((_time.monotonic() - start) * 1000)
|
|
55
|
+
return ToolResult(name=name, status="error", error_code="exception",
|
|
56
|
+
text=f"Error executing {name}: {e}", duration_ms=dur)
|
|
57
|
+
dur = int((_time.monotonic() - start) * 1000)
|
|
41
58
|
|
|
42
59
|
if len(result) > max_output:
|
|
43
60
|
half = max_output // 2
|
|
44
61
|
quarter = max_output // 4
|
|
45
62
|
result = result[:half] + f"\n[... {len(result) - half - quarter} chars truncated ...]\n" + result[-quarter:]
|
|
46
|
-
return result
|
|
63
|
+
return classify(name, params, result, dur)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Structured tool results (Agent-Buildout PRD, section 7.4 / "structured tool
|
|
2
|
+
results"): a tool's output is not immediately reduced to a single text string —
|
|
3
|
+
status, diagnostics, retryability, and timing stay available to the controller for
|
|
4
|
+
loop/progress detection, verification, and recovery.
|
|
5
|
+
|
|
6
|
+
Incremental by design: tools still return plain strings (no rewrite), and
|
|
7
|
+
tool_registry.execute() still returns a string for full backward-compat. The new
|
|
8
|
+
execute_structured() WRAPS that string into a ToolResult, classifying status/error/
|
|
9
|
+
retryability from the text + timing. Logic original to Drydock.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ToolResult:
|
|
19
|
+
"""A tool call's outcome with structure the controller can reason about."""
|
|
20
|
+
name: str
|
|
21
|
+
status: str = "ok" # "ok" | "error" | "not_found"
|
|
22
|
+
text: str = "" # the human/model-facing string (backward-compat)
|
|
23
|
+
changed_state: bool = False # did it mutate files/env (Write/Edit/mutating Bash)?
|
|
24
|
+
retryable: bool = False # transient failure worth retrying?
|
|
25
|
+
error_code: str | None = None # coarse classification when status == error
|
|
26
|
+
exit_code: int | None = None # for Bash, if present
|
|
27
|
+
duration_ms: int = 0
|
|
28
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def to_event(self) -> dict:
|
|
31
|
+
"""Compact dict for the event log (no big blobs)."""
|
|
32
|
+
return {
|
|
33
|
+
"name": self.name, "status": self.status,
|
|
34
|
+
"changed_state": self.changed_state, "retryable": self.retryable,
|
|
35
|
+
"error_code": self.error_code, "exit_code": self.exit_code,
|
|
36
|
+
"duration_ms": self.duration_ms, "result_chars": len(self.text),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_EXIT = re.compile(r"\[exit code:\s*(-?\d+)\]")
|
|
41
|
+
_MUTATING_BASH = re.compile(
|
|
42
|
+
r"(?:^|[\s;&|])(?:rm|mv|cp|mkdir|touch|tee|sed\s+-i|dd|chmod|chown|ln|"
|
|
43
|
+
r"git\s+(?:commit|add|apply|checkout|reset|rm|mv)|pip\s+install|npm\s+i|"
|
|
44
|
+
r"apt|make(?:\s|$)|cargo\s+build|>|>>)\b", re.I,
|
|
45
|
+
)
|
|
46
|
+
# Error phrases that usually mean "try again might work" (transient).
|
|
47
|
+
_RETRYABLE = re.compile(
|
|
48
|
+
r"\b(timed out|timeout|connection (refused|reset|error)|temporarily|"
|
|
49
|
+
r"try again|rate limit|EAGAIN|ETIMEDOUT|network is unreachable|"
|
|
50
|
+
r"resource temporarily unavailable|503|502|429)\b", re.I,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _classify_error(text: str) -> tuple[str, bool]:
|
|
55
|
+
"""(error_code, retryable) for an error result string."""
|
|
56
|
+
t = text.lower()
|
|
57
|
+
if _RETRYABLE.search(text):
|
|
58
|
+
return "transient", True
|
|
59
|
+
if "not found" in t or "no such file" in t or "command not found" in t:
|
|
60
|
+
return "not_found", False
|
|
61
|
+
if "permission denied" in t:
|
|
62
|
+
return "permission", False
|
|
63
|
+
if "syntax error" in t or "invalid" in t:
|
|
64
|
+
return "invalid_input", False
|
|
65
|
+
if "timed out" in t:
|
|
66
|
+
return "timeout", True
|
|
67
|
+
return "error", False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def classify(name: str, params: dict, text: str, duration_ms: int) -> ToolResult:
|
|
71
|
+
"""Build a ToolResult from a tool's (name, params, string result). Never raises."""
|
|
72
|
+
text = text if isinstance(text, str) else str(text or "")
|
|
73
|
+
r = ToolResult(name=name, text=text, duration_ms=max(0, int(duration_ms)))
|
|
74
|
+
|
|
75
|
+
m = _EXIT.search(text)
|
|
76
|
+
if m:
|
|
77
|
+
try:
|
|
78
|
+
r.exit_code = int(m.group(1))
|
|
79
|
+
except ValueError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# Status: an "Error…" prefixed string, a "tool not found", or a non-zero Bash
|
|
83
|
+
# exit means the call did not succeed. Otherwise ok.
|
|
84
|
+
lowered = text.lstrip()[:60].lower()
|
|
85
|
+
if lowered.startswith("error: tool") and "not found" in text.lower():
|
|
86
|
+
r.status, r.error_code = "not_found", "not_found"
|
|
87
|
+
elif lowered.startswith("error"):
|
|
88
|
+
r.status = "error"
|
|
89
|
+
r.error_code, r.retryable = _classify_error(text)
|
|
90
|
+
elif r.exit_code not in (None, 0):
|
|
91
|
+
r.status = "error"
|
|
92
|
+
r.error_code, r.retryable = _classify_error(text)
|
|
93
|
+
|
|
94
|
+
# State change: Write/Edit always mutate; Bash only for mutating commands.
|
|
95
|
+
if name in ("Write", "Edit", "GitCommit", "BuildKnowledge", "GraphAdd"):
|
|
96
|
+
r.changed_state = r.status == "ok"
|
|
97
|
+
elif name == "Bash":
|
|
98
|
+
cmd = (params or {}).get("command", "") if isinstance(params, dict) else ""
|
|
99
|
+
r.changed_state = bool(_MUTATING_BASH.search(cmd)) and r.status == "ok"
|
|
100
|
+
|
|
101
|
+
return r
|
|
@@ -29,6 +29,7 @@ drydock/stig.py
|
|
|
29
29
|
drydock/suggest.py
|
|
30
30
|
drydock/task_state.py
|
|
31
31
|
drydock/tool_registry.py
|
|
32
|
+
drydock/tool_result.py
|
|
32
33
|
drydock/tuning.py
|
|
33
34
|
drydock/verification.py
|
|
34
35
|
drydock/web.py
|
|
@@ -110,6 +111,7 @@ tests/test_repetition_interrupt.py
|
|
|
110
111
|
tests/test_rmf.py
|
|
111
112
|
tests/test_rmf_graph.py
|
|
112
113
|
tests/test_rmf_stig_graph.py
|
|
114
|
+
tests/test_rolling_plan.py
|
|
113
115
|
tests/test_runaway_repetition.py
|
|
114
116
|
tests/test_screenshot.py
|
|
115
117
|
tests/test_server_probe.py
|
|
@@ -126,6 +128,7 @@ tests/test_todo.py
|
|
|
126
128
|
tests/test_tool_arg_coercion.py
|
|
127
129
|
tests/test_tool_arg_coercion_more.py
|
|
128
130
|
tests/test_tool_arg_parsing.py
|
|
131
|
+
tests/test_tool_result.py
|
|
129
132
|
tests/test_tools_undo.py
|
|
130
133
|
tests/test_tui.py
|
|
131
134
|
tests/test_tuning.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.132"
|
|
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"
|
|
@@ -83,8 +83,11 @@ def test_adaptive_reasoning_high_to_plan_then_low(monkeypatch):
|
|
|
83
83
|
yield AssistantTurn("done", [], 1, 1)
|
|
84
84
|
|
|
85
85
|
monkeypatch.setattr(agent_mod, "stream", fake_stream)
|
|
86
|
-
# execute
|
|
87
|
-
|
|
86
|
+
# execute would run a real Bash; stub it to keep the test hermetic. The loop
|
|
87
|
+
# now calls execute_structured (returns a ToolResult with .text).
|
|
88
|
+
from drydock.tool_result import ToolResult
|
|
89
|
+
monkeypatch.setattr(agent_mod, "execute_structured",
|
|
90
|
+
lambda name, inp, cfg: ToolResult(name=name, text="ok"))
|
|
88
91
|
st = AgentState()
|
|
89
92
|
list(run("go", st, {"model": "gemma4"}, "sys"))
|
|
90
93
|
# First (planning) turn high, the continuation that consumes the tool result low.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Rolling plan (Agent-Buildout PRD Epic C): 1 active + <=4 pending steps with
|
|
2
|
+
stable ids; revisions bump the version; unchanged steps keep their id."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock.task_state import RollingPlan, TaskState, _step_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_stable_ids_survive_revision():
|
|
9
|
+
p = RollingPlan()
|
|
10
|
+
p.update_from_items([("read file", "done"), ("fix bug", "in_progress"), ("test", "pending")])
|
|
11
|
+
ids1 = {s["text"]: s["id"] for s in p.steps}
|
|
12
|
+
# revise: fix bug now done, test now active
|
|
13
|
+
p.update_from_items([("read file", "done"), ("fix bug", "done"), ("test", "in_progress")])
|
|
14
|
+
ids2 = {s["text"]: s["id"] for s in p.steps}
|
|
15
|
+
assert ids1["fix bug"] == ids2["fix bug"] # same step, same id
|
|
16
|
+
assert ids1["test"] == ids2["test"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_caps_pending_to_four_plus_active():
|
|
20
|
+
p = RollingPlan()
|
|
21
|
+
items = [("active", "in_progress")] + [(f"p{i}", "pending") for i in range(8)]
|
|
22
|
+
p.update_from_items(items)
|
|
23
|
+
pend = [s for s in p.steps if s["status"] == "pending"]
|
|
24
|
+
assert len(pend) == 4 and any(s["status"] == "in_progress" for s in p.steps)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_version_bumps_only_on_change():
|
|
28
|
+
p = RollingPlan()
|
|
29
|
+
assert p.update_from_items([("a", "pending")]) and p.version == 1
|
|
30
|
+
assert not p.update_from_items([("a", "pending")]) and p.version == 1 # no change
|
|
31
|
+
assert p.update_from_items([("a", "done")]) and p.version == 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_active_and_remaining():
|
|
35
|
+
p = RollingPlan()
|
|
36
|
+
p.update_from_items([("a", "done"), ("b", "in_progress"), ("c", "pending")])
|
|
37
|
+
assert p.active_step()["text"] == "b" and p.remaining() == 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_serialization_roundtrip():
|
|
41
|
+
ts = TaskState.from_objective("Do it.\n1. a\n2. b")
|
|
42
|
+
ts.plan.update_from_items([("a", "done"), ("b", "in_progress")])
|
|
43
|
+
d = ts.to_dict()
|
|
44
|
+
assert d["plan"]["version"] == 1 and len(d["plan"]["steps"]) == 2
|
|
45
|
+
assert TaskState.from_dict(d).plan.active_step()["text"] == "b"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_step_id_deterministic():
|
|
49
|
+
assert _step_id("fix the bug") == _step_id("Fix The Bug ") # case/space-insensitive
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Structured tool results (Agent-Buildout PRD 7.4): execute_structured() wraps a
|
|
2
|
+
tool's string output in a ToolResult (status/error/retryable/changed_state/duration)
|
|
3
|
+
while execute() stays a plain string (backward compatibility)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from drydock.tool_result import ToolResult, classify
|
|
7
|
+
from drydock.tools import register_all
|
|
8
|
+
from drydock import tool_registry
|
|
9
|
+
|
|
10
|
+
register_all()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_execute_still_returns_str():
|
|
14
|
+
r = tool_registry.execute("Bash", {"command": "echo hi", "timeout": 5}, {"cwd": "/tmp"})
|
|
15
|
+
assert isinstance(r, str) and "hi" in r
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_structured_success():
|
|
19
|
+
r = tool_registry.execute_structured("Bash", {"command": "echo hi", "timeout": 5}, {"cwd": "/tmp"})
|
|
20
|
+
assert isinstance(r, ToolResult) and r.status == "ok" and r.duration_ms >= 0 and not r.changed_state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_structured_failure_exit_code():
|
|
24
|
+
r = tool_registry.execute_structured(
|
|
25
|
+
"Bash", {"command": "python3 -c 'import sys;sys.exit(3)'", "timeout": 5}, {"cwd": "/tmp"})
|
|
26
|
+
assert r.status == "error" and r.exit_code == 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_not_found():
|
|
30
|
+
r = tool_registry.execute_structured("NoSuchTool", {}, {})
|
|
31
|
+
assert r.status == "not_found" and r.error_code == "not_found"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_classify_changed_state_and_retryable():
|
|
35
|
+
assert classify("Write", {}, "wrote 10 bytes", 1).changed_state
|
|
36
|
+
assert not classify("Read", {}, "contents", 1).changed_state
|
|
37
|
+
assert classify("Bash", {"command": "touch x"}, "", 1).changed_state
|
|
38
|
+
assert not classify("Bash", {"command": "ls -la"}, "files", 1).changed_state
|
|
39
|
+
r = classify("Bash", {"command": "curl x"}, "Error: connection refused", 1)
|
|
40
|
+
assert r.status == "error" and r.retryable and r.error_code == "transient"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_error_prefix_string():
|
|
44
|
+
r = classify("Edit", {}, "Error: old_string not found", 1)
|
|
45
|
+
assert r.status == "error" and not r.changed_state # failed edit didn't mutate
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_to_event_is_compact():
|
|
49
|
+
ev = classify("Bash", {"command": "echo x"}, "x", 5).to_event()
|
|
50
|
+
assert ev["status"] == "ok" and "result_chars" in ev and "text" not in ev
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|