dex-agents-deepagents-cli 0.6.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.
- dex_agents_deepagents_cli/__init__.py +112 -0
- dex_agents_deepagents_cli/__main__.py +14 -0
- dex_agents_deepagents_cli/connector_tool_names.py +138 -0
- dex_agents_deepagents_cli/harness.py +443 -0
- dex_agents_deepagents_cli/py.typed +0 -0
- dex_agents_deepagents_cli/workspace_backend.py +382 -0
- dex_agents_deepagents_cli-0.6.1.dist-info/METADATA +199 -0
- dex_agents_deepagents_cli-0.6.1.dist-info/RECORD +11 -0
- dex_agents_deepagents_cli-0.6.1.dist-info/WHEEL +4 -0
- dex_agents_deepagents_cli-0.6.1.dist-info/entry_points.txt +3 -0
- dex_agents_deepagents_cli-0.6.1.dist-info/licenses/LICENSE +105 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Sustainable Use License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
|
|
8
|
+
|
|
9
|
+
"""The Deep Agents backend of the command: real files in the workspace and its shell."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import dataclasses
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
from datetime import timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
|
27
|
+
from deepagents.backends.protocol import (
|
|
28
|
+
DEFAULT_GREP_TIMEOUT,
|
|
29
|
+
BackendProtocol,
|
|
30
|
+
GlobResult,
|
|
31
|
+
GrepResult,
|
|
32
|
+
LsResult,
|
|
33
|
+
)
|
|
34
|
+
from deepagents.backends.utils import compile_grep_include_glob
|
|
35
|
+
|
|
36
|
+
#: The prefixes Deep Agents' ``FilesystemMiddleware`` writes its own files under.
|
|
37
|
+
DEEP_AGENTS_ARTIFACT_PREFIXES = ("/large_tool_results/", "/conversation_history/")
|
|
38
|
+
|
|
39
|
+
#: How long a session's artifacts stay after their last change.
|
|
40
|
+
SESSION_ARTIFACTS_RETENTION = timedelta(days=30)
|
|
41
|
+
|
|
42
|
+
_LOGGER = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_RUNNING_SESSION_ID: ContextVar[str | None] = ContextVar(
|
|
45
|
+
"dex_agents_deepagents_running_session_id", default=None
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Search results keyed by path, each with its matching (line number, text) pairs.
|
|
49
|
+
_SearchResults = dict[str, list[tuple[int, str]]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorkspaceShellBackend(LocalShellBackend):
|
|
53
|
+
"""A ``LocalShellBackend`` on the host's paths whose file tools stay in the workspace.
|
|
54
|
+
|
|
55
|
+
File tools take absolute host paths, as Deep Agents' local mode does, so
|
|
56
|
+
a file tool and ``execute`` name a file the same way. A path that
|
|
57
|
+
resolves outside the workspace, through ``..`` or a symbolic link, fails
|
|
58
|
+
the file operation with an error the model sees. ``ls``, ``glob``, and
|
|
59
|
+
``grep`` leave out every entry whose real path is outside the workspace,
|
|
60
|
+
and ``grep`` never opens such a file.
|
|
61
|
+
|
|
62
|
+
This is a guardrail against mistaken paths, not a sandbox. ``execute``
|
|
63
|
+
runs ``/bin/sh`` in the workspace with the user's permissions and can
|
|
64
|
+
reach any path. A link swapped during a call and a hard link to an
|
|
65
|
+
outside file are not detected.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, workspace: Path) -> None:
|
|
69
|
+
"""Root the backend at the workspace.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
workspace: The absolute workspace directory.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
RuntimeError: If the installed deepagents lacks the method the
|
|
76
|
+
confinement overrides.
|
|
77
|
+
"""
|
|
78
|
+
if not callable(getattr(LocalShellBackend, "_resolve_path", None)):
|
|
79
|
+
# The confinement overrides this private method; the dependency range pins it.
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"this deepagents release has no FilesystemBackend._resolve_path; install the "
|
|
82
|
+
"version range dex-agents-deepagents-cli declares"
|
|
83
|
+
)
|
|
84
|
+
super().__init__(root_dir=workspace, virtual_mode=False, inherit_env=True)
|
|
85
|
+
self._workspace_real_path = Path(os.path.realpath(workspace))
|
|
86
|
+
|
|
87
|
+
def ls(self, path: str) -> LsResult:
|
|
88
|
+
"""List a directory, without the entries whose real path leaves the workspace."""
|
|
89
|
+
result = super().ls(path)
|
|
90
|
+
if result.entries is None:
|
|
91
|
+
return result
|
|
92
|
+
entries = [entry for entry in result.entries if self._is_inside(entry["path"])]
|
|
93
|
+
return dataclasses.replace(result, entries=entries)
|
|
94
|
+
|
|
95
|
+
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
|
|
96
|
+
"""Find files, without the matches whose real path leaves the workspace."""
|
|
97
|
+
result = super().glob(pattern, path)
|
|
98
|
+
if result.matches is None:
|
|
99
|
+
return result
|
|
100
|
+
matches = [match for match in result.matches if self._is_inside(match["path"])]
|
|
101
|
+
return dataclasses.replace(result, matches=matches)
|
|
102
|
+
|
|
103
|
+
def grep(
|
|
104
|
+
self,
|
|
105
|
+
pattern: str,
|
|
106
|
+
path: str | None = None,
|
|
107
|
+
glob: str | None = None,
|
|
108
|
+
*,
|
|
109
|
+
max_count: int | None = None,
|
|
110
|
+
context_lines: int = 0,
|
|
111
|
+
) -> GrepResult:
|
|
112
|
+
"""Search files, without the matches in files whose real path leaves the workspace."""
|
|
113
|
+
result = super().grep(pattern, path, glob, max_count=max_count, context_lines=context_lines)
|
|
114
|
+
if result.matches is None:
|
|
115
|
+
return result
|
|
116
|
+
matches = [match for match in result.matches if self._is_inside(match["path"])]
|
|
117
|
+
return dataclasses.replace(result, matches=matches)
|
|
118
|
+
|
|
119
|
+
def _python_search(
|
|
120
|
+
self,
|
|
121
|
+
pattern: str,
|
|
122
|
+
base_full: Path,
|
|
123
|
+
include_glob: str | None,
|
|
124
|
+
*,
|
|
125
|
+
max_count: int | None = None,
|
|
126
|
+
timeout: int = DEFAULT_GREP_TIMEOUT,
|
|
127
|
+
) -> tuple[_SearchResults, bool, str | None]:
|
|
128
|
+
# Deep Agents' fallback opens every file it walks; this one checks each real path first.
|
|
129
|
+
deadline = time.monotonic() + timeout
|
|
130
|
+
matches_glob = compile_grep_include_glob(include_glob) if include_glob else None
|
|
131
|
+
root = base_full if base_full.is_dir() else base_full.parent
|
|
132
|
+
candidates = (
|
|
133
|
+
[base_full]
|
|
134
|
+
if base_full.is_file()
|
|
135
|
+
else (
|
|
136
|
+
Path(directory) / name
|
|
137
|
+
for directory, _, names in os.walk(root, followlinks=False)
|
|
138
|
+
for name in names
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
results: _SearchResults = {}
|
|
142
|
+
unreadable: list[str] = []
|
|
143
|
+
total = 0
|
|
144
|
+
for candidate in candidates:
|
|
145
|
+
if time.monotonic() > deadline:
|
|
146
|
+
return results, True, None
|
|
147
|
+
if matches_glob is not None and not matches_glob(
|
|
148
|
+
candidate.relative_to(root).as_posix()
|
|
149
|
+
):
|
|
150
|
+
continue
|
|
151
|
+
if not self._is_inside(str(candidate)) or not _is_small_file(
|
|
152
|
+
candidate, self.max_file_size_bytes
|
|
153
|
+
):
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
with candidate.open(encoding="utf-8", errors="strict") as handle:
|
|
157
|
+
for line_number, line in enumerate(handle, 1):
|
|
158
|
+
if pattern not in line:
|
|
159
|
+
continue
|
|
160
|
+
if max_count is not None and total >= max_count:
|
|
161
|
+
return results, True, None
|
|
162
|
+
results.setdefault(str(candidate), []).append(
|
|
163
|
+
(line_number, line.rstrip("\n"))
|
|
164
|
+
)
|
|
165
|
+
total += 1
|
|
166
|
+
except UnicodeDecodeError:
|
|
167
|
+
# Binary files are skipped, as ripgrep does.
|
|
168
|
+
continue
|
|
169
|
+
except OSError as error:
|
|
170
|
+
unreadable.append(f"- {candidate}: {type(error).__name__}: {error.strerror}")
|
|
171
|
+
if not unreadable:
|
|
172
|
+
return results, False, None
|
|
173
|
+
return (
|
|
174
|
+
results,
|
|
175
|
+
False,
|
|
176
|
+
"One or more files could not be fully searched:\n" + "\n".join(unreadable),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _resolve_path(self, key: str) -> Path:
|
|
180
|
+
resolved = super()._resolve_path(key)
|
|
181
|
+
if not self._is_inside(str(resolved)):
|
|
182
|
+
# An OSError, so each file operation reports it as its own error result.
|
|
183
|
+
raise PermissionError(
|
|
184
|
+
f"{key} is outside the workspace {self._workspace_real_path}; file tools "
|
|
185
|
+
"may only use paths inside it"
|
|
186
|
+
)
|
|
187
|
+
return resolved
|
|
188
|
+
|
|
189
|
+
def _is_inside(self, path: str) -> bool:
|
|
190
|
+
# realpath also follows a dangling link to the target a write would create.
|
|
191
|
+
real_path = Path(os.path.realpath(self.cwd / path))
|
|
192
|
+
return real_path.is_relative_to(self._workspace_real_path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SessionArtifactsBackend(FilesystemBackend):
|
|
196
|
+
"""A virtual ``FilesystemBackend`` whose root is one private directory per session.
|
|
197
|
+
|
|
198
|
+
Deep Agents names a large tool result after its tool call ID, which two
|
|
199
|
+
sessions may share. Each session therefore gets its own directory,
|
|
200
|
+
``<artifacts>/<SHA-256 of the session ID>/<name>``. The name is stable,
|
|
201
|
+
so a Worker that restarts on the same machine finds the files. Every
|
|
202
|
+
file operation first checks each directory of that path: the user owns
|
|
203
|
+
it, it is no symbolic link, and its mode becomes 0700 when it is looser.
|
|
204
|
+
A failed check, or an operation outside a running model call or tool
|
|
205
|
+
Step, returns the operation's error result.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, artifacts_directory: Path, name: str) -> None:
|
|
209
|
+
"""Keep the per-user directory and the name of this kind of file.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
artifacts_directory: The private per-user directory, such as
|
|
213
|
+
``default_artifacts_directory()``.
|
|
214
|
+
name: The subdirectory of each session, such as ``large_tool_results``.
|
|
215
|
+
"""
|
|
216
|
+
self._artifacts_directory = artifacts_directory
|
|
217
|
+
self._name = name
|
|
218
|
+
super().__init__(root_dir=artifacts_directory, virtual_mode=True)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def cwd(self) -> Path:
|
|
222
|
+
"""Return the running session's checked directory.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
PermissionError: Outside a running session, or when a directory of
|
|
226
|
+
the path fails its check. Every file operation reports it as an error.
|
|
227
|
+
"""
|
|
228
|
+
session_id = _RUNNING_SESSION_ID.get()
|
|
229
|
+
if session_id is None:
|
|
230
|
+
raise PermissionError("Deep Agents' own files are available only while a session runs")
|
|
231
|
+
artifacts = ensure_artifacts_directory(self._artifacts_directory)
|
|
232
|
+
session_directory = _private_directory(
|
|
233
|
+
artifacts / hashlib.sha256(session_id.encode()).hexdigest()
|
|
234
|
+
)
|
|
235
|
+
# The virtual root check compares resolved paths.
|
|
236
|
+
return _private_directory(session_directory / self._name).resolve()
|
|
237
|
+
|
|
238
|
+
@cwd.setter
|
|
239
|
+
def cwd(self, root_dir: Path) -> None:
|
|
240
|
+
# FilesystemBackend's constructor assigns its root; each session's root replaces it.
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@contextlib.contextmanager
|
|
245
|
+
def bind_artifacts_session(session_id: str) -> Iterator[None]:
|
|
246
|
+
"""Make Deep Agents' own files those of one session while the block runs.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
session_id: The session ID, which is the Flow ID.
|
|
250
|
+
"""
|
|
251
|
+
token = _RUNNING_SESSION_ID.set(session_id)
|
|
252
|
+
try:
|
|
253
|
+
yield
|
|
254
|
+
finally:
|
|
255
|
+
_RUNNING_SESSION_ID.reset(token)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_workspace_backend(
|
|
259
|
+
workspace: Path, artifacts_directory: Path | None = None
|
|
260
|
+
) -> CompositeBackend:
|
|
261
|
+
"""Build the command's backend: the workspace, with Deep Agents' own files kept apart.
|
|
262
|
+
|
|
263
|
+
Deep Agents writes large tool results and long user messages under
|
|
264
|
+
``/large_tool_results/`` and ``/conversation_history/``. Those prefixes go
|
|
265
|
+
to the running session's private directory, so they never land at the
|
|
266
|
+
file system root, in the workspace, or in another session.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
workspace: The absolute workspace directory.
|
|
270
|
+
artifacts_directory: The per-user directory of Deep Agents' own
|
|
271
|
+
files; ``None`` uses ``default_artifacts_directory()``.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The backend for ``create_deep_agent``.
|
|
275
|
+
"""
|
|
276
|
+
artifacts = artifacts_directory or default_artifacts_directory()
|
|
277
|
+
routes: dict[str, BackendProtocol] = {
|
|
278
|
+
prefix: SessionArtifactsBackend(artifacts, prefix.strip("/"))
|
|
279
|
+
for prefix in DEEP_AGENTS_ARTIFACT_PREFIXES
|
|
280
|
+
}
|
|
281
|
+
return CompositeBackend(default=WorkspaceShellBackend(workspace), routes=routes)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def default_artifacts_directory() -> Path:
|
|
285
|
+
"""Return the per-user directory of Deep Agents' own files, without creating it.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
``~/.dex/agents/deepagents-artifacts``.
|
|
289
|
+
"""
|
|
290
|
+
return Path.home() / ".dex" / "agents" / "deepagents-artifacts"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def ensure_artifacts_directory(artifacts_directory: Path) -> Path:
|
|
294
|
+
"""Create the per-user artifacts directory and check it and its two parents.
|
|
295
|
+
|
|
296
|
+
The parents, such as ``~/.dex`` and ``~/.dex/agents``, keep their mode,
|
|
297
|
+
but neither may be a symbolic link that another user owns.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
artifacts_directory: The per-user directory.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The directory, owned by the user, mode 0700.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
PermissionError: If a parent is another user's link, or the directory
|
|
307
|
+
is a link or another user's.
|
|
308
|
+
"""
|
|
309
|
+
for parent in (artifacts_directory.parent.parent, artifacts_directory.parent):
|
|
310
|
+
if parent.is_symlink() and parent.lstat().st_uid != os.getuid():
|
|
311
|
+
raise PermissionError(f"{parent} is a link that another user owns")
|
|
312
|
+
artifacts_directory.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
return _private_directory(artifacts_directory)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def remove_stale_session_artifacts(
|
|
317
|
+
artifacts_directory: Path, retention: timedelta = SESSION_ARTIFACTS_RETENTION
|
|
318
|
+
) -> list[Path]:
|
|
319
|
+
"""Delete the session directories whose files nothing changed within the retention.
|
|
320
|
+
|
|
321
|
+
The command calls it once when it starts. It never follows a link, and
|
|
322
|
+
a directory it cannot read or delete stays and is logged.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
artifacts_directory: The per-user directory.
|
|
326
|
+
retention: How long after its last change a session's files stay.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The deleted session directories.
|
|
330
|
+
"""
|
|
331
|
+
if not artifacts_directory.is_dir() or artifacts_directory.is_symlink():
|
|
332
|
+
return []
|
|
333
|
+
cutoff = time.time() - retention.total_seconds()
|
|
334
|
+
removed: list[Path] = []
|
|
335
|
+
try:
|
|
336
|
+
session_directories = list(artifacts_directory.iterdir())
|
|
337
|
+
except OSError as error:
|
|
338
|
+
_LOGGER.warning("cannot list session artifacts in %s: %s", artifacts_directory, error)
|
|
339
|
+
return removed
|
|
340
|
+
for session_directory in session_directories:
|
|
341
|
+
try:
|
|
342
|
+
if not stat.S_ISDIR(session_directory.lstat().st_mode):
|
|
343
|
+
continue
|
|
344
|
+
if _latest_change(session_directory) >= cutoff:
|
|
345
|
+
continue
|
|
346
|
+
shutil.rmtree(session_directory)
|
|
347
|
+
except OSError as error:
|
|
348
|
+
_LOGGER.warning(
|
|
349
|
+
"cannot remove stale session artifacts %s: %s", session_directory, error
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
_LOGGER.info("removed stale session artifacts %s", session_directory)
|
|
353
|
+
removed.append(session_directory)
|
|
354
|
+
return removed
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _latest_change(directory: Path) -> float:
|
|
358
|
+
latest = directory.lstat().st_mtime
|
|
359
|
+
for parent, directory_names, file_names in os.walk(directory, followlinks=False):
|
|
360
|
+
for name in (*directory_names, *file_names):
|
|
361
|
+
latest = max(latest, (Path(parent) / name).lstat().st_mtime)
|
|
362
|
+
return latest
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _private_directory(directory: Path) -> Path:
|
|
366
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
367
|
+
status = directory.lstat()
|
|
368
|
+
if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode):
|
|
369
|
+
raise PermissionError(f"{directory} is not a directory")
|
|
370
|
+
if status.st_uid != os.getuid():
|
|
371
|
+
raise PermissionError(f"{directory} belongs to another user")
|
|
372
|
+
if stat.S_IMODE(status.st_mode) & 0o077:
|
|
373
|
+
directory.chmod(0o700)
|
|
374
|
+
return directory
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _is_small_file(path: Path, max_bytes: int) -> bool:
|
|
378
|
+
try:
|
|
379
|
+
status = path.stat()
|
|
380
|
+
except OSError:
|
|
381
|
+
return False
|
|
382
|
+
return stat.S_ISREG(status.st_mode) and status.st_size <= max_bytes
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dex-agents-deepagents-cli
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: The dex-agents-deepagents command: a durable Deep Agents agent in your terminal
|
|
5
|
+
Author: Super Durable
|
|
6
|
+
License-Expression: LicenseRef-Sustainable-Use-1.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Dist: deepagents>=0.7.32,<0.8
|
|
13
|
+
Requires-Dist: dex-agents==0.6.1
|
|
14
|
+
Requires-Dist: dex-agents-cli-core==0.6.1
|
|
15
|
+
Requires-Dist: dex-agents-langgraph==0.6.1
|
|
16
|
+
Requires-Dist: dex-python-sdk==1.45.0
|
|
17
|
+
Requires-Dist: langchain>=1.4.7,<2
|
|
18
|
+
Requires-Dist: langchain-core>=1.7.3,<1.8
|
|
19
|
+
Requires-Dist: langchain-openai>=1.0,<2
|
|
20
|
+
Requires-Dist: langgraph>=1.2.19,<1.3
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Project-URL: Homepage, https://github.com/superdurable/dex/tree/main/agents/cli/python/deepagents
|
|
23
|
+
Project-URL: Repository, https://github.com/superdurable/dex
|
|
24
|
+
Project-URL: Changelog, https://github.com/superdurable/dex/blob/main/agents/CHANGELOG.md
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# dex-agents-deepagents-cli
|
|
28
|
+
|
|
29
|
+
`dex-agents-deepagents-cli` provides the `dex-agents-deepagents` command. It
|
|
30
|
+
runs a [Deep Agents](https://github.com/langchain-ai/deepagents) agent as a
|
|
31
|
+
durable Dex agent session and chats with it in your terminal. The shared
|
|
32
|
+
CLI core, [`dex-agents-cli-core`](../core/README.md), starts or reuses
|
|
33
|
+
`dexcli dev`, connects the connectors MCP server, and runs the Worker. This
|
|
34
|
+
package only builds the agent with `create_deep_agent` and runs it on the
|
|
35
|
+
[LangGraph adapter](../../../python/langgraph/README.md).
|
|
36
|
+
|
|
37
|
+
## Running it
|
|
38
|
+
|
|
39
|
+
The command needs Python 3.11 or later and a key for the model's provider.
|
|
40
|
+
The default model is OpenAI's, so set `OPENAI_API_KEY`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export OPENAI_API_KEY=sk-...
|
|
44
|
+
uvx --from dex-agents-deepagents-cli dex-agents-deepagents
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first run asks the `init` questions and writes
|
|
48
|
+
`~/.dex/agents/deepagents.yaml`. Then type a message at the `>` prompt.
|
|
49
|
+
Approve or reject tool calls with `y` or `n`, and press Ctrl-D to exit. The
|
|
50
|
+
session stays in Dex, and the command prints the `--resume` line that
|
|
51
|
+
continues it. See the [CLI core README](../core/README.md#running-a-command)
|
|
52
|
+
for every subcommand and option, and [`agents/cli`](../../README.md) for the
|
|
53
|
+
configuration file.
|
|
54
|
+
|
|
55
|
+
The command never prints or stores provider keys. The chat model reads its
|
|
56
|
+
key from the provider's standard environment variable.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
`model` is a LangChain `provider:model` ID, as `init_chat_model` takes it. It
|
|
61
|
+
defaults to `openai:gpt-5.2`; set `model` in the file or pass `--model` to
|
|
62
|
+
change it. An `openai:` model uses the Responses API, as Deep Agents does.
|
|
63
|
+
|
|
64
|
+
| Provider | Model example | Environment |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| OpenAI | `openai:gpt-5.2` | `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` |
|
|
67
|
+
| Anthropic | `anthropic:claude-opus-5-5` | `ANTHROPIC_API_KEY` |
|
|
68
|
+
| Google | `google_genai:gemini-2.5-flash` | `GOOGLE_API_KEY` |
|
|
69
|
+
|
|
70
|
+
Other providers need their LangChain integration package, such as
|
|
71
|
+
`uvx --from dex-agents-deepagents-cli --with langchain-mistralai dex-agents-deepagents`.
|
|
72
|
+
|
|
73
|
+
`system_prompt` replaces the default instructions. Session settings may
|
|
74
|
+
change the model and the instructions from the next turn on. The
|
|
75
|
+
`deepagents` section has no options yet, and any key in it is a configuration
|
|
76
|
+
error.
|
|
77
|
+
|
|
78
|
+
## Deep Agents' tools in Dex
|
|
79
|
+
|
|
80
|
+
The agent keeps Deep Agents' own tools and behavior. The CLI core adds the
|
|
81
|
+
connector, web fetch, and browser tools on top. The agent runs on the
|
|
82
|
+
LangGraph adapter, so every model call is a `RunModel` Step and every tool
|
|
83
|
+
call runs in its own tool Step, after approval when its policy asks for it.
|
|
84
|
+
|
|
85
|
+
| Deep Agents tool | In this command |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `ls`, `read_file`, `glob`, `grep` | Reads of the real workspace files. They run at once. |
|
|
88
|
+
| `write_file`, `edit_file`, `delete` | Writes of the real workspace files. They run at once unless `approval.always` names the tool. |
|
|
89
|
+
| `execute` | Runs a command with `/bin/sh` in the workspace. It runs at once unless `approval.always` names it; `local_tools.shell.allow` has no effect. |
|
|
90
|
+
| `task` | Runs a subagent inside the call's tool Step. It runs at once unless `approval.always` names any Deep Agents tool, because the subagent may call it without its own approval. |
|
|
91
|
+
| `write_todos` | Updates the todo list in the agent's state. It runs at once. |
|
|
92
|
+
|
|
93
|
+
- Deep Agents asks for no approval unless `interrupt_on` names a tool, and
|
|
94
|
+
this command follows it: `approval.always` plays the role of
|
|
95
|
+
`interrupt_on`. Connector writes always wait for approval.
|
|
96
|
+
- The file tools use Deep Agents' `LocalShellBackend` on the host's paths,
|
|
97
|
+
rooted at the workspace, so a file tool and `execute` name a file the same
|
|
98
|
+
way. Paths are absolute. A path that resolves outside the workspace fails
|
|
99
|
+
with an error the model sees, and `ls`, `glob`, and `grep` leave out
|
|
100
|
+
entries whose real path is outside it, such as a link to a file elsewhere;
|
|
101
|
+
`grep` never opens such a file.
|
|
102
|
+
- That check is a guardrail against mistaken paths, not a sandbox.
|
|
103
|
+
`execute` runs any command with your permissions, without approval by
|
|
104
|
+
default. A link that changes during a call, or a hard link in the
|
|
105
|
+
workspace to a file elsewhere, is not detected. Use OS-level isolation,
|
|
106
|
+
such as a container, when the agent must not reach other files.
|
|
107
|
+
- `local_tools.files` and `local_tools.shell.enabled` switch the tools on and
|
|
108
|
+
off per turn. `task` needs both, because a subagent uses both.
|
|
109
|
+
- Each tool Step attempt runs Deep Agents' tool middleware around the tool,
|
|
110
|
+
such as `FilesystemMiddleware`'s eviction of a large result to a file. A
|
|
111
|
+
retried attempt runs it again. The
|
|
112
|
+
session Flow sets the adapter's `should_defer_tool_calls_before_middleware`
|
|
113
|
+
for that. Deep Agents writes evicted results and long user messages under
|
|
114
|
+
the paths `/large_tool_results/` and `/conversation_history/`, which its
|
|
115
|
+
file tools read. Each session keeps them in its own directory,
|
|
116
|
+
`~/.dex/agents/deepagents-artifacts/<SHA-256 of the session ID>/`, so two
|
|
117
|
+
sessions never see each other's files and a restarted Worker on the same
|
|
118
|
+
machine finds them. Before each file operation the command checks every
|
|
119
|
+
directory from `deepagents-artifacts` down: you own it, it is no link, and
|
|
120
|
+
a looser mode becomes 0700. `~/.dex` and `~/.dex/agents` keep their mode
|
|
121
|
+
but must not be another user's link. A failed check is an error result of
|
|
122
|
+
that tool call.
|
|
123
|
+
- When the command starts, it deletes the artifacts of every session whose
|
|
124
|
+
files changed more than 30 days ago. It never follows a link, and it logs
|
|
125
|
+
each directory it removes or cannot remove. A session resumed after that
|
|
126
|
+
can no longer read its earlier large results.
|
|
127
|
+
- `edit_file`, `execute`, and `task` calls are never retried, so a Worker that
|
|
128
|
+
dies during one records `outcome_unknown`. `write_file` and `delete` calls
|
|
129
|
+
may run again after a failure.
|
|
130
|
+
- `write_todos` is LangChain's `TodoListMiddleware` tool. It returns a graph
|
|
131
|
+
`Command` that the adapter records and applies. Each turn starts from the
|
|
132
|
+
stored messages, so the todo list lasts for one turn; the plan stays in the
|
|
133
|
+
conversation.
|
|
134
|
+
- `task` runs the subagent's model and tool calls inline, in one tool Step.
|
|
135
|
+
They have no Steps or approvals of their own, use the configuration file's
|
|
136
|
+
model even when session settings change it, and do not count in the
|
|
137
|
+
session's usage. Connector tools are not offered to a subagent.
|
|
138
|
+
- A connector tool whose name a Deep Agents tool has gets the prefix
|
|
139
|
+
`connector_`, such as `connector_read_file`. The other connector tools keep
|
|
140
|
+
their names.
|
|
141
|
+
- Every model call gets the turn's instructions, the workspace path, and the
|
|
142
|
+
turn's model through the adapter's `select_turn_chat_model`.
|
|
143
|
+
- Deep Agents' summarization is turned off for now. It would make an extra
|
|
144
|
+
model call with the configuration file's model inside a `RunModel` Step, so
|
|
145
|
+
a model from the session settings would not reach it. The session does not
|
|
146
|
+
compact either: Dex compaction needs
|
|
147
|
+
the model's context window, which the configuration does not set. A long
|
|
148
|
+
session ends when its input no longer fits the model's context window;
|
|
149
|
+
start a new session then.
|
|
150
|
+
|
|
151
|
+
## Images
|
|
152
|
+
|
|
153
|
+
`/image <path>` attaches an image file to the next message. The command gives
|
|
154
|
+
the LangChain adapter the media store of `media.store`, so the model sees the
|
|
155
|
+
image, and images that tools return go to the same store. See
|
|
156
|
+
[Images](../../README.md#images).
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
Develop with uv 0.12.2. The package resolves `dex-agents`, the LangGraph
|
|
161
|
+
adapter, the CLI core, and the test support package from this repository by
|
|
162
|
+
path. `ci.sh` installs the locked dependencies, checks that LangChain and
|
|
163
|
+
LangGraph match the adapter's lock, checks lint and types, and runs the tests
|
|
164
|
+
against a private `dexcli dev`:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
cd agents/cli/python/deepagents
|
|
168
|
+
DEXCLI=/path/to/dexcli ./ci.sh
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The tests run the command in a pseudo-terminal with a private home. The chat
|
|
172
|
+
model calls the scripted Responses API of
|
|
173
|
+
[`dex-agents-cli-testing`](../testing) on 127.0.0.1; a refused proxy stops
|
|
174
|
+
any other request, and no provider variable of the host reaches the command.
|
|
175
|
+
A fake connectors MCP server serves a connector read and write. The tests
|
|
176
|
+
cover a chat turn and `--resume`; `read_file`, `write_file`, and `edit_file` on
|
|
177
|
+
real workspace files without approval; `execute` in the workspace; a path
|
|
178
|
+
outside the workspace, which is refused; `write_file` waiting for approval
|
|
179
|
+
when `approval.always` names it, and rejected file and connector writes that
|
|
180
|
+
never run; a connector read and an approved connector write that runs once; a
|
|
181
|
+
connector tool named `read_file`, which the model sees as
|
|
182
|
+
`connector_connector_read_file` beside a connector tool named
|
|
183
|
+
`connector_read_file`; `write_todos`; a `task` subagent that writes a
|
|
184
|
+
workspace file inside one tool Step, and a `task` that waits for approval
|
|
185
|
+
when `approval.always` names `read_file`; and a large `execute` result that
|
|
186
|
+
lands in the session's own artifacts directory. Backend tests cover links to
|
|
187
|
+
files outside the workspace, which `ls`, `glob`, and `grep` leave out with
|
|
188
|
+
and without ripgrep, which the Python search never opens, and which reads and
|
|
189
|
+
writes refuse; two sessions that write the same large-result path without
|
|
190
|
+
seeing each other's file; a session directory that is a link, which is an
|
|
191
|
+
error result; looser artifact directories, which become 0700; and the
|
|
192
|
+
removal of a session's artifacts after 30 days, which never follows a link.
|
|
193
|
+
They also cover the offered tools, which are Deep Agents' own and none of
|
|
194
|
+
the CLI core's file and shell tools; switched-off file and shell tools, which
|
|
195
|
+
are not offered; a model change and a `system_prompt` in the session
|
|
196
|
+
settings, which reach the next model request; and a key in the `deepagents`
|
|
197
|
+
section. The command also checks that its graph has no summarization, that
|
|
198
|
+
its tool rules cover every tool of the graph, and that a model from the
|
|
199
|
+
session settings reaches every model call.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
dex_agents_deepagents_cli/__init__.py,sha256=ZVSn5vyc1EvFoAuD5LCWzEZ0BlvKPq5fizm1rsgHQJU,3303
|
|
2
|
+
dex_agents_deepagents_cli/__main__.py,sha256=uSDfXwYWxHJwhAj7UCtHJBTQxnGXne8Y36zGIp7tvuM,439
|
|
3
|
+
dex_agents_deepagents_cli/connector_tool_names.py,sha256=-yk6GznBfoep-tVOpkB4SCwP11LFdosUxacsMyP_92s,5727
|
|
4
|
+
dex_agents_deepagents_cli/harness.py,sha256=QQVth1mZDVn_xgFoxegUgvLdSN0AFJRPePdDYY2S6Ic,17073
|
|
5
|
+
dex_agents_deepagents_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dex_agents_deepagents_cli/workspace_backend.py,sha256=oLfXfAonzJTt7zsW7631WP9iAs1C9zkyUL64IwmrhzQ,15152
|
|
7
|
+
dex_agents_deepagents_cli-0.6.1.dist-info/licenses/LICENSE,sha256=0AJCV4ky-vrSnT08IIdYpyiFPPoF2UrtKsBAvuvt1G4,4220
|
|
8
|
+
dex_agents_deepagents_cli-0.6.1.dist-info/WHEEL,sha256=q9Wah2_CgP24mRNSlTyb0F8Yrbz55SzVl8eHLkZ_wvE,80
|
|
9
|
+
dex_agents_deepagents_cli-0.6.1.dist-info/entry_points.txt,sha256=mfNobIu3HGTTaMsMjsFyvQpLzpZWev5Oi3_7AjgJS4U,73
|
|
10
|
+
dex_agents_deepagents_cli-0.6.1.dist-info/METADATA,sha256=rT4F8A1S9sZ_s_-InHVDZBEcHIE0wja2tkxjILykDGY,10950
|
|
11
|
+
dex_agents_deepagents_cli-0.6.1.dist-info/RECORD,,
|