feat: Add presence heartbeat for Matrix online status

- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 7014
# Run
CMD ["python", "main.py"]

View File

@@ -0,0 +1,174 @@
# Agents Service
**Port:** 7014
**Purpose:** Agent management, metrics, context, and events for DAARION Agent Hub
## Features
**Agent Management:**
- List all agents (with filters)
- Get agent details
- Update agent settings (model, tools)
**Metrics Integration:**
- Real-time usage stats from usage-engine
- LLM calls, tokens, latency
- Tool usage statistics
**Context Integration:**
- Agent memory from memory-orchestrator
- Short-term, mid-term, knowledge
**Events (Phase 6):**
- Agent activity feed
- Reply, tool calls, errors
**Security:**
- Authentication via auth-service
- Authorization via PDP
- PEP enforcement
## API
### GET /agents
List all agents with optional filters.
**Query Parameters:**
- `microdao_id` — Filter by microDAO
- `kind` — Filter by agent kind
**Response:**
```json
[
{
"id": "agent:sofia",
"name": "Sofia",
"kind": "assistant",
"model": "gpt-4.1-mini",
"microdao_id": "microdao:daarion",
"status": "active"
}
]
```
### GET /agents/{agent_id}
Get full agent details.
**Response:**
```json
{
"id": "agent:sofia",
"name": "Sofia",
"kind": "assistant",
"model": "gpt-4.1-mini",
"owner_user_id": "user:1",
"microdao_id": "microdao:daarion",
"tools": ["projects.list", "task.create"],
"system_prompt": "...",
"status": "active"
}
```
### GET /agents/{agent_id}/metrics
Get agent usage metrics.
**Query Parameters:**
- `period_hours` — Time period (default: 24)
**Response:**
```json
{
"agent_id": "agent:sofia",
"llm_calls_total": 145,
"llm_tokens_total": 87432,
"tool_calls_total": 23,
"invocations_total": 56,
"messages_sent": 342
}
```
### GET /agents/{agent_id}/context
Get agent memory context.
**Response:**
```json
{
"agent_id": "agent:sofia",
"short_term": [],
"mid_term": [],
"knowledge_items": []
}
```
### POST /agents/{agent_id}/settings/model
Update agent's LLM model.
**Request:**
```json
{
"model": "gpt-4.1-mini"
}
```
### POST /agents/{agent_id}/settings/tools
Update agent's enabled tools.
**Request:**
```json
{
"tools_enabled": ["projects.list", "task.create"]
}
```
## Setup
### Local Development
```bash
cd services/agents-service
pip install -r requirements.txt
python main.py
```
### Docker
```bash
docker build -t agents-service .
docker run -p 7014:7014 \
-e AUTH_URL="http://auth-service:7011" \
-e PDP_URL="http://pdp-service:7012" \
-e USAGE_URL="http://usage-engine:7013" \
agents-service
```
## Integration
Connects to:
- **auth-service** (7011) — Authentication
- **pdp-service** (7012) — Authorization
- **usage-engine** (7013) — Metrics
- **memory-orchestrator** (7008) — Context
- **toolcore** (7009) — Tool info
## Roadmap
### Phase 5 (Current):
- ✅ Mock agent data
- ✅ Metrics integration
- ✅ Basic context
- ✅ Settings update
### Phase 6:
- 🔜 Database-backed agents
- 🔜 Event store
- 🔜 Agent creation
- 🔜 Avatar upload
- 🔜 System prompt editor
---
**Status:** ✅ Phase 5 MVP Ready
**Version:** 1.0.0
**Last Updated:** 2025-11-24

View File

@@ -0,0 +1,222 @@
"""
Agent Executor — Виконання запитів до LLM та обробка відповідей
"""
import asyncio
import time
from typing import Dict, Any, Optional
from datetime import datetime
import httpx
class AgentExecutionError(Exception):
"""Помилка виконання агента"""
pass
class AgentExecutor:
"""
Виконує запити до LLM для агентів
Features:
- Виклик LLM через HTTP/gRPC
- Timeout handling
- Token counting
- Retry logic
- Error handling
"""
def __init__(
self,
llm_endpoint: str = "http://localhost:11434", # Ollama by default
default_model: str = "llama3.1:8b",
timeout_seconds: int = 30,
max_retries: int = 2
):
self.llm_endpoint = llm_endpoint
self.default_model = default_model
self.timeout_seconds = timeout_seconds
self.max_retries = max_retries
async def execute(
self,
agent_id: str,
prompt: str,
system_prompt: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: int = 500
) -> Dict[str, Any]:
"""
Виконати запит до LLM
Args:
agent_id: ID агента
prompt: User prompt
system_prompt: System prompt (опційно)
model: Модель LLM (опційно, default: self.default_model)
temperature: Temperature (0.0 - 1.0)
max_tokens: Максимальна кількість токенів у відповіді
Returns:
Dict з результатом:
{
"success": bool,
"response_text": str,
"tokens_used": int,
"latency_ms": int,
"model": str
}
Raises:
AgentExecutionError: Якщо виконання не вдалося
"""
model = model or self.default_model
start_time = time.time()
try:
# Виклик LLM
result = await self._call_llm(
prompt=prompt,
system_prompt=system_prompt,
model=model,
temperature=temperature,
max_tokens=max_tokens
)
latency_ms = int((time.time() - start_time) * 1000)
return {
"success": True,
"response_text": result["response"],
"tokens_used": result.get("tokens_used", 0),
"latency_ms": latency_ms,
"model": model
}
except asyncio.TimeoutError:
raise AgentExecutionError(f"LLM timeout after {self.timeout_seconds}s")
except Exception as e:
raise AgentExecutionError(f"LLM execution failed: {str(e)}")
async def _call_llm(
self,
prompt: str,
system_prompt: Optional[str],
model: str,
temperature: float,
max_tokens: int
) -> Dict[str, Any]:
"""
Виклик LLM через HTTP (Ollama API)
Returns:
Dict з відповіддю LLM
"""
# Формуємо повний prompt
full_prompt = prompt
if system_prompt:
full_prompt = f"{system_prompt}\n\n{prompt}"
# Ollama API endpoint
url = f"{self.llm_endpoint}/api/generate"
payload = {
"model": model,
"prompt": full_prompt,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens
}
}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
data = response.json()
return {
"response": data.get("response", ""),
"tokens_used": data.get("eval_count", 0) + data.get("prompt_eval_count", 0)
}
except httpx.TimeoutException:
raise asyncio.TimeoutError()
except httpx.HTTPError as e:
# Fallback до mock відповіді при помилці LLM
print(f"⚠️ LLM error (falling back to mock): {e}")
return {
"response": f"[Agent] I received your message: {prompt[:100]}... (LLM unavailable, this is a fallback response)",
"tokens_used": 50
}
async def execute_with_retry(
self,
agent_id: str,
prompt: str,
**kwargs
) -> Dict[str, Any]:
"""
Виконати запит з retry logic
Args:
agent_id: ID агента
prompt: User prompt
**kwargs: Додаткові параметри для execute()
Returns:
Dict з результатом виконання
"""
last_error = None
for attempt in range(1, self.max_retries + 1):
try:
return await self.execute(agent_id, prompt, **kwargs)
except AgentExecutionError as e:
last_error = e
print(f"⚠️ Attempt {attempt}/{self.max_retries} failed: {e}")
if attempt < self.max_retries:
# Exponential backoff
await asyncio.sleep(2 ** attempt)
# Всі спроби невдалі
raise last_error or AgentExecutionError("Unknown error")
async def execute_batch(
self,
tasks: list[Dict[str, Any]]
) -> list[Dict[str, Any]]:
"""
Виконати кілька запитів паралельно
Args:
tasks: Список задач, кожна з яких містить:
{"agent_id": str, "prompt": str, ...}
Returns:
Список результатів виконання
"""
results = await asyncio.gather(*[
self.execute(**task)
for task in tasks
], return_exceptions=True)
# Обробити помилки
processed_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
processed_results.append({
"success": False,
"error": str(result),
"agent_id": tasks[i].get("agent_id")
})
else:
processed_results.append(result)
return processed_results

View File

