agentcode-cli 1.0.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.
tools.py ADDED
@@ -0,0 +1,672 @@
1
+ """
2
+ AgentCode - Tool definitions and execution.
3
+
4
+ These are the "hands" of the agent — the actions it can take in the real world.
5
+ Each tool maps to a function definition (for the LLM) and an implementation.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ import glob as glob_module
11
+ from pathlib import Path
12
+
13
+
14
+ # ── Configurable limits (set at startup via configure_limits) ─────────────────
15
+
16
+ _LIMITS: dict = {
17
+ "max_file_size": 1_000_000,
18
+ "max_output": 50_000,
19
+ "max_search_results": 100,
20
+ }
21
+
22
+
23
+ def configure_limits(limits) -> None:
24
+ """Apply a LimitsSettings object to tool execution. Call once at startup."""
25
+ _LIMITS["max_file_size"] = limits.max_file_size
26
+ _LIMITS["max_output"] = limits.max_output
27
+ _LIMITS["max_search_results"] = limits.max_search_results
28
+
29
+
30
+ # ── Tool Definitions (OpenAI-compatible function calling schema) ──────────────
31
+ # LiteLLM translates these to the right format for each provider.
32
+
33
+ TOOL_DEFINITIONS = [
34
+ {
35
+ "type": "function",
36
+ "function": {
37
+ "name": "read_file",
38
+ "description": (
39
+ "Read the contents of a file. Use this to understand existing code "
40
+ "before making changes. Returns the file content with line numbers."
41
+ ),
42
+ "parameters": {
43
+ "type": "object",
44
+ "properties": {
45
+ "path": {
46
+ "type": "string",
47
+ "description": "Absolute or relative path to the file to read.",
48
+ },
49
+ "start_line": {
50
+ "type": "integer",
51
+ "description": "Optional: first line to read (1-indexed). Omit to read from start.",
52
+ },
53
+ "end_line": {
54
+ "type": "integer",
55
+ "description": "Optional: last line to read (1-indexed). Omit to read to end.",
56
+ },
57
+ },
58
+ "required": ["path"],
59
+ },
60
+ },
61
+ },
62
+ {
63
+ "type": "function",
64
+ "function": {
65
+ "name": "write_file",
66
+ "description": (
67
+ "Create a new file or overwrite an existing file with the given content. "
68
+ "Use this for creating new files. For editing existing files, prefer "
69
+ "edit_file to make surgical changes."
70
+ ),
71
+ "parameters": {
72
+ "type": "object",
73
+ "properties": {
74
+ "path": {
75
+ "type": "string",
76
+ "description": "Path to the file to write.",
77
+ },
78
+ "content": {
79
+ "type": "string",
80
+ "description": "The full content to write to the file.",
81
+ },
82
+ },
83
+ "required": ["path", "content"],
84
+ },
85
+ },
86
+ },
87
+ {
88
+ "type": "function",
89
+ "function": {
90
+ "name": "edit_file",
91
+ "description": (
92
+ "Make a targeted edit to an existing file by replacing a unique string "
93
+ "with new content. The old_string must match exactly and appear only once "
94
+ "in the file. Always read the file first to get the exact text."
95
+ ),
96
+ "parameters": {
97
+ "type": "object",
98
+ "properties": {
99
+ "path": {
100
+ "type": "string",
101
+ "description": "Path to the file to edit.",
102
+ },
103
+ "old_string": {
104
+ "type": "string",
105
+ "description": "The exact string to find and replace. Must be unique in the file.",
106
+ },
107
+ "new_string": {
108
+ "type": "string",
109
+ "description": "The string to replace old_string with. Use empty string to delete.",
110
+ },
111
+ },
112
+ "required": ["path", "old_string", "new_string"],
113
+ },
114
+ },
115
+ },
116
+ {
117
+ "type": "function",
118
+ "function": {
119
+ "name": "run_command",
120
+ "description": (
121
+ "Execute a bash command in the user's shell. Use for running tests, "
122
+ "installing packages, git operations, or any shell task. Commands run "
123
+ "in the project directory. Stderr and stdout are both captured."
124
+ ),
125
+ "parameters": {
126
+ "type": "object",
127
+ "properties": {
128
+ "command": {
129
+ "type": "string",
130
+ "description": "The bash command to execute.",
131
+ },
132
+ "timeout": {
133
+ "type": "integer",
134
+ "description": "Max seconds to wait (default: 30).",
135
+ },
136
+ },
137
+ "required": ["command"],
138
+ },
139
+ },
140
+ },
141
+ {
142
+ "type": "function",
143
+ "function": {
144
+ "name": "list_directory",
145
+ "description": (
146
+ "List files and directories at a given path. Shows file sizes and types. "
147
+ "Use to understand project structure before diving into files."
148
+ ),
149
+ "parameters": {
150
+ "type": "object",
151
+ "properties": {
152
+ "path": {
153
+ "type": "string",
154
+ "description": "Directory path to list. Defaults to current directory.",
155
+ },
156
+ "depth": {
157
+ "type": "integer",
158
+ "description": "How many levels deep to list (default: 2).",
159
+ },
160
+ },
161
+ "required": [],
162
+ },
163
+ },
164
+ },
165
+ {
166
+ "type": "function",
167
+ "function": {
168
+ "name": "search_files",
169
+ "description": (
170
+ "Search for files by name pattern (glob). Use to find files matching "
171
+ "a pattern like '*.py' or 'test_*.js'."
172
+ ),
173
+ "parameters": {
174
+ "type": "object",
175
+ "properties": {
176
+ "pattern": {
177
+ "type": "string",
178
+ "description": "Glob pattern to match (e.g., '**/*.py', 'src/**/*.ts').",
179
+ },
180
+ "path": {
181
+ "type": "string",
182
+ "description": "Base directory to search from. Defaults to '.'.",
183
+ },
184
+ },
185
+ "required": ["pattern"],
186
+ },
187
+ },
188
+ },
189
+ {
190
+ "type": "function",
191
+ "function": {
192
+ "name": "search_text",
193
+ "description": (
194
+ "Search for a text pattern (regex) across files in a directory. "
195
+ "Similar to 'grep -rn'. Returns matching lines with file paths and "
196
+ "line numbers."
197
+ ),
198
+ "parameters": {
199
+ "type": "object",
200
+ "properties": {
201
+ "pattern": {
202
+ "type": "string",
203
+ "description": "Regex pattern to search for.",
204
+ },
205
+ "path": {
206
+ "type": "string",
207
+ "description": "Directory to search in. Defaults to '.'.",
208
+ },
209
+ "include": {
210
+ "type": "string",
211
+ "description": "File glob to include (e.g., '*.py'). Optional.",
212
+ },
213
+ },
214
+ "required": ["pattern"],
215
+ },
216
+ },
217
+ },
218
+ {
219
+ "type": "function",
220
+ "function": {
221
+ "name": "git_status",
222
+ "description": "Show the git working tree status — staged, unstaged, and untracked files.",
223
+ "parameters": {
224
+ "type": "object",
225
+ "properties": {},
226
+ "required": [],
227
+ },
228
+ },
229
+ },
230
+ {
231
+ "type": "function",
232
+ "function": {
233
+ "name": "git_diff",
234
+ "description": (
235
+ "Show changes in the working tree. Use staged=true to see changes "
236
+ "that are staged (indexed) for the next commit."
237
+ ),
238
+ "parameters": {
239
+ "type": "object",
240
+ "properties": {
241
+ "staged": {
242
+ "type": "boolean",
243
+ "description": "If true, show staged changes. Default false shows unstaged changes.",
244
+ },
245
+ },
246
+ "required": [],
247
+ },
248
+ },
249
+ },
250
+ {
251
+ "type": "function",
252
+ "function": {
253
+ "name": "git_log",
254
+ "description": "Show recent commit history.",
255
+ "parameters": {
256
+ "type": "object",
257
+ "properties": {
258
+ "n": {
259
+ "type": "integer",
260
+ "description": "Number of commits to show (default: 10).",
261
+ },
262
+ "oneline": {
263
+ "type": "boolean",
264
+ "description": "If true, show one line per commit.",
265
+ },
266
+ },
267
+ "required": [],
268
+ },
269
+ },
270
+ },
271
+ {
272
+ "type": "function",
273
+ "function": {
274
+ "name": "git_commit",
275
+ "description": (
276
+ "Stage files and create a commit. By default stages all modified tracked files. "
277
+ "Use files to stage specific paths, or add_all=true to include untracked files."
278
+ ),
279
+ "parameters": {
280
+ "type": "object",
281
+ "properties": {
282
+ "message": {
283
+ "type": "string",
284
+ "description": "The commit message.",
285
+ },
286
+ "files": {
287
+ "type": "array",
288
+ "items": {"type": "string"},
289
+ "description": "Specific file paths to stage. Omit to stage all modified tracked files.",
290
+ },
291
+ "add_all": {
292
+ "type": "boolean",
293
+ "description": "If true, stage everything including untracked files (git add -A).",
294
+ },
295
+ },
296
+ "required": ["message"],
297
+ },
298
+ },
299
+ },
300
+ {
301
+ "type": "function",
302
+ "function": {
303
+ "name": "git_branch",
304
+ "description": "List, create, or switch git branches.",
305
+ "parameters": {
306
+ "type": "object",
307
+ "properties": {
308
+ "action": {
309
+ "type": "string",
310
+ "enum": ["list", "create", "switch"],
311
+ "description": "list all branches, create a new branch, or switch to an existing one.",
312
+ },
313
+ "name": {
314
+ "type": "string",
315
+ "description": "Branch name — required for create and switch.",
316
+ },
317
+ },
318
+ "required": ["action"],
319
+ },
320
+ },
321
+ },
322
+ {
323
+ "type": "function",
324
+ "function": {
325
+ "name": "git_push",
326
+ "description": "Push commits to a remote repository.",
327
+ "parameters": {
328
+ "type": "object",
329
+ "properties": {
330
+ "remote": {
331
+ "type": "string",
332
+ "description": "Remote name (default: origin).",
333
+ },
334
+ "branch": {
335
+ "type": "string",
336
+ "description": "Branch to push. Omit to push the current branch.",
337
+ },
338
+ },
339
+ "required": [],
340
+ },
341
+ },
342
+ },
343
+ {
344
+ "type": "function",
345
+ "function": {
346
+ "name": "spawn_subagents",
347
+ "description": (
348
+ "Spawn multiple independent agents to work on subtasks in parallel. "
349
+ "Use when a task can be split into independent parts that don't depend on each other — "
350
+ "e.g. analyze multiple files simultaneously, run parallel searches, or investigate "
351
+ "different parts of the codebase at once. Each subagent gets its own context and "
352
+ "runs the full agentic loop. Results are returned combined."
353
+ ),
354
+ "parameters": {
355
+ "type": "object",
356
+ "properties": {
357
+ "tasks": {
358
+ "type": "array",
359
+ "items": {"type": "string"},
360
+ "description": "List of independent subtask prompts to run in parallel. Keep each task self-contained.",
361
+ },
362
+ },
363
+ "required": ["tasks"],
364
+ },
365
+ },
366
+ },
367
+ ]
368
+
369
+
370
+ # ── Tool Implementations ─────────────────────────────────────────────────────
371
+
372
+ def _read_file(path: str, start_line: int | None = None, end_line: int | None = None) -> str:
373
+ """Read a file and return its contents with line numbers."""
374
+ try:
375
+ p = Path(path).expanduser()
376
+ if not p.exists():
377
+ return f"Error: File not found: {path}"
378
+ if not p.is_file():
379
+ return f"Error: Not a file: {path}"
380
+ if p.stat().st_size > _LIMITS["max_file_size"]:
381
+ return f"Error: File too large ({p.stat().st_size} bytes). Use start_line/end_line."
382
+
383
+ lines = p.read_text(errors="replace").splitlines()
384
+ start = (start_line - 1) if start_line else 0
385
+ end = end_line if end_line else len(lines)
386
+ selected = lines[start:end]
387
+
388
+ numbered = [f"{i + start + 1:>5} │ {line}" for i, line in enumerate(selected)]
389
+ header = f"── {path} ({len(lines)} lines total, showing {start+1}-{end}) ──"
390
+ return header + "\n" + "\n".join(numbered)
391
+ except Exception as e:
392
+ return f"Error reading file: {e}"
393
+
394
+
395
+ def _write_file(path: str, content: str) -> str:
396
+ """Write content to a file, creating directories as needed."""
397
+ try:
398
+ p = Path(path).expanduser()
399
+ p.parent.mkdir(parents=True, exist_ok=True)
400
+ p.write_text(content)
401
+ return f"✓ Wrote {len(content)} bytes to {path}"
402
+ except Exception as e:
403
+ return f"Error writing file: {e}"
404
+
405
+
406
+ def _edit_file(path: str, old_string: str, new_string: str) -> str:
407
+ """Replace a unique string in a file."""
408
+ try:
409
+ p = Path(path).expanduser()
410
+ if not p.exists():
411
+ return f"Error: File not found: {path}"
412
+
413
+ content = p.read_text(errors="replace")
414
+ count = content.count(old_string)
415
+
416
+ if count == 0:
417
+ return f"Error: old_string not found in {path}. Read the file first to get exact text."
418
+ if count > 1:
419
+ return f"Error: old_string appears {count} times in {path}. Make it more specific."
420
+
421
+ new_content = content.replace(old_string, new_string, 1)
422
+ p.write_text(new_content)
423
+ return f"✓ Edited {path} (replaced 1 occurrence)"
424
+ except Exception as e:
425
+ return f"Error editing file: {e}"
426
+
427
+
428
+ def _run_command(command: str, timeout: int = 30) -> str:
429
+ """Execute a bash command and return stdout + stderr."""
430
+ try:
431
+ result = subprocess.run(
432
+ command,
433
+ shell=True,
434
+ capture_output=True,
435
+ text=True,
436
+ timeout=timeout,
437
+ cwd=os.getcwd(),
438
+ )
439
+ output_parts = []
440
+ if result.stdout:
441
+ output_parts.append(f"STDOUT:\n{result.stdout}")
442
+ if result.stderr:
443
+ output_parts.append(f"STDERR:\n{result.stderr}")
444
+ output_parts.append(f"EXIT CODE: {result.returncode}")
445
+
446
+ output = "\n".join(output_parts)
447
+ if len(output) > _LIMITS["max_output"]:
448
+ half = _LIMITS["max_output"] // 2
449
+ output = output[:half] + "\n\n... [truncated] ...\n\n" + output[-half:]
450
+ return output
451
+ except subprocess.TimeoutExpired:
452
+ return f"Error: Command timed out after {timeout}s"
453
+ except Exception as e:
454
+ return f"Error running command: {e}"
455
+
456
+
457
+ def _list_directory(path: str = ".", depth: int = 2) -> str:
458
+ """List directory contents up to a given depth."""
459
+ try:
460
+ base = Path(path).expanduser().resolve()
461
+ if not base.exists():
462
+ return f"Error: Directory not found: {path}"
463
+ if not base.is_dir():
464
+ return f"Error: Not a directory: {path}"
465
+
466
+ lines = [f"📁 {base}/"]
467
+ _tree(base, lines, prefix="", depth=depth, max_depth=depth)
468
+ return "\n".join(lines[:500]) # Cap output
469
+ except Exception as e:
470
+ return f"Error listing directory: {e}"
471
+
472
+
473
+ def _tree(directory: Path, lines: list, prefix: str, depth: int, max_depth: int):
474
+ """Recursively build a tree listing."""
475
+ if depth <= 0:
476
+ return
477
+
478
+ skip = {".git", "node_modules", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache"}
479
+ try:
480
+ entries = sorted(directory.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
481
+ except PermissionError:
482
+ lines.append(f"{prefix}[permission denied]")
483
+ return
484
+
485
+ entries = [e for e in entries if e.name not in skip and not e.name.startswith(".")]
486
+ for i, entry in enumerate(entries[:50]): # Cap per-level
487
+ connector = "└── " if i == len(entries) - 1 else "├── "
488
+ if entry.is_dir():
489
+ lines.append(f"{prefix}{connector}📁 {entry.name}/")
490
+ extension = " " if i == len(entries) - 1 else "│ "
491
+ _tree(entry, lines, prefix + extension, depth - 1, max_depth)
492
+ else:
493
+ size = entry.stat().st_size
494
+ size_str = _human_size(size)
495
+ lines.append(f"{prefix}{connector}{entry.name} ({size_str})")
496
+
497
+
498
+ def _human_size(size: int) -> str:
499
+ for unit in ("B", "KB", "MB", "GB"):
500
+ if size < 1024:
501
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
502
+ size /= 1024
503
+ return f"{size:.1f}TB"
504
+
505
+
506
+ def _search_files(pattern: str, path: str = ".") -> str:
507
+ """Search for files matching a glob pattern."""
508
+ try:
509
+ base = Path(path).expanduser().resolve()
510
+ matches = sorted(glob_module.glob(str(base / pattern), recursive=True))
511
+ cap = _LIMITS["max_search_results"]
512
+ matches = [str(Path(m).relative_to(base)) for m in matches[:cap]]
513
+ if not matches:
514
+ return f"No files matching '{pattern}' in {path}"
515
+ return f"Found {len(matches)} files:\n" + "\n".join(matches)
516
+ except Exception as e:
517
+ return f"Error searching files: {e}"
518
+
519
+
520
+ def _search_text(pattern: str, path: str = ".", include: str | None = None) -> str:
521
+ """Search for text pattern across files using grep."""
522
+ try:
523
+ cmd = ["grep", "-rn", "--color=never", "-E", pattern]
524
+ if include:
525
+ cmd.extend(["--include", include])
526
+ cmd.append(path)
527
+
528
+ result = subprocess.run(
529
+ cmd, capture_output=True, text=True, timeout=15, cwd=os.getcwd()
530
+ )
531
+ output = result.stdout
532
+ if not output:
533
+ return f"No matches for pattern '{pattern}' in {path}"
534
+ lines = output.strip().split("\n")
535
+ cap = _LIMITS["max_search_results"]
536
+ if len(lines) > cap:
537
+ return "\n".join(lines[:cap]) + f"\n... and {len(lines) - cap} more matches"
538
+ return "\n".join(lines)
539
+ except subprocess.TimeoutExpired:
540
+ return "Error: Search timed out"
541
+ except Exception as e:
542
+ return f"Error searching text: {e}"
543
+
544
+
545
+ # ── Git Tool Implementations ─────────────────────────────────────────────────
546
+
547
+ def _git_run(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
548
+ return subprocess.run(args, capture_output=True, text=True, timeout=timeout, cwd=os.getcwd())
549
+
550
+
551
+ def _git_status() -> str:
552
+ try:
553
+ r = _git_run(["git", "status"])
554
+ if r.returncode != 0:
555
+ return f"Error: {r.stderr.strip() or 'git status failed'}"
556
+ return r.stdout or "Nothing to report."
557
+ except FileNotFoundError:
558
+ return "Error: git not found."
559
+ except Exception as e:
560
+ return f"Error: {e}"
561
+
562
+
563
+ def _git_diff(staged: bool = False) -> str:
564
+ try:
565
+ cmd = ["git", "diff"] + (["--staged"] if staged else [])
566
+ r = _git_run(cmd)
567
+ if r.returncode != 0:
568
+ return f"Error: {r.stderr.strip() or 'git diff failed'}"
569
+ if not r.stdout:
570
+ return "No staged changes." if staged else "No unstaged changes."
571
+ output = r.stdout
572
+ if len(output) > _LIMITS["max_output"]:
573
+ output = output[:_LIMITS["max_output"]] + "\n... [truncated]"
574
+ return output
575
+ except Exception as e:
576
+ return f"Error: {e}"
577
+
578
+
579
+ def _git_log(n: int = 10, oneline: bool = False) -> str:
580
+ try:
581
+ fmt = "--oneline" if oneline else "--pretty=format:%h %s (%an, %ar)"
582
+ r = _git_run(["git", "log", f"-{n}", fmt])
583
+ if r.returncode != 0:
584
+ return f"Error: {r.stderr.strip() or 'git log failed'}"
585
+ return r.stdout.strip() or "No commits yet."
586
+ except Exception as e:
587
+ return f"Error: {e}"
588
+
589
+
590
+ def _git_commit(message: str, files: list[str] | None = None, add_all: bool = False) -> str:
591
+ try:
592
+ if add_all:
593
+ stage = _git_run(["git", "add", "-A"])
594
+ elif files:
595
+ stage = _git_run(["git", "add", *files])
596
+ else:
597
+ stage = _git_run(["git", "add", "-u"])
598
+
599
+ if stage.returncode != 0:
600
+ return f"Error staging files: {stage.stderr.strip()}"
601
+
602
+ r = _git_run(["git", "commit", "-m", message])
603
+ if r.returncode != 0:
604
+ return f"Error committing: {r.stderr.strip() or r.stdout.strip()}"
605
+ return r.stdout.strip()
606
+ except Exception as e:
607
+ return f"Error: {e}"
608
+
609
+
610
+ def _git_branch(action: str = "list", name: str | None = None) -> str:
611
+ try:
612
+ if action == "list":
613
+ r = _git_run(["git", "branch", "-a"])
614
+ elif action == "create":
615
+ if not name:
616
+ return "Error: branch name required for create."
617
+ r = _git_run(["git", "checkout", "-b", name])
618
+ elif action == "switch":
619
+ if not name:
620
+ return "Error: branch name required for switch."
621
+ r = _git_run(["git", "checkout", name])
622
+ else:
623
+ return f"Error: unknown action '{action}'. Use list, create, or switch."
624
+
625
+ if r.returncode != 0:
626
+ return f"Error: {r.stderr.strip() or 'git branch operation failed'}"
627
+ return r.stdout.strip() or r.stderr.strip() or "Done."
628
+ except Exception as e:
629
+ return f"Error: {e}"
630
+
631
+
632
+ def _git_push(remote: str = "origin", branch: str | None = None) -> str:
633
+ try:
634
+ cmd = ["git", "push", remote] + ([branch] if branch else [])
635
+ r = _git_run(cmd, timeout=30)
636
+ if r.returncode != 0:
637
+ return f"Error: {r.stderr.strip() or 'git push failed'}"
638
+ return r.stdout.strip() or r.stderr.strip() or "Pushed successfully."
639
+ except Exception as e:
640
+ return f"Error: {e}"
641
+
642
+
643
+ # ── Tool Router ───────────────────────────────────────────────────────────────
644
+
645
+ TOOL_MAP = {
646
+ "read_file": _read_file,
647
+ "write_file": _write_file,
648
+ "edit_file": _edit_file,
649
+ "run_command": _run_command,
650
+ "list_directory": _list_directory,
651
+ "search_files": _search_files,
652
+ "search_text": _search_text,
653
+ "git_status": _git_status,
654
+ "git_diff": _git_diff,
655
+ "git_log": _git_log,
656
+ "git_commit": _git_commit,
657
+ "git_branch": _git_branch,
658
+ "git_push": _git_push,
659
+ }
660
+
661
+
662
+ def execute_tool(name: str, args: dict) -> str:
663
+ """Route a tool call to its implementation."""
664
+ fn = TOOL_MAP.get(name)
665
+ if fn is None:
666
+ return f"Error: Unknown tool '{name}'"
667
+ try:
668
+ return fn(**args)
669
+ except TypeError as e:
670
+ return f"Error: Invalid arguments for {name}: {e}"
671
+ except Exception as e:
672
+ return f"Error executing {name}: {e}"