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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|