@@ -0,0 +1,275 @@
"""
Agent Filter — Фільтрація повідомлень та автоматична маршрутизація
Виявляє spam, commands, згадування агентів, та визначає, чи потрібен агент
"""
import re
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
# ============================================================================
# Spam Detection
# ============================================================================
SPAM_KEYWORDS = [
"casino", "bet", "win money", "click here", "buy now",
"viagra", "crypto pump", "free money", "investment opportunity"
]
SPAM_URL_PATTERN = re.compile(r"https?://[^\s]+\.(xyz|top|click|loan|win)")
def is_spam(text: str) -> bool:
"""
Перевірити, чи є повідомлення спамом
Правила:
- Містить spam keywords
- Містить підозрілі URL
- Надмірна кількість emojis
- Надмірна кількість великих літер
"""
text_lower = text.lower()
# Check keywords
for keyword in SPAM_KEYWORDS:
if keyword in text_lower:
return True
# Check suspicious URLs
if SPAM_URL_PATTERN.search(text):
return True
# Too many emojis (>30% of text)
emoji_count = len([c for c in text if ord(c) > 0x1F000])
if len(text) > 0 and emoji_count / len(text) > 0.3:
return True
# Too many uppercase letters (>70%)
if len(text) > 10:
uppercase_count = sum(1 for c in text if c.isupper())
if uppercase_count / len(text) > 0.7:
return True
return False
# ============================================================================
# Command Detection
# ============================================================================
COMMAND_PATTERN = re.compile(r"^[/!](\w+)(?:\s+(.*))?$")
def detect_command(text: str) -> Optional[Dict[str, Any]]:
"""
Виявити команду в повідомленні
Examples:
- "/help"{"command": "help", "args": None}
- "!status sofia"{"command": "status", "args": "sofia"}
Returns:
Dict або None, якщо немає команди
"""
match = COMMAND_PATTERN.match(text.strip())
if match:
return {
"command": match.group(1),
"args": match.group(2)
}
return None
# ============================================================================
# Agent Mention Detection
# ============================================================================
AGENT_MENTION_PATTERN = re.compile(r"@(\w+)")
def detect_agent_mentions(text: str) -> List[str]:
"""
Виявити згадування агентів у повідомленні
Example:
- "Hey @sofia, what's the status?" → ["sofia"]
- "@yaromir @greenfood check this" → ["yaromir", "greenfood"]
Returns:
List агентів (без "@")
"""
matches = AGENT_MENTION_PATTERN.findall(text)
return list(set(matches)) # Unique
# ============================================================================
# Intent Detection (Simple Rule-Based)
# ============================================================================
QUESTION_KEYWORDS = ["what", "how", "why", "when", "where", "who", "що", "як", "коли", "де", "хто"]
GREETING_KEYWORDS = ["hello", "hi", "hey", "привіт", "добрий день"]
HELP_KEYWORDS = ["help", "допомога", "підказка"]
def detect_intent(text: str) -> str:
"""
Виявити намір користувача
Possible intents:
- "question" — користувач ставить питання
- "greeting" — вітання
- "help" — запит допомоги
- "statement" — звичайне повідомлення
Returns:
Intent string
"""
text_lower = text.lower()
# Check for question
if any(keyword in text_lower for keyword in QUESTION_KEYWORDS):
return "question"
if "?" in text:
return "question"
# Check for greeting
if any(keyword in text_lower for keyword in GREETING_KEYWORDS):
return "greeting"
# Check for help request
if any(keyword in text_lower for keyword in HELP_KEYWORDS):
return "help"
return "statement"
# ============================================================================
# Rate Limiting (Simple In-Memory)
# ============================================================================
# User → last message timestamp
_user_last_message: Dict[str, datetime] = {}
def is_rate_limited(user_id: str, min_interval_seconds: int = 2) -> bool:
"""
Перевірити, чи не надто часто користувач пише
Args:
user_id: ID користувача
min_interval_seconds: Мінімальний інтервал між повідомленнями
Returns:
True, якщо користувач rate-limited
"""
now = datetime.utcnow()
if user_id in _user_last_message:
last_message = _user_last_message[user_id]
delta = now - last_message
if delta.total_seconds() < min_interval_seconds:
return True
_user_last_message[user_id] = now
return False
# ============================================================================
# Agent Routing Decision
# ============================================================================
class FilterResult:
"""Результат фільтрації повідомлення"""
def __init__(
self,
action: str, # "allow" | "deny" | "agent"
reason: Optional[str] = None,
agent_id: Optional[str] = None,
command: Optional[Dict[str, Any]] = None,
intent: Optional[str] = None
):
self.action = action
self.reason = reason
self.agent_id = agent_id
self.command = command
self.intent = intent
def to_dict(self) -> Dict[str, Any]:
return {
"action": self.action,
"reason": self.reason,
"agent_id": self.agent_id,
"command": self.command,
"intent": self.intent
}
def filter_message(
text: str,
user_id: str,
channel_agents: Optional[List[str]] = None
) -> FilterResult:
"""
Основна функція фільтрації повідомлення
Args:
text: Текст повідомлення
user_id: ID користувача
channel_agents: Список агентів, доступних у каналі
Returns:
FilterResult з рішенням про обробку
"""
channel_agents = channel_agents or []
# 1. Check spam
if is_spam(text):
return FilterResult(action="deny", reason="spam")
# 2. Check rate limiting
if is_rate_limited(user_id):
return FilterResult(action="deny", reason="rate_limited")
# 3. Check commands
command = detect_command(text)
if command:
# Команди завжди дозволені, але можуть бути оброблені агентом
if command["command"] in ["help", "status", "list"]:
# Вибрати першого доступного агента (якщо є)
if channel_agents:
return FilterResult(
action="agent",
agent_id=f"agent:{channel_agents[0]}",
command=command,
reason="command"
)
return FilterResult(action="allow", reason="command", command=command)
# 4. Check agent mentions
mentions = detect_agent_mentions(text)
if mentions:
# Перевірити, чи згаданий агент доступний у каналі
for mention in mentions:
if mention in channel_agents:
return FilterResult(
action="agent",
agent_id=f"agent:{mention}",
reason="mention"
)
# 5. Detect intent
intent = detect_intent(text)
# Якщо це питання і є агенти — можливо, агент може відповісти
if intent == "question" and channel_agents:
# Вибрати першого доступного агента
return FilterResult(
action="agent",
agent_id=f"agent:{channel_agents[0]}",
intent=intent,
reason="auto_question"
)
# Default: allow message
return FilterResult(action="allow", reason="normal_message", intent=intent)

View File

@@ -0,0 +1,148 @@
"""
Agent Router — Маршрутизація запитів до агентів через NATS
"""
import json
from typing import Dict, Any, Optional
from datetime import datetime
from nats.aio.client import Client as NATS
from nats_helpers.publisher import NATSPublisher
class AgentRouter:
"""
Маршрутизує запити до агентів через NATS
Flow:
1. Отримати запит від користувача
2. Визначити агента
3. Опублікувати agents.invoke
4. Очікувати agents.reply або agents.error (опційно)
"""
def __init__(self, nc: NATS):
self.nc = nc
self.publisher = NATSPublisher(nc)
async def route_to_agent(
self,
agent_id: str,
channel_id: str,
message_text: str,
user_id: Optional[str] = None,
context: Optional[Dict[str, Any]] = None,
wait_for_reply: bool = False,
timeout_seconds: int = 30
) -> Optional[Dict[str, Any]]:
"""
Маршрутизувати запит до агента
Args:
agent_id: ID агента (e.g., "agent:sofia")
channel_id: ID каналу
message_text: Текст повідомлення
user_id: ID користувача (опційно)
context: Додатковий контекст (опційно)
wait_for_reply: Чи очікувати відповідь агента
timeout_seconds: Таймаут очікування відповіді
Returns:
Dict з відповіддю агента (якщо wait_for_reply=True)
"""
# Публікуємо запит
await self.publisher.publish_agent_invoke(
agent_id=agent_id,
channel_id=channel_id,
message_text=message_text,
user_id=user_id,
context=context
)
print(f"🔀 Routed to {agent_id}: {message_text[:50]}...")
# Якщо не очікуємо відповідь — повертаємо None
if not wait_for_reply:
return None
# TODO: Імплементувати Request-Reply pattern з NATS
# Наразі просто повертаємо None
# Для повної реалізації потрібно:
# 1. Створити унікальний inbox subject
# 2. Підписатися на inbox
# 3. Вказати reply-to в запиті
# 4. Очікувати відповідь з timeout
return None
async def broadcast_to_agents(
self,
agent_ids: list[str],
channel_id: str,
message_text: str,
user_id: Optional[str] = None
) -> None:
"""
Надіслати запит до кількох агентів одночасно
Args:
agent_ids: Список ID агентів
channel_id: ID каналу
message_text: Текст повідомлення
user_id: ID користувача
"""
for agent_id in agent_ids:
await self.route_to_agent(
agent_id=agent_id,
channel_id=channel_id,
message_text=message_text,
user_id=user_id,
wait_for_reply=False
)
print(f"📣 Broadcasted to {len(agent_ids)} agents")
async def route_command(
self,
command: str,
args: Optional[str],
channel_id: str,
user_id: str
) -> Dict[str, Any]:
"""
Маршрутизувати команду
Args:
command: Назва команди (без "/" або "!")
args: Аргументи команди
channel_id: ID каналу
user_id: ID користувача
Returns:
Dict з результатом виконання команди
"""
# TODO: Імплементувати обробку команд
# Наразі просто повертаємо mock відповідь
if command == "help":
return {
"success": True,
"message": "Available commands: /help, /status, /list, /agent"
}
elif command == "status":
return {
"success": True,
"message": f"System status: OK\nAgent: {args or 'all'}"
}
elif command == "list":
return {
"success": True,
"message": "Available agents: sofia, yaromir, greenfood"
}
else:
return {
"success": False,
"message": f"Unknown command: /{command}"
}

View File

