Files
ajarbot/adapters/runtime.py

286 lines
9.4 KiB
Python
Raw Normal View History

"""
Adapter runtime system for ajarbot.
Connects messaging platform adapters to the Agent instance.
"""
import asyncio
import re
import traceback
from typing import Any, Callable, Dict, List, Optional
from adapters.base import (
AdapterRegistry,
BaseAdapter,
InboundMessage,
OutboundMessage,
)
from agent import Agent
class AdapterRuntime:
"""
Runtime system that connects adapters to the Agent.
Acts as the bridge between messaging platforms (Slack, Telegram, etc.)
and the Agent (memory + LLM).
"""
def __init__(
self,
agent: Agent,
registry: Optional[AdapterRegistry] = None,
) -> None:
self.agent = agent
self.registry = registry or AdapterRegistry()
self.message_loop_task: Optional[asyncio.Task] = None
self._message_queue: asyncio.Queue = asyncio.Queue()
self._is_running = False
# User ID mapping: platform_user_id -> username
self._user_mapping: Dict[str, str] = {}
self._preprocessors: List[
Callable[[InboundMessage], InboundMessage]
] = []
self._postprocessors: List[
Callable[[str, InboundMessage], str]
] = []
def add_adapter(self, adapter: BaseAdapter) -> None:
"""Add and configure an adapter."""
self.registry.register(adapter)
adapter.register_message_handler(self._on_message_received)
def map_user(self, platform_user_id: str, username: str) -> None:
"""Map a platform user ID to an ajarbot username."""
self._user_mapping[platform_user_id] = username
def get_username(
self, platform: str, platform_user_id: str
) -> str:
"""
Get ajarbot username for a platform user.
Falls back to platform_user_id format if no mapping exists.
"""
key = f"{platform}:{platform_user_id}"
# Use underscore for fallback to match validation rules
fallback = f"{platform}_{platform_user_id}"
return self._user_mapping.get(key, fallback)
def add_preprocessor(
self,
preprocessor: Callable[[InboundMessage], InboundMessage],
) -> None:
"""Add a message preprocessor (e.g., for commands, filters)."""
self._preprocessors.append(preprocessor)
def add_postprocessor(
self,
postprocessor: Callable[[str, InboundMessage], str],
) -> None:
"""Add a response postprocessor (e.g., for formatting)."""
self._postprocessors.append(postprocessor)
def _on_message_received(self, message: InboundMessage) -> None:
"""Handle incoming message from an adapter.
This may be called from different event loop contexts (e.g.,
python-telegram-bot's internal loop vs. our main asyncio loop),
so we use loop-safe scheduling instead of create_task().
"""
try:
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(self._message_queue.put_nowait, message)
except RuntimeError:
# No running loop - should not happen in normal operation
# but handle gracefully
print("[Runtime] Warning: No event loop for message dispatch")
self._message_queue.put_nowait(message)
async def _process_message_queue(self) -> None:
"""Background task to process incoming messages."""
print("[Runtime] Message processing loop started")
while self._is_running:
try:
message = await asyncio.wait_for(
self._message_queue.get(), timeout=1.0
)
await self._process_message(message)
except asyncio.TimeoutError:
continue
except Exception as e:
print(f"[Runtime] Error processing message: {e}")
traceback.print_exc()
print("[Runtime] Message processing loop stopped")
async def _process_message(self, message: InboundMessage) -> None:
"""Process a single message."""
preview = message.text[:50]
print(
f"[{message.platform.upper()}] "
f"Message from {message.username}: {preview}..."
)
try:
# Apply preprocessors
processed_message = message
for preprocessor in self._preprocessors:
processed_message = preprocessor(processed_message)
username = self.get_username(
message.platform, message.user_id
)
adapter = self.registry.get(message.platform)
if adapter:
await adapter.send_typing_indicator(message.channel_id)
# Get response from agent (synchronous call in thread)
response = await asyncio.to_thread(
self.agent.chat,
user_message=processed_message.text,
username=username,
)
# Apply postprocessors
for postprocessor in self._postprocessors:
response = postprocessor(response, processed_message)
# Send response back
if adapter:
reply_to = (
message.metadata.get("ts")
or message.metadata.get("message_id")
)
outbound = OutboundMessage(
platform=message.platform,
channel_id=message.channel_id,
text=response,
thread_id=message.thread_id,
reply_to_id=reply_to,
)
result = await adapter.send_message(outbound)
platform_tag = message.platform.upper()
if result.get("success"):
print(
f"[{platform_tag}] Response sent "
f"({len(response)} chars)"
)
else:
print(
f"[{platform_tag}] Failed to send response: "
f"{result.get('error')}"
)
except Exception as e:
print(f"[Runtime] Error processing message: {e}")
traceback.print_exc()
if hasattr(self.agent, 'healing_system'):
self.agent.healing_system.capture_error(
error=e,
component="adapters/runtime.py:_process_message",
intent=f"Processing message from {message.platform}",
context={
"platform": message.platform,
"user": message.username,
"message_preview": message.text[:100],
},
)
await self._send_error_reply(message)
async def _send_error_reply(self, message: InboundMessage) -> None:
"""Attempt to send an error message back to the user."""
try:
adapter = self.registry.get(message.platform)
if adapter:
error_msg = OutboundMessage(
platform=message.platform,
channel_id=message.channel_id,
text=(
"Sorry, I encountered an error processing "
"your message. Please try again."
),
thread_id=message.thread_id,
)
await adapter.send_message(error_msg)
except Exception:
pass
async def start(self) -> None:
"""Start the runtime and all adapters."""
print("[Runtime] Starting adapter runtime...")
await self.registry.start_all()
self._is_running = True
self.message_loop_task = asyncio.create_task(
self._process_message_queue()
)
print("[Runtime] Runtime started")
async def stop(self) -> None:
"""Stop the runtime and all adapters."""
print("[Runtime] Stopping adapter runtime...")
self._is_running = False
if self.message_loop_task:
await self.message_loop_task
await self.registry.stop_all()
self.agent.shutdown()
print("[Runtime] Runtime stopped")
async def health_check(self) -> Dict[str, Any]:
"""Get health status of all adapters."""
status: Dict[str, Any] = {
"runtime_running": self._is_running,
"adapters": {},
}
for adapter in self.registry.get_all():
adapter_health = await adapter.health_check()
status["adapters"][adapter.platform_name] = adapter_health
return status
# --- Example Preprocessors and Postprocessors ---
def command_preprocessor(message: InboundMessage) -> InboundMessage:
"""Example: Handle bot commands."""
if not message.text.startswith("/"):
return message
parts = message.text.split(maxsplit=1)
command = parts[0]
if command == "/status":
message.text = "What is your current status?"
elif command == "/help":
message.text = (
"Please provide help information about what you can do."
)
return message
def markdown_postprocessor(
response: str, original_message: InboundMessage
) -> str:
"""Example: Ensure markdown compatibility for Slack."""
if original_message.platform != "slack":
return response
# Convert standard markdown bold to Slack mrkdwn
response = response.replace("**", "*")
# Slack doesn't support ## headers
response = re.sub(r"^#+\s+", "", response, flags=re.MULTILINE)
return response