import argparse import base64 import json import os import re import subprocess import sys import tempfile import textwrap import threading import time import urllib.request import urllib.error OLLAMA_URL = "http://localhost:11434" # Importar utilidades rich de Colmena con fallback mínimo try: from colmena_animations import HexLoader as BeeSpinner, MessageReceiver, SplashScreen except Exception: class BeeSpinner: def __init__(self, message="Colmena cargando"): self.message = message def start(self): return self def stop(self): pass def __enter__(self): return self.start() def __exit__(self, *args): self.stop() MessageReceiver = None SplashScreen = None try: import colmena_tts except Exception: colmena_tts = None MODEL = "colmena-one" EMBEDDING_MODEL = "nomic-embed-text:latest" VISION_MODEL = "colmena-vision" MAX_ITERATIONS = 6 SYSTEM_PROMPT = """Eres Colmena-Agente: la versión operativa de Colmena-One con herramientas reales en la máquina local. REGLAS DURAS: - Solo invoca herramientas si realmente necesitas datos o acciones externas para responder. - Nunca inventes resultados de herramientas. Si no puedes ejecutar algo, di "no verificado". - Para operaciones destructivas o mutadoras (borrar, sobrescribir, ejecutar código/shell) pide confirmación al usuario o indícame que use --yes. - No reveles secretos, tokens, contraseñas ni datos sensibles del usuario. - Responde siempre en español mexicano: corto, claro, sin humo. - Eres experto en múltiples lenguajes: Python, JavaScript/TypeScript, Go, Rust, C/C++, Java, Kotlin, shell/PowerShell y más. Para conocer el código de tus repositorios indexados, primero usa search_codebase. PROTOCOLO DE HERRAMIENTAS: - Invoca herramientas mediante tool_calls en JSON. - Recibirás los resultados y podrás invocar otra herramienta o responder al usuario. - Si el resultado es muy largo, resume lo relevante para la tarea. - Si una herramienta no alcanza, explica por qué y detente. """ TOOLS = [ { "type": "function", "function": { "name": "read_file", "description": "Lee el contenido de un archivo de texto local.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta del archivo (absoluta o relativa al directorio de trabajo)."}, "limit": {"type": "integer", "description": "Máximo de líneas a leer (default 200)."}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "write_file", "description": "Crea o sobrescribe un archivo de texto. USAR SOLO si el usuario lo pide explícitamente o es obvio que quiere guardar algo.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta del archivo a crear/sobrescribir."}, "content": {"type": "string", "description": "Contenido completo del archivo."}, }, "required": ["path", "content"], }, }, }, { "type": "function", "function": { "name": "edit_file", "description": "Edita un archivo reemplazando old_string por new_string. USAR SOLO si el usuario pide modificar un archivo existente.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta del archivo."}, "old_string": {"type": "string", "description": "Texto exacto a reemplazar (debe aparecer en el archivo)."}, "new_string": {"type": "string", "description": "Texto nuevo que ocupará su lugar."}, }, "required": ["path", "old_string", "new_string"], }, }, }, { "type": "function", "function": { "name": "list_directory", "description": "Lista archivos y carpetas de un directorio.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta del directorio (default directorio actual)."}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "search_files", "description": "Busca un patrón de texto dentro de archivos de un directorio.", "parameters": { "type": "object", "properties": { "pattern": {"type": "string", "description": "Texto o regex a buscar."}, "path": {"type": "string", "description": "Directorio donde buscar."}, "include": {"type": "string", "description": "Glob de archivos a incluir (default '*')."}, }, "required": ["pattern", "path"], }, }, }, { "type": "function", "function": { "name": "run_shell", "description": "Ejecuta un comando en la shell local (PowerShell en Windows, bash en Linux/Mac). USAR SOLO cuando sea necesario y seguro.", "parameters": { "type": "object", "properties": { "command": {"type": "string", "description": "Comando a ejecutar."}, "explanation": {"type": "string", "description": "Breve explicación de por qué es necesario."}, }, "required": ["command", "explanation"], }, }, }, { "type": "function", "function": { "name": "run_python", "description": "Ejecuta código Python temporalmente en un entorno aislado (un script temporal). Útil para cálculos, transformaciones de datos o automatización segura.", "parameters": { "type": "object", "properties": { "code": {"type": "string", "description": "Código Python completo a ejecutar."}, "explanation": {"type": "string", "description": "Breve explicación de qué hace el código."}, }, "required": ["code", "explanation"], }, }, }, { "type": "function", "function": { "name": "web_fetch", "description": "Descarga el contenido de una URL pública (GET) y lo devuelve como texto. Útil para leer documentación o artículos.", "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "URL completa (debe empezar con http:// o https://)."}, "max_chars": {"type": "integer", "description": "Máximo de caracteres a devolver (default 6000)."}, }, "required": ["url"], }, }, }, { "type": "function", "function": { "name": "compute_embedding", "description": "Genera un embedding vectorial de un texto usando nomic-embed-text. Útil para búsqueda semántica y comparación de similitud.", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "Texto a vectorizar."}, }, "required": ["text"], }, }, }, { "type": "function", "function": { "name": "analyze_image", "description": "Analiza una imagen usando colmena-vision (basado en gemma3:4b). Devuelve una descripción o interpretación de la imagen.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta de la imagen (jpg, png, etc.)."}, "prompt": {"type": "string", "description": "Pregunta o instrucción sobre la imagen (default: describe lo que ves)."}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "get_environment_summary", "description": "Obtiene un resumen del entorno: sistema operativo, modelos Ollama disponibles y directorio actual.", "parameters": { "type": "object", "properties": {}, }, }, }, { "type": "function", "function": { "name": "search_codebase", "description": "Busca información semántica en repositorios indexados con embeddings (usa la base de vectores de Colmena). Usar cuando la pregunta sea sobre código o documentación de tus repos.", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Pregunta o términos de búsqueda en lenguaje natural."}, "top_k": {"type": "integer", "description": "Cantidad máxima de resultados (default 5)."}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "index_codebase", "description": "Indexa un directorio/repositorio en la base de vectores de Colmena para búsqueda semántica futura. Puede tardar varios minutos en repos grandes.", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Ruta del repositorio/directorio a indexar."}, }, "required": ["path"], }, }, }, ] DANGEROUS_SHELL = [ "rm ", "rm -", "remove-item", "del ", "erase", "format", "shutdown", "restart-computer", "stop-service", "kill ", "taskkill", "rd ", "rmdir", "> ", ">> ", "out-file", "set-content", "clear-content", "reg delete", "mkfs", "dd if", ":(){ :|:& };:", "Invoke-Expression", "iex", "wget -O", "curl -O", "Invoke-WebRequest", "certutil -f", "bitsadmin", ] WORKING_DIR = os.getcwd() def is_path_safe(path): """Marca como insegura rutas absolutas fuera del working dir o con '..'. En modo --yes se permite todo, aquí solo reportamos.""" abs_path = os.path.abspath(path) try: rel = os.path.relpath(abs_path, WORKING_DIR) except Exception: return False, abs_path if rel.startswith("..") or os.path.isabs(path): return False, abs_path return True, abs_path def truncate(text, length=6000, indicator="\n... (truncado)"): if text and len(text) > length: return text[:length] + indicator return text def confirm(msg): try: ans = input(f"\n⚠️ {msg}\n ¿Continuar? [s/N]: ").strip().lower() except EOFError: return False return ans in ("s", "si", "sí", "y", "yes") def extract_json_tool_calls(content): """Extrae objetos JSON que parezcan llamadas a herramientas del contenido del LLM.""" candidates = [] try: obj = json.loads(content.strip()) candidates.append(obj) except Exception: pass for block in re.findall(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL): try: obj = json.loads(block) candidates.append(obj) except Exception: pass for block in re.findall(r"\s*(\{.*?\})\s*", content, re.DOTALL): try: obj = json.loads(block) candidates.append(obj) except Exception: pass if not candidates: brace_count = 0 start = -1 for i, ch in enumerate(content): if ch == "{": if brace_count == 0: start = i brace_count += 1 elif ch == "}": brace_count -= 1 if brace_count == 0 and start >= 0: try: obj = json.loads(content[start:i + 1]) candidates.append(obj) except Exception: pass start = -1 calls = [] for obj in candidates: if not isinstance(obj, dict): continue def norm_args(a): if isinstance(a, dict): return a if isinstance(a, str): try: return json.loads(a) except Exception: return {} return {} if "function" in obj: func = obj["function"] if isinstance(func, dict) and "name" in func: args = norm_args(func.get("arguments", {})) calls.append({"function": {"name": func["name"], "arguments": args}}) elif "name" in obj: args = norm_args(obj.get("arguments", {})) calls.append({"function": {"name": obj["name"], "arguments": args}}) return calls def ollama_chat(messages, tools=None): payload = { "model": MODEL, "messages": messages, "stream": False, "options": {"temperature": 0.3, "num_ctx": 8192}, } if tools: payload["tools"] = tools data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( f"{OLLAMA_URL}/api/chat", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with BeeSpinner("🐝 Colmena agente pensando"): with urllib.request.urlopen(req, timeout=900) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: return {"error": f"HTTP {e.code}: {e.read().decode('utf-8', errors='ignore')}"} except Exception as e: return {"error": str(e)} def ollama_generate(model, prompt): payload = {"model": model, "prompt": prompt, "stream": False} data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( f"{OLLAMA_URL}/api/generate", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with BeeSpinner("🐝 Colmena agente razonando"): with urllib.request.urlopen(req, timeout=900) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: return {"error": f"HTTP {e.code}: {e.read().decode('utf-8', errors='ignore')}"} except Exception as e: return {"error": str(e)} def ollama_embeddings(text): payload = {"model": EMBEDDING_MODEL, "prompt": text} data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( f"{OLLAMA_URL}/api/embeddings", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with BeeSpinner("🐝 Generando embedding"): with urllib.request.urlopen(req, timeout=120) as resp: return json.loads(resp.read().decode("utf-8")) except Exception as e: return {"error": str(e)} def tool_read_file(path, limit=200): try: safe, abs_path = is_path_safe(path) if not safe: return f"⚠️ Ruta fuera del directorio de trabajo habitual. Para operar aquí, el usuario debe usar --yes. Ruta: {abs_path}" with open(abs_path, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() total = len(lines) if limit and total > limit: content = "".join(lines[:limit]) return f"(mostrando {limit} de {total} líneas)\n{content}" return "".join(lines) except Exception as e: return f"Error leyendo archivo: {e}" def tool_write_file(path, content, auto_confirm=False): safe, abs_path = is_path_safe(path) if not safe and not auto_confirm: return f"⚠️ Ruta fuera del directorio de trabajo habitual. Usa --yes para permitir escribir aquí: {abs_path}" if os.path.exists(abs_path) and not auto_confirm: if not confirm(f"El archivo ya existe: {abs_path}\n¿Sobrescribir?"): return "Escritura cancelada por el usuario." try: os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True) with open(abs_path, "w", encoding="utf-8") as f: f.write(content) return f"Archivo escrito exitosamente: {abs_path} ({len(content)} caracteres)." except Exception as e: return f"Error escribiendo archivo: {e}" def tool_edit_file(path, old_string, new_string, auto_confirm=False): safe, abs_path = is_path_safe(path) if not safe and not auto_confirm: return f"⚠️ Ruta fuera del directorio de trabajo habitual. Usa --yes para permitir editar aquí: {abs_path}" try: with open(abs_path, "r", encoding="utf-8", errors="ignore") as f: text = f.read() if old_string not in text: return "No se encontró old_string en el archivo. Operación cancelada." if not auto_confirm: if not confirm(f"Se va a modificar el archivo: {abs_path}\n¿Continuar?"): return "Edición cancelada por el usuario." text = text.replace(old_string, new_string, 1) with open(abs_path, "w", encoding="utf-8") as f: f.write(text) return f"Archivo editado exitosamente: {abs_path}" except Exception as e: return f"Error editando archivo: {e}" def tool_list_directory(path="."): try: entries = os.listdir(path) lines = [] for e in entries[:200]: full = os.path.join(path, e) kind = "DIR " if os.path.isdir(full) else "FILE" size = "" if os.path.isfile(full): size = f" ({os.path.getsize(full)} bytes)" lines.append(f"{kind}: {e}{size}") if len(entries) > 200: lines.append(f"... y {len(entries)-200} entradas más") return "\n".join(lines) if lines else "(directorio vacío)" except Exception as e: return f"Error listando directorio: {e}" def tool_search_files(pattern, path, include="*"): results = [] try: for root, dirs, files in os.walk(path): dirs[:] = [d for d in dirs if d not in {".git", "node_modules", "__pycache__", ".venv", "venv", ".ollama"}] for fn in files: if include != "*" and not re.search(include.replace("*", ".*"), fn): continue full = os.path.join(root, fn) try: with open(full, "r", encoding="utf-8", errors="ignore") as f: for i, line in enumerate(f, 1): if re.search(pattern, line, re.IGNORECASE): results.append(f"{full}:{i}: {line.strip()}") if len(results) >= 50: break if len(results) >= 50: break except Exception: continue if len(results) >= 50: break if not results: return "No se encontraron coincidencias." return "\n".join(results[:50]) except Exception as e: return f"Error buscando archivos: {e}" def is_dangerous(command): c = command.lower() return any(k in c for k in DANGEROUS_SHELL) def tool_run_shell(command, explanation, auto_confirm=False): if is_dangerous(command) and not auto_confirm: if not confirm(f"Comando potencialmente destructivo:\n {command}\nRazón: {explanation}\n¿Ejecutar?"): return "Comando cancelado por el usuario." try: if os.name == "nt": proc = subprocess.run( ["powershell", "-NoProfile", "-Command", command], capture_output=True, text=True, timeout=60, shell=False, ) else: proc = subprocess.run(command, capture_output=True, text=True, timeout=60, shell=True) out = proc.stdout or "" err = proc.stderr or "" if proc.returncode != 0: return truncate(f"Exit code {proc.returncode}\nSTDOUT:\n{out}\nSTDERR:\n{err}", 4000) combined = (out + err).strip() return truncate(combined or "(comando ejecutado sin salida)", 4000) except Exception as e: return f"Error ejecutando comando: {e}" def tool_run_python(code, explanation, auto_confirm=False): if not auto_confirm: summary = textwrap.shorten(code, width=120, placeholder="...") if not confirm(f"Se va a ejecutar código Python:\n {summary}\nRazón: {explanation}\n¿Continuar?"): return "Ejecución cancelada por el usuario." try: fd, tmp_path = tempfile.mkstemp(suffix=".py") os.write(fd, code.encode("utf-8")) os.close(fd) proc = subprocess.run( [sys.executable, tmp_path], capture_output=True, text=True, timeout=60, cwd=WORKING_DIR, ) try: os.remove(tmp_path) except Exception: pass out = proc.stdout or "" err = proc.stderr or "" if proc.returncode != 0: return truncate(f"Exit code {proc.returncode}\nSTDOUT:\n{out}\nSTDERR:\n{err}", 4000) return truncate(out.strip() or "(script ejecutado sin salida)", 4000) except Exception as e: return f"Error ejecutando Python: {e}" def tool_web_fetch(url, max_chars=6000): if not url.startswith(("http://", "https://")): return "URL inválida. Solo se permiten http:// o https://" try: req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 Colmena-Agent"}) with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read(200_000) charset = resp.headers.get_content_charset() or "utf-8" text = raw.decode(charset, errors="ignore") return truncate(text, max_chars) except Exception as e: return f"Error descargando URL: {e}" def tool_compute_embedding(text): resp = ollama_embeddings(text) if "error" in resp: return f"Error en embeddings: {resp['error']}" vec = resp.get("embedding", []) if not vec: return "No se recibió embedding." preview = ", ".join(f"{v:.4f}" for v in vec[:8]) return f"Embedding generado. Dimensiones: {len(vec)}. Primeros valores: [{preview}, ...]" def tool_analyze_image(path, prompt="describe lo que ves", auto_confirm=False): safe, abs_path = is_path_safe(path) if not safe and not auto_confirm: return f"⚠️ Ruta fuera del directorio de trabajo habitual. Usa --yes para analizar aquí: {abs_path}" try: with open(abs_path, "rb") as f: b64 = base64.b64encode(f.read()).decode("utf-8") except Exception as e: return f"Error leyendo imagen: {e}" messages = [ { "role": "user", "content": prompt, "images": [b64], } ] return ollama_chat_raw(VISION_MODEL, messages) def ollama_chat_raw(model, messages): payload = {"model": model, "messages": messages, "stream": False} data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( f"{OLLAMA_URL}/api/chat", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with BeeSpinner("🐝 Colmena visión analizando"): with urllib.request.urlopen(req, timeout=120) as resp: d = json.loads(resp.read().decode("utf-8")) return d.get("message", {}).get("content", "(sin respuesta)") except Exception as e: return f"Error en chat con visión: {e}" def tool_get_environment_summary(): try: import platform models = "no verificado" try: req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET") with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) names = [m.get("name") for m in data.get("models", [])] models = ", ".join(names) if names else "ninguno" except Exception as e: models = f"error: {e}" return f"OS: {platform.system()} {platform.release()}\nDir de trabajo: {WORKING_DIR}\nModelos Ollama: {models}" except Exception as e: return f"Error resumiendo entorno: {e}" DEFAULT_VECTOR_DB = os.path.join(os.path.expanduser("~"), ".colmena", "vectordb.json") def _indexer_path(): local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "colmena-index.py") return local if os.path.exists(local) else "colmena-index.py" def tool_search_codebase(query, top_k=5): if not os.path.exists(DEFAULT_VECTOR_DB): return "No hay base de vectores indexada. Ejecutá primero: python colmena-index.py index " cmd = [sys.executable, _indexer_path(), "search", query, "--db", DEFAULT_VECTOR_DB, "--top-k", str(top_k)] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) return truncate((proc.stdout or "") + (proc.stderr or ""), 6000) except Exception as e: return f"Error buscando en base de vectores: {e}" def tool_index_codebase(path): cmd = [sys.executable, _indexer_path(), "index", path, "--db", DEFAULT_VECTOR_DB] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) return truncate((proc.stdout or "") + (proc.stderr or ""), 6000) except Exception as e: return f"Error indexando repositorio: {e}" def execute_tool(call, auto_confirm=False): name = call.get("function", {}).get("name") args = call.get("function", {}).get("arguments", {}) or {} if isinstance(args, str): try: args = json.loads(args) except Exception: args = {} if name == "read_file": return name, tool_read_file(args.get("path"), args.get("limit", 200)) elif name == "write_file": return name, tool_write_file(args.get("path"), args.get("content", ""), auto_confirm) elif name == "edit_file": return name, tool_edit_file(args.get("path"), args.get("old_string", ""), args.get("new_string", ""), auto_confirm) elif name == "list_directory": return name, tool_list_directory(args.get("path", ".")) elif name == "search_files": return name, tool_search_files(args.get("pattern"), args.get("path"), args.get("include", "*")) elif name == "run_shell": return name, tool_run_shell(args.get("command"), args.get("explanation", ""), auto_confirm) elif name == "run_python": return name, tool_run_python(args.get("code"), args.get("explanation", ""), auto_confirm) elif name == "web_fetch": return name, tool_web_fetch(args.get("url"), args.get("max_chars", 6000)) elif name == "compute_embedding": return name, tool_compute_embedding(args.get("text", "")) elif name == "analyze_image": return name, tool_analyze_image(args.get("path"), args.get("prompt", "describe lo que ves"), auto_confirm) elif name == "get_environment_summary": return name, tool_get_environment_summary() elif name == "search_codebase": return name, tool_search_codebase(args.get("query"), args.get("top_k", 5)) elif name == "index_codebase": return name, tool_index_codebase(args.get("path")) else: return name, f"Herramienta desconocida: {name}" def main(): parser = argparse.ArgumentParser( description="Colmena-Agente: agente local con herramientas reales." ) parser.add_argument("prompt", nargs="?", help="Tarea o pregunta para el agente.") parser.add_argument( "--yes", action="store_true", help="Permite operaciones destructivas/mutadoras sin confirmación interactiva (¡cuidado!).", ) parser.add_argument( "--max-iters", type=int, default=MAX_ITERATIONS, help=f"Máximo de iteraciones de herramientas (default {MAX_ITERATIONS}).", ) parser.add_argument( "--voice", default=None, help="Hablar la respuesta final con una voz/preset (ej: memo, sabina, david, zira, 0, 1). Requiere pyttsx3.", ) parser.add_argument( "--voice-list", action="store_true", help="Listar voces/presets disponibles y salir.", ) args = parser.parse_args() if args.voice_list: if colmena_tts: colmena_tts.print_voices() else: print("⚠️ colmena_tts no disponible. Instalá pyttsx3.") sys.exit(0) if not args.prompt: parser.error("Se requiere un PROMPT. Ejemplo: colmena-agent.py 'lee README.md'") if SplashScreen: SplashScreen.show() messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": args.prompt}, ] for i in range(args.max_iters): resp = ollama_chat(messages, tools=TOOLS) if "error" in resp: print(f"❌ Error de Ollama: {resp['error']}") sys.exit(1) message = resp.get("message", {}) content = message.get("content", "") tool_calls = message.get("tool_calls") # Adaptar si el modelo devuelve JSON en texto en vez de tool_calls nativo if not tool_calls and content: parsed = extract_json_tool_calls(content) if parsed: tool_calls = parsed content = re.sub(r".*?", "", content, flags=re.DOTALL).strip() content = re.sub(r"```(?:json)?\s*.*?\s*```", "", content, flags=re.DOTALL).strip() if not tool_calls: if MessageReceiver and sys.stdout.isatty(): MessageReceiver("📨 Mensaje entrante").play_and_wait() print(content or "(sin respuesta)") if args.voice and content and colmena_tts: speech_text = content[:400] try: colmena_tts.speak(speech_text, voice=args.voice) except Exception as e: print(f"⚠️ Error TTS: {e}", file=sys.stderr) return print(f"🛠️ Iteración {i+1}: invocando {len(tool_calls)} herramienta(s)...") for call in tool_calls: name, result = execute_tool(call, auto_confirm=args.yes) print(f" → {name}") # No imprimir el resultado completo para no saturar la conversación; dejarlo en messages if len(result) > 300: print(f" ({len(result)} caracteres devueltos)") else: for line in result.splitlines()[:3]: print(f" {line}") messages.append({ "role": "assistant", "content": content, "tool_calls": [call], }) messages.append({ "role": "tool", "content": result, }) print("⚠️ Se alcanzó el máximo de iteraciones. El agente no terminó de responder.") if __name__ == "__main__": main()