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.
Files changed (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. 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
+ ]