deepagents 0.1.4__py3-none-any.whl → 0.1.5rc2__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.
@@ -0,0 +1,424 @@
1
+ """Shared utility functions for memory backend implementations.
2
+
3
+ This module contains both user-facing string formatters and structured
4
+ helpers used by backends and the composite router. Structured helpers
5
+ enable composition without fragile string parsing.
6
+ """
7
+
8
+ import re
9
+ import wcmatch.glob as wcglob
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any, Literal, TypedDict, List, Dict
13
+
14
+ EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
15
+ MAX_LINE_LENGTH = 2000
16
+ LINE_NUMBER_WIDTH = 6
17
+ TOOL_RESULT_TOKEN_LIMIT = 20000 # Same threshold as eviction
18
+ TRUNCATION_GUIDANCE = "... [results truncated, try being more specific with your parameters]"
19
+
20
+
21
+ class FileInfo(TypedDict, total=False):
22
+ """Structured file listing info.
23
+
24
+ Minimal contract used across backends. Only "path" is required.
25
+ Other fields are best-effort and may be absent depending on backend.
26
+ """
27
+ path: str
28
+ is_dir: bool
29
+ size: int # bytes (approx)
30
+ modified_at: str # ISO timestamp if known
31
+
32
+
33
+ class GrepMatch(TypedDict):
34
+ """Structured grep match entry."""
35
+ path: str
36
+ line: int
37
+ text: str
38
+
39
+
40
+ def format_content_with_line_numbers(
41
+ content: str | list[str],
42
+ start_line: int = 1,
43
+ ) -> str:
44
+ """Format file content with line numbers (cat -n style).
45
+
46
+ Args:
47
+ content: File content as string or list of lines
48
+ start_line: Starting line number (default: 1)
49
+
50
+ Returns:
51
+ Formatted content with line numbers
52
+ """
53
+ if isinstance(content, str):
54
+ lines = content.split("\n")
55
+ if lines and lines[-1] == "":
56
+ lines = lines[:-1]
57
+ else:
58
+ lines = content
59
+
60
+ return "\n".join(
61
+ f"{i + start_line:{LINE_NUMBER_WIDTH}d}\t{line[:MAX_LINE_LENGTH]}"
62
+ for i, line in enumerate(lines)
63
+ )
64
+
65
+
66
+ def check_empty_content(content: str) -> str | None:
67
+ """Check if content is empty and return warning message.
68
+
69
+ Args:
70
+ content: Content to check
71
+
72
+ Returns:
73
+ Warning message if empty, None otherwise
74
+ """
75
+ if not content or content.strip() == "":
76
+ return EMPTY_CONTENT_WARNING
77
+ return None
78
+
79
+
80
+ def file_data_to_string(file_data: dict[str, Any]) -> str:
81
+ """Convert FileData to plain string content.
82
+
83
+ Args:
84
+ file_data: FileData dict with 'content' key
85
+
86
+ Returns:
87
+ Content as string with lines joined by newlines
88
+ """
89
+ return "\n".join(file_data["content"])
90
+
91
+
92
+ def create_file_data(content: str, created_at: str | None = None) -> dict[str, Any]:
93
+ """Create a FileData object with timestamps.
94
+
95
+ Args:
96
+ content: File content as string
97
+ created_at: Optional creation timestamp (ISO format)
98
+
99
+ Returns:
100
+ FileData dict with content and timestamps
101
+ """
102
+ lines = content.split("\n") if isinstance(content, str) else content
103
+ lines = [line[i:i+MAX_LINE_LENGTH] for line in lines for i in range(0, len(line) or 1, MAX_LINE_LENGTH)]
104
+ now = datetime.now(UTC).isoformat()
105
+
106
+ return {
107
+ "content": lines,
108
+ "created_at": created_at or now,
109
+ "modified_at": now,
110
+ }
111
+
112
+
113
+ def update_file_data(file_data: dict[str, Any], content: str) -> dict[str, Any]:
114
+ """Update FileData with new content, preserving creation timestamp.
115
+
116
+ Args:
117
+ file_data: Existing FileData dict
118
+ content: New content as string
119
+
120
+ Returns:
121
+ Updated FileData dict
122
+ """
123
+ lines = content.split("\n") if isinstance(content, str) else content
124
+ lines = [line[i:i+MAX_LINE_LENGTH] for line in lines for i in range(0, len(line) or 1, MAX_LINE_LENGTH)]
125
+ now = datetime.now(UTC).isoformat()
126
+
127
+ return {
128
+ "content": lines,
129
+ "created_at": file_data["created_at"],
130
+ "modified_at": now,
131
+ }
132
+
133
+
134
+ def format_read_response(
135
+ file_data: dict[str, Any],
136
+ offset: int,
137
+ limit: int,
138
+ ) -> str:
139
+ """Format file data for read response with line numbers.
140
+
141
+ Args:
142
+ file_data: FileData dict
143
+ offset: Line offset (0-indexed)
144
+ limit: Maximum number of lines
145
+
146
+ Returns:
147
+ Formatted content or error message
148
+ """
149
+ content = file_data_to_string(file_data)
150
+ empty_msg = check_empty_content(content)
151
+ if empty_msg:
152
+ return empty_msg
153
+
154
+ lines = content.splitlines()
155
+ start_idx = offset
156
+ end_idx = min(start_idx + limit, len(lines))
157
+
158
+ if start_idx >= len(lines):
159
+ return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)"
160
+
161
+ selected_lines = lines[start_idx:end_idx]
162
+ return format_content_with_line_numbers(selected_lines, start_line=start_idx + 1)
163
+
164
+
165
+ def perform_string_replacement(
166
+ content: str,
167
+ old_string: str,
168
+ new_string: str,
169
+ replace_all: bool,
170
+ ) -> tuple[str, int] | str:
171
+ """Perform string replacement with occurrence validation.
172
+
173
+ Args:
174
+ content: Original content
175
+ old_string: String to replace
176
+ new_string: Replacement string
177
+ replace_all: Whether to replace all occurrences
178
+
179
+ Returns:
180
+ Tuple of (new_content, occurrences) on success, or error message string
181
+ """
182
+ occurrences = content.count(old_string)
183
+
184
+ if occurrences == 0:
185
+ return f"Error: String not found in file: '{old_string}'"
186
+
187
+ if occurrences > 1 and not replace_all:
188
+ return f"Error: String '{old_string}' appears {occurrences} times in file. Use replace_all=True to replace all instances, or provide a more specific string with surrounding context."
189
+
190
+ new_content = content.replace(old_string, new_string)
191
+ return new_content, occurrences
192
+
193
+
194
+ def truncate_if_too_long(result: list[str] | str) -> list[str] | str:
195
+ """Truncate list or string result if it exceeds token limit (rough estimate: 4 chars/token)."""
196
+ if isinstance(result, list):
197
+ total_chars = sum(len(item) for item in result)
198
+ if total_chars > TOOL_RESULT_TOKEN_LIMIT * 4:
199
+ return result[: len(result) * TOOL_RESULT_TOKEN_LIMIT * 4 // total_chars] + [TRUNCATION_GUIDANCE]
200
+ return result
201
+ else: # string
202
+ if len(result) > TOOL_RESULT_TOKEN_LIMIT * 4:
203
+ return result[: TOOL_RESULT_TOKEN_LIMIT * 4] + "\n" + TRUNCATION_GUIDANCE
204
+ return result
205
+
206
+
207
+ def _validate_path(path: str | None) -> str:
208
+ """Validate and normalize a path.
209
+
210
+ Args:
211
+ path: Path to validate
212
+
213
+ Returns:
214
+ Normalized path starting with /
215
+
216
+ Raises:
217
+ ValueError: If path is invalid
218
+ """
219
+ path = path or "/"
220
+ if not path or path.strip() == "":
221
+ raise ValueError("Path cannot be empty")
222
+
223
+ normalized = path if path.startswith("/") else "/" + path
224
+
225
+ if not normalized.endswith("/"):
226
+ normalized += "/"
227
+
228
+ return normalized
229
+
230
+
231
+ def _glob_search_files(
232
+ files: dict[str, Any],
233
+ pattern: str,
234
+ path: str = "/",
235
+ ) -> str:
236
+ """Search files dict for paths matching glob pattern.
237
+
238
+ Args:
239
+ files: Dictionary of file paths to FileData.
240
+ pattern: Glob pattern (e.g., "*.py", "**/*.ts").
241
+ path: Base path to search from.
242
+
243
+ Returns:
244
+ Newline-separated file paths, sorted by modification time (most recent first).
245
+ Returns "No files found" if no matches.
246
+
247
+ Example:
248
+ ```python
249
+ files = {"/src/main.py": FileData(...), "/test.py": FileData(...)}
250
+ _glob_search_files(files, "*.py", "/")
251
+ # Returns: "/test.py\n/src/main.py" (sorted by modified_at)
252
+ ```
253
+ """
254
+ try:
255
+ normalized_path = _validate_path(path)
256
+ except ValueError:
257
+ return "No files found"
258
+
259
+ filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
260
+
261
+ # Respect standard glob semantics:
262
+ # - Patterns without path separators (e.g., "*.py") match only in the current
263
+ # directory (non-recursive) relative to `path`.
264
+ # - Use "**" explicitly for recursive matching.
265
+ effective_pattern = pattern
266
+
267
+ matches = []
268
+ for file_path, file_data in filtered.items():
269
+ relative = file_path[len(normalized_path) :].lstrip("/")
270
+ if not relative:
271
+ relative = file_path.split("/")[-1]
272
+
273
+ if wcglob.globmatch(relative, effective_pattern, flags=wcglob.BRACE | wcglob.GLOBSTAR):
274
+ matches.append((file_path, file_data["modified_at"]))
275
+
276
+ matches.sort(key=lambda x: x[1], reverse=True)
277
+
278
+ if not matches:
279
+ return "No files found"
280
+
281
+ return "\n".join(fp for fp, _ in matches)
282
+
283
+
284
+ def _format_grep_results(
285
+ results: dict[str, list[tuple[int, str]]],
286
+ output_mode: Literal["files_with_matches", "content", "count"],
287
+ ) -> str:
288
+ """Format grep search results based on output mode.
289
+
290
+ Args:
291
+ results: Dictionary mapping file paths to list of (line_num, line_content) tuples
292
+ output_mode: Output format - "files_with_matches", "content", or "count"
293
+
294
+ Returns:
295
+ Formatted string output
296
+ """
297
+ if output_mode == "files_with_matches":
298
+ return "\n".join(sorted(results.keys()))
299
+ elif output_mode == "count":
300
+ lines = []
301
+ for file_path in sorted(results.keys()):
302
+ count = len(results[file_path])
303
+ lines.append(f"{file_path}: {count}")
304
+ return "\n".join(lines)
305
+ else:
306
+ lines = []
307
+ for file_path in sorted(results.keys()):
308
+ lines.append(f"{file_path}:")
309
+ for line_num, line in results[file_path]:
310
+ lines.append(f" {line_num}: {line}")
311
+ return "\n".join(lines)
312
+
313
+
314
+ def _grep_search_files(
315
+ files: dict[str, Any],
316
+ pattern: str,
317
+ path: str | None = None,
318
+ glob: str | None = None,
319
+ output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
320
+ ) -> str:
321
+ """Search file contents for regex pattern.
322
+
323
+ Args:
324
+ files: Dictionary of file paths to FileData.
325
+ pattern: Regex pattern to search for.
326
+ path: Base path to search from.
327
+ glob: Optional glob pattern to filter files (e.g., "*.py").
328
+ output_mode: Output format - "files_with_matches", "content", or "count".
329
+
330
+ Returns:
331
+ Formatted search results. Returns "No matches found" if no results.
332
+
333
+ Example:
334
+ ```python
335
+ files = {"/file.py": FileData(content=["import os", "print('hi')"], ...)}
336
+ _grep_search_files(files, "import", "/")
337
+ # Returns: "/file.py" (with output_mode="files_with_matches")
338
+ ```
339
+ """
340
+ try:
341
+ regex = re.compile(pattern)
342
+ except re.error as e:
343
+ return f"Invalid regex pattern: {e}"
344
+
345
+ try:
346
+ normalized_path = _validate_path(path)
347
+ except ValueError:
348
+ return "No matches found"
349
+
350
+ filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
351
+
352
+ if glob:
353
+ filtered = {fp: fd for fp, fd in filtered.items() if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)}
354
+
355
+ results: dict[str, list[tuple[int, str]]] = {}
356
+ for file_path, file_data in filtered.items():
357
+ for line_num, line in enumerate(file_data["content"], 1):
358
+ if regex.search(line):
359
+ if file_path not in results:
360
+ results[file_path] = []
361
+ results[file_path].append((line_num, line))
362
+
363
+ if not results:
364
+ return "No matches found"
365
+ return _format_grep_results(results, output_mode)
366
+
367
+
368
+ # -------- Structured helpers for composition --------
369
+
370
+ def grep_matches_from_files(
371
+ files: dict[str, Any],
372
+ pattern: str,
373
+ path: str | None = None,
374
+ glob: str | None = None,
375
+ ) -> list[GrepMatch] | str:
376
+ """Return structured grep matches from an in-memory files mapping.
377
+
378
+ Returns a list of GrepMatch on success, or a string for invalid inputs
379
+ (e.g., invalid regex). We deliberately do not raise here to keep backends
380
+ non-throwing in tool contexts and preserve user-facing error messages.
381
+ """
382
+ try:
383
+ regex = re.compile(pattern)
384
+ except re.error as e:
385
+ return f"Invalid regex pattern: {e}"
386
+
387
+ try:
388
+ normalized_path = _validate_path(path)
389
+ except ValueError:
390
+ return []
391
+
392
+ filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
393
+
394
+ if glob:
395
+ filtered = {
396
+ fp: fd
397
+ for fp, fd in filtered.items()
398
+ if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)
399
+ }
400
+
401
+ matches: list[GrepMatch] = []
402
+ for file_path, file_data in filtered.items():
403
+ for line_num, line in enumerate(file_data["content"], 1):
404
+ if regex.search(line):
405
+ matches.append({"path": file_path, "line": int(line_num), "text": line})
406
+ return matches
407
+
408
+
409
+ def build_grep_results_dict(matches: List[GrepMatch]) -> Dict[str, list[tuple[int, str]]]:
410
+ """Group structured matches into the legacy dict form used by formatters."""
411
+ grouped: Dict[str, list[tuple[int, str]]] = {}
412
+ for m in matches:
413
+ grouped.setdefault(m["path"], []).append((m["line"], m["text"]))
414
+ return grouped
415
+
416
+
417
+ def format_grep_matches(
418
+ matches: List[GrepMatch],
419
+ output_mode: Literal["files_with_matches", "content", "count"],
420
+ ) -> str:
421
+ """Format structured grep matches using existing formatting logic."""
422
+ if not matches:
423
+ return "No matches found"
424
+ return _format_grep_results(build_grep_results_dict(matches), output_mode)
deepagents/graph.py CHANGED
@@ -17,6 +17,7 @@ from langgraph.graph.state import CompiledStateGraph
17
17
  from langgraph.store.base import BaseStore
