tarang 4.4.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.
tarang/ws/executor.py ADDED
@@ -0,0 +1,638 @@
1
+ """
2
+ Tool Executor for Local Tool Execution.
3
+
4
+ Executes tools requested by the backend agent:
5
+ - File operations (read, write, edit, delete)
6
+ - Directory operations (list, create)
7
+ - Shell commands
8
+ - Search operations
9
+
10
+ All operations are executed locally on the user's machine.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import fnmatch
16
+ import logging
17
+ import os
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, List, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Type for approval callback
26
+ ApprovalCallback = Callable[[str, str, Dict[str, Any]], bool]
27
+
28
+
29
+ class ToolExecutor:
30
+ """
31
+ Executes tools locally for the hybrid architecture.
32
+
33
+ Usage:
34
+ executor = ToolExecutor(project_root="/path/to/project")
35
+ result = await executor.execute("read_file", {"file_path": "src/main.py"})
36
+ """
37
+
38
+ # Maximum file size to read (10MB)
39
+ MAX_FILE_SIZE = 10 * 1024 * 1024
40
+
41
+ # Maximum lines to return for file reads
42
+ MAX_LINES = 2000
43
+
44
+ # Shell command timeout (seconds)
45
+ SHELL_TIMEOUT = 60
46
+
47
+ def __init__(
48
+ self,
49
+ project_root: str,
50
+ approval_callback: Optional[ApprovalCallback] = None,
51
+ ):
52
+ self.project_root = Path(project_root).resolve()
53
+ self.approval_callback = approval_callback
54
+
55
+ # Tool registry
56
+ self._tools: Dict[str, Callable] = {
57
+ "read_file": self._read_file,
58
+ "write_file": self._write_file,
59
+ "edit_file": self._edit_file,
60
+ "delete_file": self._delete_file,
61
+ "list_files": self._list_files,
62
+ "search_files": self._search_files,
63
+ "search_code": self._search_code,
64
+ "get_file_info": self._get_file_info,
65
+ "create_directory": self._create_directory,
66
+ "shell": self._shell,
67
+ }
68
+
69
+ async def execute(
70
+ self,
71
+ tool: str,
72
+ args: Dict[str, Any],
73
+ require_approval: bool = False,
74
+ ) -> Dict[str, Any]:
75
+ """
76
+ Execute a tool with the given arguments.
77
+
78
+ Args:
79
+ tool: Tool name
80
+ args: Tool arguments
81
+ require_approval: Whether to ask for user approval
82
+
83
+ Returns:
84
+ Tool result dictionary
85
+ """
86
+ if tool not in self._tools:
87
+ return {"error": f"Unknown tool: {tool}"}
88
+
89
+ # Check approval if required
90
+ if require_approval and self.approval_callback:
91
+ description = self._get_tool_description(tool, args)
92
+ approved = self.approval_callback(tool, description, args)
93
+ if not approved:
94
+ return {"skipped": True, "message": "User rejected operation"}
95
+
96
+ try:
97
+ handler = self._tools[tool]
98
+ result = await handler(**args)
99
+ return result
100
+ except TypeError as e:
101
+ return {"error": f"Invalid arguments for {tool}: {e}"}
102
+ except Exception as e:
103
+ logger.exception(f"Tool execution error: {tool}")
104
+ return {"error": str(e)}
105
+
106
+ def _resolve_path(self, file_path: str) -> Path:
107
+ """Resolve a path relative to project root."""
108
+ path = Path(file_path)
109
+
110
+ # If absolute and within project, use as-is
111
+ if path.is_absolute():
112
+ try:
113
+ path.relative_to(self.project_root)
114
+ return path
115
+ except ValueError:
116
+ # Outside project - treat as relative
117
+ path = Path(file_path.lstrip("/"))
118
+
119
+ # Resolve relative to project root
120
+ resolved = (self.project_root / path).resolve()
121
+
122
+ # Security check: ensure within project root
123
+ try:
124
+ resolved.relative_to(self.project_root)
125
+ except ValueError:
126
+ raise ValueError(f"Path escapes project root: {file_path}")
127
+
128
+ return resolved
129
+
130
+ def _get_tool_description(self, tool: str, args: Dict[str, Any]) -> str:
131
+ """Get human-readable description of tool operation."""
132
+ if tool == "read_file":
133
+ return f"Read file: {args.get('file_path', '?')}"
134
+ elif tool == "write_file":
135
+ return f"Write file: {args.get('file_path', '?')}"
136
+ elif tool == "edit_file":
137
+ return f"Edit file: {args.get('file_path', '?')}"
138
+ elif tool == "delete_file":
139
+ return f"Delete file: {args.get('file_path', '?')}"
140
+ elif tool == "shell":
141
+ return f"Run command: {args.get('command', '?')}"
142
+ elif tool == "list_files":
143
+ return f"List files: {args.get('path', '.')}"
144
+ elif tool == "search_files":
145
+ return f"Search files: {args.get('pattern', '?')}"
146
+ elif tool == "search_code":
147
+ return f"Search code index: {args.get('query', '?')}"
148
+ else:
149
+ return f"{tool}: {args}"
150
+
151
+ # Tool implementations
152
+
153
+ async def _read_file(
154
+ self,
155
+ file_path: str,
156
+ start_line: Optional[int] = None,
157
+ end_line: Optional[int] = None,
158
+ max_lines: Optional[int] = None,
159
+ ) -> Dict[str, Any]:
160
+ """Read file contents."""
161
+ path = self._resolve_path(file_path)
162
+
163
+ if not path.exists():
164
+ return {"error": f"File not found: {file_path}"}
165
+
166
+ if not path.is_file():
167
+ return {"error": f"Not a file: {file_path}"}
168
+
169
+ # Check file size
170
+ size = path.stat().st_size
171
+ if size > self.MAX_FILE_SIZE:
172
+ return {
173
+ "error": f"File too large: {size} bytes (max: {self.MAX_FILE_SIZE})",
174
+ "size": size,
175
+ }
176
+
177
+ try:
178
+ content = path.read_text(encoding="utf-8", errors="replace")
179
+ lines = content.splitlines()
180
+ total_lines = len(lines)
181
+
182
+ # Apply line range
183
+ if start_line is not None or end_line is not None:
184
+ start = (start_line or 1) - 1 # Convert to 0-based
185
+ end = end_line or total_lines
186
+ lines = lines[start:end]
187
+
188
+ # Apply max lines
189
+ max_l = max_lines or self.MAX_LINES
190
+ truncated = len(lines) > max_l
191
+ if truncated:
192
+ lines = lines[:max_l]
193
+
194
+ return {
195
+ "content": "\n".join(lines),
196
+ "total_lines": total_lines,
197
+ "lines_returned": len(lines),
198
+ "truncated": truncated,
199
+ "file_path": str(path.relative_to(self.project_root)),
200
+ }
201
+
202
+ except UnicodeDecodeError:
203
+ return {"error": f"Cannot read binary file: {file_path}"}
204
+ except Exception as e:
205
+ return {"error": f"Read error: {e}"}
206
+
207
+ async def _write_file(
208
+ self,
209
+ file_path: str,
210
+ content: str,
211
+ create_directories: bool = True,
212
+ ) -> Dict[str, Any]:
213
+ """Write content to a file."""
214
+ path = self._resolve_path(file_path)
215
+
216
+ # Create parent directories if needed
217
+ if create_directories:
218
+ path.parent.mkdir(parents=True, exist_ok=True)
219
+
220
+ try:
221
+ # Check if file exists
222
+ existed = path.exists()
223
+ old_content = path.read_text() if existed else None
224
+
225
+ # Write new content
226
+ path.write_text(content, encoding="utf-8")
227
+
228
+ return {
229
+ "success": True,
230
+ "file_path": str(path.relative_to(self.project_root)),
231
+ "created": not existed,
232
+ "lines_written": len(content.splitlines()),
233
+ "bytes_written": len(content.encode("utf-8")),
234
+ }
235
+
236
+ except Exception as e:
237
+ return {"error": f"Write error: {e}"}
238
+
239
+ async def _edit_file(
240
+ self,
241
+ file_path: str,
242
+ search: str,
243
+ replace: str,
244
+ all_occurrences: bool = False,
245
+ ) -> Dict[str, Any]:
246
+ """Edit file with search/replace."""
247
+ path = self._resolve_path(file_path)
248
+
249
+ if not path.exists():
250
+ return {"error": f"File not found: {file_path}"}
251
+
252
+ try:
253
+ content = path.read_text(encoding="utf-8")
254
+
255
+ # Count occurrences
256
+ count = content.count(search)
257
+
258
+ if count == 0:
259
+ return {
260
+ "error": "Search string not found in file",
261
+ "file_path": str(path.relative_to(self.project_root)),
262
+ }
263
+
264
+ # Perform replacement
265
+ if all_occurrences:
266
+ new_content = content.replace(search, replace)
267
+ replacements = count
268
+ else:
269
+ new_content = content.replace(search, replace, 1)
270
+ replacements = 1
271
+
272
+ path.write_text(new_content, encoding="utf-8")
273
+
274
+ return {
275
+ "success": True,
276
+ "file_path": str(path.relative_to(self.project_root)),
277
+ "replacements": replacements,
278
+ "total_occurrences": count,
279
+ }
280
+
281
+ except Exception as e:
282
+ return {"error": f"Edit error: {e}"}
283
+
284
+ async def _delete_file(self, file_path: str) -> Dict[str, Any]:
285
+ """Delete a file."""
286
+ path = self._resolve_path(file_path)
287
+
288
+ if not path.exists():
289
+ return {"error": f"File not found: {file_path}"}
290
+
291
+ try:
292
+ if path.is_file():
293
+ path.unlink()
294
+ else:
295
+ return {"error": f"Not a file: {file_path}"}
296
+
297
+ return {
298
+ "success": True,
299
+ "file_path": str(path.relative_to(self.project_root)),
300
+ "deleted": True,
301
+ }
302
+
303
+ except Exception as e:
304
+ return {"error": f"Delete error: {e}"}
305
+
306
+ async def _list_files(
307
+ self,
308
+ path: str = ".",
309
+ pattern: Optional[str] = None,
310
+ recursive: bool = True,
311
+ include_hidden: bool = False,
312
+ max_files: int = 500,
313
+ ) -> Dict[str, Any]:
314
+ """List files in a directory."""
315
+ # Handle absolute paths directly
316
+ path_obj = Path(path)
317
+ if path_obj.is_absolute():
318
+ dir_path = path_obj.resolve()
319
+ else:
320
+ dir_path = self._resolve_path(path)
321
+
322
+ if not dir_path.exists():
323
+ return {"error": f"Directory not found: {path}"}
324
+
325
+ if not dir_path.is_dir():
326
+ return {"error": f"Not a directory: {path}"}
327
+
328
+ try:
329
+ files = []
330
+ dirs = []
331
+
332
+ if recursive:
333
+ items = dir_path.rglob("*")
334
+ else:
335
+ items = dir_path.iterdir()
336
+
337
+ for item in items:
338
+ # Skip hidden files unless requested
339
+ if not include_hidden and item.name.startswith("."):
340
+ continue
341
+
342
+ # Apply pattern filter
343
+ if pattern and not fnmatch.fnmatch(item.name, pattern):
344
+ continue
345
+
346
+ # Try relative to project_root first, then to dir_path
347
+ try:
348
+ rel_path = str(item.relative_to(self.project_root))
349
+ except ValueError:
350
+ # Path is outside project_root, use relative to dir_path
351
+ try:
352
+ rel_path = str(item.relative_to(dir_path))
353
+ except ValueError:
354
+ continue
355
+
356
+ if item.is_file():
357
+ files.append(rel_path)
358
+ elif item.is_dir():
359
+ dirs.append(rel_path)
360
+
361
+ # Limit results
362
+ if len(files) + len(dirs) >= max_files:
363
+ break
364
+
365
+ return {
366
+ "files": sorted(files)[:max_files],
367
+ "directories": sorted(dirs)[:50],
368
+ "total_files": len(files),
369
+ "total_directories": len(dirs),
370
+ "truncated": len(files) >= max_files,
371
+ }
372
+
373
+ except Exception as e:
374
+ return {"error": f"List error: {e}"}
375
+
376
+ async def _search_files(
377
+ self,
378
+ pattern: str,
379
+ path: str = ".",
380
+ file_pattern: Optional[str] = None,
381
+ max_results: int = 100,
382
+ context_lines: int = 2,
383
+ ) -> Dict[str, Any]:
384
+ """Search for pattern in files."""
385
+ import re
386
+
387
+ dir_path = self._resolve_path(path)
388
+
389
+ if not dir_path.exists():
390
+ return {"error": f"Directory not found: {path}"}
391
+
392
+ try:
393
+ regex = re.compile(pattern, re.IGNORECASE)
394
+ except re.error as e:
395
+ return {"error": f"Invalid regex pattern: {e}"}
396
+
397
+ matches = []
398
+ files_searched = 0
399
+
400
+ try:
401
+ for file_path in dir_path.rglob("*"):
402
+ if not file_path.is_file():
403
+ continue
404
+
405
+ # Skip hidden and binary
406
+ if file_path.name.startswith("."):
407
+ continue
408
+
409
+ # Apply file pattern filter
410
+ if file_pattern and not fnmatch.fnmatch(file_path.name, file_pattern):
411
+ continue
412
+
413
+ # Skip large files
414
+ if file_path.stat().st_size > 1024 * 1024: # 1MB
415
+ continue
416
+
417
+ files_searched += 1
418
+
419
+ try:
420
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
421
+ lines = content.splitlines()
422
+
423
+ for i, line in enumerate(lines):
424
+ if regex.search(line):
425
+ # Get context
426
+ start = max(0, i - context_lines)
427
+ end = min(len(lines), i + context_lines + 1)
428
+ context = lines[start:end]
429
+
430
+ matches.append({
431
+ "file": str(file_path.relative_to(self.project_root)),
432
+ "line": i + 1,
433
+ "content": line.strip(),
434
+ "context": context,
435
+ })
436
+
437
+ if len(matches) >= max_results:
438
+ break
439
+
440
+ except (UnicodeDecodeError, PermissionError):
441
+ continue
442
+
443
+ if len(matches) >= max_results:
444
+ break
445
+
446
+ return {
447
+ "matches": matches,
448
+ "total_matches": len(matches),
449
+ "files_searched": files_searched,
450
+ "truncated": len(matches) >= max_results,
451
+ }
452
+
453
+ except Exception as e:
454
+ return {"error": f"Search error: {e}"}
455
+
456
+ async def _search_code(
457
+ self,
458
+ query: str,
459
+ hops: int = 1,
460
+ max_chunks: int = 10,
461
+ ) -> Dict[str, Any]:
462
+ """
463
+ Search code using BM25 + Knowledge Graph.
464
+
465
+ Uses the project's index created via /index command.
466
+ Returns relevant code chunks with their relationships.
467
+ """
468
+ try:
469
+ from tarang.context import get_retriever
470
+ except ImportError:
471
+ return {
472
+ "error": "Context retrieval module not available. Run 'pip install tarang' to install.",
473
+ "indexed": False,
474
+ }
475
+
476
+ # Get retriever for this project
477
+ retriever = get_retriever(self.project_root)
478
+
479
+ if retriever is None or not retriever.is_ready:
480
+ return {
481
+ "error": "Project not indexed. Run '/index' command first to build the code index.",
482
+ "indexed": False,
483
+ "hint": "The /index command creates a searchable index of your codebase using BM25 and a Symbol Knowledge Graph.",
484
+ }
485
+
486
+ try:
487
+ # Execute search
488
+ result = retriever.retrieve(
489
+ query=query,
490
+ hops=min(hops, 2),
491
+ max_chunks=min(max_chunks, 20),
492
+ )
493
+
494
+ # Format response
495
+ return {
496
+ "success": True,
497
+ "indexed": True,
498
+ "query": query,
499
+ "chunks": [
500
+ {
501
+ "id": c.id,
502
+ "file": c.file,
503
+ "type": c.type,
504
+ "name": c.name,
505
+ "signature": c.signature,
506
+ "content": c.content,
507
+ "line_start": c.line_start,
508
+ "line_end": c.line_end,
509
+ }
510
+ for c in result.chunks
511
+ ],
512
+ "signatures": result.signatures,
513
+ "graph": result.graph_context,
514
+ "stats": result.stats,
515
+ }
516
+
517
+ except Exception as e:
518
+ logger.exception("search_code error")
519
+ return {
520
+ "error": f"Search failed: {e}",
521
+ "indexed": True,
522
+ }
523
+
524
+ async def _get_file_info(self, file_path: str) -> Dict[str, Any]:
525
+ """Get file metadata."""
526
+ path = self._resolve_path(file_path)
527
+
528
+ if not path.exists():
529
+ return {"error": f"File not found: {file_path}"}
530
+
531
+ try:
532
+ stat = path.stat()
533
+
534
+ return {
535
+ "file_path": str(path.relative_to(self.project_root)),
536
+ "exists": True,
537
+ "is_file": path.is_file(),
538
+ "is_directory": path.is_dir(),
539
+ "size": stat.st_size,
540
+ "modified": stat.st_mtime,
541
+ "created": stat.st_ctime,
542
+ }
543
+
544
+ except Exception as e:
545
+ return {"error": f"Info error: {e}"}
546
+
547
+ async def _create_directory(
548
+ self,
549
+ path: str,
550
+ parents: bool = True,
551
+ ) -> Dict[str, Any]:
552
+ """Create a directory."""
553
+ dir_path = self._resolve_path(path)
554
+
555
+ try:
556
+ dir_path.mkdir(parents=parents, exist_ok=True)
557
+
558
+ return {
559
+ "success": True,
560
+ "path": str(dir_path.relative_to(self.project_root)),
561
+ "created": True,
562
+ }
563
+
564
+ except Exception as e:
565
+ return {"error": f"Create directory error: {e}"}
566
+
567
+ async def _shell(
568
+ self,
569
+ command: str,
570
+ cwd: Optional[str] = None,
571
+ timeout: Optional[int] = None,
572
+ env: Optional[Dict[str, str]] = None,
573
+ ) -> Dict[str, Any]:
574
+ """Execute a shell command."""
575
+ # Resolve working directory
576
+ if cwd:
577
+ work_dir = self._resolve_path(cwd)
578
+ else:
579
+ work_dir = self.project_root
580
+
581
+ if not work_dir.exists():
582
+ return {"error": f"Working directory not found: {cwd}"}
583
+
584
+ # Set timeout
585
+ cmd_timeout = timeout or self.SHELL_TIMEOUT
586
+
587
+ # Prepare environment
588
+ cmd_env = os.environ.copy()
589
+ if env:
590
+ cmd_env.update(env)
591
+
592
+ try:
593
+ # Run command
594
+ result = await asyncio.get_event_loop().run_in_executor(
595
+ None,
596
+ lambda: subprocess.run(
597
+ command,
598
+ shell=True,
599
+ cwd=work_dir,
600
+ capture_output=True,
601
+ timeout=cmd_timeout,
602
+ env=cmd_env,
603
+ ),
604
+ )
605
+
606
+ stdout = result.stdout.decode("utf-8", errors="replace")
607
+ stderr = result.stderr.decode("utf-8", errors="replace")
608
+
609
+ # Truncate long output
610
+ max_output = 50000
611
+ stdout_truncated = len(stdout) > max_output
612
+ stderr_truncated = len(stderr) > max_output
613
+
614
+ if stdout_truncated:
615
+ stdout = stdout[:max_output] + "\n... (truncated)"
616
+ if stderr_truncated:
617
+ stderr = stderr[:max_output] + "\n... (truncated)"
618
+
619
+ return {
620
+ "success": result.returncode == 0,
621
+ "exit_code": result.returncode,
622
+ "stdout": stdout,
623
+ "stderr": stderr,
624
+ "command": command,
625
+ "cwd": str(work_dir.relative_to(self.project_root)),
626
+ }
627
+
628
+ except subprocess.TimeoutExpired:
629
+ return {
630
+ "error": f"Command timed out after {cmd_timeout}s",
631
+ "command": command,
632
+ "timeout": True,
633
+ }
634
+ except Exception as e:
635
+ return {
636
+ "error": f"Shell error: {e}",
637
+ "command": command,
638
+ }