drydock-cli 3.0.49__tar.gz → 3.0.51__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.49/drydock_cli.egg-info → drydock_cli-3.0.51}/PKG-INFO +1 -1
- drydock_cli-3.0.51/drydock/gittools.py +96 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tools/__init__.py +183 -2
- drydock_cli-3.0.51/drydock/web.py +98 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock_cli.egg-info/SOURCES.txt +5 -1
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/pyproject.toml +1 -1
- drydock_cli-3.0.51/tests/test_gittools.py +78 -0
- drydock_cli-3.0.51/tests/test_web_tools.py +72 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/LICENSE +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/NOTICE +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/README.md +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/agent.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/cli.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/config.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/detect.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/guards.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/providers.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tui/app.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/setup.cfg +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_config.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.49 → drydock_cli-3.0.51}/tests/test_vision_input.py +0 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Dedicated Version Control helpers — first-class git for the agent.
|
|
2
|
+
|
|
3
|
+
Git works through the generic Bash tool, but purpose-built git_* tools give the
|
|
4
|
+
model concise, structured, TRUNCATED output (a raw `git diff` can blow the
|
|
5
|
+
context window) and a safe commit path, without it having to remember exact git
|
|
6
|
+
invocations. Read ops (status/diff/log) are side-effect-free; commit is local
|
|
7
|
+
and reversible (push stays gated through Bash's approval modal).
|
|
8
|
+
|
|
9
|
+
All logic original to Drydock.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitError(RuntimeError):
|
|
17
|
+
"""A git operation failed (not a repo, git missing, or a command error)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_git(args: list[str], cwd: str, timeout: float = 30.0) -> str:
|
|
21
|
+
"""Run `git <args>` in cwd, returning stdout. Raises GitError on failure."""
|
|
22
|
+
try:
|
|
23
|
+
p = subprocess.run(
|
|
24
|
+
["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
25
|
+
timeout=timeout,
|
|
26
|
+
)
|
|
27
|
+
except FileNotFoundError as e:
|
|
28
|
+
raise GitError("git is not installed or not on PATH") from e
|
|
29
|
+
except subprocess.TimeoutExpired as e:
|
|
30
|
+
raise GitError(f"git {args[0]} timed out after {timeout:.0f}s") from e
|
|
31
|
+
if p.returncode != 0:
|
|
32
|
+
err = (p.stderr or p.stdout or "").strip()
|
|
33
|
+
if "not a git repository" in err.lower():
|
|
34
|
+
raise GitError(f"not a git repository: {cwd}")
|
|
35
|
+
raise GitError(err or f"git {args[0]} failed (exit {p.returncode})")
|
|
36
|
+
return p.stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate(text: str, max_chars: int, *, tail: bool = False) -> str:
|
|
40
|
+
if len(text) <= max_chars:
|
|
41
|
+
return text
|
|
42
|
+
note = f"\n[... {len(text) - max_chars} chars truncated ...]\n"
|
|
43
|
+
return (note + text[-max_chars:]) if tail else (text[:max_chars] + note)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def status(cwd: str) -> str:
|
|
47
|
+
"""Branch + concise porcelain status of the working tree."""
|
|
48
|
+
branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).strip()
|
|
49
|
+
porcelain = _run_git(["status", "--porcelain=v1", "--branch"], cwd).strip()
|
|
50
|
+
if len(porcelain.splitlines()) <= 1: # only the "## branch" header line
|
|
51
|
+
return f"On branch {branch}. Working tree clean."
|
|
52
|
+
return f"On branch {branch}:\n{_truncate(porcelain, 4000)}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def diff(cwd: str, path: str | None = None, *, staged: bool = False,
|
|
56
|
+
max_chars: int = 8000) -> str:
|
|
57
|
+
"""Working-tree (or --staged) diff, optionally scoped to a path. Truncated."""
|
|
58
|
+
args = ["diff"]
|
|
59
|
+
if staged:
|
|
60
|
+
args.append("--cached")
|
|
61
|
+
if path:
|
|
62
|
+
args += ["--", path]
|
|
63
|
+
out = _run_git(args, cwd)
|
|
64
|
+
if not out.strip():
|
|
65
|
+
scope = " (staged)" if staged else ""
|
|
66
|
+
return f"No{scope} changes" + (f" in {path}" if path else "") + "."
|
|
67
|
+
# Lead with a --stat summary so the model sees the shape even when truncated.
|
|
68
|
+
stat_args = ["diff", "--stat"] + (["--cached"] if staged else [])
|
|
69
|
+
if path:
|
|
70
|
+
stat_args += ["--", path]
|
|
71
|
+
stat = _run_git(stat_args, cwd).strip()
|
|
72
|
+
return f"{stat}\n\n{_truncate(out, max_chars)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def log(cwd: str, n: int = 10) -> str:
|
|
76
|
+
"""The last n commits, one line each."""
|
|
77
|
+
n = max(1, min(n, 50))
|
|
78
|
+
out = _run_git(["log", f"-{n}", "--oneline", "--decorate"], cwd).strip()
|
|
79
|
+
return out or "No commits yet."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def commit(cwd: str, message: str, *, add_all: bool = True) -> str:
|
|
83
|
+
"""Stage (all, by default) and commit. Local + reversible (git reset undoes
|
|
84
|
+
it). Refuses an empty message. Does NOT push."""
|
|
85
|
+
message = (message or "").strip()
|
|
86
|
+
if not message:
|
|
87
|
+
raise GitError("a commit needs a non-empty message")
|
|
88
|
+
if add_all:
|
|
89
|
+
_run_git(["add", "-A"], cwd)
|
|
90
|
+
# Nothing staged → say so instead of erroring opaquely.
|
|
91
|
+
staged = _run_git(["diff", "--cached", "--name-only"], cwd).strip()
|
|
92
|
+
if not staged:
|
|
93
|
+
return "Nothing to commit — the working tree has no staged changes."
|
|
94
|
+
out = _run_git(["commit", "-m", message], cwd)
|
|
95
|
+
head = _run_git(["rev-parse", "--short", "HEAD"], cwd).strip()
|
|
96
|
+
return f"Committed {head}:\n{out.strip()}"
|
|
@@ -161,6 +161,90 @@ SCHEMAS = [
|
|
|
161
161
|
"required": ["prompt"],
|
|
162
162
|
},
|
|
163
163
|
},
|
|
164
|
+
{
|
|
165
|
+
"name": "GitStatus",
|
|
166
|
+
"description": (
|
|
167
|
+
"Show the current branch and a concise list of changed/staged/"
|
|
168
|
+
"untracked files (git status). Use it to understand the working tree "
|
|
169
|
+
"before editing or committing."
|
|
170
|
+
),
|
|
171
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"name": "GitDiff",
|
|
175
|
+
"description": (
|
|
176
|
+
"Show the diff of changes (git diff), with a --stat summary first and "
|
|
177
|
+
"the body truncated to fit context. Set staged=true for staged "
|
|
178
|
+
"changes; pass a path to scope it to one file/dir."
|
|
179
|
+
),
|
|
180
|
+
"input_schema": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"path": {"type": "string", "description": "Optional file/dir to scope the diff."},
|
|
184
|
+
"staged": {"type": "boolean", "description": "Diff staged changes instead of the working tree."},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"name": "GitLog",
|
|
190
|
+
"description": "Show the last n commits, one line each (git log --oneline).",
|
|
191
|
+
"input_schema": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"properties": {
|
|
194
|
+
"n": {"type": "integer", "description": "How many commits (default 10)."},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "GitCommit",
|
|
200
|
+
"description": (
|
|
201
|
+
"Stage all changes and commit with a message (git add -A && git "
|
|
202
|
+
"commit). Local and reversible; does NOT push. Use it to package a "
|
|
203
|
+
"completed, verified change. Write a clear, specific message."
|
|
204
|
+
),
|
|
205
|
+
"input_schema": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"message": {"type": "string", "description": "The commit message."},
|
|
209
|
+
"add_all": {"type": "boolean", "description": "Stage all changes first (default true)."},
|
|
210
|
+
},
|
|
211
|
+
"required": ["message"],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "WebSearch",
|
|
216
|
+
"description": (
|
|
217
|
+
"Search the internet and get back the top results (title, URL, "
|
|
218
|
+
"snippet). Use it for current events, docs, library/API details, "
|
|
219
|
+
"error messages, or anything you're unsure about or that may have "
|
|
220
|
+
"changed since training. Follow up with WebFetch to read a result in "
|
|
221
|
+
"full. Returns a clean message if the machine is offline."
|
|
222
|
+
),
|
|
223
|
+
"input_schema": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"query": {"type": "string", "description": "The search query."},
|
|
227
|
+
"k": {"type": "integer", "description": "Max results (default 5)."},
|
|
228
|
+
},
|
|
229
|
+
"required": ["query"],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"name": "WebFetch",
|
|
234
|
+
"description": (
|
|
235
|
+
"Fetch a web page (or any URL) and return its readable text content, "
|
|
236
|
+
"HTML stripped. Use it to read a page found via WebSearch or a URL the "
|
|
237
|
+
"user gave you. Returns a clean message if the machine is offline."
|
|
238
|
+
),
|
|
239
|
+
"input_schema": {
|
|
240
|
+
"type": "object",
|
|
241
|
+
"properties": {
|
|
242
|
+
"url": {"type": "string", "description": "The URL to fetch."},
|
|
243
|
+
"max_chars": {"type": "integer", "description": "Max chars to return (default 6000)."},
|
|
244
|
+
},
|
|
245
|
+
"required": ["url"],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
164
248
|
{
|
|
165
249
|
"name": "Knowledge",
|
|
166
250
|
"description": (
|
|
@@ -735,6 +819,90 @@ def tool_task(params: dict, config: dict) -> str:
|
|
|
735
819
|
return f"[sub-agent finished {steps} step(s) with no summary]"
|
|
736
820
|
|
|
737
821
|
|
|
822
|
+
def _git_cwd(config: dict) -> str:
|
|
823
|
+
return config.get("cwd") or os.getcwd()
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def tool_gitstatus(params: dict, config: dict) -> str:
|
|
827
|
+
from drydock import gittools
|
|
828
|
+
try:
|
|
829
|
+
return gittools.status(_git_cwd(config))
|
|
830
|
+
except gittools.GitError as e:
|
|
831
|
+
return f"git status failed: {e}"
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def tool_gitdiff(params: dict, config: dict) -> str:
|
|
835
|
+
from drydock import gittools
|
|
836
|
+
try:
|
|
837
|
+
return gittools.diff(
|
|
838
|
+
_git_cwd(config),
|
|
839
|
+
path=(params.get("path") or None),
|
|
840
|
+
staged=bool(params.get("staged")),
|
|
841
|
+
)
|
|
842
|
+
except gittools.GitError as e:
|
|
843
|
+
return f"git diff failed: {e}"
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def tool_gitlog(params: dict, config: dict) -> str:
|
|
847
|
+
from drydock import gittools
|
|
848
|
+
try:
|
|
849
|
+
n = int(params.get("n") or 10)
|
|
850
|
+
except (TypeError, ValueError):
|
|
851
|
+
n = 10
|
|
852
|
+
try:
|
|
853
|
+
return gittools.log(_git_cwd(config), n=n)
|
|
854
|
+
except gittools.GitError as e:
|
|
855
|
+
return f"git log failed: {e}"
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def tool_gitcommit(params: dict, config: dict) -> str:
|
|
859
|
+
from drydock import gittools
|
|
860
|
+
try:
|
|
861
|
+
return gittools.commit(
|
|
862
|
+
_git_cwd(config),
|
|
863
|
+
params.get("message") or "",
|
|
864
|
+
add_all=params.get("add_all", True) is not False,
|
|
865
|
+
)
|
|
866
|
+
except gittools.GitError as e:
|
|
867
|
+
return f"git commit failed: {e}"
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def tool_websearch(params: dict, config: dict) -> str:
|
|
871
|
+
"""Search the internet (DuckDuckGo). Read-only; clean message when offline."""
|
|
872
|
+
from drydock import web
|
|
873
|
+
|
|
874
|
+
query = (params.get("query") or "").strip()
|
|
875
|
+
if not query:
|
|
876
|
+
return "Error: `WebSearch` needs a `query`."
|
|
877
|
+
try:
|
|
878
|
+
k = int(params.get("k") or 5)
|
|
879
|
+
except (TypeError, ValueError):
|
|
880
|
+
k = 5
|
|
881
|
+
try:
|
|
882
|
+
results = web.search(query, k=max(1, min(k, 10)))
|
|
883
|
+
except web.WebError as e:
|
|
884
|
+
return f"Web search unavailable: {e}. You appear to be offline — answer from your own knowledge."
|
|
885
|
+
return web.format_search(query, results)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def tool_webfetch(params: dict, config: dict) -> str:
|
|
889
|
+
"""Fetch a URL and return readable text. Read-only; clean message offline."""
|
|
890
|
+
from drydock import web
|
|
891
|
+
|
|
892
|
+
url = (params.get("url") or "").strip()
|
|
893
|
+
if not url:
|
|
894
|
+
return "Error: `WebFetch` needs a `url`."
|
|
895
|
+
try:
|
|
896
|
+
mc = int(params.get("max_chars") or 6000)
|
|
897
|
+
except (TypeError, ValueError):
|
|
898
|
+
mc = 6000
|
|
899
|
+
try:
|
|
900
|
+
text = web.fetch(url, max_chars=max(500, min(mc, 30000)))
|
|
901
|
+
except web.WebError as e:
|
|
902
|
+
return f"Could not fetch the page: {e}."
|
|
903
|
+
return text or f"(no readable text extracted from {url})"
|
|
904
|
+
|
|
905
|
+
|
|
738
906
|
def tool_knowledge(params: dict, config: dict) -> str:
|
|
739
907
|
"""Query the project's GraphRAG knowledge base (built with /graphrag build).
|
|
740
908
|
Read-only; returns the most relevant passages plus related graph entities.
|
|
@@ -773,6 +941,12 @@ _TOOLS = [
|
|
|
773
941
|
("todo", tool_todo, False),
|
|
774
942
|
("task", tool_task, True),
|
|
775
943
|
("Knowledge", tool_knowledge, True),
|
|
944
|
+
("WebSearch", tool_websearch, True),
|
|
945
|
+
("WebFetch", tool_webfetch, True),
|
|
946
|
+
("GitStatus", tool_gitstatus, True),
|
|
947
|
+
("GitDiff", tool_gitdiff, True),
|
|
948
|
+
("GitLog", tool_gitlog, True),
|
|
949
|
+
("GitCommit", tool_gitcommit, False),
|
|
776
950
|
]
|
|
777
951
|
|
|
778
952
|
def register_all():
|
|
@@ -782,9 +956,16 @@ def register_all():
|
|
|
782
956
|
"Read": tool_read, "Write": tool_write, "Edit": tool_edit,
|
|
783
957
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
784
958
|
"todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
|
|
959
|
+
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
960
|
+
"GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
|
|
961
|
+
"GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
|
|
785
962
|
}[name]
|
|
786
|
-
#
|
|
787
|
-
|
|
963
|
+
# Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
|
|
964
|
+
# GitCommit writes a local, reversible commit).
|
|
965
|
+
read_only = name in (
|
|
966
|
+
"Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
|
|
967
|
+
"GitStatus", "GitDiff", "GitLog",
|
|
968
|
+
)
|
|
788
969
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
789
970
|
|
|
790
971
|
register_all()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Internet access — web search + page fetch, stdlib only (no deps, no API key).
|
|
2
|
+
|
|
3
|
+
Search goes through DuckDuckGo's HTML endpoint (a form POST; the GET form is
|
|
4
|
+
bot-challenged). Fetch pulls a page and reduces it to readable text. Both are
|
|
5
|
+
best-effort and degrade cleanly when offline — drydock targets local LLMs that
|
|
6
|
+
may run air-gapped, so a network failure returns a plain message, never raises.
|
|
7
|
+
|
|
8
|
+
All logic original to Drydock.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0"
|
|
19
|
+
_DDG = "https://html.duckduckgo.com/html/"
|
|
20
|
+
|
|
21
|
+
_RE_RESULT = re.compile(r'result__a"[^>]*href="([^"]+)".*?>(.*?)</a>', re.S)
|
|
22
|
+
_RE_SNIPPET = re.compile(r'result__snippet"[^>]*>(.*?)</a>', re.S)
|
|
23
|
+
_RE_TAG = re.compile(r"<[^>]+>")
|
|
24
|
+
_RE_SCRIPT = re.compile(r"<(script|style|noscript|svg)\b.*?</\1>", re.S | re.I)
|
|
25
|
+
_RE_WS = re.compile(r"[ \t]+")
|
|
26
|
+
_RE_BLANKS = re.compile(r"\n\s*\n\s*\n+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _strip_html(s: str) -> str:
|
|
30
|
+
return html.unescape(_RE_TAG.sub("", s)).strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebError(RuntimeError):
|
|
34
|
+
"""Network/HTTP failure reaching the internet (carries a user-facing msg)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def search(query: str, k: int = 5, *, timeout: float = 12.0) -> list[dict]:
|
|
38
|
+
"""Return up to k web results: [{title, url, snippet}]. Raises WebError on a
|
|
39
|
+
network failure so the caller can show a clean offline message."""
|
|
40
|
+
data = urllib.parse.urlencode({"q": query}).encode()
|
|
41
|
+
req = urllib.request.Request(_DDG, data=data, headers={"User-Agent": _UA})
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted)
|
|
44
|
+
body = r.read().decode("utf-8", "ignore")
|
|
45
|
+
except (urllib.error.URLError, OSError) as e:
|
|
46
|
+
raise WebError(f"could not reach the internet ({e})") from e
|
|
47
|
+
|
|
48
|
+
links = _RE_RESULT.findall(body)
|
|
49
|
+
snippets = _RE_SNIPPET.findall(body)
|
|
50
|
+
out: list[dict] = []
|
|
51
|
+
for (url, title), snip in zip(links, snippets + [""] * len(links)):
|
|
52
|
+
url = html.unescape(url)
|
|
53
|
+
if url.startswith("//"):
|
|
54
|
+
url = "https:" + url
|
|
55
|
+
# DDG sometimes wraps the target in a redirect with ?uddg=<real-url>.
|
|
56
|
+
m = re.search(r"[?&]uddg=([^&]+)", url)
|
|
57
|
+
if m:
|
|
58
|
+
url = urllib.parse.unquote(m.group(1))
|
|
59
|
+
out.append({"title": _strip_html(title), "url": url, "snippet": _strip_html(snip)})
|
|
60
|
+
if len(out) >= k:
|
|
61
|
+
break
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch(url: str, *, max_chars: int = 6000, timeout: float = 12.0) -> str:
|
|
66
|
+
"""Fetch a URL and return its readable text (HTML stripped). Raises WebError
|
|
67
|
+
on a network/HTTP failure."""
|
|
68
|
+
if not url.startswith(("http://", "https://")):
|
|
69
|
+
url = "https://" + url
|
|
70
|
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (user-supplied URL)
|
|
73
|
+
ctype = r.headers.get("Content-Type", "")
|
|
74
|
+
raw = r.read(4_000_000) # cap the download
|
|
75
|
+
except (urllib.error.URLError, OSError) as e:
|
|
76
|
+
raise WebError(f"could not fetch {url} ({e})") from e
|
|
77
|
+
|
|
78
|
+
text = raw.decode("utf-8", "ignore")
|
|
79
|
+
if "html" in ctype.lower() or "<html" in text[:2000].lower():
|
|
80
|
+
text = _RE_SCRIPT.sub(" ", text)
|
|
81
|
+
text = _RE_TAG.sub(" ", text)
|
|
82
|
+
text = html.unescape(text)
|
|
83
|
+
text = _RE_WS.sub(" ", text)
|
|
84
|
+
text = _RE_BLANKS.sub("\n\n", "\n".join(ln.strip() for ln in text.splitlines()))
|
|
85
|
+
text = text.strip()
|
|
86
|
+
if len(text) > max_chars:
|
|
87
|
+
text = text[:max_chars] + f"\n[... truncated, {len(text) - max_chars} more chars ...]"
|
|
88
|
+
return text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_search(query: str, results: list[dict]) -> str:
|
|
92
|
+
if not results:
|
|
93
|
+
return f"No web results for {query!r}."
|
|
94
|
+
parts = [f"Web results for {query!r}:"]
|
|
95
|
+
for i, r in enumerate(results, 1):
|
|
96
|
+
parts.append(f"\n[{i}] {r['title']}\n {r['url']}\n {r['snippet']}")
|
|
97
|
+
parts.append("\nTo read one in full, use WebFetch with its URL.")
|
|
98
|
+
return "\n".join(parts)
|
|
@@ -10,12 +10,14 @@ drydock/cli.py
|
|
|
10
10
|
drydock/compaction.py
|
|
11
11
|
drydock/config.py
|
|
12
12
|
drydock/detect.py
|
|
13
|
+
drydock/gittools.py
|
|
13
14
|
drydock/graphrag.py
|
|
14
15
|
drydock/guards.py
|
|
15
16
|
drydock/loop_detect.py
|
|
16
17
|
drydock/providers.py
|
|
17
18
|
drydock/tool_registry.py
|
|
18
19
|
drydock/tuning.py
|
|
20
|
+
drydock/web.py
|
|
19
21
|
drydock/tools/__init__.py
|
|
20
22
|
drydock/tui/__init__.py
|
|
21
23
|
drydock/tui/app.py
|
|
@@ -43,6 +45,7 @@ tests/test_detect.py
|
|
|
43
45
|
tests/test_empty_response.py
|
|
44
46
|
tests/test_failure_loop.py
|
|
45
47
|
tests/test_first_run_setup.py
|
|
48
|
+
tests/test_gittools.py
|
|
46
49
|
tests/test_graphrag.py
|
|
47
50
|
tests/test_guards_and_tools.py
|
|
48
51
|
tests/test_hallucinated_tools.py
|
|
@@ -60,4 +63,5 @@ tests/test_tool_arg_parsing.py
|
|
|
60
63
|
tests/test_tools_undo.py
|
|
61
64
|
tests/test_tui.py
|
|
62
65
|
tests/test_tuning.py
|
|
63
|
-
tests/test_vision_input.py
|
|
66
|
+
tests/test_vision_input.py
|
|
67
|
+
tests/test_web_tools.py
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.51"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Dedicated Version Control tools (drydock/gittools.py): structured, truncated
|
|
2
|
+
git for the agent. Tests run in isolated temp repos."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from drydock import gittools
|
|
10
|
+
from drydock.tools import (
|
|
11
|
+
tool_gitstatus, tool_gitdiff, tool_gitlog, tool_gitcommit,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _git(args, cwd):
|
|
16
|
+
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def repo(tmp_path):
|
|
21
|
+
_git(["init", "-q"], tmp_path)
|
|
22
|
+
_git(["config", "user.email", "t@t.com"], tmp_path)
|
|
23
|
+
_git(["config", "user.name", "T"], tmp_path)
|
|
24
|
+
(tmp_path / "a.txt").write_text("hello\n")
|
|
25
|
+
_git(["add", "-A"], tmp_path)
|
|
26
|
+
_git(["commit", "-qm", "initial"], tmp_path)
|
|
27
|
+
return tmp_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_status_clean_then_dirty(repo):
|
|
31
|
+
cfg = {"cwd": str(repo)}
|
|
32
|
+
assert "clean" in tool_gitstatus({}, cfg).lower()
|
|
33
|
+
(repo / "a.txt").write_text("hello\nworld\n")
|
|
34
|
+
out = tool_gitstatus({}, cfg)
|
|
35
|
+
assert "a.txt" in out and "clean" not in out.lower()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_diff_shows_stat_and_body(repo):
|
|
39
|
+
(repo / "a.txt").write_text("hello\nchanged\n")
|
|
40
|
+
out = tool_gitdiff({}, {"cwd": str(repo)})
|
|
41
|
+
assert "a.txt" in out and "changed" in out and "insertion" in out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_diff_no_changes(repo):
|
|
45
|
+
assert "No changes" in tool_gitdiff({}, {"cwd": str(repo)})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_log_lists_commits(repo):
|
|
49
|
+
out = tool_gitlog({"n": 5}, {"cwd": str(repo)})
|
|
50
|
+
assert "initial" in out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_commit_stages_and_commits(repo):
|
|
54
|
+
(repo / "b.txt").write_text("new file\n")
|
|
55
|
+
out = tool_gitcommit({"message": "add b"}, {"cwd": str(repo)})
|
|
56
|
+
assert "Committed" in out
|
|
57
|
+
assert "add b" in tool_gitlog({}, {"cwd": str(repo)})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_commit_empty_message_refused(repo):
|
|
61
|
+
(repo / "b.txt").write_text("x")
|
|
62
|
+
assert "non-empty message" in tool_gitcommit({"message": " "}, {"cwd": str(repo)})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_commit_nothing_to_commit(repo):
|
|
66
|
+
assert "Nothing to commit" in tool_gitcommit({"message": "noop"}, {"cwd": str(repo)})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_not_a_repo_is_graceful(tmp_path):
|
|
70
|
+
out = tool_gitstatus({}, {"cwd": str(tmp_path)})
|
|
71
|
+
assert "not a git repository" in out.lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_diff_truncates_large_body(repo):
|
|
75
|
+
# modify a TRACKED file so it appears in the working-tree diff
|
|
76
|
+
(repo / "a.txt").write_text("\n".join(f"line {i}" for i in range(5000)))
|
|
77
|
+
out = gittools.diff(str(repo), max_chars=1000)
|
|
78
|
+
assert "truncated" in out and len(out) < 4000
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Internet access: WebSearch + WebFetch tools (drydock/web.py). Network is
|
|
2
|
+
mocked so tests are deterministic and run offline; the offline path is tested
|
|
3
|
+
explicitly (drydock targets local LLMs that may be air-gapped — must degrade,
|
|
4
|
+
never raise)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from drydock import web
|
|
9
|
+
from drydock.tools import tool_websearch, tool_webfetch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_websearch_formats_results(monkeypatch):
|
|
13
|
+
monkeypatch.setattr(web, "search", lambda q, k=5, **kw: [
|
|
14
|
+
{"title": "Python 3.13", "url": "https://docs.python.org/3/whatsnew/3.13.html",
|
|
15
|
+
"snippet": "new features"},
|
|
16
|
+
])
|
|
17
|
+
out = tool_websearch({"query": "python 3.13 features"}, {})
|
|
18
|
+
assert "Python 3.13" in out and "docs.python.org" in out and "WebFetch" in out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_websearch_offline_is_graceful(monkeypatch):
|
|
22
|
+
def boom(*a, **k):
|
|
23
|
+
raise web.WebError("could not reach the internet (offline)")
|
|
24
|
+
monkeypatch.setattr(web, "search", boom)
|
|
25
|
+
out = tool_websearch({"query": "anything"}, {})
|
|
26
|
+
assert "unavailable" in out.lower() and "offline" in out.lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_websearch_needs_query():
|
|
30
|
+
assert "needs a `query`" in tool_websearch({}, {})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_webfetch_returns_text(monkeypatch):
|
|
34
|
+
monkeypatch.setattr(web, "fetch", lambda url, **kw: "Readable page text here.")
|
|
35
|
+
out = tool_webfetch({"url": "example.com"}, {})
|
|
36
|
+
assert out == "Readable page text here."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_webfetch_offline_is_graceful(monkeypatch):
|
|
40
|
+
def boom(*a, **k):
|
|
41
|
+
raise web.WebError("could not fetch (offline)")
|
|
42
|
+
monkeypatch.setattr(web, "fetch", boom)
|
|
43
|
+
out = tool_webfetch({"url": "https://x.com"}, {})
|
|
44
|
+
assert "could not fetch" in out.lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_webfetch_needs_url():
|
|
48
|
+
assert "needs a `url`" in tool_webfetch({}, {})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_strip_html_and_parsing_helpers():
|
|
52
|
+
# The HTML→text reducer drops tags + unescapes entities.
|
|
53
|
+
assert web._strip_html("<b>Hello & bye</b>") == "Hello & bye"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_search_parses_ddg_html(monkeypatch):
|
|
57
|
+
sample = (
|
|
58
|
+
'<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa">'
|
|
59
|
+
'Example A</a>'
|
|
60
|
+
'<a class="result__snippet" href="x">snippet A text</a>'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
class _Resp:
|
|
64
|
+
def __enter__(self): return self
|
|
65
|
+
def __exit__(self, *a): return False
|
|
66
|
+
def read(self): return sample.encode()
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr(web.urllib.request, "urlopen", lambda *a, **k: _Resp())
|
|
69
|
+
res = web.search("q", k=5)
|
|
70
|
+
assert res and res[0]["title"] == "Example A"
|
|
71
|
+
assert res[0]["url"] == "https://example.com/a" # uddg redirect unwrapped
|
|
72
|
+
assert res[0]["snippet"] == "snippet A text"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|