deepagents 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. deepagents/backends/composite.py +37 -2
  2. deepagents/backends/protocol.py +48 -0
  3. deepagents/backends/sandbox.py +341 -0
  4. deepagents/backends/store.py +3 -11
  5. deepagents/graph.py +7 -3
  6. deepagents/middleware/__init__.py +0 -2
  7. deepagents/middleware/filesystem.py +224 -21
  8. deepagents/middleware/subagents.py +7 -4
  9. {deepagents-0.2.5.dist-info → deepagents-0.2.7.dist-info}/METADATA +5 -4
  10. deepagents-0.2.7.dist-info/RECORD +18 -0
  11. deepagents-0.2.7.dist-info/top_level.txt +1 -0
  12. deepagents/middleware/resumable_shell.py +0 -86
  13. deepagents-0.2.5.dist-info/RECORD +0 -38
  14. deepagents-0.2.5.dist-info/licenses/LICENSE +0 -21
  15. deepagents-0.2.5.dist-info/top_level.txt +0 -2
  16. deepagents-cli/README.md +0 -3
  17. deepagents-cli/deepagents_cli/README.md +0 -196
  18. deepagents-cli/deepagents_cli/__init__.py +0 -5
  19. deepagents-cli/deepagents_cli/__main__.py +0 -6
  20. deepagents-cli/deepagents_cli/agent.py +0 -278
  21. deepagents-cli/deepagents_cli/agent_memory.py +0 -226
  22. deepagents-cli/deepagents_cli/commands.py +0 -89
  23. deepagents-cli/deepagents_cli/config.py +0 -118
  24. deepagents-cli/deepagents_cli/default_agent_prompt.md +0 -110
  25. deepagents-cli/deepagents_cli/execution.py +0 -636
  26. deepagents-cli/deepagents_cli/file_ops.py +0 -347
  27. deepagents-cli/deepagents_cli/input.py +0 -270
  28. deepagents-cli/deepagents_cli/main.py +0 -226
  29. deepagents-cli/deepagents_cli/py.typed +0 -0
  30. deepagents-cli/deepagents_cli/token_utils.py +0 -63
  31. deepagents-cli/deepagents_cli/tools.py +0 -140
  32. deepagents-cli/deepagents_cli/ui.py +0 -489
  33. deepagents-cli/tests/test_file_ops.py +0 -119
  34. deepagents-cli/tests/test_placeholder.py +0 -5
  35. {deepagents-0.2.5.dist-info → deepagents-0.2.7.dist-info}/WHEEL +0 -0
