femtobot
Femtobot — Demo de arquitectura tipo "nanobot" (simplificada)
Este módulo implementa una versión educativa (≈300 líneas) de la arquitectura en capas usada en proyectos de agentes conversacionales:
Channel → MessageBus → AgentLoop → Tools / Memory
Objetivo: Servir como ejemplo didáctico y punto de partida para experimentar con un bucle de agente que soporta invocación de "tools" y manejo de sesiones.
Requisitos:
- Python 3.8+
- Dependencia de runtime: openai (se especifica en requirements.txt)
Configuración:
Define la variable de entorno OPENROUTER_API_KEY con tu clave de
OpenRouter/OpenAI si deseas usar el proveedor OpenRouterProvider:
export OPENROUTER_API_KEY="sk-or-v1-..."
Obtén una key en: https://openrouter.ai/keys
Nota: Este archivo está pensado para modificar solo la documentación y experimentar; no cambia la lógica básica del agente en esta versión.
1""" 2Femtobot — Demo de arquitectura tipo "nanobot" (simplificada) 3 4Este módulo implementa una versión educativa (≈300 líneas) de la 5arquitectura en capas usada en proyectos de agentes conversacionales: 6 7 Channel → MessageBus → AgentLoop → Tools / Memory 8 9Objetivo: 10 Servir como ejemplo didáctico y punto de partida para experimentar 11 con un bucle de agente que soporta invocación de "tools" y manejo de 12 sesiones. 13 14Requisitos: 15 - Python 3.8+ 16 - Dependencia de runtime: `openai` (se especifica en `requirements.txt`) 17 18Configuración: 19 Define la variable de entorno `OPENROUTER_API_KEY` con tu clave de 20 OpenRouter/OpenAI si deseas usar el proveedor `OpenRouterProvider`: 21 22 export OPENROUTER_API_KEY="sk-or-v1-..." 23 24 Obtén una key en: https://openrouter.ai/keys 25 26Nota: 27 Este archivo está pensado para modificar solo la documentación y 28 experimentar; no cambia la lógica básica del agente en esta versión. 29""" 30 31import asyncio 32import os 33import datetime 34from abc import ABC, abstractmethod 35from dataclasses import dataclass 36from openai import AsyncOpenAI 37 38 39# ══════════════════════════════════════════════════════════════════ 40# 1. CAPA DE MENSAJES (bus/events.py en nanobot) 41# Define los tipos de mensajes que fluyen por el sistema. 42# ══════════════════════════════════════════════════════════════════ 43 44 45@dataclass 46class InboundMessage: 47 """Mensaje que llega desde un canal (usuario → agente).""" 48 49 content: str 50 channel: str = "cli" 51 chat_id: str = "default" 52 session_key: str = "cli:default" 53 54 55@dataclass 56class OutboundMessage: 57 """Mensaje que sale hacia un canal (agente → usuario).""" 58 59 content: str 60 channel: str = "cli" 61 chat_id: str = "default" 62 63 64# ══════════════════════════════════════════════════════════════════ 65# 2. MESSAGE BUS (bus/queue.py en nanobot) 66# Cola asíncrona que desacopla canales del agente. 67# Los canales publican mensajes; el agente los consume. 68# ══════════════════════════════════════════════════════════════════ 69 70 71class MessageBus: 72 """ 73 Bus de mensajes central. 74 75 Patrón: productor/consumidor asíncrono. 76 - Los canales publican InboundMessages. 77 - El AgentLoop consume InboundMessages y publica OutboundMessages. 78 - Los canales consumen OutboundMessages para enviar al usuario. 79 """ 80 81 def __init__(self): 82 self._inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() 83 self._outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() 84 85 async def publish_inbound(self, msg: InboundMessage) -> None: 86 """Canal → Bus: encola mensaje del usuario. 87 88 Args: 89 msg: InboundMessage con el contenido y metadatos del mensaje. 90 """ 91 await self._inbound.put(msg) 92 93 async def consume_inbound(self) -> InboundMessage: 94 """Bus → AgentLoop: saca el próximo mensaje a procesar. 95 96 Returns: 97 InboundMessage con el contenido y metadatos del mensaje. 98 """ 99 return await self._inbound.get() 100 101 async def publish_outbound(self, msg: OutboundMessage) -> None: 102 """AgentLoop → Bus: encola respuesta del agente. 103 104 Args: 105 msg: OutboundMessage con el contenido y metadatos de la respuesta. 106 """ 107 await self._outbound.put(msg) 108 109 async def consume_outbound(self) -> OutboundMessage: 110 """Bus → Canal: saca la respuesta para enviar al usuario. 111 112 Returns: 113 OutboundMessage con el contenido y metadatos de la respuesta. 114 """ 115 return await self._outbound.get() 116 117 118# ══════════════════════════════════════════════════════════════════ 119# 3. SISTEMA DE TOOLS (agent/tools/ en nanobot) 120# Las tools son capacidades que el LLM puede invocar. 121# Cada tool tiene: nombre, descripción, parámetros, ejecución. 122# ══════════════════════════════════════════════════════════════════ 123 124 125class Tool(ABC): 126 """Clase base para todas las tools (agent/tools/base.py).""" 127 128 @property 129 @abstractmethod 130 def name(self) -> str: 131 """Nombre único de la tool.""" 132 ... 133 134 @property 135 @abstractmethod 136 def description(self) -> str: 137 """Descripción que el LLM usará para decidir cuándo llamarla.""" 138 ... 139 140 @property 141 @abstractmethod 142 def parameters(self) -> dict: 143 """Esquema JSON de los parámetros.""" 144 ... 145 146 @abstractmethod 147 async def execute(self, **kwargs) -> str: 148 """Lógica de ejecución de la tool.""" 149 ... 150 151 def to_anthropic_format(self) -> dict: 152 """Convierte la tool al formato que espera la API de Anthropic.""" 153 return { 154 "name": self.name, 155 "description": self.description, 156 "input_schema": self.parameters, 157 } 158 159 160class DateTimeTool(Tool): 161 """Tool de ejemplo: fecha y hora actual. 162 163 El LLM puede llamarla para obtener la fecha y hora del sistema. 164 Ejemplo de definición de tool simple, sin parámetros. 165 Como usarla tool: 166 "¿Qué hora es?" → LLM llama a "get_datetime" → ejecuta tool 167 → devuelve resultado al LLM → LLM responde al usuario con la hora actual. 168 """ 169 170 @property 171 def name(self) -> str: 172 return "get_datetime" 173 174 @property 175 def description(self) -> str: 176 return "Obtiene la fecha y hora actual del sistema." 177 178 @property 179 def parameters(self) -> dict: 180 return { 181 "type": "object", 182 "properties": {}, 183 "required": [], 184 } 185 186 async def execute(self) -> str: 187 """Implementación de la tool: retorna la fecha y hora actual. 188 189 Returns: 190 str: Cadena con la fecha y hora actual formateada en 191 "YYYY-MM-DD HH:MM:SS". Diseñado para ser invocado por el 192 LLM cuando se requiere información temporal precisa. 193 """ 194 now = datetime.datetime.now() 195 return f"Fecha y hora actual: {now.strftime('%Y-%m-%d %H:%M:%S')}" 196 197 198class ToolRegistry: 199 """ 200 Registro de tools disponibles (agent/tools/registry.py). 201 El AgentLoop pide las definiciones al LLM y ejecuta las calls. 202 """ 203 204 def __init__(self): 205 self._tools: dict[str, Tool] = {} 206 207 def register(self, tool: Tool) -> None: 208 """Registra una tool en el sistema.""" 209 self._tools[tool.name] = tool 210 print(f" [Registry] Tool registrada: '{tool.name}'") 211 212 def get_definitions(self) -> list[dict]: 213 """Retorna las definiciones en formato Anthropic para el LLM.""" 214 return [t.to_anthropic_format() for t in self._tools.values()] 215 216 async def execute(self, name: str, arguments: dict) -> str: 217 """Ejecuta una tool por nombre con los argumentos dados.""" 218 if name not in self._tools: 219 return f"Error: tool '{name}' no encontrada." 220 tool = self._tools[name] 221 print(f" [Tool] Ejecutando '{name}' con args: {arguments}") 222 return await tool.execute(**arguments) 223 224 225# ══════════════════════════════════════════════════════════════════ 226# 4. MEMORIA / SESIÓN (session/manager.py + agent/memory.py) 227# Persiste el historial de conversación por sesión. 228# ══════════════════════════════════════════════════════════════════ 229 230 231class Session: 232 """ 233 Sesión de conversación. 234 Guarda el historial de mensajes en el formato que espera el LLM. 235 """ 236 237 def __init__(self, key: str): 238 self.key = key 239 self.messages: list[dict] = [] # Historial en formato Anthropic 240 241 def add_user(self, content: str) -> None: 242 """Agrega un mensaje del usuario al historial. 243 244 Args: 245 content: Texto del mensaje del usuario. 246 """ 247 self.messages.append({"role": "user", "content": content}) 248 249 def add_assistant(self, content: str) -> None: 250 """Agrega un mensaje del asistente al historial. 251 252 Args: 253 content: Texto del mensaje del asistente. 254 """ 255 self.messages.append({"role": "assistant", "content": content}) 256 257 def get_history(self, max_messages: int = 20) -> list[dict]: 258 """Retorna el historial de mensajes para el LLM, limitado a los últimos N mensajes. 259 260 Args: 261 max_messages: Número máximo de mensajes a retornar. 262 263 Returns: 264 Lista de mensajes en formato Anthropic (role/content). 265 """ 266 return self.messages[-max_messages:] 267 268 269class SessionManager: 270 """ 271 Administra sesiones por clave (channel:chat_id). 272 En nanobot real esto persiste en disco. 273 """ 274 275 def __init__(self): 276 self._sessions: dict[str, Session] = {} 277 278 def get_or_create(self, key: str) -> Session: 279 """Obtiene la sesión por clave o crea una nueva si no existe. 280 Args: 281 key: Clave de la sesión (ej: "cli:default"). 282 Returns: 283 Session asociada a la clave. 284 """ 285 if key not in self._sessions: 286 self._sessions[key] = Session(key) 287 return self._sessions[key] 288 289 def save(self, session: Session) -> None: 290 """Guarda la sesión en el registro (en memoria en este demo). 291 En nanobot real esto escribiría a disco para persistencia. 292 Args: 293 session: Session a guardar. 294 """ 295 self._sessions[session.key] = session 296 297 298# ══════════════════════════════════════════════════════════════════ 299# 5. PROVEEDOR LLM (providers/ en nanobot) 300# Abstracción sobre la API del modelo de lenguaje. 301# ══════════════════════════════════════════════════════════════════ 302 303 304@dataclass 305class LLMResponse: 306 """Respuesta normalizada del LLM.""" 307 308 content: str | None # Texto final (si no hay tool calls) 309 tool_calls: list[dict] # Lista de tool calls [{name, input}] 310 311 @property 312 def has_tool_calls(self) -> bool: 313 """Indica si la respuesta incluye llamadas a tools.""" 314 return len(self.tool_calls) > 0 315 316 317class LLMProvider(ABC): 318 """Interfaz base para proveedores de LLM (providers/base.py). 319 320 Define el método chat() que el AgentLoop usará para interactuar con el modelo. 321 Cada implementación concreta (OpenRouter, OpenAI, Anthropic) adaptará esta interfaz 322 a su API específica, pero el AgentLoop solo conoce esta abstracción. 323 """ 324 325 @abstractmethod 326 async def chat( 327 self, 328 messages: list[dict], 329 tools: list[dict], 330 system: str = "", 331 max_tokens: int = 1024, 332 ) -> LLMResponse: 333 """Llama al LLM con el contexto dado y retorna una respuesta normalizada. 334 335 Args: 336 messages: Lista de mensajes en formato Anthropic (role/content). 337 tools: Lista de definiciones de tools en formato Anthropic. 338 system: Prompt de sistema para definir identidad/reglas. 339 max_tokens: Límite de tokens para la respuesta. 340 341 Returns: 342 LLMResponse con el texto final y las tool calls (si las hay). 343 """ 344 ... 345 346 347class OpenRouterProvider(LLMProvider): 348 """Proveedor de LLM usando OpenRouter. 349 OpenRouter expone una API compatible con OpenAI, lo que permite 350 acceder a cientos de modelos (Claude, GPT, Gemini, Llama, etc.) 351 con una sola integración. 352 353 En este demo usamos el modelo gratuito "stepfun/step-3.5-flash:free", 354 pero puedes cambiarlo por cualquier otro modelo disponible en OpenRouter. 355 356 En nanobot real usamos "anthropic/claude-sonnet-4-5" 357 para aprovechar sus capacidades de tool use, pero 358 "stepfun/step-3.5-flash:free" también soporta herramientas 359 y es una buena opción para demos sin costo. 360 """ 361 362 def __init__( 363 self, 364 api_key: str, 365 model: str = "stepfun/step-3.5-flash:free", 366 ): 367 """Inicializa el cliente de OpenRouter con la API key y modelo especificados. 368 Args: 369 api_key: Clave de API de OpenRouter. 370 model: Nombre del modelo a usar (ej: "anthropic/claude-sonnet-4-5"). 371 """ 372 373 # La clase AsyncOpenAI es compatible con la API de OpenRouter. 374 self.client = AsyncOpenAI( 375 api_key=api_key, 376 base_url="https://openrouter.ai/api/v1", 377 ) 378 self.model = model 379 380 def _tools_to_openai_format(self, tools: list[dict]) -> list[dict]: 381 """ 382 Convierte tools del formato Anthropic al formato OpenAI/OpenRouter. 383 Anthropic usa 'input_schema'; OpenAI usa 'parameters'. 384 385 Args: 386 tools: Lista de definiciones de tools en formato Anthropic. 387 Returns: 388 Lista de definiciones de tools en formato OpenAI/OpenRouter. 389 """ 390 converted = [] 391 for t in tools: 392 converted.append( 393 { 394 "type": "function", 395 "function": { 396 "name": t["name"], 397 "description": t["description"], 398 "parameters": t["input_schema"], 399 }, 400 } 401 ) 402 return converted 403 404 async def chat( 405 self, 406 messages: list[dict], 407 tools: list[dict], 408 system: str = "", 409 max_tokens: int = 1024, 410 ) -> LLMResponse: 411 """Implementación del método chat() usando la API de OpenRouter. 412 Args: 413 messages: Lista de mensajes en formato Anthropic (role/content). 414 tools: Lista de definiciones de tools en formato Anthropic. 415 system: Prompt de sistema para definir identidad/reglas. 416 max_tokens: Límite de tokens para la respuesta. 417 Returns: 418 LLMResponse con el texto final y las tool calls (si las hay). 419 """ 420 import json 421 422 # OpenAI pone el system prompt como primer mensaje con role "system" 423 all_messages = [{"role": "system", "content": system}] + messages 424 425 openai_tools = self._tools_to_openai_format(tools) if tools else [] 426 427 kwargs = dict( 428 model=self.model, 429 max_tokens=max_tokens, 430 messages=all_messages, 431 ) 432 if openai_tools: 433 kwargs["tools"] = openai_tools 434 435 response = await self.client.chat.completions.create(**kwargs) 436 choice = response.choices[0] 437 message = choice.message 438 439 # Extrae tool calls y texto de la respuesta 440 tool_calls = [] 441 if message.tool_calls: 442 for tc in message.tool_calls: 443 tool_calls.append( 444 { 445 "id": tc.id, 446 "name": tc.function.name, 447 "input": json.loads(tc.function.arguments), 448 } 449 ) 450 451 return LLMResponse( 452 content=message.content, 453 tool_calls=tool_calls, 454 ) 455 456 457# ══════════════════════════════════════════════════════════════════ 458# 6. CONTEXT BUILDER (agent/context.py en nanobot) 459# Ensambla el prompt de sistema y los mensajes para el LLM. 460# ══════════════════════════════════════════════════════════════════ 461 462 463class ContextBuilder: 464 """ 465 Construye el contexto completo para cada llamada al LLM. 466 En nanobot real incluye: identidad, memoria, skills, metadatos. 467 En este demo simplificamos a un prompt de sistema fijo + historial de mensajes. 468 El método build_messages() combina el historial de la sesión con el mensaje actual. 469 470 """ 471 472 def build_system_prompt(self) -> str: 473 """Construye el prompt de sistema que define la identidad y reglas del agente. 474 Se podria implementar la lectura de un archivo o plantilla, pero para este 475 demo lo dejamos hardcodeado para enfocarnos en la arquitectura. 476 """ 477 478 AGENT_IDENTITY = """Agents Intructions: 479 Eres femtobot🐈✨ un asistende de AI personal. 480 """ 481 482 AGENT_SOUL = """Soul: 483 Personalidad: amigable, ingenioso, directo. 484 Habilidades: usar herramientas, responder preguntas, mantener conversaciones. 485 Objetivo: ayudar al usuario de la mejor manera posible. 486 Valores: Exactitud sobre velocidad, transparencia del usuario, seguridad y ética. 487 Estilo de comunicación: claro, conciso, sin rodeos, siempre en español. 488 """ 489 490 AGENT_TOOLS = """Tools Instructions: 491 uedes usar las siguientes herramientas cuando lo consideres necesario para responder al usuario: 492 - get_datetime: Obtiene la fecha y hora actual del sistema. 493 """ 494 return ( 495 AGENT_IDENTITY, 496 AGENT_SOUL, 497 AGENT_TOOLS, 498 f"Fecha actual: {datetime.date.today()}", 499 ) 500 501 def build_messages(self, history: list[dict], current_message: str) -> list[dict]: 502 """ 503 Construye la lista de mensajes para el LLM: 504 [historial previo] + [mensaje actual del usuario] 505 El historial ya está en formato Anthropic (role/content). 506 507 Args: 508 history: Lista de mensajes previos en formato Anthropic. 509 current_message: Texto del mensaje actual del usuario. 510 Returns: 511 Lista de mensajes combinada para enviar al LLM. 512 """ 513 messages = list(history) # Copia del historial 514 messages.append({"role": "user", "content": current_message}) 515 return messages 516 517 def add_assistant_with_tool_calls( 518 self, messages: list[dict], content: str | None, tool_calls: list[dict] 519 ) -> list[dict]: 520 """Agrega la respuesta del asistente (con tool calls) al hilo. 521 En formato OpenAI/OpenRouter los tool_calls van en el mismo mensaje. 522 523 Args: 524 messages: Lista de mensajes actual. 525 content: Texto de la respuesta del asistente (puede ser None si solo hay tool calls). 526 tool_calls: Lista de tool calls que el LLM quiere ejecutar. 527 Returns: 528 Lista de mensajes actualizada con la respuesta del asistente y las tool calls. 529 """ 530 openai_tool_calls = [ 531 { 532 "id": tc["id"], 533 "type": "function", 534 "function": { 535 "name": tc["name"], 536 "arguments": __import__("json").dumps(tc["input"]), 537 }, 538 } 539 for tc in tool_calls 540 ] 541 messages.append( 542 { 543 "role": "assistant", 544 "content": content or "", 545 "tool_calls": openai_tool_calls, 546 } 547 ) 548 return messages 549 550 def add_tool_results( 551 self, messages: list[dict], tool_calls: list[dict], results: list[str] 552 ) -> list[dict]: 553 """Agrega los resultados de las tools al hilo. 554 En OpenAI cada resultado va como un mensaje separado con role 'tool'. 555 556 Args: 557 messages: Lista de mensajes actual. 558 tool_calls: Lista de tool calls que se ejecutaron. 559 results: Lista de resultados correspondientes a cada tool call. 560 Returns: 561 Lista de mensajes actualizada con los resultados de las tools. 562 """ 563 for tc, result in zip(tool_calls, results): 564 messages.append( 565 { 566 "role": "tool", 567 "tool_call_id": tc["id"], 568 "content": result, 569 } 570 ) 571 return messages 572 573 574# ══════════════════════════════════════════════════════════════════ 575# 7. AGENT LOOP (agent/loop.py en nanobot) ← NÚCLEO DEL SISTEMA 576# Orquesta todo: recibe mensajes, llama al LLM, ejecuta tools, 577# guarda sesión y devuelve respuesta. 578# ══════════════════════════════════════════════════════════════════ 579 580 581class AgentLoop: 582 """ 583 Bucle principal del agente. 584 585 Flujo por cada mensaje: 586 1. Recibe InboundMessage del bus 587 2. Carga historial de la sesión 588 3. Construye contexto (system prompt + historial + mensaje) 589 4. Llama al LLM → si hay tool calls, las ejecuta y repite 590 5. Cuando el LLM responde con texto, guarda y publica respuesta 591 6. Maneja comandos especiales (ej: /stop para detener el agente) 592 7. Maneja errores internos y los reporta al usuario 593 8. Limita el número de iteraciones para evitar loops infinitos 594 9. Imprime logs detallados para seguimiento del proceso 595 10. Es concurrente: puede procesar múltiples mensajes a la vez sin bloquearse 596 """ 597 598 def __init__( 599 self, 600 bus: MessageBus, 601 provider: LLMProvider, 602 tools: ToolRegistry, 603 sessions: SessionManager, 604 context: ContextBuilder, 605 max_iterations: int = 10, # Nanobot real usa 40 606 ): 607 """Inicializa el AgentLoop con sus dependencias. 608 609 Args: 610 bus: MessageBus para recibir mensajes y publicar respuestas. 611 provider: LLMProvider para interactuar con el modelo de lenguaje. 612 tools: ToolRegistry con las tools disponibles para el agente. 613 sessions: SessionManager para manejar el historial de conversaciones. 614 context: ContextBuilder para construir el contexto de cada llamada al LLM. 615 max_iterations: Límite de iteraciones del bucle agente (LLM ↔ Tools) para evitar loops infinitos. 616 """ 617 self.bus = bus 618 self.provider = provider 619 self.tools = tools 620 self.sessions = sessions 621 self.context = context 622 self.max_iterations = max_iterations 623 self._running = False 624 625 async def run(self) -> None: 626 """Bucle principal que consume mensajes del bus y los procesa. 627 Maneja comandos especiales y errores internos. 628 """ 629 self._running = True 630 print("\n[AgentLoop] Iniciado. Esperando mensajes...\n") 631 632 while self._running: 633 try: 634 # Espera el próximo mensaje (timeout para poder salir) 635 msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) 636 except asyncio.TimeoutError: 637 continue 638 639 # Manejo de comandos especiales 640 if msg.content.strip().lower() == "/stop": 641 self._running = False 642 await self.bus.publish_outbound( 643 OutboundMessage("Deteniendo femtobot...", msg.channel, msg.chat_id) 644 ) 645 break 646 647 # Procesa el mensaje como tarea asíncrona 648 asyncio.create_task(self._dispatch(msg)) 649 650 async def _dispatch(self, msg: InboundMessage) -> None: 651 """Procesa un mensaje individual: llama al método principal de 652 procesamiento y maneja errores. 653 Args: 654 msg: InboundMessage a procesar. 655 """ 656 try: 657 response = await self._process_message(msg) 658 await self.bus.publish_outbound(response) 659 except Exception as e: 660 error_msg = f"Error interno: {e}" 661 print(f" [AgentLoop] {error_msg}") 662 await self.bus.publish_outbound( 663 OutboundMessage(error_msg, msg.channel, msg.chat_id) 664 ) 665 666 async def _process_message(self, msg: InboundMessage) -> OutboundMessage: 667 """Procesa el mensaje: carga sesión, construye contexto, ejecuta bucle agente, 668 guarda sesión y devuelve respuesta: 669 sesión → contexto → loop LLM+tools → respuesta 670 671 Args: 672 msg: InboundMessage con el contenido y metadatos del mensaje a procesar. 673 Returns: 674 OutboundMessage con la respuesta final para el usuario. 675 """ 676 print(f"\n[AgentLoop] Procesando: '{msg.content}'") 677 678 # Carga o crea la sesión para este chat 679 session = self.sessions.get_or_create(msg.session_key) 680 681 # Construye los mensajes para el LLM 682 messages = self.context.build_messages( 683 history=session.get_history(), 684 current_message=msg.content, 685 ) 686 687 # Ejecuta el bucle agente (LLM ↔ Tools) 688 final_content = await self._run_agent_loop(messages) 689 690 # Guarda la conversación en la sesión 691 session.add_user(msg.content) 692 session.add_assistant(final_content) 693 self.sessions.save(session) 694 695 return OutboundMessage(final_content, msg.channel, msg.chat_id) 696 697 async def _run_agent_loop(self, messages: list[dict]) -> str: 698 """ 699 Bucle interno: LLM → tool calls → resultados → LLM → ... 700 Termina cuando el LLM da una respuesta de texto sin tool calls. 701 702 Args: 703 messages: Lista de mensajes para enviar al LLM (incluye historial + mensaje actual). 704 Returns: 705 str: Respuesta final del LLM para el usuario. 706 """ 707 system_prompt = self.context.build_system_prompt() 708 tool_definitions = self.tools.get_definitions() 709 710 for iteration in range(1, self.max_iterations + 1): 711 print(f" [Loop] Iteración {iteration}/{self.max_iterations}") 712 713 # Llama al LLM 714 response = await self.provider.chat( 715 messages=messages, 716 tools=tool_definitions, 717 system=system_prompt, 718 ) 719 720 if response.has_tool_calls: 721 # El LLM quiere usar tools → ejecutarlas y continuar 722 print(f" [Loop] LLM solicita {len(response.tool_calls)} tool(s)") 723 724 # Agrega la respuesta del asistente al hilo 725 messages = self.context.add_assistant_with_tool_calls( 726 messages, response.content, response.tool_calls 727 ) 728 729 # Ejecuta cada tool call 730 results = [] 731 for tc in response.tool_calls: 732 result = await self.tools.execute(tc["name"], tc["input"]) 733 results.append(result) 734 print(f" [Loop] Resultado de '{tc['name']}': {result}") 735 736 # Agrega resultados al hilo de mensajes 737 messages = self.context.add_tool_results( 738 messages, response.tool_calls, results 739 ) 740 # Continúa el loop → el LLM verá los resultados 741 742 else: 743 # El LLM respondió con texto → fin del loop 744 print(f" [Loop] LLM responde con texto. Fin del loop.") 745 return response.content or "(sin respuesta)" 746 747 return "Alcancé el límite de iteraciones sin respuesta final." 748 749 750# ══════════════════════════════════════════════════════════════════ 751# 8. CANAL CLI (channels/base.py en nanobot) 752# El canal más simple: lee de stdin, escribe en stdout. 753# En nanobot real existen canales Telegram, WhatsApp, Discord... 754# ══════════════════════════════════════════════════════════════════ 755 756 757class Channel(ABC): 758 """Interfaz base para canales de comunicación (channels/base.py). 759 Define el método run() que cada canal implementará para manejar la entrada/salida. 760 El AgentLoop es agnóstico al canal: solo interactúa con el MessageBus. 761 """ 762 763 @abstractmethod 764 async def run(self) -> None: 765 """Bucle principal del canal para manejar entrada/salida.""" 766 ... 767 768 769class CLIChannel(Channel): 770 """Canal de línea de comandos (CLI). 771 Lee input del usuario desde stdin y publica mensajes en el bus. 772 Escucha respuestas del bus y las imprime en stdout. 773 Es un canal síncrono adaptado a asyncio usando run_in_executor para no bloquear el bucle del agente. 774 775 """ 776 777 def __init__(self, bus: MessageBus): 778 self.bus = bus 779 780 async def run(self) -> None: 781 """Bucle de entrada/salida del canal CLI.""" 782 print("╔══════════════════════════════════════╗") 783 print("║ 🐈 femtobot — Demo Arquitectura ║") 784 print("║ Escribe /stop para salir ║") 785 print("╚══════════════════════════════════════╝\n") 786 787 while True: 788 # Lee input del usuario (asyncio-friendly) 789 loop = asyncio.get_event_loop() 790 user_input = await loop.run_in_executor(None, input, "Tú: ") 791 792 if not user_input.strip(): 793 continue 794 795 # Publica el mensaje en el bus 796 await self.bus.publish_inbound( 797 InboundMessage( 798 content=user_input, 799 channel="cli", 800 chat_id="default", 801 session_key="cli:default", 802 ) 803 ) 804 805 # Espera y muestra la respuesta 806 response = await self.bus.consume_outbound() 807 print(f"\n🤖 femtobot: {response.content}\n") 808 809 if user_input.strip().lower() == "/stop": 810 break 811 812 813# ══════════════════════════════════════════════════════════════════ 814# 9. BOOTSTRAP (cli/ en nanobot) 815# Ensambla todos los componentes y arranca el sistema. 816# ══════════════════════════════════════════════════════════════════ 817 818 819async def main(): 820 """ 821 Punto de entrada: ensambla y arranca femtobot. 822 823 Diagrama de componentes: 824 825 [CLIChannel] ──publish──► [MessageBus] ──consume──► [AgentLoop] 826 │ 827 ┌───────────────┤ 828 ▼ ▼ 829 [ToolRegistry] [SessionManager] 830 │ 831 [DateTime] 832 El CLIChannel lee input del usuario y lo publica en el MessageBus. 833 El AgentLoop consume mensajes del MessageBus, construye el contexto, 834 llama al LLM y ejecuta tools según sea necesario. 835 Luego publica la respuesta de vuelta en el MessageBus para que el CLIChannel 836 la muestre al usuario. 837 """ 838 api_key = os.environ.get("OPENROUTER_API_KEY") 839 if not api_key: 840 print("❌ Error: define la variable OPENROUTER_API_KEY") 841 print(" export OPENROUTER_API_KEY='sk-or-v1-...'") 842 print(" Obtén tu key en: https://openrouter.ai/keys") 843 return 844 845 print("\n[Bootstrap] Iniciando femtobot...\n") 846 847 # 1. Crea el bus central 848 bus = MessageBus() 849 print("[Bootstrap] ✓ MessageBus creado") 850 851 # 2. Registra las tools 852 tools = ToolRegistry() 853 tools.register(DateTimeTool()) 854 print("[Bootstrap] ✓ Tools registradas") 855 856 # 3. Crea los demás componentes 857 # Puedes cambiar el modelo a cualquiera disponible en OpenRouter: 858 # "anthropic/claude-sonnet-4-5" 859 # "openai/gpt-4o" 860 # "google/gemini-2.0-flash-001" 861 # "meta-llama/llama-3.3-70b-instruct" 862 provider = OpenRouterProvider( 863 api_key=api_key, 864 model="stepfun/step-3.5-flash:free", 865 ) 866 sessions = SessionManager() 867 context = ContextBuilder() 868 print(f"[Bootstrap] ✓ Provider OpenRouter listo (modelo: {provider.model})") 869 870 # 4. Crea el agente 871 agent = AgentLoop( 872 bus=bus, 873 provider=provider, 874 tools=tools, 875 sessions=sessions, 876 context=context, 877 ) 878 print("[Bootstrap] ✓ AgentLoop listo") 879 880 # 5. Crea el canal CLI 881 channel = CLIChannel(bus=bus) 882 print("[Bootstrap] ✓ CLIChannel listo") 883 884 # 6. Arranca ambos concurrentemente 885 print("[Bootstrap] Arrancando sistema...\n") 886 await asyncio.gather( 887 agent.run(), 888 channel.run(), 889 ) 890 891 892if __name__ == "__main__": 893 asyncio.run(main())
46@dataclass 47class InboundMessage: 48 """Mensaje que llega desde un canal (usuario → agente).""" 49 50 content: str 51 channel: str = "cli" 52 chat_id: str = "default" 53 session_key: str = "cli:default"
Mensaje que llega desde un canal (usuario → agente).
56@dataclass 57class OutboundMessage: 58 """Mensaje que sale hacia un canal (agente → usuario).""" 59 60 content: str 61 channel: str = "cli" 62 chat_id: str = "default"
Mensaje que sale hacia un canal (agente → usuario).
72class MessageBus: 73 """ 74 Bus de mensajes central. 75 76 Patrón: productor/consumidor asíncrono. 77 - Los canales publican InboundMessages. 78 - El AgentLoop consume InboundMessages y publica OutboundMessages. 79 - Los canales consumen OutboundMessages para enviar al usuario. 80 """ 81 82 def __init__(self): 83 self._inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() 84 self._outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() 85 86 async def publish_inbound(self, msg: InboundMessage) -> None: 87 """Canal → Bus: encola mensaje del usuario. 88 89 Args: 90 msg: InboundMessage con el contenido y metadatos del mensaje. 91 """ 92 await self._inbound.put(msg) 93 94 async def consume_inbound(self) -> InboundMessage: 95 """Bus → AgentLoop: saca el próximo mensaje a procesar. 96 97 Returns: 98 InboundMessage con el contenido y metadatos del mensaje. 99 """ 100 return await self._inbound.get() 101 102 async def publish_outbound(self, msg: OutboundMessage) -> None: 103 """AgentLoop → Bus: encola respuesta del agente. 104 105 Args: 106 msg: OutboundMessage con el contenido y metadatos de la respuesta. 107 """ 108 await self._outbound.put(msg) 109 110 async def consume_outbound(self) -> OutboundMessage: 111 """Bus → Canal: saca la respuesta para enviar al usuario. 112 113 Returns: 114 OutboundMessage con el contenido y metadatos de la respuesta. 115 """ 116 return await self._outbound.get()
Bus de mensajes central.
Patrón: productor/consumidor asíncrono.
- Los canales publican InboundMessages.
- El AgentLoop consume InboundMessages y publica OutboundMessages.
- Los canales consumen OutboundMessages para enviar al usuario.
86 async def publish_inbound(self, msg: InboundMessage) -> None: 87 """Canal → Bus: encola mensaje del usuario. 88 89 Args: 90 msg: InboundMessage con el contenido y metadatos del mensaje. 91 """ 92 await self._inbound.put(msg)
Canal → Bus: encola mensaje del usuario.
Args: msg: InboundMessage con el contenido y metadatos del mensaje.
94 async def consume_inbound(self) -> InboundMessage: 95 """Bus → AgentLoop: saca el próximo mensaje a procesar. 96 97 Returns: 98 InboundMessage con el contenido y metadatos del mensaje. 99 """ 100 return await self._inbound.get()
Bus → AgentLoop: saca el próximo mensaje a procesar.
Returns: InboundMessage con el contenido y metadatos del mensaje.
102 async def publish_outbound(self, msg: OutboundMessage) -> None: 103 """AgentLoop → Bus: encola respuesta del agente. 104 105 Args: 106 msg: OutboundMessage con el contenido y metadatos de la respuesta. 107 """ 108 await self._outbound.put(msg)
AgentLoop → Bus: encola respuesta del agente.
Args: msg: OutboundMessage con el contenido y metadatos de la respuesta.
110 async def consume_outbound(self) -> OutboundMessage: 111 """Bus → Canal: saca la respuesta para enviar al usuario. 112 113 Returns: 114 OutboundMessage con el contenido y metadatos de la respuesta. 115 """ 116 return await self._outbound.get()
Bus → Canal: saca la respuesta para enviar al usuario.
Returns: OutboundMessage con el contenido y metadatos de la respuesta.
126class Tool(ABC): 127 """Clase base para todas las tools (agent/tools/base.py).""" 128 129 @property 130 @abstractmethod 131 def name(self) -> str: 132 """Nombre único de la tool.""" 133 ... 134 135 @property 136 @abstractmethod 137 def description(self) -> str: 138 """Descripción que el LLM usará para decidir cuándo llamarla.""" 139 ... 140 141 @property 142 @abstractmethod 143 def parameters(self) -> dict: 144 """Esquema JSON de los parámetros.""" 145 ... 146 147 @abstractmethod 148 async def execute(self, **kwargs) -> str: 149 """Lógica de ejecución de la tool.""" 150 ... 151 152 def to_anthropic_format(self) -> dict: 153 """Convierte la tool al formato que espera la API de Anthropic.""" 154 return { 155 "name": self.name, 156 "description": self.description, 157 "input_schema": self.parameters, 158 }
Clase base para todas las tools (agent/tools/base.py).
129 @property 130 @abstractmethod 131 def name(self) -> str: 132 """Nombre único de la tool.""" 133 ...
Nombre único de la tool.
135 @property 136 @abstractmethod 137 def description(self) -> str: 138 """Descripción que el LLM usará para decidir cuándo llamarla.""" 139 ...
Descripción que el LLM usará para decidir cuándo llamarla.
141 @property 142 @abstractmethod 143 def parameters(self) -> dict: 144 """Esquema JSON de los parámetros.""" 145 ...
Esquema JSON de los parámetros.
147 @abstractmethod 148 async def execute(self, **kwargs) -> str: 149 """Lógica de ejecución de la tool.""" 150 ...
Lógica de ejecución de la tool.
152 def to_anthropic_format(self) -> dict: 153 """Convierte la tool al formato que espera la API de Anthropic.""" 154 return { 155 "name": self.name, 156 "description": self.description, 157 "input_schema": self.parameters, 158 }
Convierte la tool al formato que espera la API de Anthropic.
161class DateTimeTool(Tool): 162 """Tool de ejemplo: fecha y hora actual. 163 164 El LLM puede llamarla para obtener la fecha y hora del sistema. 165 Ejemplo de definición de tool simple, sin parámetros. 166 Como usarla tool: 167 "¿Qué hora es?" → LLM llama a "get_datetime" → ejecuta tool 168 → devuelve resultado al LLM → LLM responde al usuario con la hora actual. 169 """ 170 171 @property 172 def name(self) -> str: 173 return "get_datetime" 174 175 @property 176 def description(self) -> str: 177 return "Obtiene la fecha y hora actual del sistema." 178 179 @property 180 def parameters(self) -> dict: 181 return { 182 "type": "object", 183 "properties": {}, 184 "required": [], 185 } 186 187 async def execute(self) -> str: 188 """Implementación de la tool: retorna la fecha y hora actual. 189 190 Returns: 191 str: Cadena con la fecha y hora actual formateada en 192 "YYYY-MM-DD HH:MM:SS". Diseñado para ser invocado por el 193 LLM cuando se requiere información temporal precisa. 194 """ 195 now = datetime.datetime.now() 196 return f"Fecha y hora actual: {now.strftime('%Y-%m-%d %H:%M:%S')}"
Tool de ejemplo: fecha y hora actual.
El LLM puede llamarla para obtener la fecha y hora del sistema. Ejemplo de definición de tool simple, sin parámetros. Como usarla tool: "¿Qué hora es?" → LLM llama a "get_datetime" → ejecuta tool → devuelve resultado al LLM → LLM responde al usuario con la hora actual.
175 @property 176 def description(self) -> str: 177 return "Obtiene la fecha y hora actual del sistema."
Descripción que el LLM usará para decidir cuándo llamarla.
179 @property 180 def parameters(self) -> dict: 181 return { 182 "type": "object", 183 "properties": {}, 184 "required": [], 185 }
Esquema JSON de los parámetros.
187 async def execute(self) -> str: 188 """Implementación de la tool: retorna la fecha y hora actual. 189 190 Returns: 191 str: Cadena con la fecha y hora actual formateada en 192 "YYYY-MM-DD HH:MM:SS". Diseñado para ser invocado por el 193 LLM cuando se requiere información temporal precisa. 194 """ 195 now = datetime.datetime.now() 196 return f"Fecha y hora actual: {now.strftime('%Y-%m-%d %H:%M:%S')}"
Implementación de la tool: retorna la fecha y hora actual.
Returns: str: Cadena con la fecha y hora actual formateada en "YYYY-MM-DD HH:MM:SS". Diseñado para ser invocado por el LLM cuando se requiere información temporal precisa.
Inherited Members
199class ToolRegistry: 200 """ 201 Registro de tools disponibles (agent/tools/registry.py). 202 El AgentLoop pide las definiciones al LLM y ejecuta las calls. 203 """ 204 205 def __init__(self): 206 self._tools: dict[str, Tool] = {} 207 208 def register(self, tool: Tool) -> None: 209 """Registra una tool en el sistema.""" 210 self._tools[tool.name] = tool 211 print(f" [Registry] Tool registrada: '{tool.name}'") 212 213 def get_definitions(self) -> list[dict]: 214 """Retorna las definiciones en formato Anthropic para el LLM.""" 215 return [t.to_anthropic_format() for t in self._tools.values()] 216 217 async def execute(self, name: str, arguments: dict) -> str: 218 """Ejecuta una tool por nombre con los argumentos dados.""" 219 if name not in self._tools: 220 return f"Error: tool '{name}' no encontrada." 221 tool = self._tools[name] 222 print(f" [Tool] Ejecutando '{name}' con args: {arguments}") 223 return await tool.execute(**arguments)
Registro de tools disponibles (agent/tools/registry.py). El AgentLoop pide las definiciones al LLM y ejecuta las calls.
208 def register(self, tool: Tool) -> None: 209 """Registra una tool en el sistema.""" 210 self._tools[tool.name] = tool 211 print(f" [Registry] Tool registrada: '{tool.name}'")
Registra una tool en el sistema.
213 def get_definitions(self) -> list[dict]: 214 """Retorna las definiciones en formato Anthropic para el LLM.""" 215 return [t.to_anthropic_format() for t in self._tools.values()]
Retorna las definiciones en formato Anthropic para el LLM.
217 async def execute(self, name: str, arguments: dict) -> str: 218 """Ejecuta una tool por nombre con los argumentos dados.""" 219 if name not in self._tools: 220 return f"Error: tool '{name}' no encontrada." 221 tool = self._tools[name] 222 print(f" [Tool] Ejecutando '{name}' con args: {arguments}") 223 return await tool.execute(**arguments)
Ejecuta una tool por nombre con los argumentos dados.
232class Session: 233 """ 234 Sesión de conversación. 235 Guarda el historial de mensajes en el formato que espera el LLM. 236 """ 237 238 def __init__(self, key: str): 239 self.key = key 240 self.messages: list[dict] = [] # Historial en formato Anthropic 241 242 def add_user(self, content: str) -> None: 243 """Agrega un mensaje del usuario al historial. 244 245 Args: 246 content: Texto del mensaje del usuario. 247 """ 248 self.messages.append({"role": "user", "content": content}) 249 250 def add_assistant(self, content: str) -> None: 251 """Agrega un mensaje del asistente al historial. 252 253 Args: 254 content: Texto del mensaje del asistente. 255 """ 256 self.messages.append({"role": "assistant", "content": content}) 257 258 def get_history(self, max_messages: int = 20) -> list[dict]: 259 """Retorna el historial de mensajes para el LLM, limitado a los últimos N mensajes. 260 261 Args: 262 max_messages: Número máximo de mensajes a retornar. 263 264 Returns: 265 Lista de mensajes en formato Anthropic (role/content). 266 """ 267 return self.messages[-max_messages:]
Sesión de conversación. Guarda el historial de mensajes en el formato que espera el LLM.
242 def add_user(self, content: str) -> None: 243 """Agrega un mensaje del usuario al historial. 244 245 Args: 246 content: Texto del mensaje del usuario. 247 """ 248 self.messages.append({"role": "user", "content": content})
Agrega un mensaje del usuario al historial.
Args: content: Texto del mensaje del usuario.
250 def add_assistant(self, content: str) -> None: 251 """Agrega un mensaje del asistente al historial. 252 253 Args: 254 content: Texto del mensaje del asistente. 255 """ 256 self.messages.append({"role": "assistant", "content": content})
Agrega un mensaje del asistente al historial.
Args: content: Texto del mensaje del asistente.
258 def get_history(self, max_messages: int = 20) -> list[dict]: 259 """Retorna el historial de mensajes para el LLM, limitado a los últimos N mensajes. 260 261 Args: 262 max_messages: Número máximo de mensajes a retornar. 263 264 Returns: 265 Lista de mensajes en formato Anthropic (role/content). 266 """ 267 return self.messages[-max_messages:]
Retorna el historial de mensajes para el LLM, limitado a los últimos N mensajes.
Args: max_messages: Número máximo de mensajes a retornar.
Returns: Lista de mensajes en formato Anthropic (role/content).
270class SessionManager: 271 """ 272 Administra sesiones por clave (channel:chat_id). 273 En nanobot real esto persiste en disco. 274 """ 275 276 def __init__(self): 277 self._sessions: dict[str, Session] = {} 278 279 def get_or_create(self, key: str) -> Session: 280 """Obtiene la sesión por clave o crea una nueva si no existe. 281 Args: 282 key: Clave de la sesión (ej: "cli:default"). 283 Returns: 284 Session asociada a la clave. 285 """ 286 if key not in self._sessions: 287 self._sessions[key] = Session(key) 288 return self._sessions[key] 289 290 def save(self, session: Session) -> None: 291 """Guarda la sesión en el registro (en memoria en este demo). 292 En nanobot real esto escribiría a disco para persistencia. 293 Args: 294 session: Session a guardar. 295 """ 296 self._sessions[session.key] = session
Administra sesiones por clave (channel:chat_id). En nanobot real esto persiste en disco.
279 def get_or_create(self, key: str) -> Session: 280 """Obtiene la sesión por clave o crea una nueva si no existe. 281 Args: 282 key: Clave de la sesión (ej: "cli:default"). 283 Returns: 284 Session asociada a la clave. 285 """ 286 if key not in self._sessions: 287 self._sessions[key] = Session(key) 288 return self._sessions[key]
Obtiene la sesión por clave o crea una nueva si no existe. Args: key: Clave de la sesión (ej: "cli:default"). Returns: Session asociada a la clave.
290 def save(self, session: Session) -> None: 291 """Guarda la sesión en el registro (en memoria en este demo). 292 En nanobot real esto escribiría a disco para persistencia. 293 Args: 294 session: Session a guardar. 295 """ 296 self._sessions[session.key] = session
Guarda la sesión en el registro (en memoria en este demo). En nanobot real esto escribiría a disco para persistencia. Args: session: Session a guardar.
305@dataclass 306class LLMResponse: 307 """Respuesta normalizada del LLM.""" 308 309 content: str | None # Texto final (si no hay tool calls) 310 tool_calls: list[dict] # Lista de tool calls [{name, input}] 311 312 @property 313 def has_tool_calls(self) -> bool: 314 """Indica si la respuesta incluye llamadas a tools.""" 315 return len(self.tool_calls) > 0
Respuesta normalizada del LLM.
318class LLMProvider(ABC): 319 """Interfaz base para proveedores de LLM (providers/base.py). 320 321 Define el método chat() que el AgentLoop usará para interactuar con el modelo. 322 Cada implementación concreta (OpenRouter, OpenAI, Anthropic) adaptará esta interfaz 323 a su API específica, pero el AgentLoop solo conoce esta abstracción. 324 """ 325 326 @abstractmethod 327 async def chat( 328 self, 329 messages: list[dict], 330 tools: list[dict], 331 system: str = "", 332 max_tokens: int = 1024, 333 ) -> LLMResponse: 334 """Llama al LLM con el contexto dado y retorna una respuesta normalizada. 335 336 Args: 337 messages: Lista de mensajes en formato Anthropic (role/content). 338 tools: Lista de definiciones de tools en formato Anthropic. 339 system: Prompt de sistema para definir identidad/reglas. 340 max_tokens: Límite de tokens para la respuesta. 341 342 Returns: 343 LLMResponse con el texto final y las tool calls (si las hay). 344 """ 345 ...
Interfaz base para proveedores de LLM (providers/base.py).
Define el método chat() que el AgentLoop usará para interactuar con el modelo. Cada implementación concreta (OpenRouter, OpenAI, Anthropic) adaptará esta interfaz a su API específica, pero el AgentLoop solo conoce esta abstracción.
326 @abstractmethod 327 async def chat( 328 self, 329 messages: list[dict], 330 tools: list[dict], 331 system: str = "", 332 max_tokens: int = 1024, 333 ) -> LLMResponse: 334 """Llama al LLM con el contexto dado y retorna una respuesta normalizada. 335 336 Args: 337 messages: Lista de mensajes en formato Anthropic (role/content). 338 tools: Lista de definiciones de tools en formato Anthropic. 339 system: Prompt de sistema para definir identidad/reglas. 340 max_tokens: Límite de tokens para la respuesta. 341 342 Returns: 343 LLMResponse con el texto final y las tool calls (si las hay). 344 """ 345 ...
Llama al LLM con el contexto dado y retorna una respuesta normalizada.
Args: messages: Lista de mensajes en formato Anthropic (role/content). tools: Lista de definiciones de tools en formato Anthropic. system: Prompt de sistema para definir identidad/reglas. max_tokens: Límite de tokens para la respuesta.
Returns: LLMResponse con el texto final y las tool calls (si las hay).
348class OpenRouterProvider(LLMProvider): 349 """Proveedor de LLM usando OpenRouter. 350 OpenRouter expone una API compatible con OpenAI, lo que permite 351 acceder a cientos de modelos (Claude, GPT, Gemini, Llama, etc.) 352 con una sola integración. 353 354 En este demo usamos el modelo gratuito "stepfun/step-3.5-flash:free", 355 pero puedes cambiarlo por cualquier otro modelo disponible en OpenRouter. 356 357 En nanobot real usamos "anthropic/claude-sonnet-4-5" 358 para aprovechar sus capacidades de tool use, pero 359 "stepfun/step-3.5-flash:free" también soporta herramientas 360 y es una buena opción para demos sin costo. 361 """ 362 363 def __init__( 364 self, 365 api_key: str, 366 model: str = "stepfun/step-3.5-flash:free", 367 ): 368 """Inicializa el cliente de OpenRouter con la API key y modelo especificados. 369 Args: 370 api_key: Clave de API de OpenRouter. 371 model: Nombre del modelo a usar (ej: "anthropic/claude-sonnet-4-5"). 372 """ 373 374 # La clase AsyncOpenAI es compatible con la API de OpenRouter. 375 self.client = AsyncOpenAI( 376 api_key=api_key, 377 base_url="https://openrouter.ai/api/v1", 378 ) 379 self.model = model 380 381 def _tools_to_openai_format(self, tools: list[dict]) -> list[dict]: 382 """ 383 Convierte tools del formato Anthropic al formato OpenAI/OpenRouter. 384 Anthropic usa 'input_schema'; OpenAI usa 'parameters'. 385 386 Args: 387 tools: Lista de definiciones de tools en formato Anthropic. 388 Returns: 389 Lista de definiciones de tools en formato OpenAI/OpenRouter. 390 """ 391 converted = [] 392 for t in tools: 393 converted.append( 394 { 395 "type": "function", 396 "function": { 397 "name": t["name"], 398 "description": t["description"], 399 "parameters": t["input_schema"], 400 }, 401 } 402 ) 403 return converted 404 405 async def chat( 406 self, 407 messages: list[dict], 408 tools: list[dict], 409 system: str = "", 410 max_tokens: int = 1024, 411 ) -> LLMResponse: 412 """Implementación del método chat() usando la API de OpenRouter. 413 Args: 414 messages: Lista de mensajes en formato Anthropic (role/content). 415 tools: Lista de definiciones de tools en formato Anthropic. 416 system: Prompt de sistema para definir identidad/reglas. 417 max_tokens: Límite de tokens para la respuesta. 418 Returns: 419 LLMResponse con el texto final y las tool calls (si las hay). 420 """ 421 import json 422 423 # OpenAI pone el system prompt como primer mensaje con role "system" 424 all_messages = [{"role": "system", "content": system}] + messages 425 426 openai_tools = self._tools_to_openai_format(tools) if tools else [] 427 428 kwargs = dict( 429 model=self.model, 430 max_tokens=max_tokens, 431 messages=all_messages, 432 ) 433 if openai_tools: 434 kwargs["tools"] = openai_tools 435 436 response = await self.client.chat.completions.create(**kwargs) 437 choice = response.choices[0] 438 message = choice.message 439 440 # Extrae tool calls y texto de la respuesta 441 tool_calls = [] 442 if message.tool_calls: 443 for tc in message.tool_calls: 444 tool_calls.append( 445 { 446 "id": tc.id, 447 "name": tc.function.name, 448 "input": json.loads(tc.function.arguments), 449 } 450 ) 451 452 return LLMResponse( 453 content=message.content, 454 tool_calls=tool_calls, 455 )
Proveedor de LLM usando OpenRouter. OpenRouter expone una API compatible con OpenAI, lo que permite acceder a cientos de modelos (Claude, GPT, Gemini, Llama, etc.) con una sola integración.
En este demo usamos el modelo gratuito "stepfun/step-3.5-flash:free", pero puedes cambiarlo por cualquier otro modelo disponible en OpenRouter.
En nanobot real usamos "anthropic/claude-sonnet-4-5" para aprovechar sus capacidades de tool use, pero "stepfun/step-3.5-flash:free" también soporta herramientas y es una buena opción para demos sin costo.
363 def __init__( 364 self, 365 api_key: str, 366 model: str = "stepfun/step-3.5-flash:free", 367 ): 368 """Inicializa el cliente de OpenRouter con la API key y modelo especificados. 369 Args: 370 api_key: Clave de API de OpenRouter. 371 model: Nombre del modelo a usar (ej: "anthropic/claude-sonnet-4-5"). 372 """ 373 374 # La clase AsyncOpenAI es compatible con la API de OpenRouter. 375 self.client = AsyncOpenAI( 376 api_key=api_key, 377 base_url="https://openrouter.ai/api/v1", 378 ) 379 self.model = model
Inicializa el cliente de OpenRouter con la API key y modelo especificados. Args: api_key: Clave de API de OpenRouter. model: Nombre del modelo a usar (ej: "anthropic/claude-sonnet-4-5").
405 async def chat( 406 self, 407 messages: list[dict], 408 tools: list[dict], 409 system: str = "", 410 max_tokens: int = 1024, 411 ) -> LLMResponse: 412 """Implementación del método chat() usando la API de OpenRouter. 413 Args: 414 messages: Lista de mensajes en formato Anthropic (role/content). 415 tools: Lista de definiciones de tools en formato Anthropic. 416 system: Prompt de sistema para definir identidad/reglas. 417 max_tokens: Límite de tokens para la respuesta. 418 Returns: 419 LLMResponse con el texto final y las tool calls (si las hay). 420 """ 421 import json 422 423 # OpenAI pone el system prompt como primer mensaje con role "system" 424 all_messages = [{"role": "system", "content": system}] + messages 425 426 openai_tools = self._tools_to_openai_format(tools) if tools else [] 427 428 kwargs = dict( 429 model=self.model, 430 max_tokens=max_tokens, 431 messages=all_messages, 432 ) 433 if openai_tools: 434 kwargs["tools"] = openai_tools 435 436 response = await self.client.chat.completions.create(**kwargs) 437 choice = response.choices[0] 438 message = choice.message 439 440 # Extrae tool calls y texto de la respuesta 441 tool_calls = [] 442 if message.tool_calls: 443 for tc in message.tool_calls: 444 tool_calls.append( 445 { 446 "id": tc.id, 447 "name": tc.function.name, 448 "input": json.loads(tc.function.arguments), 449 } 450 ) 451 452 return LLMResponse( 453 content=message.content, 454 tool_calls=tool_calls, 455 )
Implementación del método chat() usando la API de OpenRouter. Args: messages: Lista de mensajes en formato Anthropic (role/content). tools: Lista de definiciones de tools en formato Anthropic. system: Prompt de sistema para definir identidad/reglas. max_tokens: Límite de tokens para la respuesta. Returns: LLMResponse con el texto final y las tool calls (si las hay).
464class ContextBuilder: 465 """ 466 Construye el contexto completo para cada llamada al LLM. 467 En nanobot real incluye: identidad, memoria, skills, metadatos. 468 En este demo simplificamos a un prompt de sistema fijo + historial de mensajes. 469 El método build_messages() combina el historial de la sesión con el mensaje actual. 470 471 """ 472 473 def build_system_prompt(self) -> str: 474 """Construye el prompt de sistema que define la identidad y reglas del agente. 475 Se podria implementar la lectura de un archivo o plantilla, pero para este 476 demo lo dejamos hardcodeado para enfocarnos en la arquitectura. 477 """ 478 479 AGENT_IDENTITY = """Agents Intructions: 480 Eres femtobot🐈✨ un asistende de AI personal. 481 """ 482 483 AGENT_SOUL = """Soul: 484 Personalidad: amigable, ingenioso, directo. 485 Habilidades: usar herramientas, responder preguntas, mantener conversaciones. 486 Objetivo: ayudar al usuario de la mejor manera posible. 487 Valores: Exactitud sobre velocidad, transparencia del usuario, seguridad y ética. 488 Estilo de comunicación: claro, conciso, sin rodeos, siempre en español. 489 """ 490 491 AGENT_TOOLS = """Tools Instructions: 492 uedes usar las siguientes herramientas cuando lo consideres necesario para responder al usuario: 493 - get_datetime: Obtiene la fecha y hora actual del sistema. 494 """ 495 return ( 496 AGENT_IDENTITY, 497 AGENT_SOUL, 498 AGENT_TOOLS, 499 f"Fecha actual: {datetime.date.today()}", 500 ) 501 502 def build_messages(self, history: list[dict], current_message: str) -> list[dict]: 503 """ 504 Construye la lista de mensajes para el LLM: 505 [historial previo] + [mensaje actual del usuario] 506 El historial ya está en formato Anthropic (role/content). 507 508 Args: 509 history: Lista de mensajes previos en formato Anthropic. 510 current_message: Texto del mensaje actual del usuario. 511 Returns: 512 Lista de mensajes combinada para enviar al LLM. 513 """ 514 messages = list(history) # Copia del historial 515 messages.append({"role": "user", "content": current_message}) 516 return messages 517 518 def add_assistant_with_tool_calls( 519 self, messages: list[dict], content: str | None, tool_calls: list[dict] 520 ) -> list[dict]: 521 """Agrega la respuesta del asistente (con tool calls) al hilo. 522 En formato OpenAI/OpenRouter los tool_calls van en el mismo mensaje. 523 524 Args: 525 messages: Lista de mensajes actual. 526 content: Texto de la respuesta del asistente (puede ser None si solo hay tool calls). 527 tool_calls: Lista de tool calls que el LLM quiere ejecutar. 528 Returns: 529 Lista de mensajes actualizada con la respuesta del asistente y las tool calls. 530 """ 531 openai_tool_calls = [ 532 { 533 "id": tc["id"], 534 "type": "function", 535 "function": { 536 "name": tc["name"], 537 "arguments": __import__("json").dumps(tc["input"]), 538 }, 539 } 540 for tc in tool_calls 541 ] 542 messages.append( 543 { 544 "role": "assistant", 545 "content": content or "", 546 "tool_calls": openai_tool_calls, 547 } 548 ) 549 return messages 550 551 def add_tool_results( 552 self, messages: list[dict], tool_calls: list[dict], results: list[str] 553 ) -> list[dict]: 554 """Agrega los resultados de las tools al hilo. 555 En OpenAI cada resultado va como un mensaje separado con role 'tool'. 556 557 Args: 558 messages: Lista de mensajes actual. 559 tool_calls: Lista de tool calls que se ejecutaron. 560 results: Lista de resultados correspondientes a cada tool call. 561 Returns: 562 Lista de mensajes actualizada con los resultados de las tools. 563 """ 564 for tc, result in zip(tool_calls, results): 565 messages.append( 566 { 567 "role": "tool", 568 "tool_call_id": tc["id"], 569 "content": result, 570 } 571 ) 572 return messages
Construye el contexto completo para cada llamada al LLM. En nanobot real incluye: identidad, memoria, skills, metadatos. En este demo simplificamos a un prompt de sistema fijo + historial de mensajes. El método build_messages() combina el historial de la sesión con el mensaje actual.
473 def build_system_prompt(self) -> str: 474 """Construye el prompt de sistema que define la identidad y reglas del agente. 475 Se podria implementar la lectura de un archivo o plantilla, pero para este 476 demo lo dejamos hardcodeado para enfocarnos en la arquitectura. 477 """ 478 479 AGENT_IDENTITY = """Agents Intructions: 480 Eres femtobot🐈✨ un asistende de AI personal. 481 """ 482 483 AGENT_SOUL = """Soul: 484 Personalidad: amigable, ingenioso, directo. 485 Habilidades: usar herramientas, responder preguntas, mantener conversaciones. 486 Objetivo: ayudar al usuario de la mejor manera posible. 487 Valores: Exactitud sobre velocidad, transparencia del usuario, seguridad y ética. 488 Estilo de comunicación: claro, conciso, sin rodeos, siempre en español. 489 """ 490 491 AGENT_TOOLS = """Tools Instructions: 492 uedes usar las siguientes herramientas cuando lo consideres necesario para responder al usuario: 493 - get_datetime: Obtiene la fecha y hora actual del sistema. 494 """ 495 return ( 496 AGENT_IDENTITY, 497 AGENT_SOUL, 498 AGENT_TOOLS, 499 f"Fecha actual: {datetime.date.today()}", 500 )
Construye el prompt de sistema que define la identidad y reglas del agente. Se podria implementar la lectura de un archivo o plantilla, pero para este demo lo dejamos hardcodeado para enfocarnos en la arquitectura.
502 def build_messages(self, history: list[dict], current_message: str) -> list[dict]: 503 """ 504 Construye la lista de mensajes para el LLM: 505 [historial previo] + [mensaje actual del usuario] 506 El historial ya está en formato Anthropic (role/content). 507 508 Args: 509 history: Lista de mensajes previos en formato Anthropic. 510 current_message: Texto del mensaje actual del usuario. 511 Returns: 512 Lista de mensajes combinada para enviar al LLM. 513 """ 514 messages = list(history) # Copia del historial 515 messages.append({"role": "user", "content": current_message}) 516 return messages
Construye la lista de mensajes para el LLM: [historial previo] + [mensaje actual del usuario] El historial ya está en formato Anthropic (role/content).
Args: history: Lista de mensajes previos en formato Anthropic. current_message: Texto del mensaje actual del usuario. Returns: Lista de mensajes combinada para enviar al LLM.
518 def add_assistant_with_tool_calls( 519 self, messages: list[dict], content: str | None, tool_calls: list[dict] 520 ) -> list[dict]: 521 """Agrega la respuesta del asistente (con tool calls) al hilo. 522 En formato OpenAI/OpenRouter los tool_calls van en el mismo mensaje. 523 524 Args: 525 messages: Lista de mensajes actual. 526 content: Texto de la respuesta del asistente (puede ser None si solo hay tool calls). 527 tool_calls: Lista de tool calls que el LLM quiere ejecutar. 528 Returns: 529 Lista de mensajes actualizada con la respuesta del asistente y las tool calls. 530 """ 531 openai_tool_calls = [ 532 { 533 "id": tc["id"], 534 "type": "function", 535 "function": { 536 "name": tc["name"], 537 "arguments": __import__("json").dumps(tc["input"]), 538 }, 539 } 540 for tc in tool_calls 541 ] 542 messages.append( 543 { 544 "role": "assistant", 545 "content": content or "", 546 "tool_calls": openai_tool_calls, 547 } 548 ) 549 return messages
Agrega la respuesta del asistente (con tool calls) al hilo. En formato OpenAI/OpenRouter los tool_calls van en el mismo mensaje.
Args: messages: Lista de mensajes actual. content: Texto de la respuesta del asistente (puede ser None si solo hay tool calls). tool_calls: Lista de tool calls que el LLM quiere ejecutar. Returns: Lista de mensajes actualizada con la respuesta del asistente y las tool calls.
551 def add_tool_results( 552 self, messages: list[dict], tool_calls: list[dict], results: list[str] 553 ) -> list[dict]: 554 """Agrega los resultados de las tools al hilo. 555 En OpenAI cada resultado va como un mensaje separado con role 'tool'. 556 557 Args: 558 messages: Lista de mensajes actual. 559 tool_calls: Lista de tool calls que se ejecutaron. 560 results: Lista de resultados correspondientes a cada tool call. 561 Returns: 562 Lista de mensajes actualizada con los resultados de las tools. 563 """ 564 for tc, result in zip(tool_calls, results): 565 messages.append( 566 { 567 "role": "tool", 568 "tool_call_id": tc["id"], 569 "content": result, 570 } 571 ) 572 return messages
Agrega los resultados de las tools al hilo. En OpenAI cada resultado va como un mensaje separado con role 'tool'.
Args: messages: Lista de mensajes actual. tool_calls: Lista de tool calls que se ejecutaron. results: Lista de resultados correspondientes a cada tool call. Returns: Lista de mensajes actualizada con los resultados de las tools.
582class AgentLoop: 583 """ 584 Bucle principal del agente. 585 586 Flujo por cada mensaje: 587 1. Recibe InboundMessage del bus 588 2. Carga historial de la sesión 589 3. Construye contexto (system prompt + historial + mensaje) 590 4. Llama al LLM → si hay tool calls, las ejecuta y repite 591 5. Cuando el LLM responde con texto, guarda y publica respuesta 592 6. Maneja comandos especiales (ej: /stop para detener el agente) 593 7. Maneja errores internos y los reporta al usuario 594 8. Limita el número de iteraciones para evitar loops infinitos 595 9. Imprime logs detallados para seguimiento del proceso 596 10. Es concurrente: puede procesar múltiples mensajes a la vez sin bloquearse 597 """ 598 599 def __init__( 600 self, 601 bus: MessageBus, 602 provider: LLMProvider, 603 tools: ToolRegistry, 604 sessions: SessionManager, 605 context: ContextBuilder, 606 max_iterations: int = 10, # Nanobot real usa 40 607 ): 608 """Inicializa el AgentLoop con sus dependencias. 609 610 Args: 611 bus: MessageBus para recibir mensajes y publicar respuestas. 612 provider: LLMProvider para interactuar con el modelo de lenguaje. 613 tools: ToolRegistry con las tools disponibles para el agente. 614 sessions: SessionManager para manejar el historial de conversaciones. 615 context: ContextBuilder para construir el contexto de cada llamada al LLM. 616 max_iterations: Límite de iteraciones del bucle agente (LLM ↔ Tools) para evitar loops infinitos. 617 """ 618 self.bus = bus 619 self.provider = provider 620 self.tools = tools 621 self.sessions = sessions 622 self.context = context 623 self.max_iterations = max_iterations 624 self._running = False 625 626 async def run(self) -> None: 627 """Bucle principal que consume mensajes del bus y los procesa. 628 Maneja comandos especiales y errores internos. 629 """ 630 self._running = True 631 print("\n[AgentLoop] Iniciado. Esperando mensajes...\n") 632 633 while self._running: 634 try: 635 # Espera el próximo mensaje (timeout para poder salir) 636 msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) 637 except asyncio.TimeoutError: 638 continue 639 640 # Manejo de comandos especiales 641 if msg.content.strip().lower() == "/stop": 642 self._running = False 643 await self.bus.publish_outbound( 644 OutboundMessage("Deteniendo femtobot...", msg.channel, msg.chat_id) 645 ) 646 break 647 648 # Procesa el mensaje como tarea asíncrona 649 asyncio.create_task(self._dispatch(msg)) 650 651 async def _dispatch(self, msg: InboundMessage) -> None: 652 """Procesa un mensaje individual: llama al método principal de 653 procesamiento y maneja errores. 654 Args: 655 msg: InboundMessage a procesar. 656 """ 657 try: 658 response = await self._process_message(msg) 659 await self.bus.publish_outbound(response) 660 except Exception as e: 661 error_msg = f"Error interno: {e}" 662 print(f" [AgentLoop] {error_msg}") 663 await self.bus.publish_outbound( 664 OutboundMessage(error_msg, msg.channel, msg.chat_id) 665 ) 666 667 async def _process_message(self, msg: InboundMessage) -> OutboundMessage: 668 """Procesa el mensaje: carga sesión, construye contexto, ejecuta bucle agente, 669 guarda sesión y devuelve respuesta: 670 sesión → contexto → loop LLM+tools → respuesta 671 672 Args: 673 msg: InboundMessage con el contenido y metadatos del mensaje a procesar. 674 Returns: 675 OutboundMessage con la respuesta final para el usuario. 676 """ 677 print(f"\n[AgentLoop] Procesando: '{msg.content}'") 678 679 # Carga o crea la sesión para este chat 680 session = self.sessions.get_or_create(msg.session_key) 681 682 # Construye los mensajes para el LLM 683 messages = self.context.build_messages( 684 history=session.get_history(), 685 current_message=msg.content, 686 ) 687 688 # Ejecuta el bucle agente (LLM ↔ Tools) 689 final_content = await self._run_agent_loop(messages) 690 691 # Guarda la conversación en la sesión 692 session.add_user(msg.content) 693 session.add_assistant(final_content) 694 self.sessions.save(session) 695 696 return OutboundMessage(final_content, msg.channel, msg.chat_id) 697 698 async def _run_agent_loop(self, messages: list[dict]) -> str: 699 """ 700 Bucle interno: LLM → tool calls → resultados → LLM → ... 701 Termina cuando el LLM da una respuesta de texto sin tool calls. 702 703 Args: 704 messages: Lista de mensajes para enviar al LLM (incluye historial + mensaje actual). 705 Returns: 706 str: Respuesta final del LLM para el usuario. 707 """ 708 system_prompt = self.context.build_system_prompt() 709 tool_definitions = self.tools.get_definitions() 710 711 for iteration in range(1, self.max_iterations + 1): 712 print(f" [Loop] Iteración {iteration}/{self.max_iterations}") 713 714 # Llama al LLM 715 response = await self.provider.chat( 716 messages=messages, 717 tools=tool_definitions, 718 system=system_prompt, 719 ) 720 721 if response.has_tool_calls: 722 # El LLM quiere usar tools → ejecutarlas y continuar 723 print(f" [Loop] LLM solicita {len(response.tool_calls)} tool(s)") 724 725 # Agrega la respuesta del asistente al hilo 726 messages = self.context.add_assistant_with_tool_calls( 727 messages, response.content, response.tool_calls 728 ) 729 730 # Ejecuta cada tool call 731 results = [] 732 for tc in response.tool_calls: 733 result = await self.tools.execute(tc["name"], tc["input"]) 734 results.append(result) 735 print(f" [Loop] Resultado de '{tc['name']}': {result}") 736 737 # Agrega resultados al hilo de mensajes 738 messages = self.context.add_tool_results( 739 messages, response.tool_calls, results 740 ) 741 # Continúa el loop → el LLM verá los resultados 742 743 else: 744 # El LLM respondió con texto → fin del loop 745 print(f" [Loop] LLM responde con texto. Fin del loop.") 746 return response.content or "(sin respuesta)" 747 748 return "Alcancé el límite de iteraciones sin respuesta final."
Bucle principal del agente.
Flujo por cada mensaje:
- Recibe InboundMessage del bus
- Carga historial de la sesión
- Construye contexto (system prompt + historial + mensaje)
- Llama al LLM → si hay tool calls, las ejecuta y repite
- Cuando el LLM responde con texto, guarda y publica respuesta
- Maneja comandos especiales (ej: /stop para detener el agente)
- Maneja errores internos y los reporta al usuario
- Limita el número de iteraciones para evitar loops infinitos
- Imprime logs detallados para seguimiento del proceso
- Es concurrente: puede procesar múltiples mensajes a la vez sin bloquearse
599 def __init__( 600 self, 601 bus: MessageBus, 602 provider: LLMProvider, 603 tools: ToolRegistry, 604 sessions: SessionManager, 605 context: ContextBuilder, 606 max_iterations: int = 10, # Nanobot real usa 40 607 ): 608 """Inicializa el AgentLoop con sus dependencias. 609 610 Args: 611 bus: MessageBus para recibir mensajes y publicar respuestas. 612 provider: LLMProvider para interactuar con el modelo de lenguaje. 613 tools: ToolRegistry con las tools disponibles para el agente. 614 sessions: SessionManager para manejar el historial de conversaciones. 615 context: ContextBuilder para construir el contexto de cada llamada al LLM. 616 max_iterations: Límite de iteraciones del bucle agente (LLM ↔ Tools) para evitar loops infinitos. 617 """ 618 self.bus = bus 619 self.provider = provider 620 self.tools = tools 621 self.sessions = sessions 622 self.context = context 623 self.max_iterations = max_iterations 624 self._running = False
Inicializa el AgentLoop con sus dependencias.
Args: bus: MessageBus para recibir mensajes y publicar respuestas. provider: LLMProvider para interactuar con el modelo de lenguaje. tools: ToolRegistry con las tools disponibles para el agente. sessions: SessionManager para manejar el historial de conversaciones. context: ContextBuilder para construir el contexto de cada llamada al LLM. max_iterations: Límite de iteraciones del bucle agente (LLM ↔ Tools) para evitar loops infinitos.
626 async def run(self) -> None: 627 """Bucle principal que consume mensajes del bus y los procesa. 628 Maneja comandos especiales y errores internos. 629 """ 630 self._running = True 631 print("\n[AgentLoop] Iniciado. Esperando mensajes...\n") 632 633 while self._running: 634 try: 635 # Espera el próximo mensaje (timeout para poder salir) 636 msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) 637 except asyncio.TimeoutError: 638 continue 639 640 # Manejo de comandos especiales 641 if msg.content.strip().lower() == "/stop": 642 self._running = False 643 await self.bus.publish_outbound( 644 OutboundMessage("Deteniendo femtobot...", msg.channel, msg.chat_id) 645 ) 646 break 647 648 # Procesa el mensaje como tarea asíncrona 649 asyncio.create_task(self._dispatch(msg))
Bucle principal que consume mensajes del bus y los procesa. Maneja comandos especiales y errores internos.
758class Channel(ABC): 759 """Interfaz base para canales de comunicación (channels/base.py). 760 Define el método run() que cada canal implementará para manejar la entrada/salida. 761 El AgentLoop es agnóstico al canal: solo interactúa con el MessageBus. 762 """ 763 764 @abstractmethod 765 async def run(self) -> None: 766 """Bucle principal del canal para manejar entrada/salida.""" 767 ...
Interfaz base para canales de comunicación (channels/base.py). Define el método run() que cada canal implementará para manejar la entrada/salida. El AgentLoop es agnóstico al canal: solo interactúa con el MessageBus.
770class CLIChannel(Channel): 771 """Canal de línea de comandos (CLI). 772 Lee input del usuario desde stdin y publica mensajes en el bus. 773 Escucha respuestas del bus y las imprime en stdout. 774 Es un canal síncrono adaptado a asyncio usando run_in_executor para no bloquear el bucle del agente. 775 776 """ 777 778 def __init__(self, bus: MessageBus): 779 self.bus = bus 780 781 async def run(self) -> None: 782 """Bucle de entrada/salida del canal CLI.""" 783 print("╔══════════════════════════════════════╗") 784 print("║ 🐈 femtobot — Demo Arquitectura ║") 785 print("║ Escribe /stop para salir ║") 786 print("╚══════════════════════════════════════╝\n") 787 788 while True: 789 # Lee input del usuario (asyncio-friendly) 790 loop = asyncio.get_event_loop() 791 user_input = await loop.run_in_executor(None, input, "Tú: ") 792 793 if not user_input.strip(): 794 continue 795 796 # Publica el mensaje en el bus 797 await self.bus.publish_inbound( 798 InboundMessage( 799 content=user_input, 800 channel="cli", 801 chat_id="default", 802 session_key="cli:default", 803 ) 804 ) 805 806 # Espera y muestra la respuesta 807 response = await self.bus.consume_outbound() 808 print(f"\n🤖 femtobot: {response.content}\n") 809 810 if user_input.strip().lower() == "/stop": 811 break
Canal de línea de comandos (CLI). Lee input del usuario desde stdin y publica mensajes en el bus. Escucha respuestas del bus y las imprime en stdout. Es un canal síncrono adaptado a asyncio usando run_in_executor para no bloquear el bucle del agente.
781 async def run(self) -> None: 782 """Bucle de entrada/salida del canal CLI.""" 783 print("╔══════════════════════════════════════╗") 784 print("║ 🐈 femtobot — Demo Arquitectura ║") 785 print("║ Escribe /stop para salir ║") 786 print("╚══════════════════════════════════════╝\n") 787 788 while True: 789 # Lee input del usuario (asyncio-friendly) 790 loop = asyncio.get_event_loop() 791 user_input = await loop.run_in_executor(None, input, "Tú: ") 792 793 if not user_input.strip(): 794 continue 795 796 # Publica el mensaje en el bus 797 await self.bus.publish_inbound( 798 InboundMessage( 799 content=user_input, 800 channel="cli", 801 chat_id="default", 802 session_key="cli:default", 803 ) 804 ) 805 806 # Espera y muestra la respuesta 807 response = await self.bus.consume_outbound() 808 print(f"\n🤖 femtobot: {response.content}\n") 809 810 if user_input.strip().lower() == "/stop": 811 break
Bucle de entrada/salida del canal CLI.
820async def main(): 821 """ 822 Punto de entrada: ensambla y arranca femtobot. 823 824 Diagrama de componentes: 825 826 [CLIChannel] ──publish──► [MessageBus] ──consume──► [AgentLoop] 827 │ 828 ┌───────────────┤ 829 ▼ ▼ 830 [ToolRegistry] [SessionManager] 831 │ 832 [DateTime] 833 El CLIChannel lee input del usuario y lo publica en el MessageBus. 834 El AgentLoop consume mensajes del MessageBus, construye el contexto, 835 llama al LLM y ejecuta tools según sea necesario. 836 Luego publica la respuesta de vuelta en el MessageBus para que el CLIChannel 837 la muestre al usuario. 838 """ 839 api_key = os.environ.get("OPENROUTER_API_KEY") 840 if not api_key: 841 print("❌ Error: define la variable OPENROUTER_API_KEY") 842 print(" export OPENROUTER_API_KEY='sk-or-v1-...'") 843 print(" Obtén tu key en: https://openrouter.ai/keys") 844 return 845 846 print("\n[Bootstrap] Iniciando femtobot...\n") 847 848 # 1. Crea el bus central 849 bus = MessageBus() 850 print("[Bootstrap] ✓ MessageBus creado") 851 852 # 2. Registra las tools 853 tools = ToolRegistry() 854 tools.register(DateTimeTool()) 855 print("[Bootstrap] ✓ Tools registradas") 856 857 # 3. Crea los demás componentes 858 # Puedes cambiar el modelo a cualquiera disponible en OpenRouter: 859 # "anthropic/claude-sonnet-4-5" 860 # "openai/gpt-4o" 861 # "google/gemini-2.0-flash-001" 862 # "meta-llama/llama-3.3-70b-instruct" 863 provider = OpenRouterProvider( 864 api_key=api_key, 865 model="stepfun/step-3.5-flash:free", 866 ) 867 sessions = SessionManager() 868 context = ContextBuilder() 869 print(f"[Bootstrap] ✓ Provider OpenRouter listo (modelo: {provider.model})") 870 871 # 4. Crea el agente 872 agent = AgentLoop( 873 bus=bus, 874 provider=provider, 875 tools=tools, 876 sessions=sessions, 877 context=context, 878 ) 879 print("[Bootstrap] ✓ AgentLoop listo") 880 881 # 5. Crea el canal CLI 882 channel = CLIChannel(bus=bus) 883 print("[Bootstrap] ✓ CLIChannel listo") 884 885 # 6. Arranca ambos concurrentemente 886 print("[Bootstrap] Arrancando sistema...\n") 887 await asyncio.gather( 888 agent.run(), 889 channel.run(), 890 )
Punto de entrada: ensambla y arranca femtobot.
Diagrama de componentes:
[CLIChannel] ──publish──► [MessageBus] ──consume──► [AgentLoop] │ ┌───────────────┤ ▼ ▼ [ToolRegistry] [SessionManager] │ [DateTime] El CLIChannel lee input del usuario y lo publica en el MessageBus. El AgentLoop consume mensajes del MessageBus, construye el contexto, llama al LLM y ejecuta tools según sea necesario. Luego publica la respuesta de vuelta en el MessageBus para que el CLIChannel la muestre al usuario.