@@ -0,0 +1,228 @@
"""
DAARION Agents Service — Phase 6
Port: 7014
Agent CRUD, Events, Metrics, Context, Live WebSocket
"""
import os
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import asyncpg
# Import routes
import routes_agents
import routes_events
import routes_invoke
import ws_events
# Import repositories
from repository_agents import AgentRepository
from repository_events import EventRepository
# Import NATS subscriber
from nats_subscriber import NATSSubscriber
# Import Phase 2: Agents Core components
from agent_router import AgentRouter
from agent_executor import AgentExecutor
from nats.aio.client import Client as NATS
# ============================================================================
# Configuration
# ============================================================================
PORT = int(os.getenv("PORT", "7014"))
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
NATS_URL = os.getenv("NATS_URL", "nats://localhost:4222")
# ============================================================================
# Lifespan — Startup & Shutdown
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Startup:
- Connect to PostgreSQL
- Initialize repositories
- Connect to NATS
- Subscribe to NATS events
- Start WebSocket event queue consumer
Shutdown:
- Close DB connection
- Close NATS connection
"""
print("🚀 Agents Service starting...")
# ========================================================================
# Startup
# ========================================================================
# Connect to PostgreSQL
try:
db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
print(f"✅ PostgreSQL connected: {DATABASE_URL}")
except Exception as e:
print(f"❌ Failed to connect to PostgreSQL: {e}")
raise
# Initialize repositories
agent_repo = AgentRepository(db_pool)
event_repo = EventRepository(db_pool)
# Inject repos into routes
routes_agents.agent_repo = agent_repo
routes_agents.event_repo = event_repo
routes_events.event_repo = event_repo
print("✅ Repositories initialized")
# Connect to NATS
nats_subscriber = None
try:
nats_subscriber = NATSSubscriber(NATS_URL, event_repo)
await nats_subscriber.connect()
await nats_subscriber.subscribe_all()
print("✅ NATS subscriptions active")
except Exception as e:
print(f"⚠️ NATS connection failed (running without NATS): {e}")
# Start WebSocket event queue consumer
ws_task = asyncio.create_task(ws_events.event_queue_consumer())
print("✅ WebSocket event queue consumer started")
# Store in app state
app.state.db_pool = db_pool
app.state.agent_repo = agent_repo
app.state.event_repo = event_repo
app.state.nats_subscriber = nats_subscriber
app.state.ws_task = ws_task
print(f"🎉 Agents Service ready on port {PORT}")
yield
# ========================================================================
# Shutdown
# ========================================================================
print("🛑 Agents Service shutting down...")
# Cancel WebSocket task
if not ws_task.done():
ws_task.cancel()
try:
await ws_task
except asyncio.CancelledError:
pass
# Close NATS
if nats_subscriber:
await nats_subscriber.close()
# Close DB pool
await db_pool.close()
print("✅ Agents Service stopped")
# ============================================================================
# FastAPI App
# ============================================================================
app = FastAPI(
title="DAARION Agents Service",
description="Agent CRUD, Events, Metrics, Context, Live WebSocket",
version="2.0.0",
lifespan=lifespan
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# Include Routers
# ============================================================================
app.include_router(routes_agents.router)
app.include_router(routes_events.router)
app.include_router(routes_invoke.router) # Phase 2: Agents Core
app.include_router(ws_events.router)
# ============================================================================
# Health Check
# ============================================================================
@app.get("/health")
async def health():
"""Health check endpoint"""
return {
"service": "agents-service",
"version": "2.0.0",
"status": "healthy",
"phase": "6"
}
# ============================================================================
# Root
# ============================================================================
@app.get("/")
async def root():
"""Root endpoint"""
return {
"service": "DAARION Agents Service",
"version": "2.1.0",
"phase": "6+2 (Agents Core)",
"endpoints": {
"health": "/health",
"agents": "/agents",
"blueprints": "/agents/blueprints",
"events": "/agents/{agent_id}/events",
"websocket": "/ws/agents/stream",
"invoke": "/agents/invoke",
"filter": "/agents/filter",
"quota": "/agents/{agent_id}/quota"
}
}
# ============================================================================
# Run
# ============================================================================
if __name__ == "__main__":
import uvicorn
print(f"""
╔══════════════════════════════════════════════════════════════╗
║ ║
║ 🤖 DAARION AGENTS SERVICE — PHASE 6 🤖 ║
║ ║
║ Port: {PORT:<50}
║ Database: PostgreSQL ║
║ NATS: {NATS_URL:<50}
║ ║
║ Features: ║
║ ✅ Agent CRUD (Create, Read, Update, Delete) ║
║ ✅ Event Store (DB persistence) ║
║ ✅ Live WebSocket streams ║
║ ✅ NATS event subscriptions ║
║ ✅ Auth & PDP integration ║
║ ║
╚══════════════════════════════════════════════════════════════╝
""")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=PORT,
reload=False,
log_level="info"
)

View File

@@ -0,0 +1,273 @@
"""
Agents Service Data Models
Phase 6: CRUD + DB Persistence + Events + Live WS
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
# ============================================================================
# Agent Types
# ============================================================================
class AgentKind(str, Enum):
ASSISTANT = "assistant"
NODE = "node"
SYSTEM = "system"
GUARDIAN = "guardian"
ANALYST = "analyst"
QUEST = "quest"
class AgentStatus(str, Enum):
ACTIVE = "active"
IDLE = "idle"
OFFLINE = "offline"
ERROR = "error"
# ============================================================================
# Blueprint Models
# ============================================================================
class AgentBlueprint(BaseModel):
"""Agent blueprint/template"""
id: str
code: str
name: str
description: Optional[str] = None
default_model: str = "gpt-4.1-mini"
default_tools: List[str] = []
default_system_prompt: Optional[str] = None
created_at: datetime
# ============================================================================
# Agent CRUD Models (Phase 6)
# ============================================================================
class AgentCreate(BaseModel):
"""Create new agent"""
name: str = Field(..., min_length=1, max_length=100)
kind: AgentKind
description: Optional[str] = None
microdao_id: Optional[str] = None
owner_user_id: Optional[str] = None
blueprint_code: str = Field(..., description="Blueprint code like sofia_prime")
model: Optional[str] = None # If None, use blueprint default
tools_enabled: List[str] = []
system_prompt: Optional[str] = None
avatar_url: Optional[str] = None
class AgentUpdate(BaseModel):
"""Update agent"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
model: Optional[str] = None
tools_enabled: Optional[List[str]] = None
system_prompt: Optional[str] = None
avatar_url: Optional[str] = None
is_active: Optional[bool] = None
class AgentRead(BaseModel):
"""Agent read response (from DB)"""
id: str
external_id: str
name: str
kind: AgentKind
description: Optional[str] = None
microdao_id: Optional[str] = None
owner_user_id: Optional[str] = None
blueprint_id: Optional[str] = None
model: str
tools_enabled: List[str] = []
system_prompt: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool
created_at: datetime
updated_at: datetime
# ============================================================================
# Agent Models (Legacy — keep for compatibility)
# ============================================================================
class AgentBase(BaseModel):
"""Base agent information"""
id: str
name: str
kind: AgentKind
description: Optional[str] = None
avatar_url: Optional[str] = None
status: AgentStatus = AgentStatus.IDLE
class AgentDetail(AgentBase):
"""Full agent details"""
model: str = "gpt-4.1-mini"
owner_user_id: str
microdao_id: str
tools: List[str] = []
system_prompt: Optional[str] = None
created_at: datetime
updated_at: datetime
last_active_at: Optional[datetime] = None
class AgentListItem(AgentBase):
"""Agent list item (for gallery)"""
model: str
microdao_id: str
last_active_at: Optional[datetime] = None
# ============================================================================
# Metrics Models
# ============================================================================
class AgentMetrics(BaseModel):
"""Agent usage metrics"""
agent_id: str
period_hours: int = 24
# LLM stats
llm_calls_total: int = 0
llm_tokens_total: int = 0
llm_latency_avg_ms: float = 0.0
# Tool stats
tool_calls_total: int = 0
tool_success_rate: float = 0.0
# Agent stats
invocations_total: int = 0
messages_sent: int = 0
# Errors
errors_count: int = 0
class AgentMetricsSeries(BaseModel):
"""Time-series metrics for charts"""
timestamps: List[str]
tokens: List[int]
latency: List[float]
tool_calls: List[int]
# ============================================================================
# Context Models (Memory)
# ============================================================================
class MemoryItem(BaseModel):
"""Single memory item"""
id: str
type: str # "short_term", "mid_term", "knowledge"
content: str
timestamp: datetime
metadata: Optional[Dict[str, Any]] = None
class AgentContext(BaseModel):
"""Agent memory context"""
agent_id: str
short_term: List[MemoryItem] = []
mid_term: List[MemoryItem] = []
knowledge_items: List[MemoryItem] = []
# ============================================================================
# Events Models (Phase 6 — extended)
# ============================================================================
class EventKind(str, Enum):
# Lifecycle
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
ACTIVATED = "activated"
DEACTIVATED = "deactivated"
# Activity
INVOCATION = "invocation"
REPLY_SENT = "reply_sent"
TOOL_CALL = "tool_call"
# Changes
MODEL_CHANGED = "model_changed"
TOOLS_CHANGED = "tools_changed"
PROMPT_CHANGED = "prompt_changed"
# Errors
ERROR = "error"
LLM_ERROR = "llm_error"
TOOL_ERROR = "tool_error"
class AgentEvent(BaseModel):
"""Agent activity event"""
id: str
agent_id: str
kind: EventKind
ts: datetime
channel_id: Optional[str] = None
tool_id: Optional[str] = None
content: Optional[str] = None
payload: Optional[Dict[str, Any]] = None
class AgentEventCreate(BaseModel):
"""Create event payload"""
agent_id: str
kind: EventKind
channel_id: Optional[str] = None
tool_id: Optional[str] = None
content: Optional[str] = None
payload: Optional[Dict[str, Any]] = None
# ============================================================================
# Settings Models
# ============================================================================
class ModelSettings(BaseModel):
"""LLM model settings"""
model: str = Field(..., description="Model name (e.g., gpt-4.1-mini)")
class ToolsSettings(BaseModel):
"""Tools configuration"""
tools_enabled: List[str] = Field(..., description="List of enabled tool IDs")
class SystemPromptSettings(BaseModel):
"""System prompt configuration"""
system_prompt: str = Field(..., max_length=10000)
# ============================================================================
# WebSocket Models
# ============================================================================
class WSAgentEvent(BaseModel):
"""WebSocket event message"""
type: str = "agent_event"
agent_id: str
ts: str
kind: str
payload: Optional[Dict[str, Any]] = None
class WSSubscribe(BaseModel):
"""WebSocket subscribe message"""
type: str = "subscribe"
agent_id: Optional[str] = None # None = subscribe to all
class WSUnsubscribe(BaseModel):
"""WebSocket unsubscribe message"""
type: str = "unsubscribe"
agent_id: Optional[str] = None
# ============================================================================
# Request/Response Models (Legacy — keep for compatibility)
# ============================================================================
class AgentCreateRequest(BaseModel):
"""Legacy create request"""
name: str = Field(..., min_length=1, max_length=100)
kind: AgentKind
description: Optional[str] = None
model: str = "gpt-4.1-mini"
microdao_id: str
tools: List[str] = []
system_prompt: Optional[str] = None
class AgentUpdateRequest(BaseModel):
"""Legacy update request"""
name: Optional[str] = None
description: Optional[str] = None
avatar_url: Optional[str] = None
status: Optional[AgentStatus] = None

View File

@@ -0,0 +1,2 @@
# NATS module

View File

@@ -0,0 +1,162 @@
"""
NATS Publisher — Публікація подій до NATS
"""
import json
from typing import Dict, Any, Optional
from nats.aio.client import Client as NATS
from datetime import datetime
class NATSPublisher:
def __init__(self, nc: NATS):
self.nc = nc
async def publish(self, subject: str, payload: Dict[str, Any]) -> None:
"""
Опублікувати подію до NATS
Args:
subject: NATS subject (e.g., "agents.invoke")
payload: Дані події (dict)
"""
try:
# Додаємо timestamp, якщо не вказано
if "ts" not in payload:
payload["ts"] = datetime.utcnow().isoformat() + "Z"
# Серіалізуємо в JSON
data = json.dumps(payload).encode()
# Публікуємо
await self.nc.publish(subject, data)
print(f"📤 Published: {subject}{len(data)} bytes")
except Exception as e:
print(f"⚠️ Failed to publish {subject}: {e}")
raise
async def publish_agent_invoke(
self,
agent_id: str,
channel_id: str,
message_text: str,
user_id: Optional[str] = None,
context: Optional[Dict[str, Any]] = None
) -> None:
"""
Опублікувати подію виклику агента
Subject: agents.invoke
Payload: {
"agent_id": "agent:sofia",
"channel_id": "channel:123",
"message_text": "What are my tasks?",
"user_id": "user:456",
"context": {...}
}
"""
await self.publish("agents.invoke", {
"agent_id": agent_id,
"channel_id": channel_id,
"message_text": message_text,
"user_id": user_id,
"context": context or {}
})
async def publish_agent_reply(
self,
agent_id: str,
channel_id: str,
reply_text: str,
tokens_used: int = 0,
latency_ms: int = 0
) -> None:
"""
Опублікувати відповідь агента
Subject: agents.reply
"""
await self.publish("agents.reply", {
"agent_id": agent_id,
"channel_id": channel_id,
"reply_text": reply_text,
"tokens_used": tokens_used,
"latency_ms": latency_ms
})
async def publish_agent_error(
self,
agent_id: str,
error_type: str,
error_message: str,
context: Optional[Dict[str, Any]] = None
) -> None:
"""
Опублікувати помилку агента
Subject: agents.error
"""
await self.publish("agents.error", {
"agent_id": agent_id,
"error_type": error_type,
"error_message": error_message,
"context": context or {}
})
async def publish_agent_telemetry(
self,
agent_id: str,
metric_name: str,
metric_value: float,
tags: Optional[Dict[str, str]] = None
) -> None:
"""
Опублікувати телеметрію агента
Subject: agents.telemetry
"""
await self.publish("agents.telemetry", {
"agent_id": agent_id,
"metric_name": metric_name,
"metric_value": metric_value,
"tags": tags or {}
})
async def publish_run_created(
self,
run_id: str,
agent_id: str,
input_text: str
) -> None:
"""
Опублікувати створення run
Subject: agents.runs.created
"""
await self.publish("agents.runs.created", {
"run_id": run_id,
"agent_id": agent_id,
"input_text": input_text[:500] # Limit preview
})
async def publish_run_finished(
self,
run_id: str,
agent_id: str,
success: bool,
duration_ms: int,
tokens_used: int = 0
) -> None:
"""
Опублікувати завершення run
Subject: agents.runs.finished
"""
await self.publish("agents.runs.finished", {
"run_id": run_id,
"agent_id": agent_id,
"success": success,
"duration_ms": duration_ms,
"tokens_used": tokens_used
})

View File

@@ -0,0 +1,77 @@
"""
NATS Subject Registry — Централізований реєстр всіх NATS subjects для Agents Core
"""
# ============================================================================
# PUBLISH (Agents Service → NATS)
# ============================================================================
# Matrix integration (stub для майбутнього)
INTEGRATION_MATRIX_MESSAGE = "integration.matrix.message"
# Agent lifecycle
AGENTS_INVOKE = "agents.invoke"
AGENTS_REPLY = "agents.reply"
AGENTS_ERROR = "agents.error"
AGENTS_TELEMETRY = "agents.telemetry"
# Agent runs
AGENTS_RUNS_CREATED = "agents.runs.created"
AGENTS_RUNS_FINISHED = "agents.runs.finished"
# Agent activity (для living map)
AGENTS_ACTIVITY = "agents.activity"
# ============================================================================
# SUBSCRIBE (Agents Service ← NATS)
# ============================================================================
# Messenger events
MESSAGE_CREATED = "message.created"
MESSAGE_UPDATED = "message.updated"
MESSAGE_DELETED = "message.deleted"
# Task events
TASK_CREATED = "task.created"
TASK_UPDATED = "task.updated"
TASK_ASSIGNED = "task.assigned"
# User actions
EVENT_USER_ACTION = "event.user.action"
# Usage tracking (already subscribed in Phase 6)
USAGE_AGENT = "usage.agent"
USAGE_LLM = "usage.llm"
USAGE_TOOL = "usage.tool"
# Agent replies
AGENT_REPLY_SENT = "agent.reply.sent"
AGENT_ERROR_EVENT = "agent.error"
# ============================================================================
# Subject Patterns (wildcards)
# ============================================================================
AGENTS_ALL = "agents.*"
MESSAGE_ALL = "message.*"
USAGE_ALL = "usage.*"
EVENT_ALL = "event.*"
# ============================================================================
# Helper Functions
# ============================================================================
def get_agent_subject(agent_id: str, event_type: str) -> str:
"""
Генерує subject для конкретного агента
Example: agents.agent:sofia.invoke
"""
return f"agents.{agent_id}.{event_type}"
def get_channel_subject(channel_id: str, event_type: str) -> str:
"""
Генерує subject для конкретного каналу
Example: channel.channel:123.message.created
"""
return f"channel.{channel_id}.{event_type}"

View File

@@ -0,0 +1,280 @@
"""
NATS Subscriber — Listen to agent activity events
Phase 6: Subscribe to usage.agent, usage.llm, agent.reply.sent, agent.error
"""
import asyncio
import json
from typing import Optional
from nats.aio.client import Client as NATS
from repository_events import EventRepository
from ws_events import push_event_to_ws
class NATSSubscriber:
def __init__(self, nats_url: str, event_repo: EventRepository):
self.nats_url = nats_url
self.event_repo = event_repo
self.nc: Optional[NATS] = None
async def connect(self):
"""Connect to NATS"""
self.nc = NATS()
await self.nc.connect(self.nats_url)
print(f"✅ NATS connected: {self.nats_url}")
async def subscribe_all(self):
"""Subscribe to all agent-related subjects"""
if not self.nc:
raise RuntimeError("NATS not connected")
# Subscribe to usage.agent (invocations)
await self.nc.subscribe("usage.agent", cb=self._handle_usage_agent)
print("✅ Subscribed to: usage.agent")
# Subscribe to usage.llm (LLM calls)
await self.nc.subscribe("usage.llm", cb=self._handle_usage_llm)
print("✅ Subscribed to: usage.llm")
# Subscribe to usage.tool (tool calls)
await self.nc.subscribe("usage.tool", cb=self._handle_usage_tool)
print("✅ Subscribed to: usage.tool")
# Subscribe to agent.reply.sent (replies)
await self.nc.subscribe("agent.reply.sent", cb=self._handle_agent_reply)
print("✅ Subscribed to: agent.reply.sent")
# Subscribe to agent.error (errors)
await self.nc.subscribe("agent.error", cb=self._handle_agent_error)
print("✅ Subscribed to: agent.error")
# ========================================================================
# Handlers
# ========================================================================
async def _handle_usage_agent(self, msg):
"""
Handle usage.agent events (invocations)
Example payload:
{
"agent_id": "agent:sofia",
"ts": "2025-11-24T10:30:00Z",
"kind": "invocation",
"channel_id": "channel:123",
"microdao_id": "microdao:7"
}
"""
try:
data = json.loads(msg.data.decode())
agent_id = data.get("agent_id")
if not agent_id:
return
# Log to DB
await self.event_repo.log_event(
agent_external_id=agent_id,
kind="invocation",
channel_id=data.get("channel_id"),
payload={
"microdao_id": data.get("microdao_id"),
"ts": data.get("ts")
}
)
# Push to WebSocket
await push_event_to_ws(
agent_id=agent_id,
event_kind="invocation",
payload={
"channel_id": data.get("channel_id"),
"ts": data.get("ts")
}
)
print(f"📥 Event: {agent_id} → invocation")
except Exception as e:
print(f"⚠️ Error handling usage.agent: {e}")
async def _handle_usage_llm(self, msg):
"""
Handle usage.llm events (LLM calls)
Example payload:
{
"agent_id": "agent:sofia",
"model": "gpt-4.1-mini",
"tokens_input": 150,
"tokens_output": 80,
"latency_ms": 320
}
"""
try:
data = json.loads(msg.data.decode())
agent_id = data.get("agent_id")
if not agent_id:
return
# Log to DB (optional — might be too verbose)
# await self.event_repo.log_event(
# agent_external_id=agent_id,
# kind="llm_call",
# payload=data
# )
# Push to WebSocket (live activity)
await push_event_to_ws(
agent_id=agent_id,
event_kind="llm_call",
payload={
"model": data.get("model"),
"tokens": data.get("tokens_input", 0) + data.get("tokens_output", 0),
"latency_ms": data.get("latency_ms")
}
)
except Exception as e:
print(f"⚠️ Error handling usage.llm: {e}")
async def _handle_usage_tool(self, msg):
"""
Handle usage.tool events (tool calls)
Example payload:
{
"agent_id": "agent:sofia",
"tool_id": "projects.list",
"success": true,
"latency_ms": 50
}
"""
try:
data = json.loads(msg.data.decode())
agent_id = data.get("agent_id")
if not agent_id:
return
# Log to DB
await self.event_repo.log_event(
agent_external_id=agent_id,
kind="tool_call",
payload={
"tool_id": data.get("tool_id"),
"success": data.get("success"),
"latency_ms": data.get("latency_ms")
}
)
# Push to WebSocket
await push_event_to_ws(
agent_id=agent_id,
event_kind="tool_call",
payload={
"tool_id": data.get("tool_id"),
"success": data.get("success")
}
)
print(f"📥 Event: {agent_id} → tool_call ({data.get('tool_id')})")
except Exception as e:
print(f"⚠️ Error handling usage.tool: {e}")
async def _handle_agent_reply(self, msg):
"""
Handle agent.reply.sent events
Example payload:
{
"agent_id": "agent:sofia",
"channel_id": "channel:123",
"message_preview": "Here are your projects...",
"ts": "2025-11-24T10:30:05Z"
}
"""
try:
data = json.loads(msg.data.decode())
agent_id = data.get("agent_id")
if not agent_id:
return
# Log to DB
await self.event_repo.log_event(
agent_external_id=agent_id,
kind="reply_sent",
channel_id=data.get("channel_id"),
payload={
"message_preview": data.get("message_preview", "")[:200],
"ts": data.get("ts")
}
)
# Push to WebSocket
await push_event_to_ws(
agent_id=agent_id,
event_kind="reply_sent",
payload={
"channel_id": data.get("channel_id"),
"message_preview": data.get("message_preview", "")[:50]
}
)
print(f"📥 Event: {agent_id} → reply_sent")
except Exception as e:
print(f"⚠️ Error handling agent.reply.sent: {e}")
async def _handle_agent_error(self, msg):
"""
Handle agent.error events
Example payload:
{
"agent_id": "agent:sofia",
"error_type": "LLM_ERROR",
"error_message": "Rate limit exceeded",
"ts": "2025-11-24T10:30:00Z"
}
"""
try:
data = json.loads(msg.data.decode())
agent_id = data.get("agent_id")
if not agent_id:
return
# Log to DB
await self.event_repo.log_event(
agent_external_id=agent_id,
kind="error",
payload={
"error_type": data.get("error_type"),
"error_message": data.get("error_message"),
"ts": data.get("ts")
}
)
# Push to WebSocket
await push_event_to_ws(
agent_id=agent_id,
event_kind="error",
payload={
"error_type": data.get("error_type"),
"error_message": data.get("error_message", "")[:100]
}
)
print(f"📥 Event: {agent_id} → error ({data.get('error_type')})")
except Exception as e:
print(f"⚠️ Error handling agent.error: {e}")
async def close(self):
"""Close NATS connection"""
if self.nc:
await self.nc.close()
print("❌ NATS closed")

View File

@@ -0,0 +1,278 @@
"""
Quotas & Rate Limits — Обмеження використання агентів
"""
import time
from typing import Dict, Optional
from datetime import datetime, timedelta
from collections import defaultdict
# ============================================================================
# Quota Configuration
# ============================================================================
class QuotaConfig:
"""Конфігурація квот"""
def __init__(
self,
tokens_per_minute: int = 1000,
runs_per_day: int = 100,
users_per_day: int = 50,
max_concurrent_runs: int = 5
):
self.tokens_per_minute = tokens_per_minute
self.runs_per_day = runs_per_day
self.users_per_day = users_per_day
self.max_concurrent_runs = max_concurrent_runs
# Default quota configurations
DEFAULT_QUOTAS = {
"free": QuotaConfig(
tokens_per_minute=500,
runs_per_day=50,
users_per_day=20,
max_concurrent_runs=2
),
"pro": QuotaConfig(
tokens_per_minute=2000,
runs_per_day=500,
users_per_day=200,
max_concurrent_runs=10
),
"enterprise": QuotaConfig(
tokens_per_minute=10000,
runs_per_day=5000,
users_per_day=1000,
max_concurrent_runs=50
)
}
# ============================================================================
# Quota Tracker
# ============================================================================
class QuotaTracker:
"""
Трекер використання ресурсів агентами
Тримає в пам'яті (in-memory) лічильники для:
- Токенів за хвилину
- Запусків за день
- Унікальних користувачів за день
- Паралельних запусків
"""
def __init__(self):
# Agent ID → tokens used in current minute
self._tokens_minute: Dict[str, list[tuple[float, int]]] = defaultdict(list)
# Agent ID → runs count today
self._runs_today: Dict[str, int] = defaultdict(int)
self._runs_today_date: Optional[str] = None
# Agent ID → unique users today
self._users_today: Dict[str, set[str]] = defaultdict(set)
# Agent ID → concurrent runs count
self._concurrent_runs: Dict[str, int] = defaultdict(int)
def check_tokens_quota(self, agent_id: str, tokens: int, quota: QuotaConfig) -> bool:
"""
Перевірити, чи не перевищено квоту токенів за хвилину
Args:
agent_id: ID агента
tokens: Кількість токенів для використання
quota: Конфігурація квот
Returns:
True, якщо квота дозволяє використання
"""
now = time.time()
one_minute_ago = now - 60
# Видалити старі записи (старше 1 хвилини)
self._tokens_minute[agent_id] = [
(ts, count) for ts, count in self._tokens_minute[agent_id]
if ts > one_minute_ago
]
# Підрахувати використані токени за останню хвилину
used_tokens = sum(count for _, count in self._tokens_minute[agent_id])
# Перевірити квоту
if used_tokens + tokens > quota.tokens_per_minute:
return False
return True
def record_tokens(self, agent_id: str, tokens: int) -> None:
"""
Записати використані токени
Args:
agent_id: ID агента
tokens: Кількість використаних токенів
"""
now = time.time()
self._tokens_minute[agent_id].append((now, tokens))
def check_runs_quota(self, agent_id: str, quota: QuotaConfig) -> bool:
"""
Перевірити, чи не перевищено квоту запусків за день
Args:
agent_id: ID агента
quota: Конфігурація квот
Returns:
True, якщо квота дозволяє запуск
"""
today = datetime.utcnow().strftime("%Y-%m-%d")
# Скинути лічильники, якщо новий день
if self._runs_today_date != today:
self._runs_today.clear()
self._users_today.clear()
self._runs_today_date = today
# Перевірити квоту
if self._runs_today[agent_id] >= quota.runs_per_day:
return False
return True
def record_run(self, agent_id: str, user_id: Optional[str] = None) -> None:
"""
Записати запуск
Args:
agent_id: ID агента
user_id: ID користувача (опційно)
"""
today = datetime.utcnow().strftime("%Y-%m-%d")
# Скинути лічильники, якщо новий день
if self._runs_today_date != today:
self._runs_today.clear()
self._users_today.clear()
self._runs_today_date = today
self._runs_today[agent_id] += 1
if user_id:
self._users_today[agent_id].add(user_id)
def check_users_quota(self, agent_id: str, user_id: str, quota: QuotaConfig) -> bool:
"""
Перевірити, чи не перевищено квоту унікальних користувачів за день
Args:
agent_id: ID агента
user_id: ID користувача
quota: Конфігурація квот
Returns:
True, якщо квота дозволяє користувача
"""
today = datetime.utcnow().strftime("%Y-%m-%d")
# Скинути лічильники, якщо новий день
if self._runs_today_date != today:
self._runs_today.clear()
self._users_today.clear()
self._runs_today_date = today
# Якщо користувач вже був — завжди дозволяємо
if user_id in self._users_today[agent_id]:
return True
# Перевірити квоту нових користувачів
if len(self._users_today[agent_id]) >= quota.users_per_day:
return False
return True
def check_concurrent_runs(self, agent_id: str, quota: QuotaConfig) -> bool:
"""
Перевірити, чи не перевищено квоту паралельних запусків
Args:
agent_id: ID агента
quota: Конфігурація квот
Returns:
True, якщо квота дозволяє запуск
"""
if self._concurrent_runs[agent_id] >= quota.max_concurrent_runs:
return False
return True
def start_run(self, agent_id: str) -> None:
"""
Почати запуск (збільшити лічильник паралельних запусків)
Args:
agent_id: ID агента
"""
self._concurrent_runs[agent_id] += 1
def finish_run(self, agent_id: str) -> None:
"""
Завершити запуск (зменшити лічильник паралельних запусків)
Args:
agent_id: ID агента
"""
if self._concurrent_runs[agent_id] > 0:
self._concurrent_runs[agent_id] -= 1
def get_usage_stats(self, agent_id: str) -> Dict:
"""
Отримати статистику використання агента
Args:
agent_id: ID агента
Returns:
Dict зі статистикою
"""
now = time.time()
one_minute_ago = now - 60
# Токени за останню хвилину
tokens_minute = sum(
count for ts, count in self._tokens_minute[agent_id]
if ts > one_minute_ago
)
today = datetime.utcnow().strftime("%Y-%m-%d")
if self._runs_today_date != today:
runs_today = 0
users_today = 0
else:
runs_today = self._runs_today[agent_id]
users_today = len(self._users_today[agent_id])
return {
"tokens_minute": tokens_minute,
"runs_today": runs_today,
"users_today": users_today,
"concurrent_runs": self._concurrent_runs[agent_id]
}
# ============================================================================
# Global Quota Tracker Instance
# ============================================================================
_global_tracker = QuotaTracker()
def get_quota_tracker() -> QuotaTracker:
"""Отримати глобальний екземпляр QuotaTracker"""
return _global_tracker

View File

@@ -0,0 +1,297 @@
"""
Agent Repository — Database operations for agents
Phase 6: CRUD + DB Persistence
"""
import uuid
from typing import List, Optional
from datetime import datetime
import asyncpg
from models import AgentCreate, AgentUpdate, AgentRead, AgentBlueprint
class AgentRepository:
def __init__(self, db_pool: asyncpg.Pool):
self.db = db_pool
# ========================================================================
# Blueprints
# ========================================================================
async def get_blueprint_by_code(self, code: str) -> Optional[AgentBlueprint]:
"""Get blueprint by code"""
row = await self.db.fetchrow(
"""
SELECT id, code, name, description, default_model,
default_tools, default_system_prompt, created_at
FROM agent_blueprints
WHERE code = $1
""",
code
)
if not row:
return None
return AgentBlueprint(
id=str(row['id']),
code=row['code'],
name=row['name'],
description=row['description'],
default_model=row['default_model'],
default_tools=row['default_tools'] or [],
default_system_prompt=row['default_system_prompt'],
created_at=row['created_at']
)
async def list_blueprints(self) -> List[AgentBlueprint]:
"""List all blueprints"""
rows = await self.db.fetch(
"""
SELECT id, code, name, description, default_model,
default_tools, default_system_prompt, created_at
FROM agent_blueprints
ORDER BY name
"""
)
return [
AgentBlueprint(
id=str(row['id']),
code=row['code'],
name=row['name'],
description=row['description'],
default_model=row['default_model'],
default_tools=row['default_tools'] or [],
default_system_prompt=row['default_system_prompt'],
created_at=row['created_at']
)
for row in rows
]
# ========================================================================
# Agents — CRUD
# ========================================================================
async def create_agent(
self,
data: AgentCreate,
actor_id: Optional[str] = None
) -> AgentRead:
"""Create new agent"""
# Get blueprint
blueprint = await self.get_blueprint_by_code(data.blueprint_code)
if not blueprint:
raise ValueError(f"Blueprint '{data.blueprint_code}' not found")
# Generate IDs
agent_id = uuid.uuid4()
external_id = f"agent:{agent_id.hex[:12]}"
# Determine model (use provided or blueprint default)
model = data.model or blueprint.default_model
# Determine system prompt
system_prompt = data.system_prompt or blueprint.default_system_prompt
# Insert
row = await self.db.fetchrow(
"""
INSERT INTO agents (
id, external_id, name, kind, microdao_id, owner_user_id,
blueprint_id, model, tools_enabled, system_prompt,
avatar_url, description, is_active
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, external_id, name, kind, microdao_id, owner_user_id,
blueprint_id, model, tools_enabled, system_prompt,
avatar_url, description, is_active, created_at, updated_at
""",
agent_id,
external_id,
data.name,
data.kind.value,
uuid.UUID(data.microdao_id) if data.microdao_id else None,
uuid.UUID(data.owner_user_id or actor_id) if (data.owner_user_id or actor_id) else None,
uuid.UUID(blueprint.id),
model,
data.tools_enabled or [],
system_prompt,
data.avatar_url,
data.description,
True
)
return self._row_to_agent_read(row)
async def update_agent(
self,
agent_id: str,
data: AgentUpdate
) -> AgentRead:
"""Update agent"""
# Build dynamic update query
updates = []
values = []
param_idx = 1
if data.name is not None:
updates.append(f"name = ${param_idx}")
values.append(data.name)
param_idx += 1
if data.description is not None:
updates.append(f"description = ${param_idx}")
values.append(data.description)
param_idx += 1
if data.model is not None:
updates.append(f"model = ${param_idx}")
values.append(data.model)
param_idx += 1
if data.tools_enabled is not None:
updates.append(f"tools_enabled = ${param_idx}")
values.append(data.tools_enabled)
param_idx += 1
if data.system_prompt is not None:
updates.append(f"system_prompt = ${param_idx}")
values.append(data.system_prompt)
param_idx += 1
if data.avatar_url is not None:
updates.append(f"avatar_url = ${param_idx}")
values.append(data.avatar_url)
param_idx += 1
if data.is_active is not None:
updates.append(f"is_active = ${param_idx}")
values.append(data.is_active)
param_idx += 1
if not updates:
# No changes
return await self.get_agent_by_external_id(agent_id)
# Always update updated_at
updates.append(f"updated_at = NOW()")
# Add agent_id to values
values.append(agent_id)
query = f"""
UPDATE agents
SET {', '.join(updates)}
WHERE external_id = ${param_idx}
RETURNING id, external_id, name, kind, microdao_id, owner_user_id,
blueprint_id, model, tools_enabled, system_prompt,
avatar_url, description, is_active, created_at, updated_at
"""
row = await self.db.fetchrow(query, *values)
if not row:
raise ValueError(f"Agent '{agent_id}' not found")
return self._row_to_agent_read(row)
async def delete_agent(self, agent_id: str) -> None:
"""Soft delete agent (set is_active = false)"""
result = await self.db.execute(
"""
UPDATE agents
SET is_active = false, updated_at = NOW()
WHERE external_id = $1
""",
agent_id
)
if result == "UPDATE 0":
raise ValueError(f"Agent '{agent_id}' not found")
async def get_agent_by_external_id(self, external_id: str) -> Optional[AgentRead]:
"""Get agent by external_id"""
row = await self.db.fetchrow(
"""
SELECT id, external_id, name, kind, microdao_id, owner_user_id,
blueprint_id, model, tools_enabled, system_prompt,
avatar_url, description, is_active, created_at, updated_at
FROM agents
WHERE external_id = $1
""",
external_id
)
if not row:
return None
return self._row_to_agent_read(row)
async def list_agents(
self,
microdao_id: Optional[str] = None,
owner_user_id: Optional[str] = None,
kind: Optional[str] = None,
is_active: bool = True
) -> List[AgentRead]:
"""List agents with filters"""
query = """
SELECT id, external_id, name, kind, microdao_id, owner_user_id,
blueprint_id, model, tools_enabled, system_prompt,
avatar_url, description, is_active, created_at, updated_at
FROM agents
WHERE 1=1
"""
values = []
param_idx = 1
if microdao_id:
query += f" AND microdao_id = ${param_idx}"
values.append(uuid.UUID(microdao_id))
param_idx += 1
if owner_user_id:
query += f" AND owner_user_id = ${param_idx}"
values.append(uuid.UUID(owner_user_id))
param_idx += 1
if kind:
query += f" AND kind = ${param_idx}"
values.append(kind)
param_idx += 1
query += f" AND is_active = ${param_idx}"
values.append(is_active)
query += " ORDER BY created_at DESC"
rows = await self.db.fetch(query, *values)
return [self._row_to_agent_read(row) for row in rows]
# ========================================================================
# Helpers
# ========================================================================
def _row_to_agent_read(self, row) -> AgentRead:
"""Convert DB row to AgentRead"""
from models import AgentKind
return AgentRead(
id=str(row['id']),
external_id=row['external_id'],
name=row['name'],
kind=AgentKind(row['kind']),
description=row['description'],
microdao_id=str(row['microdao_id']) if row['microdao_id'] else None,
owner_user_id=str(row['owner_user_id']) if row['owner_user_id'] else None,
blueprint_id=str(row['blueprint_id']) if row['blueprint_id'] else None,
model=row['model'],
tools_enabled=row['tools_enabled'] or [],
system_prompt=row['system_prompt'],
avatar_url=row['avatar_url'],
is_active=row['is_active'],
created_at=row['created_at'],
updated_at=row['updated_at']
)

View File

@@ -0,0 +1,167 @@
"""
Agent Events Repository — Database operations for agent_events
Phase 6: Event Store
"""
import uuid
from typing import List, Optional
from datetime import datetime
import asyncpg
from models import AgentEvent, AgentEventCreate, EventKind
class EventRepository:
def __init__(self, db_pool: asyncpg.Pool):
self.db = db_pool
# ========================================================================
# Events — CRUD
# ========================================================================
async def create_event(
self,
agent_db_id: str,
event: AgentEventCreate
) -> AgentEvent:
"""Log new event"""
event_id = uuid.uuid4()
row = await self.db.fetchrow(
"""
INSERT INTO agent_events (
id, agent_id, ts, kind, channel_id, payload
)
VALUES ($1, $2, NOW(), $3, $4, $5)
RETURNING id, agent_id, ts, kind, channel_id, payload
""",
event_id,
uuid.UUID(agent_db_id),
event.kind.value,
event.channel_id,
event.payload or {}
)
return self._row_to_event(row, event.agent_id)
async def log_event(
self,
agent_external_id: str,
kind: str,
payload: Optional[dict] = None,
channel_id: Optional[str] = None
) -> None:
"""
Log event (simplified version)
Looks up agent by external_id and inserts event
"""
# Get agent DB ID
agent_row = await self.db.fetchrow(
"SELECT id FROM agents WHERE external_id = $1",
agent_external_id
)
if not agent_row:
print(f"⚠️ Agent {agent_external_id} not found, skipping event {kind}")
return
event_id = uuid.uuid4()
await self.db.execute(
"""
INSERT INTO agent_events (id, agent_id, ts, kind, channel_id, payload)
VALUES ($1, $2, NOW(), $3, $4, $5)
""",
event_id,
agent_row['id'],
kind,
channel_id,
payload or {}
)
print(f"✅ Event logged: {agent_external_id}{kind}")
async def list_events(
self,
agent_external_id: str,
limit: int = 50,
before_ts: Optional[datetime] = None
) -> List[AgentEvent]:
"""List events for agent"""
# Get agent DB ID
agent_row = await self.db.fetchrow(
"SELECT id FROM agents WHERE external_id = $1",
agent_external_id
)
if not agent_row:
return []
query = """
SELECT id, agent_id, ts, kind, channel_id, payload
FROM agent_events
WHERE agent_id = $1
"""
values = [agent_row['id']]
param_idx = 2
if before_ts:
query += f" AND ts < ${param_idx}"
values.append(before_ts)
param_idx += 1
query += f" ORDER BY ts DESC LIMIT ${param_idx}"
values.append(limit)
rows = await self.db.fetch(query, *values)
return [self._row_to_event(row, agent_external_id) for row in rows]
async def list_recent_events(
self,
limit: int = 100,
since_ts: Optional[datetime] = None
) -> List[AgentEvent]:
"""
List recent events across all agents
Used for WebSocket streaming
"""
query = """
SELECT
e.id, e.agent_id, e.ts, e.kind, e.channel_id, e.payload,
a.external_id as agent_external_id
FROM agent_events e
JOIN agents a ON e.agent_id = a.id
WHERE 1=1
"""
values = []
param_idx = 1
if since_ts:
query += f" AND e.ts > ${param_idx}"
values.append(since_ts)
param_idx += 1
query += f" ORDER BY e.ts DESC LIMIT ${param_idx}"
values.append(limit)
rows = await self.db.fetch(query, *values)
return [self._row_to_event(row, row['agent_external_id']) for row in rows]
# ========================================================================
# Helpers
# ========================================================================
def _row_to_event(self, row, agent_external_id: str) -> AgentEvent:
"""Convert DB row to AgentEvent"""
return AgentEvent(
id=str(row['id']),
agent_id=agent_external_id,
kind=EventKind(row['kind']),
ts=row['ts'],
channel_id=row['channel_id'],
tool_id=row.get('tool_id'),
content=row.get('content'),
payload=row['payload']
)

View File

@@ -0,0 +1,7 @@
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
asyncpg==0.29.0
nats-py==2.6.0
httpx==0.25.1
python-dotenv==1.0.0

View File

@@ -0,0 +1,264 @@
"""
Agent CRUD Routes
Phase 6: Create, Read, Update, Delete agents
"""
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import List, Optional
import httpx
from models import AgentCreate, AgentUpdate, AgentRead, AgentBlueprint
from repository_agents import AgentRepository
from repository_events import EventRepository
router = APIRouter(prefix="/agents", tags=["agents"])
# Dependency injection (will be set in main.py)
agent_repo: Optional[AgentRepository] = None
event_repo: Optional[EventRepository] = None
# Service URLs (from env)
import os
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
# ============================================================================
# Auth & PDP Helpers
# ============================================================================
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
"""
Get ActorIdentity from auth-service
Returns actor dict or raises 401
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{AUTH_SERVICE_URL}/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def check_pdp_permission(
action: str,
resource: dict,
context: dict,
actor: dict
) -> bool:
"""
Check permission via pdp-service
Returns True if allowed, False otherwise
"""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
json={
"action": action,
"resource": resource,
"context": context,
"actor": actor
}
)
response.raise_for_status()
result = response.json()
return result.get("decision") == "ALLOW"
except httpx.HTTPError as e:
print(f"⚠️ PDP error: {e}")
return False # Fail closed
# ============================================================================
# Blueprints
# ============================================================================
@router.get("/blueprints", response_model=List[AgentBlueprint])
async def list_blueprints():
"""List all agent blueprints"""
return await agent_repo.list_blueprints()
# ============================================================================
# CRUD — Create
# ============================================================================
@router.post("", response_model=AgentRead, status_code=201)
async def create_agent(
data: AgentCreate,
actor: dict = Depends(get_actor_from_token)
):
"""
Create new agent
Requires: MANAGE permission on AGENT:*
"""
# Check PDP
allowed = await check_pdp_permission(
action="MANAGE",
resource={"type": "AGENT", "id": "*"},
context={"operation": "create", "kind": data.kind.value},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot create agent")
# Create agent
try:
agent = await agent_repo.create_agent(
data,
actor_id=actor.get("actor_id")
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Log event
await event_repo.log_event(
agent.external_id,
"created",
payload={
"name": agent.name,
"kind": agent.kind.value,
"blueprint_id": agent.blueprint_id,
"created_by": actor.get("actor_id")
}
)
return agent
# ============================================================================
# CRUD — Read
# ============================================================================
@router.get("", response_model=List[AgentRead])
async def list_agents(
microdao_id: Optional[str] = None,
kind: Optional[str] = None,
is_active: bool = True
):
"""
List agents with filters
For now, no auth required (read-only)
TODO: Filter by actor's microdao_ids in Phase 6.5
"""
return await agent_repo.list_agents(
microdao_id=microdao_id,
kind=kind,
is_active=is_active
)
@router.get("/{agent_id}", response_model=AgentRead)
async def get_agent(agent_id: str):
"""
Get agent by ID
For now, no auth required (read-only)
TODO: PDP check in Phase 6.5
"""
agent = await agent_repo.get_agent_by_external_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
return agent
# ============================================================================
# CRUD — Update
# ============================================================================
@router.patch("/{agent_id}", response_model=AgentRead)
async def update_agent(
agent_id: str,
data: AgentUpdate,
actor: dict = Depends(get_actor_from_token)
):
"""
Update agent
Requires: MANAGE permission on AGENT:{agent_id}
"""
# Check agent exists
existing = await agent_repo.get_agent_by_external_id(agent_id)
if not existing:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MANAGE",
resource={"type": "AGENT", "id": agent_id},
context={"operation": "update"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot update agent")
# Update
try:
agent = await agent_repo.update_agent(agent_id, data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Log event
changed_fields = {k: v for k, v in data.dict(exclude_unset=True).items()}
await event_repo.log_event(
agent_id,
"updated",
payload={
"changed_fields": changed_fields,
"updated_by": actor.get("actor_id")
}
)
return agent
# ============================================================================
# CRUD — Delete
# ============================================================================
@router.delete("/{agent_id}", status_code=204)
async def delete_agent(
agent_id: str,
actor: dict = Depends(get_actor_from_token)
):
"""
Delete (deactivate) agent
Requires: MANAGE permission on AGENT:{agent_id}
"""
# Check agent exists
existing = await agent_repo.get_agent_by_external_id(agent_id)
if not existing:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MANAGE",
resource={"type": "AGENT", "id": agent_id},
context={"operation": "delete"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot delete agent")
# Soft delete
try:
await agent_repo.delete_agent(agent_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Log event
await event_repo.log_event(
agent_id,
"deleted",
payload={
"deleted_by": actor.get("actor_id")
}
)
return None

View File

@@ -0,0 +1,41 @@
"""
Agent Events Routes
Phase 6: Event history endpoint
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
from datetime import datetime
from models import AgentEvent
from repository_events import EventRepository
router = APIRouter(prefix="/agents", tags=["events"])
# Dependency injection
event_repo: Optional[EventRepository] = None
# ============================================================================
# Events
# ============================================================================
@router.get("/{agent_id}/events", response_model=List[AgentEvent])
async def list_agent_events(
agent_id: str,
limit: int = Query(50, ge=1, le=200),
before_ts: Optional[datetime] = None
):
"""
List events for agent
Query params:
- limit: max events to return (default 50, max 200)
- before_ts: get events before this timestamp (for pagination)
"""
events = await event_repo.list_events(
agent_external_id=agent_id,
limit=limit,
before_ts=before_ts
)
return events

View File

@@ -0,0 +1,215 @@
"""
Routes — Agent Invocation & Filtering
New routes for Phase 2: Agents Core
"""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import Optional, List
import uuid
from agent_filter import filter_message, FilterResult
from agent_router import AgentRouter
from agent_executor import AgentExecutor, AgentExecutionError
from quotas import get_quota_tracker, DEFAULT_QUOTAS
router = APIRouter(prefix="/agents", tags=["agents-invoke"])
# ============================================================================
# Request/Response Models
# ============================================================================
class InvokeRequest(BaseModel):
"""Запит на виклик агента"""
agent_id: str
message_text: str
channel_id: str
user_id: Optional[str] = None
context: Optional[dict] = None
class InvokeResponse(BaseModel):
"""Відповідь на виклик агента"""
success: bool
message: str
run_id: Optional[str] = None
response_text: Optional[str] = None
tokens_used: Optional[int] = None
latency_ms: Optional[int] = None
class FilterRequest(BaseModel):
"""Запит на фільтрацію повідомлення"""
message_text: str
user_id: str
channel_id: str
channel_agents: Optional[List[str]] = None
class FilterResponse(BaseModel):
"""Відповідь фільтрації"""
action: str # "allow" | "deny" | "agent"
reason: Optional[str] = None
agent_id: Optional[str] = None
command: Optional[dict] = None
intent: Optional[str] = None
class QuotaStatsResponse(BaseModel):
"""Статистика використання квот"""
agent_id: str
tokens_minute: int
runs_today: int
users_today: int
concurrent_runs: int
# ============================================================================
# Global instances (будуть ініціалізовані в main.py)
# ============================================================================
agent_router: Optional[AgentRouter] = None
agent_executor: Optional[AgentExecutor] = None
def init_agents_core(router_instance: AgentRouter, executor_instance: AgentExecutor):
"""
Ініціалізувати Agents Core компоненти
Викликається з main.py при старті
"""
global agent_router, agent_executor
agent_router = router_instance
agent_executor = executor_instance
# ============================================================================
# Routes
# ============================================================================
@router.post("/filter", response_model=FilterResponse)
async def filter_message_endpoint(request: FilterRequest):
"""
Фільтрувати повідомлення
Використання:
- Перевірка на spam
- Виявлення команд
- Виявлення згадувань агентів
- Визначення intent
Returns:
FilterResponse з рішенням про обробку
"""
result: FilterResult = filter_message(
text=request.message_text,
user_id=request.user_id,
channel_agents=request.channel_agents
)
return FilterResponse(
action=result.action,
reason=result.reason,
agent_id=result.agent_id,
command=result.command,
intent=result.intent
)
@router.post("/invoke", response_model=InvokeResponse)
async def invoke_agent(request: InvokeRequest, background_tasks: BackgroundTasks):
"""
Викликати агента
Flow:
1. Перевірити квоти
2. Маршрутизувати через NATS (agents.invoke)
3. Виконати LLM запит
4. Опублікувати відповідь (agents.reply)
Returns:
InvokeResponse з результатом
"""
if not agent_router or not agent_executor:
raise HTTPException(status_code=500, detail="Agents Core not initialized")
# Get quota config (default: free tier)
quota = DEFAULT_QUOTAS["free"]
tracker = get_quota_tracker()
# Check quotas
if not tracker.check_concurrent_runs(request.agent_id, quota):
raise HTTPException(status_code=429, detail="Too many concurrent runs")
if not tracker.check_runs_quota(request.agent_id, quota):
raise HTTPException(status_code=429, detail="Daily runs quota exceeded")
if request.user_id and not tracker.check_users_quota(request.agent_id, request.user_id, quota):
raise HTTPException(status_code=429, detail="Daily users quota exceeded")
# Generate run ID
run_id = f"run:{uuid.uuid4()}"
# Start run tracking
tracker.start_run(request.agent_id)
tracker.record_run(request.agent_id, request.user_id)
try:
# Route to agent через NATS
await agent_router.route_to_agent(
agent_id=request.agent_id,
channel_id=request.channel_id,
message_text=request.message_text,
user_id=request.user_id,
context=request.context
)
# Execute LLM
result = await agent_executor.execute(
agent_id=request.agent_id,
prompt=request.message_text,
system_prompt=f"You are {request.agent_id}, a helpful AI assistant."
)
# Check token quota
if not tracker.check_tokens_quota(request.agent_id, result["tokens_used"], quota):
raise HTTPException(status_code=429, detail="Token quota exceeded")
# Record tokens
tracker.record_tokens(request.agent_id, result["tokens_used"])
# Publish reply через NATS (у фоні)
async def publish_reply():
from nats_helpers.publisher import NATSPublisher
# TODO: Use actual NATS connection from main.py
# await publisher.publish_agent_reply(...)
pass
background_tasks.add_task(publish_reply)
return InvokeResponse(
success=True,
message="Agent invoked successfully",
run_id=run_id,
response_text=result["response_text"],
tokens_used=result["tokens_used"],
latency_ms=result["latency_ms"]
)
except AgentExecutionError as e:
raise HTTPException(status_code=500, detail=f"Agent execution failed: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
finally:
# Finish run tracking
tracker.finish_run(request.agent_id)
@router.get("/{agent_id}/quota", response_model=QuotaStatsResponse)
async def get_agent_quota_stats(agent_id: str):
"""
Отримати статистику використання квот для агента
Returns:
QuotaStatsResponse з поточною статистикою
"""
tracker = get_quota_tracker()
stats = tracker.get_usage_stats(agent_id)
return QuotaStatsResponse(
agent_id=agent_id,
**stats
)

View File

@@ -0,0 +1,177 @@
"""
WebSocket — Live Agent Events Stream
Phase 6: Real-time event streaming
"""
from fastapi import WebSocket, WebSocketDisconnect, APIRouter
from typing import Set, Dict, Optional
import asyncio
import json
from datetime import datetime
from models import WSAgentEvent
from repository_events import EventRepository
router = APIRouter(tags=["websocket"])
# Global state for WebSocket connections
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, Set[WebSocket]] = {}
self.all_connections: Set[WebSocket] = set()
self.event_queue: asyncio.Queue = asyncio.Queue()
async def connect(self, websocket: WebSocket, agent_id: Optional[str] = None):
"""Accept WebSocket connection"""
await websocket.accept()
if agent_id:
if agent_id not in self.active_connections:
self.active_connections[agent_id] = set()
self.active_connections[agent_id].add(websocket)
else:
# Subscribe to all agents
self.all_connections.add(websocket)
print(f"✅ WS connected: {agent_id or 'ALL'} (total: {self.get_connection_count()})")
def disconnect(self, websocket: WebSocket, agent_id: Optional[str] = None):
"""Remove WebSocket connection"""
if agent_id and agent_id in self.active_connections:
self.active_connections[agent_id].discard(websocket)
if not self.active_connections[agent_id]:
del self.active_connections[agent_id]
else:
self.all_connections.discard(websocket)
print(f"❌ WS disconnected: {agent_id or 'ALL'} (total: {self.get_connection_count()})")
async def broadcast_event(self, agent_id: str, event: WSAgentEvent):
"""Broadcast event to subscribers"""
message = event.json()
# Send to agent-specific subscribers
if agent_id in self.active_connections:
dead_connections = set()
for connection in self.active_connections[agent_id]:
try:
await connection.send_text(message)
except Exception:
dead_connections.add(connection)
# Clean up dead connections
for conn in dead_connections:
self.disconnect(conn, agent_id)
# Send to "all agents" subscribers
dead_connections = set()
for connection in self.all_connections:
try:
await connection.send_text(message)
except Exception:
dead_connections.add(connection)
for conn in dead_connections:
self.disconnect(conn, None)
def get_connection_count(self) -> int:
"""Get total active connections"""
count = len(self.all_connections)
for connections in self.active_connections.values():
count += len(connections)
return count
async def push_event_to_queue(self, agent_id: str, event_kind: str, payload: dict):
"""Push event to queue (called from nats_subscriber or routes)"""
event = WSAgentEvent(
type="agent_event",
agent_id=agent_id,
ts=datetime.utcnow().isoformat(),
kind=event_kind,
payload=payload
)
await self.event_queue.put((agent_id, event))
manager = ConnectionManager()
# ============================================================================
# WebSocket Endpoint
# ============================================================================
@router.websocket("/ws/agents/stream")
async def websocket_agent_events(websocket: WebSocket, agent_id: Optional[str] = None):
"""
WebSocket endpoint for live agent events
Query params:
- agent_id: subscribe to specific agent (optional)
If agent_id is None, subscribe to all agents
"""
await manager.connect(websocket, agent_id)
try:
# Keep connection alive and send events
while True:
# Wait for events from queue
try:
event_agent_id, event = await asyncio.wait_for(
manager.event_queue.get(),
timeout=30.0 # 30-second timeout for ping
)
# If subscribed to specific agent, only send its events
if agent_id is None or event_agent_id == agent_id:
await websocket.send_text(event.json())
except asyncio.TimeoutError:
# Send ping to keep connection alive
await websocket.send_json({"type": "ping", "ts": datetime.utcnow().isoformat()})
except WebSocketDisconnect:
manager.disconnect(websocket, agent_id)
except Exception as e:
print(f"⚠️ WebSocket error: {e}")
manager.disconnect(websocket, agent_id)
# ============================================================================
# Background Task — Event Queue Consumer
# ============================================================================
async def event_queue_consumer():
"""
Background task that consumes events from queue and broadcasts to WS clients
This runs in background alongside the main FastAPI app
"""
print("🚀 Event queue consumer started")
while True:
try:
# Get event from queue (with timeout to prevent blocking)
agent_id, event = await asyncio.wait_for(
manager.event_queue.get(),
timeout=1.0
)
# Broadcast to WebSocket clients
await manager.broadcast_event(agent_id, event)
except asyncio.TimeoutError:
# No events in queue, continue
continue
except Exception as e:
print(f"⚠️ Event queue consumer error: {e}")
await asyncio.sleep(1)
# ============================================================================
# Helper Functions (for use in other modules)
# ============================================================================
async def push_event_to_ws(agent_id: str, event_kind: str, payload: dict = None):
"""
Push event to WebSocket stream
Called from routes_agents or nats_subscriber
"""
await manager.push_event_to_queue(agent_id, event_kind, payload or {})