code-puppy 0.0.96__py3-none-any.whl → 0.0.118__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.
- code_puppy/__init__.py +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +256 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.96.dist-info/RECORD +0 -32
- {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,65 +14,110 @@ import difflib
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
import traceback
|
|
17
|
-
from typing import Any, Dict, List
|
|
17
|
+
from typing import Any, Dict, List, Union
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
import json_repair
|
|
20
20
|
from pydantic import BaseModel
|
|
21
21
|
from pydantic_ai import RunContext
|
|
22
22
|
|
|
23
|
-
from code_puppy.
|
|
23
|
+
from code_puppy.messaging import emit_error, emit_info, emit_warning
|
|
24
|
+
from code_puppy.tools.common import _find_best_window, generate_group_id
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
class DeleteSnippetPayload(BaseModel):
|
|
28
|
+
file_path: str
|
|
29
|
+
delete_snippet: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Replacement(BaseModel):
|
|
33
|
+
old_str: str
|
|
34
|
+
new_str: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReplacementsPayload(BaseModel):
|
|
38
|
+
file_path: str
|
|
39
|
+
replacements: List[Replacement]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContentPayload(BaseModel):
|
|
43
|
+
file_path: str
|
|
44
|
+
content: str
|
|
45
|
+
overwrite: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
EditFilePayload = Union[DeleteSnippetPayload, ReplacementsPayload, ContentPayload]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _print_diff(diff_text: str, message_group: str = None) -> None:
|
|
27
52
|
"""Pretty-print *diff_text* with colour-coding (always runs)."""
|
|
28
|
-
|
|
29
|
-
|
|
53
|
+
|
|
54
|
+
emit_info(
|
|
55
|
+
"[bold cyan]\n── DIFF ────────────────────────────────────────────────[/bold cyan]",
|
|
56
|
+
message_group=message_group,
|
|
30
57
|
)
|
|
31
58
|
if diff_text and diff_text.strip():
|
|
32
59
|
for line in diff_text.splitlines():
|
|
60
|
+
# Git-style diff coloring using markup strings for TUI compatibility
|
|
33
61
|
if line.startswith("+") and not line.startswith("+++"):
|
|
34
|
-
|
|
62
|
+
# Addition line - use markup string instead of Rich Text
|
|
63
|
+
emit_info(
|
|
64
|
+
f"[bold green]{line}[/bold green]",
|
|
65
|
+
highlight=False,
|
|
66
|
+
message_group=message_group,
|
|
67
|
+
)
|
|
35
68
|
elif line.startswith("-") and not line.startswith("---"):
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
# Removal line - use markup string instead of Rich Text
|
|
70
|
+
emit_info(
|
|
71
|
+
f"[bold red]{line}[/bold red]",
|
|
72
|
+
highlight=False,
|
|
73
|
+
message_group=message_group,
|
|
74
|
+
)
|
|
75
|
+
elif line.startswith("@@"):
|
|
76
|
+
# Hunk info - use markup string instead of Rich Text
|
|
77
|
+
emit_info(
|
|
78
|
+
f"[bold cyan]{line}[/bold cyan]",
|
|
79
|
+
highlight=False,
|
|
80
|
+
message_group=message_group,
|
|
81
|
+
)
|
|
82
|
+
elif line.startswith("+++") or line.startswith("---"):
|
|
83
|
+
# Filename lines in diff - use markup string instead of Rich Text
|
|
84
|
+
emit_info(
|
|
85
|
+
f"[dim white]{line}[/dim white]",
|
|
86
|
+
highlight=False,
|
|
87
|
+
message_group=message_group,
|
|
88
|
+
)
|
|
39
89
|
else:
|
|
40
|
-
|
|
90
|
+
# Context lines - no special formatting
|
|
91
|
+
emit_info(line, highlight=False, message_group=message_group)
|
|
41
92
|
else:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"[bold cyan]───────────────────────────────────────────────────────[/bold cyan]"
|
|
93
|
+
emit_info("[dim]-- no diff available --[/dim]", message_group=message_group)
|
|
94
|
+
emit_info(
|
|
95
|
+
"[bold cyan]───────────────────────────────────────────────────────[/bold cyan]",
|
|
96
|
+
message_group=message_group,
|
|
45
97
|
)
|
|
46
98
|
|
|
47
99
|
|
|
48
|
-
def _log_error(
|
|
49
|
-
|
|
100
|
+
def _log_error(
|
|
101
|
+
msg: str, exc: Exception | None = None, message_group: str = None
|
|
102
|
+
) -> None:
|
|
103
|
+
emit_error(f"{msg}", message_group=message_group)
|
|
50
104
|
if exc is not None:
|
|
51
|
-
|
|
105
|
+
emit_error(traceback.format_exc(), highlight=False, message_group=message_group)
|
|
52
106
|
|
|
53
107
|
|
|
54
108
|
def _delete_snippet_from_file(
|
|
55
|
-
context: RunContext | None, file_path: str, snippet: str
|
|
109
|
+
context: RunContext | None, file_path: str, snippet: str, message_group: str = None
|
|
56
110
|
) -> Dict[str, Any]:
|
|
57
111
|
file_path = os.path.abspath(file_path)
|
|
58
112
|
diff_text = ""
|
|
59
113
|
try:
|
|
60
114
|
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
61
|
-
return {
|
|
62
|
-
"success": False,
|
|
63
|
-
"path": file_path,
|
|
64
|
-
"message": f"File '{file_path}' does not exist.",
|
|
65
|
-
"changed": False,
|
|
66
|
-
"diff": diff_text,
|
|
67
|
-
}
|
|
115
|
+
return {"error": f"File '{file_path}' does not exist.", "diff": diff_text}
|
|
68
116
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
69
117
|
original = f.read()
|
|
70
118
|
if snippet not in original:
|
|
71
119
|
return {
|
|
72
|
-
"
|
|
73
|
-
"path": file_path,
|
|
74
|
-
"message": f"Snippet not found in file '{file_path}'.",
|
|
75
|
-
"changed": False,
|
|
120
|
+
"error": f"Snippet not found in file '{file_path}'.",
|
|
76
121
|
"diff": diff_text,
|
|
77
122
|
}
|
|
78
123
|
modified = original.replace(snippet, "")
|
|
@@ -94,13 +139,15 @@ def _delete_snippet_from_file(
|
|
|
94
139
|
"changed": True,
|
|
95
140
|
"diff": diff_text,
|
|
96
141
|
}
|
|
97
|
-
except Exception as exc:
|
|
98
|
-
_log_error("Unhandled exception in delete_snippet_from_file", exc)
|
|
142
|
+
except Exception as exc:
|
|
99
143
|
return {"error": str(exc), "diff": diff_text}
|
|
100
144
|
|
|
101
145
|
|
|
102
146
|
def _replace_in_file(
|
|
103
|
-
context: RunContext | None,
|
|
147
|
+
context: RunContext | None,
|
|
148
|
+
path: str,
|
|
149
|
+
replacements: List[Dict[str, str]],
|
|
150
|
+
message_group: str = None,
|
|
104
151
|
) -> Dict[str, Any]:
|
|
105
152
|
"""Robust replacement engine with explicit edge‑case reporting."""
|
|
106
153
|
file_path = os.path.abspath(path)
|
|
@@ -138,8 +185,9 @@ def _replace_in_file(
|
|
|
138
185
|
)
|
|
139
186
|
|
|
140
187
|
if modified == original:
|
|
141
|
-
|
|
142
|
-
"
|
|
188
|
+
emit_warning(
|
|
189
|
+
"No changes to apply – proposed content is identical.",
|
|
190
|
+
message_group=message_group,
|
|
143
191
|
)
|
|
144
192
|
return {
|
|
145
193
|
"success": False,
|
|
@@ -174,6 +222,7 @@ def _write_to_file(
|
|
|
174
222
|
path: str,
|
|
175
223
|
content: str,
|
|
176
224
|
overwrite: bool = False,
|
|
225
|
+
message_group: str = None,
|
|
177
226
|
) -> Dict[str, Any]:
|
|
178
227
|
file_path = os.path.abspath(path)
|
|
179
228
|
|
|
@@ -216,44 +265,76 @@ def _write_to_file(
|
|
|
216
265
|
|
|
217
266
|
|
|
218
267
|
def delete_snippet_from_file(
|
|
219
|
-
context: RunContext, file_path: str, snippet: str
|
|
268
|
+
context: RunContext, file_path: str, snippet: str, message_group: str = None
|
|
220
269
|
) -> Dict[str, Any]:
|
|
221
|
-
|
|
222
|
-
|
|
270
|
+
emit_info(
|
|
271
|
+
f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]",
|
|
272
|
+
message_group=message_group,
|
|
273
|
+
)
|
|
274
|
+
res = _delete_snippet_from_file(
|
|
275
|
+
context, file_path, snippet, message_group=message_group
|
|
276
|
+
)
|
|
223
277
|
diff = res.get("diff", "")
|
|
224
278
|
if diff:
|
|
225
|
-
_print_diff(diff)
|
|
279
|
+
_print_diff(diff, message_group=message_group)
|
|
226
280
|
return res
|
|
227
281
|
|
|
228
282
|
|
|
229
283
|
def write_to_file(
|
|
230
|
-
context: RunContext,
|
|
284
|
+
context: RunContext,
|
|
285
|
+
path: str,
|
|
286
|
+
content: str,
|
|
287
|
+
overwrite: bool,
|
|
288
|
+
message_group: str = None,
|
|
231
289
|
) -> Dict[str, Any]:
|
|
232
|
-
|
|
233
|
-
|
|
290
|
+
emit_info(
|
|
291
|
+
f"✏️ Writing file [bold blue]{path}[/bold blue]", message_group=message_group
|
|
292
|
+
)
|
|
293
|
+
res = _write_to_file(
|
|
294
|
+
context, path, content, overwrite=overwrite, message_group=message_group
|
|
295
|
+
)
|
|
234
296
|
diff = res.get("diff", "")
|
|
235
297
|
if diff:
|
|
236
|
-
_print_diff(diff)
|
|
298
|
+
_print_diff(diff, message_group=message_group)
|
|
237
299
|
return res
|
|
238
300
|
|
|
239
301
|
|
|
240
302
|
def replace_in_file(
|
|
241
|
-
context: RunContext,
|
|
303
|
+
context: RunContext,
|
|
304
|
+
path: str,
|
|
305
|
+
replacements: List[Dict[str, str]],
|
|
306
|
+
message_group: str = None,
|
|
242
307
|
) -> Dict[str, Any]:
|
|
243
|
-
|
|
244
|
-
|
|
308
|
+
emit_info(
|
|
309
|
+
f"♻️ Replacing text in [bold yellow]{path}[/bold yellow]",
|
|
310
|
+
message_group=message_group,
|
|
311
|
+
)
|
|
312
|
+
res = _replace_in_file(context, path, replacements, message_group=message_group)
|
|
245
313
|
diff = res.get("diff", "")
|
|
246
314
|
if diff:
|
|
247
|
-
_print_diff(diff)
|
|
315
|
+
_print_diff(diff, message_group=message_group)
|
|
248
316
|
return res
|
|
249
317
|
|
|
250
318
|
|
|
251
|
-
def _edit_file(
|
|
319
|
+
def _edit_file(
|
|
320
|
+
context: RunContext, payload: EditFilePayload, group_id: str = None
|
|
321
|
+
) -> Dict[str, Any]:
|
|
252
322
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
323
|
+
High-level implementation of the *edit_file* behaviour.
|
|
324
|
+
|
|
325
|
+
This function performs the heavy-lifting after the lightweight agent-exposed wrapper has
|
|
326
|
+
validated / coerced the inbound *payload* to one of the Pydantic models declared at the top
|
|
327
|
+
of this module.
|
|
328
|
+
|
|
329
|
+
Supported payload variants
|
|
330
|
+
--------------------------
|
|
331
|
+
• **ContentPayload** – full file write / overwrite.
|
|
332
|
+
• **ReplacementsPayload** – targeted in-file replacements.
|
|
333
|
+
• **DeleteSnippetPayload** – remove an exact snippet.
|
|
334
|
+
|
|
335
|
+
The helper decides which low-level routine to delegate to and ensures the resulting unified
|
|
336
|
+
diff is always returned so the caller can pretty-print it for the user.
|
|
337
|
+
|
|
257
338
|
Parameters
|
|
258
339
|
----------
|
|
259
340
|
path : str
|
|
@@ -267,52 +348,57 @@ def _edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
|
|
|
267
348
|
{"delete_snippet": "text to remove"}
|
|
268
349
|
The function auto-detects the payload type and routes to the appropriate internal helper.
|
|
269
350
|
"""
|
|
270
|
-
|
|
271
|
-
|
|
351
|
+
# Use provided group_id or generate one if not provided
|
|
352
|
+
if group_id is None:
|
|
353
|
+
group_id = generate_group_id("edit_file", payload.file_path)
|
|
354
|
+
|
|
355
|
+
emit_info(
|
|
356
|
+
"\n[bold white on blue] EDIT FILE [/bold white on blue]", message_group=group_id
|
|
357
|
+
)
|
|
358
|
+
file_path = os.path.abspath(payload.file_path)
|
|
272
359
|
try:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
360
|
+
if isinstance(payload, DeleteSnippetPayload):
|
|
361
|
+
return delete_snippet_from_file(
|
|
362
|
+
context, file_path, payload.delete_snippet, message_group=group_id
|
|
363
|
+
)
|
|
364
|
+
elif isinstance(payload, ReplacementsPayload):
|
|
365
|
+
# Convert Pydantic Replacement models to dict format for legacy compatibility
|
|
366
|
+
replacements_dict = [
|
|
367
|
+
{"old_str": rep.old_str, "new_str": rep.new_str}
|
|
368
|
+
for rep in payload.replacements
|
|
369
|
+
]
|
|
370
|
+
return replace_in_file(
|
|
371
|
+
context, file_path, replacements_dict, message_group=group_id
|
|
278
372
|
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
373
|
+
elif isinstance(payload, ContentPayload):
|
|
374
|
+
file_exists = os.path.exists(file_path)
|
|
375
|
+
if file_exists and not payload.overwrite:
|
|
376
|
+
return {
|
|
377
|
+
"success": False,
|
|
378
|
+
"path": file_path,
|
|
379
|
+
"message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
|
|
380
|
+
"changed": False,
|
|
381
|
+
}
|
|
382
|
+
return write_to_file(
|
|
383
|
+
context,
|
|
384
|
+
file_path,
|
|
385
|
+
payload.content,
|
|
386
|
+
payload.overwrite,
|
|
387
|
+
message_group=group_id,
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
283
390
|
return {
|
|
284
391
|
"success": False,
|
|
285
392
|
"path": file_path,
|
|
286
|
-
"message": f"
|
|
393
|
+
"message": f"Unknown payload type: {type(payload)}",
|
|
287
394
|
"changed": False,
|
|
288
|
-
"diff": "",
|
|
289
395
|
}
|
|
290
|
-
try:
|
|
291
|
-
if isinstance(parsed_payload, dict):
|
|
292
|
-
if "delete_snippet" in parsed_payload:
|
|
293
|
-
snippet = parsed_payload["delete_snippet"]
|
|
294
|
-
return delete_snippet_from_file(context, file_path, snippet)
|
|
295
|
-
if "replacements" in parsed_payload:
|
|
296
|
-
replacements = parsed_payload["replacements"]
|
|
297
|
-
return replace_in_file(context, file_path, replacements)
|
|
298
|
-
if "content" in parsed_payload:
|
|
299
|
-
content = parsed_payload["content"]
|
|
300
|
-
overwrite = bool(parsed_payload.get("overwrite", False))
|
|
301
|
-
file_exists = os.path.exists(file_path)
|
|
302
|
-
if file_exists and not overwrite:
|
|
303
|
-
return {
|
|
304
|
-
"success": False,
|
|
305
|
-
"path": file_path,
|
|
306
|
-
"message": f"File '{file_path}' exists. Set 'overwrite': true to replace.",
|
|
307
|
-
"changed": False,
|
|
308
|
-
}
|
|
309
|
-
return write_to_file(context, file_path, content, overwrite)
|
|
310
|
-
return write_to_file(context, file_path, diff, overwrite=False)
|
|
311
396
|
except Exception as e:
|
|
312
|
-
|
|
313
|
-
"
|
|
397
|
+
emit_error(
|
|
398
|
+
"Unable to route file modification tool call to sub-tool",
|
|
399
|
+
message_group=group_id,
|
|
314
400
|
)
|
|
315
|
-
|
|
401
|
+
emit_error(str(e), message_group=group_id)
|
|
316
402
|
return {
|
|
317
403
|
"success": False,
|
|
318
404
|
"path": file_path,
|
|
@@ -321,18 +407,16 @@ def _edit_file(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
|
|
|
321
407
|
}
|
|
322
408
|
|
|
323
409
|
|
|
324
|
-
def _delete_file(
|
|
325
|
-
|
|
410
|
+
def _delete_file(
|
|
411
|
+
context: RunContext, file_path: str, message_group: str = None
|
|
412
|
+
) -> Dict[str, Any]:
|
|
413
|
+
emit_info(
|
|
414
|
+
f"🗑️ Deleting file [bold red]{file_path}[/bold red]", message_group=message_group
|
|
415
|
+
)
|
|
326
416
|
file_path = os.path.abspath(file_path)
|
|
327
417
|
try:
|
|
328
418
|
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
329
|
-
res = {
|
|
330
|
-
"success": False,
|
|
331
|
-
"path": file_path,
|
|
332
|
-
"message": f"File '{file_path}' does not exist.",
|
|
333
|
-
"changed": False,
|
|
334
|
-
"diff": "",
|
|
335
|
-
}
|
|
419
|
+
res = {"error": f"File '{file_path}' does not exist.", "diff": ""}
|
|
336
420
|
else:
|
|
337
421
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
338
422
|
original = f.read()
|
|
@@ -355,34 +439,167 @@ def _delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
|
|
|
355
439
|
}
|
|
356
440
|
except Exception as exc:
|
|
357
441
|
_log_error("Unhandled exception in delete_file", exc)
|
|
358
|
-
res = {
|
|
359
|
-
|
|
360
|
-
"path": file_path,
|
|
361
|
-
"message": str(exc),
|
|
362
|
-
"changed": False,
|
|
363
|
-
"diff": "",
|
|
364
|
-
}
|
|
365
|
-
_print_diff(res.get("diff", ""))
|
|
442
|
+
res = {"error": str(exc), "diff": ""}
|
|
443
|
+
_print_diff(res.get("diff", ""), message_group=message_group)
|
|
366
444
|
return res
|
|
367
445
|
|
|
368
446
|
|
|
369
|
-
class EditFileOutput(BaseModel):
|
|
370
|
-
success: bool | None
|
|
371
|
-
path: str | None
|
|
372
|
-
message: str | None
|
|
373
|
-
changed: bool | None
|
|
374
|
-
diff: str | None
|
|
375
|
-
|
|
376
|
-
|
|
377
447
|
def register_file_modifications_tools(agent):
|
|
378
448
|
"""Attach file-editing tools to *agent* with mandatory diff rendering."""
|
|
379
449
|
|
|
380
450
|
@agent.tool(retries=5)
|
|
381
451
|
def edit_file(
|
|
382
|
-
context: RunContext,
|
|
383
|
-
) ->
|
|
384
|
-
|
|
452
|
+
context: RunContext, payload: EditFilePayload | str = ""
|
|
453
|
+
) -> Dict[str, Any]:
|
|
454
|
+
"""Comprehensive file editing tool supporting multiple modification strategies.
|
|
455
|
+
|
|
456
|
+
This is the primary file modification tool that supports three distinct editing
|
|
457
|
+
approaches: full content replacement, targeted text replacements, and snippet
|
|
458
|
+
deletion. It provides robust diff generation, error handling, and automatic
|
|
459
|
+
retry capabilities for reliable file operations.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
463
|
+
payload (EditFilePayload): One of three payload types:
|
|
464
|
+
|
|
465
|
+
ContentPayload:
|
|
466
|
+
- content (str): Full file content to write
|
|
467
|
+
- overwrite (bool, optional): Whether to overwrite existing files.
|
|
468
|
+
Defaults to False (safe mode).
|
|
469
|
+
|
|
470
|
+
ReplacementsPayload:
|
|
471
|
+
- replacements (List[Replacement]): List of text replacements where
|
|
472
|
+
each Replacement contains:
|
|
473
|
+
- old_str (str): Exact text to find and replace
|
|
474
|
+
- new_str (str): Replacement text
|
|
475
|
+
|
|
476
|
+
DeleteSnippetPayload:
|
|
477
|
+
- delete_snippet (str): Exact text snippet to remove from file
|
|
478
|
+
|
|
479
|
+
file_path (str): Path to the target file. Can be relative or absolute.
|
|
480
|
+
File will be created if it doesn't exist (for ContentPayload).
|
|
481
|
+
Returns:
|
|
482
|
+
Dict[str, Any]: Operation result containing:
|
|
483
|
+
- success (bool): True if operation completed successfully
|
|
484
|
+
- path (str): Absolute path to the modified file
|
|
485
|
+
- message (str): Human-readable description of what occurred
|
|
486
|
+
- changed (bool): True if file content was actually modified
|
|
487
|
+
- error (str, optional): Error message if operation failed
|
|
488
|
+
|
|
489
|
+
Note:
|
|
490
|
+
- Automatic retry (up to 5 attempts) for transient failures
|
|
491
|
+
- Unified diff is generated and displayed for all operations
|
|
492
|
+
- Fuzzy matching (Jaro-Winkler) used for replacements when exact match fails
|
|
493
|
+
- Minimum similarity threshold of 0.95 for fuzzy replacements
|
|
494
|
+
- Creates parent directories automatically when needed
|
|
495
|
+
- UTF-8 encoding enforced for all file operations
|
|
496
|
+
|
|
497
|
+
Examples:
|
|
498
|
+
>>> # Create new file
|
|
499
|
+
>>> payload = ContentPayload(file_path="foo.py", content="print('Hello World')")
|
|
500
|
+
>>> result = edit_file(payload)
|
|
501
|
+
|
|
502
|
+
>>> # Replace specific text
|
|
503
|
+
>>> replacements = [Replacement(old_str="foo", new_str="bar")]
|
|
504
|
+
>>> payload = ReplacementsPayload(file_path="foo.py", replacements=replacements)
|
|
505
|
+
>>> result = edit_file(payload)
|
|
506
|
+
|
|
507
|
+
>>> # Delete code block
|
|
508
|
+
>>> payload = DeleteSnippetPayload(file_path="foo.py", delete_snippet="# TODO: remove this")
|
|
509
|
+
>>> result = edit_file(payload)
|
|
510
|
+
|
|
511
|
+
Warning:
|
|
512
|
+
- Always verify file contents after modification
|
|
513
|
+
- Use overwrite=False by default to prevent accidental data loss
|
|
514
|
+
- Large files may be slow due to diff generation
|
|
515
|
+
- Exact string matching required for reliable replacements
|
|
516
|
+
|
|
517
|
+
Best Practice:
|
|
518
|
+
- Use ReplacementsPayload for targeted changes to preserve file structure
|
|
519
|
+
- Read file first to understand current content before modifications
|
|
520
|
+
- Keep replacement strings specific and unique to avoid unintended matches
|
|
521
|
+
- Test modifications on non-critical files first
|
|
522
|
+
"""
|
|
523
|
+
# Generate group_id for edit_file tool execution
|
|
524
|
+
if isinstance(payload, str):
|
|
525
|
+
# Fallback for weird models that just can't help but send json strings...
|
|
526
|
+
payload = json.loads(json_repair.repair_json(payload))
|
|
527
|
+
if "replacements" in payload:
|
|
528
|
+
payload = ReplacementsPayload(**payload)
|
|
529
|
+
if "delete_snippet" in payload:
|
|
530
|
+
payload = DeleteSnippetPayload(**payload)
|
|
531
|
+
if "content" in payload:
|
|
532
|
+
payload = ContentPayload(**payload)
|
|
533
|
+
else:
|
|
534
|
+
file_path = "Unknown"
|
|
535
|
+
if "file_path" in payload:
|
|
536
|
+
file_path = payload["file_path"]
|
|
537
|
+
return {
|
|
538
|
+
"success": False,
|
|
539
|
+
"path": file_path,
|
|
540
|
+
"message": "One of 'content', 'replacements', or 'delete_snippet' must be provided in payload.",
|
|
541
|
+
"changed": False,
|
|
542
|
+
}
|
|
543
|
+
group_id = generate_group_id("edit_file", payload.file_path)
|
|
544
|
+
result = _edit_file(context, payload, group_id)
|
|
545
|
+
if "diff" in result:
|
|
546
|
+
del result["diff"]
|
|
547
|
+
return result
|
|
385
548
|
|
|
386
549
|
@agent.tool(retries=5)
|
|
387
|
-
def delete_file(context: RunContext, file_path: str = "") ->
|
|
388
|
-
|
|
550
|
+
def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
|
|
551
|
+
"""Safely delete files with comprehensive logging and diff generation.
|
|
552
|
+
|
|
553
|
+
This tool provides safe file deletion with automatic diff generation to show
|
|
554
|
+
exactly what content was removed. It includes proper error handling and
|
|
555
|
+
automatic retry capabilities for reliable operation.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
559
|
+
file_path (str): Path to the file to delete. Can be relative or absolute.
|
|
560
|
+
Must be an existing regular file (not a directory).
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Dict[str, Any]: Operation result containing:
|
|
564
|
+
- success (bool): True if file was successfully deleted
|
|
565
|
+
- path (str): Absolute path to the deleted file
|
|
566
|
+
- message (str): Human-readable description of the operation
|
|
567
|
+
- changed (bool): True if file was actually removed
|
|
568
|
+
- error (str, optional): Error message if deletion failed
|
|
569
|
+
|
|
570
|
+
Note:
|
|
571
|
+
- Automatic retry (up to 5 attempts) for transient failures
|
|
572
|
+
- Complete file content is captured and shown in diff before deletion
|
|
573
|
+
- Only deletes regular files, not directories or special files
|
|
574
|
+
- Generates unified diff showing all removed content
|
|
575
|
+
- Error if file doesn't exist or is not accessible
|
|
576
|
+
|
|
577
|
+
Examples:
|
|
578
|
+
>>> # Delete temporary file
|
|
579
|
+
>>> result = delete_file(ctx, "temp_output.txt")
|
|
580
|
+
>>> if result['success']:
|
|
581
|
+
... print(f"Successfully deleted {result['path']}")
|
|
582
|
+
|
|
583
|
+
>>> # Delete with error handling
|
|
584
|
+
>>> result = delete_file(ctx, "config.bak")
|
|
585
|
+
>>> if 'error' in result:
|
|
586
|
+
... print(f"Deletion failed: {result['error']}")
|
|
587
|
+
|
|
588
|
+
Warning:
|
|
589
|
+
- File deletion is irreversible - ensure you have backups if needed
|
|
590
|
+
- Will not delete directories (use appropriate directory removal tools)
|
|
591
|
+
- No "trash" or "recycle bin" - files are permanently removed
|
|
592
|
+
- Check file importance before deletion
|
|
593
|
+
|
|
594
|
+
Best Practice:
|
|
595
|
+
- Always verify file path before deletion
|
|
596
|
+
- Review the generated diff to confirm deletion scope
|
|
597
|
+
- Consider moving files to backup location instead of deleting
|
|
598
|
+
- Use in combination with list_files to verify target
|
|
599
|
+
"""
|
|
600
|
+
# Generate group_id for delete_file tool execution
|
|
601
|
+
group_id = generate_group_id("delete_file", file_path)
|
|
602
|
+
result = _delete_file(context, file_path, message_group=group_id)
|
|
603
|
+
if "diff" in result:
|
|
604
|
+
del result["diff"]
|
|
605
|
+
return result
|