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()