deep-agent-cli 0.1.0__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 (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ from agent.config import BindMount, ModelProfile, SandboxConfig
2
+ from agent.control import RunController
3
+ from agent.factory import AgentSpec, PreparedAgent, build_agent, create_agent
4
+ from agent.permission import PermissionMode
5
+ from agent.runner import (
6
+ AgentRunner,
7
+ InterruptKind,
8
+ InterruptState,
9
+ RunEvent,
10
+ RunResult,
11
+ UnknownInterruptError,
12
+ )
13
+ from agent.sandbox import ExecutionMode, SandboxUnavailableError
14
+ from agent.attachments import ImageAttachment, ImageAttachmentRef
15
+ from agent.session import SessionInfo, SessionStore, StopReason
16
+ from agent.stream import StreamDeltaCallback
17
+
18
+ __all__ = [
19
+ "AgentRunner",
20
+ "InterruptKind",
21
+ "InterruptState",
22
+ "UnknownInterruptError",
23
+ "AgentSpec",
24
+ "BindMount",
25
+ "ExecutionMode",
26
+ "ModelProfile",
27
+ "PermissionMode",
28
+ "PreparedAgent",
29
+ "RunController",
30
+ "RunResult",
31
+ "RunEvent",
32
+ "SandboxConfig",
33
+ "SandboxUnavailableError",
34
+ "SessionInfo",
35
+ "SessionStore",
36
+ "StopReason",
37
+ "ImageAttachment",
38
+ "ImageAttachmentRef",
39
+ "StreamDeltaCallback",
40
+ "create_agent",
41
+ "build_agent",
42
+ ]
agent/attachments.py ADDED
@@ -0,0 +1,303 @@
1
+ """Workspace-scoped image attachments stored outside LangGraph checkpoints."""
2
+ from __future__ import annotations
3
+
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ import fcntl
7
+ import hashlib
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import tempfile
12
+ import threading
13
+ from typing import Any, Iterable, Iterator
14
+ from uuid import uuid4
15
+
16
+
17
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
18
+ MAX_IMAGES_PER_MESSAGE = 4
19
+ ATTACHMENT_META_KEY = "image_attachments"
20
+ _REF_TYPE = "deep-agent/image-attachment-ref"
21
+ _REF_VERSION = 1
22
+ _STORAGE_KEY = re.compile(r"^[0-9a-f]{64}\.(?:png|jpe?g|webp|gif)$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ImageAttachment:
27
+ filename: str
28
+ mime_type: str
29
+ data: bytes
30
+ size: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ImageAttachmentRef:
35
+ id: str
36
+ filename: str
37
+ mime_type: str
38
+ size: int
39
+ storage_key: str
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return {
43
+ "type": _REF_TYPE,
44
+ "version": _REF_VERSION,
45
+ "id": self.id,
46
+ "filename": self.filename,
47
+ "mime_type": self.mime_type,
48
+ "size": self.size,
49
+ "storage_key": self.storage_key,
50
+ }
51
+
52
+ @classmethod
53
+ def from_dict(cls, value: Any) -> "ImageAttachmentRef":
54
+ if not isinstance(value, dict):
55
+ raise ValueError("attachment reference must be a mapping")
56
+ if value.get("type") != _REF_TYPE or value.get("version") != _REF_VERSION:
57
+ raise ValueError("unsupported attachment reference")
58
+ ref = cls(
59
+ id=str(value.get("id") or ""),
60
+ filename=str(value.get("filename") or ""),
61
+ mime_type=str(value.get("mime_type") or ""),
62
+ size=int(value.get("size") or 0),
63
+ storage_key=str(value.get("storage_key") or ""),
64
+ )
65
+ if not ref.id or not ref.filename or not _STORAGE_KEY.fullmatch(ref.storage_key):
66
+ raise ValueError("invalid attachment reference")
67
+ if ref.mime_type not in _MIME_TO_EXTENSION or ref.size <= 0 or ref.size > MAX_IMAGE_BYTES:
68
+ raise ValueError("invalid attachment reference metadata")
69
+ return ref
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AttachmentCleanupResult:
74
+ scanned: int
75
+ kept: int
76
+ deleted: int
77
+ freed_bytes: int
78
+ skipped_busy: bool = False
79
+
80
+
81
+ _MIME_TO_EXTENSION = {
82
+ "image/png": "png",
83
+ "image/jpeg": "jpg",
84
+ "image/webp": "webp",
85
+ "image/gif": "gif",
86
+ }
87
+
88
+
89
+ def detect_image_mime(data: bytes) -> str:
90
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
91
+ return "image/png"
92
+ if data.startswith(b"\xff\xd8\xff"):
93
+ return "image/jpeg"
94
+ if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
95
+ return "image/webp"
96
+ if data.startswith((b"GIF87a", b"GIF89a")):
97
+ return "image/gif"
98
+ raise ValueError("Unsupported image format; use PNG, JPEG, WebP, or GIF")
99
+
100
+
101
+ def image_attachment_from_bytes(data: bytes, *, filename: str) -> ImageAttachment:
102
+ size = len(data)
103
+ if size <= 0:
104
+ raise ValueError("Image is empty")
105
+ if size > MAX_IMAGE_BYTES:
106
+ raise ValueError(f"Image exceeds the {MAX_IMAGE_BYTES // (1024 * 1024)} MiB limit")
107
+ mime_type = detect_image_mime(data)
108
+ return ImageAttachment(filename=Path(filename).name or "image", mime_type=mime_type, data=data, size=size)
109
+
110
+
111
+ def image_attachment_from_path(path: Path) -> ImageAttachment:
112
+ if not path.is_file():
113
+ raise ValueError(f"Image path does not exist: {path}")
114
+ size = path.stat().st_size
115
+ if size > MAX_IMAGE_BYTES:
116
+ raise ValueError(f"Image exceeds the {MAX_IMAGE_BYTES // (1024 * 1024)} MiB limit: {path.name}")
117
+ return image_attachment_from_bytes(path.read_bytes(), filename=path.name)
118
+
119
+
120
+ def refs_to_dicts(refs: Iterable[ImageAttachmentRef]) -> list[dict[str, Any]]:
121
+ return [ref.to_dict() for ref in refs]
122
+
123
+
124
+ def refs_from_message(message: Any) -> tuple[ImageAttachmentRef, ...]:
125
+ additional = getattr(message, "additional_kwargs", None)
126
+ raw = additional.get(ATTACHMENT_META_KEY, []) if isinstance(additional, dict) else []
127
+ if not isinstance(raw, list):
128
+ return ()
129
+ refs: list[ImageAttachmentRef] = []
130
+ for item in raw:
131
+ try:
132
+ refs.append(ImageAttachmentRef.from_dict(item))
133
+ except (TypeError, ValueError):
134
+ continue
135
+ return tuple(refs)
136
+
137
+
138
+ def find_attachment_storage_keys(value: Any) -> set[str]:
139
+ """Recursively collect only versioned, valid attachment references."""
140
+ found: set[str] = set()
141
+ stack = [value]
142
+ seen: set[int] = set()
143
+ while stack:
144
+ item = stack.pop()
145
+ if isinstance(item, (dict, list, tuple, set)) or hasattr(item, "__dict__"):
146
+ identity = id(item)
147
+ if identity in seen:
148
+ continue
149
+ seen.add(identity)
150
+ if isinstance(item, dict):
151
+ try:
152
+ found.add(ImageAttachmentRef.from_dict(item).storage_key)
153
+ continue
154
+ except (TypeError, ValueError):
155
+ stack.extend(item.values())
156
+ elif isinstance(item, (list, tuple, set)):
157
+ stack.extend(item)
158
+ elif hasattr(item, "model_dump"):
159
+ try:
160
+ stack.append(item.model_dump())
161
+ except Exception: # noqa: BLE001
162
+ continue
163
+ elif hasattr(item, "__dict__"):
164
+ stack.append(vars(item))
165
+ return found
166
+
167
+
168
+ class AttachmentStore:
169
+ """Content-addressed attachment files shared by all workspace sessions."""
170
+
171
+ def __init__(self, root: Path) -> None:
172
+ self.root = root
173
+ self.root.mkdir(parents=True, mode=0o700, exist_ok=True)
174
+ try:
175
+ os.chmod(self.root, 0o700)
176
+ except OSError:
177
+ pass
178
+ self._lock_path = self.root / ".workspace.lock"
179
+ self._runtime_lock: Any | None = None
180
+ self._mutex = threading.RLock()
181
+
182
+ @classmethod
183
+ def beside_database(cls, database: Path) -> "AttachmentStore":
184
+ return cls(database.with_suffix(".attachments"))
185
+
186
+ def acquire_runtime_lease(self) -> None:
187
+ if self._runtime_lock is not None:
188
+ return
189
+ handle = self._lock_path.open("a+b")
190
+ fcntl.flock(handle.fileno(), fcntl.LOCK_SH)
191
+ self._runtime_lock = handle
192
+
193
+ def close(self) -> None:
194
+ if self._runtime_lock is not None:
195
+ fcntl.flock(self._runtime_lock.fileno(), fcntl.LOCK_UN)
196
+ self._runtime_lock.close()
197
+ self._runtime_lock = None
198
+
199
+ def put(self, attachment: ImageAttachment) -> ImageAttachmentRef:
200
+ if attachment.size != len(attachment.data):
201
+ raise ValueError("Image size does not match its byte content")
202
+ if attachment.size <= 0 or attachment.size > MAX_IMAGE_BYTES:
203
+ raise ValueError("Image size is outside the supported range")
204
+ detected = detect_image_mime(attachment.data)
205
+ if attachment.mime_type != detected:
206
+ raise ValueError(f"Image MIME mismatch: declared {attachment.mime_type}, detected {detected}")
207
+ digest = hashlib.sha256(attachment.data).hexdigest()
208
+ storage_key = f"{digest}.{_MIME_TO_EXTENSION[detected]}"
209
+ target = self._path_for_key(storage_key)
210
+ with self._mutex:
211
+ if not target.exists():
212
+ fd, temp_name = tempfile.mkstemp(prefix=".upload-", dir=self.root)
213
+ try:
214
+ with os.fdopen(fd, "wb") as handle:
215
+ handle.write(attachment.data)
216
+ handle.flush()
217
+ os.fsync(handle.fileno())
218
+ os.chmod(temp_name, 0o600)
219
+ os.replace(temp_name, target)
220
+ finally:
221
+ try:
222
+ os.unlink(temp_name)
223
+ except FileNotFoundError:
224
+ pass
225
+ return ImageAttachmentRef(
226
+ id=f"img_{uuid4().hex}",
227
+ filename=Path(attachment.filename).name or f"image.{_MIME_TO_EXTENSION[detected]}",
228
+ mime_type=detected,
229
+ size=attachment.size,
230
+ storage_key=storage_key,
231
+ )
232
+
233
+ def read(self, ref: ImageAttachmentRef) -> ImageAttachment:
234
+ path = self._path_for_key(ref.storage_key)
235
+ try:
236
+ data = path.read_bytes()
237
+ except FileNotFoundError as exc:
238
+ raise FileNotFoundError(f"Attachment is missing: {ref.filename} ({ref.storage_key})") from exc
239
+ attachment = image_attachment_from_bytes(data, filename=ref.filename)
240
+ if attachment.mime_type != ref.mime_type or attachment.size != ref.size:
241
+ raise ValueError(f"Attachment is corrupt or changed: {ref.filename}")
242
+ if hashlib.sha256(data).hexdigest() != ref.storage_key.split(".", 1)[0]:
243
+ raise ValueError(f"Attachment checksum mismatch: {ref.filename}")
244
+ return attachment
245
+
246
+ def cleanup(
247
+ self,
248
+ live_storage_keys: set[str],
249
+ *,
250
+ protected_storage_keys: Iterable[str] = (),
251
+ release_runtime_lease: bool = False,
252
+ ) -> AttachmentCleanupResult:
253
+ """Delete unreferenced files while holding the exclusive workspace lock."""
254
+ protected = set(protected_storage_keys)
255
+ live = live_storage_keys | protected
256
+ with self._mutex:
257
+ had_runtime = release_runtime_lease and self._runtime_lock is not None
258
+ if had_runtime:
259
+ self.close()
260
+ try:
261
+ with self._maintenance_lock() as locked:
262
+ if not locked:
263
+ return AttachmentCleanupResult(0, 0, 0, 0, skipped_busy=True)
264
+ files = [path for path in self.root.iterdir() if path.is_file() and _STORAGE_KEY.fullmatch(path.name)]
265
+ deleted = 0
266
+ freed = 0
267
+ kept = 0
268
+ for path in files:
269
+ if path.name in live:
270
+ kept += 1
271
+ continue
272
+ size = path.stat().st_size
273
+ path.unlink()
274
+ deleted += 1
275
+ freed += size
276
+ return AttachmentCleanupResult(len(files), kept, deleted, freed)
277
+ finally:
278
+ if had_runtime:
279
+ self.acquire_runtime_lease()
280
+
281
+ @contextmanager
282
+ def _maintenance_lock(self) -> Iterator[bool]:
283
+ handle = self._lock_path.open("a+b")
284
+ locked = False
285
+ try:
286
+ try:
287
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
288
+ locked = True
289
+ except BlockingIOError:
290
+ pass
291
+ yield locked
292
+ finally:
293
+ if locked:
294
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
295
+ handle.close()
296
+
297
+ def _path_for_key(self, storage_key: str) -> Path:
298
+ if not _STORAGE_KEY.fullmatch(storage_key):
299
+ raise ValueError("Invalid attachment storage key")
300
+ path = self.root / storage_key
301
+ if path.parent != self.root:
302
+ raise ValueError("Attachment path escapes its store")
303
+ return path
agent/bootstrap.py ADDED
@@ -0,0 +1,44 @@
1
+ """Initialize the CLI's user-owned configuration on first launch."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+ import tempfile
8
+
9
+ from agent.config import default_skills_dir, require_outside_workspace
10
+
11
+
12
+ def initialize_user_files(*, workspace: Path | None = None) -> Path | None:
13
+ """Create missing default files; return config path only when newly created."""
14
+ work = (workspace or Path.cwd()).resolve()
15
+ skills = default_skills_dir()
16
+ root = skills.parent
17
+ config = root / "config.yaml"
18
+ require_outside_workspace(root, work, label="configuration directory")
19
+ require_outside_workspace(skills, work, label="skills directory")
20
+ if root.is_symlink() or skills.is_symlink():
21
+ raise ValueError("configuration and skills directories must not be symlinks")
22
+ root.mkdir(mode=0o700, exist_ok=True)
23
+ skills.mkdir(mode=0o700, exist_ok=True)
24
+ if os.environ.get("DEEP_AGENT_CONFIG"):
25
+ return None
26
+ if config.exists() or config.is_symlink():
27
+ if not config.is_file():
28
+ raise ValueError(f"configuration path must be a file: {config}")
29
+ return None
30
+ template = files("agent").joinpath("config.example.yaml")
31
+ with tempfile.NamedTemporaryFile(mode="wb", dir=root, prefix=".config-", delete=False) as temporary:
32
+ temp_path = Path(temporary.name)
33
+ try:
34
+ os.chmod(temp_path, 0o600)
35
+ temporary.write(template.read_bytes())
36
+ temporary.flush()
37
+ os.fsync(temporary.fileno())
38
+ try:
39
+ os.link(temp_path, config)
40
+ except FileExistsError:
41
+ return None
42
+ finally:
43
+ temp_path.unlink(missing_ok=True)
44
+ return config
agent/cancel.py ADDED
@@ -0,0 +1,107 @@
1
+ """Optional cancellation protocol for synchronous tools and execute."""
2
+ from __future__ import annotations
3
+
4
+ from contextvars import ContextVar, Token
5
+ from dataclasses import dataclass, field
6
+ import threading
7
+ from typing import Any, Callable
8
+ from uuid import uuid4
9
+
10
+
11
+ CancelCallback = Callable[[], None]
12
+
13
+
14
+ @dataclass
15
+ class ToolCancelContext:
16
+ tool_name: str
17
+ tool_call_id: str
18
+ cancel_event: threading.Event
19
+ token: str = field(default_factory=lambda: str(uuid4()))
20
+ _callbacks: list[CancelCallback] = field(default_factory=list)
21
+ _lock: threading.Lock = field(default_factory=threading.Lock)
22
+ _process: Any = None
23
+ _cancelled: bool = False
24
+
25
+ @property
26
+ def cancelled(self) -> bool:
27
+ return self._cancelled or self.cancel_event.is_set()
28
+
29
+ def register_callback(self, callback: CancelCallback) -> None:
30
+ with self._lock:
31
+ if self.cancelled:
32
+ callback()
33
+ return
34
+ self._callbacks.append(callback)
35
+
36
+ def register_process(self, process: Any) -> None:
37
+ with self._lock:
38
+ self._process = process
39
+ if self.cancelled:
40
+ _kill_process_group(process)
41
+
42
+ def clear_process(self) -> None:
43
+ with self._lock:
44
+ self._process = None
45
+
46
+ def request_cancel(self) -> None:
47
+ with self._lock:
48
+ if self._cancelled:
49
+ return
50
+ self._cancelled = True
51
+ callbacks = list(self._callbacks)
52
+ process = self._process
53
+ for callback in callbacks:
54
+ try:
55
+ callback()
56
+ except Exception: # noqa: BLE001
57
+ pass
58
+ if process is not None:
59
+ _kill_process_group(process)
60
+
61
+
62
+ _CURRENT: ContextVar[ToolCancelContext | None] = ContextVar("deep_agent_cancel_ctx", default=None)
63
+ _OUTPUT_EMITTER: ContextVar[Callable[[str, str, str], None] | None] = ContextVar(
64
+ "deep_agent_tool_output_emitter", default=None,
65
+ )
66
+
67
+
68
+ def set_cancel_context(ctx: ToolCancelContext) -> Token:
69
+ return _CURRENT.set(ctx)
70
+
71
+
72
+ def clear_cancel_context() -> None:
73
+ _CURRENT.set(None)
74
+
75
+
76
+ def get_cancel_context() -> ToolCancelContext | None:
77
+ return _CURRENT.get()
78
+
79
+
80
+ def set_output_emitter(emitter: Callable[[str, str, str], None] | None) -> Token:
81
+ return _OUTPUT_EMITTER.set(emitter)
82
+
83
+
84
+ def get_output_emitter() -> Callable[[str, str, str], None] | None:
85
+ return _OUTPUT_EMITTER.get()
86
+
87
+
88
+ def emit_tool_output(tool_call_id: str, content: str, *, stream: str = "merged") -> None:
89
+ emitter = get_output_emitter()
90
+ if emitter is not None and content:
91
+ emitter(tool_call_id, content, stream)
92
+
93
+
94
+ def _kill_process_group(process: Any) -> None:
95
+ import os
96
+ import signal
97
+
98
+ pid = getattr(process, "pid", None)
99
+ if not pid:
100
+ return
101
+ try:
102
+ os.killpg(pid, signal.SIGKILL)
103
+ except (ProcessLookupError, PermissionError, OSError):
104
+ try:
105
+ process.kill()
106
+ except Exception: # noqa: BLE001
107
+ pass
agent/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Terminal presentation layer for the DeepAgent template."""
2
+
3
+ from agent.cli.app import CliApplication
4
+
5
+ __all__ = ["CliApplication"]