@@ -1,347 +0,0 @@
1
- """Helpers for tracking file operations and computing diffs for CLI display."""
2
-
3
- from __future__ import annotations
4
-
5
- import difflib
6
- from dataclasses import dataclass, field
7
- from pathlib import Path
8
- from typing import Any, Literal
9
-
10
- from deepagents.backends.utils import perform_string_replacement
11
-
12
- FileOpStatus = Literal["pending", "success", "error"]
13
-
14
-
15
- @dataclass
16
- class ApprovalPreview:
17
- """Data used to render HITL previews."""
18
-
19
- title: str
20
- details: list[str]
21
- diff: str | None = None
22
- diff_title: str | None = None
23
- error: str | None = None
24
-
25
-
26
- def _safe_read(path: Path) -> str | None:
27
- """Read file content, returning None on failure."""
28
- try:
29
- return path.read_text()
30
- except (OSError, UnicodeDecodeError):
31
- return None
32
-
33
-
34
- def _count_lines(text: str) -> int:
35
- """Count lines in text, treating empty strings as zero lines."""
36
- if not text:
37
- return 0
38
- return len(text.splitlines())
39
-
40
-
41
- def compute_unified_diff(
42
- before: str,
43
- after: str,
44
- display_path: str,
45
- *,
46
- max_lines: int | None = 800,
47
- ) -> str | None:
48
- """Compute a unified diff between before and after content."""
49
- before_lines = before.splitlines()
50
- after_lines = after.splitlines()
51
- diff_lines = list(
52
- difflib.unified_diff(
53
- before_lines,
54
- after_lines,
55
- fromfile=f"{display_path} (before)",
56
- tofile=f"{display_path} (after)",
57
- lineterm="",
58
- )
59
- )
60
- if not diff_lines:
61
- return None
62
- if max_lines is not None and len(diff_lines) > max_lines:
63
- truncated = diff_lines[: max_lines - 1]
64
- truncated.append("... (diff truncated)")
65
- return "\n".join(truncated)
66
- return "\n".join(diff_lines)
67
-
68
-
69
- @dataclass
70
- class FileOpMetrics:
71
- """Line and byte level metrics for a file operation."""
72
-
73
- lines_read: int = 0
74
- start_line: int | None = None
75
- end_line: int | None = None
76
- lines_written: int = 0
77
- lines_added: int = 0
78
- lines_removed: int = 0
79
- bytes_written: int = 0
80
-
81
-
82
- @dataclass
83
- class FileOperationRecord:
84
- """Track a single filesystem tool call."""
85
-
86
- tool_name: str
87
- display_path: str
88
- physical_path: Path | None
89
- tool_call_id: str | None
90
- args: dict[str, Any] = field(default_factory=dict)
91
- status: FileOpStatus = "pending"
92
- error: str | None = None
93
- metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
94
- diff: str | None = None
95
- before_content: str | None = None
96
- after_content: str | None = None
97
- read_output: str | None = None
98
-
99
-
100
- def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Path | None:
101
- """Convert a virtual/relative path to a physical filesystem path."""
102
- if not path_str:
103
- return None
104
- try:
105
- if assistant_id and path_str.startswith("/memories/"):
106
- agent_dir = Path.home() / ".deepagents" / assistant_id
107
- suffix = path_str.removeprefix("/memories/").lstrip("/")
108
- return (agent_dir / suffix).resolve()
109
- path = Path(path_str)
110
- if path.is_absolute():
111
- return path
112
- return (Path.cwd() / path).resolve()
113
- except (OSError, ValueError):
114
- return None
115
-
116
-
117
- def format_display_path(path_str: str | None) -> str:
118
- """Format a path for display."""
119
- if not path_str:
120
- return "(unknown)"
121
- try:
122
- path = Path(path_str)
123
- if path.is_absolute():
124
- return path.name or str(path)
125
- return str(path)
126
- except (OSError, ValueError):
127
- return str(path_str)
128
-
129
-
130
- def build_approval_preview(
131
- tool_name: str,
132
- args: dict[str, Any] | None,
133
- assistant_id: str | None,
134
- ) -> ApprovalPreview | None:
135
- """Collect summary info and diff for HITL approvals."""
136
- if args is None:
137
- return None
138
-
139
- path_str = str(args.get("file_path") or args.get("path") or "")
140
- display_path = format_display_path(path_str)
141
- physical_path = resolve_physical_path(path_str, assistant_id)
142
-
143
- if tool_name == "write_file":
144
- content = str(args.get("content", ""))
145
- before = _safe_read(physical_path) if physical_path and physical_path.exists() else ""
146
- after = content
147
- diff = compute_unified_diff(before or "", after, display_path, max_lines=None)
148
- additions = 0
149
- if diff:
150
- additions = sum(
151
- 1
152
- for line in diff.splitlines()
153
- if line.startswith("+") and not line.startswith("+++")
154
- )
155
- total_lines = _count_lines(after)
156
- details = [
157
- f"File: {path_str}",
158
- "Action: Create new file" + (" (overwrites existing content)" if before else ""),
159
- f"Lines to write: {additions or total_lines}",
160
- f"Bytes to write: {len(after.encode('utf-8'))}",
161
- ]
162
- return ApprovalPreview(
163
- title=f"Write {display_path}",
164
- details=details,
165
- diff=diff,
166
- diff_title=f"Diff {display_path}",
167
- )
168
-
169
- if tool_name == "edit_file":
170
- if physical_path is None:
171
- return ApprovalPreview(
172
- title=f"Update {display_path}",
173
- details=[f"File: {path_str}", "Action: Replace text"],
174
- error="Unable to resolve file path.",
175
- )
176
- before = _safe_read(physical_path)
177
- if before is None:
178
- return ApprovalPreview(
179
- title=f"Update {display_path}",
180
- details=[f"File: {path_str}", "Action: Replace text"],
181
- error="Unable to read current file contents.",
182
- )
183
- old_string = str(args.get("old_string", ""))
184
- new_string = str(args.get("new_string", ""))
185
- replace_all = bool(args.get("replace_all", False))
186
- replacement = perform_string_replacement(before, old_string, new_string, replace_all)
187
- if isinstance(replacement, str):
188
- return ApprovalPreview(
189
- title=f"Update {display_path}",
190
- details=[f"File: {path_str}", "Action: Replace text"],
191
- error=replacement,
192
- )
193
- after, occurrences = replacement
194
- diff = compute_unified_diff(before, after, display_path, max_lines=None)
195
- additions = 0
196
- deletions = 0
197
- if diff:
198
- additions = sum(
199
- 1
200
- for line in diff.splitlines()
201
- if line.startswith("+") and not line.startswith("+++")
202
- )
203
- deletions = sum(
204
- 1
205
- for line in diff.splitlines()
206
- if line.startswith("-") and not line.startswith("---")
207
- )
208
- details = [
209
- f"File: {path_str}",
210
- f"Action: Replace text ({'all occurrences' if replace_all else 'single occurrence'})",
211
- f"Occurrences matched: {occurrences}",
212
- f"Lines changed: +{additions} / -{deletions}",
213
- ]
214
- return ApprovalPreview(
215
- title=f"Update {display_path}",
216
- details=details,
217
- diff=diff,
218
- diff_title=f"Diff {display_path}",
219
- )
220
-
221
- return None
222
-
223
-
224
- class FileOpTracker:
225
- """Collect file operation metrics during a CLI interaction."""
226
-
227
- def __init__(self, *, assistant_id: str | None) -> None:
228
- self.assistant_id = assistant_id
229
- self.active: dict[str | None, FileOperationRecord] = {}
230
- self.completed: list[FileOperationRecord] = []
231
-
232
- def start_operation(
233
- self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
234
- ) -> None:
235
- if tool_name not in {"read_file", "write_file", "edit_file"}:
236
- return
237
- path_str = str(args.get("file_path") or args.get("path") or "")
238
- display_path = format_display_path(path_str)
239
- record = FileOperationRecord(
240
- tool_name=tool_name,
241
- display_path=display_path,
242
- physical_path=resolve_physical_path(path_str, self.assistant_id),
243
- tool_call_id=tool_call_id,
244
- args=args,
245
- )
246
- if tool_name in {"write_file", "edit_file"} and record.physical_path:
247
- record.before_content = _safe_read(record.physical_path) or ""
248
- self.active[tool_call_id] = record
249
-
250
- def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None:
251
- tool_call_id = getattr(tool_message, "tool_call_id", None)
252
- record = self.active.get(tool_call_id)
253
- if record is None:
254
- return None
255
-
256
- content = tool_message.content
257
- if isinstance(content, list):
258
- # Some tool messages may return list segments; join them for analysis.
259
- joined = []
260
- for item in content:
261
- if isinstance(item, str):
262
- joined.append(item)
263
- else:
264
- joined.append(str(item))
265
- content_text = "\n".join(joined)
266
- else:
267
- content_text = str(content) if content is not None else ""
268
-
269
- if getattr(
270
- tool_message, "status", "success"
271
- ) != "success" or content_text.lower().startswith("error"):
272
- record.status = "error"
273
- record.error = content_text
274
- self._finalize(record)
275
- return record
276
-
277
- record.status = "success"
278
-
279
- if record.tool_name == "read_file":
280
- record.read_output = content_text
281
- lines = _count_lines(content_text)
282
- record.metrics.lines_read = lines
283
- offset = record.args.get("offset")
284
- limit = record.args.get("limit")
285
- if isinstance(offset, int):
286
- record.metrics.start_line = offset + 1
287
- if lines:
288
- record.metrics.end_line = offset + lines
289
- elif lines:
290
- record.metrics.start_line = 1
291
- record.metrics.end_line = lines
292
- if isinstance(limit, int) and lines > limit:
293
- record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
294
- else:
295
- self._populate_after_content(record)
296
- if record.after_content is None:
297
- record.status = "error"
298
- record.error = "Could not read updated file content."
299
- self._finalize(record)
300
- return record
301
- record.metrics.lines_written = _count_lines(record.after_content)
302
- before_lines = _count_lines(record.before_content or "")
303
- diff = compute_unified_diff(
304
- record.before_content or "",
305
- record.after_content,
306
- record.display_path,
307
- max_lines=None,
308
- )
309
- record.diff = diff
310
- if diff:
311
- additions = sum(
312
- 1
313
- for line in diff.splitlines()
314
- if line.startswith("+") and not line.startswith("+++")
315
- )
316
- deletions = sum(
317
- 1
318
- for line in diff.splitlines()
319
- if line.startswith("-") and not line.startswith("---")
320
- )
321
- record.metrics.lines_added = additions
322
- record.metrics.lines_removed = deletions
323
- elif record.tool_name == "write_file" and (record.before_content or "") == "":
324
- record.metrics.lines_added = record.metrics.lines_written
325
- record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
326
- if record.diff is None and (record.before_content or "") != record.after_content:
327
- record.diff = compute_unified_diff(
328
- record.before_content or "",
329
- record.after_content,
330
- record.display_path,
331
- max_lines=None,
332
- )
333
- if record.diff is None and before_lines != record.metrics.lines_written:
334
- record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
335
-
336
- self._finalize(record)
337
- return record
338
-
339
- def _populate_after_content(self, record: FileOperationRecord) -> None:
340
- if record.physical_path is None:
341
- record.after_content = None
342
- return
343
- record.after_content = _safe_read(record.physical_path)
344
-
345
- def _finalize(self, record: FileOperationRecord) -> None:
346
- self.completed.append(record)
347
- self.active.pop(record.tool_call_id, None)
@@ -1,270 +0,0 @@
1
- """Input handling, completers, and prompt session for the CLI."""
2
-
3
- import os
4
- import re
5
- from collections.abc import Callable
6
- from pathlib import Path
7
-
8
- from prompt_toolkit import PromptSession
9
- from prompt_toolkit.completion import (
10
- Completer,
11
- Completion,
12
- PathCompleter,
13
- merge_completers,
14
- )
15
- from prompt_toolkit.document import Document
16
- from prompt_toolkit.enums import EditingMode
17
- from prompt_toolkit.formatted_text import HTML
18
- from prompt_toolkit.key_binding import KeyBindings
19
-
20
- from .config import COLORS, COMMANDS, SessionState, console
21
-
22
- # Regex patterns for context-aware completion
23
- AT_MENTION_RE = re.compile(r"@(?P<path>(?:[^\s@]|(?<=\\)\s)*)$")
24
- SLASH_COMMAND_RE = re.compile(r"^/(?P<command>[a-z]*)$")
25
-
26
-
27
- class FilePathCompleter(Completer):
28
- """Activate filesystem completion only when cursor is after '@'."""
29
-
30
- def __init__(self):
31
- self.path_completer = PathCompleter(
32
- expanduser=True,
33
- min_input_len=0,
34
- only_directories=False,
35
- )
36
-
37
- def get_completions(self, document, complete_event):
38
- """Get file path completions when @ is detected."""
39
- text = document.text_before_cursor
40
-
41
- # Use regex to detect @path pattern at end of line
42
- m = AT_MENTION_RE.search(text)
43
- if not m:
44
- return # Not in an @path context
45
-
46
- path_fragment = m.group("path")
47
-
48
- # Unescape the path for PathCompleter (it doesn't understand escape sequences)
49
- unescaped_fragment = path_fragment.replace("\\ ", " ")
50
-
51
- # Strip trailing backslash if present (user is in the process of typing an escape)
52
- unescaped_fragment = unescaped_fragment.removesuffix("\\")
53
-
54
- # Create temporary document for the unescaped path fragment
55
- temp_doc = Document(text=unescaped_fragment, cursor_position=len(unescaped_fragment))
56
-
57
- # Get completions from PathCompleter and use its start_position
58
- # PathCompleter returns suffix text with start_position=0 (insert at cursor)
59
- for comp in self.path_completer.get_completions(temp_doc, complete_event):
60
- # Add trailing / for directories so users can continue navigating
61
- completed_path = Path(unescaped_fragment + comp.text).expanduser()
62
- # Re-escape spaces in the completion text for the command line
63
- completion_text = comp.text.replace(" ", "\\ ")
64
- if completed_path.is_dir() and not completion_text.endswith("/"):
65
- completion_text += "/"
66
-
67
- yield Completion(
68
- text=completion_text,
69
- start_position=comp.start_position, # Use PathCompleter's position (usually 0)
70
- display=comp.display,
71
- display_meta=comp.display_meta,
72
- )
73
-
74
-
75
- class CommandCompleter(Completer):
76
- """Activate command completion only when line starts with '/'."""
77
-
78
- def get_completions(self, document, complete_event):
79
- """Get command completions when / is at the start."""
80
- text = document.text_before_cursor
81
-
82
- # Use regex to detect /command pattern at start of line
83
- m = SLASH_COMMAND_RE.match(text)
84
- if not m:
85
- return # Not in a /command context
86
-
87
- command_fragment = m.group("command")
88
-
89
- # Match commands that start with the fragment (case-insensitive)
90
- for cmd_name, cmd_desc in COMMANDS.items():
91
- if cmd_name.startswith(command_fragment.lower()):
92
- yield Completion(
93
- text=cmd_name,
94
- start_position=-len(command_fragment), # Fixed position for original document
95
- display=cmd_name,
96
- display_meta=cmd_desc,
97
- )
98
-
99
-
100
- def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
101
- """Extract @file mentions and return cleaned text with resolved file paths."""
102
- pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
103
- matches = re.findall(pattern, text)
104
-
105
- files = []
106
- for match in matches:
107
- # Remove escape characters
108
- clean_path = match.replace("\\ ", " ")
109
- path = Path(clean_path).expanduser()
110
-
111
- # Try to resolve relative to cwd
112
- if not path.is_absolute():
113
- path = Path.cwd() / path
114
-
115
- try:
116
- path = path.resolve()
117
- if path.exists() and path.is_file():
118
- files.append(path)
119
- else:
120
- console.print(f"[yellow]Warning: File not found: {match}[/yellow]")
121
- except Exception as e:
122
- console.print(f"[yellow]Warning: Invalid path {match}: {e}[/yellow]")
123
-
124
- return text, files
125
-
126
-
127
- def get_bottom_toolbar(
128
- session_state: SessionState, session_ref: dict
129
- ) -> Callable[[], list[tuple[str, str]]]:
130
- """Return toolbar function that shows auto-approve status and BASH MODE."""
131
-
132
- def toolbar() -> list[tuple[str, str]]:
133
- parts = []
134
-
135
- # Check if we're in BASH mode (input starts with !)
136
- try:
137
- session = session_ref.get("session")
138
- if session:
139
- current_text = session.default_buffer.text
140
- if current_text.startswith("!"):
141
- parts.append(("bg:#ff1493 fg:#ffffff bold", " BASH MODE "))
142
- parts.append(("", " | "))
143
- except (AttributeError, TypeError):
144
- # Silently ignore - toolbar is non-critical and called frequently
145
- pass
146
-
147
- # Base status message
148
- if session_state.auto_approve:
149
- base_msg = "auto-accept ON (CTRL+T to toggle)"
150
- base_class = "class:toolbar-green"
151
- else:
152
- base_msg = "manual accept (CTRL+T to toggle)"
153
- base_class = "class:toolbar-orange"
154
-
155
- parts.append((base_class, base_msg))
156
-
157
- return parts
158
-
159
- return toolbar
160
-
161
-
162
- def create_prompt_session(assistant_id: str, session_state: SessionState) -> PromptSession:
163
- """Create a configured PromptSession with all features."""
164
- # Set default editor if not already set
165
- if "EDITOR" not in os.environ:
166
- os.environ["EDITOR"] = "nano"
167
-
168
- # Create key bindings
169
- kb = KeyBindings()
170
-
171
- # Bind Ctrl+T to toggle auto-approve
172
- @kb.add("c-t")
173
- def _(event):
174
- """Toggle auto-approve mode."""
175
- session_state.toggle_auto_approve()
176
- # Force UI refresh to update toolbar
177
- event.app.invalidate()
178
-
179
- # Bind regular Enter to submit (intuitive behavior)
180
- @kb.add("enter")
181
- def _(event):
182
- """Enter submits the input, unless completion menu is active."""
183
- buffer = event.current_buffer
184
-
185
- # If completion menu is showing, apply the current completion
186
- if buffer.complete_state:
187
- # Get the current completion (the highlighted one)
188
- current_completion = buffer.complete_state.current_completion
189
-
190
- # If no completion is selected (user hasn't navigated), select and apply the first one
191
- if not current_completion and buffer.complete_state.completions:
192
- # Move to the first completion
193
- buffer.complete_next()
194
- # Now apply it
195
- buffer.apply_completion(buffer.complete_state.current_completion)
196
- elif current_completion:
197
- # Apply the already-selected completion
198
- buffer.apply_completion(current_completion)
199
- else:
200
- # No completions available, close menu
201
- buffer.complete_state = None
202
- # Don't submit if buffer is empty or only whitespace
203
- elif buffer.text.strip():
204
- # Normal submit
205
- buffer.validate_and_handle()
206
- # If empty, do nothing (don't submit)
207
-
208
- # Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
209
- @kb.add("escape", "enter")
210
- def _(event):
211
- """Alt+Enter inserts a newline for multi-line input."""
212
- event.current_buffer.insert_text("\n")
213
-
214
- # Ctrl+E to open in external editor
215
- @kb.add("c-e")
216
- def _(event):
217
- """Open the current input in an external editor (nano by default)."""
218
- event.current_buffer.open_in_editor()
219
-
220
- # Backspace handler to retrigger completions after deletion
221
- @kb.add("backspace")
222
- def _(event):
223
- """Handle backspace and retrigger completion if in @ or / context."""
224
- buffer = event.current_buffer
225
-
226
- # Perform the normal backspace action
227
- buffer.delete_before_cursor(count=1)
228
-
229
- # Check if we're in a completion context (@ or /)
230
- text = buffer.document.text_before_cursor
231
- if AT_MENTION_RE.search(text) or SLASH_COMMAND_RE.match(text):
232
- # Retrigger completion
233
- buffer.start_completion(select_first=False)
234
-
235
- from prompt_toolkit.styles import Style
236
-
237
- # Define styles for the toolbar with full-width background colors
238
- toolbar_style = Style.from_dict(
239
- {
240
- "bottom-toolbar": "noreverse", # Disable default reverse video
241
- "toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
242
- "toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
243
- }
244
- )
245
-
246
- # Create session reference dict for toolbar to access session
247
- session_ref = {}
248
-
249
- # Create the session
250
- session = PromptSession(
251
- message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
252
- multiline=True, # Keep multiline support but Enter submits
253
- key_bindings=kb,
254
- completer=merge_completers([CommandCompleter(), FilePathCompleter()]),
255
- editing_mode=EditingMode.EMACS,
256
- complete_while_typing=True, # Show completions as you type
257
- complete_in_thread=True, # Async completion prevents menu freezing
258
- mouse_support=False,
259
- enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
260
- bottom_toolbar=get_bottom_toolbar(
261
- session_state, session_ref
262
- ), # Persistent status bar at bottom
263
- style=toolbar_style, # Apply toolbar styling
264
- reserve_space_for_menu=7, # Reserve space for completion menu to show 5-6 results
265
- )
266
-
267
- # Store session reference for toolbar to access
268
- session_ref["session"] = session
269
-
270
- return session