deepagents-cli 0.0.1__py3-none-any.whl → 0.0.3__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.
Potentially problematic release.
This version of deepagents-cli might be problematic. Click here for more details.
- deepagents/__init__.py +1 -12
- deepagents/cli.py +257 -272
- deepagents/default_agent_prompt.md +0 -27
- deepagents/graph.py +16 -40
- deepagents/memory/__init__.py +17 -0
- deepagents/memory/backends/__init__.py +15 -0
- deepagents/memory/backends/composite.py +250 -0
- deepagents/memory/backends/filesystem.py +330 -0
- deepagents/memory/backends/state.py +206 -0
- deepagents/memory/backends/store.py +351 -0
- deepagents/memory/backends/utils.py +319 -0
- deepagents/memory/protocol.py +164 -0
- deepagents/middleware/__init__.py +3 -3
- deepagents/middleware/agent_memory.py +207 -0
- deepagents/middleware/filesystem.py +229 -773
- deepagents/middleware/patch_tool_calls.py +44 -0
- deepagents/middleware/subagents.py +7 -6
- deepagents/pretty_cli.py +289 -0
- {deepagents_cli-0.0.1.dist-info → deepagents_cli-0.0.3.dist-info}/METADATA +26 -30
- deepagents_cli-0.0.3.dist-info/RECORD +24 -0
- deepagents/middleware/common.py +0 -16
- deepagents/middleware/local_filesystem.py +0 -741
- deepagents/prompts.py +0 -327
- deepagents/skills.py +0 -85
- deepagents_cli-0.0.1.dist-info/RECORD +0 -17
- {deepagents_cli-0.0.1.dist-info → deepagents_cli-0.0.3.dist-info}/WHEEL +0 -0
- {deepagents_cli-0.0.1.dist-info → deepagents_cli-0.0.3.dist-info}/entry_points.txt +0 -0
- {deepagents_cli-0.0.1.dist-info → deepagents_cli-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {deepagents_cli-0.0.1.dist-info → deepagents_cli-0.0.3.dist-info}/top_level.txt +0 -0
|
@@ -1,741 +0,0 @@
|
|
|
1
|
-
"""Middleware that exposes local filesystem tools to an agent.
|
|
2
|
-
|
|
3
|
-
This mirrors the structure of `FilesystemMiddleware` but operates on the
|
|
4
|
-
host/local filesystem (disk) rather than the in-memory/mock filesystem.
|
|
5
|
-
It ports the tool behavior from `src/deepagents/local_fs_tools.py` into
|
|
6
|
-
middleware-provided tools so they can be injected via AgentMiddleware.
|
|
7
|
-
"""
|
|
8
|
-
# ruff: noqa: E501
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from collections.abc import Awaitable, Callable
|
|
13
|
-
from typing import Any, Optional, Union
|
|
14
|
-
|
|
15
|
-
import os
|
|
16
|
-
import pathlib
|
|
17
|
-
import re
|
|
18
|
-
import subprocess
|
|
19
|
-
|
|
20
|
-
from langchain.agents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse
|
|
21
|
-
from langchain.tools.tool_node import ToolCallRequest
|
|
22
|
-
from langchain_core.messages import ToolMessage
|
|
23
|
-
from langgraph.types import Command
|
|
24
|
-
from langgraph.config import get_config
|
|
25
|
-
from langchain_core.tools import tool
|
|
26
|
-
from deepagents.middleware.filesystem import (
|
|
27
|
-
_create_file_data,
|
|
28
|
-
_format_content_with_line_numbers,
|
|
29
|
-
FilesystemState,
|
|
30
|
-
FILESYSTEM_SYSTEM_PROMPT,
|
|
31
|
-
)
|
|
32
|
-
from deepagents.middleware.common import TOO_LARGE_TOOL_MSG, FILESYSTEM_SYSTEM_PROMPT_GLOB_GREP_SUPPLEMENT
|
|
33
|
-
|
|
34
|
-
from deepagents.prompts import (
|
|
35
|
-
EDIT_DESCRIPTION,
|
|
36
|
-
TOOL_DESCRIPTION,
|
|
37
|
-
GLOB_DESCRIPTION,
|
|
38
|
-
GREP_DESCRIPTION,
|
|
39
|
-
WRITE_DESCRIPTION,
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
LOCAL_LIST_FILES_TOOL_DESCRIPTION = """Lists all files in the specified directory on disk.
|
|
43
|
-
|
|
44
|
-
Usage:
|
|
45
|
-
- The ls tool will return a list of all files in the specified directory.
|
|
46
|
-
- The path parameter accepts both absolute paths (starting with /) and relative paths
|
|
47
|
-
- Relative paths are resolved relative to the current working directory
|
|
48
|
-
- This is very useful for exploring the file system and finding the right file to read or edit.
|
|
49
|
-
- You should almost ALWAYS use this tool before using the Read or Edit tools."""
|
|
50
|
-
|
|
51
|
-
LOCAL_READ_FILE_TOOL_DESCRIPTION = TOOL_DESCRIPTION + "\n- You should ALWAYS make sure a file has been read before editing it."
|
|
52
|
-
LOCAL_EDIT_FILE_TOOL_DESCRIPTION = EDIT_DESCRIPTION
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# -----------------------------
|
|
56
|
-
# Path Resolution Helper
|
|
57
|
-
# -----------------------------
|
|
58
|
-
|
|
59
|
-
def _resolve_path(path: str, cwd: str, long_term_memory: bool = False) -> str:
|
|
60
|
-
"""Resolve relative paths against CWD, leave absolute paths unchanged.
|
|
61
|
-
|
|
62
|
-
Special handling: /memories/* paths are redirected to ~/.deepagents/<agent_name>/
|
|
63
|
-
agent_name is retrieved from the runtime config. If agent_name is None, /memories
|
|
64
|
-
paths will return an error. This only works if long_term_memory=True.
|
|
65
|
-
"""
|
|
66
|
-
if path.startswith("/memories"):
|
|
67
|
-
if not long_term_memory:
|
|
68
|
-
raise ValueError(
|
|
69
|
-
"Long-term memory is disabled. "
|
|
70
|
-
"/memories/ access requires long_term_memory=True."
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# Get agent_name from config
|
|
74
|
-
config = get_config()
|
|
75
|
-
agent_name = config.get("configurable", {}).get("agent_name") if config else None
|
|
76
|
-
|
|
77
|
-
if agent_name is None:
|
|
78
|
-
raise ValueError(
|
|
79
|
-
"Memory access is disabled when no agent name is provided. "
|
|
80
|
-
"To use /memories/, run with --agent <name> to enable memory features."
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
agent_dir = pathlib.Path.home() / ".deepagents" / agent_name
|
|
84
|
-
if path == "/memories":
|
|
85
|
-
return str(agent_dir)
|
|
86
|
-
else:
|
|
87
|
-
relative_part = path[len("/memories/"):]
|
|
88
|
-
return str(agent_dir / relative_part)
|
|
89
|
-
|
|
90
|
-
if os.path.isabs(path):
|
|
91
|
-
return path
|
|
92
|
-
return str(pathlib.Path(cwd) / path)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# -----------------------------
|
|
96
|
-
# Tool Implementations (Local)
|
|
97
|
-
# -----------------------------
|
|
98
|
-
|
|
99
|
-
def _ls_impl(path: str = ".", cwd: str | None = None, long_term_memory: bool = False) -> list[str]:
|
|
100
|
-
"""List all files in the specified directory on disk."""
|
|
101
|
-
try:
|
|
102
|
-
if cwd:
|
|
103
|
-
path = _resolve_path(path, cwd, long_term_memory)
|
|
104
|
-
path_obj = pathlib.Path(path)
|
|
105
|
-
if not path_obj.exists():
|
|
106
|
-
return [f"Error: Path '{path}' does not exist"]
|
|
107
|
-
if not path_obj.is_dir():
|
|
108
|
-
return [f"Error: Path '{path}' is not a directory"]
|
|
109
|
-
|
|
110
|
-
items: list[str] = []
|
|
111
|
-
for item in path_obj.iterdir():
|
|
112
|
-
items.append(str(item.name))
|
|
113
|
-
return sorted(items)
|
|
114
|
-
except Exception as e: # pragma: no cover - defensive
|
|
115
|
-
return [f"Error listing directory: {str(e)}"]
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _read_file_impl(
|
|
119
|
-
file_path: str,
|
|
120
|
-
offset: int = 0,
|
|
121
|
-
limit: int = 2000,
|
|
122
|
-
cwd: str | None = None,
|
|
123
|
-
long_term_memory: bool = False,
|
|
124
|
-
) -> str:
|
|
125
|
-
"""Read a file from the local filesystem and return cat -n formatted content."""
|
|
126
|
-
try:
|
|
127
|
-
if cwd:
|
|
128
|
-
file_path = _resolve_path(file_path, cwd, long_term_memory)
|
|
129
|
-
path_obj = pathlib.Path(file_path)
|
|
130
|
-
|
|
131
|
-
if not path_obj.exists():
|
|
132
|
-
return f"Error: File '{file_path}' not found"
|
|
133
|
-
if not path_obj.is_file():
|
|
134
|
-
return f"Error: '{file_path}' is not a file"
|
|
135
|
-
|
|
136
|
-
try:
|
|
137
|
-
with open(path_obj, "r", encoding="utf-8") as f:
|
|
138
|
-
content = f.read()
|
|
139
|
-
except UnicodeDecodeError:
|
|
140
|
-
# Fallback to binary read and lenient decode
|
|
141
|
-
with open(path_obj, "rb") as f:
|
|
142
|
-
content = f.read().decode("utf-8", errors="ignore")
|
|
143
|
-
|
|
144
|
-
if not content or content.strip() == "":
|
|
145
|
-
return "System reminder: File exists but has empty contents"
|
|
146
|
-
|
|
147
|
-
lines = content.splitlines()
|
|
148
|
-
start_idx = offset
|
|
149
|
-
end_idx = min(start_idx + limit, len(lines))
|
|
150
|
-
|
|
151
|
-
if start_idx >= len(lines):
|
|
152
|
-
return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)"
|
|
153
|
-
|
|
154
|
-
result_lines = []
|
|
155
|
-
for i in range(start_idx, end_idx):
|
|
156
|
-
line_content = lines[i]
|
|
157
|
-
if len(line_content) > 2000:
|
|
158
|
-
line_content = line_content[:2000]
|
|
159
|
-
result_lines.append(f"{i + 1:6d}\t{line_content}")
|
|
160
|
-
|
|
161
|
-
return "\n".join(result_lines)
|
|
162
|
-
|
|
163
|
-
except Exception as e: # pragma: no cover - defensive
|
|
164
|
-
return f"Error reading file: {str(e)}"
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _write_file_impl(
|
|
168
|
-
file_path: str,
|
|
169
|
-
content: str,
|
|
170
|
-
cwd: str | None = None,
|
|
171
|
-
long_term_memory: bool = False,
|
|
172
|
-
) -> str:
|
|
173
|
-
"""Write content to a file on the local filesystem (creates parents)."""
|
|
174
|
-
try:
|
|
175
|
-
if cwd:
|
|
176
|
-
file_path = _resolve_path(file_path, cwd, long_term_memory)
|
|
177
|
-
path_obj = pathlib.Path(file_path)
|
|
178
|
-
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
-
with open(path_obj, "w", encoding="utf-8") as f:
|
|
180
|
-
f.write(content)
|
|
181
|
-
return f"Successfully wrote to file '{file_path}'"
|
|
182
|
-
except Exception as e: # pragma: no cover - defensive
|
|
183
|
-
return f"Error writing file: {str(e)}"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _edit_file_impl(
|
|
187
|
-
file_path: str,
|
|
188
|
-
old_string: str,
|
|
189
|
-
new_string: str,
|
|
190
|
-
replace_all: bool = False,
|
|
191
|
-
cwd: str | None = None,
|
|
192
|
-
long_term_memory: bool = False,
|
|
193
|
-
) -> str:
|
|
194
|
-
"""Edit a file on disk by replacing old_string with new_string."""
|
|
195
|
-
try:
|
|
196
|
-
if cwd:
|
|
197
|
-
file_path = _resolve_path(file_path, cwd, long_term_memory)
|
|
198
|
-
path_obj = pathlib.Path(file_path)
|
|
199
|
-
if not path_obj.exists():
|
|
200
|
-
return f"Error: File '{file_path}' not found"
|
|
201
|
-
if not path_obj.is_file():
|
|
202
|
-
return f"Error: '{file_path}' is not a file"
|
|
203
|
-
|
|
204
|
-
try:
|
|
205
|
-
with open(path_obj, "r", encoding="utf-8") as f:
|
|
206
|
-
content = f.read()
|
|
207
|
-
except UnicodeDecodeError:
|
|
208
|
-
return f"Error: File '{file_path}' contains non-UTF-8 content"
|
|
209
|
-
|
|
210
|
-
if old_string not in content:
|
|
211
|
-
return f"Error: String not found in file: '{old_string}'"
|
|
212
|
-
|
|
213
|
-
if not replace_all:
|
|
214
|
-
occurrences = content.count(old_string)
|
|
215
|
-
if occurrences > 1:
|
|
216
|
-
return (
|
|
217
|
-
f"Error: String '{old_string}' appears {occurrences} times in file. "
|
|
218
|
-
"Use replace_all=True to replace all instances, or provide a more specific string with surrounding context."
|
|
219
|
-
)
|
|
220
|
-
elif occurrences == 0:
|
|
221
|
-
return f"Error: String not found in file: '{old_string}'"
|
|
222
|
-
|
|
223
|
-
if replace_all:
|
|
224
|
-
new_content = content.replace(old_string, new_string)
|
|
225
|
-
else:
|
|
226
|
-
new_content = content.replace(old_string, new_string, 1)
|
|
227
|
-
|
|
228
|
-
with open(path_obj, "w", encoding="utf-8") as f:
|
|
229
|
-
f.write(new_content)
|
|
230
|
-
|
|
231
|
-
if replace_all:
|
|
232
|
-
replacement_count = content.count(old_string)
|
|
233
|
-
return f"Successfully replaced {replacement_count} instance(s) of the string in '{file_path}'"
|
|
234
|
-
return f"Successfully replaced string in '{file_path}'"
|
|
235
|
-
|
|
236
|
-
except Exception as e: # pragma: no cover - defensive
|
|
237
|
-
return f"Error editing file: {str(e)}"
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _glob_impl(
|
|
241
|
-
pattern: str,
|
|
242
|
-
path: str = ".",
|
|
243
|
-
max_results: int = 100,
|
|
244
|
-
include_dirs: bool = False,
|
|
245
|
-
recursive: bool = True,
|
|
246
|
-
cwd: str | None = None,
|
|
247
|
-
long_term_memory: bool = False,
|
|
248
|
-
) -> str:
|
|
249
|
-
"""Find files and directories using glob patterns on local filesystem."""
|
|
250
|
-
try:
|
|
251
|
-
if cwd:
|
|
252
|
-
path = _resolve_path(path, cwd, long_term_memory)
|
|
253
|
-
path_obj = pathlib.Path(path)
|
|
254
|
-
if not path_obj.exists():
|
|
255
|
-
return f"Error: Path '{path}' does not exist"
|
|
256
|
-
if not path_obj.is_dir():
|
|
257
|
-
return f"Error: Path '{path}' is not a directory"
|
|
258
|
-
|
|
259
|
-
results: list[str] = []
|
|
260
|
-
try:
|
|
261
|
-
matches = path_obj.rglob(pattern) if recursive else path_obj.glob(pattern)
|
|
262
|
-
for match in matches:
|
|
263
|
-
if len(results) >= max_results:
|
|
264
|
-
break
|
|
265
|
-
if match.is_file():
|
|
266
|
-
results.append(str(match))
|
|
267
|
-
elif match.is_dir() and include_dirs:
|
|
268
|
-
results.append(f"{match}/")
|
|
269
|
-
results.sort()
|
|
270
|
-
except Exception as e:
|
|
271
|
-
return f"Error processing glob pattern: {str(e)}"
|
|
272
|
-
|
|
273
|
-
if not results:
|
|
274
|
-
search_type = "recursive" if recursive else "non-recursive"
|
|
275
|
-
dirs_note = " (including directories)" if include_dirs else ""
|
|
276
|
-
return f"No matches found for pattern '{pattern}' in '{path}' ({search_type} search{dirs_note})"
|
|
277
|
-
|
|
278
|
-
header = f"Found {len(results)} matches for pattern '{pattern}'"
|
|
279
|
-
if len(results) >= max_results:
|
|
280
|
-
header += f" (limited to {max_results} results)"
|
|
281
|
-
header += ":\n\n"
|
|
282
|
-
return header + "\n".join(results)
|
|
283
|
-
|
|
284
|
-
except Exception as e: # pragma: no cover - defensive
|
|
285
|
-
return f"Error in glob search: {str(e)}"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def _grep_impl(
|
|
289
|
-
pattern: str,
|
|
290
|
-
files: Optional[Union[str, list[str]]] = None,
|
|
291
|
-
path: Optional[str] = None,
|
|
292
|
-
file_pattern: str = "*",
|
|
293
|
-
max_results: int = 50,
|
|
294
|
-
case_sensitive: bool = False,
|
|
295
|
-
context_lines: int = 0,
|
|
296
|
-
regex: bool = False,
|
|
297
|
-
cwd: str | None = None,
|
|
298
|
-
long_term_memory: bool = False,
|
|
299
|
-
) -> str:
|
|
300
|
-
"""Search for text patterns within files using ripgrep on local filesystem."""
|
|
301
|
-
try:
|
|
302
|
-
if not files and not path:
|
|
303
|
-
return "Error: Must provide either 'files' parameter or 'path' parameter"
|
|
304
|
-
|
|
305
|
-
if cwd:
|
|
306
|
-
if files:
|
|
307
|
-
if isinstance(files, str):
|
|
308
|
-
files = _resolve_path(files, cwd, long_term_memory)
|
|
309
|
-
else:
|
|
310
|
-
files = [_resolve_path(f, cwd, long_term_memory) for f in files]
|
|
311
|
-
if path:
|
|
312
|
-
path = _resolve_path(path, cwd, long_term_memory)
|
|
313
|
-
|
|
314
|
-
cmd: list[str] = ["rg"]
|
|
315
|
-
if regex:
|
|
316
|
-
cmd.extend(["-e", pattern])
|
|
317
|
-
else:
|
|
318
|
-
cmd.extend(["-F", pattern])
|
|
319
|
-
|
|
320
|
-
if not case_sensitive:
|
|
321
|
-
cmd.append("-i")
|
|
322
|
-
|
|
323
|
-
if context_lines > 0:
|
|
324
|
-
cmd.extend(["-C", str(context_lines)])
|
|
325
|
-
|
|
326
|
-
if max_results > 0:
|
|
327
|
-
cmd.extend(["-m", str(max_results)])
|
|
328
|
-
|
|
329
|
-
if file_pattern != "*":
|
|
330
|
-
cmd.extend(["-g", file_pattern])
|
|
331
|
-
|
|
332
|
-
if files:
|
|
333
|
-
if isinstance(files, str):
|
|
334
|
-
cmd.append(files)
|
|
335
|
-
else:
|
|
336
|
-
cmd.extend(files)
|
|
337
|
-
elif path:
|
|
338
|
-
cmd.append(path)
|
|
339
|
-
|
|
340
|
-
try:
|
|
341
|
-
result = subprocess.run(
|
|
342
|
-
cmd,
|
|
343
|
-
capture_output=True,
|
|
344
|
-
text=True,
|
|
345
|
-
timeout=30,
|
|
346
|
-
cwd=path if path and os.path.isdir(path) else None,
|
|
347
|
-
)
|
|
348
|
-
if result.returncode == 0:
|
|
349
|
-
return result.stdout
|
|
350
|
-
elif result.returncode == 1:
|
|
351
|
-
pattern_desc = f"regex pattern '{pattern}'" if regex else f"text '{pattern}'"
|
|
352
|
-
case_desc = " (case-sensitive)" if case_sensitive else " (case-insensitive)"
|
|
353
|
-
return f"No matches found for {pattern_desc}{case_desc}"
|
|
354
|
-
else:
|
|
355
|
-
return f"Error running ripgrep: {result.stderr}"
|
|
356
|
-
except subprocess.TimeoutExpired:
|
|
357
|
-
return "Error: ripgrep search timed out"
|
|
358
|
-
except FileNotFoundError:
|
|
359
|
-
return "Error: ripgrep (rg) not found. Please install ripgrep to use this tool."
|
|
360
|
-
except Exception as e:
|
|
361
|
-
return f"Error running ripgrep: {str(e)}"
|
|
362
|
-
|
|
363
|
-
except Exception as e: # pragma: no cover - defensive
|
|
364
|
-
return f"Error in grep search: {str(e)}"
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
# --------------------------------
|
|
368
|
-
# Middleware: LocalFilesystemMiddleware
|
|
369
|
-
# --------------------------------
|
|
370
|
-
|
|
371
|
-
LOCAL_FILESYSTEM_SYSTEM_PROMPT = FILESYSTEM_SYSTEM_PROMPT + "\n" + FILESYSTEM_SYSTEM_PROMPT_GLOB_GREP_SUPPLEMENT
|
|
372
|
-
|
|
373
|
-
# Skills discovery paths
|
|
374
|
-
STANDARD_SKILL_PATHS = [
|
|
375
|
-
"~/.deepagents/skills",
|
|
376
|
-
"./.deepagents/skills",
|
|
377
|
-
]
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _get_local_filesystem_tools(custom_tool_descriptions: dict[str, str] | None = None, cwd: str | None = None, long_term_memory: bool = False):
|
|
381
|
-
"""Return tool instances for local filesystem operations.
|
|
382
|
-
|
|
383
|
-
agent_name is retrieved from runtime config via get_config() when tools are called.
|
|
384
|
-
"""
|
|
385
|
-
# We already decorated read/write/edit/glob/grep with @tool including descriptions
|
|
386
|
-
# Only `ls` needs a manual wrapper to attach a description.
|
|
387
|
-
ls_description = (
|
|
388
|
-
custom_tool_descriptions.get("ls") if custom_tool_descriptions else LOCAL_LIST_FILES_TOOL_DESCRIPTION
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
@tool(description=ls_description)
|
|
392
|
-
def ls(path: str = ".") -> list[str]: # noqa: D401 - simple wrapper
|
|
393
|
-
"""List all files in the specified directory."""
|
|
394
|
-
return _ls_impl(path, cwd=cwd, long_term_memory=long_term_memory)
|
|
395
|
-
|
|
396
|
-
read_desc = (
|
|
397
|
-
(custom_tool_descriptions or {}).get("read_file", LOCAL_READ_FILE_TOOL_DESCRIPTION)
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
@tool(description=read_desc)
|
|
401
|
-
def read_file(file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
402
|
-
return _read_file_impl(file_path, offset, limit, cwd=cwd, long_term_memory=long_term_memory)
|
|
403
|
-
|
|
404
|
-
write_desc = (
|
|
405
|
-
(custom_tool_descriptions or {}).get("write_file", WRITE_DESCRIPTION)
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
@tool(description=write_desc)
|
|
409
|
-
def write_file(file_path: str, content: str) -> str:
|
|
410
|
-
return _write_file_impl(file_path, content, cwd=cwd, long_term_memory=long_term_memory)
|
|
411
|
-
|
|
412
|
-
edit_desc = (
|
|
413
|
-
(custom_tool_descriptions or {}).get("edit_file", LOCAL_EDIT_FILE_TOOL_DESCRIPTION)
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
@tool(description=edit_desc)
|
|
417
|
-
def edit_file(
|
|
418
|
-
file_path: str,
|
|
419
|
-
old_string: str,
|
|
420
|
-
new_string: str,
|
|
421
|
-
replace_all: bool = False,
|
|
422
|
-
) -> str:
|
|
423
|
-
return _edit_file_impl(file_path, old_string, new_string, replace_all, cwd=cwd, long_term_memory=long_term_memory)
|
|
424
|
-
|
|
425
|
-
glob_desc = (
|
|
426
|
-
(custom_tool_descriptions or {}).get("glob", GLOB_DESCRIPTION)
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
@tool(description=glob_desc)
|
|
430
|
-
def glob(
|
|
431
|
-
pattern: str,
|
|
432
|
-
path: str = ".",
|
|
433
|
-
max_results: int = 100,
|
|
434
|
-
include_dirs: bool = False,
|
|
435
|
-
recursive: bool = True,
|
|
436
|
-
) -> str:
|
|
437
|
-
return _glob_impl(pattern, path, max_results, include_dirs, recursive, cwd=cwd, long_term_memory=long_term_memory)
|
|
438
|
-
|
|
439
|
-
grep_desc = (
|
|
440
|
-
(custom_tool_descriptions or {}).get("grep", GREP_DESCRIPTION)
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
@tool(description=grep_desc)
|
|
444
|
-
def grep(
|
|
445
|
-
pattern: str,
|
|
446
|
-
files: Optional[Union[str, list[str]]] = None,
|
|
447
|
-
path: Optional[str] = None,
|
|
448
|
-
file_pattern: str = "*",
|
|
449
|
-
max_results: int = 50,
|
|
450
|
-
case_sensitive: bool = False,
|
|
451
|
-
context_lines: int = 0,
|
|
452
|
-
regex: bool = False,
|
|
453
|
-
) -> str:
|
|
454
|
-
return _grep_impl(
|
|
455
|
-
pattern,
|
|
456
|
-
files=files,
|
|
457
|
-
path=path,
|
|
458
|
-
file_pattern=file_pattern,
|
|
459
|
-
max_results=max_results,
|
|
460
|
-
case_sensitive=case_sensitive,
|
|
461
|
-
context_lines=context_lines,
|
|
462
|
-
regex=regex,
|
|
463
|
-
cwd=cwd,
|
|
464
|
-
long_term_memory=long_term_memory,
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
return [ls, read_file, write_file, edit_file, glob, grep]
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
class LocalFilesystemMiddleware(AgentMiddleware):
|
|
471
|
-
"""Middleware that injects local filesystem tools into an agent.
|
|
472
|
-
|
|
473
|
-
Tools added:
|
|
474
|
-
- ls
|
|
475
|
-
- read_file
|
|
476
|
-
- write_file
|
|
477
|
-
- edit_file
|
|
478
|
-
- glob
|
|
479
|
-
- grep
|
|
480
|
-
|
|
481
|
-
Skills are automatically discovered from:
|
|
482
|
-
- ~/.deepagents/skills/ (personal skills)
|
|
483
|
-
- ./.deepagents/skills/ (project skills)
|
|
484
|
-
"""
|
|
485
|
-
|
|
486
|
-
state_schema = FilesystemState
|
|
487
|
-
|
|
488
|
-
def _check_ripgrep_installed(self) -> None:
|
|
489
|
-
"""Check if ripgrep (rg) is installed on the system.
|
|
490
|
-
|
|
491
|
-
Raises:
|
|
492
|
-
RuntimeError: If ripgrep is not found on the system.
|
|
493
|
-
"""
|
|
494
|
-
try:
|
|
495
|
-
subprocess.run(
|
|
496
|
-
["rg", "--version"],
|
|
497
|
-
capture_output=True,
|
|
498
|
-
timeout=5,
|
|
499
|
-
check=False,
|
|
500
|
-
)
|
|
501
|
-
except FileNotFoundError:
|
|
502
|
-
raise RuntimeError(
|
|
503
|
-
"ripgrep (rg) is not installed. The grep tool requires ripgrep to function. "
|
|
504
|
-
"Please install it from https://github.com/BurntSushi/ripgrep#installation"
|
|
505
|
-
)
|
|
506
|
-
except Exception as e:
|
|
507
|
-
raise RuntimeError(f"Error checking for ripgrep installation: {str(e)}")
|
|
508
|
-
|
|
509
|
-
def __init__(
|
|
510
|
-
self,
|
|
511
|
-
*,
|
|
512
|
-
system_prompt: str | None = None,
|
|
513
|
-
custom_tool_descriptions: dict[str, str] | None = None,
|
|
514
|
-
tool_token_limit_before_evict: int | None = 20000,
|
|
515
|
-
cwd: str | None = None,
|
|
516
|
-
long_term_memory: bool = False,
|
|
517
|
-
) -> None:
|
|
518
|
-
self.cwd = cwd or os.getcwd()
|
|
519
|
-
self.long_term_memory = long_term_memory
|
|
520
|
-
|
|
521
|
-
# Check if ripgrep is installed
|
|
522
|
-
self._check_ripgrep_installed()
|
|
523
|
-
|
|
524
|
-
# Discover skills from standard locations
|
|
525
|
-
self.skills = self._discover_skills()
|
|
526
|
-
|
|
527
|
-
# Build system prompt
|
|
528
|
-
cwd_prompt = f"\n\nCurrent working directory: {self.cwd}\n\nWhen using filesystem tools (ls, read_file, write_file, edit_file, glob, grep), relative paths will be resolved relative to this directory."
|
|
529
|
-
|
|
530
|
-
# Add long-term memory documentation if enabled
|
|
531
|
-
memory_prompt = ""
|
|
532
|
-
if long_term_memory:
|
|
533
|
-
memory_prompt = "\n\n## Long-term Memory\n\nYou can access long-term memory storage at /memories/. Files stored here persist across sessions and are saved to ~/.deepagents/<agent_name>/. You must use --agent <name> to enable this feature."
|
|
534
|
-
|
|
535
|
-
base_prompt = system_prompt or LOCAL_FILESYSTEM_SYSTEM_PROMPT
|
|
536
|
-
skills_prompt = self._build_skills_prompt()
|
|
537
|
-
|
|
538
|
-
self.system_prompt = base_prompt + cwd_prompt + memory_prompt + skills_prompt
|
|
539
|
-
self.tools = _get_local_filesystem_tools(custom_tool_descriptions, cwd=self.cwd, long_term_memory=long_term_memory)
|
|
540
|
-
self.tool_token_limit_before_evict = tool_token_limit_before_evict
|
|
541
|
-
|
|
542
|
-
def _discover_skills(self) -> list[dict[str, str]]:
|
|
543
|
-
"""Discover skills from standard filesystem locations.
|
|
544
|
-
|
|
545
|
-
Returns:
|
|
546
|
-
List of skill metadata dictionaries with keys: name, path, description, source.
|
|
547
|
-
"""
|
|
548
|
-
from deepagents.skills import parse_skill_frontmatter
|
|
549
|
-
|
|
550
|
-
discovered = {}
|
|
551
|
-
|
|
552
|
-
for base_path in STANDARD_SKILL_PATHS:
|
|
553
|
-
# Expand ~ to home directory
|
|
554
|
-
expanded_path = os.path.expanduser(base_path)
|
|
555
|
-
|
|
556
|
-
# Resolve relative paths against cwd
|
|
557
|
-
if not os.path.isabs(expanded_path):
|
|
558
|
-
expanded_path = os.path.join(self.cwd, expanded_path)
|
|
559
|
-
|
|
560
|
-
if not os.path.exists(expanded_path):
|
|
561
|
-
continue
|
|
562
|
-
|
|
563
|
-
# Find all SKILL.md files
|
|
564
|
-
try:
|
|
565
|
-
skill_files = _glob_impl(
|
|
566
|
-
pattern="**/SKILL.md",
|
|
567
|
-
path=expanded_path,
|
|
568
|
-
max_results=1000,
|
|
569
|
-
recursive=True,
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
# Parse the glob output (skip header line)
|
|
573
|
-
if "Found" not in skill_files:
|
|
574
|
-
continue
|
|
575
|
-
|
|
576
|
-
lines = skill_files.split('\n')
|
|
577
|
-
skill_paths = [line for line in lines[2:] if line.strip()] # Skip header and empty
|
|
578
|
-
|
|
579
|
-
for skill_path in skill_paths:
|
|
580
|
-
try:
|
|
581
|
-
# Read SKILL.md file
|
|
582
|
-
content = _read_file_impl(skill_path, cwd=None)
|
|
583
|
-
if content.startswith("Error:"):
|
|
584
|
-
continue
|
|
585
|
-
|
|
586
|
-
# Remove line numbers from cat -n format
|
|
587
|
-
content_lines = []
|
|
588
|
-
for line in content.split('\n'):
|
|
589
|
-
# Format is " 1\tcontent"
|
|
590
|
-
if '\t' in line:
|
|
591
|
-
content_lines.append(line.split('\t', 1)[1])
|
|
592
|
-
actual_content = '\n'.join(content_lines)
|
|
593
|
-
|
|
594
|
-
# Parse YAML frontmatter
|
|
595
|
-
frontmatter = parse_skill_frontmatter(actual_content)
|
|
596
|
-
if not frontmatter.get('name'):
|
|
597
|
-
continue
|
|
598
|
-
|
|
599
|
-
skill_name = frontmatter['name']
|
|
600
|
-
source = "project" if "./.deepagents" in base_path else "personal"
|
|
601
|
-
|
|
602
|
-
# Project skills override personal skills
|
|
603
|
-
discovered[skill_name] = {
|
|
604
|
-
"name": skill_name,
|
|
605
|
-
"path": skill_path,
|
|
606
|
-
"description": frontmatter.get('description', ''),
|
|
607
|
-
"version": frontmatter.get('version', ''),
|
|
608
|
-
"source": source,
|
|
609
|
-
}
|
|
610
|
-
except Exception:
|
|
611
|
-
# Skip skills that fail to parse
|
|
612
|
-
continue
|
|
613
|
-
|
|
614
|
-
except Exception:
|
|
615
|
-
# Skip paths that fail to glob
|
|
616
|
-
continue
|
|
617
|
-
|
|
618
|
-
return list(discovered.values())
|
|
619
|
-
|
|
620
|
-
def _build_skills_prompt(self) -> str:
|
|
621
|
-
"""Build the skills section of the system prompt.
|
|
622
|
-
|
|
623
|
-
Returns:
|
|
624
|
-
System prompt text describing available skills, or empty string if no skills.
|
|
625
|
-
"""
|
|
626
|
-
if not self.skills:
|
|
627
|
-
return ""
|
|
628
|
-
|
|
629
|
-
prompt = "\n\n## Available Skills\n\nYou have access to the following skills:"
|
|
630
|
-
|
|
631
|
-
for i, skill in enumerate(self.skills, 1):
|
|
632
|
-
prompt += f"\n\n{i}. **{skill['name']}** ({skill['path']})"
|
|
633
|
-
if skill['description']:
|
|
634
|
-
prompt += f"\n - {skill['description']}"
|
|
635
|
-
prompt += f"\n - Source: {skill['source']}"
|
|
636
|
-
|
|
637
|
-
prompt += "\n\nTo use a skill, read its SKILL.md file using `read_file`. Skills may contain additional resources in scripts/, references/, and assets/ subdirectories."
|
|
638
|
-
|
|
639
|
-
return prompt
|
|
640
|
-
|
|
641
|
-
def wrap_model_call(
|
|
642
|
-
self,
|
|
643
|
-
request: ModelRequest,
|
|
644
|
-
handler: Callable[[ModelRequest], ModelResponse],
|
|
645
|
-
) -> ModelResponse:
|
|
646
|
-
if self.system_prompt is not None:
|
|
647
|
-
request.system_prompt = (
|
|
648
|
-
request.system_prompt + "\n\n" + self.system_prompt
|
|
649
|
-
if request.system_prompt
|
|
650
|
-
else self.system_prompt
|
|
651
|
-
)
|
|
652
|
-
return handler(request)
|
|
653
|
-
|
|
654
|
-
async def awrap_model_call(
|
|
655
|
-
self,
|
|
656
|
-
request: ModelRequest,
|
|
657
|
-
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
658
|
-
) -> ModelResponse:
|
|
659
|
-
if self.system_prompt is not None:
|
|
660
|
-
request.system_prompt = (
|
|
661
|
-
request.system_prompt + "\n\n" + self.system_prompt
|
|
662
|
-
if request.system_prompt
|
|
663
|
-
else self.system_prompt
|
|
664
|
-
)
|
|
665
|
-
return await handler(request)
|
|
666
|
-
|
|
667
|
-
# --------- Token-eviction to filesystem state (like FilesystemMiddleware) ---------
|
|
668
|
-
def _intercept_large_tool_result(self, tool_result: ToolMessage | Command) -> ToolMessage | Command:
|
|
669
|
-
if isinstance(tool_result, ToolMessage) and isinstance(tool_result.content, str):
|
|
670
|
-
content = tool_result.content
|
|
671
|
-
if self.tool_token_limit_before_evict and len(content) > 4 * self.tool_token_limit_before_evict:
|
|
672
|
-
file_path = f"/large_tool_results/{tool_result.tool_call_id}"
|
|
673
|
-
file_data = _create_file_data(content)
|
|
674
|
-
state_update = {
|
|
675
|
-
"messages": [
|
|
676
|
-
ToolMessage(
|
|
677
|
-
TOO_LARGE_TOOL_MSG.format(
|
|
678
|
-
tool_call_id=tool_result.tool_call_id,
|
|
679
|
-
file_path=file_path,
|
|
680
|
-
content_sample=_format_content_with_line_numbers(
|
|
681
|
-
file_data["content"][:10], format_style="tab", start_line=1
|
|
682
|
-
),
|
|
683
|
-
),
|
|
684
|
-
tool_call_id=tool_result.tool_call_id,
|
|
685
|
-
)
|
|
686
|
-
],
|
|
687
|
-
"files": {file_path: file_data},
|
|
688
|
-
}
|
|
689
|
-
return Command(update=state_update)
|
|
690
|
-
elif isinstance(tool_result, Command):
|
|
691
|
-
update = tool_result.update
|
|
692
|
-
if update is None:
|
|
693
|
-
return tool_result
|
|
694
|
-
message_updates = update.get("messages", [])
|
|
695
|
-
file_updates = update.get("files", {})
|
|
696
|
-
|
|
697
|
-
edited_message_updates = []
|
|
698
|
-
for message in message_updates:
|
|
699
|
-
if self.tool_token_limit_before_evict and isinstance(message, ToolMessage) and isinstance(message.content, str):
|
|
700
|
-
content = message.content
|
|
701
|
-
if len(content) > 4 * self.tool_token_limit_before_evict:
|
|
702
|
-
file_path = f"/large_tool_results/{message.tool_call_id}"
|
|
703
|
-
file_data = _create_file_data(content)
|
|
704
|
-
edited_message_updates.append(
|
|
705
|
-
ToolMessage(
|
|
706
|
-
TOO_LARGE_TOOL_MSG.format(
|
|
707
|
-
tool_call_id=message.tool_call_id,
|
|
708
|
-
file_path=file_path,
|
|
709
|
-
content_sample=_format_content_with_line_numbers(
|
|
710
|
-
file_data["content"][:10], format_style="tab", start_line=1
|
|
711
|
-
),
|
|
712
|
-
),
|
|
713
|
-
tool_call_id=message.tool_call_id,
|
|
714
|
-
)
|
|
715
|
-
)
|
|
716
|
-
file_updates[file_path] = file_data
|
|
717
|
-
continue
|
|
718
|
-
edited_message_updates.append(message)
|
|
719
|
-
return Command(update={**update, "messages": edited_message_updates, "files": file_updates})
|
|
720
|
-
return tool_result
|
|
721
|
-
|
|
722
|
-
def wrap_tool_call(
|
|
723
|
-
self,
|
|
724
|
-
request: ToolCallRequest,
|
|
725
|
-
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
|
726
|
-
) -> ToolMessage | Command:
|
|
727
|
-
# Skip eviction for local filesystem tools
|
|
728
|
-
if self.tool_token_limit_before_evict is None or request.tool_call["name"] in {"ls", "read_file", "write_file", "edit_file", "glob", "grep"}:
|
|
729
|
-
return handler(request)
|
|
730
|
-
tool_result = handler(request)
|
|
731
|
-
return self._intercept_large_tool_result(tool_result)
|
|
732
|
-
|
|
733
|
-
async def awrap_tool_call(
|
|
734
|
-
self,
|
|
735
|
-
request: ToolCallRequest,
|
|
736
|
-
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
|
737
|
-
) -> ToolMessage | Command:
|
|
738
|
-
if self.tool_token_limit_before_evict is None or request.tool_call["name"] in {"ls", "read_file", "write_file", "edit_file", "glob", "grep"}:
|
|
739
|
-
return await handler(request)
|
|
740
|
-
tool_result = await handler(request)
|
|
741
|
-
return self._intercept_large_tool_result(tool_result)
|