drydock-cli 3.0.121__tar.gz → 3.0.123__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.121/drydock_cli.egg-info → drydock_cli-3.0.123}/PKG-INFO +1 -1
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/agent.py +14 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/config.py +4 -0
- drydock_cli-3.0.123/drydock/task_state.py +84 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tui/app.py +12 -1
- {drydock_cli-3.0.121 → drydock_cli-3.0.123/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/pyproject.toml +1 -1
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_screenshot.py +1 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_suggest.py +10 -0
- drydock_cli-3.0.123/tests/test_task_state.py +69 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/LICENSE +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/NOTICE +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/README.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/advisor.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-data.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-debug.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-finetune.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-metrics.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-rl.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/ml-train.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/cci.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/cli.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/detect.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/extract.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/guards.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/poam.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/providers.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/recipes.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/skills.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/stig.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/suggest.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock/web.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/setup.cfg +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_advisor.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_background.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_binary_output.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_crossplatform.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_sanitize.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_shell.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_stdin.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_stop_partial.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_bash_timeout_param.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_cci.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_config.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_edit_replace_all.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_glob_edit_edges.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_graphify_example.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_graphrag_quoted_path.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_grep_and_read_robust.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_overthink_interrupt.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_poam.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_recipes.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_repetition_interrupt.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_server_probe.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_stall_retry.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tool_arg_coercion.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tool_arg_coercion_more.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_viewimage.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_windows_shell.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_write_content_coerce.py +0 -0
- {drydock_cli-3.0.121 → drydock_cli-3.0.123}/tests/test_xccdf.py +0 -0
|
@@ -47,6 +47,7 @@ from drydock.compaction import (
|
|
|
47
47
|
maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
|
|
48
48
|
)
|
|
49
49
|
from drydock.loop_detect import LoopTracker
|
|
50
|
+
from drydock.task_state import TaskState
|
|
50
51
|
from drydock.tuning import (
|
|
51
52
|
filter_tool_schemas,
|
|
52
53
|
hallucinated_tool_message,
|
|
@@ -80,6 +81,7 @@ class AgentState:
|
|
|
80
81
|
last_input_tokens: int = 0 # prompt tokens the SERVER counted on the last call
|
|
81
82
|
turn_count: int = 0
|
|
82
83
|
current_effort: str = "" # "high"/"low" of the in-flight LLM call (for the UI)
|
|
84
|
+
task: "TaskState" = field(default_factory=lambda: TaskState()) # structured objective
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
def drop_last_turn(messages: list) -> bool:
|
|
@@ -114,6 +116,18 @@ def run(
|
|
|
114
116
|
"""
|
|
115
117
|
state.messages.append({"role": "user", "content": user_message})
|
|
116
118
|
|
|
119
|
+
# Capture the structured objective the FIRST time a task arrives, so it lives
|
|
120
|
+
# outside the transcript. The original objective is authoritative for the task.
|
|
121
|
+
if not state.task.is_set() and isinstance(user_message, str) and user_message.strip():
|
|
122
|
+
state.task = TaskState.from_objective(user_message)
|
|
123
|
+
# Keep the objective + acceptance criteria in the SYSTEM PROMPT every turn, so
|
|
124
|
+
# they survive compaction (which only touches the message transcript, never the
|
|
125
|
+
# system prompt) — the model can't drift off the goal on a long task.
|
|
126
|
+
if config.get("task_anchor", True) and state.task.is_set():
|
|
127
|
+
anchor = state.task.anchor_text()
|
|
128
|
+
if anchor:
|
|
129
|
+
system_prompt = system_prompt + "\n\n" + anchor
|
|
130
|
+
|
|
117
131
|
# Recipe retrieval: give the model the TECHNIQUE this task needs (forensics,
|
|
118
132
|
# git-history rewrite, numpy-2.0 fix, cert gen, …) by appending the relevant
|
|
119
133
|
# bundled recipes to the system prompt. Keyword-matched, so only fitting ones
|
|
@@ -44,6 +44,10 @@ DEFAULTS: dict[str, object] = {
|
|
|
44
44
|
# instead of guessing. Retrieval is keyword-overlap; only relevant recipes are
|
|
45
45
|
# added (none = nothing injected). Set false to disable.
|
|
46
46
|
"recipes": True,
|
|
47
|
+
# Keep the structured objective + acceptance criteria (task_state.py) in the
|
|
48
|
+
# system prompt every turn so they survive context compaction — the model can't
|
|
49
|
+
# drift off the original goal on a long task. Set false to disable.
|
|
50
|
+
"task_anchor": True,
|
|
47
51
|
"theme": "harbor",
|
|
48
52
|
# Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
|
|
49
53
|
# Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Structured task state — the authoritative objective + acceptance criteria kept
|
|
2
|
+
OUTSIDE the chat transcript, so they survive context compaction and the model never
|
|
3
|
+
loses sight of the goal on a long task.
|
|
4
|
+
|
|
5
|
+
First increment toward the Agent-Buildout PRD (Epic A: Structured Task State /
|
|
6
|
+
Principle 4.2 "structured state is authoritative"). Deliberately small — a dataclass
|
|
7
|
+
the agent loop populates once and re-injects as a compact anchor after compaction —
|
|
8
|
+
so it drops into the current loop without a rewrite. All logic original to Drydock.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
# Requirement-like lines: numbered (1. / 1)) or bulleted (- * •).
|
|
16
|
+
_LIST_ITEM = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$", re.M)
|
|
17
|
+
# Imperative "must/should/exactly/required" sentences (fallback when there's no list).
|
|
18
|
+
_IMPERATIVE = re.compile(r"\b(must|should|exactly|required|ensure|has to|needs? to)\b", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_acceptance_criteria(objective: str, limit: int = 10) -> list[str]:
|
|
22
|
+
"""Pull discrete acceptance criteria out of the objective: numbered/bulleted
|
|
23
|
+
lines first, else imperative must/should sentences. Best-effort; never raises."""
|
|
24
|
+
if not objective or not isinstance(objective, str):
|
|
25
|
+
return []
|
|
26
|
+
items = [m.group(1).strip() for m in _LIST_ITEM.finditer(objective)]
|
|
27
|
+
if not items:
|
|
28
|
+
for s in re.split(r"(?<=[.!?])\s+", objective):
|
|
29
|
+
s = s.strip()
|
|
30
|
+
if s and len(s) < 240 and _IMPERATIVE.search(s):
|
|
31
|
+
items.append(s)
|
|
32
|
+
# de-dupe preserving order
|
|
33
|
+
seen: set[str] = set()
|
|
34
|
+
out = []
|
|
35
|
+
for it in items:
|
|
36
|
+
k = it.lower()
|
|
37
|
+
if k not in seen:
|
|
38
|
+
seen.add(k)
|
|
39
|
+
out.append(it)
|
|
40
|
+
return out[:limit]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TaskState:
|
|
45
|
+
"""Authoritative task facts, independent of the message transcript."""
|
|
46
|
+
objective: str = ""
|
|
47
|
+
acceptance_criteria: list[str] = field(default_factory=list)
|
|
48
|
+
phase: str = "understand"
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_objective(cls, objective: str) -> TaskState:
|
|
52
|
+
obj = (objective or "").strip() if isinstance(objective, str) else str(objective or "").strip()
|
|
53
|
+
return cls(objective=obj, acceptance_criteria=extract_acceptance_criteria(obj))
|
|
54
|
+
|
|
55
|
+
def is_set(self) -> bool:
|
|
56
|
+
return bool(self.objective)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
"""Serializable WITHOUT the transcript — the task can be reconstructed from this."""
|
|
60
|
+
return {
|
|
61
|
+
"objective": self.objective,
|
|
62
|
+
"acceptance_criteria": list(self.acceptance_criteria),
|
|
63
|
+
"phase": self.phase,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, d: dict) -> TaskState:
|
|
68
|
+
return cls(
|
|
69
|
+
objective=d.get("objective", ""),
|
|
70
|
+
acceptance_criteria=list(d.get("acceptance_criteria", [])),
|
|
71
|
+
phase=d.get("phase", "understand"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def anchor_text(self) -> str:
|
|
75
|
+
"""A compact, durable reminder re-injected after compaction so a summarized
|
|
76
|
+
transcript can never lose the original objective + criteria. '' if unset."""
|
|
77
|
+
if not self.objective:
|
|
78
|
+
return ""
|
|
79
|
+
head = self.objective if len(self.objective) <= 700 else self.objective[:700] + " …"
|
|
80
|
+
lines = [f"[TASK — do not lose sight of the original objective]\n{head}"]
|
|
81
|
+
if self.acceptance_criteria:
|
|
82
|
+
lines.append("Acceptance criteria (all must be met):")
|
|
83
|
+
lines += [f" - {c}" for c in self.acceptance_criteria]
|
|
84
|
+
return "\n".join(lines)
|
|
@@ -84,6 +84,17 @@ def _fmt_tokens(n: int) -> str:
|
|
|
84
84
|
return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def is_slash_command(text: str) -> bool:
|
|
88
|
+
"""True only for a real ``/command``, NOT a message that merely starts with a
|
|
89
|
+
path — e.g. ``/app/env.py defines ...`` or ``/report.tex``. A command name is a
|
|
90
|
+
single leading token of word chars (no path separators, no dots); anything with
|
|
91
|
+
``/``, ``\\`` or ``.`` in that first token is treated as a normal agent message."""
|
|
92
|
+
if not text.startswith("/"):
|
|
93
|
+
return False
|
|
94
|
+
head = text[1:].split(maxsplit=1)[0] if len(text) > 1 else ""
|
|
95
|
+
return bool(head) and not any(c in head for c in "/\\.")
|
|
96
|
+
|
|
97
|
+
|
|
87
98
|
class DrydockApp(App):
|
|
88
99
|
CSS = """
|
|
89
100
|
Screen { background: #0b1f2a; }
|
|
@@ -425,7 +436,7 @@ class DrydockApp(App):
|
|
|
425
436
|
prompt.clear()
|
|
426
437
|
if not text:
|
|
427
438
|
return
|
|
428
|
-
if text
|
|
439
|
+
if is_slash_command(text):
|
|
429
440
|
self._handle_slash(text) # meta commands stay out of history
|
|
430
441
|
return
|
|
431
442
|
prompt.cmd_history.add(text)
|
|
@@ -26,6 +26,7 @@ drydock/rmf_graph.py
|
|
|
26
26
|
drydock/skills.py
|
|
27
27
|
drydock/stig.py
|
|
28
28
|
drydock/suggest.py
|
|
29
|
+
drydock/task_state.py
|
|
29
30
|
drydock/tool_registry.py
|
|
30
31
|
drydock/tuning.py
|
|
31
32
|
drydock/web.py
|
|
@@ -117,6 +118,7 @@ tests/test_streaming_newlines.py
|
|
|
117
118
|
tests/test_subagent.py
|
|
118
119
|
tests/test_suggest.py
|
|
119
120
|
tests/test_system_prompt_help.py
|
|
121
|
+
tests/test_task_state.py
|
|
120
122
|
tests/test_todo.py
|
|
121
123
|
tests/test_tool_arg_coercion.py
|
|
122
124
|
tests/test_tool_arg_coercion_more.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.123"
|
|
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"
|
|
@@ -21,6 +21,7 @@ def test_registered_and_available_to_gemma():
|
|
|
21
21
|
def test_graceful_when_no_grabber(monkeypatch):
|
|
22
22
|
monkeypatch.setattr(tools, "_IS_WINDOWS", False)
|
|
23
23
|
monkeypatch.setattr("sys.platform", "linux")
|
|
24
|
+
monkeypatch.setenv("DISPLAY", ":0") # a display exists, but no grabber is installed
|
|
24
25
|
monkeypatch.setattr("shutil.which", lambda _n: None) # no grabbers installed
|
|
25
26
|
out = tools.tool_screenshot({}, {"cwd": os.getcwd()})
|
|
26
27
|
assert out.startswith("Error") and "screenshot tool" in out
|
|
@@ -18,3 +18,13 @@ def test_priority_and_cases():
|
|
|
18
18
|
def test_error_beats_plan_and_writes():
|
|
19
19
|
assert s(ctx_pct=10, wrote_files=True, ran_bash=True, had_error=True,
|
|
20
20
|
in_git=True, plan_remaining=True) == "fix the error above"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_is_slash_command_distinguishes_paths():
|
|
24
|
+
from drydock.tui.app import is_slash_command as c
|
|
25
|
+
assert c("/help") and c("/ml-train foo") and c("/ask!") and c("/compact")
|
|
26
|
+
# file paths / messages starting with a path are NOT commands
|
|
27
|
+
assert not c("/app/env.py defines a gridworld")
|
|
28
|
+
assert not c("/report.tex")
|
|
29
|
+
assert not c("/data/file.h5 holds predictions")
|
|
30
|
+
assert not c("Train a model") and not c("/")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Structured task state (Agent-Buildout PRD Epic A): the objective + acceptance
|
|
2
|
+
criteria live outside the transcript, are serializable, and survive compaction."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock import agent
|
|
6
|
+
from drydock.providers import AssistantTurn
|
|
7
|
+
from drydock.task_state import TaskState, extract_acceptance_criteria
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_extracts_numbered_and_bulleted_criteria():
|
|
11
|
+
obj = "Build a parser. Requirements:\n1. Read /app/in.txt\n2. Write /app/out.json\n- Exit 0"
|
|
12
|
+
c = extract_acceptance_criteria(obj)
|
|
13
|
+
assert c == ["Read /app/in.txt", "Write /app/out.json", "Exit 0"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_extracts_imperative_when_no_list():
|
|
17
|
+
c = extract_acceptance_criteria("The script must print the CN and it should exit 0.")
|
|
18
|
+
assert any("must print" in x for x in c)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_from_objective_and_serializable_without_transcript():
|
|
22
|
+
ts = TaskState.from_objective("Do X.\n1. A\n2. B")
|
|
23
|
+
assert ts.is_set() and ts.acceptance_criteria == ["A", "B"] and ts.phase == "understand"
|
|
24
|
+
d = ts.to_dict() # A1.1: serializable without the transcript
|
|
25
|
+
assert d["objective"] and d["acceptance_criteria"] == ["A", "B"]
|
|
26
|
+
assert TaskState.from_dict(d).objective == ts.objective # reconstructable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_anchor_text_includes_objective_and_criteria():
|
|
30
|
+
a = TaskState.from_objective("Fix the bug.\n1. tests pass\n2. no regressions").anchor_text()
|
|
31
|
+
assert "Fix the bug" in a and "tests pass" in a and "no regressions" in a
|
|
32
|
+
assert TaskState().anchor_text() == "" # empty when unset
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_capture(cfg, msg, state=None):
|
|
36
|
+
seen = {}
|
|
37
|
+
def fake_stream(model, system, messages, tool_schemas, config):
|
|
38
|
+
seen["system"] = system
|
|
39
|
+
yield AssistantTurn("done", [], 5, 5)
|
|
40
|
+
orig = agent.stream; agent.stream = fake_stream
|
|
41
|
+
try:
|
|
42
|
+
st = state or agent.AgentState()
|
|
43
|
+
list(agent.run(msg, st, cfg, "BASE"))
|
|
44
|
+
return seen["system"], st
|
|
45
|
+
finally:
|
|
46
|
+
agent.stream = orig
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_objective_captured_and_anchored_in_system_prompt():
|
|
50
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
|
|
51
|
+
sysp, st = _run_capture(cfg, "Recover the password.\n1. it is 23 chars\n2. write it to out.txt")
|
|
52
|
+
assert st.task.is_set() and st.task.acceptance_criteria == ["it is 23 chars", "write it to out.txt"]
|
|
53
|
+
assert "TASK" in sysp and "23 chars" in sysp # anchor present in system prompt
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_anchor_survives_a_compacted_follow_up_turn():
|
|
57
|
+
# A2: after the transcript is replaced (compaction), the objective is still in the
|
|
58
|
+
# system prompt on the next turn because it lives in TaskState, not the messages.
|
|
59
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": True}
|
|
60
|
+
_, st = _run_capture(cfg, "Original objective X.\n1. criterion one")
|
|
61
|
+
st.messages = [{"role": "user", "content": "(transcript summarized away)"}] # simulate compaction
|
|
62
|
+
sysp2, _ = _run_capture(cfg, "keep going", state=st)
|
|
63
|
+
assert "Original objective X" in sysp2 and "criterion one" in sysp2
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_disabled_when_task_anchor_false():
|
|
67
|
+
cfg = {"model": "gemma4", "max_tokens": 8192, "context_limit": 65536, "task_anchor": False}
|
|
68
|
+
sysp, _ = _run_capture(cfg, "Do the thing.\n1. a criterion")
|
|
69
|
+
assert "TASK" not in sysp
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|