aloop 0.1.0__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.
Potentially problematic release.
This version of aloop might be problematic. Click here for more details.
- agent/__init__.py +0 -0
- agent/agent.py +182 -0
- agent/base.py +406 -0
- agent/context.py +126 -0
- agent/todo.py +149 -0
- agent/tool_executor.py +54 -0
- agent/verification.py +135 -0
- aloop-0.1.0.dist-info/METADATA +246 -0
- aloop-0.1.0.dist-info/RECORD +62 -0
- aloop-0.1.0.dist-info/WHEEL +5 -0
- aloop-0.1.0.dist-info/entry_points.txt +2 -0
- aloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- aloop-0.1.0.dist-info/top_level.txt +9 -0
- cli.py +19 -0
- config.py +146 -0
- interactive.py +865 -0
- llm/__init__.py +51 -0
- llm/base.py +26 -0
- llm/compat.py +226 -0
- llm/content_utils.py +309 -0
- llm/litellm_adapter.py +450 -0
- llm/message_types.py +245 -0
- llm/model_manager.py +265 -0
- llm/retry.py +95 -0
- main.py +246 -0
- memory/__init__.py +20 -0
- memory/compressor.py +554 -0
- memory/manager.py +538 -0
- memory/serialization.py +82 -0
- memory/short_term.py +88 -0
- memory/token_tracker.py +203 -0
- memory/types.py +51 -0
- tools/__init__.py +6 -0
- tools/advanced_file_ops.py +557 -0
- tools/base.py +51 -0
- tools/calculator.py +50 -0
- tools/code_navigator.py +975 -0
- tools/explore.py +254 -0
- tools/file_ops.py +150 -0
- tools/git_tools.py +791 -0
- tools/notify.py +69 -0
- tools/parallel_execute.py +420 -0
- tools/session_manager.py +205 -0
- tools/shell.py +147 -0
- tools/shell_background.py +470 -0
- tools/smart_edit.py +491 -0
- tools/todo.py +130 -0
- tools/web_fetch.py +673 -0
- tools/web_search.py +61 -0
- utils/__init__.py +15 -0
- utils/logger.py +105 -0
- utils/model_pricing.py +49 -0
- utils/runtime.py +75 -0
- utils/terminal_ui.py +422 -0
- utils/tui/__init__.py +39 -0
- utils/tui/command_registry.py +49 -0
- utils/tui/components.py +306 -0
- utils/tui/input_handler.py +393 -0
- utils/tui/model_ui.py +204 -0
- utils/tui/progress.py +292 -0
- utils/tui/status_bar.py +178 -0
- utils/tui/theme.py +165 -0
tools/smart_edit.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Smart code editing tool with fuzzy matching and preview capabilities.
|
|
2
|
+
|
|
3
|
+
This tool provides advanced editing features beyond the basic EditTool:
|
|
4
|
+
- Fuzzy matching: Handles whitespace and indentation differences
|
|
5
|
+
- Diff preview: Shows before/after comparison
|
|
6
|
+
- Auto backup: Creates .bak files before editing (disabled by default in git repos)
|
|
7
|
+
- Rollback: Can revert changes if editing fails
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from difflib import SequenceMatcher, unified_diff
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import aiofiles
|
|
16
|
+
import aiofiles.os
|
|
17
|
+
|
|
18
|
+
from tools.base import BaseTool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_git_repo(path: Path) -> bool:
|
|
22
|
+
"""Check if the given path is inside a git repository."""
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
26
|
+
cwd=path.parent if path.is_file() else path,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=5,
|
|
30
|
+
)
|
|
31
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
32
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SmartEditTool(BaseTool):
|
|
37
|
+
"""Intelligent code editing with fuzzy matching and safety features."""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self.fuzzy_threshold = 0.8 # Minimum similarity ratio for fuzzy matching
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def name(self) -> str:
|
|
44
|
+
return "smart_edit"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def description(self) -> str:
|
|
48
|
+
return """Intelligent code editing tool with fuzzy matching and preview.
|
|
49
|
+
|
|
50
|
+
This is the RECOMMENDED tool for editing code (prefer over edit_file).
|
|
51
|
+
|
|
52
|
+
Features:
|
|
53
|
+
- Fuzzy matching: Automatically handles whitespace/indentation differences
|
|
54
|
+
- Diff preview: Shows exactly what will change
|
|
55
|
+
- Auto backup: Creates .bak files before editing (disabled in git repos)
|
|
56
|
+
- Rollback: Automatically reverts if editing fails
|
|
57
|
+
|
|
58
|
+
Modes:
|
|
59
|
+
1. diff_replace: Find and replace code with fuzzy matching (MOST COMMON)
|
|
60
|
+
- Handles indentation/whitespace differences automatically
|
|
61
|
+
- Shows diff preview before applying
|
|
62
|
+
- Required: old_code, new_code
|
|
63
|
+
|
|
64
|
+
2. smart_insert: Insert code relative to an anchor point
|
|
65
|
+
- Find an anchor line and insert before/after it
|
|
66
|
+
- Required: anchor, code, position ('before'/'after')
|
|
67
|
+
|
|
68
|
+
3. block_edit: Edit a range of lines
|
|
69
|
+
- Replace lines from start_line to end_line
|
|
70
|
+
- Required: start_line, end_line, new_content
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
# Replace a function with fuzzy matching
|
|
74
|
+
smart_edit(
|
|
75
|
+
file_path="agent/base.py",
|
|
76
|
+
mode="diff_replace",
|
|
77
|
+
old_code="def run(self, task):\\n # old implementation",
|
|
78
|
+
new_code="def run(self, task):\\n # new implementation"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Insert after a specific line
|
|
82
|
+
smart_edit(
|
|
83
|
+
file_path="config.py",
|
|
84
|
+
mode="smart_insert",
|
|
85
|
+
anchor="class Config:",
|
|
86
|
+
code=" FEATURE_FLAG = True",
|
|
87
|
+
position="after"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
IMPORTANT:
|
|
91
|
+
- Always use fuzzy_match=True (default) for code to handle formatting
|
|
92
|
+
- Set dry_run=True first to preview changes
|
|
93
|
+
- Backup is disabled by default in git repos (use create_backup=True to force)"""
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def parameters(self) -> Dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"file_path": {"type": "string", "description": "Path to the file to edit"},
|
|
99
|
+
"mode": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"description": "Edit mode: diff_replace, smart_insert, or block_edit",
|
|
102
|
+
"enum": ["diff_replace", "smart_insert", "block_edit"],
|
|
103
|
+
},
|
|
104
|
+
"old_code": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "Code to find and replace (diff_replace mode). Can be approximate - fuzzy matching will find it.",
|
|
107
|
+
},
|
|
108
|
+
"new_code": {"type": "string", "description": "New code to insert (diff_replace mode)"},
|
|
109
|
+
"anchor": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "Anchor line to insert relative to (smart_insert mode)",
|
|
112
|
+
},
|
|
113
|
+
"code": {"type": "string", "description": "Code to insert (smart_insert mode)"},
|
|
114
|
+
"position": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "Where to insert: 'before' or 'after' anchor (smart_insert mode)",
|
|
117
|
+
"enum": ["before", "after"],
|
|
118
|
+
},
|
|
119
|
+
"start_line": {
|
|
120
|
+
"type": "integer",
|
|
121
|
+
"description": "Starting line number (block_edit mode, 1-indexed)",
|
|
122
|
+
},
|
|
123
|
+
"end_line": {
|
|
124
|
+
"type": "integer",
|
|
125
|
+
"description": "Ending line number (block_edit mode, 1-indexed, inclusive)",
|
|
126
|
+
},
|
|
127
|
+
"fuzzy_match": {
|
|
128
|
+
"type": "boolean",
|
|
129
|
+
"description": "Enable fuzzy matching for whitespace differences (default: true)",
|
|
130
|
+
},
|
|
131
|
+
"dry_run": {
|
|
132
|
+
"type": "boolean",
|
|
133
|
+
"description": "Preview changes without applying (default: false)",
|
|
134
|
+
},
|
|
135
|
+
"create_backup": {
|
|
136
|
+
"type": "boolean",
|
|
137
|
+
"description": "Create .bak backup file (default: false in git repos, true otherwise)",
|
|
138
|
+
},
|
|
139
|
+
"show_diff": {
|
|
140
|
+
"type": "boolean",
|
|
141
|
+
"description": "Show diff preview even when not dry_run (default: true)",
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async def execute(
|
|
146
|
+
self,
|
|
147
|
+
file_path: str,
|
|
148
|
+
mode: str,
|
|
149
|
+
old_code: str = "",
|
|
150
|
+
new_code: str = "",
|
|
151
|
+
anchor: str = "",
|
|
152
|
+
code: str = "",
|
|
153
|
+
position: str = "after",
|
|
154
|
+
start_line: int = 0,
|
|
155
|
+
end_line: int = 0,
|
|
156
|
+
fuzzy_match: bool = True,
|
|
157
|
+
dry_run: bool = False,
|
|
158
|
+
create_backup: Optional[bool] = None,
|
|
159
|
+
show_diff: bool = True,
|
|
160
|
+
**kwargs,
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Execute smart edit operation."""
|
|
163
|
+
try:
|
|
164
|
+
path = Path(file_path)
|
|
165
|
+
|
|
166
|
+
# Validation
|
|
167
|
+
if not await aiofiles.os.path.exists(str(path)):
|
|
168
|
+
return f"Error: File does not exist: {file_path}"
|
|
169
|
+
|
|
170
|
+
# Determine create_backup default: False in git repos, True otherwise
|
|
171
|
+
if create_backup is None:
|
|
172
|
+
create_backup = not _is_git_repo(path)
|
|
173
|
+
|
|
174
|
+
# Read original content
|
|
175
|
+
async with aiofiles.open(path, encoding="utf-8") as f:
|
|
176
|
+
original_content = await f.read()
|
|
177
|
+
|
|
178
|
+
# Execute the appropriate edit mode
|
|
179
|
+
if mode == "diff_replace":
|
|
180
|
+
result = await self._diff_replace(
|
|
181
|
+
path,
|
|
182
|
+
original_content,
|
|
183
|
+
old_code,
|
|
184
|
+
new_code,
|
|
185
|
+
fuzzy_match,
|
|
186
|
+
dry_run,
|
|
187
|
+
create_backup,
|
|
188
|
+
show_diff,
|
|
189
|
+
)
|
|
190
|
+
elif mode == "smart_insert":
|
|
191
|
+
result = await self._smart_insert(
|
|
192
|
+
path,
|
|
193
|
+
original_content,
|
|
194
|
+
anchor,
|
|
195
|
+
code,
|
|
196
|
+
position,
|
|
197
|
+
dry_run,
|
|
198
|
+
create_backup,
|
|
199
|
+
show_diff,
|
|
200
|
+
)
|
|
201
|
+
elif mode == "block_edit":
|
|
202
|
+
result = await self._block_edit(
|
|
203
|
+
path,
|
|
204
|
+
original_content,
|
|
205
|
+
start_line,
|
|
206
|
+
end_line,
|
|
207
|
+
new_code,
|
|
208
|
+
dry_run,
|
|
209
|
+
create_backup,
|
|
210
|
+
show_diff,
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
return f"Error: Unknown mode '{mode}'. Supported: diff_replace, smart_insert, block_edit"
|
|
214
|
+
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
return f"Error executing smart_edit: {str(e)}"
|
|
219
|
+
|
|
220
|
+
async def _diff_replace(
|
|
221
|
+
self,
|
|
222
|
+
path: Path,
|
|
223
|
+
original_content: str,
|
|
224
|
+
old_code: str,
|
|
225
|
+
new_code: str,
|
|
226
|
+
fuzzy_match: bool,
|
|
227
|
+
dry_run: bool,
|
|
228
|
+
create_backup: bool,
|
|
229
|
+
show_diff: bool,
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Replace code with fuzzy matching."""
|
|
232
|
+
if not old_code:
|
|
233
|
+
return "Error: old_code parameter is required for diff_replace mode"
|
|
234
|
+
|
|
235
|
+
# Try exact match first
|
|
236
|
+
similarity = 1.0 # Default for exact match
|
|
237
|
+
if old_code in original_content:
|
|
238
|
+
match_start = original_content.find(old_code)
|
|
239
|
+
match_end = match_start + len(old_code)
|
|
240
|
+
elif fuzzy_match:
|
|
241
|
+
# Try fuzzy matching
|
|
242
|
+
match_result = self._fuzzy_find(old_code, original_content)
|
|
243
|
+
if match_result is None:
|
|
244
|
+
return f"Error: Could not find code block (even with fuzzy matching).\n\nSearched for:\n{old_code[:200]}..."
|
|
245
|
+
match_start, match_end, similarity = match_result
|
|
246
|
+
|
|
247
|
+
# Show what was actually matched if similarity is not perfect
|
|
248
|
+
if similarity < 0.99:
|
|
249
|
+
matched_text = original_content[match_start:match_end]
|
|
250
|
+
info = f"\n[Fuzzy match found with {similarity:.1%} similarity]\nMatched text:\n{matched_text[:200]}...\n"
|
|
251
|
+
else:
|
|
252
|
+
info = ""
|
|
253
|
+
else:
|
|
254
|
+
return f"Error: Exact match not found and fuzzy_match is disabled.\n\nSearched for:\n{old_code[:200]}..."
|
|
255
|
+
|
|
256
|
+
# Create new content with replacement
|
|
257
|
+
new_content = original_content[:match_start] + new_code + original_content[match_end:]
|
|
258
|
+
|
|
259
|
+
# Generate diff for preview
|
|
260
|
+
diff = self._generate_diff(original_content, new_content, str(path), context_lines=3)
|
|
261
|
+
|
|
262
|
+
# Show diff if requested
|
|
263
|
+
output_parts = []
|
|
264
|
+
if show_diff or dry_run:
|
|
265
|
+
if similarity < 0.99 and fuzzy_match:
|
|
266
|
+
output_parts.append(info)
|
|
267
|
+
output_parts.append(f"Diff preview:\n{diff}\n")
|
|
268
|
+
|
|
269
|
+
# Dry run - don't actually modify
|
|
270
|
+
if dry_run:
|
|
271
|
+
output_parts.append("[DRY RUN] No changes made to file.")
|
|
272
|
+
return "\n".join(output_parts)
|
|
273
|
+
|
|
274
|
+
# Create backup if requested
|
|
275
|
+
backup_path = None
|
|
276
|
+
if create_backup:
|
|
277
|
+
backup_path = await self._create_backup(path)
|
|
278
|
+
output_parts.append(f"Created backup: {backup_path}")
|
|
279
|
+
|
|
280
|
+
# Apply changes
|
|
281
|
+
try:
|
|
282
|
+
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
|
283
|
+
await f.write(new_content)
|
|
284
|
+
output_parts.append(f"✓ Successfully edited {path}")
|
|
285
|
+
return "\n".join(output_parts)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
# Rollback if writing failed
|
|
288
|
+
if create_backup and backup_path and await aiofiles.os.path.exists(str(backup_path)):
|
|
289
|
+
await self._copy_file(backup_path, path)
|
|
290
|
+
output_parts.append(f"✗ Edit failed, restored from backup: {e}")
|
|
291
|
+
else:
|
|
292
|
+
output_parts.append(f"✗ Edit failed: {e}")
|
|
293
|
+
return "\n".join(output_parts)
|
|
294
|
+
|
|
295
|
+
async def _smart_insert(
|
|
296
|
+
self,
|
|
297
|
+
path: Path,
|
|
298
|
+
original_content: str,
|
|
299
|
+
anchor: str,
|
|
300
|
+
code: str,
|
|
301
|
+
position: str,
|
|
302
|
+
dry_run: bool,
|
|
303
|
+
create_backup: bool,
|
|
304
|
+
show_diff: bool,
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Insert code relative to an anchor line."""
|
|
307
|
+
if not anchor:
|
|
308
|
+
return "Error: anchor parameter is required for smart_insert mode"
|
|
309
|
+
if not code:
|
|
310
|
+
return "Error: code parameter is required for smart_insert mode"
|
|
311
|
+
|
|
312
|
+
lines = original_content.splitlines(keepends=True)
|
|
313
|
+
|
|
314
|
+
# Find anchor line
|
|
315
|
+
anchor_idx = None
|
|
316
|
+
for i, line in enumerate(lines):
|
|
317
|
+
if anchor in line:
|
|
318
|
+
anchor_idx = i
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
if anchor_idx is None:
|
|
322
|
+
return f"Error: Anchor line not found: {anchor}"
|
|
323
|
+
|
|
324
|
+
# Ensure code ends with newline
|
|
325
|
+
if not code.endswith("\n"):
|
|
326
|
+
code += "\n"
|
|
327
|
+
|
|
328
|
+
# Insert at appropriate position
|
|
329
|
+
if position == "before":
|
|
330
|
+
lines.insert(anchor_idx, code)
|
|
331
|
+
else: # after
|
|
332
|
+
lines.insert(anchor_idx + 1, code)
|
|
333
|
+
|
|
334
|
+
new_content = "".join(lines)
|
|
335
|
+
|
|
336
|
+
# Generate and show diff
|
|
337
|
+
output_parts = []
|
|
338
|
+
if show_diff or dry_run:
|
|
339
|
+
diff = self._generate_diff(original_content, new_content, str(path))
|
|
340
|
+
output_parts.append(f"Diff preview:\n{diff}\n")
|
|
341
|
+
|
|
342
|
+
if dry_run:
|
|
343
|
+
output_parts.append("[DRY RUN] No changes made to file.")
|
|
344
|
+
return "\n".join(output_parts)
|
|
345
|
+
|
|
346
|
+
# Create backup and apply
|
|
347
|
+
backup_path = None
|
|
348
|
+
if create_backup:
|
|
349
|
+
backup_path = await self._create_backup(path)
|
|
350
|
+
output_parts.append(f"Created backup: {backup_path}")
|
|
351
|
+
|
|
352
|
+
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
|
353
|
+
await f.write(new_content)
|
|
354
|
+
output_parts.append(f"✓ Successfully inserted code {position} anchor in {path}")
|
|
355
|
+
return "\n".join(output_parts)
|
|
356
|
+
|
|
357
|
+
async def _block_edit(
|
|
358
|
+
self,
|
|
359
|
+
path: Path,
|
|
360
|
+
original_content: str,
|
|
361
|
+
start_line: int,
|
|
362
|
+
end_line: int,
|
|
363
|
+
new_content_block: str,
|
|
364
|
+
dry_run: bool,
|
|
365
|
+
create_backup: bool,
|
|
366
|
+
show_diff: bool,
|
|
367
|
+
) -> str:
|
|
368
|
+
"""Edit a block of lines."""
|
|
369
|
+
if start_line <= 0 or end_line <= 0:
|
|
370
|
+
return "Error: line numbers must be positive (1-indexed)"
|
|
371
|
+
if start_line > end_line:
|
|
372
|
+
return "Error: start_line must be <= end_line"
|
|
373
|
+
|
|
374
|
+
lines = original_content.splitlines(keepends=True)
|
|
375
|
+
|
|
376
|
+
if start_line > len(lines) or end_line > len(lines):
|
|
377
|
+
return f"Error: line range {start_line}-{end_line} exceeds file length {len(lines)}"
|
|
378
|
+
|
|
379
|
+
# Ensure new content ends with newline
|
|
380
|
+
if not new_content_block.endswith("\n"):
|
|
381
|
+
new_content_block += "\n"
|
|
382
|
+
|
|
383
|
+
# Replace the block
|
|
384
|
+
new_lines = lines[: start_line - 1] + [new_content_block] + lines[end_line:]
|
|
385
|
+
new_content = "".join(new_lines)
|
|
386
|
+
|
|
387
|
+
# Generate and show diff
|
|
388
|
+
output_parts = []
|
|
389
|
+
if show_diff or dry_run:
|
|
390
|
+
diff = self._generate_diff(original_content, new_content, str(path))
|
|
391
|
+
output_parts.append(f"Diff preview:\n{diff}\n")
|
|
392
|
+
|
|
393
|
+
if dry_run:
|
|
394
|
+
output_parts.append("[DRY RUN] No changes made to file.")
|
|
395
|
+
return "\n".join(output_parts)
|
|
396
|
+
|
|
397
|
+
# Create backup and apply
|
|
398
|
+
backup_path = None
|
|
399
|
+
if create_backup:
|
|
400
|
+
backup_path = await self._create_backup(path)
|
|
401
|
+
output_parts.append(f"Created backup: {backup_path}")
|
|
402
|
+
|
|
403
|
+
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
|
404
|
+
await f.write(new_content)
|
|
405
|
+
output_parts.append(f"✓ Successfully edited lines {start_line}-{end_line} in {path}")
|
|
406
|
+
return "\n".join(output_parts)
|
|
407
|
+
|
|
408
|
+
def _fuzzy_find(self, target: str, text: str) -> Optional[Tuple[int, int, float]]:
|
|
409
|
+
"""
|
|
410
|
+
Find target in text using fuzzy matching.
|
|
411
|
+
|
|
412
|
+
Returns: (start_pos, end_pos, similarity_ratio) or None if not found
|
|
413
|
+
"""
|
|
414
|
+
# Normalize whitespace for matching
|
|
415
|
+
target_normalized = self._normalize_whitespace(target)
|
|
416
|
+
|
|
417
|
+
# Sliding window approach
|
|
418
|
+
target_lines = target.splitlines()
|
|
419
|
+
text_lines = text.splitlines()
|
|
420
|
+
|
|
421
|
+
best_match = None
|
|
422
|
+
best_ratio = 0
|
|
423
|
+
|
|
424
|
+
# Try different window sizes around target length
|
|
425
|
+
for window_size in range(len(target_lines), len(target_lines) + 5):
|
|
426
|
+
if window_size > len(text_lines):
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
for i in range(len(text_lines) - window_size + 1):
|
|
430
|
+
window = text_lines[i : i + window_size]
|
|
431
|
+
window_text = "\n".join(window)
|
|
432
|
+
window_normalized = self._normalize_whitespace(window_text)
|
|
433
|
+
|
|
434
|
+
# Calculate similarity
|
|
435
|
+
ratio = SequenceMatcher(None, target_normalized, window_normalized).ratio()
|
|
436
|
+
|
|
437
|
+
if ratio > best_ratio and ratio >= self.fuzzy_threshold:
|
|
438
|
+
# Found better match - calculate actual character positions
|
|
439
|
+
char_start = len("\n".join(text_lines[:i]))
|
|
440
|
+
if i > 0:
|
|
441
|
+
char_start += 1 # Account for newline
|
|
442
|
+
char_end = char_start + len(window_text)
|
|
443
|
+
|
|
444
|
+
best_match = (char_start, char_end, ratio)
|
|
445
|
+
best_ratio = ratio
|
|
446
|
+
|
|
447
|
+
return best_match
|
|
448
|
+
|
|
449
|
+
def _normalize_whitespace(self, text: str) -> str:
|
|
450
|
+
"""Normalize whitespace for fuzzy matching."""
|
|
451
|
+
# Replace multiple spaces/tabs with single space
|
|
452
|
+
# Keep line structure but normalize indentation
|
|
453
|
+
lines = []
|
|
454
|
+
for line in text.splitlines():
|
|
455
|
+
# Strip leading/trailing whitespace but keep structure
|
|
456
|
+
normalized = " ".join(line.split())
|
|
457
|
+
lines.append(normalized)
|
|
458
|
+
return "\n".join(lines)
|
|
459
|
+
|
|
460
|
+
def _generate_diff(
|
|
461
|
+
self, old_content: str, new_content: str, filename: str, context_lines: int = 3
|
|
462
|
+
) -> str:
|
|
463
|
+
"""Generate unified diff between old and new content."""
|
|
464
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
465
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
466
|
+
|
|
467
|
+
diff_lines = unified_diff(
|
|
468
|
+
old_lines,
|
|
469
|
+
new_lines,
|
|
470
|
+
fromfile=f"{filename} (original)",
|
|
471
|
+
tofile=f"{filename} (modified)",
|
|
472
|
+
lineterm="",
|
|
473
|
+
n=context_lines,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
return "".join(diff_lines)
|
|
477
|
+
|
|
478
|
+
async def _create_backup(self, path: Path) -> Path:
|
|
479
|
+
"""Create a backup file with .bak extension."""
|
|
480
|
+
backup_path = path.with_suffix(path.suffix + ".bak")
|
|
481
|
+
await self._copy_file(path, backup_path)
|
|
482
|
+
return backup_path
|
|
483
|
+
|
|
484
|
+
async def _copy_file(self, source: Path, destination: Path) -> None:
|
|
485
|
+
"""Copy a file using async IO."""
|
|
486
|
+
async with aiofiles.open(source, "rb") as src, aiofiles.open(destination, "wb") as dst:
|
|
487
|
+
while True:
|
|
488
|
+
chunk = await src.read(1024 * 1024)
|
|
489
|
+
if not chunk:
|
|
490
|
+
break
|
|
491
|
+
await dst.write(chunk)
|
tools/todo.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Todo list tool for agents to manage complex multi-step tasks."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from agent.todo import TodoList
|
|
6
|
+
from tools.base import BaseTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TodoTool(BaseTool):
|
|
10
|
+
"""Tool for managing todo lists during task execution."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, todo_list: TodoList):
|
|
13
|
+
"""Initialize with a TodoList instance.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
todo_list: The TodoList instance to manage
|
|
17
|
+
"""
|
|
18
|
+
self._todo_list = todo_list
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "manage_todo_list"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def description(self) -> str:
|
|
26
|
+
return """Manage your task list for complex multi-step work.
|
|
27
|
+
|
|
28
|
+
WHEN TO USE:
|
|
29
|
+
- Tasks with 3+ distinct steps
|
|
30
|
+
- Multi-file operations
|
|
31
|
+
- Complex workflows requiring planning
|
|
32
|
+
- Anytime you need to track progress
|
|
33
|
+
|
|
34
|
+
OPERATIONS:
|
|
35
|
+
- add: Create new tasks (requires content and activeForm)
|
|
36
|
+
- update: Change task status to pending, in_progress, or completed (requires index and status)
|
|
37
|
+
- list: View all current tasks
|
|
38
|
+
- remove: Delete a task (requires index)
|
|
39
|
+
- clear_completed: Remove all completed tasks
|
|
40
|
+
|
|
41
|
+
CRITICAL RULES:
|
|
42
|
+
- Exactly ONE task must be in_progress at any time
|
|
43
|
+
- Mark tasks completed IMMEDIATELY after finishing
|
|
44
|
+
- Use activeForm for present continuous (e.g., "Reading file" not "Read file")
|
|
45
|
+
|
|
46
|
+
EXAMPLES:
|
|
47
|
+
- add: {"content": "Read data.csv", "activeForm": "Reading data.csv"}
|
|
48
|
+
- update: {"index": 1, "status": "in_progress"}
|
|
49
|
+
- update: {"index": 1, "status": "completed"}
|
|
50
|
+
- list: {} (no parameters)"""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def parameters(self) -> Dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"operation": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Operation to perform: add, update, list, remove, or clear_completed",
|
|
58
|
+
},
|
|
59
|
+
"content": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "Todo content in imperative form (for add operation)",
|
|
62
|
+
},
|
|
63
|
+
"activeForm": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "Todo content in present continuous form (for add operation)",
|
|
66
|
+
},
|
|
67
|
+
"index": {
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"description": "1-indexed position of todo item (for update/remove operations)",
|
|
70
|
+
},
|
|
71
|
+
"status": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "New status: pending, in_progress, or completed (for update operation)",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def execute(
|
|
78
|
+
self,
|
|
79
|
+
operation: str,
|
|
80
|
+
content: str = "",
|
|
81
|
+
activeForm: str = "",
|
|
82
|
+
index: int = 0,
|
|
83
|
+
status: str = "",
|
|
84
|
+
**kwargs,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Execute todo list operation.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
operation: The operation to perform
|
|
90
|
+
content: Todo content (for add)
|
|
91
|
+
activeForm: Active form of content (for add)
|
|
92
|
+
index: Item index (for update/remove)
|
|
93
|
+
status: New status (for update)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Result message
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
# Convert index to int if it's a float (LLM may pass 1.0 instead of 1)
|
|
100
|
+
if isinstance(index, float):
|
|
101
|
+
index = int(index)
|
|
102
|
+
|
|
103
|
+
if operation == "add":
|
|
104
|
+
if not content or not activeForm:
|
|
105
|
+
return "Error: Both 'content' and 'activeForm' are required for add operation"
|
|
106
|
+
return self._todo_list.add(content, activeForm)
|
|
107
|
+
|
|
108
|
+
elif operation == "update":
|
|
109
|
+
if index <= 0:
|
|
110
|
+
return "Error: 'index' must be provided and positive for update operation"
|
|
111
|
+
if not status:
|
|
112
|
+
return "Error: 'status' must be provided for update operation"
|
|
113
|
+
return self._todo_list.update_status(index, status)
|
|
114
|
+
|
|
115
|
+
elif operation == "list":
|
|
116
|
+
return self._todo_list.format_list()
|
|
117
|
+
|
|
118
|
+
elif operation == "remove":
|
|
119
|
+
if index <= 0:
|
|
120
|
+
return "Error: 'index' must be provided and positive for remove operation"
|
|
121
|
+
return self._todo_list.remove(index)
|
|
122
|
+
|
|
123
|
+
elif operation == "clear_completed":
|
|
124
|
+
return self._todo_list.clear_completed()
|
|
125
|
+
|
|
126
|
+
else:
|
|
127
|
+
return f"Error: Unknown operation '{operation}'. Supported: add, update, list, remove, clear_completed"
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return f"Error executing todo operation: {str(e)}"
|