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())
@dataclass
class InboundMessage:
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).

InboundMessage( content: str, channel: str = 'cli', chat_id: str = 'default', session_key: str = 'cli:default')
content: str
channel: str = 'cli'
chat_id: str = 'default'
session_key: str = 'cli:default'
@dataclass
class OutboundMessage:
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).

OutboundMessage(content: str, channel: str = 'cli', chat_id: str = 'default')
content: str
channel: str = 'cli'
chat_id: str = 'default'
class MessageBus:
 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.
async def publish_inbound(self, msg: InboundMessage) -> None:
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.

async def consume_inbound(self) -> InboundMessage:
 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.

async def publish_outbound(self, msg: OutboundMessage) -> None:
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.

async def consume_outbound(self) -> OutboundMessage:
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.

class Tool(abc.ABC):
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).

name: str
129    @property
130    @abstractmethod
131    def name(self) -> str:
132        """Nombre único de la tool."""
133        ...

Nombre único de la tool.

description: str
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.

parameters: dict
141    @property
142    @abstractmethod
143    def parameters(self) -> dict:
144        """Esquema JSON de los parámetros."""
145        ...

Esquema JSON de los parámetros.

@abstractmethod
async def execute(self, **kwargs) -> str:
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.

def to_anthropic_format(self) -> dict:
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.

class DateTimeTool(Tool):
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.

name: str
171    @property
172    def name(self) -> str:
173        return "get_datetime"

Nombre único de la tool.

description: str
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.

parameters: dict
179    @property
180    def parameters(self) -> dict:
181        return {
182            "type": "object",
183            "properties": {},
184            "required": [],
185        }

Esquema JSON de los parámetros.

async def execute(self) -> str:
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
Tool
to_anthropic_format
class ToolRegistry:
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.

def register(self, tool: Tool) -> None:
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.

def get_definitions(self) -> list[dict]:
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.

async def execute(self, name: str, arguments: dict) -> str:
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.

class Session:
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.

Session(key: str)
238    def __init__(self, key: str):
239        self.key = key
240        self.messages: list[dict] = []  # Historial en formato Anthropic
key
messages: list[dict]
def add_user(self, content: str) -> None:
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.

def add_assistant(self, content: str) -> None:
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.

def get_history(self, max_messages: int = 20) -> list[dict]:
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).

class SessionManager:
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.

def get_or_create(self, key: str) -> Session:
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.

def save(self, session: Session) -> None:
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.

@dataclass
class LLMResponse:
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.

LLMResponse(content: str | None, tool_calls: list[dict])
content: str | None
tool_calls: list[dict]
has_tool_calls: bool
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

Indica si la respuesta incluye llamadas a tools.

class LLMProvider(abc.ABC):
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.

@abstractmethod
async def chat( self, messages: list[dict], tools: list[dict], system: str = '', max_tokens: int = 1024) -> LLMResponse:
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).

class OpenRouterProvider(LLMProvider):
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.

OpenRouterProvider(api_key: str, model: str = 'stepfun/step-3.5-flash:free')
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").

client
model
async def chat( self, messages: list[dict], tools: list[dict], system: str = '', max_tokens: int = 1024) -> LLMResponse:
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).

class ContextBuilder:
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.

def build_system_prompt(self) -> str:
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.

def build_messages(self, history: list[dict], current_message: str) -> list[dict]:
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.

def add_assistant_with_tool_calls( self, messages: list[dict], content: str | None, tool_calls: list[dict]) -> list[dict]:
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.

def add_tool_results( self, messages: list[dict], tool_calls: list[dict], results: list[str]) -> list[dict]:
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.

class AgentLoop:
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:

  1. Recibe InboundMessage del bus
  2. Carga historial de la sesión
  3. Construye contexto (system prompt + historial + mensaje)
  4. Llama al LLM → si hay tool calls, las ejecuta y repite
  5. Cuando el LLM responde con texto, guarda y publica respuesta
  6. Maneja comandos especiales (ej: /stop para detener el agente)
  7. Maneja errores internos y los reporta al usuario
  8. Limita el número de iteraciones para evitar loops infinitos
  9. Imprime logs detallados para seguimiento del proceso
  10. Es concurrente: puede procesar múltiples mensajes a la vez sin bloquearse
AgentLoop( bus: MessageBus, provider: LLMProvider, tools: ToolRegistry, sessions: SessionManager, context: ContextBuilder, max_iterations: int = 10)
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.

bus
provider
tools
sessions
context
max_iterations
async def run(self) -> None:
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.

class Channel(abc.ABC):
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.

@abstractmethod
async def run(self) -> None:
764    @abstractmethod
765    async def run(self) -> None:
766        """Bucle principal del canal para manejar entrada/salida."""
767        ...

Bucle principal del canal para manejar entrada/salida.

class CLIChannel(Channel):
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.

CLIChannel(bus: MessageBus)
778    def __init__(self, bus: MessageBus):
779        self.bus = bus
bus
async def run(self) -> None:
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.

async def main():
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.