copper-pilot-cli 0.1.1__py3-none-any.whl
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.
- copper_pilot_cli/__init__.py +37 -0
- copper_pilot_cli/__main__.py +6 -0
- copper_pilot_cli/_upstream/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
- copper_pilot_cli/_version.py +32 -0
- copper_pilot_cli/clipboard.py +107 -0
- copper_pilot_cli/copper_api.py +24 -0
- copper_pilot_cli/copper_app.py +1071 -0
- copper_pilot_cli/copper_auth.py +232 -0
- copper_pilot_cli/copper_config.py +62 -0
- copper_pilot_cli/copper_features.py +263 -0
- copper_pilot_cli/copper_graph.py +178 -0
- copper_pilot_cli/copper_hooks.py +49 -0
- copper_pilot_cli/copper_main.py +345 -0
- copper_pilot_cli/copper_preferences.py +85 -0
- copper_pilot_cli/copper_presentation.py +325 -0
- copper_pilot_cli/copper_protocol.py +421 -0
- copper_pilot_cli/copper_theme.py +34 -0
- copper_pilot_cli/copper_tools.py +23 -0
- copper_pilot_cli/copper_update.py +32 -0
- copper_pilot_cli/copper_widgets.py +1224 -0
- copper_pilot_cli/copper_workspace.py +151 -0
- copper_pilot_cli/deepagents_tools.py +556 -0
- copper_pilot_cli/diagnostics.py +55 -0
- copper_pilot_cli/langchain.py +215 -0
- copper_pilot_cli/media_utils.py +626 -0
- copper_pilot_cli/py.typed +0 -0
- copper_pilot_cli/sessions.py +1578 -0
- copper_pilot_cli/textual_patches.py +35 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
- copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
- copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
- copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
- copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Folder workspace discovery and recent-project persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from copper_pilot_cli.copper_config import paths
|
|
11
|
+
|
|
12
|
+
IGNORED_NAMES = {
|
|
13
|
+
".git",
|
|
14
|
+
".hg",
|
|
15
|
+
".svn",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".venv",
|
|
19
|
+
"venv",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class WorkspaceContext:
|
|
27
|
+
root: Path
|
|
28
|
+
schematic_path: str | None
|
|
29
|
+
pcb_path: str | None
|
|
30
|
+
project_files: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_workspace(value: str | Path | None = None) -> Path:
|
|
34
|
+
"""Resolve an existing directory, defaulting to the current directory."""
|
|
35
|
+
candidate = Path(value or Path.cwd()).expanduser().resolve()
|
|
36
|
+
if not candidate.exists():
|
|
37
|
+
raise FileNotFoundError(candidate)
|
|
38
|
+
if not candidate.is_dir():
|
|
39
|
+
raise NotADirectoryError(candidate)
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _walk(root: Path, max_depth: int = 8, max_files: int = 2_000) -> list[Path]:
|
|
44
|
+
found: list[Path] = []
|
|
45
|
+
for current, dirs, files in os.walk(root):
|
|
46
|
+
relative = Path(current).relative_to(root)
|
|
47
|
+
if len(relative.parts) >= max_depth:
|
|
48
|
+
dirs[:] = []
|
|
49
|
+
dirs[:] = sorted(name for name in dirs if name not in IGNORED_NAMES)
|
|
50
|
+
for name in sorted(files):
|
|
51
|
+
found.append(Path(current) / name)
|
|
52
|
+
if len(found) >= max_files:
|
|
53
|
+
return found
|
|
54
|
+
return found
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _relative(root: Path, value: Path | None) -> str | None:
|
|
58
|
+
return value.relative_to(root).as_posix() if value else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def discover_workspace(root: Path) -> WorkspaceContext:
|
|
62
|
+
"""Discover KiCad/Altium artifacts without requiring them."""
|
|
63
|
+
files = _walk(root)
|
|
64
|
+
projects = sorted(
|
|
65
|
+
(path for path in files if path.suffix.lower() in {".kicad_pro", ".prjpcb"}),
|
|
66
|
+
key=lambda item: (len(item.relative_to(root).parts), item.as_posix().lower()),
|
|
67
|
+
)
|
|
68
|
+
schematics = sorted(
|
|
69
|
+
(path for path in files if path.suffix.lower() in {".kicad_sch", ".schdoc"}),
|
|
70
|
+
key=lambda item: item.as_posix().lower(),
|
|
71
|
+
)
|
|
72
|
+
pcbs = sorted(
|
|
73
|
+
(path for path in files if path.suffix.lower() in {".kicad_pcb", ".pcbdoc"}),
|
|
74
|
+
key=lambda item: item.as_posix().lower(),
|
|
75
|
+
)
|
|
76
|
+
preferred_schematic: Path | None = None
|
|
77
|
+
preferred_pcb: Path | None = None
|
|
78
|
+
if projects:
|
|
79
|
+
project = projects[0]
|
|
80
|
+
matching_schematic = project.with_suffix(".kicad_sch")
|
|
81
|
+
matching_pcb = project.with_suffix(".kicad_pcb")
|
|
82
|
+
preferred_schematic = (
|
|
83
|
+
matching_schematic
|
|
84
|
+
if matching_schematic in schematics
|
|
85
|
+
else next(
|
|
86
|
+
(item for item in schematics if item.parent == project.parent),
|
|
87
|
+
schematics[0] if schematics else None,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
preferred_pcb = (
|
|
91
|
+
matching_pcb
|
|
92
|
+
if matching_pcb in pcbs
|
|
93
|
+
else next(
|
|
94
|
+
(item for item in pcbs if item.parent == project.parent),
|
|
95
|
+
pcbs[0] if pcbs else None,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
preferred_schematic = schematics[0] if schematics else None
|
|
100
|
+
preferred_pcb = pcbs[0] if pcbs else None
|
|
101
|
+
return WorkspaceContext(
|
|
102
|
+
root=root,
|
|
103
|
+
schematic_path=_relative(root, preferred_schematic),
|
|
104
|
+
pcb_path=_relative(root, preferred_pcb),
|
|
105
|
+
project_files=tuple(_relative(root, item) or "" for item in projects),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_initial_file_tree(root: Path, limit: int = 2_000) -> str:
|
|
110
|
+
"""Build a bounded relative file list for the hosted agent."""
|
|
111
|
+
paths = [
|
|
112
|
+
path.relative_to(root).as_posix()
|
|
113
|
+
for path in _walk(root, max_files=limit)
|
|
114
|
+
if ".copperpilot" not in path.relative_to(root).parts
|
|
115
|
+
]
|
|
116
|
+
return "\n".join(paths)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remember_workspace(context: WorkspaceContext, limit: int = 20) -> None:
|
|
120
|
+
"""Persist most-recently-used folders atomically."""
|
|
121
|
+
destination = paths().recent_projects
|
|
122
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
existing: list[dict[str, object]] = []
|
|
124
|
+
try:
|
|
125
|
+
loaded = json.loads(destination.read_text())
|
|
126
|
+
if isinstance(loaded, list):
|
|
127
|
+
existing = [row for row in loaded if isinstance(row, dict)]
|
|
128
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
129
|
+
pass
|
|
130
|
+
root = str(context.root)
|
|
131
|
+
rows = [{"root": root, **asdict(context)}]
|
|
132
|
+
rows.extend(row for row in existing if row.get("root") != root)
|
|
133
|
+
temporary = destination.with_suffix(".tmp")
|
|
134
|
+
temporary.write_text(json.dumps(rows[:limit], default=str, indent=2))
|
|
135
|
+
temporary.replace(destination)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def recent_workspaces() -> list[Path]:
|
|
139
|
+
"""Return existing recent workspace folders."""
|
|
140
|
+
try:
|
|
141
|
+
rows = json.loads(paths().recent_projects.read_text(encoding="utf-8"))
|
|
142
|
+
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
143
|
+
return []
|
|
144
|
+
result: list[Path] = []
|
|
145
|
+
for row in rows if isinstance(rows, list) else []:
|
|
146
|
+
if not isinstance(row, dict):
|
|
147
|
+
continue
|
|
148
|
+
candidate = Path(str(row.get("root") or "")).expanduser()
|
|
149
|
+
if candidate.is_dir():
|
|
150
|
+
result.append(candidate.resolve())
|
|
151
|
+
return result
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Hosted CopperPilot tool requests dispatched through Deep Agents native tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import mimetypes
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from deepagents.backends import LocalShellBackend
|
|
17
|
+
from deepagents.middleware.filesystem import FilesystemMiddleware
|
|
18
|
+
from langchain.tools import ToolRuntime
|
|
19
|
+
from langchain_core.messages import ToolMessage
|
|
20
|
+
from langchain_core.tools import BaseTool
|
|
21
|
+
|
|
22
|
+
from copper_pilot_cli.copper_protocol import ToolRequest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApprovalMode(StrEnum):
|
|
26
|
+
"""Local side-effect approval mode."""
|
|
27
|
+
|
|
28
|
+
MANUAL = "manual"
|
|
29
|
+
AUTO = "auto"
|
|
30
|
+
YOLO = "yolo"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolRejected(RuntimeError):
|
|
34
|
+
"""The user or local policy rejected a tool."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ApprovalRequest:
|
|
39
|
+
"""One hosted tool call awaiting local review."""
|
|
40
|
+
|
|
41
|
+
tool_call_id: str
|
|
42
|
+
tool_name: str
|
|
43
|
+
arguments: dict[str, Any]
|
|
44
|
+
reason: str
|
|
45
|
+
canonical_name: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ApprovalHandler = Callable[[ApprovalRequest], Awaitable[bool]]
|
|
49
|
+
QuestionHandler = Callable[[Any], Awaitable[Any]]
|
|
50
|
+
|
|
51
|
+
_ALIASES = {
|
|
52
|
+
"read": "read_file",
|
|
53
|
+
"write": "write_file",
|
|
54
|
+
"edit": "edit_file",
|
|
55
|
+
"delete_file": "delete",
|
|
56
|
+
"bash": "execute",
|
|
57
|
+
}
|
|
58
|
+
_READ_TOOLS = {"ls", "read_file", "glob", "grep", "read_binary_file"}
|
|
59
|
+
_MUTATION_TOOLS = {"write_file", "edit_file", "delete", "write_binary_file"}
|
|
60
|
+
_SHELL_CONTROL_RE = re.compile(r"(?:\n|\r|&&|\|\||[;&|`<>]|\$\(|\$\{)")
|
|
61
|
+
_ROUTINE_WRITE_SUFFIXES = frozenset(
|
|
62
|
+
{
|
|
63
|
+
".c",
|
|
64
|
+
".cc",
|
|
65
|
+
".cpp",
|
|
66
|
+
".css",
|
|
67
|
+
".go",
|
|
68
|
+
".h",
|
|
69
|
+
".hpp",
|
|
70
|
+
".html",
|
|
71
|
+
".ipynb",
|
|
72
|
+
".java",
|
|
73
|
+
".js",
|
|
74
|
+
".jsx",
|
|
75
|
+
".json",
|
|
76
|
+
".kt",
|
|
77
|
+
".md",
|
|
78
|
+
".mdx",
|
|
79
|
+
".php",
|
|
80
|
+
".proto",
|
|
81
|
+
".py",
|
|
82
|
+
".rb",
|
|
83
|
+
".rs",
|
|
84
|
+
".rst",
|
|
85
|
+
".scss",
|
|
86
|
+
".sql",
|
|
87
|
+
".swift",
|
|
88
|
+
".tex",
|
|
89
|
+
".toml",
|
|
90
|
+
".ts",
|
|
91
|
+
".tsx",
|
|
92
|
+
".txt",
|
|
93
|
+
".vue",
|
|
94
|
+
".xml",
|
|
95
|
+
".yaml",
|
|
96
|
+
".yml",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
_DEPENDENCY_FILES = frozenset(
|
|
100
|
+
{
|
|
101
|
+
"cargo.lock",
|
|
102
|
+
"cargo.toml",
|
|
103
|
+
"go.mod",
|
|
104
|
+
"go.sum",
|
|
105
|
+
"package-lock.json",
|
|
106
|
+
"package.json",
|
|
107
|
+
"pnpm-lock.yaml",
|
|
108
|
+
"poetry.lock",
|
|
109
|
+
"pyproject.toml",
|
|
110
|
+
"requirements.txt",
|
|
111
|
+
"uv.lock",
|
|
112
|
+
"yarn.lock",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
_SENSITIVE_PARTS = frozenset(
|
|
116
|
+
{
|
|
117
|
+
".agents",
|
|
118
|
+
".buildkite",
|
|
119
|
+
".circleci",
|
|
120
|
+
".claude",
|
|
121
|
+
".copperpilot",
|
|
122
|
+
".deepagents",
|
|
123
|
+
".devcontainer",
|
|
124
|
+
".git",
|
|
125
|
+
".github",
|
|
126
|
+
".husky",
|
|
127
|
+
".ssh",
|
|
128
|
+
".vscode",
|
|
129
|
+
"cron.d",
|
|
130
|
+
"hooks",
|
|
131
|
+
"launchagents",
|
|
132
|
+
"launchdaemons",
|
|
133
|
+
"systemd",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
_SENSITIVE_NAMES = frozenset(
|
|
137
|
+
{
|
|
138
|
+
".bash_profile",
|
|
139
|
+
".bashrc",
|
|
140
|
+
".env",
|
|
141
|
+
".mcp.json",
|
|
142
|
+
".pre-commit-config.yaml",
|
|
143
|
+
".profile",
|
|
144
|
+
".zshrc",
|
|
145
|
+
"action.yaml",
|
|
146
|
+
"action.yml",
|
|
147
|
+
"agents.md",
|
|
148
|
+
"authorized_keys",
|
|
149
|
+
"claude.md",
|
|
150
|
+
"codeowners",
|
|
151
|
+
"compose.yaml",
|
|
152
|
+
"compose.yml",
|
|
153
|
+
"conftest.py",
|
|
154
|
+
"docker-compose.yaml",
|
|
155
|
+
"docker-compose.yml",
|
|
156
|
+
"dockerfile",
|
|
157
|
+
"noxfile.py",
|
|
158
|
+
"setup.py",
|
|
159
|
+
"sitecustomize.py",
|
|
160
|
+
"sudoers",
|
|
161
|
+
"tox.ini",
|
|
162
|
+
"usercustomize.py",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
_SCRIPT_SUFFIXES = frozenset({".bash", ".bat", ".cmd", ".command", ".fish", ".ps1", ".sh", ".zsh"})
|
|
166
|
+
_DANGEROUS_EXECUTABLES = frozenset(
|
|
167
|
+
{"rm", "dd", "mv", "cp", "chmod", "chown", "format", "mkfs", "shred", "git"}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def canonical_tool_name(tool_name: str) -> str:
|
|
172
|
+
"""Map the hosted harness vocabulary to Deep Agents tool names."""
|
|
173
|
+
|
|
174
|
+
return _ALIASES.get(tool_name, tool_name)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_path(root: Path, raw: object) -> Path | None:
|
|
178
|
+
if not isinstance(raw, str) or not raw:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
candidate = Path(raw).expanduser()
|
|
182
|
+
if not candidate.is_absolute():
|
|
183
|
+
candidate = root / candidate
|
|
184
|
+
return candidate.resolve(strict=False)
|
|
185
|
+
except (OSError, RuntimeError, ValueError):
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _is_within(root: Path, path: Path) -> bool:
|
|
190
|
+
try:
|
|
191
|
+
path.relative_to(root)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return False
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _routine_write_allowed(root: Path, arguments: dict[str, Any]) -> bool:
|
|
198
|
+
path = _resolve_path(root, arguments.get("file_path"))
|
|
199
|
+
if path is None or not _is_within(root, path):
|
|
200
|
+
return False
|
|
201
|
+
relative_parts = tuple(part.lower() for part in path.relative_to(root).parts)
|
|
202
|
+
if any(part in _SENSITIVE_PARTS for part in relative_parts):
|
|
203
|
+
return False
|
|
204
|
+
if path.name.lower() in _SENSITIVE_NAMES | _DEPENDENCY_FILES:
|
|
205
|
+
return False
|
|
206
|
+
return path.suffix.lower() in _ROUTINE_WRITE_SUFFIXES - _SCRIPT_SUFFIXES
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_only_git_allowed(command: object, root: Path) -> bool:
|
|
210
|
+
if not isinstance(command, str) or not command.strip() or _SHELL_CONTROL_RE.search(command):
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
parts = shlex.split(command)
|
|
214
|
+
except ValueError:
|
|
215
|
+
return False
|
|
216
|
+
if len(parts) < 2 or parts[0] != "git":
|
|
217
|
+
return False
|
|
218
|
+
if parts[1] not in {"diff", "log", "ls-files", "rev-parse", "show", "status"}:
|
|
219
|
+
return False
|
|
220
|
+
for token in parts[2:]:
|
|
221
|
+
candidate = token.split("=", 1)[-1] if "=" in token else token
|
|
222
|
+
if not (
|
|
223
|
+
candidate.startswith(("/", "~", "../", "..\\"))
|
|
224
|
+
or "/../" in candidate
|
|
225
|
+
or "\\..\\" in candidate
|
|
226
|
+
):
|
|
227
|
+
continue
|
|
228
|
+
path = _resolve_path(root, candidate)
|
|
229
|
+
if path is None or not _is_within(root, path):
|
|
230
|
+
return False
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def dangerous_shell_command(command: object) -> bool:
|
|
235
|
+
"""Classify shell commands using the desktop client's conservative tier."""
|
|
236
|
+
if not isinstance(command, str) or not command.strip():
|
|
237
|
+
return False
|
|
238
|
+
try:
|
|
239
|
+
parts = shlex.split(command)
|
|
240
|
+
except ValueError:
|
|
241
|
+
return True
|
|
242
|
+
if not parts:
|
|
243
|
+
return False
|
|
244
|
+
executable = Path(parts[0]).name.lower()
|
|
245
|
+
return executable in _DANGEROUS_EXECUTABLES
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def routine_action(
|
|
249
|
+
tool_name: str,
|
|
250
|
+
arguments: dict[str, Any],
|
|
251
|
+
workspace: Path | None = None,
|
|
252
|
+
) -> bool:
|
|
253
|
+
"""Pinned dcode deterministic Auto policy for locally supported tools."""
|
|
254
|
+
|
|
255
|
+
root = (workspace or Path.cwd()).resolve()
|
|
256
|
+
canonical = canonical_tool_name(tool_name)
|
|
257
|
+
if canonical in _READ_TOOLS:
|
|
258
|
+
return True
|
|
259
|
+
if canonical in {"write_file", "edit_file"}:
|
|
260
|
+
return _routine_write_allowed(root, arguments)
|
|
261
|
+
if canonical == "execute":
|
|
262
|
+
return _read_only_git_allowed(arguments.get("command"), root)
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class LocalToolBroker:
|
|
267
|
+
"""Thin CopperPilot protocol adapter over Deep Agents filesystem tools."""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
workspace: Path,
|
|
272
|
+
*,
|
|
273
|
+
mode: ApprovalMode = ApprovalMode.MANUAL,
|
|
274
|
+
approve: ApprovalHandler | None = None,
|
|
275
|
+
ask_user: QuestionHandler | None = None,
|
|
276
|
+
always_allow_shell_commands: bool = True,
|
|
277
|
+
always_allow_dangerous_shell_commands: bool = False,
|
|
278
|
+
) -> None:
|
|
279
|
+
self.workspace = workspace.resolve()
|
|
280
|
+
self.mode = mode
|
|
281
|
+
self.approve = approve or self._reject
|
|
282
|
+
self.ask_user = ask_user or self._no_question_handler
|
|
283
|
+
self.always_allow_shell_commands = always_allow_shell_commands
|
|
284
|
+
self.always_allow_dangerous_shell_commands = always_allow_dangerous_shell_commands
|
|
285
|
+
self._results: dict[str, Any] = {}
|
|
286
|
+
self._inflight: dict[str, asyncio.Task[Any]] = {}
|
|
287
|
+
self._middleware: FilesystemMiddleware[Any, Any] | None = None
|
|
288
|
+
self._backend: LocalShellBackend | None = None
|
|
289
|
+
self._tools: dict[str, BaseTool] = {}
|
|
290
|
+
self._configure_native_tools()
|
|
291
|
+
|
|
292
|
+
def _configure_native_tools(self) -> None:
|
|
293
|
+
self._backend = LocalShellBackend(
|
|
294
|
+
root_dir=self.workspace,
|
|
295
|
+
virtual_mode=True,
|
|
296
|
+
timeout=120,
|
|
297
|
+
max_output_bytes=5_000_000,
|
|
298
|
+
inherit_env=True,
|
|
299
|
+
)
|
|
300
|
+
self._middleware = FilesystemMiddleware(
|
|
301
|
+
backend=self._backend,
|
|
302
|
+
tools="all",
|
|
303
|
+
max_execute_timeout=3600,
|
|
304
|
+
grep_max_count=1000,
|
|
305
|
+
)
|
|
306
|
+
self._tools = {tool.name: tool for tool in self._middleware.tools}
|
|
307
|
+
|
|
308
|
+
def set_workspace(self, workspace: Path) -> None:
|
|
309
|
+
"""Rebind native tools to a newly selected workspace."""
|
|
310
|
+
|
|
311
|
+
self.workspace = workspace.resolve()
|
|
312
|
+
self._configure_native_tools()
|
|
313
|
+
|
|
314
|
+
def set_shell_auto_allow(self, *, normal: bool, dangerous: bool) -> None:
|
|
315
|
+
self.always_allow_shell_commands = normal
|
|
316
|
+
self.always_allow_dangerous_shell_commands = dangerous
|
|
317
|
+
|
|
318
|
+
async def _reject(self, _request: ApprovalRequest) -> bool:
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
async def _no_question_handler(self, _question: Any) -> Any:
|
|
322
|
+
raise ToolRejected("No interactive question handler is available.")
|
|
323
|
+
|
|
324
|
+
def begin_turn(self) -> None:
|
|
325
|
+
"""Scope idempotent tool-call results to one hosted turn."""
|
|
326
|
+
|
|
327
|
+
if self._inflight:
|
|
328
|
+
raise RuntimeError("Cannot begin a turn while a local tool is running.")
|
|
329
|
+
self._results.clear()
|
|
330
|
+
|
|
331
|
+
async def cancel_all(self) -> None:
|
|
332
|
+
"""Cancel adapter-owned in-flight calls.
|
|
333
|
+
|
|
334
|
+
Deep Agents owns operation execution; this adapter owns only awaiting tasks.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
tasks = list(self._inflight.values())
|
|
338
|
+
for task in tasks:
|
|
339
|
+
task.cancel()
|
|
340
|
+
if tasks:
|
|
341
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
342
|
+
|
|
343
|
+
async def execute(self, request: ToolRequest) -> Any:
|
|
344
|
+
if request.tool_call_id in self._results:
|
|
345
|
+
return self._results[request.tool_call_id]
|
|
346
|
+
if request.tool_call_id in self._inflight:
|
|
347
|
+
return await self._inflight[request.tool_call_id]
|
|
348
|
+
task = asyncio.create_task(self._execute_once(request))
|
|
349
|
+
self._inflight[request.tool_call_id] = task
|
|
350
|
+
try:
|
|
351
|
+
result = await task
|
|
352
|
+
self._results[request.tool_call_id] = result
|
|
353
|
+
return result
|
|
354
|
+
finally:
|
|
355
|
+
self._inflight.pop(request.tool_call_id, None)
|
|
356
|
+
|
|
357
|
+
async def _authorized(
|
|
358
|
+
self,
|
|
359
|
+
request: ToolRequest,
|
|
360
|
+
canonical_name: str,
|
|
361
|
+
*,
|
|
362
|
+
external: bool = False,
|
|
363
|
+
) -> None:
|
|
364
|
+
if external:
|
|
365
|
+
raise ToolRejected("Paths outside the workspace are not available.")
|
|
366
|
+
gated = canonical_name in _MUTATION_TOOLS or canonical_name == "execute"
|
|
367
|
+
if not gated or self.mode is ApprovalMode.YOLO:
|
|
368
|
+
return
|
|
369
|
+
if self.mode is ApprovalMode.AUTO:
|
|
370
|
+
if canonical_name == "execute":
|
|
371
|
+
danger = dangerous_shell_command(request.arguments.get("command"))
|
|
372
|
+
if danger and self.always_allow_dangerous_shell_commands:
|
|
373
|
+
return
|
|
374
|
+
if not danger and self.always_allow_shell_commands:
|
|
375
|
+
return
|
|
376
|
+
elif routine_action(canonical_name, request.arguments, self.workspace):
|
|
377
|
+
return
|
|
378
|
+
approved = await self.approve(
|
|
379
|
+
ApprovalRequest(
|
|
380
|
+
tool_call_id=request.tool_call_id,
|
|
381
|
+
tool_name=request.tool_name,
|
|
382
|
+
arguments=request.arguments,
|
|
383
|
+
reason="Local side effect",
|
|
384
|
+
canonical_name=canonical_name,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
if not approved:
|
|
388
|
+
raise ToolRejected(f"{request.tool_name} was rejected.")
|
|
389
|
+
|
|
390
|
+
def _virtual_path(self, raw: object) -> tuple[str, bool]:
|
|
391
|
+
resolved = _resolve_path(self.workspace, raw)
|
|
392
|
+
if resolved is None:
|
|
393
|
+
raise ValueError("file_path must be a non-empty valid path.")
|
|
394
|
+
external = not _is_within(self.workspace, resolved)
|
|
395
|
+
if external:
|
|
396
|
+
return str(resolved), True
|
|
397
|
+
relative = resolved.relative_to(self.workspace)
|
|
398
|
+
return "/" + PurePosixPath(relative).as_posix(), False
|
|
399
|
+
|
|
400
|
+
def _normalize_arguments(
|
|
401
|
+
self,
|
|
402
|
+
request: ToolRequest,
|
|
403
|
+
canonical_name: str,
|
|
404
|
+
) -> tuple[dict[str, Any], bool]:
|
|
405
|
+
arguments = dict(request.arguments)
|
|
406
|
+
external = False
|
|
407
|
+
if canonical_name in {
|
|
408
|
+
"read_file",
|
|
409
|
+
"write_file",
|
|
410
|
+
"edit_file",
|
|
411
|
+
"delete",
|
|
412
|
+
"read_binary_file",
|
|
413
|
+
"write_binary_file",
|
|
414
|
+
}:
|
|
415
|
+
arguments["file_path"], external = self._virtual_path(arguments.get("file_path"))
|
|
416
|
+
elif canonical_name == "ls":
|
|
417
|
+
arguments["path"], external = self._virtual_path(arguments.get("path") or ".")
|
|
418
|
+
elif canonical_name in {"glob", "grep"} and arguments.get("path") is not None:
|
|
419
|
+
arguments["path"], external = self._virtual_path(arguments["path"])
|
|
420
|
+
if canonical_name == "execute":
|
|
421
|
+
timeout_ms = request.timeout_ms or arguments.pop("timeout_ms", None)
|
|
422
|
+
if timeout_ms is not None and "timeout" not in arguments:
|
|
423
|
+
arguments["timeout"] = max(1, (int(timeout_ms) + 999) // 1000)
|
|
424
|
+
arguments.pop("kill_on_timeout", None)
|
|
425
|
+
arguments.pop("background", None)
|
|
426
|
+
elif canonical_name == "read_file" and arguments.get("offset") is not None:
|
|
427
|
+
# CopperPilot's desktop harness uses 1-indexed offsets; Deep Agents uses
|
|
428
|
+
# zero-indexed offsets.
|
|
429
|
+
arguments["offset"] = max(int(arguments["offset"]) - 1, 0)
|
|
430
|
+
return arguments, external
|
|
431
|
+
|
|
432
|
+
def _runtime(self, request: ToolRequest) -> ToolRuntime[Any, dict[str, Any]]:
|
|
433
|
+
return ToolRuntime(
|
|
434
|
+
state={},
|
|
435
|
+
context=None,
|
|
436
|
+
config={},
|
|
437
|
+
stream_writer=lambda _value: None,
|
|
438
|
+
tool_call_id=request.tool_call_id,
|
|
439
|
+
store=None,
|
|
440
|
+
tools=list(self._tools.values()),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def _message_result(message: ToolMessage) -> dict[str, Any]:
|
|
445
|
+
if message.status == "error":
|
|
446
|
+
return {"error": str(message.content)}
|
|
447
|
+
result: dict[str, Any] = {"result": message.content}
|
|
448
|
+
artifact = message.artifact
|
|
449
|
+
if isinstance(artifact, dict):
|
|
450
|
+
for key in ("exit_code", "truncated"):
|
|
451
|
+
if key in artifact:
|
|
452
|
+
result[key] = artifact[key]
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
async def _execute_native(
|
|
456
|
+
self,
|
|
457
|
+
request: ToolRequest,
|
|
458
|
+
canonical_name: str,
|
|
459
|
+
arguments: dict[str, Any],
|
|
460
|
+
) -> dict[str, Any]:
|
|
461
|
+
native_tool = self._tools.get(canonical_name)
|
|
462
|
+
if native_tool is None:
|
|
463
|
+
raise ValueError(f"Unsupported local tool: {request.tool_name}")
|
|
464
|
+
payload = {
|
|
465
|
+
"type": "tool_call",
|
|
466
|
+
"id": request.tool_call_id,
|
|
467
|
+
"name": canonical_name,
|
|
468
|
+
"args": {**arguments, "runtime": self._runtime(request)},
|
|
469
|
+
}
|
|
470
|
+
message = await native_tool.ainvoke(payload)
|
|
471
|
+
if not isinstance(message, ToolMessage):
|
|
472
|
+
raise TypeError(f"{canonical_name} returned an invalid result.")
|
|
473
|
+
return self._message_result(message)
|
|
474
|
+
|
|
475
|
+
async def _execute_once(self, request: ToolRequest) -> Any:
|
|
476
|
+
if request.tool_name == "ask_user_question":
|
|
477
|
+
timeout = max((request.timeout_ms or 105_000) / 1000, 1)
|
|
478
|
+
try:
|
|
479
|
+
return await asyncio.wait_for(self.ask_user(request.arguments), timeout)
|
|
480
|
+
except TimeoutError:
|
|
481
|
+
return {"error": "Ask-user prompt timed out."}
|
|
482
|
+
|
|
483
|
+
canonical_name = canonical_tool_name(request.tool_name)
|
|
484
|
+
arguments, external = self._normalize_arguments(request, canonical_name)
|
|
485
|
+
await self._authorized(request, canonical_name, external=external)
|
|
486
|
+
|
|
487
|
+
if canonical_name == "read_binary_file":
|
|
488
|
+
return await self._read_binary(arguments["file_path"])
|
|
489
|
+
if canonical_name == "write_binary_file":
|
|
490
|
+
return await self._write_binary(arguments)
|
|
491
|
+
return await self._execute_native(request, canonical_name, arguments)
|
|
492
|
+
|
|
493
|
+
async def _read_binary(self, file_path: str) -> dict[str, Any]:
|
|
494
|
+
assert self._backend is not None
|
|
495
|
+
result = await self._backend.aread(file_path)
|
|
496
|
+
if result.error:
|
|
497
|
+
return {"error": result.error}
|
|
498
|
+
if result.file_data is None:
|
|
499
|
+
return {"error": f"No data returned for {file_path}"}
|
|
500
|
+
content = result.file_data["content"]
|
|
501
|
+
encoding = result.file_data.get("encoding", "utf-8")
|
|
502
|
+
if encoding != "base64":
|
|
503
|
+
content = base64.b64encode(content.encode()).decode()
|
|
504
|
+
mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
505
|
+
return {
|
|
506
|
+
"result": f"data:{mime_type};base64,{content}",
|
|
507
|
+
"encoding": "base64",
|
|
508
|
+
"mime_type": mime_type,
|
|
509
|
+
"byte_length": len(base64.b64decode(content)),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async def _write_binary(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
513
|
+
assert self._backend is not None
|
|
514
|
+
encoding = str(arguments.get("encoding") or "base64")
|
|
515
|
+
if encoding != "base64":
|
|
516
|
+
return {"error": f"write_binary_file: unsupported encoding '{encoding}'"}
|
|
517
|
+
encoded = str(arguments.get("data") or arguments.get("content") or "")
|
|
518
|
+
if not encoded:
|
|
519
|
+
return {"error": "write_binary_file: data is required (base64 string)"}
|
|
520
|
+
try:
|
|
521
|
+
content = base64.b64decode("".join(encoded.split()), validate=True)
|
|
522
|
+
except ValueError as exc:
|
|
523
|
+
return {"error": f"write_binary_file: invalid base64 payload: {exc}"}
|
|
524
|
+
responses = await self._backend.aupload_files([(arguments["file_path"], content)])
|
|
525
|
+
response = responses[0]
|
|
526
|
+
if response.error:
|
|
527
|
+
return {"error": str(response.error)}
|
|
528
|
+
mime_type = (
|
|
529
|
+
str(arguments.get("mime_type") or "")
|
|
530
|
+
or mimetypes.guess_type(arguments["file_path"])[0]
|
|
531
|
+
or "application/octet-stream"
|
|
532
|
+
)
|
|
533
|
+
return {
|
|
534
|
+
"result": f"Updated file {response.path}",
|
|
535
|
+
"encoding": "base64",
|
|
536
|
+
"mime_type": mime_type,
|
|
537
|
+
"byte_length": len(content),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def normalized_tool_names(names: Sequence[str]) -> list[str]:
|
|
542
|
+
"""Return canonical names while preserving request order."""
|
|
543
|
+
|
|
544
|
+
return [canonical_tool_name(name) for name in names]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
__all__ = [
|
|
548
|
+
"ApprovalMode",
|
|
549
|
+
"ApprovalRequest",
|
|
550
|
+
"LocalToolBroker",
|
|
551
|
+
"ToolRejected",
|
|
552
|
+
"canonical_tool_name",
|
|
553
|
+
"dangerous_shell_command",
|
|
554
|
+
"normalized_tool_names",
|
|
555
|
+
"routine_action",
|
|
556
|
+
]
|