zjcode 0.0.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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Helpers for tracking file operations and computing diffs for display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from deepagents.backends.protocol import BackendProtocol
|
|
15
|
+
|
|
16
|
+
FileOpStatus = Literal["pending", "success", "error"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ApprovalPreview:
|
|
21
|
+
"""Data used to render HITL previews."""
|
|
22
|
+
|
|
23
|
+
title: str
|
|
24
|
+
details: list[str]
|
|
25
|
+
diff: str | None = None
|
|
26
|
+
diff_title: str | None = None
|
|
27
|
+
error: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_read(path: Path) -> str | None:
|
|
31
|
+
"""Read file content, returning None on failure.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
File content as string, or None if reading fails.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
return path.read_text(encoding="utf-8")
|
|
38
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
39
|
+
logger.debug("Failed to read file %s: %s", path, e)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _count_lines(text: str) -> int:
|
|
44
|
+
"""Count lines in text, treating empty strings as zero lines.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Number of lines in the text.
|
|
48
|
+
"""
|
|
49
|
+
if not text:
|
|
50
|
+
return 0
|
|
51
|
+
return len(text.splitlines())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_unified_diff(
|
|
55
|
+
before: str,
|
|
56
|
+
after: str,
|
|
57
|
+
display_path: str,
|
|
58
|
+
*,
|
|
59
|
+
max_lines: int | None = 800,
|
|
60
|
+
context_lines: int = 3,
|
|
61
|
+
) -> str | None:
|
|
62
|
+
"""Compute a unified diff between before and after content.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
before: Original content
|
|
66
|
+
after: New content
|
|
67
|
+
display_path: Path for display in diff headers
|
|
68
|
+
max_lines: Maximum number of diff lines (None for unlimited)
|
|
69
|
+
context_lines: Number of context lines around changes (default 3)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Unified diff string or None if no changes
|
|
73
|
+
"""
|
|
74
|
+
before_lines = before.splitlines()
|
|
75
|
+
after_lines = after.splitlines()
|
|
76
|
+
diff_lines = list(
|
|
77
|
+
difflib.unified_diff(
|
|
78
|
+
before_lines,
|
|
79
|
+
after_lines,
|
|
80
|
+
fromfile=f"{display_path} (before)",
|
|
81
|
+
tofile=f"{display_path} (after)",
|
|
82
|
+
lineterm="",
|
|
83
|
+
n=context_lines,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
if not diff_lines:
|
|
87
|
+
return None
|
|
88
|
+
if max_lines is not None and len(diff_lines) > max_lines:
|
|
89
|
+
truncated = diff_lines[: max_lines - 1]
|
|
90
|
+
truncated.append("...")
|
|
91
|
+
return "\n".join(truncated)
|
|
92
|
+
return "\n".join(diff_lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class FileOpMetrics:
|
|
97
|
+
"""Line and byte level metrics for a file operation."""
|
|
98
|
+
|
|
99
|
+
lines_read: int = 0
|
|
100
|
+
start_line: int | None = None
|
|
101
|
+
end_line: int | None = None
|
|
102
|
+
lines_written: int = 0
|
|
103
|
+
lines_added: int = 0
|
|
104
|
+
lines_removed: int = 0
|
|
105
|
+
bytes_written: int = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class FileOperationRecord:
|
|
110
|
+
"""Track a single filesystem tool call."""
|
|
111
|
+
|
|
112
|
+
tool_name: str
|
|
113
|
+
display_path: str
|
|
114
|
+
physical_path: Path | None
|
|
115
|
+
tool_call_id: str | None
|
|
116
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
117
|
+
status: FileOpStatus = "pending"
|
|
118
|
+
error: str | None = None
|
|
119
|
+
metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
|
|
120
|
+
diff: str | None = None
|
|
121
|
+
before_content: str | None = None
|
|
122
|
+
after_content: str | None = None
|
|
123
|
+
read_output: str | None = None
|
|
124
|
+
hitl_approved: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_physical_path(
|
|
128
|
+
path_str: str | None, assistant_id: str | None
|
|
129
|
+
) -> Path | None:
|
|
130
|
+
"""Convert a virtual/relative path to a physical filesystem path.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Resolved physical Path, or None if path is empty or resolution fails.
|
|
134
|
+
"""
|
|
135
|
+
if not path_str:
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
if assistant_id and path_str.startswith("/memories/"):
|
|
139
|
+
from deepagents_code.config import settings
|
|
140
|
+
|
|
141
|
+
agent_dir = settings.get_agent_dir(assistant_id)
|
|
142
|
+
suffix = path_str.removeprefix("/memories/").lstrip("/")
|
|
143
|
+
return (agent_dir / suffix).resolve()
|
|
144
|
+
path = Path(path_str)
|
|
145
|
+
if path.is_absolute():
|
|
146
|
+
return path
|
|
147
|
+
return (Path.cwd() / path).resolve()
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_SENSITIVE_FILE_NAMES = frozenset(
|
|
153
|
+
{
|
|
154
|
+
".envrc",
|
|
155
|
+
".netrc",
|
|
156
|
+
"_netrc",
|
|
157
|
+
".pgpass",
|
|
158
|
+
".npmrc",
|
|
159
|
+
".pypirc",
|
|
160
|
+
".htpasswd",
|
|
161
|
+
".git-credentials",
|
|
162
|
+
"credentials",
|
|
163
|
+
"credentials.json",
|
|
164
|
+
"token.json",
|
|
165
|
+
"auth.json",
|
|
166
|
+
"id_rsa",
|
|
167
|
+
"id_dsa",
|
|
168
|
+
"id_ecdsa",
|
|
169
|
+
"id_ed25519",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
"""Basenames (lowercased) that commonly hold secrets and must not be rendered."""
|
|
173
|
+
|
|
174
|
+
_SENSITIVE_FILE_SUFFIXES = (
|
|
175
|
+
".pem",
|
|
176
|
+
".key",
|
|
177
|
+
".pfx",
|
|
178
|
+
".p12",
|
|
179
|
+
".keystore",
|
|
180
|
+
".jks",
|
|
181
|
+
)
|
|
182
|
+
"""File suffixes (lowercased) for private keys / keystores that hold secrets."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_sensitive_file_path(path_str: str | None) -> bool:
|
|
186
|
+
"""Return whether a path points at a credential/secret file.
|
|
187
|
+
|
|
188
|
+
Best-effort, filename-based, case-insensitive heuristic. It matches `.env`
|
|
189
|
+
and its variants (e.g. `.env.local`), well-known credential filenames, and
|
|
190
|
+
private-key/keystore suffixes, and is used to suppress diff/content
|
|
191
|
+
rendering for those files so their contents are not shown in the terminal
|
|
192
|
+
UI or scrollback. It classifies by name only, not content, so
|
|
193
|
+
secret-bearing files with unrecognized names still render.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
path_str: Filesystem path to classify (a display or absolute path).
|
|
197
|
+
May be `None` or empty.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
`True` if the basename matches a known credential pattern. A falsy
|
|
201
|
+
path returns `False` (nothing to classify). An unparseable path
|
|
202
|
+
returns `True` and logs a warning, so the redaction gate fails
|
|
203
|
+
closed on unexpected input rather than leaking.
|
|
204
|
+
"""
|
|
205
|
+
if not path_str:
|
|
206
|
+
return False
|
|
207
|
+
try:
|
|
208
|
+
name = Path(path_str).name.lower()
|
|
209
|
+
except (OSError, ValueError, TypeError):
|
|
210
|
+
logger.warning(
|
|
211
|
+
"is_sensitive_file_path: could not parse %r; treating as sensitive",
|
|
212
|
+
path_str,
|
|
213
|
+
)
|
|
214
|
+
return True
|
|
215
|
+
if not name:
|
|
216
|
+
return False
|
|
217
|
+
if name == ".env" or name.startswith(".env."):
|
|
218
|
+
return True
|
|
219
|
+
if name in _SENSITIVE_FILE_NAMES:
|
|
220
|
+
return True
|
|
221
|
+
return name.endswith(_SENSITIVE_FILE_SUFFIXES)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def format_display_path(path_str: str | None) -> str:
|
|
225
|
+
"""Format a path for display.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Formatted path string suitable for display.
|
|
229
|
+
"""
|
|
230
|
+
if not path_str:
|
|
231
|
+
return "(unknown)"
|
|
232
|
+
try:
|
|
233
|
+
path = Path(path_str)
|
|
234
|
+
if path.is_absolute():
|
|
235
|
+
return path.name or str(path)
|
|
236
|
+
return str(path)
|
|
237
|
+
except (OSError, ValueError):
|
|
238
|
+
return str(path_str)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_approval_preview(
|
|
242
|
+
tool_name: str,
|
|
243
|
+
args: dict[str, Any],
|
|
244
|
+
assistant_id: str | None,
|
|
245
|
+
) -> ApprovalPreview | None:
|
|
246
|
+
"""Collect summary info and diff for HITL approvals.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
ApprovalPreview with diff and details, or None if tool not supported.
|
|
250
|
+
"""
|
|
251
|
+
path_str = str(args.get("file_path") or args.get("path") or "")
|
|
252
|
+
display_path = format_display_path(path_str)
|
|
253
|
+
physical_path = resolve_physical_path(path_str, assistant_id)
|
|
254
|
+
|
|
255
|
+
if tool_name == "write_file":
|
|
256
|
+
content = str(args.get("content", ""))
|
|
257
|
+
before = (
|
|
258
|
+
_safe_read(physical_path)
|
|
259
|
+
if physical_path and physical_path.exists()
|
|
260
|
+
else ""
|
|
261
|
+
)
|
|
262
|
+
after = content
|
|
263
|
+
diff = compute_unified_diff(before or "", after, display_path, max_lines=100)
|
|
264
|
+
additions = 0
|
|
265
|
+
if diff:
|
|
266
|
+
additions = sum(
|
|
267
|
+
1
|
|
268
|
+
for line in diff.splitlines()
|
|
269
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
270
|
+
)
|
|
271
|
+
total_lines = _count_lines(after)
|
|
272
|
+
details = [
|
|
273
|
+
f"File: {path_str}",
|
|
274
|
+
"Action: Create new file"
|
|
275
|
+
+ (" (overwrites existing content)" if before else ""),
|
|
276
|
+
f"Lines to write: {additions or total_lines}",
|
|
277
|
+
]
|
|
278
|
+
return ApprovalPreview(
|
|
279
|
+
title=f"Write {display_path}",
|
|
280
|
+
details=details,
|
|
281
|
+
diff=diff,
|
|
282
|
+
diff_title=f"Diff {display_path}",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if tool_name == "delete":
|
|
286
|
+
details = [f"File: {path_str}", "Action: Delete file or directory"]
|
|
287
|
+
if physical_path is None:
|
|
288
|
+
return ApprovalPreview(
|
|
289
|
+
title=f"Delete {display_path}",
|
|
290
|
+
details=details,
|
|
291
|
+
error="Unable to resolve file path.",
|
|
292
|
+
)
|
|
293
|
+
before = _safe_read(physical_path)
|
|
294
|
+
diff = None
|
|
295
|
+
if before is not None:
|
|
296
|
+
diff = compute_unified_diff(before, "", display_path, max_lines=100)
|
|
297
|
+
details.append(f"Lines to delete: {_count_lines(before)}")
|
|
298
|
+
elif physical_path.exists():
|
|
299
|
+
details.append("Contents: directory or unreadable file")
|
|
300
|
+
return ApprovalPreview(
|
|
301
|
+
title=f"Delete {display_path}",
|
|
302
|
+
details=details,
|
|
303
|
+
diff=diff,
|
|
304
|
+
diff_title=f"Diff {display_path}",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if tool_name == "edit_file":
|
|
308
|
+
if physical_path is None:
|
|
309
|
+
return ApprovalPreview(
|
|
310
|
+
title=f"Update {display_path}",
|
|
311
|
+
details=[f"File: {path_str}", "Action: Replace text"],
|
|
312
|
+
error="Unable to resolve file path.",
|
|
313
|
+
)
|
|
314
|
+
before = _safe_read(physical_path)
|
|
315
|
+
if before is None:
|
|
316
|
+
return ApprovalPreview(
|
|
317
|
+
title=f"Update {display_path}",
|
|
318
|
+
details=[f"File: {path_str}", "Action: Replace text"],
|
|
319
|
+
error="Unable to read current file contents.",
|
|
320
|
+
)
|
|
321
|
+
old_string = str(args.get("old_string", ""))
|
|
322
|
+
new_string = str(args.get("new_string", ""))
|
|
323
|
+
replace_all = bool(args.get("replace_all"))
|
|
324
|
+
from deepagents.backends.utils import perform_string_replacement
|
|
325
|
+
|
|
326
|
+
replacement = perform_string_replacement(
|
|
327
|
+
before, old_string, new_string, replace_all
|
|
328
|
+
)
|
|
329
|
+
if isinstance(replacement, str):
|
|
330
|
+
return ApprovalPreview(
|
|
331
|
+
title=f"Update {display_path}",
|
|
332
|
+
details=[f"File: {path_str}", "Action: Replace text"],
|
|
333
|
+
error=replacement,
|
|
334
|
+
)
|
|
335
|
+
after, occurrences = replacement
|
|
336
|
+
diff = compute_unified_diff(before, after, display_path, max_lines=None)
|
|
337
|
+
additions = 0
|
|
338
|
+
deletions = 0
|
|
339
|
+
if diff:
|
|
340
|
+
additions = sum(
|
|
341
|
+
1
|
|
342
|
+
for line in diff.splitlines()
|
|
343
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
344
|
+
)
|
|
345
|
+
deletions = sum(
|
|
346
|
+
1
|
|
347
|
+
for line in diff.splitlines()
|
|
348
|
+
if line.startswith("-") and not line.startswith("---")
|
|
349
|
+
)
|
|
350
|
+
action = "all occurrences" if replace_all else "single occurrence"
|
|
351
|
+
details = [
|
|
352
|
+
f"File: {path_str}",
|
|
353
|
+
f"Action: Replace text ({action})",
|
|
354
|
+
f"Occurrences matched: {occurrences}",
|
|
355
|
+
f"Lines changed: +{additions} / -{deletions}",
|
|
356
|
+
]
|
|
357
|
+
return ApprovalPreview(
|
|
358
|
+
title=f"Update {display_path}",
|
|
359
|
+
details=details,
|
|
360
|
+
diff=diff,
|
|
361
|
+
diff_title=f"Diff {display_path}",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class FileOpTracker:
|
|
368
|
+
"""Collect file operation metrics during an interaction."""
|
|
369
|
+
|
|
370
|
+
def __init__(
|
|
371
|
+
self, *, assistant_id: str | None, backend: BackendProtocol | None = None
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Initialize the tracker."""
|
|
374
|
+
self.assistant_id = assistant_id
|
|
375
|
+
self.backend = backend
|
|
376
|
+
self.active: dict[str | None, FileOperationRecord] = {}
|
|
377
|
+
self.completed: list[FileOperationRecord] = []
|
|
378
|
+
|
|
379
|
+
def start_operation(
|
|
380
|
+
self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Begin tracking a file operation.
|
|
383
|
+
|
|
384
|
+
Creates a record for the operation and, for write/edit/delete
|
|
385
|
+
operations, captures the file's content before the operation.
|
|
386
|
+
"""
|
|
387
|
+
if tool_name not in {"read_file", "write_file", "edit_file", "delete"}:
|
|
388
|
+
return
|
|
389
|
+
path_str = str(args.get("file_path") or args.get("path") or "")
|
|
390
|
+
display_path = format_display_path(path_str)
|
|
391
|
+
record = FileOperationRecord(
|
|
392
|
+
tool_name=tool_name,
|
|
393
|
+
display_path=display_path,
|
|
394
|
+
physical_path=resolve_physical_path(path_str, self.assistant_id),
|
|
395
|
+
tool_call_id=tool_call_id,
|
|
396
|
+
args=args,
|
|
397
|
+
)
|
|
398
|
+
if tool_name in {"write_file", "edit_file", "delete"}:
|
|
399
|
+
if self.backend and path_str:
|
|
400
|
+
try:
|
|
401
|
+
responses = self.backend.download_files([path_str])
|
|
402
|
+
if (
|
|
403
|
+
responses
|
|
404
|
+
and responses[0].content is not None
|
|
405
|
+
and responses[0].error is None
|
|
406
|
+
):
|
|
407
|
+
record.before_content = responses[0].content.decode("utf-8")
|
|
408
|
+
else:
|
|
409
|
+
record.before_content = ""
|
|
410
|
+
except (OSError, UnicodeDecodeError, AttributeError) as e:
|
|
411
|
+
logger.debug(
|
|
412
|
+
"Failed to read before_content for %s: %s", path_str, e
|
|
413
|
+
)
|
|
414
|
+
record.before_content = ""
|
|
415
|
+
elif record.physical_path:
|
|
416
|
+
record.before_content = _safe_read(record.physical_path) or ""
|
|
417
|
+
self.active[tool_call_id] = record
|
|
418
|
+
|
|
419
|
+
def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None: # noqa: ANN401 # Tool message type is dynamic
|
|
420
|
+
"""Complete a file operation with the tool message result.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
The completed FileOperationRecord, or None if no matching operation.
|
|
424
|
+
"""
|
|
425
|
+
tool_call_id = getattr(tool_message, "tool_call_id", None)
|
|
426
|
+
record = self.active.get(tool_call_id)
|
|
427
|
+
if record is None:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
content = tool_message.content
|
|
431
|
+
if isinstance(content, list):
|
|
432
|
+
# Some tool messages may return list segments; join them for analysis.
|
|
433
|
+
joined = []
|
|
434
|
+
for item in content:
|
|
435
|
+
if isinstance(item, str):
|
|
436
|
+
joined.append(item)
|
|
437
|
+
else:
|
|
438
|
+
joined.append(str(item))
|
|
439
|
+
content_text = "\n".join(joined)
|
|
440
|
+
else:
|
|
441
|
+
content_text = str(content) if content is not None else ""
|
|
442
|
+
|
|
443
|
+
if getattr(
|
|
444
|
+
tool_message, "status", "success"
|
|
445
|
+
) != "success" or content_text.lower().startswith("error"):
|
|
446
|
+
record.status = "error"
|
|
447
|
+
record.error = content_text
|
|
448
|
+
self._finalize(record)
|
|
449
|
+
return record
|
|
450
|
+
|
|
451
|
+
record.status = "success"
|
|
452
|
+
|
|
453
|
+
if record.tool_name == "read_file":
|
|
454
|
+
record.read_output = content_text
|
|
455
|
+
lines = _count_lines(content_text)
|
|
456
|
+
record.metrics.lines_read = lines
|
|
457
|
+
offset = record.args.get("offset")
|
|
458
|
+
limit = record.args.get("limit")
|
|
459
|
+
if isinstance(offset, int):
|
|
460
|
+
if offset > lines:
|
|
461
|
+
offset = 0
|
|
462
|
+
record.metrics.start_line = offset + 1
|
|
463
|
+
if lines:
|
|
464
|
+
record.metrics.end_line = offset + lines
|
|
465
|
+
elif lines:
|
|
466
|
+
record.metrics.start_line = 1
|
|
467
|
+
record.metrics.end_line = lines
|
|
468
|
+
if isinstance(limit, int) and lines > limit:
|
|
469
|
+
record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
|
|
470
|
+
else:
|
|
471
|
+
if record.tool_name == "delete":
|
|
472
|
+
# Reached only after the success-status check above, so the
|
|
473
|
+
# tool reported the path removed. Model an empty "after" to
|
|
474
|
+
# diff the removed content against; there is nothing to read
|
|
475
|
+
# back from disk. This trusts the tool's success status and
|
|
476
|
+
# is sound for backends where a successful delete means the
|
|
477
|
+
# path is gone.
|
|
478
|
+
record.after_content = ""
|
|
479
|
+
else:
|
|
480
|
+
# Write/edit: read the updated content back from backend/disk.
|
|
481
|
+
self._populate_after_content(record)
|
|
482
|
+
if record.after_content is None:
|
|
483
|
+
record.status = "error"
|
|
484
|
+
record.error = "Could not read updated file content."
|
|
485
|
+
self._finalize(record)
|
|
486
|
+
return record
|
|
487
|
+
record.metrics.lines_written = _count_lines(record.after_content)
|
|
488
|
+
before_lines = _count_lines(record.before_content or "")
|
|
489
|
+
diff = compute_unified_diff(
|
|
490
|
+
record.before_content or "",
|
|
491
|
+
record.after_content,
|
|
492
|
+
record.display_path,
|
|
493
|
+
max_lines=100,
|
|
494
|
+
)
|
|
495
|
+
record.diff = diff
|
|
496
|
+
if diff:
|
|
497
|
+
additions = sum(
|
|
498
|
+
1
|
|
499
|
+
for line in diff.splitlines()
|
|
500
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
501
|
+
)
|
|
502
|
+
deletions = sum(
|
|
503
|
+
1
|
|
504
|
+
for line in diff.splitlines()
|
|
505
|
+
if line.startswith("-") and not line.startswith("---")
|
|
506
|
+
)
|
|
507
|
+
record.metrics.lines_added = additions
|
|
508
|
+
record.metrics.lines_removed = deletions
|
|
509
|
+
elif record.tool_name == "write_file" and not (record.before_content or ""):
|
|
510
|
+
record.metrics.lines_added = record.metrics.lines_written
|
|
511
|
+
record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
|
|
512
|
+
if (
|
|
513
|
+
record.diff is None
|
|
514
|
+
and (record.before_content or "") != record.after_content
|
|
515
|
+
):
|
|
516
|
+
record.diff = compute_unified_diff(
|
|
517
|
+
record.before_content or "",
|
|
518
|
+
record.after_content,
|
|
519
|
+
record.display_path,
|
|
520
|
+
max_lines=100,
|
|
521
|
+
)
|
|
522
|
+
if record.diff is None and before_lines != record.metrics.lines_written:
|
|
523
|
+
record.metrics.lines_added = max(
|
|
524
|
+
record.metrics.lines_written - before_lines, 0
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
self._finalize(record)
|
|
528
|
+
return record
|
|
529
|
+
|
|
530
|
+
def mark_hitl_approved(self, tool_name: str, args: dict[str, Any]) -> None:
|
|
531
|
+
"""Mark operations matching tool_name and file_path as HIL-approved."""
|
|
532
|
+
file_path = args.get("file_path") or args.get("path")
|
|
533
|
+
if not file_path:
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
# Mark all active records that match
|
|
537
|
+
for record in self.active.values():
|
|
538
|
+
if record.tool_name == tool_name:
|
|
539
|
+
record_path = record.args.get("file_path") or record.args.get("path")
|
|
540
|
+
if record_path == file_path:
|
|
541
|
+
record.hitl_approved = True
|
|
542
|
+
|
|
543
|
+
def _populate_after_content(self, record: FileOperationRecord) -> None:
|
|
544
|
+
# Use backend if available (works for any BackendProtocol implementation)
|
|
545
|
+
if self.backend:
|
|
546
|
+
try:
|
|
547
|
+
file_path = record.args.get("file_path") or record.args.get("path")
|
|
548
|
+
if file_path:
|
|
549
|
+
responses = self.backend.download_files([file_path])
|
|
550
|
+
if (
|
|
551
|
+
responses
|
|
552
|
+
and responses[0].content is not None
|
|
553
|
+
and responses[0].error is None
|
|
554
|
+
):
|
|
555
|
+
record.after_content = responses[0].content.decode("utf-8")
|
|
556
|
+
else:
|
|
557
|
+
record.after_content = None
|
|
558
|
+
else:
|
|
559
|
+
record.after_content = None
|
|
560
|
+
except (OSError, UnicodeDecodeError, AttributeError) as e:
|
|
561
|
+
logger.debug(
|
|
562
|
+
"Failed to read after_content for %s: %s",
|
|
563
|
+
record.args.get("file_path") or record.args.get("path"),
|
|
564
|
+
e,
|
|
565
|
+
)
|
|
566
|
+
record.after_content = None
|
|
567
|
+
else:
|
|
568
|
+
# Fallback: direct filesystem read when no backend provided
|
|
569
|
+
if record.physical_path is None:
|
|
570
|
+
record.after_content = None
|
|
571
|
+
return
|
|
572
|
+
record.after_content = _safe_read(record.physical_path)
|
|
573
|
+
|
|
574
|
+
def _finalize(self, record: FileOperationRecord) -> None:
|
|
575
|
+
self.completed.append(record)
|
|
576
|
+
self.active.pop(record.tool_call_id, None)
|