alpiecode 0.6.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.
codeagent/tools.py ADDED
@@ -0,0 +1,718 @@
1
+ """
2
+ Tool definitions and implementations for AlpieCode.
3
+
4
+ 11 tools total:
5
+ Files: read_file, write_file, edit_file, list_files, file_search, apply_patch
6
+ Execute: bash
7
+ Web: web_search, fetch_url
8
+ Agent: request_user_input, update_plan
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import textwrap
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .guardian import gate_command
20
+
21
+ # ── Tool schemas (OpenAI function-calling format) ─────────────────────
22
+
23
+ TOOLS = [
24
+ {
25
+ "type": "function",
26
+ "function": {
27
+ "name": "bash",
28
+ "description": (
29
+ "Run a shell command in the repo working directory. Returns stdout/stderr/exit_code. "
30
+ "Prefer file tools (read_file, edit_file) over shell for reading/writing files. "
31
+ "Use this for running tests, builds, git commands, and other CLI operations. "
32
+ "IMPORTANT: Commands run WITHOUT a TTY (no terminal). Interactive programs, "
33
+ "ncurses/curses apps, TUI apps, and terminal games will NOT work — they produce "
34
+ "garbage output or hang. Do NOT attempt to run interactive programs through this tool. "
35
+ "For interactive apps, verify correctness via: clean compilation + code review instead."
36
+ ),
37
+ "parameters": {
38
+ "type": "object",
39
+ "properties": {"command": {"type": "string", "description": "The shell command to execute"}},
40
+ "required": ["command"],
41
+ },
42
+ },
43
+ },
44
+ {
45
+ "type": "function",
46
+ "function": {
47
+ "name": "read_file",
48
+ "description": "Read a file's contents with line numbers, optionally restricted to a line range.",
49
+ "parameters": {
50
+ "type": "object",
51
+ "properties": {
52
+ "path": {"type": "string", "description": "Relative path to the file from repo root"},
53
+ "start_line": {"type": "integer", "description": "First line to read (1-indexed)"},
54
+ "end_line": {"type": "integer", "description": "Last line to read (1-indexed, inclusive)"},
55
+ },
56
+ "required": ["path"],
57
+ },
58
+ },
59
+ },
60
+ {
61
+ "type": "function",
62
+ "function": {
63
+ "name": "write_file",
64
+ "description": "Create or overwrite a file with the given content.",
65
+ "parameters": {
66
+ "type": "object",
67
+ "properties": {
68
+ "path": {"type": "string", "description": "Relative path to the file"},
69
+ "content": {"type": "string", "description": "The full content to write"},
70
+ },
71
+ "required": ["path", "content"],
72
+ },
73
+ },
74
+ },
75
+ {
76
+ "type": "function",
77
+ "function": {
78
+ "name": "edit_file",
79
+ "description": (
80
+ "Replace one exact occurrence of old_str with new_str in a file. "
81
+ "The old_str must match exactly (including whitespace/indentation). "
82
+ "You must read_file first before editing."
83
+ ),
84
+ "parameters": {
85
+ "type": "object",
86
+ "properties": {
87
+ "path": {"type": "string", "description": "Relative path to the file"},
88
+ "old_str": {"type": "string", "description": "Exact string to find (must match exactly once)"},
89
+ "new_str": {"type": "string", "description": "Replacement string"},
90
+ },
91
+ "required": ["path", "old_str", "new_str"],
92
+ },
93
+ },
94
+ },
95
+ {
96
+ "type": "function",
97
+ "function": {
98
+ "name": "list_files",
99
+ "description": "List files in the repository tree (respects .gitignore). Returns file paths.",
100
+ "parameters": {
101
+ "type": "object",
102
+ "properties": {
103
+ "path": {"type": "string", "description": "Subdirectory to list (default: repo root)"},
104
+ "max_depth": {"type": "integer", "description": "Max directory depth (default: 4)"},
105
+ },
106
+ "required": [],
107
+ },
108
+ },
109
+ },
110
+ {
111
+ "type": "function",
112
+ "function": {
113
+ "name": "file_search",
114
+ "description": (
115
+ "Search for a pattern across files in the repository using regex or literal matching. "
116
+ "Returns matching lines with file paths and line numbers. Like ripgrep/grep."
117
+ ),
118
+ "parameters": {
119
+ "type": "object",
120
+ "properties": {
121
+ "pattern": {"type": "string", "description": "Search pattern (regex or literal string)"},
122
+ "path": {"type": "string", "description": "Subdirectory to search in (default: repo root)"},
123
+ "include": {"type": "string", "description": "File glob pattern to include (e.g., '*.py')"},
124
+ "case_insensitive": {"type": "boolean", "description": "Case-insensitive search (default: false)"},
125
+ },
126
+ "required": ["pattern"],
127
+ },
128
+ },
129
+ },
130
+ {
131
+ "type": "function",
132
+ "function": {
133
+ "name": "apply_patch",
134
+ "description": (
135
+ "Apply a unified diff patch to a file. The patch should be in standard unified diff format "
136
+ "with --- and +++ headers, @@ hunk markers, and +/- line prefixes."
137
+ ),
138
+ "parameters": {
139
+ "type": "object",
140
+ "properties": {
141
+ "path": {"type": "string", "description": "Relative path to the file to patch"},
142
+ "patch": {"type": "string", "description": "Unified diff content to apply"},
143
+ },
144
+ "required": ["path", "patch"],
145
+ },
146
+ },
147
+ },
148
+ {
149
+ "type": "function",
150
+ "function": {
151
+ "name": "web_search",
152
+ "description": (
153
+ "Search the web for information. Returns relevant results with titles, URLs, and snippets. "
154
+ "Use for looking up documentation, error messages, API references, etc."
155
+ ),
156
+ "parameters": {
157
+ "type": "object",
158
+ "properties": {
159
+ "query": {"type": "string", "description": "Search query"},
160
+ "num_results": {"type": "integer", "description": "Number of results (default: 5, max: 10)"},
161
+ },
162
+ "required": ["query"],
163
+ },
164
+ },
165
+ },
166
+ {
167
+ "type": "function",
168
+ "function": {
169
+ "name": "fetch_url",
170
+ "description": "Fetch and extract text content from a URL. Returns the page content as plain text.",
171
+ "parameters": {
172
+ "type": "object",
173
+ "properties": {
174
+ "url": {"type": "string", "description": "URL to fetch"},
175
+ },
176
+ "required": ["url"],
177
+ },
178
+ },
179
+ },
180
+ {
181
+ "type": "function",
182
+ "function": {
183
+ "name": "request_user_input",
184
+ "description": (
185
+ "Ask the user a clarifying question when the task is ambiguous or you need "
186
+ "a decision before proceeding. Returns the user's response."
187
+ ),
188
+ "parameters": {
189
+ "type": "object",
190
+ "properties": {
191
+ "question": {"type": "string", "description": "The question to ask the user"},
192
+ },
193
+ "required": ["question"],
194
+ },
195
+ },
196
+ },
197
+ {
198
+ "type": "function",
199
+ "function": {
200
+ "name": "update_plan",
201
+ "description": (
202
+ "Write or update a structured task plan. Call this before making edits to document "
203
+ "what you intend to do, which files will be changed, and how you'll verify success."
204
+ ),
205
+ "parameters": {
206
+ "type": "object",
207
+ "properties": {
208
+ "plan": {
209
+ "type": "string",
210
+ "description": "The structured plan: deliverables, files to change, and verification checks",
211
+ },
212
+ },
213
+ "required": ["plan"],
214
+ },
215
+ },
216
+ },
217
+ {
218
+ "type": "function",
219
+ "function": {
220
+ "name": "view_image",
221
+ "description": (
222
+ "Inspect an image file (.png, .jpg, .jpeg, .webp, .gif, .svg) in the repository. "
223
+ "Encodes and returns image description / metadata for visual analysis."
224
+ ),
225
+ "parameters": {
226
+ "type": "object",
227
+ "properties": {
228
+ "path": {"type": "string", "description": "Relative path to the image file"},
229
+ },
230
+ "required": ["path"],
231
+ },
232
+ },
233
+ },
234
+ {
235
+ "type": "function",
236
+ "function": {
237
+ "name": "github_issues",
238
+ "description": (
239
+ "Fetch issues and pull requests from a GitHub repository. "
240
+ "Can list issues or get full details of a specific issue including comments. "
241
+ "Use this to understand bugs, feature requests, and discussions in open-source projects."
242
+ ),
243
+ "parameters": {
244
+ "type": "object",
245
+ "properties": {
246
+ "owner": {"type": "string", "description": "Repository owner (e.g., 'pytorch')"},
247
+ "repo": {"type": "string", "description": "Repository name (e.g., 'pytorch')"},
248
+ "issue_number": {"type": "integer", "description": "Specific issue number to get full details (optional)"},
249
+ "state": {"type": "string", "description": "Filter by state: 'open', 'closed', or 'all' (default: 'open')"},
250
+ "max_results": {"type": "integer", "description": "Max issues to return (default: 10, max: 30)"},
251
+ },
252
+ "required": ["owner", "repo"],
253
+ },
254
+ },
255
+ },
256
+ {
257
+ "type": "function",
258
+ "function": {
259
+ "name": "github_browse",
260
+ "description": (
261
+ "Browse a GitHub repository's structure, files, and metadata without cloning. "
262
+ "Can list directory contents or read a specific file directly from GitHub. "
263
+ "Use this for quick exploration before deciding whether to clone."
264
+ ),
265
+ "parameters": {
266
+ "type": "object",
267
+ "properties": {
268
+ "owner": {"type": "string", "description": "Repository owner (e.g., 'facebook')"},
269
+ "repo": {"type": "string", "description": "Repository name (e.g., 'react')"},
270
+ "path": {"type": "string", "description": "File or directory path to browse (default: root)"},
271
+ "info_only": {"type": "boolean", "description": "If true, return only repo metadata (stars, description, etc.)"},
272
+ },
273
+ "required": ["owner", "repo"],
274
+ },
275
+ },
276
+ },
277
+ {
278
+ "type": "function",
279
+ "function": {
280
+ "name": "clone_repo",
281
+ "description": (
282
+ "Clone a GitHub repository into the working directory for deep analysis, editing, and testing. "
283
+ "Use github_browse first for quick exploration; only clone when you need to make edits or run tests. "
284
+ "Clones with --depth 1 (shallow) to save time and disk space."
285
+ ),
286
+ "parameters": {
287
+ "type": "object",
288
+ "properties": {
289
+ "repo_url": {
290
+ "type": "string",
291
+ "description": "GitHub repo URL or owner/repo shorthand (e.g., 'pytorch/pytorch' or 'https://github.com/pytorch/pytorch')",
292
+ },
293
+ "branch": {"type": "string", "description": "Branch to clone (default: main/master)"},
294
+ },
295
+ "required": ["repo_url"],
296
+ },
297
+ },
298
+ },
299
+ ]
300
+
301
+
302
+ # ── Tool implementations ──────────────────────────────────────────────
303
+
304
+ def _build_venv_env(workdir: Path) -> dict:
305
+ """Build an environment dict with .venv/bin (or .venv/Scripts on Windows) prepended to PATH."""
306
+ env = os.environ.copy()
307
+ venv_bin = workdir / ".venv" / ("Scripts" if os.name == "nt" else "bin")
308
+ if not venv_bin.is_dir():
309
+ venv_bin = workdir / ".venv" / ("bin" if os.name == "nt" else "Scripts")
310
+ if venv_bin.is_dir():
311
+ path_sep = ";" if os.name == "nt" else ":"
312
+ env["PATH"] = str(venv_bin) + path_sep + env.get("PATH", "")
313
+ env["VIRTUAL_ENV"] = str(workdir / ".venv")
314
+ env.pop("PYTHONHOME", None)
315
+ return env
316
+
317
+
318
+ def _smart_truncate(text: str, max_chars: int = 6000) -> str:
319
+ """Truncate long output keeping both head and tail (where errors usually are).
320
+
321
+ Compiler errors appear at the top (file:line: error: ...) while the
322
+ bottom often has summary counts. Keeping both ends gives the model
323
+ far better error visibility than tail-only truncation.
324
+ """
325
+ if len(text) <= max_chars:
326
+ return text
327
+ head_size = int(max_chars * 0.6) # 60% head — where real errors live
328
+ tail_size = max_chars - head_size # 40% tail — summaries and counts
329
+ omitted = len(text) - head_size - tail_size
330
+ return (
331
+ text[:head_size]
332
+ + f"\n\n... [{omitted} chars omitted] ...\n\n"
333
+ + text[-tail_size:]
334
+ )
335
+
336
+
337
+ def _bash(workdir: Path, command: str) -> str:
338
+ """Run a shell command with guardian safety gate.
339
+
340
+ Cross-platform support for Linux, macOS, and native Windows.
341
+ """
342
+ # ── Offline-aware command interception ────────────────────────────
343
+ if getattr(_bash, '_offline_mode', False):
344
+ # Block package install commands when offline (no internet)
345
+ install_patterns = ['pip install', 'uv pip install', 'uv add', 'npm install', 'apt install', 'apt-get install']
346
+ cmd_lower = command.lower().strip()
347
+ for pattern in install_patterns:
348
+ if pattern in cmd_lower:
349
+ return json.dumps({
350
+ "stdout": "",
351
+ "stderr": f"⚠️ OFFLINE MODE: '{pattern}' blocked — no internet available. Use only standard library modules.",
352
+ "exit_code": 1,
353
+ })
354
+ # Auto-replace pytest with unittest
355
+ if 'pytest' in command or '-m pytest' in command:
356
+ command = command.replace('python -m pytest', 'python -m unittest')
357
+ command = command.replace('pytest', 'python -m unittest')
358
+
359
+ if not gate_command(command, auto_approve=True):
360
+ return json.dumps({
361
+ "stdout": "",
362
+ "stderr": "Command blocked by safety gate. Run it manually if needed.",
363
+ "exit_code": -1,
364
+ })
365
+ try:
366
+ import shutil
367
+ env = _build_venv_env(workdir)
368
+
369
+ # Cross-platform shell resolution
370
+ if shutil.which("bash"):
371
+ shell_cmd = ["bash", "-c", command]
372
+ elif os.name == "nt":
373
+ if shutil.which("powershell"):
374
+ shell_cmd = ["powershell", "-NoProfile", "-Command", command]
375
+ else:
376
+ shell_cmd = ["cmd.exe", "/c", command]
377
+ else:
378
+ shell_cmd = ["/bin/sh", "-c", command]
379
+
380
+ result = subprocess.run(
381
+ shell_cmd,
382
+ cwd=workdir, capture_output=True, text=True, timeout=300, env=env,
383
+ )
384
+
385
+ stdout = _smart_truncate(result.stdout, 6000)
386
+ stderr = _smart_truncate(result.stderr, 4000)
387
+
388
+ output = json.dumps({
389
+ "stdout": stdout,
390
+ "stderr": stderr,
391
+ "exit_code": result.returncode,
392
+ })
393
+
394
+ # Inject clear error signal for failed commands
395
+ if result.returncode != 0:
396
+ output = (
397
+ f"⚠️ COMMAND FAILED (exit_code={result.returncode}). "
398
+ f"Read the error output carefully and fix the ROOT CAUSE.\n"
399
+ + output
400
+ )
401
+
402
+ return output
403
+ except subprocess.TimeoutExpired:
404
+ return json.dumps({"stdout": "", "stderr": "Command timed out after 300s", "exit_code": -1})
405
+
406
+
407
+ def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int = None) -> str:
408
+ target = workdir / path
409
+ if not target.exists():
410
+ return f"error: file not found: {path}"
411
+ if not target.is_file():
412
+ return f"error: not a file: {path}"
413
+ try:
414
+ lines = target.read_text(errors="replace").splitlines()
415
+ except Exception as e:
416
+ return f"error reading file: {e}"
417
+ total = len(lines)
418
+ if start_line is not None:
419
+ lines = lines[start_line - 1 : end_line if end_line else None]
420
+ numbered = [f"{i + (start_line or 1):4d} | {line}" for i, line in enumerate(lines)]
421
+ result = "\n".join(numbered)
422
+ if start_line is not None:
423
+ result += f"\n\n[Showing lines {start_line}-{end_line or total} of {total} total]"
424
+ return result
425
+
426
+
427
+ def _write_file(workdir: Path, path: str, content: str) -> str:
428
+ p = workdir / path
429
+ p.parent.mkdir(parents=True, exist_ok=True)
430
+ p.write_text(content)
431
+ return f"wrote {len(content)} bytes to {path}"
432
+
433
+
434
+ def _edit_file(workdir: Path, path: str, old_str: str, new_str: str) -> str:
435
+ p = workdir / path
436
+ if not p.exists():
437
+ return f"error: file not found: {path}"
438
+ text = p.read_text()
439
+ count = text.count(old_str)
440
+ if count == 0:
441
+ return "error: old_str not found in file. Make sure it matches exactly (including whitespace)."
442
+ if count > 1:
443
+ return f"error: old_str matched {count} times, need exactly 1 match. Use a more specific old_str."
444
+ p.write_text(text.replace(old_str, new_str, 1))
445
+ return "edit applied"
446
+
447
+
448
+ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
449
+ target = workdir / path
450
+ if not target.exists():
451
+ return f"error: path not found: {path}"
452
+
453
+ # Try git ls-files first
454
+ try:
455
+ result = subprocess.run(
456
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
457
+ cwd=target, capture_output=True, text=True, timeout=10
458
+ )
459
+ if result.returncode == 0 and result.stdout.strip():
460
+ files = result.stdout.strip().splitlines()
461
+ filtered = [f for f in files if f.count(os.sep) < max_depth]
462
+ if filtered:
463
+ return "\n".join(sorted(filtered[:200]))
464
+ except (subprocess.TimeoutExpired, FileNotFoundError):
465
+ pass
466
+
467
+ # Fallback: os.walk
468
+ entries = []
469
+ for root, dirs, files in os.walk(target):
470
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
471
+ rel_root = os.path.relpath(root, target)
472
+ depth = 0 if rel_root == "." else rel_root.count(os.sep) + 1
473
+ if depth >= max_depth:
474
+ dirs.clear()
475
+ continue
476
+ for fname in sorted(files):
477
+ if fname.startswith("."):
478
+ continue
479
+ rel_path = os.path.join(rel_root, fname) if rel_root != "." else fname
480
+ entries.append(rel_path)
481
+ return "\n".join(entries[:200]) or "(empty directory)"
482
+
483
+
484
+ def _file_search(workdir: Path, pattern: str, path: str = ".", include: str = None,
485
+ case_insensitive: bool = False) -> str:
486
+ """Search for a pattern across files using grep."""
487
+ target = workdir / path
488
+
489
+ # Build grep command
490
+ cmd = ["grep", "-rn", "--color=never"]
491
+ if case_insensitive:
492
+ cmd.append("-i")
493
+ if include:
494
+ cmd.extend(["--include", include])
495
+ # Exclude common non-code directories
496
+ for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
497
+ cmd.extend(["--exclude-dir", excl])
498
+ cmd.extend([pattern, str(target)])
499
+
500
+ try:
501
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=workdir)
502
+ output = result.stdout.strip()
503
+ if not output:
504
+ return f"No matches found for pattern: {pattern}"
505
+
506
+ lines = output.splitlines()
507
+ if len(lines) > 50:
508
+ return "\n".join(lines[:50]) + f"\n\n... ({len(lines) - 50} more matches)"
509
+ return output
510
+ except subprocess.TimeoutExpired:
511
+ return "error: search timed out after 30s"
512
+ except FileNotFoundError:
513
+ return "error: grep not available on this system"
514
+
515
+
516
+ def _apply_patch(workdir: Path, path: str, patch: str) -> str:
517
+ """Apply a unified diff patch to a file."""
518
+ target = workdir / path
519
+ if not target.exists():
520
+ return f"error: file not found: {path}"
521
+
522
+ try:
523
+ # Write patch to temp file and apply
524
+ patch_file = workdir / ".alpiecode_patch.tmp"
525
+ # Ensure patch has proper file headers
526
+ full_patch = patch
527
+ if not patch.startswith("---"):
528
+ full_patch = f"--- a/{path}\n+++ b/{path}\n{patch}"
529
+ patch_file.write_text(full_patch)
530
+
531
+ result = subprocess.run(
532
+ ["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)],
533
+ cwd=workdir, capture_output=True, text=True, timeout=10
534
+ )
535
+ patch_file.unlink(missing_ok=True)
536
+
537
+ if result.returncode == 0:
538
+ return f"patch applied successfully to {path}"
539
+ else:
540
+ return f"error applying patch: {result.stderr or result.stdout}"
541
+ except FileNotFoundError:
542
+ # Fallback: manual line-by-line patch application
543
+ patch_file = workdir / ".alpiecode_patch.tmp"
544
+ patch_file.unlink(missing_ok=True)
545
+ return "error: 'patch' command not available. Use edit_file instead."
546
+ except Exception as e:
547
+ patch_file = workdir / ".alpiecode_patch.tmp"
548
+ patch_file.unlink(missing_ok=True)
549
+ return f"error applying patch: {e}"
550
+
551
+
552
+ def _web_search(query: str, num_results: int = 5) -> str:
553
+ """Search the web using DDGS (DuckDuckGo)."""
554
+ import warnings
555
+ warnings.filterwarnings("ignore", category=RuntimeWarning)
556
+
557
+ try:
558
+ try:
559
+ from ddgs import DDGS
560
+ except ImportError:
561
+ from duckduckgo_search import DDGS
562
+
563
+ with DDGS() as ddgs:
564
+ results = list(ddgs.text(query, max_results=min(num_results, 10)))
565
+ if not results:
566
+ return (
567
+ f"No web results found for '{query}'. "
568
+ "Tip: If searching for Python package documentation, use bash: python -c 'import pkg; help(pkg)'"
569
+ )
570
+ formatted = []
571
+ for i, r in enumerate(results, 1):
572
+ formatted.append(f"{i}. **{r.get('title', 'No title')}**\n URL: {r.get('href', '')}\n {r.get('body', '')}")
573
+ return "\n\n".join(formatted)
574
+ except ImportError:
575
+ return "error: web search unavailable — install ddgs or duckduckgo-search package"
576
+ except Exception as e:
577
+ return f"error during web search: {e}. Tip: Try python -c 'help(...)' for installed library docs."
578
+
579
+
580
+ def _fetch_url(url: str) -> str:
581
+ """Fetch and extract text content from a URL."""
582
+ try:
583
+ import urllib.request
584
+ req = urllib.request.Request(url, headers={"User-Agent": "AlpieCode/0.2.0"})
585
+ with urllib.request.urlopen(req, timeout=15) as resp:
586
+ html = resp.read().decode("utf-8", errors="replace")
587
+
588
+ # Try to extract text with beautifulsoup if available
589
+ try:
590
+ from bs4 import BeautifulSoup
591
+ soup = BeautifulSoup(html, "html.parser")
592
+ # Remove script and style elements
593
+ for tag in soup(["script", "style", "nav", "footer", "header"]):
594
+ tag.decompose()
595
+ text = soup.get_text(separator="\n", strip=True)
596
+ except ImportError:
597
+ # Fallback: basic HTML tag stripping
598
+ text = re.sub(r"<[^>]+>", "", html)
599
+ text = re.sub(r"\s+", " ", text).strip()
600
+
601
+ # Truncate to reasonable length
602
+ if len(text) > 8000:
603
+ text = text[:8000] + f"\n\n... (truncated, {len(text)} chars total)"
604
+ return text
605
+ except Exception as e:
606
+ return f"error fetching URL: {e}"
607
+
608
+
609
+ def _request_user_input(question: str) -> str:
610
+ """Ask the user a question and return their response."""
611
+ try:
612
+ from rich.console import Console
613
+ from rich.panel import Panel
614
+ console = Console()
615
+ console.print(Panel(
616
+ f"[bold]{question}[/bold]",
617
+ title="❓ Agent needs your input",
618
+ border_style="yellow",
619
+ ))
620
+ response = console.input("[bold green]Your answer ❯[/bold green] ").strip()
621
+ except ImportError:
622
+ print(f"\n❓ Agent needs your input: {question}")
623
+ response = input("Your answer ❯ ").strip()
624
+
625
+ return response or "(no response)"
626
+
627
+
628
+ def _update_plan(workdir: Path, plan: str) -> str:
629
+ """Save the agent's task plan to a file."""
630
+ plan_path = workdir / ".alpiecode_plan.md"
631
+ plan_path.write_text(f"# AlpieCode Task Plan\n\n{plan}\n")
632
+ return f"plan saved to .alpiecode_plan.md"
633
+
634
+
635
+ def _view_image(workdir: Path, path: str) -> str:
636
+ """Inspect and return metadata + base64 data for an image file."""
637
+ target = workdir / path
638
+ if not target.exists():
639
+ return f"error: image file not found: {path}"
640
+
641
+ import base64
642
+ ext = target.suffix.lower().lstrip(".")
643
+ mime_type = f"image/{'jpeg' if ext in ('jpg', 'jpeg') else ext}"
644
+
645
+ try:
646
+ size = target.stat().st_size
647
+ encoded = base64.b64encode(target.read_bytes()).decode("utf-8")
648
+ # Truncate string for string representation, model will get image context
649
+ return f"[Image {path} | Type: {mime_type} | Size: {size} bytes | Base64 Length: {len(encoded)}]"
650
+ except Exception as e:
651
+ return f"error reading image: {e}"
652
+
653
+
654
+ # ── GitHub tool implementations ───────────────────────────────────────
655
+
656
+ def _github_issues(owner: str, repo: str, issue_number: int = None,
657
+ state: str = "open", max_results: int = 10) -> str:
658
+ """Fetch issues/PRs from a GitHub repo."""
659
+ from .github import fetch_issues, fetch_issue_detail
660
+ if issue_number:
661
+ return fetch_issue_detail(owner, repo, issue_number)
662
+ return fetch_issues(owner, repo, state=state, max_results=max_results)
663
+
664
+
665
+ def _github_browse(owner: str, repo: str, path: str = "",
666
+ info_only: bool = False) -> str:
667
+ """Browse a GitHub repo's structure and files."""
668
+ from .github import fetch_repo_info, fetch_repo_tree
669
+ if info_only:
670
+ return fetch_repo_info(owner, repo)
671
+ if path:
672
+ return fetch_repo_tree(owner, repo, path)
673
+ # Return repo info + root tree
674
+ info = fetch_repo_info(owner, repo)
675
+ tree = fetch_repo_tree(owner, repo)
676
+ return f"=== Repository Info ===\n{info}\n\n=== Root Directory ===\n{tree}"
677
+
678
+
679
+ def _clone_repo(workdir: Path, repo_url: str, branch: str = None) -> str:
680
+ """Clone a GitHub repo into the workdir."""
681
+ from .github import clone_repo
682
+ return clone_repo(repo_url, workdir, branch=branch)
683
+
684
+
685
+ # ── Dispatch factory ──────────────────────────────────────────────────
686
+
687
+ def make_dispatch(workdir: Path):
688
+ """Bind tool implementations to a specific working directory."""
689
+ return {
690
+ "bash": lambda a: _bash(workdir, a["command"]),
691
+ "read_file": lambda a: _read_file(workdir, a["path"], a.get("start_line"), a.get("end_line")),
692
+ "write_file": lambda a: _write_file(workdir, a["path"], a["content"]),
693
+ "edit_file": lambda a: _edit_file(workdir, a["path"], a["old_str"], a["new_str"]),
694
+ "list_files": lambda a: _list_files(workdir, a.get("path", "."), a.get("max_depth", 4)),
695
+ "file_search": lambda a: _file_search(workdir, a["pattern"], a.get("path", "."),
696
+ a.get("include"), a.get("case_insensitive", False)),
697
+ "apply_patch": lambda a: _apply_patch(workdir, a["path"], a["patch"]),
698
+ "web_search": lambda a: _web_search(a["query"], a.get("num_results", 5)),
699
+ "fetch_url": lambda a: _fetch_url(a["url"]),
700
+ "request_user_input": lambda a: _request_user_input(a["question"]),
701
+ "update_plan": lambda a: _update_plan(workdir, a["plan"]),
702
+ "view_image": lambda a: _view_image(workdir, a["path"]),
703
+ "github_issues": lambda a: _github_issues(
704
+ a["owner"], a["repo"],
705
+ issue_number=a.get("issue_number"),
706
+ state=a.get("state", "open"),
707
+ max_results=a.get("max_results", 10),
708
+ ),
709
+ "github_browse": lambda a: _github_browse(
710
+ a["owner"], a["repo"],
711
+ path=a.get("path", ""),
712
+ info_only=a.get("info_only", False),
713
+ ),
714
+ "clone_repo": lambda a: _clone_repo(
715
+ workdir, a["repo_url"],
716
+ branch=a.get("branch"),
717
+ ),
718
+ }