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:
16
services/agents-service/Dockerfile
Normal file
16
services/agents-service/Dockerfile
Normal 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"]
|
||||
174
services/agents-service/README.md
Normal file
174
services/agents-service/README.md
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
222
services/agents-service/agent_executor.py
Normal file
222
services/agents-service/agent_executor.py
Normal 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
|
||||
|
||||
275
services/agents-service/agent_filter.py
Normal file
275
services/agents-service/agent_filter.py
Normal 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)
|
||||
|
||||
148
services/agents-service/agent_router.py
Normal file
148
services/agents-service/agent_router.py
Normal 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}"
|
||||
}
|
||||
|
||||
228
services/agents-service/main.py
Normal file
228
services/agents-service/main.py
Normal 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"
|
||||
)
|
||||
273
services/agents-service/models.py
Normal file
273
services/agents-service/models.py
Normal 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
|
||||
2
services/agents-service/nats_helpers/__init__.py
Normal file
2
services/agents-service/nats_helpers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# NATS module
|
||||
|
||||
162
services/agents-service/nats_helpers/publisher.py
Normal file
162
services/agents-service/nats_helpers/publisher.py
Normal 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
|
||||
})
|
||||
|
||||
77
services/agents-service/nats_helpers/subjects.py
Normal file
77
services/agents-service/nats_helpers/subjects.py
Normal 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}"
|
||||
|
||||
280
services/agents-service/nats_subscriber.py
Normal file
280
services/agents-service/nats_subscriber.py
Normal 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")
|
||||
|
||||
278
services/agents-service/quotas.py
Normal file
278
services/agents-service/quotas.py
Normal 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
|
||||
|
||||
297
services/agents-service/repository_agents.py
Normal file
297
services/agents-service/repository_agents.py
Normal 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']
|
||||
)
|
||||
|
||||
167
services/agents-service/repository_events.py
Normal file
167
services/agents-service/repository_events.py
Normal 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']
|
||||
)
|
||||
|
||||
7
services/agents-service/requirements.txt
Normal file
7
services/agents-service/requirements.txt
Normal 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
|
||||
264
services/agents-service/routes_agents.py
Normal file
264
services/agents-service/routes_agents.py
Normal 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
|
||||
|
||||
41
services/agents-service/routes_events.py
Normal file
41
services/agents-service/routes_events.py
Normal 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
|
||||
|
||||
215
services/agents-service/routes_invoke.py
Normal file
215
services/agents-service/routes_invoke.py
Normal 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
|
||||
)
|
||||
|
||||
177
services/agents-service/ws_events.py
Normal file
177
services/agents-service/ws_events.py
Normal 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 {})
|
||||
|
||||
Reference in New Issue
Block a user