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.
Files changed (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +256 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.118.dist-info/RECORD +86 -0
  77. code_puppy-0.0.96.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {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
- from json_repair import repair_json
19
+ import json_repair
20
20
  from pydantic import BaseModel
21
21
  from pydantic_ai import RunContext
22
22
 
23
- from code_puppy.tools.common import _find_best_window, console
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
- def _print_diff(diff_text: str) -> None:
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
- console.print(
29
- "[bold cyan]\n── DIFF ────────────────────────────────────────────────[/bold cyan]"
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
- console.print(f"[bold green]{line}[/bold green]", highlight=False)
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
- console.print(f"[bold red]{line}[/bold red]", highlight=False)
37
- elif line.startswith("@"):
38
- console.print(f"[bold cyan]{line}[/bold cyan]", highlight=False)
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
- console.print(line, highlight=False)
90
+ # Context lines - no special formatting
91
+ emit_info(line, highlight=False, message_group=message_group)
41
92
  else:
42
- console.print("[dim]-- no diff available --[/dim]")
43
- console.print(
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(msg: str, exc: Exception | None = None) -> None:
49
- console.print(f"[bold red]Error:[/bold red] {msg}")
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
- console.print(traceback.format_exc(), highlight=False)
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
- "success": False,
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: # noqa: BLE001
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, path: str, replacements: List[Dict[str, str]]
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
- console.print(
142
- "[bold yellow]No changes to apply – proposed content is identical.[/bold yellow]"
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
- console.log(f"🗑️ Deleting snippet from file [bold red]{file_path}[/bold red]")
222
- res = _delete_snippet_from_file(context, file_path, snippet)
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, path: str, content: str, overwrite: bool
284
+ context: RunContext,
285
+ path: str,
286
+ content: str,
287
+ overwrite: bool,
288
+ message_group: str = None,
231
289
  ) -> Dict[str, Any]:
232
- console.log(f"✏️ Writing file [bold blue]{path}[/bold blue]")
233
- res = _write_to_file(context, path, content, overwrite=overwrite)
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, path: str, replacements: List[Dict[str, str]]
303
+ context: RunContext,
304
+ path: str,
305
+ replacements: List[Dict[str, str]],
306
+ message_group: str = None,
242
307
  ) -> Dict[str, Any]:
243
- console.log(f"♻️ Replacing text in [bold yellow]{path}[/bold yellow]")
244
- res = _replace_in_file(context, path, replacements)
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(context: RunContext, path: str, diff: str) -> Dict[str, Any]:
319
+ def _edit_file(
320
+ context: RunContext, payload: EditFilePayload, group_id: str = None
321
+ ) -> Dict[str, Any]:
252
322
  """
253
- Unified file editing tool that can:
254
- - Create/write a new file when the target does not exist (using raw content or a JSON payload with a "content" key)
255
- - Replace text within an existing file via a JSON payload with "replacements" (delegates to internal replace logic)
256
- - Delete a snippet from an existing file via a JSON payload with "delete_snippet"
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
- console.print("\n[bold white on blue] EDIT FILE [/bold white on blue]")
271
- file_path = os.path.abspath(path)
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
- parsed_payload = json.loads(diff)
274
- except json.JSONDecodeError:
275
- try:
276
- console.print(
277
- "[bold yellow] JSON Parsing Failed! TRYING TO REPAIR! [/bold yellow]"
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
- parsed_payload = json.loads(repair_json(diff))
280
- console.print("[bold white on blue] SUCCESS - WOOF! [/bold white on blue]")
281
- except Exception as e:
282
- console.print(f"[bold red] Unable to parse diff [/bold red] -- {str(e)}")
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"Unable to parse diff JSON -- {str(e)}",
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
- console.print(
313
- "[bold red] Unable to route file modification tool call to sub-tool [/bold red]"
397
+ emit_error(
398
+ "Unable to route file modification tool call to sub-tool",
399
+ message_group=group_id,
314
400
  )
315
- console.print(str(e))
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(context: RunContext, file_path: str = "") -> Dict[str, Any]:
325
- console.log(f"🗑️ Deleting file [bold red]{file_path}[/bold red]")
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
- "success": False,
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, path: str = "", diff: str = ""
383
- ) -> EditFileOutput:
384
- return EditFileOutput(**_edit_file(context, path, diff))
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 = "") -> EditFileOutput:
388
- return EditFileOutput(**_delete_file(context, file_path))
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