Files
Mark-LIV/actions/dev_agent.py
T
2026-03-02 04:30:10 +03:00

491 lines
15 KiB
Python

# actions/dev_agent.py
# AI-powered development agent — plans, builds, and debugs full projects.
#
# Flow:
# Describe project → Gemini plans file structure → Files written one by one
# → VSCode opened → Entry point executed → Error? → Identify file → Fix → Retry
# → Speaks only when done (success or failure)
#
# Models:
# Planning : gemini-2.5-flash (architecture, structure, debugging)
# Writing : gemini-2.5-flash-lite (fast file generation)
import subprocess
import sys
import json
import re
import time
from pathlib import Path
def get_base_dir():
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).resolve().parent.parent
BASE_DIR = get_base_dir()
API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json"
PROJECTS_DIR = Path.home() / "Desktop" / "JarvisProjects"
MAX_FIX_ATTEMPTS = 4
MODEL_PLANNER = "gemini-2.5-flash"
MODEL_WRITER = "gemini-2.5-flash-lite"
def _get_api_key() -> str:
with open(API_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)["gemini_api_key"]
def _get_model(model_name: str):
import google.generativeai as genai
genai.configure(api_key=_get_api_key())
return genai.GenerativeModel(model_name)
def _clean_code(text: str) -> str:
text = text.strip()
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
return text.strip()
def _clean_json(text: str) -> str:
text = text.strip()
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
return text.strip()
def _is_rate_limit(error: Exception) -> bool:
return "429" in str(error) or "quota" in str(error).lower()
def _get_interpreter(path: Path) -> list[str] | None:
return {
".py": [sys.executable],
".js": ["node"],
".ts": ["ts-node"],
".sh": ["bash"],
".ps1": ["powershell", "-File"],
".rb": ["ruby"],
".php": ["php"],
}.get(path.suffix.lower())
def _has_error(output: str) -> bool:
if "timed out" in output.lower():
return False
signals = ["error", "exception", "traceback", "syntaxerror",
"nameerror", "typeerror", "importerror", "stderr", "failed"]
return any(s in output.lower() for s in signals)
def _identify_error_file(error_output: str, project_files: list[str]) -> str | None:
"""
Try to find which file caused the error from traceback.
Returns filename or None.
"""
for line in error_output.splitlines():
for f in project_files:
if Path(f).name in line or f in line:
return f
return None
def _plan_project(description: str, language: str) -> dict:
"""
Ask Gemini to plan the full project structure.
Returns:
{
"project_name": "my_app",
"entry_point": "main.py",
"files": [
{
"path": "main.py",
"description": "Entry point, starts the app"
},
{
"path": "utils/helpers.py",
"description": "Utility functions"
}
],
"run_command": "python main.py",
"dependencies": ["requests", "flask"]
}
"""
model = _get_model(MODEL_PLANNER)
prompt = f"""You are a senior software architect.
Plan the complete file structure for the following project.
Language: {language}
Description: {description}
Return ONLY a valid JSON object with this exact structure:
{{
"project_name": "short_snake_case_name",
"entry_point": "main.py",
"files": [
{{"path": "main.py", "description": "what this file does"}},
{{"path": "utils/helpers.py", "description": "what this file does"}}
],
"run_command": "python main.py",
"dependencies": ["package1", "package2"]
}}
Rules:
- Keep it simple. Only include files that are truly necessary.
- No explanation, no markdown, no backticks. Pure JSON only.
- Entry point must be one of the files listed.
- Use relative paths only.
JSON:"""
try:
response = model.generate_content(prompt)
raw = _clean_json(response.text)
return json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Planner returned invalid JSON: {e}\nRaw: {response.text[:300]}")
def _write_file(
file_path: str,
file_description: str,
project_description: str,
all_files: list[dict],
language: str,
project_dir: Path
) -> str:
"""Write one file. Returns the generated code."""
model = _get_model(MODEL_WRITER)
file_list = "\n".join(
f" - {f['path']}: {f['description']}" for f in all_files
)
prompt = f"""You are an expert {language} developer.
Write the code for ONE specific file in a larger project.
Project goal: {project_description}
All files in this project:
{file_list}
Now write ONLY the file: {file_path}
Purpose of this file: {file_description}
Rules:
- Output ONLY the code for this file. No explanation, no markdown, no backticks.
- Import from other project files using relative imports where needed.
- Add helpful inline comments.
- Handle errors properly.
- Use modern best practices.
Code for {file_path}:"""
try:
response = model.generate_content(prompt)
code = _clean_code(response.text)
# Save file
full_path = project_dir / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(code, encoding="utf-8")
print(f"[DevAgent] ✅ Written: {file_path}")
return code
except Exception as e:
if _is_rate_limit(e):
raise RateLimitError(str(e))
raise
def _install_dependencies(dependencies: list[str], project_dir: Path) -> str:
if not dependencies:
return "No dependencies to install."
print(f"[DevAgent] 📦 Installing: {dependencies}")
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install"] + dependencies,
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=120, cwd=str(project_dir)
)
if result.returncode == 0:
return f"Installed: {', '.join(dependencies)}"
return f"Install warning: {result.stderr[:200]}"
except subprocess.TimeoutExpired:
return "Dependency install timed out."
except Exception as e:
return f"Install error: {e}"
def _open_vscode(project_dir: Path) -> bool:
vscode_paths = [
"code",
r"C:\Users\{}\AppData\Local\Programs\Microsoft VS Code\bin\code.cmd".format(
Path.home().name
),
]
for cmd in vscode_paths:
try:
subprocess.Popen(
[cmd, str(project_dir)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
shell=True
)
time.sleep(2)
print(f"[DevAgent] 💻 VSCode opened: {project_dir}")
return True
except Exception:
continue
print("[DevAgent] ⚠️ VSCode not found.")
return False
def _run_project(run_command: str, project_dir: Path, timeout: int = 30) -> str:
"""Run the project entry point, return output."""
print(f"[DevAgent] 🚀 Running: {run_command}")
try:
parts = run_command.split()
if parts[0] == "python":
parts[0] = sys.executable
result = subprocess.run(
parts,
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout, cwd=str(project_dir)
)
output = result.stdout.strip()
error = result.stderr.strip()
parts_out = []
if output: parts_out.append(f"Output:\n{output}")
if error: parts_out.append(f"Stderr:\n{error}")
return "\n\n".join(parts_out) if parts_out else "Ran with no output."
except subprocess.TimeoutExpired:
return f"Timed out after {timeout}s. (Long-running app may be working fine.)"
except FileNotFoundError as e:
return f"Command not found: {e}"
except Exception as e:
return f"Run error: {e}"
def _fix_file(
file_path: str,
current_code: str,
error_output: str,
project_description: str,
all_files: list[dict],
language: str,
project_dir: Path
) -> str:
"""Ask Gemini to fix a specific file based on error output."""
model = _get_model(MODEL_PLANNER)
file_list = "\n".join(
f" - {f['path']}: {f['description']}" for f in all_files
)
prompt = f"""You are an expert {language} debugger.
Fix the file below. It caused an error when the project was run.
Project goal: {project_description}
All files in this project:
{file_list}
File to fix: {file_path}
Error output:
{error_output[:3000]}
Current code:
{current_code}
Return ONLY the fixed code — no explanation, no markdown, no backticks.
Fixed code:"""
try:
response = model.generate_content(prompt)
fixed = _clean_code(response.text)
full_path = project_dir / file_path
full_path.write_text(fixed, encoding="utf-8")
print(f"[DevAgent] 🔧 Fixed: {file_path}")
return fixed
except Exception as e:
if _is_rate_limit(e):
raise RateLimitError(str(e))
raise
class RateLimitError(Exception):
pass
def _build_project(
description: str,
language: str,
project_name: str,
timeout: int,
speak=None,
player=None
) -> str:
"""
Full build loop:
Plan → Write files → Install deps → Open VSCode → Run → Fix loop
"""
def log(msg: str):
print(f"[DevAgent] {msg}")
if player:
player.write_log(f"[DevAgent] {msg}")
log("Planning project structure...")
try:
plan = _plan_project(description, language)
except RateLimitError:
msg = "You have reached the rate limit, sir. Please try again shortly."
if speak: speak(msg)
return msg
except ValueError as e:
msg = f"Planning failed: {e}"
if speak: speak(msg)
return msg
proj_name = project_name or plan.get("project_name", "jarvis_project")
proj_name = re.sub(r"[^\w\-]", "_", proj_name)
project_dir = PROJECTS_DIR / proj_name
project_dir.mkdir(parents=True, exist_ok=True)
files = plan.get("files", [])
entry_point = plan.get("entry_point", "main.py")
run_command = plan.get("run_command", f"python {entry_point}")
dependencies = plan.get("dependencies", [])
log(f"Project: {proj_name} | Files: {len(files)} | Entry: {entry_point}")
file_codes: dict[str, str] = {}
for file_info in files:
file_path = file_info.get("path", "")
file_desc = file_info.get("description", "")
if not file_path:
continue
log(f"Writing {file_path}...")
try:
code = _write_file(
file_path, file_desc, description,
files, language, project_dir
)
file_codes[file_path] = code
except RateLimitError:
msg = "You have reached the rate limit, sir. Please try again shortly."
if speak: speak(msg)
return msg
except Exception as e:
log(f"Failed to write {file_path}: {e}")
continue
if not file_codes:
msg = "I could not write any files for this project, sir."
if speak: speak(msg)
return msg
if dependencies:
log(f"Installing dependencies: {dependencies}")
_install_dependencies(dependencies, project_dir)
_open_vscode(project_dir)
last_output = ""
for attempt in range(1, MAX_FIX_ATTEMPTS + 1):
log(f"Running project (attempt {attempt}/{MAX_FIX_ATTEMPTS})...")
last_output = _run_project(run_command, project_dir, timeout)
log(f"Output: {last_output[:150]}")
if not _has_error(last_output):
msg = (
f"Project '{proj_name}' is working, sir. "
f"Built in {attempt} attempt{'s' if attempt > 1 else ''}. "
f"Opened in VSCode at {project_dir}."
)
if speak: speak(msg)
return f"{msg}\n\nOutput:\n{last_output}"
if attempt == MAX_FIX_ATTEMPTS:
break
error_file = _identify_error_file(last_output, list(file_codes.keys()))
if not error_file:
error_file = entry_point
log(f"Error in '{error_file}', fixing...")
try:
fixed = _fix_file(
error_file,
file_codes.get(error_file, ""),
last_output,
description,
files,
language,
project_dir
)
file_codes[error_file] = fixed
except RateLimitError:
msg = "You have reached the rate limit, sir. Please try again shortly."
if speak: speak(msg)
return msg
except Exception as e:
log(f"Fix failed: {e}")
msg = (
f"I was unable to get '{proj_name}' working after {MAX_FIX_ATTEMPTS} attempts, sir. "
f"The project is saved at {project_dir} — you can open it in VSCode and check manually."
)
if speak: speak(msg)
return f"{msg}\n\nLast error:\n{last_output[:500]}"
def dev_agent(
parameters: dict,
response=None,
player=None,
session_memory=None,
speak=None
) -> str:
"""
Called from main.py.
parameters:
description : What the project should do (required)
language : Programming language (default: python)
project_name : Optional folder name (auto-generated if not given)
timeout : Run timeout in seconds (default: 30)
"""
p = parameters or {}
description = p.get("description", "").strip()
language = p.get("language", "python").strip()
project_name = p.get("project_name", "").strip()
timeout = int(p.get("timeout", 30))
if not description:
return "Please describe the project you want me to build, sir."
return _build_project(
description = description,
language = language,
project_name = project_name,
timeout = timeout,
speak = speak,
player = player
)