18
18
  from langgraph.types import Checkpointer
19
19
 
20
+ from deepagents.backends.protocol import BackendProtocol, BackendFactory
20
21
  from deepagents.middleware.filesystem import FilesystemMiddleware
21
22
  from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
22
23
  from deepagents.middleware.subagents import CompiledSubAgent, SubAgent, SubAgentMiddleware
@@ -47,7 +48,7 @@ def create_deep_agent(
47
48
  context_schema: type[Any] | None = None,
48
49
  checkpointer: Checkpointer | None = None,
49
50
  store: BaseStore | None = None,
50
- use_longterm_memory: bool = False,
51
+ backend: BackendProtocol | BackendFactory | None = None,
51
52
  interrupt_on: dict[str, bool | InterruptOnConfig] | None = None,
52
53
  debug: bool = False,
53
54
  name: str | None = None,
@@ -56,15 +57,15 @@ def create_deep_agent(
56
57
  """Create a deep agent.
57
58
 
58
59
  This agent will by default have access to a tool to write todos (write_todos),
59
- four file editing tools: write_file, ls, read_file, edit_file, and a tool to call
60
- subagents.
60
+ six file editing tools: write_file, ls, read_file, edit_file, glob_search, grep_search,
61
+ and a tool to call subagents.
61
62
 
62
63
  Args:
64
+ model: The model to use. Defaults to Claude Sonnet 4.
63
65
  tools: The tools the agent should have access to.
64
66
  system_prompt: The additional instructions the agent should have. Will go in
65
67
  the system prompt.
66
68
  middleware: Additional middleware to apply after standard middleware.
67
- model: The model to use.
68
69
  subagents: The subagents to use. Each subagent should be a dictionary with the
69
70
  following keys:
70
71
  - `name`
@@ -78,9 +79,9 @@ def create_deep_agent(
78
79
  response_format: A structured output response format to use for the agent.
79
80
  context_schema: The schema of the deep agent.
80
81
  checkpointer: Optional checkpointer for persisting agent state between runs.
81
- store: Optional store for persisting longterm memories.
82
- use_longterm_memory: Whether to use longterm memory - you must provide a store
83
- in order to use longterm memory.
82
+ store: Optional store for persistent storage (required if backend uses StoreBackend).
83
+ backend: Optional backend for file storage. Pass either a Backend instance or a
84
+ callable factory like `lambda rt: StateBackend(rt)`.
84
85
  interrupt_on: Optional Dict[str, bool | InterruptOnConfig] mapping tool names to
85
86
  interrupt configs.
86
87
  debug: Whether to enable debug mode. Passed through to create_agent.
@@ -95,18 +96,14 @@ def create_deep_agent(
95
96
 
96
97
  deepagent_middleware = [
97
98
  TodoListMiddleware(),
98
- FilesystemMiddleware(
99
- long_term_memory=use_longterm_memory,
100
- ),
99
+ FilesystemMiddleware(backend=backend),
101
100
  SubAgentMiddleware(
102
101
  default_model=model,
103
102
  default_tools=tools,
104
103
  subagents=subagents if subagents is not None else [],
105
104
  default_middleware=[
106
105
  TodoListMiddleware(),
107
- FilesystemMiddleware(
108
- long_term_memory=use_longterm_memory,
109
- ),
106
+ FilesystemMiddleware(backend=backend),
110
107
  SummarizationMiddleware(
111
108
  model=model,
112
109
  max_tokens_before_summary=170000,