gateway/gwserver/modules/agentservice_code_helpers.py
2025-04-11 23:39:10 +02:00

750 lines
No EOL
30 KiB
Python

"""
Erweiterter Coder-Agent für die Entwicklung und Ausführung von Python-Code.
Integriert direkten Code-Executor zur Vereinfachung des Ablaufs.
"""
import logging
import json
import os
import asyncio
import re
import uuid
import subprocess
import tempfile
import traceback
import sys
import importlib.util
import inspect
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Union
from modules.agentservice_base import BaseAgent
from modules.lucydom_interface import get_lucydom_interface
from modules.agentservice_utils import FileUtils, WorkflowUtils, MessageUtils, LoggingUtils
from connectors.connector_aichat_openai import ChatService
from modules import agentservice_code_helpers
logger = logging.getLogger(__name__)
class CodeExecutor:
"""
Führt generierten Code in einer isolierten virtuellen Umgebung aus,
während Zugriff auf spezifische App-Module gewährt wird und
automatisch erforderliche Pakete installiert werden.
"""
def __init__(self,
app_modules: List[str] = None,
venv_path: Optional[str] = None,
timeout: int = 30,
max_memory_mb: int = 512,
allowed_packages: List[str] = None,
blocked_packages: List[str] = None):
"""
Initialisiert den CodeExecutor.
Args:
app_modules: Liste von Modulnamen, die dem generierten Code zur Verfügung stehen sollen
venv_path: Pfad zur virtuellen Umgebung. Falls None, wird eine temporäre erstellt
timeout: Maximale Ausführungszeit in Sekunden
max_memory_mb: Maximaler Arbeitsspeicher in MB
allowed_packages: Liste erlaubter Pakete (wenn None, werden alle erlaubt, außer blockierte)
blocked_packages: Liste blockierter Pakete (z.B. gefährliche oder ressourcenintensive)
"""
self.app_modules = app_modules or []
self.venv_path = venv_path
self.timeout = timeout
self.max_memory_mb = max_memory_mb
self.temp_dir = None
self.allowed_packages = allowed_packages
self.blocked_packages = blocked_packages or ["cryptography", "flask", "django", "tornado", "requests"]
def _create_venv(self) -> str:
"""Erstellt eine virtuelle Umgebung und gibt den Pfad zurück."""
if self.venv_path and os.path.exists(self.venv_path):
return self.venv_path
# Temporäres Verzeichnis für die virtuelle Umgebung erstellen
self.temp_dir = tempfile.mkdtemp(prefix="ai_code_exec_")
venv_path = os.path.join(self.temp_dir, "venv")
try:
# Virtuelle Umgebung erstellen
logger.info(f"Erstelle virtuelle Umgebung in {venv_path}")
subprocess.run([sys.executable, "-m", "venv", venv_path],
check=True,
capture_output=True)
return venv_path
except subprocess.CalledProcessError as e:
logger.error(f"Fehler beim Erstellen der virtuellen Umgebung: {e}")
raise RuntimeError(f"Konnte venv nicht erstellen: {e}")
def _get_pip_executable(self, venv_path: str) -> str:
"""Ermittelt den Pfad zum pip-Executable in der virtuellen Umgebung."""
if os.name == 'nt': # Windows
return os.path.join(venv_path, "Scripts", "pip.exe")
else: # Unix/Linux
return os.path.join(venv_path, "bin", "pip")
def _get_python_executable(self, venv_path: str) -> str:
"""Ermittelt den Pfad zum Python-Executable in der virtuellen Umgebung."""
if os.name == 'nt': # Windows
return os.path.join(venv_path, "Scripts", "python.exe")
else: # Unix/Linux
return os.path.join(venv_path, "bin", "python")
def _install_packages(self, packages: List[str], venv_path: str) -> Tuple[bool, str]:
"""
Installiert Pakete in der virtuellen Umgebung.
Args:
packages: Liste der zu installierenden Pakete
venv_path: Pfad zur virtuellen Umgebung
Returns:
Tuple aus (Erfolg, Fehlermeldung)
"""
if not packages:
return True, ""
# Überprüfen, ob Pakete erlaubt sind
blocked = []
for package in packages:
# Paketname ohne Version extrahieren
pkg_name = re.split('[=<>]', package)[0].strip()
if self.blocked_packages and pkg_name.lower() in [p.lower() for p in self.blocked_packages]:
blocked.append(pkg_name)
if self.allowed_packages and pkg_name.lower() not in [p.lower() for p in self.allowed_packages]:
blocked.append(pkg_name)
if blocked:
return False, f"Die folgenden Pakete sind nicht erlaubt: {', '.join(blocked)}"
# Pakete installieren
pip_executable = self._get_pip_executable(venv_path)
logger.info(f"Installiere Pakete in virtueller Umgebung: {', '.join(packages)}")
try:
# pip aktualisieren - mache diesen Schritt optional
try:
subprocess.run(
[pip_executable, "install", "--upgrade", "pip"],
check=False, # Changed from True to False to make it optional
capture_output=True,
timeout=60
)
except Exception as pip_error:
# Log the error but continue
logger.warning(f"Pip-Upgrade fehlgeschlagen, fahre mit Paketinstallation fort: {pip_error}")
# Pakete installieren
process = subprocess.run(
[pip_executable, "install"] + packages,
check=True,
capture_output=True,
text=True,
timeout=120 # 2 Minuten Timeout für Paketinstallation
)
return True, process.stdout
except subprocess.CalledProcessError as e:
error_msg = f"Fehler bei der Paketinstallation: {e.stderr}"
logger.error(error_msg)
return False, error_msg
except subprocess.TimeoutExpired:
return False, "Zeitüberschreitung bei der Paketinstallation."
except Exception as e:
return False, f"Unerwarteter Fehler bei der Paketinstallation: {str(e)}"
def _extract_required_packages(self, code: str) -> List[str]:
"""
Extrahiert benötigte Pakete aus dem Code durch Analyse von Import-Statements
und Pip-Installationsanweisungen.
Args:
code: Der Python-Code
Returns:
Liste der erkannten Paketnamen
"""
packages = set()
# Paketkommentare erkennen (# pip install package)
pip_comments = re.findall(r'#\s*pip\s+install\s+([^#\n]+)', code)
for comment in pip_comments:
for pkg in comment.split():
if pkg and not pkg.startswith('-'):
packages.add(pkg.strip())
# Import-Statements analysieren
import_lines = re.findall(r'^(?:import|from)\s+([^\s.]+)(?:\s+import|\s*$|\.)', code, re.MULTILINE)
# Standardmodule, die nicht installiert werden müssen
std_modules = {
'os', 'sys', 'time', 'datetime', 'math', 're', 'random', 'json',
'collections', 'itertools', 'functools', 'pathlib', 'shutil',
'tempfile', 'uuid', 'subprocess', 'threading', 'logging',
'traceback', 'io', 'copy'
}
# Module der App, die nicht installiert werden müssen
app_modules_prefixes = set(m.split('.')[0] for m in self.app_modules)
for module in import_lines:
if module not in std_modules and module not in app_modules_prefixes:
packages.add(module)
return list(packages)
def _create_module_loader(self) -> str:
"""
Erstellt ein Hilfsskript, das App-Module in die venv importiert.
Gibt den Pfad zum Hilfsskript zurück.
"""
if not self.app_modules:
return ""
# Temporäre Datei für den Module-Loader erstellen
module_loader_path = os.path.join(self.temp_dir or tempfile.mkdtemp(prefix="ai_code_exec_"),
"module_loader.py")
# Pfad zu den App-Modulen bestimmen
app_path = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
# Modul-Loader-Code generieren
loader_code = f"""
import sys
import importlib.util
import os
# App-Pfad zum Suchpfad hinzufügen
sys.path.insert(0, "{app_path}")
# Module importieren
modules = {{}}
"""
# Code zum Importieren der Module hinzufügen
for module_name in self.app_modules:
loader_code += f"""
try:
modules["{module_name}"] = __import__("{module_name}", fromlist=["*"])
print(f"Modul '{module_name}' erfolgreich importiert")
except ImportError as e:
print(f"Fehler beim Importieren von '{module_name}': {{e}}")
"""
# Loader-Datei schreiben
with open(module_loader_path, "w") as f:
f.write(loader_code)
return module_loader_path
def execute_code(self, code: str, input_data: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Führt den generierten Code in einer isolierten Umgebung aus.
Args:
code: Der auszuführende Python-Code
input_data: Eingabedaten für den Code (werden als JSON serialisiert)
Returns:
Dict mit Ausführungsergebnissen, Ausgabe und Fehlern
"""
# Virtuelle Umgebung erstellen oder bestehende verwenden
venv_path = self._create_venv()
# Erforderliche Pakete aus dem Code extrahieren
required_packages = self._extract_required_packages(code)
# Pakete installieren, falls erforderlich
install_success = True
install_log = ""
if required_packages:
install_success, install_log = self._install_packages(required_packages, venv_path)
if not install_success:
return {
"success": False,
"output": "",
"error": f"Fehler bei der Installation der erforderlichen Pakete: {install_log}",
"result": None,
"installed_packages": required_packages
}
# Temporäre Datei für den Code erstellen
code_id = str(uuid.uuid4())[:8]
code_file_path = os.path.join(self.temp_dir or tempfile.mkdtemp(prefix="ai_code_exec_"),
f"ai_code_{code_id}.py")
# Module-Loader erstellen
module_loader_path = self._create_module_loader()
# Eingabedaten als JSON speichern, wenn vorhanden
input_path = ""
if input_data:
import json
input_path = os.path.join(self.temp_dir or tempfile.mkdtemp(prefix="ai_code_exec_"),
f"input_{code_id}.json")
with open(input_path, "w") as f:
json.dump(input_data, f)
# Outputpfad für Ergebnisse
output_path = os.path.join(self.temp_dir or tempfile.mkdtemp(prefix="ai_code_exec_"),
f"output_{code_id}.json")
# Prepare all paths using forward slashes for consistency across platforms
safe_module_loader_path = module_loader_path.replace('\\', '/') if module_loader_path else ""
safe_input_path = input_path.replace('\\', '/') if input_path else ""
safe_output_path = output_path.replace('\\', '/')
wrapped_code = f"""
# -*- coding: utf-8 -*-
# coding: utf-8
import sys
import json
import traceback
import os
# Ergebnisstruktur
result = {{
"success": False,
"output": "",
"error": "",
"result": None,
"installed_packages": {required_packages}
}}
try:
# Module laden, falls erforderlich
if "{safe_module_loader_path}":
module_loader = __import__("module_loader")
globals().update({{k: v for k, v in module_loader.modules.items()}})
# Eingabedaten laden, falls vorhanden
input_data = None
if "{safe_input_path}":
with open("{safe_input_path}", "r") as f:
input_data = json.load(f)
# Ausgabeumleitung
from io import StringIO
original_stdout = sys.stdout
original_stderr = sys.stderr
captured_stdout = StringIO()
captured_stderr = StringIO()
sys.stdout = captured_stdout
sys.stderr = captured_stderr
# Benutzercode ausführen
try:
# Den Code in einem lokalen Namespace ausführen
local_vars = {{"input_data": input_data}}
exec('''{code}''', globals(), local_vars)
# Ergebnis speichern, falls eine Variable 'result' definiert wurde
if "result" in local_vars:
result["result"] = local_vars["result"]
result["success"] = True
except Exception as e:
result["error"] = str(e)
result["error"] += "\\n" + traceback.format_exc()
finally:
# Ausgabe erfassen
result["output"] = captured_stdout.getvalue()
result["error"] += captured_stderr.getvalue()
# Ausgabeumleitung zurücksetzen
sys.stdout = original_stdout
sys.stderr = original_stderr
except Exception as outer_e:
result["error"] = f"Fehler beim Ausführen des Setups: {{outer_e}}\\n{{traceback.format_exc()}}"
# Ergebnis speichern
with open("{safe_output_path}", "w") as f:
json.dump(result, f, default=str)
"""
# Code in temporäre Datei schreiben with UTF-8 encoding
with open(code_file_path, "w", encoding="utf-8") as f:
f.write(wrapped_code)
# Python-Interpreter aus der virtuellen Umgebung bestimmen
python_executable = self._get_python_executable(venv_path)
# Code ausführen
logger.info(f"Führe Code in virtueller Umgebung aus: {python_executable}")
try:
# Prozess mit Ressourcenbeschränkungen ausführen
cmd = [python_executable, code_file_path]
# Umgebungsvariablen setzen, um Speicherlimit zu erzwingen
env = os.environ.copy()
if self.max_memory_mb:
if os.name == 'posix': # Unix/Linux
# Auf Unix-Systemen können wir ulimit verwenden
cmd = ["bash", "-c", f"ulimit -v {self.max_memory_mb * 1024} && {python_executable} {code_file_path}"]
elif os.name == 'nt': # Windows
# Auf Windows können wir keine harten Speichergrenzen setzen, aber Job Objects verwenden
# Hier müsste eine komplexere Lösung implementiert werden
pass
# Prozess starten und mit Timeout ausführen
process = subprocess.run(
cmd,
timeout=self.timeout,
env=env,
capture_output=True,
text=True
)
# Ergebnis aus der Ausgabedatei lesen
if os.path.exists(output_path):
with open(output_path, "r") as f:
import json
execution_result = json.load(f)
else:
execution_result = {
"success": False,
"output": process.stdout,
"error": f"Keine Ergebnisdatei gefunden. Stderr: {process.stderr}",
"result": None,
"installed_packages": required_packages
}
except subprocess.TimeoutExpired:
execution_result = {
"success": False,
"output": "",
"error": f"Zeitüberschreitung bei der Ausführung (Timeout nach {self.timeout} Sekunden)",
"result": None,
"installed_packages": required_packages
}
except Exception as e:
execution_result = {
"success": False,
"output": "",
"error": f"Fehler bei der Ausführung: {str(e)}",
"result": None,
"installed_packages": required_packages
}
# Informationen zur Paketinstallation hinzufügen
if install_log:
execution_result["package_install_log"] = install_log
# Temporäre Dateien aufräumen
self._cleanup_temp_files([code_file_path, input_path, output_path])
return execution_result
def _cleanup_temp_files(self, file_paths: List[str]):
"""Räumt temporäre Dateien auf."""
for path in file_paths:
if path and os.path.exists(path):
try:
os.remove(path)
except Exception as e:
logger.warning(f"Konnte temporäre Datei nicht löschen {path}: {e}")
def cleanup(self):
"""Räumt alle temporären Ressourcen auf."""
if self.temp_dir and os.path.exists(self.temp_dir):
import shutil
try:
shutil.rmtree(self.temp_dir)
logger.info(f"Temporäres Verzeichnis gelöscht: {self.temp_dir}")
except Exception as e:
logger.warning(f"Konnte temporäres Verzeichnis nicht löschen {self.temp_dir}: {e}")
def __del__(self):
"""Aufräumen beim Garbage Collection."""
self.cleanup()
class CoderAgent(BaseAgent):
"""Erweiterter Agent für die Entwicklung und Ausführung von Python-Code"""
def __init__(self):
"""Initialize the coder agent with proper type and capabilities"""
super().__init__()
# Agent metadata
self.id = "coder"
self.type = "coder"
self.name = "Python Code Agent"
self.description = "Entwickelt und führt Python-Code aus"
self.capabilities = "code_development,data_processing,file_processing,automation"
self.result_format = "python_code"
# Init utilities
self.file_utils = FileUtils()
self.message_utils = MessageUtils()
# Executor settings
self.executor_timeout = 60 # seconds
self.executor_memory_limit = 512 # MB
# AI service settings
self.ai_temperature = 0.2 # Lower temperature for more deterministic code generation
self.ai_max_tokens = 2000 # Enough tokens for complex code
def get_agent_info(self) -> Dict[str, Any]:
"""Get agent information for agent registry"""
return {
"id": self.id,
"type": self.type,
"name": self.name,
"description": self.description,
"capabilities": self.capabilities,
"result_format": self.result_format,
"metadata": {
"timeout": self.executor_timeout,
"memory_limit": self.executor_memory_limit
}
}
async def process_message(self, message: Dict[str, Any],
workflow: Dict[str, Any],
context: Dict[str, Any] = None,
log_func=None) -> Dict[str, Any]:
"""
Processes a message to develop and execute Python code.
Args:
message: The message to process
workflow: The current workflow
context: Additional context information
log_func: Function for workflow logging
Returns:
Response message
"""
# Initialize logging
workflow_id = workflow.get("id")
logging_utils = LoggingUtils(workflow_id, log_func)
logging_utils.info(f"CoderAgent startet Verarbeitung", "agents")
# Initialize utilities
workflow_utils = WorkflowUtils(workflow_id)
# Create response message
response = self.message_utils.create_message(workflow_id, role="assistant")
response["agent_type"] = self.type
response["agent_name"] = self.name
response["parent_message_id"] = message.get("id")
try:
# Check if user directly provided code
content = message.get("content", "")
documents = message.get("documents", [])
# Extract code from message content
code_blocks = re.findall(r'```(?:python)?\s*([\s\S]*?)```', content)
code_to_execute = None
if code_blocks:
# Use the first code block found
code_to_execute = code_blocks[0]
logging_utils.info(f"Code aus Nachricht extrahiert ({len(code_to_execute)} Zeichen)", "agents")
else:
# Generate code based on the message content using OpenAI
logging_utils.info("Kein Code in der Nachricht gefunden, generiere neuen Code mit AI", "agents")
# Generate code using AI
code_to_execute = await self._generate_code_from_prompt(content, documents, context)
if not code_to_execute:
logging_utils.warning("AI konnte keinen Code generieren", "agents")
response["content"] = "Ich konnte basierend auf Ihrer Anfrage keinen ausführbaren Code generieren. Bitte geben Sie detailliertere Anweisungen an."
self.message_utils.finalize_message(response)
return response
logging_utils.info(f"Code mit AI generiert ({len(code_to_execute)} Zeichen)", "agents")
# Get database interface for code execution
mandate_id = workflow.get("mandate_id", 0)
user_id = workflow.get("user_id", 0)
lucydom_interface = get_lucydom_interface(mandate_id, user_id)
# Execute the code
if code_to_execute:
logging_utils.info("Führe Code aus", "execution")
# Prepare execution context
execution_context = {
"workflow_id": workflow_id,
"documents": documents,
"message": message,
"mandate_id": mandate_id,
"user_id": user_id
}
# Execute code
result = await self._execute_code(code_to_execute, lucydom_interface, execution_context)
# Prepare response
if result.get("success", False):
# Code execution successful
output = result.get("output", "")
execution_result = result.get("result")
logging_utils.info("Code erfolgreich ausgeführt", "execution")
# Format response content
response_content = f"## Code erfolgreich ausgeführt\n\n"
# Include the executed code
response_content += f"### Ausgeführter Code\n\n```python\n{code_to_execute}\n```\n\n"
# Include the output if available
if output:
response_content += f"### Ausgabe\n\n```\n{output}\n```\n\n"
# Include the execution result if available
if execution_result:
result_str = json.dumps(execution_result, indent=2) if isinstance(execution_result, (dict, list)) else str(execution_result)
response_content += f"### Ergebnis\n\n```\n{result_str}\n```\n\n"
response["content"] = response_content
# Process any files created by the code
if isinstance(execution_result, dict) and "created_files" in execution_result:
created_files = execution_result.get("created_files", [])
for file_info in created_files:
file_id = file_info.get("id")
if file_id:
logging_utils.info(f"Füge erstellte Datei {file_info.get('name', file_id)} zu Dokumenten hinzu", "files")
file_meta = lucydom_interface.get_file(file_id)
if file_meta:
# Add file document to the response
doc = {
"id": f"doc_{uuid.uuid4()}",
"source": file_meta,
"type": "file"
}
response["documents"].append(doc)
else:
# Code execution failed
error = result.get("error", "Unbekannter Fehler")
logging_utils.error(f"Fehler bei der Codeausführung: {error}", "execution")
# Format error response
response_content = f"## Fehler bei der Codeausführung\n\n"
response_content += f"### Ausgeführter Code\n\n```python\n{code_to_execute}\n```\n\n"
response_content += f"### Fehler\n\n```\n{error}\n```\n\n"
# Add recommendation based on error
response_content += self._get_error_recommendation(error)
response["content"] = response_content
else:
# No code to execute
response["content"] = "Ich konnte keinen ausführbaren Code finden oder generieren. Bitte geben Sie Python-Code an oder erläutern Sie Ihre Anforderungen genauer."
# Finalize response
self.message_utils.finalize_message(response)
# Log success
logging_utils.info("CoderAgent hat die Anfrage erfolgreich verarbeitet", "agents")
return response
except Exception as e:
error_msg = f"Fehler bei der Verarbeitung durch den CoderAgent: {str(e)}"
logging_utils.error(error_msg, "error")
# Create error response
response["content"] = f"## Fehler bei der Verarbeitung\n\n```\n{error_msg}\n\n{traceback.format_exc()}\n```"
self.message_utils.finalize_message(response)
return response
async def _generate_code_from_prompt(self, prompt: str, documents: List[Dict[str, Any]], context: Dict[str, Any] = None) -> str:
"""
Generate Python code from a prompt using OpenAI service.
Args:
prompt: The prompt to generate code from
documents: Documents associated with the prompt
context: Additional context information
Returns:
Generated Python code
"""
try:
# Initialize AI service
chat_service = ChatService()
# Prepare a detailed prompt for code generation
ai_prompt = self._prepare_code_prompt(prompt, documents)
# Create messages for the OpenAI API
messages = [
{"role": "system", "content": "You are a Python code generator. Generate only executable Python code without explanations. The code should be well-commented, handle errors appropriately, and follow best practices."},
{"role": "user", "content": ai_prompt}
]
# Call the OpenAI API
logging.info(f"Calling OpenAI API to generate code")
generated_content = await chat_service.call_api(messages, temperature=self.ai_temperature, max_tokens=self.ai_max_tokens)
# Extract code from the response (the AI might wrap it in markdown)
code_blocks = re.findall(r'```(?:python)?\s*([\s\S]*?)```', generated_content)
if code_blocks:
# Use the first code block found
return code_blocks[0].strip()
else:
# If no code block is found, return the raw response
return generated_content.strip()
except Exception as e:
logging.error(f"Error generating code with AI: {str(e)}", exc_info=True)
# Return a basic error-handling code
estr=str(e).replace('"', '\\"')
return f"""
# Error during code generation
print(f"An error occurred during code generation: {estr}")
# Return an error result
result = {{"error": "Code generation failed", "message": "{estr}"}}
"""
def _prepare_code_prompt(self, user_prompt: str, documents: List[Dict[str, Any]]) -> str:
"""
Prepares a detailed prompt for the AI to generate Python code.
Args:
user_prompt: The original user request
documents: Available documents
Returns:
A detailed prompt for code generation
"""
# Start with the user's request
prompt = f"""Generate Python code to solve the following task:
{user_prompt}
"""
# Add information about available documents
if documents:
prompt += "\nAvailable documents:\n"
for i, doc in enumerate(documents):
source = doc.get("source", {})
doc_name = source.get("name", f"Document {i+1}")
doc_type = source.get("content_type", "unknown")
doc_id = source.get("id", "")
prompt += f"- {doc_name} (type: {doc_type}, id: {doc_id})\n"
# Add information about how to access documents
prompt += """
To access these documents, use:
- await load_file(file_id, encoding='utf-8') for text files
- await load_file(file_id) for binary files
"""