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.
Files changed (143) hide show
  1. {drydock_cli-3.0.129/drydock_cli.egg-info → drydock_cli-3.0.132}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/agent.py +22 -8
  3. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/task_state.py +58 -0
  4. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tool_registry.py +21 -4
  5. drydock_cli-3.0.132/drydock/tool_result.py +101 -0
  6. {drydock_cli-3.0.129 → drydock_cli-3.0.132/drydock_cli.egg-info}/PKG-INFO +1 -1
  7. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/SOURCES.txt +3 -0
  8. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/pyproject.toml +1 -1
  9. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_leaked_tool_call.py +5 -2
  10. drydock_cli-3.0.132/tests/test_rolling_plan.py +49 -0
  11. drydock_cli-3.0.132/tests/test_tool_result.py +50 -0
  12. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/LICENSE +0 -0
  13. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/NOTICE +0 -0
  14. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/README.md +0 -0
  15. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/advisor.py +0 -0
  18. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/bash_safety.py +0 -0
  19. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/__init__.py +0 -0
  20. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-data.md +0 -0
  21. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-debug.md +0 -0
  22. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-finetune.md +0 -0
  23. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-metrics.md +0 -0
  24. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-rl.md +0 -0
  25. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/ml-train.md +0 -0
  26. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-categorize.md +0 -0
  27. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-control.md +0 -0
  28. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-poam.md +0 -0
  29. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/rmf-review.md +0 -0
  30. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/stig-assess.md +0 -0
  31. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/builtin_skills/stig-remediate.md +0 -0
  32. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/cci.py +0 -0
  33. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/cli.py +0 -0
  34. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/compaction.py +0 -0
  35. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/config.py +0 -0
  36. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/detect.py +0 -0
  37. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/events.py +0 -0
  38. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/extract.py +0 -0
  39. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/gittools.py +0 -0
  40. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/graphrag.py +0 -0
  41. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/guards.py +0 -0
  42. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/loop_detect.py +0 -0
  43. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/mcp.py +0 -0
  44. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/poam.py +0 -0
  45. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/providers.py +0 -0
  46. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/recipes.py +0 -0
  47. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/rmf.py +0 -0
  48. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/rmf_graph.py +0 -0
  49. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/skills.py +0 -0
  50. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/stig.py +0 -0
  51. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/suggest.py +0 -0
  52. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tools/__init__.py +0 -0
  53. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/__init__.py +0 -0
  54. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/app.py +0 -0
  55. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/approval.py +0 -0
  56. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/messages.py +0 -0
  57. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tui/widgets.py +0 -0
  58. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/tuning.py +0 -0
  59. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/verification.py +0 -0
  60. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock/web.py +0 -0
  61. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/dependency_links.txt +0 -0
  62. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/entry_points.txt +0 -0
  63. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/requires.txt +0 -0
  64. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/drydock_cli.egg-info/top_level.txt +0 -0
  65. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/setup.cfg +0 -0
  66. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_advisor.py +0 -0
  67. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_approval.py +0 -0
  68. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_back_command.py +0 -0
  69. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_background.py +0 -0
  70. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_binary_output.py +0 -0
  71. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_crossplatform.py +0 -0
  72. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_output_bounding.py +0 -0
  73. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_process_group.py +0 -0
  74. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_safety.py +0 -0
  75. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_sanitize.py +0 -0
  76. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_shell.py +0 -0
  77. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_stdin.py +0 -0
  78. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_stop_partial.py +0 -0
  79. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_timeout_network.py +0 -0
  80. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_bash_timeout_param.py +0 -0
  81. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_cci.py +0 -0
  82. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_cli_agents.py +0 -0
  83. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_compact_command.py +0 -0
  84. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_compaction.py +0 -0
  85. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_config.py +0 -0
  86. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_config_migration.py +0 -0
  87. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_context_limit_config.py +0 -0
  88. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_detect.py +0 -0
  89. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_dispatch.py +0 -0
  90. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_e2e_connected.py +0 -0
  91. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_edit_replace_all.py +0 -0
  92. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_empty_response.py +0 -0
  93. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_events.py +0 -0
  94. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_extract.py +0 -0
  95. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_failure_loop.py +0 -0
  96. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_first_run_setup.py +0 -0
  97. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_gittools.py +0 -0
  98. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_glob_edit_edges.py +0 -0
  99. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphify_example.py +0 -0
  100. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphrag.py +0 -0
  101. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_graphrag_quoted_path.py +0 -0
  102. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_grep_and_read_robust.py +0 -0
  103. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_guards_and_tools.py +0 -0
  104. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_hallucinated_tools.py +0 -0
  105. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_loop_detect.py +0 -0
  106. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_mcp.py +0 -0
  107. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_oneshot_unreachable.py +0 -0
  108. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_overthink_interrupt.py +0 -0
  109. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_plan_autocontinue.py +0 -0
  110. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_poam.py +0 -0
  111. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_providers_unreachable.py +0 -0
  112. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_read_index.py +0 -0
  113. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_recipes.py +0 -0
  114. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_repetition_interrupt.py +0 -0
  115. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf.py +0 -0
  116. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf_graph.py +0 -0
  117. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_rmf_stig_graph.py +0 -0
  118. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_runaway_repetition.py +0 -0
  119. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_screenshot.py +0 -0
  120. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_server_probe.py +0 -0
  121. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_skills.py +0 -0
  122. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stall_retry.py +0 -0
  123. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stig.py +0 -0
  124. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_stop.py +0 -0
  125. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_streaming_newlines.py +0 -0
  126. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_subagent.py +0 -0
  127. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_suggest.py +0 -0
  128. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_system_prompt_help.py +0 -0
  129. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_task_state.py +0 -0
  130. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_todo.py +0 -0
  131. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_coercion.py +0 -0
  132. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_coercion_more.py +0 -0
  133. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tool_arg_parsing.py +0 -0
  134. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tools_undo.py +0 -0
  135. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tui.py +0 -0
  136. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_tuning.py +0 -0
  137. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_verification_gate.py +0 -0
  138. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_viewimage.py +0 -0
  139. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_vision_input.py +0 -0
  140. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_web_tools.py +0 -0
  141. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_windows_shell.py +0 -0
  142. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_write_content_coerce.py +0 -0
  143. {drydock_cli-3.0.129 → drydock_cli-3.0.132}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.129
3
+ Version: 3.0.132
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.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, execute
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: object | None = None # optional EventLog for a durable execution trace
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
- result = execute(tc["name"], tc["input"], config)
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
- _emit(state, "tool", name=tc["name"],
456
- input=str(tc.get("input"))[:200],
457
- result_chars=len(str(result)),
458
- error=str(result)[:80].lower().startswith("error"))
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 f"Error: tool '{name}' not found."
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
- return f"Error executing {name}: {e}"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.129
3
+ Version: 3.0.132
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -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.129"
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() would run a real Bash; stub it to keep the test hermetic.
87
- monkeypatch.setattr(agent_mod, "execute", lambda name, inp, cfg: "ok")
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