generado bot de telegram con capacidades de kanboard
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
import time
|
||||
import json
|
||||
import requests
|
||||
import traceback
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from functools import wraps
|
||||
|
||||
|
||||
class LokiLogger:
|
||||
"""
|
||||
Logger compatible con Grafana Loki / Alloy.
|
||||
Envía logs en formato JSON a través del endpoint HTTP de Loki.
|
||||
"""
|
||||
|
||||
ALLOWED_LEVELS = (
|
||||
"TRACE",
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARN",
|
||||
"ERROR",
|
||||
"FATAL",
|
||||
"CRITICAL",
|
||||
"UNKNOWN",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str = "http://127.0.0.1:3101/loki/api/v1/push",
|
||||
add_labels: Optional[Dict[str, str]] = None,
|
||||
timeout: float = 5.0,
|
||||
min_level: str = "DEBUG",
|
||||
service_name: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
:param endpoint: URL completa del endpoint de Loki / Alloy.
|
||||
:param add_labels: etiquetas adicionales comunes (ej: {"env": "dev"})
|
||||
:param timeout: timeout en segundos para la petición HTTP
|
||||
:param min_level: nivel mínimo para enviar logs
|
||||
:param service_name: nombre del servicio (usado como 'service_name' label)
|
||||
"""
|
||||
self.endpoint = endpoint
|
||||
self.timeout = timeout
|
||||
self.service_name = service_name or "unknown-service"
|
||||
|
||||
min_level = min_level.upper()
|
||||
if min_level not in self.ALLOWED_LEVELS:
|
||||
raise ValueError(f"min_level debe estar en {self.ALLOWED_LEVELS}")
|
||||
self.min_level = min_level
|
||||
|
||||
# 🔹 Labels adicionales del constructor
|
||||
self.add_labels = dict(add_labels or {})
|
||||
|
||||
self._level_order = {
|
||||
"TRACE": 0,
|
||||
"DEBUG": 1,
|
||||
"INFO": 2,
|
||||
"WARN": 3,
|
||||
"ERROR": 4,
|
||||
"FATAL": 5,
|
||||
"CRITICAL": 6,
|
||||
"UNKNOWN": 7,
|
||||
}
|
||||
|
||||
def _current_ns(self) -> str:
|
||||
"""Devuelve timestamp actual en nanosegundos como string."""
|
||||
return str(int(time.time() * 1e9))
|
||||
|
||||
def _should_log(self, level: str) -> bool:
|
||||
"""Comprueba si el nivel cumple con el mínimo configurado."""
|
||||
return self._level_order[level] >= self._level_order[self.min_level]
|
||||
|
||||
def log(
|
||||
self,
|
||||
level: str,
|
||||
message: str,
|
||||
add_labels: Optional[Dict[str, str]] = None,
|
||||
add_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Envía un log a Loki con los campos mínimos (timestamp + message)."""
|
||||
level = level.upper()
|
||||
if level == "WARNING":
|
||||
level = "WARN"
|
||||
|
||||
if level not in self.ALLOWED_LEVELS:
|
||||
raise ValueError(f"Nivel no válido: {level}. Debe estar en {self.ALLOWED_LEVELS}")
|
||||
|
||||
if not self._should_log(level):
|
||||
return
|
||||
|
||||
# 🔸 Construimos labels: solo 2 labels por defecto
|
||||
final_labels = {
|
||||
"service_name": self.service_name,
|
||||
"detected_level": level,
|
||||
}
|
||||
|
||||
# Agrega labels adicionales del constructor
|
||||
if self.add_labels:
|
||||
final_labels.update(self.add_labels)
|
||||
|
||||
# Agrega labels adicionales del método
|
||||
if add_labels:
|
||||
final_labels.update(add_labels)
|
||||
|
||||
# 🧾 El log line lleva timestamp + message + campos adicionales
|
||||
log_line_data = {
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.localtime()),
|
||||
"message": message,
|
||||
}
|
||||
|
||||
# Agrega campos adicionales si se pasan
|
||||
if add_fields:
|
||||
log_line_data.update(add_fields)
|
||||
|
||||
log_line = json.dumps(log_line_data, ensure_ascii=False)
|
||||
|
||||
body = {
|
||||
"streams": [
|
||||
{"stream": final_labels, "values": [[self._current_ns(), log_line]]}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(self.endpoint, json=body, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(f"Failed to send log to Loki: {e}", flush=True)
|
||||
|
||||
# Métodos estándar por nivel
|
||||
def trace(self, message, add_labels=None, add_fields=None):
|
||||
self.log("TRACE", message, add_labels, add_fields)
|
||||
|
||||
def debug(self, message, add_labels=None, add_fields=None):
|
||||
self.log("DEBUG", message, add_labels, add_fields)
|
||||
|
||||
def info(self, message, add_labels=None, add_fields=None):
|
||||
self.log("INFO", message, add_labels, add_fields)
|
||||
|
||||
def warn(self, message, add_labels=None, add_fields=None):
|
||||
self.log("WARN", message, add_labels, add_fields)
|
||||
|
||||
def warning(self, message, add_labels=None, add_fields=None):
|
||||
self.log("WARN", message, add_labels, add_fields)
|
||||
|
||||
def error(self, message, add_labels=None, add_fields=None):
|
||||
self.log("ERROR", message, add_labels, add_fields)
|
||||
|
||||
def exception(self, exc: Exception, add_labels=None, add_fields=None):
|
||||
"""Registra una excepción con traceback incluido."""
|
||||
tb = traceback.format_exc()
|
||||
message = str(exc)
|
||||
self.log("ERROR", f"{message}\n{tb}", add_labels=add_labels, add_fields=add_fields)
|
||||
|
||||
def fatal(self, message, add_labels=None, add_fields=None):
|
||||
self.log("FATAL", message, add_labels, add_fields)
|
||||
|
||||
def critical(self, message, add_labels=None, add_fields=None):
|
||||
self.log("CRITICAL", message, add_labels, add_fields)
|
||||
|
||||
def unknown(self, message, add_labels=None, add_fields=None):
|
||||
self.log("UNKNOWN", message, add_labels, add_fields)
|
||||
|
||||
# 🧩 Decorador para capturar excepciones
|
||||
def catch_exceptions(self, reraise: bool = False):
|
||||
"""Decorador que captura excepciones y las loguea."""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
self.error(
|
||||
f"Exception en función '{func.__name__}': {e}\n{tb}"
|
||||
)
|
||||
if reraise:
|
||||
raise
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,19 @@
|
||||
# Kanboard Agent
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
1. Copia tu token de bot en `.env` usando las siguientes variables:
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN="<token_bot>"
|
||||
TELEGRAM_ALLOWED_CHAT_ID="<chat_id>"
|
||||
TELEGRAM_ALLOWED_USER_ID="<user_id>"
|
||||
TELEGRAM_AGENT_KEY="kan" # opcional, usa el valor del registro si es distinto
|
||||
```
|
||||
2. Asegúrate de tener configurado `OPENAI_API_KEY` y cualquier otra credencial necesaria.
|
||||
3. Ejecuta el bot con `python ejecucion_telegram.py` desde el entorno virtual del proyecto.
|
||||
4. Usa el chat de Telegram indicado para conversar con el agente Kan.
|
||||
|
||||
> **Notas**
|
||||
> - El bot filtra mensajes por `user_id` y `chat_id` para que solo tú puedas usarlo.
|
||||
> - El endpoint de Loki se reutiliza automáticamente para los logs del bot.
|
||||
> - Puedes personalizar el agente cambiando `TELEGRAM_AGENT_KEY` si registras más agentes.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Paquete que contiene las definiciones de agentes personalizados."""
|
||||
|
||||
from .agente_kanboard import AgenteBasico
|
||||
from .agent_config import (
|
||||
AGENT_REGISTRY,
|
||||
DEFAULT_AGENT_NAME,
|
||||
AgentWrapper,
|
||||
create_agent,
|
||||
get_agent_wrapper,
|
||||
register_agent,
|
||||
)
|
||||
from .base import AgenteBase, AgentDefinition
|
||||
|
||||
__all__ = [
|
||||
"AgenteBasico",
|
||||
"AgentDefinition",
|
||||
"AgenteBase",
|
||||
"AgentWrapper",
|
||||
"AGENT_REGISTRY",
|
||||
"DEFAULT_AGENT_NAME",
|
||||
"create_agent",
|
||||
"get_agent_wrapper",
|
||||
"register_agent",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from time import perf_counter
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.db.base import SessionType
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from .agente_kanboard import AgenteBasico
|
||||
from .base import AgentDefinition
|
||||
from mcp_wrapper import MCPConfigError, initialize_mcp_tools, load_mcp_tools
|
||||
from utils.agno_logging import configure_agno_to_use_loki
|
||||
|
||||
ToolFactory = Callable[[Any], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentWrapper:
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str
|
||||
model_id: str
|
||||
memory_config: Dict[str, Any] = field(default_factory=dict)
|
||||
mcp_config: Optional[Dict[str, Any]] = None
|
||||
tool_factories: List[ToolFactory] = field(default_factory=list)
|
||||
markdown: bool = True
|
||||
debug_mode: bool = False
|
||||
telemetry: bool = False
|
||||
model_kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||
db: Optional[Any] = None
|
||||
user_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
resume_previous_session: bool = True
|
||||
|
||||
async def create(self, logger) -> Tuple[Agent, Dict[str, Any]]:
|
||||
configure_agno_to_use_loki(logger)
|
||||
|
||||
local_tools = [factory(logger) for factory in self.tool_factories]
|
||||
|
||||
mcp_tools: List[Any] = []
|
||||
active_mcp_servers: List[str] = []
|
||||
server_tool_map: Dict[str, List[str]] = {}
|
||||
|
||||
if self.mcp_config:
|
||||
mcp_config = dict(self.mcp_config)
|
||||
try:
|
||||
mcp_tools, active_mcp_servers = load_mcp_tools(mcp_config, logger=logger)
|
||||
if mcp_tools:
|
||||
server_tool_map = await initialize_mcp_tools(mcp_tools, logger=logger)
|
||||
except MCPConfigError as error:
|
||||
logger.error(
|
||||
"🚨 Configuración MCP inválida",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "load_mcp_config",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "error", "error": str(error)},
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"💥 Error inesperado configurando MCP",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "load_mcp_config",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "error", "error": str(error)},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
if self.db:
|
||||
logger.info(
|
||||
"🗄️ Base de datos SQLite configurada para el agente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "configure_memory",
|
||||
"agent_name": self.name,
|
||||
"db_file": getattr(self.db, "db_file", None),
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "db_ready",
|
||||
"resume_previous_session": self.resume_previous_session,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if self.resume_previous_session and not self.session_id and self.user_id:
|
||||
start = perf_counter()
|
||||
try:
|
||||
sessions = self.db.get_sessions(user_id=self.user_id, session_type=SessionType.AGENT)
|
||||
duration_ms = round((perf_counter() - start) * 1000, 3)
|
||||
if sessions:
|
||||
self.session_id = getattr(sessions[0], "session_id", None)
|
||||
logger.info(
|
||||
"📂 Sesión previa recuperada desde memoria persistente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "session_found",
|
||||
"session_id": self.session_id,
|
||||
"sessions_encontradas": len(sessions),
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🔎 No se encontró sesión previa para el agente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "session_not_found",
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
duration_ms = round((perf_counter() - start) * 1000, 3)
|
||||
logger.error(
|
||||
"🚨 Error recuperando la sesión previa desde SQLite",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "error",
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warn(
|
||||
"⚠️ Agente sin base de datos configurada; la memoria no se persistirá",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "configure_memory",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "db_missing",
|
||||
"add_history_to_context": self.memory_config.get("add_history_to_context", False),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
logger.warn(
|
||||
"⚠️ OPENAI_API_KEY no encontrado; el agente puede fallar al invocar el modelo",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "load_model_credentials",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "missing_credentials"},
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(
|
||||
id=self.model_id,
|
||||
api_key=api_key,
|
||||
**self.model_kwargs,
|
||||
),
|
||||
db=self.db,
|
||||
user_id=self.user_id,
|
||||
session_id=self.session_id,
|
||||
tools=[*local_tools, *mcp_tools],
|
||||
markdown=self.markdown,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
system_message=self.system_prompt,
|
||||
debug_mode=self.debug_mode,
|
||||
telemetry=self.telemetry,
|
||||
**self.memory_config,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🤖 Agente configurado",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "create_agent",
|
||||
"agent_name": self.name,
|
||||
"local_tools": [tool.__name__ for tool in local_tools],
|
||||
"mcp_servers": active_mcp_servers,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "created",
|
||||
"tool_count": len(agent.tools) if agent.tools else 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Sesión del agente lista para usarse",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "session_ready",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "ready",
|
||||
"session_id": getattr(agent, "session_id", self.session_id),
|
||||
"user_id": self.user_id,
|
||||
"db_file": getattr(self.db, "db_file", None) if self.db else None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"mcp_tools": mcp_tools,
|
||||
"active_servers": active_mcp_servers,
|
||||
"server_tool_map": server_tool_map,
|
||||
"local_tools": [tool.__name__ for tool in local_tools],
|
||||
"agent_name": self.name,
|
||||
"db_file": getattr(self.db, "db_file", None) if self.db else None,
|
||||
"user_id": self.user_id,
|
||||
"session_id": getattr(agent, "session_id", self.session_id),
|
||||
"resume_previous_session": self.resume_previous_session,
|
||||
}
|
||||
self.session_id = context["session_id"]
|
||||
|
||||
return agent, context
|
||||
|
||||
|
||||
def _wrapper_from_definition(definition: AgentDefinition) -> AgentWrapper:
|
||||
return AgentWrapper(
|
||||
name=definition.name,
|
||||
description=definition.description,
|
||||
system_prompt=definition.system_prompt,
|
||||
model_id=definition.model_id,
|
||||
memory_config=definition.memory_config,
|
||||
mcp_config=definition.mcp_config,
|
||||
tool_factories=definition.tool_factories,
|
||||
markdown=definition.markdown,
|
||||
debug_mode=definition.debug_mode,
|
||||
telemetry=definition.telemetry,
|
||||
model_kwargs=definition.model_kwargs,
|
||||
db=definition.db,
|
||||
user_id=definition.user_id,
|
||||
session_id=definition.session_id,
|
||||
resume_previous_session=definition.resume_previous_session,
|
||||
)
|
||||
|
||||
|
||||
_AGENT_DEFINITIONS = [
|
||||
AgenteBasico(),
|
||||
]
|
||||
|
||||
DEFAULT_AGENT_NAME = _AGENT_DEFINITIONS[0].get_registry_key()
|
||||
|
||||
AGENT_REGISTRY: Dict[str, AgentWrapper] = {
|
||||
agent.get_registry_key(): _wrapper_from_definition(agent.build_definition())
|
||||
for agent in _AGENT_DEFINITIONS
|
||||
}
|
||||
|
||||
|
||||
def register_agent(key: str, wrapper: AgentWrapper) -> None:
|
||||
AGENT_REGISTRY[key.lower()] = wrapper
|
||||
|
||||
|
||||
def get_agent_wrapper(agent: Union[str, AgentWrapper, None]) -> Tuple[str, AgentWrapper]:
|
||||
if isinstance(agent, AgentWrapper):
|
||||
return agent.name.lower(), agent
|
||||
agent_key = (agent or DEFAULT_AGENT_NAME).lower()
|
||||
if agent_key not in AGENT_REGISTRY:
|
||||
raise KeyError(f"No se encontró un agente registrado con la clave '{agent_key}'")
|
||||
return agent_key, AGENT_REGISTRY[agent_key]
|
||||
|
||||
|
||||
async def create_agent(logger, agent: Union[str, AgentWrapper, None] = None) -> Tuple[Agent, Dict[str, Any]]:
|
||||
agent_key, wrapper = get_agent_wrapper(agent)
|
||||
agent_instance, context = await wrapper.create(logger)
|
||||
context.setdefault("agent_name", wrapper.name)
|
||||
context.setdefault("agent_key", agent_key)
|
||||
return agent_instance, context
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Definición del agente básico de ejemplo."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import AgenteBase, ToolFactory
|
||||
|
||||
SYSTEM_PROMPT = ("""## 🧠 System Prompt — “Agente Kanboard”
|
||||
|
||||
**Nombre del agente:** KAN
|
||||
**Rol:** Asistente autónomo de productividad y coordinación de tareas en Kanboard.
|
||||
|
||||
---
|
||||
|
||||
### 🎯 Objetivo principal
|
||||
|
||||
Gestionar, organizar y optimizar las tareas de los proyectos en Kanboard.
|
||||
Debe actuar como un gestor de proyectos proactivo, ayudando a mantener el flujo de trabajo limpio, priorizado y actualizado.
|
||||
|
||||
---
|
||||
|
||||
### 💼 Responsabilidades
|
||||
|
||||
1. **Gestión de tareas**
|
||||
|
||||
* Crear, actualizar, mover y cerrar tareas según el estado de avance.
|
||||
* Asignar responsables, etiquetas y fechas límite con base en contexto o patrones previos.
|
||||
* Detectar tareas duplicadas o bloqueadas y sugerir acciones correctivas.
|
||||
|
||||
2. **Priorización inteligente**
|
||||
|
||||
* Ordenar tareas usando criterios de impacto, urgencia y dependencias.
|
||||
* Recalcular prioridades automáticamente si cambian fechas o recursos.
|
||||
|
||||
3. **Contexto y comunicación**
|
||||
|
||||
* Resumir el estado actual de cada proyecto.
|
||||
* Generar reportes diarios o semanales con progreso, riesgos y tareas pendientes.
|
||||
* Explicar cambios recientes en el tablero (por ejemplo: “se movieron 3 tareas a *Done*”).
|
||||
|
||||
4. **Automatización de flujo**
|
||||
|
||||
* Mover tareas automáticamente al siguiente estado según condiciones predefinidas (por ejemplo: “si los tests pasan, mover a *Ready for Review*”).
|
||||
* Crear subtareas recurrentes o tareas de seguimiento.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 Entradas esperadas
|
||||
|
||||
El agente debe ser capaz de interpretar comandos naturales como:
|
||||
|
||||
* “Crea una tarea para configurar el pipeline de ingestión en Kafka.”
|
||||
* “Muévela a *Doing* y asígnamela.”
|
||||
* “Resúmeme el tablero del proyecto *Data Lake*.”
|
||||
* “Prioriza las tareas de optimización antes que las de documentación.”
|
||||
* “Muéstrame las tareas bloqueadas desde hace más de 3 días.”
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Integraciones
|
||||
|
||||
* **Kanboard API:** CRUD de tareas, columnas, etiquetas, usuarios y comentarios.
|
||||
* **GitHub / GitLab:** Referencias automáticas a commits o PRs relacionados.
|
||||
* **Slack / Email:** Notificaciones opcionales sobre cambios importantes.
|
||||
* **Calendario / Notion (opcional):** Sincronización de deadlines y notas de planificación.
|
||||
|
||||
---
|
||||
|
||||
### 🧠 Personalidad y estilo
|
||||
|
||||
* **Tono:** Profesional, directo, pero con iniciativa.
|
||||
* **Comunicación:** Clara, contextual, priorizando acciones útiles.
|
||||
* **Criterio:** Detecta inconsistencias y las menciona sin esperar instrucción.
|
||||
(Ej: “La tarea ‘Configurar ETL’ lleva 10 días en *Doing*, ¿quieres revisarla?”)
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ Reglas de operación
|
||||
|
||||
1. No crea ni elimina proyectos sin confirmación explícita.
|
||||
2. Puede sugerir cambios automáticos, pero siempre explica el motivo.
|
||||
3. Mantiene trazabilidad de todas las acciones.
|
||||
4. Respeta el esquema de columnas estándar:
|
||||
|
||||
* **Backlog → Ready → Doing → Review → Done**
|
||||
5. Si un comando es ambiguo, pregunta antes de actuar.
|
||||
6. Todas las respuestas deben incluir **acciones sugeridas o contexto útil** (no solo datos).
|
||||
|
||||
---
|
||||
|
||||
### 📈 Objetivo secundario
|
||||
|
||||
Aprender del comportamiento del usuario (por ejemplo, patrones de asignación o tiempos de ciclo) para **recomendar automatizaciones personalizadas**, como:
|
||||
|
||||
* Recordatorios automáticos.
|
||||
* Repriorización dinámica.
|
||||
* Creación de plantillas de tareas repetitivas.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
)
|
||||
|
||||
DEFAULT_MEMORY_CONFIG: Dict[str, Any] = {
|
||||
"add_history_to_context": True,
|
||||
"num_history_messages": 10,
|
||||
"store_history_messages": True,
|
||||
}
|
||||
|
||||
DEFAULT_MCP_CONFIG: Dict[str, Any] = {
|
||||
"mcpServers": {
|
||||
"time": {
|
||||
"command": "/home/lucas/DataProyects/kanboard/.venv/bin/python",
|
||||
"args": ["/home/lucas/DataProyects/kanboard/kanboard_mcp.py"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# def _create_multiplicar_tool(logger) -> ToolFactory:
|
||||
# def multiplicar(a: float, b: float) -> float:
|
||||
# tool_call_data = {
|
||||
# "tool_name": "multiplicar",
|
||||
# "params": {"a": a, "b": b},
|
||||
# "action": "multiply_numbers",
|
||||
# }
|
||||
# logger.info(
|
||||
# "🛠️ Tool llamada: multiplicar",
|
||||
# add_fields={"agent_call": tool_call_data},
|
||||
# )
|
||||
|
||||
# resultado = a * b
|
||||
|
||||
# tool_response_data = {
|
||||
# "tool_name": "multiplicar",
|
||||
# "response": resultado,
|
||||
# "success": True,
|
||||
# "operation": f"{a} × {b} = {resultado}",
|
||||
# }
|
||||
# logger.info(
|
||||
# "✅ Tool respuesta: multiplicar completada exitosamente",
|
||||
# add_fields={"agent_response": tool_response_data},
|
||||
# )
|
||||
|
||||
# return resultado
|
||||
|
||||
# multiplicar.__name__ = "multiplicar"
|
||||
# return multiplicar
|
||||
|
||||
|
||||
class AgenteBasico(AgenteBase):
|
||||
key = "kan"
|
||||
name = "Kan"
|
||||
description = "Agente de tareas para gestionar mi tablero kanboard"
|
||||
system_prompt = SYSTEM_PROMPT
|
||||
model_id = "gpt-4o"
|
||||
markdown = True
|
||||
debug_mode = True
|
||||
telemetry = False
|
||||
|
||||
def get_memory_config(self) -> Dict[str, Any]:
|
||||
return dict(DEFAULT_MEMORY_CONFIG)
|
||||
|
||||
def get_mcp_config(self) -> Dict[str, Any]:
|
||||
return dict(DEFAULT_MCP_CONFIG)
|
||||
|
||||
# def get_tool_factories(self) -> List[ToolFactory]:
|
||||
# return [_create_multiplicar_tool]
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""Clases base para definir agentes del proyecto."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from agno.db.sqlite import SqliteDb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ToolFactory = Callable[[Any], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
"""Contenedor ligero con los parámetros para construir un AgentWrapper."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str
|
||||
model_id: str
|
||||
memory_config: Dict[str, Any]
|
||||
mcp_config: Optional[Dict[str, Any]]
|
||||
tool_factories: List[ToolFactory]
|
||||
markdown: bool
|
||||
debug_mode: bool
|
||||
telemetry: bool
|
||||
model_kwargs: Dict[str, Any]
|
||||
db: Optional[SqliteDb]
|
||||
user_id: Optional[str]
|
||||
session_id: Optional[str]
|
||||
resume_previous_session: bool
|
||||
|
||||
|
||||
class AgenteBase:
|
||||
"""Proporciona valores por defecto y utilidades para los agentes concretos."""
|
||||
|
||||
key: str = "base"
|
||||
name: str = "Agente base"
|
||||
description: str = "Agente genérico."
|
||||
system_prompt: str = ""
|
||||
model_id: str = "gpt-4o"
|
||||
markdown: bool = True
|
||||
debug_mode: bool = False
|
||||
telemetry: bool = False
|
||||
memory_config: Dict[str, Any] = {}
|
||||
mcp_config: Optional[Dict[str, Any]] = None
|
||||
model_kwargs: Dict[str, Any] = {}
|
||||
db_path: Optional[str] = "memoria/conversaciones.db"
|
||||
user_id: Optional[str] = None
|
||||
resume_previous_session: bool = True
|
||||
|
||||
def get_registry_key(self) -> str:
|
||||
"""Clave utilizada en el registro global de agentes."""
|
||||
return self.key.lower()
|
||||
|
||||
def get_memory_config(self) -> Dict[str, Any]:
|
||||
return dict(self.memory_config) if self.memory_config else {}
|
||||
|
||||
def get_mcp_config(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.mcp_config:
|
||||
return None
|
||||
return {**self.mcp_config}
|
||||
|
||||
def get_tool_factories(self) -> List[ToolFactory]:
|
||||
return []
|
||||
|
||||
def get_model_kwargs(self) -> Dict[str, Any]:
|
||||
return dict(self.model_kwargs) if self.model_kwargs else {}
|
||||
|
||||
def _resolve_db_path(self) -> Optional[Path]:
|
||||
if not self.db_path:
|
||||
return None
|
||||
db_path = Path(self.db_path)
|
||||
if not db_path.is_absolute():
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
db_path = project_root / db_path
|
||||
try:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning(
|
||||
"⚠️ No se pudo crear el directorio para la base de datos SQLite",
|
||||
extra={"db_directory": str(db_path.parent), "error": str(error)},
|
||||
)
|
||||
return None
|
||||
return db_path
|
||||
|
||||
def get_database(self) -> Optional[SqliteDb]:
|
||||
db_path = self._resolve_db_path()
|
||||
if not db_path:
|
||||
return None
|
||||
return SqliteDb(db_file=str(db_path))
|
||||
|
||||
def get_user_id(self) -> str:
|
||||
return self.user_id or self.get_registry_key()
|
||||
|
||||
def build_definition(self) -> AgentDefinition:
|
||||
"""Genera los parámetros necesarios para instanciar un AgentWrapper."""
|
||||
db = self.get_database()
|
||||
return AgentDefinition(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
system_prompt=self.system_prompt,
|
||||
model_id=self.model_id,
|
||||
memory_config=self.get_memory_config(),
|
||||
mcp_config=self.get_mcp_config(),
|
||||
tool_factories=list(self.get_tool_factories()),
|
||||
markdown=self.markdown,
|
||||
debug_mode=self.debug_mode,
|
||||
telemetry=self.telemetry,
|
||||
model_kwargs=self.get_model_kwargs(),
|
||||
db=db,
|
||||
user_id=self.get_user_id(),
|
||||
session_id=None,
|
||||
resume_previous_session=self.resume_previous_session,
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""CLI interactiva para conversar con los agentes registrados."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agentes import AGENT_REGISTRY, create_agent
|
||||
from LokiLogger import LokiLogger
|
||||
from mcp_wrapper import close_mcp_tools
|
||||
|
||||
# Logger reutilizado en toda la sesión
|
||||
logger = LokiLogger(
|
||||
service_name="agente_kanboard",
|
||||
add_labels={"env": "local", "agent": "CLI"},
|
||||
)
|
||||
|
||||
|
||||
def _make_json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {k: _make_json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_make_json_safe(v) for v in value]
|
||||
if hasattr(value, "to_dict"):
|
||||
return _make_json_safe(value.to_dict())
|
||||
if hasattr(value, "model_dump"):
|
||||
return _make_json_safe(value.model_dump())
|
||||
if hasattr(value, "dict"):
|
||||
return _make_json_safe(value.dict())
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _pretty_print(title: str, payload: Any) -> None:
|
||||
safe_payload = _make_json_safe(payload)
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
print(pformat(safe_payload, width=100, compact=False, sort_dicts=False))
|
||||
|
||||
|
||||
def _announce_session(context: Dict[str, Any]) -> None:
|
||||
summary = {
|
||||
"agente": context.get("agent_name"),
|
||||
"usuario": context.get("user_id"),
|
||||
"sesion": context.get("session_id"),
|
||||
"herramientas_locales": context.get("local_tools", []),
|
||||
"servidores_mcp_activos": context.get("active_servers", []),
|
||||
"herramientas_mcp": context.get("server_tool_map", {}),
|
||||
"base_datos": context.get("db_file"),
|
||||
"reanudar_memoria": context.get("resume_previous_session"),
|
||||
}
|
||||
|
||||
print("🚀 Sesión iniciada")
|
||||
_pretty_print("Contexto inicial", summary)
|
||||
|
||||
if context.get("db_file"):
|
||||
print(f"\n🗄️ Memoria persistente: {context['db_file']}")
|
||||
|
||||
if context.get("active_servers"):
|
||||
print("\n🔗 MCP habilitado. Servidores activos detectados.")
|
||||
else:
|
||||
print("\n⚠️ MCP no tiene servidores activos configurados.")
|
||||
|
||||
print("\nEscribe 'salir' para terminar la conversación.")
|
||||
|
||||
|
||||
def _render_response(response: Any) -> None:
|
||||
if not response:
|
||||
print("⚠️ No se obtuvo respuesta del agente.")
|
||||
return
|
||||
|
||||
agent_response_fields = {
|
||||
"run_id": response.run_id,
|
||||
"agent_id": response.agent_id,
|
||||
"session_id": response.session_id,
|
||||
"model": response.model,
|
||||
"model_provider": response.model_provider,
|
||||
"status": str(response.status),
|
||||
"metrics": {
|
||||
"input_tokens": response.metrics.input_tokens if response.metrics else 0,
|
||||
"output_tokens": response.metrics.output_tokens if response.metrics else 0,
|
||||
"total_tokens": response.metrics.total_tokens if response.metrics else 0,
|
||||
"duration": response.metrics.duration if response.metrics else 0,
|
||||
"time_to_first_token": response.metrics.time_to_first_token if response.metrics else 0,
|
||||
},
|
||||
"tools_invocadas": [tool.tool_name for tool in response.tools or []],
|
||||
}
|
||||
|
||||
_pretty_print("📊 Métricas de la respuesta", agent_response_fields)
|
||||
|
||||
if response.tools:
|
||||
herramientas = []
|
||||
for tool_call in response.tools:
|
||||
herramientas.append(
|
||||
{
|
||||
"tool_call_id": tool_call.tool_call_id,
|
||||
"tool_name": tool_call.tool_name,
|
||||
"tool_args": _make_json_safe(tool_call.tool_args),
|
||||
"result": _make_json_safe(tool_call.result),
|
||||
"error": tool_call.tool_call_error,
|
||||
"metrics": (
|
||||
tool_call.metrics.to_dict() if getattr(tool_call, "metrics", None) else None
|
||||
),
|
||||
}
|
||||
)
|
||||
_pretty_print("🧰 Herramientas invocadas", herramientas)
|
||||
else:
|
||||
print("\nℹ️ El modelo no invocó herramientas durante la respuesta.")
|
||||
|
||||
if response.content:
|
||||
print("\n💬 Respuesta del agente")
|
||||
print("----------------------")
|
||||
print(response.content)
|
||||
else:
|
||||
print("\n💬 Respuesta del agente: (vacía)")
|
||||
|
||||
|
||||
async def ejecutar_cli() -> None:
|
||||
load_dotenv()
|
||||
logger.info("🚀 Iniciando sesión interactiva del agente")
|
||||
|
||||
agent_keys = list(AGENT_REGISTRY.keys())
|
||||
if not agent_keys:
|
||||
print("\n❌ No hay agentes registrados disponibles.")
|
||||
logger.error(
|
||||
"🚫 No hay agentes registrados para iniciar la sesión",
|
||||
add_fields={"agent_call": {"action": "list_agents"}, "agent_response": {"status": "empty_registry"}},
|
||||
)
|
||||
return
|
||||
|
||||
print("\n🤖 Agentes disponibles:")
|
||||
print("=======================")
|
||||
name_to_key = {}
|
||||
for idx, key in enumerate(agent_keys, start=1):
|
||||
wrapper = AGENT_REGISTRY[key]
|
||||
name_to_key[wrapper.name.lower()] = key
|
||||
print(f"[{idx}] {wrapper.name} → {wrapper.description}")
|
||||
|
||||
selected_key = None
|
||||
while not selected_key:
|
||||
try:
|
||||
choice = input("\nSelecciona un agente (número o nombre): ").strip().lower()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⛔ Selección interrumpida por el usuario.")
|
||||
logger.info(
|
||||
"⛔ Selección de agente interrumpida por Ctrl+C",
|
||||
add_fields={
|
||||
"agent_call": {"action": "select_agent", "agent_options": agent_keys},
|
||||
"agent_response": {"status": "interrupted"},
|
||||
},
|
||||
)
|
||||
return
|
||||
if not choice:
|
||||
print("❌ Entrada vacía. Intenta nuevamente.")
|
||||
continue
|
||||
|
||||
if choice.isdigit():
|
||||
index = int(choice) - 1
|
||||
if 0 <= index < len(agent_keys):
|
||||
selected_key = agent_keys[index]
|
||||
else:
|
||||
print("❌ Número inválido. Intenta de nuevo.")
|
||||
continue
|
||||
elif choice in agent_keys:
|
||||
selected_key = choice
|
||||
elif choice in name_to_key:
|
||||
selected_key = name_to_key[choice]
|
||||
else:
|
||||
print("❌ Agente no encontrado. Intenta nuevamente.")
|
||||
continue
|
||||
|
||||
selected_wrapper = AGENT_REGISTRY[selected_key]
|
||||
print(f"\n✅ Agente seleccionado: {selected_wrapper.name}\n")
|
||||
logger.info(
|
||||
"🤖 Agente seleccionado para la sesión",
|
||||
add_fields={
|
||||
"agent_call": {"action": "select_agent", "agent_options": agent_keys},
|
||||
"agent_response": {"status": "agent_selected", "agent_key": selected_key, "agent_name": selected_wrapper.name},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
agent, context = await create_agent(logger, agent=selected_key)
|
||||
except Exception as error: # pragma: no cover - salida controlada
|
||||
logger.exception(error)
|
||||
print(f"No se pudo crear el agente: {error}")
|
||||
return
|
||||
|
||||
mcp_tools: List[Any] = context.get("mcp_tools", [])
|
||||
_announce_session(context)
|
||||
|
||||
try:
|
||||
while True:
|
||||
user_input = (await asyncio.to_thread(input, "\nTú → ")).strip()
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() in {"salir", "exit", "quit"}:
|
||||
logger.info(
|
||||
"👋 Chat finalizado por el usuario",
|
||||
add_fields={
|
||||
"agent_call": {"action": "end_chat"},
|
||||
"agent_response": {"status": "ended_by_user"},
|
||||
},
|
||||
)
|
||||
print("\n👋 Sesión finalizada por el usuario.")
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"📨 Mensaje recibido del usuario",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "user_message",
|
||||
"content": user_input,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await agent.arun(user_input)
|
||||
except Exception as error: # pragma: no cover - salida controlada
|
||||
logger.exception(error)
|
||||
print(f"Error al procesar la consulta: {error}")
|
||||
continue
|
||||
|
||||
_render_response(response)
|
||||
|
||||
except KeyboardInterrupt: # pragma: no cover - salida controlada
|
||||
logger.info(
|
||||
"⛔ Chat interrumpido por el usuario",
|
||||
add_fields={
|
||||
"agent_response": {"status": "interrupted"},
|
||||
},
|
||||
)
|
||||
print("\n⛔ Sesión interrumpida manualmente.")
|
||||
finally:
|
||||
await close_mcp_tools(mcp_tools, logger=logger)
|
||||
logger.info("✅ Sesión del agente finalizada")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(ejecutar_cli())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - ejecución directa
|
||||
main()
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Bot de Telegram para conversar con el agente Kanboard."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agentes import DEFAULT_AGENT_NAME, create_agent
|
||||
from LokiLogger import LokiLogger
|
||||
from mcp_wrapper import close_mcp_tools
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||
MAX_MESSAGE_LENGTH = 3800 # margen para markdown/formatting
|
||||
|
||||
|
||||
def _parse_int(value: Optional[str], *, env_name: str) -> Optional[int]:
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as error: # pragma: no cover - salida controlada
|
||||
raise ValueError(f"{env_name} debe ser un entero válido, se recibió: {value!r}") from error
|
||||
|
||||
|
||||
class TelegramKanBot:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
allowed_user_id: int,
|
||||
allowed_chat_id: int,
|
||||
agent_key: str,
|
||||
logger: LokiLogger,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.allowed_user_id = allowed_user_id
|
||||
self.allowed_chat_id = allowed_chat_id
|
||||
self.agent_key = agent_key
|
||||
self.logger = logger
|
||||
self.api_timeout = 30
|
||||
self.http_timeout = 35
|
||||
self.idle_sleep = 1
|
||||
self.offset: Optional[int] = None
|
||||
self.base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||
self.session = requests.Session()
|
||||
|
||||
self.agent = None
|
||||
self.context: Dict[str, Any] = {}
|
||||
|
||||
async def setup_agent(self) -> None:
|
||||
agent, context = await create_agent(self.logger, agent=self.agent_key)
|
||||
self.agent = agent
|
||||
self.context = context
|
||||
self.logger.info(
|
||||
"🤖 Agente Kan para Telegram listo",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_agent_ready", "agent": self.agent_key},
|
||||
"agent_response": {"status": "ready", "allowed_chat": self.allowed_chat_id},
|
||||
},
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.context.get("mcp_tools"):
|
||||
await close_mcp_tools(self.context["mcp_tools"], logger=self.logger)
|
||||
self.session.close()
|
||||
|
||||
async def run(self) -> None:
|
||||
if self.agent is None:
|
||||
await self.setup_agent()
|
||||
|
||||
self.logger.info(
|
||||
"🚀 Escuchando mensajes de Telegram",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_poll"},
|
||||
"agent_response": {
|
||||
"status": "listening",
|
||||
"chat_id": self.allowed_chat_id,
|
||||
"user_id": self.allowed_user_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
updates = await self._fetch_updates()
|
||||
for update in updates:
|
||||
self.offset = update["update_id"] + 1
|
||||
await self._handle_update(update)
|
||||
await asyncio.sleep(self.idle_sleep)
|
||||
except asyncio.CancelledError: # pragma: no cover - interrupción controlada
|
||||
raise
|
||||
except Exception as error:
|
||||
self.logger.exception(error)
|
||||
raise
|
||||
|
||||
async def _fetch_updates(self) -> List[Dict[str, Any]]:
|
||||
params = {
|
||||
"timeout": self.api_timeout,
|
||||
"allowed_updates": json.dumps(["message", "edited_message"]),
|
||||
}
|
||||
if self.offset is not None:
|
||||
params["offset"] = self.offset
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.get,
|
||||
f"{self.base_url}/getUpdates",
|
||||
params=params,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
return []
|
||||
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"La API de Telegram devolvió un estado no-ok",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
result = data.get("result", [])
|
||||
if result:
|
||||
self.logger.debug(
|
||||
"📩 Nuevos updates recibidos",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_get_updates"},
|
||||
"agent_response": {"status": "updates", "count": len(result)},
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
async def _handle_update(self, update: Dict[str, Any]) -> None:
|
||||
message = update.get("message") or update.get("edited_message")
|
||||
if not message:
|
||||
return
|
||||
|
||||
chat = message.get("chat", {})
|
||||
chat_id = chat.get("id")
|
||||
user = message.get("from", {})
|
||||
user_id = user.get("id")
|
||||
|
||||
if chat_id != self.allowed_chat_id or user_id != self.allowed_user_id:
|
||||
self.logger.warn(
|
||||
"Mensaje ignorado por no cumplir filtros",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_filter_message"},
|
||||
"agent_response": {"status": "ignored", "chat_id": chat_id, "user_id": user_id},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
text = message.get("text") or ""
|
||||
if not text:
|
||||
await self._send_message(chat_id, "Solo puedo procesar mensajes de texto por ahora.")
|
||||
return
|
||||
|
||||
if text.strip().lower() in {"/start", "hola", "hi"}:
|
||||
await self._send_message(chat_id, "¡Hola! Soy KAN, listo para ayudarte con Kanboard.")
|
||||
return
|
||||
|
||||
await self._process_message(chat_id, text)
|
||||
|
||||
async def _process_message(self, chat_id: int, text: str) -> None:
|
||||
self.logger.info(
|
||||
"📨 Mensaje recibido desde Telegram",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_user_message", "content": text},
|
||||
"agent_response": {"status": "processing"},
|
||||
},
|
||||
)
|
||||
typing_task = asyncio.create_task(self._typing_indicator(chat_id))
|
||||
try:
|
||||
response = await self.agent.arun(text)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "agent_run", "content": text},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
await self._send_message(chat_id, "Hubo un error procesando tu mensaje. Revisa los logs para más detalles.")
|
||||
return
|
||||
finally:
|
||||
typing_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await typing_task
|
||||
|
||||
content = (response.content or "(respuesta vacía)").strip()
|
||||
if not content:
|
||||
content = "(respuesta vacía)"
|
||||
|
||||
await self._send_message(chat_id, content)
|
||||
|
||||
if response.metrics:
|
||||
self.logger.info(
|
||||
"📊 Métricas de ejecución",
|
||||
add_fields={
|
||||
"agent_call": {"action": "agent_run_metrics"},
|
||||
"agent_response": {
|
||||
"status": "completed",
|
||||
"metrics": {
|
||||
"input_tokens": response.metrics.input_tokens,
|
||||
"output_tokens": response.metrics.output_tokens,
|
||||
"total_tokens": response.metrics.total_tokens,
|
||||
"duration_seconds": response.metrics.duration,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_message(self, chat_id: int, text: str) -> None:
|
||||
for chunk in self._chunk_text(text):
|
||||
payload = {"chat_id": chat_id, "text": chunk}
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.post,
|
||||
f"{self.base_url}/sendMessage",
|
||||
json=payload,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"Telegram rechazó el mensaje",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_message"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_message", "payload": payload},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
|
||||
async def _typing_indicator(self, chat_id: int) -> None:
|
||||
try:
|
||||
while True:
|
||||
await self._send_chat_action(chat_id, "typing")
|
||||
await asyncio.sleep(4)
|
||||
except asyncio.CancelledError: # pragma: no cover - interrupción esperada
|
||||
raise
|
||||
|
||||
async def _send_chat_action(self, chat_id: int, action: str) -> None:
|
||||
payload = {"chat_id": chat_id, "action": action}
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self.session.post,
|
||||
f"{self.base_url}/sendChatAction",
|
||||
json=payload,
|
||||
timeout=self.http_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
self.logger.error(
|
||||
"Telegram rechazó la acción del chat",
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_chat_action"},
|
||||
"agent_response": {"status": data.get("ok"), "description": data.get("description")},
|
||||
},
|
||||
)
|
||||
except Exception as error: # pragma: no cover - manejo defensivo
|
||||
self.logger.exception(
|
||||
error,
|
||||
add_fields={
|
||||
"agent_call": {"action": "telegram_send_chat_action", "payload": payload},
|
||||
"agent_response": {"status": "error"},
|
||||
},
|
||||
)
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
if len(text) <= MAX_MESSAGE_LENGTH:
|
||||
return [text]
|
||||
|
||||
chunks: List[str] = []
|
||||
current = []
|
||||
current_len = 0
|
||||
for paragraph in text.split("\n\n"):
|
||||
paragraph = paragraph.strip()
|
||||
if not paragraph:
|
||||
continue
|
||||
paragraph_len = len(paragraph) + 2 # margen para doble salto
|
||||
if current_len + paragraph_len > MAX_MESSAGE_LENGTH and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current = [paragraph]
|
||||
current_len = len(paragraph)
|
||||
elif paragraph_len > MAX_MESSAGE_LENGTH:
|
||||
chunks.extend([paragraph[i:i + MAX_MESSAGE_LENGTH] for i in range(0, len(paragraph), MAX_MESSAGE_LENGTH)])
|
||||
current = []
|
||||
current_len = 0
|
||||
else:
|
||||
current.append(paragraph)
|
||||
current_len += paragraph_len
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
async def run_bot() -> None:
|
||||
load_dotenv()
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
allowed_user_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_USER_ID"), env_name="TELEGRAM_ALLOWED_USER_ID")
|
||||
allowed_chat_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_CHAT_ID"), env_name="TELEGRAM_ALLOWED_CHAT_ID")
|
||||
agent_key = (os.getenv("TELEGRAM_AGENT_KEY") or DEFAULT_AGENT_NAME).lower()
|
||||
|
||||
if not token:
|
||||
raise RuntimeError("TELEGRAM_BOT_TOKEN debe definirse en el entorno")
|
||||
if allowed_user_id is None or allowed_chat_id is None:
|
||||
raise RuntimeError("TELEGRAM_ALLOWED_USER_ID y TELEGRAM_ALLOWED_CHAT_ID son obligatorios")
|
||||
|
||||
logger = LokiLogger(
|
||||
service_name="agente_kanboard",
|
||||
add_labels={"env": "local", "agent": "telegram"},
|
||||
)
|
||||
|
||||
bot = TelegramKanBot(
|
||||
token=token,
|
||||
allowed_user_id=allowed_user_id,
|
||||
allowed_chat_id=allowed_chat_id,
|
||||
agent_key=agent_key,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.run()
|
||||
finally:
|
||||
await bot.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(run_bot())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - ejecución directa
|
||||
main()
|
||||
+73
-78
@@ -1,6 +1,6 @@
|
||||
# kanboard_mcp.py
|
||||
"""
|
||||
Servidor FastMCP para controlar Kanboard desde un LLM.
|
||||
🚀 Servidor FastMCP para controlar Kanboard desde un LLM.
|
||||
|
||||
Este módulo expone las funciones del paquete `kanboard_utils`
|
||||
como herramientas del protocolo MCP (Model Context Protocol),
|
||||
@@ -8,9 +8,10 @@ permitiendo a modelos de lenguaje (LLMs) listar, crear, mover
|
||||
y editar tareas en Kanboard de forma segura y programática.
|
||||
|
||||
Requisitos:
|
||||
uv add fastmcp requests
|
||||
uv add fastmcp requests python-dotenv
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from kanboard_utils import (
|
||||
listar_proyectos,
|
||||
@@ -23,113 +24,107 @@ from kanboard_utils import (
|
||||
editar_tarea,
|
||||
)
|
||||
|
||||
# Instancia principal del servidor MCP
|
||||
mcp = FastMCP("Kanboard Controller", instructions="""
|
||||
# ========================
|
||||
# ⚙️ Configuración global
|
||||
# ========================
|
||||
|
||||
# Carga las variables de entorno automáticamente
|
||||
# (útil si usas un archivo .env con los valores)
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Lee las credenciales de Kanboard desde variables de entorno
|
||||
API_URL = os.getenv("KANBOARD_API_URL", "http://localhost:8080/jsonrpc.php")
|
||||
USER = os.getenv("KANBOARD_USER", "jsonrpc")
|
||||
TOKEN = os.getenv("KANBOARD_TOKEN", "792d8fdd6cbf69b3a32d800beaf48b958e4490dd9185c72c06c56061a591")
|
||||
|
||||
# ========================
|
||||
# 🧠 Inicializa el servidor
|
||||
# ========================
|
||||
mcp = FastMCP(
|
||||
"Kanboard Controller",
|
||||
instructions="""
|
||||
Controlador MCP para Kanboard que permite a un LLM gestionar tareas,
|
||||
proyectos y usuarios de un tablero Kanboard mediante JSON-RPC.
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# === Herramientas expuestas ===
|
||||
# =====================================================
|
||||
# 🧩 Herramientas disponibles para el agente LLM
|
||||
# =====================================================
|
||||
|
||||
@mcp.tool
|
||||
def list_projects(api_url: str, usuario: str, token: str) -> list[dict]:
|
||||
"""
|
||||
📁 Listar todos los proyectos de Kanboard.
|
||||
|
||||
Permite recuperar todos los proyectos accesibles para un usuario.
|
||||
Ideal para que el modelo entienda qué tableros existen antes de operar.
|
||||
|
||||
Parámetros:
|
||||
api_url (str): URL del endpoint JSON-RPC, por ejemplo http://localhost:8080/jsonrpc.php
|
||||
usuario (str): Nombre de usuario con permisos API
|
||||
token (str): Token de autenticación
|
||||
|
||||
Retorna:
|
||||
list[dict]: Proyectos con campos como 'id', 'name', 'identifier', etc.
|
||||
"""
|
||||
return listar_proyectos(api_url, usuario, token)
|
||||
def list_projects() -> list[dict]:
|
||||
"""📁 Listar todos los proyectos de Kanboard."""
|
||||
return listar_proyectos(API_URL, USER, TOKEN)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def list_users(api_url: str, usuario: str, token: str) -> list[dict]:
|
||||
"""
|
||||
👥 Listar todos los usuarios disponibles en Kanboard.
|
||||
def list_users() -> list[dict]:
|
||||
"""👥 Listar todos los usuarios disponibles en Kanboard."""
|
||||
return listar_usuarios(API_URL, USER, TOKEN)
|
||||
|
||||
Devuelve la lista de usuarios con sus IDs, nombres y nombres de usuario.
|
||||
"""
|
||||
return listar_usuarios(api_url, usuario, token)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def list_tasks(api_url: str, usuario: str, token: str, project_id: int) -> list[dict]:
|
||||
def list_tasks(project_id: int) -> list[str]:
|
||||
"""
|
||||
🗂️ Listar todas las tareas de un proyecto.
|
||||
🗂️ Listar todas las tareas de un proyecto (solo nombres).
|
||||
|
||||
Permite a un LLM inspeccionar las tareas dentro de un proyecto específico.
|
||||
Devuelve una lista simple de títulos de tareas, sin metadatos adicionales.
|
||||
Esto simplifica el contexto para modelos de lenguaje.
|
||||
"""
|
||||
return listar_tareas(api_url, usuario, token, project_id)
|
||||
tareas = listar_tareas(API_URL, USER, TOKEN, project_id)
|
||||
|
||||
# 🧹 Extrae solo los títulos (ignorando otros campos)
|
||||
nombres = [t["title"] for t in tareas if "title" in t]
|
||||
|
||||
# 🔢 Devuelve lista simple de strings
|
||||
return nombres
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def list_subtasks(api_url: str, usuario: str, token: str, task_id: int) -> list[dict]:
|
||||
"""
|
||||
📋 Listar las subtareas de una tarea específica.
|
||||
|
||||
Útil para analizar el desglose de una tarea compleja.
|
||||
"""
|
||||
return listar_subtareas(api_url, usuario, token, task_id)
|
||||
def list_subtasks(task_id: int) -> list[dict]:
|
||||
"""📋 Listar las subtareas de una tarea específica."""
|
||||
return listar_subtareas(API_URL, USER, TOKEN, task_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def list_tasks_by_column(api_url: str, usuario: str, token: str, project_id: int) -> dict:
|
||||
"""
|
||||
🧩 Agrupa las tareas por columna del tablero Kanboard.
|
||||
|
||||
Permite al modelo entender la estructura actual del flujo de trabajo.
|
||||
"""
|
||||
return listar_tareas_por_columna(api_url, usuario, token, project_id)
|
||||
def list_tasks_by_column(project_id: int) -> dict:
|
||||
"""🧩 Agrupar las tareas por columna del tablero Kanboard."""
|
||||
return listar_tareas_por_columna(API_URL, USER, TOKEN, project_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def move_task(api_url: str, usuario: str, token: str, project_id: int,
|
||||
task_id: int, column_id: int, position: int = 1, swimlane_id: int = 0) -> bool:
|
||||
"""
|
||||
🔄 Mover una tarea entre columnas del tablero.
|
||||
|
||||
Permite cambiar el estado de una tarea dentro del flujo de trabajo (por ejemplo, de "To Do" a "En progreso").
|
||||
"""
|
||||
return mover_tarea_columna(api_url, usuario, token, project_id, task_id, column_id, position, swimlane_id)
|
||||
def move_task(project_id: int, task_id: int, column_id: int,
|
||||
position: int = 1, swimlane_id: int = 0) -> bool:
|
||||
"""🔄 Mover una tarea entre columnas del tablero."""
|
||||
return mover_tarea_columna(API_URL, USER, TOKEN, project_id, task_id, column_id, position, swimlane_id)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def create_task(api_url: str, usuario: str, token: str, project_id: int, titulo: str,
|
||||
descripcion: str = "", swimlane_id: int | None = None, assignee_id: int | None = None,
|
||||
tags: list[str] | None = None, priority: int | None = None) -> dict:
|
||||
"""
|
||||
➕ Crear una nueva tarea en un proyecto Kanboard.
|
||||
|
||||
Ideal para automatizar la creación de tickets desde prompts naturales.
|
||||
"""
|
||||
return crear_tarea(api_url, usuario, token, project_id, titulo, descripcion,
|
||||
swimlane_id, assignee_id, tags, priority)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def edit_task(api_url: str, usuario: str, token: str, task_id: int,
|
||||
titulo: str | None = None, descripcion: str | None = None,
|
||||
def create_task(project_id: int, titulo: str, descripcion: str = "",
|
||||
swimlane_id: int | None = None, assignee_id: int | None = None,
|
||||
tags: list[str] | None = None, priority: int | None = None) -> dict:
|
||||
"""
|
||||
✏️ Editar una tarea existente en Kanboard.
|
||||
|
||||
Actualiza campos específicos sin alterar los demás.
|
||||
Permite editar título, descripción, prioridad o etiquetas.
|
||||
"""
|
||||
return editar_tarea(api_url, usuario, token, task_id, titulo, descripcion,
|
||||
"""➕ Crear una nueva tarea en un proyecto Kanboard."""
|
||||
return crear_tarea(API_URL, USER, TOKEN, project_id, titulo, descripcion,
|
||||
swimlane_id, assignee_id, tags, priority)
|
||||
|
||||
|
||||
# === Punto de entrada del servidor ===
|
||||
@mcp.tool
|
||||
def edit_task(task_id: int, titulo: str | None = None, descripcion: str | None = None,
|
||||
swimlane_id: int | None = None, assignee_id: int | None = None,
|
||||
tags: list[str] | None = None, priority: int | None = None) -> dict:
|
||||
"""✏️ Editar una tarea existente en Kanboard."""
|
||||
return editar_tarea(API_URL, USER, TOKEN, task_id, titulo, descripcion,
|
||||
swimlane_id, assignee_id, tags, priority)
|
||||
|
||||
|
||||
# =====================================================
|
||||
# 🚀 Punto de entrada principal del servidor FastMCP
|
||||
# =====================================================
|
||||
if __name__ == "__main__":
|
||||
print("✅ Kanboard MCP listo y conectado con:")
|
||||
print(f" 🌐 API_URL: {API_URL}")
|
||||
print(f" 👤 Usuario: {USER}")
|
||||
mcp.run()
|
||||
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
"""Helpers to build and manage MCPTools instances from JSON configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
from agno.tools.mcp import MCPTools
|
||||
|
||||
|
||||
class MCPConfigError(ValueError):
|
||||
"""Raised when the provided MCP configuration is invalid."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPServerDefinition:
|
||||
"""Normalized representation of an MCP server entry."""
|
||||
|
||||
name: str
|
||||
command: Optional[str] = None
|
||||
args: List[str] = field(default_factory=list)
|
||||
transport: str = "stdio"
|
||||
url: Optional[str] = None
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
include_tools: Optional[List[str]] = None
|
||||
exclude_tools: Optional[List[str]] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
refresh_connection: Optional[bool] = None
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, data: Dict[str, Any]) -> "MCPServerDefinition":
|
||||
if not isinstance(data, dict):
|
||||
raise MCPConfigError(
|
||||
f"Configuration for MCP server '{name}' must be a JSON object."
|
||||
)
|
||||
|
||||
command = data.get("command")
|
||||
args = data.get("args", [])
|
||||
transport = data.get("transport", "stdio")
|
||||
|
||||
if args is None:
|
||||
args = []
|
||||
if not isinstance(args, list):
|
||||
raise MCPConfigError(
|
||||
f"The 'args' field for MCP server '{name}' must be an array of strings."
|
||||
)
|
||||
args = [str(arg) for arg in args]
|
||||
|
||||
env = data.get("env") or {}
|
||||
if not isinstance(env, dict):
|
||||
raise MCPConfigError(
|
||||
f"The 'env' field for MCP server '{name}' must be an object of key/value pairs."
|
||||
)
|
||||
env = {str(key): str(value) for key, value in env.items()}
|
||||
|
||||
include_tools = data.get("include_tools")
|
||||
if include_tools is not None and not isinstance(include_tools, list):
|
||||
raise MCPConfigError(
|
||||
f"The 'include_tools' field for MCP server '{name}' must be an array."
|
||||
)
|
||||
|
||||
exclude_tools = data.get("exclude_tools")
|
||||
if exclude_tools is not None and not isinstance(exclude_tools, list):
|
||||
raise MCPConfigError(
|
||||
f"The 'exclude_tools' field for MCP server '{name}' must be an array."
|
||||
)
|
||||
|
||||
timeout_seconds = data.get("timeout_seconds")
|
||||
if timeout_seconds is not None:
|
||||
timeout_seconds = int(timeout_seconds)
|
||||
|
||||
refresh_connection = data.get("refresh_connection")
|
||||
if refresh_connection is not None:
|
||||
refresh_connection = bool(refresh_connection)
|
||||
|
||||
enabled = bool(data.get("enabled", True))
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
command=command,
|
||||
args=args,
|
||||
transport=str(transport or "stdio"),
|
||||
url=data.get("url"),
|
||||
env=env,
|
||||
include_tools=[str(item) for item in include_tools] if include_tools else None,
|
||||
exclude_tools=[str(item) for item in exclude_tools] if exclude_tools else None,
|
||||
timeout_seconds=timeout_seconds,
|
||||
refresh_connection=refresh_connection,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
def to_kwargs(self) -> Dict[str, Any]:
|
||||
kwargs: Dict[str, Any] = {"transport": self.transport or "stdio"}
|
||||
|
||||
if self.command:
|
||||
command_parts = [self.command, *self.args]
|
||||
# MCPTools accepts the entire command as a string; shlex avoids shell injection.
|
||||
kwargs["command"] = shlex.join([str(part) for part in command_parts if part])
|
||||
elif (self.transport or "stdio") == "stdio":
|
||||
raise MCPConfigError(
|
||||
f"MCP server '{self.name}' must define a 'command' when using stdio transport."
|
||||
)
|
||||
|
||||
if self.url:
|
||||
kwargs["url"] = self.url
|
||||
if self.env:
|
||||
kwargs["env"] = self.env
|
||||
if self.include_tools is not None:
|
||||
kwargs["include_tools"] = self.include_tools
|
||||
if self.exclude_tools is not None:
|
||||
kwargs["exclude_tools"] = self.exclude_tools
|
||||
if self.timeout_seconds is not None:
|
||||
kwargs["timeout_seconds"] = self.timeout_seconds
|
||||
if self.refresh_connection is not None:
|
||||
kwargs["refresh_connection"] = self.refresh_connection
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
class MCPServerManager:
|
||||
"""Utility to load MCP server definitions and instantiate MCPTools."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_source: Union[str, bytes, Dict[str, Any], Path],
|
||||
*,
|
||||
logger=None,
|
||||
) -> None:
|
||||
self.logger = logger
|
||||
self._raw_config = self._load_config(config_source)
|
||||
self._servers = self._build_servers(self._raw_config)
|
||||
|
||||
@staticmethod
|
||||
def _load_config(config_source: Union[str, bytes, Dict[str, Any], Path]) -> Dict[str, Any]:
|
||||
if isinstance(config_source, Path):
|
||||
config_source = config_source.read_text(encoding="utf-8")
|
||||
|
||||
if isinstance(config_source, (str, bytes)):
|
||||
config_text = config_source.decode() if isinstance(config_source, bytes) else config_source
|
||||
try:
|
||||
loaded = json.loads(config_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise MCPConfigError(f"Invalid MCP configuration JSON: {exc}") from exc
|
||||
return MCPServerManager._validate_root(loaded)
|
||||
|
||||
if isinstance(config_source, dict):
|
||||
return MCPServerManager._validate_root(config_source)
|
||||
|
||||
raise MCPConfigError("Unsupported MCP configuration source type.")
|
||||
|
||||
@staticmethod
|
||||
def _validate_root(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
raise MCPConfigError("MCP configuration root must be a JSON object.")
|
||||
if "mcpServers" not in config:
|
||||
raise MCPConfigError("MCP configuration must include a 'mcpServers' object.")
|
||||
if not isinstance(config["mcpServers"], dict):
|
||||
raise MCPConfigError("The 'mcpServers' field must be a JSON object.")
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _build_servers(config: Dict[str, Any]) -> List[MCPServerDefinition]:
|
||||
servers = []
|
||||
for name, data in config.get("mcpServers", {}).items():
|
||||
definition = MCPServerDefinition.from_dict(str(name), data)
|
||||
servers.append(definition)
|
||||
return servers
|
||||
|
||||
def build_tools(
|
||||
self,
|
||||
only_servers: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
include_disabled: bool = False,
|
||||
) -> Tuple[List[MCPTools], List[str]]:
|
||||
target = {str(name) for name in only_servers} if only_servers else None
|
||||
tools: List[MCPTools] = []
|
||||
active_servers: List[str] = []
|
||||
|
||||
for definition in self._servers:
|
||||
if target and definition.name not in target:
|
||||
continue
|
||||
if not definition.enabled and not include_disabled:
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"⏭️ Skipping MCP server because it is disabled",
|
||||
add_fields={
|
||||
"mcp_server": definition.name,
|
||||
"agent_call": {
|
||||
"action": "load_mcp_server",
|
||||
"mcp_server": definition.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "skipped",
|
||||
"reason": "disabled",
|
||||
},
|
||||
},
|
||||
)
|
||||
continue
|
||||
try:
|
||||
tool = MCPTools(**definition.to_kwargs())
|
||||
setattr(tool, "mcp_server_id", definition.name)
|
||||
setattr(tool, "mcp_server_definition", definition)
|
||||
tools.append(tool)
|
||||
active_servers.append(definition.name)
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"⚙️ Configured MCP server",
|
||||
add_fields={
|
||||
"mcp_server": definition.name,
|
||||
"transport": definition.transport,
|
||||
"has_url": bool(definition.url),
|
||||
"agent_call": {
|
||||
"action": "configure_mcp_server",
|
||||
"mcp_server": definition.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "configured",
|
||||
"transport": definition.transport,
|
||||
"has_url": bool(definition.url),
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
if self.logger:
|
||||
self.logger.error(
|
||||
"🚨 Failed to configure MCP server",
|
||||
add_fields={
|
||||
"mcp_server": definition.name,
|
||||
"error": str(exc),
|
||||
"agent_call": {
|
||||
"action": "configure_mcp_server",
|
||||
"mcp_server": definition.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return tools, active_servers
|
||||
|
||||
@property
|
||||
def server_names(self) -> List[str]:
|
||||
return [definition.name for definition in self._servers]
|
||||
|
||||
|
||||
def load_mcp_tools(
|
||||
config_source: Union[str, bytes, Dict[str, Any], Path],
|
||||
*,
|
||||
logger=None,
|
||||
only_servers: Optional[Iterable[str]] = None,
|
||||
include_disabled: bool = False,
|
||||
) -> Tuple[List[MCPTools], List[str]]:
|
||||
"""Convenience helper to build MCPTools instances from a configuration source."""
|
||||
|
||||
manager = MCPServerManager(config_source, logger=logger)
|
||||
return manager.build_tools(only_servers=only_servers, include_disabled=include_disabled)
|
||||
|
||||
|
||||
async def initialize_mcp_tools(
|
||||
tools: Iterable[MCPTools],
|
||||
*,
|
||||
logger=None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Connect to each MCP server and return the available tool names."""
|
||||
|
||||
server_tool_map: Dict[str, List[str]] = {}
|
||||
|
||||
for toolkit in tools:
|
||||
server_id = getattr(toolkit, "mcp_server_id", toolkit.name)
|
||||
try:
|
||||
await toolkit.connect()
|
||||
tool_names = sorted(toolkit.functions.keys())
|
||||
server_tool_map[server_id] = tool_names
|
||||
if logger:
|
||||
logger.info(
|
||||
"🔌 MCP server conectado",
|
||||
add_fields={
|
||||
"mcp_server": server_id,
|
||||
"available_tools": tool_names,
|
||||
"tool_count": len(tool_names),
|
||||
"agent_call": {
|
||||
"action": "connect_mcp_server",
|
||||
"mcp_server": server_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "connected",
|
||||
"available_tools": tool_names,
|
||||
"tool_count": len(tool_names),
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.error(
|
||||
"🚨 Error conectando con MCP",
|
||||
add_fields={
|
||||
"mcp_server": server_id,
|
||||
"error": str(exc),
|
||||
"agent_call": {
|
||||
"action": "connect_mcp_server",
|
||||
"mcp_server": server_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return server_tool_map
|
||||
|
||||
|
||||
async def close_mcp_tools(tools: Iterable[MCPTools], *, logger=None) -> None:
|
||||
"""Close MCP connections gracefully."""
|
||||
|
||||
for toolkit in tools:
|
||||
server_id = getattr(toolkit, "mcp_server_id", toolkit.name)
|
||||
try:
|
||||
await toolkit.close()
|
||||
if logger:
|
||||
logger.debug(
|
||||
"🔻 MCP server desconectado",
|
||||
add_fields={
|
||||
"mcp_server": server_id,
|
||||
"agent_call": {
|
||||
"action": "close_mcp_server",
|
||||
"mcp_server": server_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "disconnected",
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
if logger:
|
||||
logger.warning(
|
||||
"⚠️ Error cerrando conexión MCP",
|
||||
add_fields={
|
||||
"mcp_server": server_id,
|
||||
"error": str(exc),
|
||||
"agent_call": {
|
||||
"action": "close_mcp_server",
|
||||
"mcp_server": server_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
Binary file not shown.
@@ -5,8 +5,11 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"agno>=2.2.8",
|
||||
"fastmcp>=2.12.5",
|
||||
"marimo>=0.17.0",
|
||||
"openai>=2.7.1",
|
||||
"sqlalchemy>=2.0.44",
|
||||
]
|
||||
|
||||
[tool.marimo.runtime]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from agno.utils.log import configure_agno_logging
|
||||
|
||||
from LokiLogger import LokiLogger
|
||||
|
||||
_AGNO_LOGGING_CONFIGURED = False
|
||||
|
||||
|
||||
class LokiLoggingHandler(logging.Handler):
|
||||
"""Bridge Python logging records to LokiLogger entries."""
|
||||
|
||||
def __init__(self, loki_logger: LokiLogger) -> None:
|
||||
super().__init__()
|
||||
self._loki_logger = loki_logger
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
message = self.format(record)
|
||||
except Exception:
|
||||
message = record.getMessage()
|
||||
|
||||
add_fields = _extract_extra_fields(record)
|
||||
if add_fields:
|
||||
self._loki_logger.log(record.levelname, message, add_fields=add_fields)
|
||||
else:
|
||||
self._loki_logger.log(record.levelname, message)
|
||||
|
||||
|
||||
def _extract_extra_fields(record: logging.LogRecord) -> Dict[str, Any]:
|
||||
"""Extract custom fields from the log record while skipping logging internals."""
|
||||
|
||||
skip_keys = {
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
}
|
||||
extra: Dict[str, Any] = {}
|
||||
for key, value in record.__dict__.items():
|
||||
if key in skip_keys:
|
||||
continue
|
||||
extra[key] = value
|
||||
return extra
|
||||
|
||||
|
||||
def configure_agno_to_use_loki(loki_logger: LokiLogger) -> None:
|
||||
"""Configure Agno logging to emit through the provided LokiLogger instance."""
|
||||
|
||||
global _AGNO_LOGGING_CONFIGURED
|
||||
if _AGNO_LOGGING_CONFIGURED:
|
||||
return
|
||||
|
||||
bridge_logger = logging.getLogger("agno.loki_bridge")
|
||||
bridge_logger.setLevel(logging.INFO)
|
||||
bridge_logger.propagate = False
|
||||
|
||||
handler = LokiLoggingHandler(loki_logger)
|
||||
handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
|
||||
|
||||
# Avoid duplicate handlers if this is invoked multiple times
|
||||
if not any(isinstance(existing, LokiLoggingHandler) for existing in bridge_logger.handlers):
|
||||
bridge_logger.handlers.clear()
|
||||
bridge_logger.addHandler(handler)
|
||||
|
||||
configure_agno_logging(
|
||||
custom_default_logger=bridge_logger,
|
||||
custom_agent_logger=bridge_logger,
|
||||
custom_team_logger=bridge_logger,
|
||||
)
|
||||
|
||||
_AGNO_LOGGING_CONFIGURED = True
|
||||
@@ -2,6 +2,30 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "agno"
|
||||
version = "2.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "gitpython" },
|
||||
{ name = "h11" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/67/c7f0154602ca3cd7ba4956921cf1ad1b194a4897d2e49c8d9fab25924ca4/agno-2.2.8.tar.gz", hash = "sha256:4df7282d5189c3c43b8196564825b1ef877505caa8b7cee1af8d0e56cea8adca", size = 1072301, upload-time = "2025-11-05T09:43:34.674Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/34/d450438ff317f9913e2a0b06e715325bc6b5c96442999381cc41bc5bd8f6/agno-2.2.8-py3-none-any.whl", hash = "sha256:17fe5ad416ccb472ae5d5b40c663fd82c0afa7942a453870afcf3ea56eab4b87", size = 1323050, upload-time = "2025-11-05T09:43:31.504Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -232,6 +256,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
@@ -303,6 +336,58 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/c1/9fb98c9649e15ea8cc691b4b09558b61dafb3dc0345f7322f8c4a8991ade/fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133", size = 329099, upload-time = "2025-10-17T13:24:57.518Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitdb"
|
||||
version = "4.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smmap" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.45"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -388,6 +473,57 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiter"
|
||||
version = "0.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/68/0357982493a7b20925aece061f7fb7a2678e3b232f8d73a6edb7e5304443/jiter-0.11.1.tar.gz", hash = "sha256:849dcfc76481c0ea0099391235b7ca97d7279e0fa4c86005457ac7c88e8b76dc", size = 168385, upload-time = "2025-10-17T11:31:15.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/4b/e4dd3c76424fad02a601d570f4f2a8438daea47ba081201a721a903d3f4c/jiter-0.11.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:71b6a920a5550f057d49d0e8bcc60945a8da998019e83f01adf110e226267663", size = 305272, upload-time = "2025-10-17T11:29:39.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/83/2cd3ad5364191130f4de80eacc907f693723beaab11a46c7d155b07a092c/jiter-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b3de72e925388453a5171be83379549300db01284f04d2a6f244d1d8de36f94", size = 314038, upload-time = "2025-10-17T11:29:40.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/3c/8e67d9ba524e97d2f04c8f406f8769a23205026b13b0938d16646d6e2d3e/jiter-0.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc19dd65a2bd3d9c044c5b4ebf657ca1e6003a97c0fc10f555aa4f7fb9821c00", size = 345977, upload-time = "2025-10-17T11:29:42.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a5/489ce64d992c29bccbffabb13961bbb0435e890d7f2d266d1f3df5e917d2/jiter-0.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d58faaa936743cd1464540562f60b7ce4fd927e695e8bc31b3da5b914baa9abd", size = 364503, upload-time = "2025-10-17T11:29:43.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/c0/e321dd83ee231d05c8fe4b1a12caf1f0e8c7a949bf4724d58397104f10f2/jiter-0.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:902640c3103625317291cb73773413b4d71847cdf9383ba65528745ff89f1d14", size = 487092, upload-time = "2025-10-17T11:29:44.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/5e/8f24ec49c8d37bd37f34ec0112e0b1a3b4b5a7b456c8efff1df5e189ad43/jiter-0.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30405f726e4c2ed487b176c09f8b877a957f535d60c1bf194abb8dadedb5836f", size = 376328, upload-time = "2025-10-17T11:29:46.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/70/ded107620e809327cf7050727e17ccfa79d6385a771b7fe38fb31318ef00/jiter-0.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3217f61728b0baadd2551844870f65219ac4a1285d5e1a4abddff3d51fdabe96", size = 356632, upload-time = "2025-10-17T11:29:47.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/53/c26f7251613f6a9079275ee43c89b8a973a95ff27532c421abc2a87afb04/jiter-0.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1364cc90c03a8196f35f396f84029f12abe925415049204446db86598c8b72c", size = 384358, upload-time = "2025-10-17T11:29:49.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/16/e0f2cc61e9c4d0b62f6c1bd9b9781d878a427656f88293e2a5335fa8ff07/jiter-0.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:53a54bf8e873820ab186b2dca9f6c3303f00d65ae5e7b7d6bda1b95aa472d646", size = 517279, upload-time = "2025-10-17T11:29:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5c/4cd095eaee68961bca3081acbe7c89e12ae24a5dae5fd5d2a13e01ed2542/jiter-0.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e29aca023627b0e0c2392d4248f6414d566ff3974fa08ff2ac8dbb96dfee92a", size = 508276, upload-time = "2025-10-17T11:29:52.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/25/f459240e69b0e09a7706d96ce203ad615ca36b0fe832308d2b7123abf2d0/jiter-0.11.1-cp313-cp313-win32.whl", hash = "sha256:f153e31d8bca11363751e875c0a70b3d25160ecbaee7b51e457f14498fb39d8b", size = 205593, upload-time = "2025-10-17T11:29:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/16/461bafe22bae79bab74e217a09c907481a46d520c36b7b9fe71ee8c9e983/jiter-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:f773f84080b667c69c4ea0403fc67bb08b07e2b7ce1ef335dea5868451e60fed", size = 203518, upload-time = "2025-10-17T11:29:55.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/72/c45de6e320edb4fa165b7b1a414193b3cae302dd82da2169d315dcc78b44/jiter-0.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:635ecd45c04e4c340d2187bcb1cea204c7cc9d32c1364d251564bf42e0e39c2d", size = 188062, upload-time = "2025-10-17T11:29:56.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/4a57922437ca8753ef823f434c2dec5028b237d84fa320f06a3ba1aec6e8/jiter-0.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d892b184da4d94d94ddb4031296931c74ec8b325513a541ebfd6dfb9ae89904b", size = 313814, upload-time = "2025-10-17T11:29:58.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/50/62a0683dadca25490a4bedc6a88d59de9af2a3406dd5a576009a73a1d392/jiter-0.11.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa22c223a3041dacb2fcd37c70dfd648b44662b4a48e242592f95bda5ab09d58", size = 344987, upload-time = "2025-10-17T11:30:00.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/00/2355dbfcbf6cdeaddfdca18287f0f38ae49446bb6378e4a5971e9356fc8a/jiter-0.11.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330e8e6a11ad4980cd66a0f4a3e0e2e0f646c911ce047014f984841924729789", size = 356399, upload-time = "2025-10-17T11:30:02.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/07/c2bd748d578fa933d894a55bff33f983bc27f75fc4e491b354bef7b78012/jiter-0.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:09e2e386ebf298547ca3a3704b729471f7ec666c2906c5c26c1a915ea24741ec", size = 203289, upload-time = "2025-10-17T11:30:03.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ee/ace64a853a1acbd318eb0ca167bad1cf5ee037207504b83a868a5849747b/jiter-0.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:fe4a431c291157e11cee7c34627990ea75e8d153894365a3bc84b7a959d23ca8", size = 188284, upload-time = "2025-10-17T11:30:05.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/00/d6006d069e7b076e4c66af90656b63da9481954f290d5eca8c715f4bf125/jiter-0.11.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0fa1f70da7a8a9713ff8e5f75ec3f90c0c870be6d526aa95e7c906f6a1c8c676", size = 304624, upload-time = "2025-10-17T11:30:06.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/45/4a0e31eb996b9ccfddbae4d3017b46f358a599ccf2e19fbffa5e531bd304/jiter-0.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:569ee559e5046a42feb6828c55307cf20fe43308e3ae0d8e9e4f8d8634d99944", size = 315042, upload-time = "2025-10-17T11:30:08.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/91/22f5746f5159a28c76acdc0778801f3c1181799aab196dbea2d29e064968/jiter-0.11.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69955fa1d92e81987f092b233f0be49d4c937da107b7f7dcf56306f1d3fcce9", size = 346357, upload-time = "2025-10-17T11:30:10.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/4f/57620857d4e1dc75c8ff4856c90cb6c135e61bff9b4ebfb5dc86814e82d7/jiter-0.11.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:090f4c9d4a825e0fcbd0a2647c9a88a0f366b75654d982d95a9590745ff0c48d", size = 365057, upload-time = "2025-10-17T11:30:11.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/34/caf7f9cc8ae0a5bb25a5440cc76c7452d264d1b36701b90fdadd28fe08ec/jiter-0.11.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf3d8cedf9e9d825233e0dcac28ff15c47b7c5512fdfe2e25fd5bbb6e6b0cee", size = 487086, upload-time = "2025-10-17T11:30:13.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/17/85b5857c329d533d433fedf98804ebec696004a1f88cabad202b2ddc55cf/jiter-0.11.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2aa9b1958f9c30d3d1a558b75f0626733c60eb9b7774a86b34d88060be1e67fe", size = 376083, upload-time = "2025-10-17T11:30:14.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d3/2d9f973f828226e6faebdef034097a2918077ea776fb4d88489949024787/jiter-0.11.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42d1ca16590b768c5e7d723055acd2633908baacb3628dd430842e2e035aa90", size = 357825, upload-time = "2025-10-17T11:30:15.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/55/848d4dabf2c2c236a05468c315c2cb9dc736c5915e65449ccecdba22fb6f/jiter-0.11.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5db4c2486a023820b701a17aec9c5a6173c5ba4393f26662f032f2de9c848b0f", size = 383933, upload-time = "2025-10-17T11:30:17.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/6c/204c95a4fbb0e26dfa7776c8ef4a878d0c0b215868011cc904bf44f707e2/jiter-0.11.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4573b78777ccfac954859a6eff45cbd9d281d80c8af049d0f1a3d9fc323d5c3a", size = 517118, upload-time = "2025-10-17T11:30:18.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/25/09956644ea5a2b1e7a2a0f665cb69a973b28f4621fa61fc0c0f06ff40a31/jiter-0.11.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7593ac6f40831d7961cb67633c39b9fef6689a211d7919e958f45710504f52d3", size = 508194, upload-time = "2025-10-17T11:30:20.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/49/4d1657355d7f5c9e783083a03a3f07d5858efa6916a7d9634d07db1c23bd/jiter-0.11.1-cp314-cp314-win32.whl", hash = "sha256:87202ec6ff9626ff5f9351507def98fcf0df60e9a146308e8ab221432228f4ea", size = 203961, upload-time = "2025-10-17T11:30:22.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/bd/f063bd5cc2712e7ca3cf6beda50894418fc0cfeb3f6ff45a12d87af25996/jiter-0.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:a5dd268f6531a182c89d0dd9a3f8848e86e92dfff4201b77a18e6b98aa59798c", size = 202804, upload-time = "2025-10-17T11:30:23.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ca/4d84193dfafef1020bf0bedd5e1a8d0e89cb67c54b8519040effc694964b/jiter-0.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:5d761f863f912a44748a21b5c4979c04252588ded8d1d2760976d2e42cd8d991", size = 188001, upload-time = "2025-10-17T11:30:24.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fa/3b05e5c9d32efc770a8510eeb0b071c42ae93a5b576fd91cee9af91689a1/jiter-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2cc5a3965285ddc33e0cab933e96b640bc9ba5940cea27ebbbf6695e72d6511c", size = 312561, upload-time = "2025-10-17T11:30:26.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d3/335822eb216154ddb79a130cbdce88fdf5c3e2b43dc5dba1fd95c485aaf5/jiter-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b572b3636a784c2768b2342f36a23078c8d3aa6d8a30745398b1bab58a6f1a8", size = 344551, upload-time = "2025-10-17T11:30:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6d/a0bed13676b1398f9b3ba61f32569f20a3ff270291161100956a577b2dd3/jiter-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad93e3d67a981f96596d65d2298fe8d1aa649deb5374a2fb6a434410ee11915e", size = 363051, upload-time = "2025-10-17T11:30:30.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/03/313eda04aa08545a5a04ed5876e52f49ab76a4d98e54578896ca3e16313e/jiter-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a83097ce379e202dcc3fe3fc71a16d523d1ee9192c8e4e854158f96b3efe3f2f", size = 485897, upload-time = "2025-10-17T11:30:31.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/a1011b9d325e40b53b1b96a17c010b8646013417f3902f97a86325b19299/jiter-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7042c51e7fbeca65631eb0c332f90c0c082eab04334e7ccc28a8588e8e2804d9", size = 375224, upload-time = "2025-10-17T11:30:33.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/da/1b45026b19dd39b419e917165ff0ea629dbb95f374a3a13d2df95e40a6ac/jiter-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a68d679c0e47649a61df591660507608adc2652442de7ec8276538ac46abe08", size = 356606, upload-time = "2025-10-17T11:30:34.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/0c/9acb0e54d6a8ba59ce923a180ebe824b4e00e80e56cefde86cc8e0a948be/jiter-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b0da75dbf4b6ec0b3c9e604d1ee8beaf15bc046fff7180f7d89e3cdbd3bb51", size = 384003, upload-time = "2025-10-17T11:30:35.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/2b/e5a5fe09d6da2145e4eed651e2ce37f3c0cf8016e48b1d302e21fb1628b7/jiter-0.11.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:69dd514bf0fa31c62147d6002e5ca2b3e7ef5894f5ac6f0a19752385f4e89437", size = 516946, upload-time = "2025-10-17T11:30:37.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fe/db936e16e0228d48eb81f9934e8327e9fde5185e84f02174fcd22a01be87/jiter-0.11.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:bb31ac0b339efa24c0ca606febd8b77ef11c58d09af1b5f2be4c99e907b11111", size = 507614, upload-time = "2025-10-17T11:30:38.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/db/c4438e8febfb303486d13c6b72f5eb71cf851e300a0c1f0b4140018dd31f/jiter-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b2ce0d6156a1d3ad41da3eec63b17e03e296b78b0e0da660876fccfada86d2f7", size = 204043, upload-time = "2025-10-17T11:30:40.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/59/81badb169212f30f47f817dfaabf965bc9b8204fed906fab58104ee541f9/jiter-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f4db07d127b54c4a2d43b4cf05ff0193e4f73e0dd90c74037e16df0b29f666e1", size = 204046, upload-time = "2025-10-17T11:30:41.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/01/43f7b4eb61db3e565574c4c5714685d042fb652f9eef7e5a3de6aafa943a/jiter-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28e4fdf2d7ebfc935523e50d1efa3970043cfaa161674fe66f9642409d001dfe", size = 188069, upload-time = "2025-10-17T11:30:43.23Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.1"
|
||||
@@ -435,14 +571,20 @@ name = "kanboard"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agno" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "marimo" },
|
||||
{ name = "openai" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agno", specifier = ">=2.2.8" },
|
||||
{ name = "fastmcp", specifier = ">=2.12.5" },
|
||||
{ name = "marimo", specifier = ">=0.17.0" },
|
||||
{ name = "openai", specifier = ">=2.7.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -679,6 +821,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/34/00c7ae8194074ed82b64e0bb7c24220eac5f77ac90c16e23cf0d2cfd2a03/narwhals-2.9.0-py3-none-any.whl", hash = "sha256:c59f7de4763004ae81691ce16df71b4e55aead0ead7ccde8c8f2ef8c9559c765", size = 422255, upload-time = "2025-10-20T12:19:15.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jiter" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/a2/f4023c1e0c868a6a5854955b3374f17153388aed95e835af114a17eac95b/openai-2.7.1.tar.gz", hash = "sha256:df4d4a3622b2df3475ead8eb0fbb3c27fd1c070fa2e55d778ca4f40e0186c726", size = 595933, upload-time = "2025-11-04T06:07:23.069Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/74/6bfc3adc81f6c2cea4439f2a734c40e3a420703bbcdc539890096a732bbd/openai-2.7.1-py3-none-any.whl", hash = "sha256:2f2530354d94c59c614645a4662b9dab0a5b881c5cd767a8587398feac0c9021", size = 1008780, upload-time = "2025-11-04T06:07:20.818Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-core"
|
||||
version = "0.19.5"
|
||||
@@ -1114,6 +1275,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -1123,6 +1293,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smmap"
|
||||
version = "5.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -1132,6 +1311,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.0.2"
|
||||
@@ -1165,6 +1365,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user