snapshot: NODE1 production state 2026-02-09

Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.

Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles

Excluded from snapshot: venv/, .env, data/, backups, .tgz archives

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Apple
2026-02-09 08:46:46 -08:00
parent 134c044c21
commit ef3473db21
9473 changed files with 408933 additions and 2769877 deletions

View File

@@ -0,0 +1,468 @@
"""
AgentOps Metrics
================
Prometheus метрики для моніторингу агентів.
Метрики:
- Per-agent: latency, tokens, errors, tool calls, budget
- Per-channel: RAG hit rate, index lag
- Per-node: GPU util, VRAM, queue lag
"""
import time
import logging
from typing import Optional, Dict, Any
from contextlib import contextmanager
from functools import wraps
logger = logging.getLogger(__name__)
# Try to import prometheus_client
try:
from prometheus_client import (
Counter, Histogram, Gauge, Summary,
CollectorRegistry, generate_latest, CONTENT_TYPE_LATEST
)
PROMETHEUS_AVAILABLE = True
except ImportError:
PROMETHEUS_AVAILABLE = False
logger.warning("prometheus_client not installed, metrics disabled")
# ==================== REGISTRY ====================
if PROMETHEUS_AVAILABLE:
# Use default registry or create custom
REGISTRY = CollectorRegistry(auto_describe=True)
# ==================== AGENT METRICS ====================
# Request latency
AGENT_LATENCY = Histogram(
'agent_latency_seconds',
'Agent request latency in seconds',
['agent_id', 'operation'],
buckets=(0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
registry=REGISTRY
)
# Token usage
AGENT_TOKENS_IN = Counter(
'agent_tokens_in_total',
'Total input tokens processed by agent',
['agent_id', 'model'],
registry=REGISTRY
)
AGENT_TOKENS_OUT = Counter(
'agent_tokens_out_total',
'Total output tokens generated by agent',
['agent_id', 'model'],
registry=REGISTRY
)
# Request counts
AGENT_REQUESTS = Counter(
'agent_requests_total',
'Total agent requests',
['agent_id', 'status'], # status: success, error, timeout
registry=REGISTRY
)
# Error rate
AGENT_ERRORS = Counter(
'agent_errors_total',
'Total agent errors',
['agent_id', 'error_type'],
registry=REGISTRY
)
# Tool calls
AGENT_TOOL_CALLS = Counter(
'agent_tool_calls_total',
'Total tool calls by agent',
['agent_id', 'tool'],
registry=REGISTRY
)
# Budget consumption
AGENT_BUDGET = Gauge(
'agent_budget_consumed',
'Budget consumed by agent per user',
['agent_id', 'user_id'],
registry=REGISTRY
)
# Active requests
AGENT_ACTIVE_REQUESTS = Gauge(
'agent_active_requests',
'Number of active requests per agent',
['agent_id'],
registry=REGISTRY
)
# ==================== CHANNEL METRICS ====================
# RAG hit rate
CHANNEL_RAG_HITS = Counter(
'channel_rag_hits_total',
'Total RAG cache hits',
['channel_id'],
registry=REGISTRY
)
CHANNEL_RAG_MISSES = Counter(
'channel_rag_misses_total',
'Total RAG cache misses',
['channel_id'],
registry=REGISTRY
)
# Index lag
CHANNEL_INDEX_LAG = Gauge(
'channel_index_lag_seconds',
'Time since last index update',
['channel_id'],
registry=REGISTRY
)
# Message queue
CHANNEL_QUEUE_SIZE = Gauge(
'channel_queue_size',
'Number of messages in channel queue',
['channel_id'],
registry=REGISTRY
)
# ==================== NODE METRICS ====================
# GPU utilization
NODE_GPU_UTIL = Gauge(
'node_gpu_utilization',
'GPU utilization percentage',
['node_id', 'gpu_id'],
registry=REGISTRY
)
# VRAM usage
NODE_VRAM_USED = Gauge(
'node_vram_used_bytes',
'VRAM used in bytes',
['node_id', 'gpu_id'],
registry=REGISTRY
)
NODE_VRAM_TOTAL = Gauge(
'node_vram_total_bytes',
'Total VRAM in bytes',
['node_id', 'gpu_id'],
registry=REGISTRY
)
# Queue lag
NODE_QUEUE_LAG = Gauge(
'node_queue_lag_seconds',
'Queue processing lag',
['node_id', 'queue'],
registry=REGISTRY
)
# NATS stream lag
NODE_NATS_LAG = Gauge(
'node_nats_stream_lag',
'NATS stream consumer lag (pending messages)',
['node_id', 'stream'],
registry=REGISTRY
)
# ==================== MEMORY METRICS ====================
MEMORY_OPERATIONS = Counter(
'memory_operations_total',
'Total memory operations',
['operation', 'store'], # store: postgres, qdrant, neo4j, redis
registry=REGISTRY
)
MEMORY_LATENCY = Histogram(
'memory_latency_seconds',
'Memory operation latency',
['operation', 'store'],
buckets=(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0),
registry=REGISTRY
)
# ==================== HANDOFF METRICS ====================
HANDOFF_REQUESTS = Counter(
'handoff_requests_total',
'Total handoff requests between agents',
['from_agent', 'to_agent', 'status'],
registry=REGISTRY
)
HANDOFF_LATENCY = Histogram(
'handoff_latency_seconds',
'Handoff latency between agents',
['from_agent', 'to_agent'],
buckets=(0.1, 0.5, 1.0, 2.5, 5.0, 10.0),
registry=REGISTRY
)
# ==================== METRIC HELPERS ====================
class MetricsCollector:
"""
Helper class for collecting metrics.
Usage:
metrics = MetricsCollector(agent_id="helion")
with metrics.track_request("chat"):
# Do work
pass
metrics.record_tokens(input_tokens=100, output_tokens=50, model="qwen3:8b")
metrics.record_tool_call("web_search")
"""
def __init__(self, agent_id: str, node_id: str = "node1"):
self.agent_id = agent_id
self.node_id = node_id
self._enabled = PROMETHEUS_AVAILABLE
@contextmanager
def track_request(self, operation: str):
"""Context manager to track request latency"""
if not self._enabled:
yield
return
AGENT_ACTIVE_REQUESTS.labels(agent_id=self.agent_id).inc()
start = time.time()
status = "success"
try:
yield
except Exception as e:
status = "error"
AGENT_ERRORS.labels(
agent_id=self.agent_id,
error_type=type(e).__name__
).inc()
raise
finally:
duration = time.time() - start
AGENT_LATENCY.labels(
agent_id=self.agent_id,
operation=operation
).observe(duration)
AGENT_REQUESTS.labels(
agent_id=self.agent_id,
status=status
).inc()
AGENT_ACTIVE_REQUESTS.labels(agent_id=self.agent_id).dec()
def record_tokens(self, input_tokens: int, output_tokens: int, model: str):
"""Record token usage"""
if not self._enabled:
return
AGENT_TOKENS_IN.labels(
agent_id=self.agent_id,
model=model
).inc(input_tokens)
AGENT_TOKENS_OUT.labels(
agent_id=self.agent_id,
model=model
).inc(output_tokens)
def record_tool_call(self, tool: str):
"""Record tool call"""
if not self._enabled:
return
AGENT_TOOL_CALLS.labels(
agent_id=self.agent_id,
tool=tool
).inc()
def record_budget(self, user_id: str, amount: float):
"""Record budget consumption"""
if not self._enabled:
return
AGENT_BUDGET.labels(
agent_id=self.agent_id,
user_id=user_id
).set(amount)
def record_error(self, error_type: str):
"""Record error"""
if not self._enabled:
return
AGENT_ERRORS.labels(
agent_id=self.agent_id,
error_type=error_type
).inc()
def record_rag_hit(self, channel_id: str, hit: bool):
"""Record RAG cache hit/miss"""
if not self._enabled:
return
if hit:
CHANNEL_RAG_HITS.labels(channel_id=channel_id).inc()
else:
CHANNEL_RAG_MISSES.labels(channel_id=channel_id).inc()
def record_memory_operation(self, operation: str, store: str, duration: float):
"""Record memory operation"""
if not self._enabled:
return
MEMORY_OPERATIONS.labels(operation=operation, store=store).inc()
MEMORY_LATENCY.labels(operation=operation, store=store).observe(duration)
def record_handoff(self, to_agent: str, status: str, duration: float = None):
"""Record handoff between agents"""
if not self._enabled:
return
HANDOFF_REQUESTS.labels(
from_agent=self.agent_id,
to_agent=to_agent,
status=status
).inc()
if duration is not None:
HANDOFF_LATENCY.labels(
from_agent=self.agent_id,
to_agent=to_agent
).observe(duration)
def track_agent_request(agent_id: str, operation: str):
"""Decorator for tracking agent requests"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
metrics = MetricsCollector(agent_id)
with metrics.track_request(operation):
return await func(*args, **kwargs)
return wrapper
return decorator
# ==================== GPU METRICS COLLECTOR ====================
async def collect_gpu_metrics(node_id: str = "node1"):
"""
Collect GPU metrics using nvidia-smi.
Should be called periodically.
"""
if not PROMETHEUS_AVAILABLE:
return
import subprocess
try:
# Run nvidia-smi
result = subprocess.run(
['nvidia-smi', '--query-gpu=index,utilization.gpu,memory.used,memory.total',
'--format=csv,noheader,nounits'],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return
for line in result.stdout.strip().split('\n'):
parts = line.split(',')
if len(parts) >= 4:
gpu_id = parts[0].strip()
util = float(parts[1].strip())
mem_used = int(parts[2].strip()) * 1024 * 1024 # MB to bytes
mem_total = int(parts[3].strip()) * 1024 * 1024
NODE_GPU_UTIL.labels(node_id=node_id, gpu_id=gpu_id).set(util / 100.0)
NODE_VRAM_USED.labels(node_id=node_id, gpu_id=gpu_id).set(mem_used)
NODE_VRAM_TOTAL.labels(node_id=node_id, gpu_id=gpu_id).set(mem_total)
except Exception as e:
logger.debug(f"GPU metrics collection failed: {e}")
# ==================== FASTAPI INTEGRATION ====================
def setup_metrics_endpoint(app):
"""
Add /metrics endpoint to FastAPI app.
Usage:
from agent_metrics import setup_metrics_endpoint
setup_metrics_endpoint(app)
"""
if not PROMETHEUS_AVAILABLE:
logger.warning("Prometheus not available, /metrics endpoint disabled")
return
from fastapi import Response
@app.get("/metrics")
async def metrics():
return Response(
content=generate_latest(REGISTRY),
media_type=CONTENT_TYPE_LATEST
)
logger.info("Metrics endpoint enabled at /metrics")
# ==================== NATS STREAM LAG COLLECTOR ====================
async def collect_nats_metrics(node_id: str = "node1", nats_url: str = "nats://nats:4222"):
"""
Collect NATS JetStream metrics.
"""
if not PROMETHEUS_AVAILABLE:
return
try:
import nats
nc = await nats.connect(nats_url)
js = nc.jetstream()
# Get stream info
streams = ["MESSAGES", "AGENT_OPS", "AUDIT", "MEMORY"]
for stream_name in streams:
try:
info = await js.stream_info(stream_name)
# Record pending messages as lag indicator
pending = info.state.messages
NODE_NATS_LAG.labels(node_id=node_id, stream=stream_name).set(pending)
except:
pass
await nc.close()
except Exception as e:
logger.debug(f"NATS metrics collection failed: {e}")
# ==================== METRICS EXPORT ====================
def get_metrics():
"""Return metrics in Prometheus format"""
if not PROMETHEUS_AVAILABLE:
return b"# prometheus_client not available"
return generate_latest(REGISTRY)
def get_content_type():
"""Return Prometheus content type"""
if not PROMETHEUS_AVAILABLE:
return "text/plain"
return CONTENT_TYPE_LATEST

View File

@@ -0,0 +1,125 @@
"""
Per-agent tool configuration.
All agents have FULL standard stack + specialized tools.
Each agent is a platform with own site, channels, database, users.
"""
# FULL standard stack - available to ALL agents
FULL_STANDARD_STACK = [
# Search & Knowledge (Priority 1)
"memory_search",
"graph_query",
# Web Research (Priority 2)
"web_search",
"web_extract",
"crawl4ai_scrape",
# Memory
"remember_fact",
# Content Generation
"image_generate",
"tts_speak",
# Presentations
"presentation_create",
"presentation_status",
"presentation_download",
]
# Specialized tools per agent (on top of standard stack)
AGENT_SPECIALIZED_TOOLS = {
# Helion - Energy platform
# Specialized: energy calculations, solar/wind analysis
"helion": [],
# Alateya - R&D Lab OS
# Specialized: experiment tracking, hypothesis testing
"alateya": [],
# Nutra - Health & Nutrition
# Specialized: nutrition calculations, supplement analysis
"nutra": [],
# AgroMatrix - Agriculture
# Specialized: crop analysis, weather integration, field mapping
"agromatrix": [],
# GreenFood - Food & Eco
# Specialized: recipe analysis, eco-scoring
"greenfood": [],
# Druid - Knowledge Search
# Specialized: deep RAG, document comparison
"druid": [],
# DaarWizz - DAO Coordination
# Specialized: governance tools, voting, treasury
"daarwizz": [],
# Clan - Community
# Specialized: event management, polls, member tracking
"clan": [],
# Eonarch - Philosophy & Evolution
# Specialized: concept mapping, timeline analysis
"eonarch": [],
}
# CrewAI team structure per agent (future implementation)
AGENT_CREW_TEAMS = {
"helion": {
"team_name": "Energy Specialists",
"agents": ["analyst", "engineer", "market_researcher", "communicator"]
},
"alateya": {
"team_name": "Research Professors",
"agents": ["prof_erudite", "prof_analyst", "prof_creative", "prof_optimizer", "prof_communicator"]
},
"nutra": {
"team_name": "Health Advisors",
"agents": ["nutritionist", "biochemist", "fitness_coach", "communicator"]
},
"agromatrix": {
"team_name": "Agro Experts",
"agents": ["agronomist", "soil_specialist", "weather_analyst", "market_analyst"]
},
"greenfood": {
"team_name": "Food & Eco Team",
"agents": ["chef", "nutritionist", "eco_analyst", "supply_chain"]
},
"druid": {
"team_name": "Knowledge Seekers",
"agents": ["researcher", "fact_checker", "synthesizer", "archivist"]
},
"daarwizz": {
"team_name": "DAO Operations",
"agents": ["governance", "treasury", "community", "tech_ops"]
},
"clan": {
"team_name": "Community Spirits",
"agents": ["welcomer", "mediator", "event_organizer", "historian"]
},
"eonarch": {
"team_name": "Consciousness Guides",
"agents": ["philosopher", "futurist", "integrator", "storyteller"]
},
}
def get_agent_tools(agent_id: str) -> list:
"""Get all tools for an agent: standard stack + specialized."""
specialized = AGENT_SPECIALIZED_TOOLS.get(agent_id, [])
return FULL_STANDARD_STACK + specialized
def is_tool_allowed(agent_id: str, tool_name: str) -> bool:
"""Check if a tool is allowed for an agent."""
allowed = get_agent_tools(agent_id)
return tool_name in allowed
def get_agent_crew(agent_id: str) -> dict:
"""Get CrewAI team configuration for an agent."""
return AGENT_CREW_TEAMS.get(agent_id, {"team_name": "Default", "agents": []})

View File

@@ -0,0 +1,257 @@
# ================================================
# DAARION Platform - Agent Registry v1.0
# ================================================
platform:
name: "MicroDAO Daarion"
version: "1.0.0"
default_agent: "helion"
fallback_agent: "helion"
# ================================================
# REGISTERED AGENTS
# ================================================
agents:
helion:
display_name: "Helion"
description: "Energy Union AI - енергетика, інфраструктура, DePIN"
domain_tags:
- energy
- power
- grid
- depin
- infrastructure
- sensors
- tariffs
- dao
capabilities:
- chat
- stt
- tts
- vision
- image_gen
- web_search
- web_scrape
- document
entrypoint:
type: "nats"
subject: "agent.helion.invoke"
memory_policy:
qdrant_collections:
read: ["helion_messages", "helion_docs", "helion_artifacts", "helion_memory_items"]
write: ["helion_messages", "helion_docs", "helion_artifacts", "helion_memory_items"]
neo4j_labels: ["HelionUser", "HelionTopic", "HelionProject", "HelionChannel"]
neo4j_filter: "agent_id = 'helion'"
redis_prefix: "helion:"
data_policy:
handoff_allow: ["nutra", "greenfood"] # Can share context with these agents
handoff_fields: ["user_intent", "context_summary", "language"]
sensitive_fields_block: ["api_keys", "internal_metrics", "wallet_addresses"]
sla:
priority: "high"
rate_limit_rpm: 60
max_tokens_per_request: 8000
prompt_file: "/app/prompts/helion_prompt.txt"
telegram_token_env: "HELION_BOT_TOKEN"
active: true
nutra:
display_name: "Nutra"
description: "AI Nutrition Agent - харчування, здоров'я, дієти"
domain_tags:
- nutrition
- food
- diet
- health
- wellness
- recipes
- allergens
- nutrients
- calories
capabilities:
- chat
- vision
- document
entrypoint:
type: "nats"
subject: "agent.nutra.invoke"
memory_policy:
qdrant_collections:
read: ["nutra_messages", "nutra_docs", "nutra_food_knowledge", "nutra_memory_items"]
write: ["nutra_messages", "nutra_docs", "nutra_food_knowledge", "nutra_memory_items"]
neo4j_labels: ["NutraUser", "NutraGoal", "NutraProduct", "NutraRecipe", "NutraRestriction"]
neo4j_filter: "agent_id = 'nutra'"
redis_prefix: "nutra:"
data_policy:
handoff_allow: ["helion"]
handoff_fields: ["user_intent", "context_summary", "language", "dietary_preferences"]
sensitive_fields_block: ["medical_history", "health_conditions"]
sla:
priority: "medium"
rate_limit_rpm: 30
max_tokens_per_request: 4000
prompt_file: "/app/prompts/nutra_prompt.txt"
telegram_token_env: "NUTRA_BOT_TOKEN"
active: true
greenfood:
display_name: "GreenFood"
description: "GreenFood DAO Agent - органічні продукти, ферми"
domain_tags:
- organic
- farming
- sustainable
- local_food
- farmers_market
capabilities:
- chat
entrypoint:
type: "nats"
subject: "agent.greenfood.invoke"
memory_policy:
qdrant_collections:
read: ["greenfood_messages", "greenfood_docs"]
write: ["greenfood_messages", "greenfood_docs"]
neo4j_labels: ["GreenFoodUser", "GreenFoodFarm", "GreenFoodProduct"]
neo4j_filter: "agent_id = 'greenfood'"
redis_prefix: "greenfood:"
data_policy:
handoff_allow: ["nutra", "helion"]
handoff_fields: ["user_intent", "context_summary"]
sensitive_fields_block: []
sla:
priority: "low"
rate_limit_rpm: 20
max_tokens_per_request: 4000
prompt_file: "/app/prompts/greenfood_prompt.txt"
telegram_token_env: "GREENFOOD_BOT_TOKEN"
active: true
druid:
display_name: "Druid"
description: "Legal/Compliance Agent - юридичні питання, регуляція"
domain_tags:
- legal
- compliance
- contracts
- regulations
- kyc
capabilities:
- chat
- document
entrypoint:
type: "nats"
subject: "agent.druid.invoke"
memory_policy:
qdrant_collections:
read: ["druid_messages", "druid_docs", "druid_legal_kb"]
write: ["druid_messages", "druid_docs"]
neo4j_labels: ["DruidUser", "DruidContract", "DruidRegulation"]
neo4j_filter: "agent_id = 'druid'"
redis_prefix: "druid:"
data_policy:
handoff_allow: ["helion"]
handoff_fields: ["user_intent", "context_summary", "jurisdiction"]
sensitive_fields_block: ["personal_id", "contracts_content"]
sla:
priority: "medium"
rate_limit_rpm: 20
max_tokens_per_request: 8000
prompt_file: "/app/prompts/druid_prompt.txt"
telegram_token_env: "DRUID_BOT_TOKEN"
active: false
daarwizz:
display_name: "DaarWizz"
description: "Operations/DevOps Agent - інфраструктура, моніторинг"
domain_tags:
- devops
- infrastructure
- monitoring
- deployment
- servers
capabilities:
- chat
- web_scrape
entrypoint:
type: "nats"
subject: "agent.daarwizz.invoke"
memory_policy:
qdrant_collections:
read: ["daarwizz_messages", "daarwizz_docs"]
write: ["daarwizz_messages", "daarwizz_docs"]
neo4j_labels: ["DaarwizzServer", "DaarwizzService", "DaarwizzAlert"]
neo4j_filter: "agent_id = 'daarwizz'"
redis_prefix: "daarwizz:"
data_policy:
handoff_allow: ["helion"]
handoff_fields: ["user_intent", "context_summary"]
sensitive_fields_block: ["credentials", "ssh_keys", "api_secrets"]
sla:
priority: "high"
rate_limit_rpm: 100
max_tokens_per_request: 4000
prompt_file: "/app/prompts/daarwizz_prompt.txt"
telegram_token_env: "DAARWIZZ_BOT_TOKEN"
active: false
# ================================================
# INTENT ROUTING RULES
# ================================================
routing:
# Hard routes (explicit commands/channels)
hard_routes:
- pattern: "^/nutra"
agent: "nutra"
- pattern: "^/helion"
agent: "helion"
- pattern: "^/greenfood"
agent: "greenfood"
- pattern: "^/legal"
agent: "druid"
- pattern: "^/ops"
agent: "daarwizz"
# Intent-based routing (keyword matching)
intent_routes:
- keywords: ["їжа", "продукт", "рецепт", "дієта", "калорі", "харчування", "вітамін",
"food", "recipe", "diet", "nutrition", "calorie", "vitamin", "meal"]
agent: "nutra"
confidence_threshold: 0.7
- keywords: ["енергія", "електрика", "сонячн", "вітер", "батаре", "тариф", "мережа",
"energy", "power", "solar", "wind", "battery", "grid", "tariff", "depin"]
agent: "helion"
confidence_threshold: 0.7
- keywords: ["органіч", "ферма", "фермер", "городни", "sustainable", "organic", "farm"]
agent: "greenfood"
confidence_threshold: 0.6
- keywords: ["юрист", "договір", "контракт", "legal", "compliance", "contract", "kyc"]
agent: "druid"
confidence_threshold: 0.8
# Fallback behavior
fallback:
agent: "helion"
message: "Передаю до основного асистента Helion."
# ================================================
# HANDOFF CONTRACT TEMPLATE
# ================================================
handoff_contract:
required_fields:
- user_intent
- source_agent
- target_agent
- timestamp
optional_fields:
- context_summary
- language
- user_preferences
- references
- constraints
- reason_for_handoff
max_context_tokens: 500
strip_sensitive: true

View File

@@ -0,0 +1,145 @@
"""
CrewAI Client for Router
Handles decision: direct LLM vs CrewAI orchestration
"""
import os
import json
import logging
import httpx
from typing import Dict, Any, Optional, Tuple, List
from pathlib import Path
logger = logging.getLogger(__name__)
CREWAI_URL = os.getenv("CREWAI_URL", "http://dagi-staging-crewai-service:9010")
CREWAI_ENABLED = os.getenv("CREWAI_ENABLED", "true").lower() == "true"
CREWAI_AGENTS_PATH = os.getenv("CREWAI_AGENTS_PATH", "/config/crewai_agents.json")
FALLBACK_CREWAI_PATH = "/app/config/crewai_agents.json"
MIN_PROMPT_LENGTH_FOR_CREW = 100
COMPLEXITY_KEYWORDS = [
"план", "plan", "аналіз", "analysis", "дослідження", "research",
"порівняй", "compare", "розроби", "develop", "створи стратегію",
"декомпозиція", "decompose", "крок за кроком", "step by step",
"детально", "in detail", "комплексний", "comprehensive"
]
_crewai_cache = None
def load_crewai_config():
global _crewai_cache
if _crewai_cache is not None:
return _crewai_cache
for path in [CREWAI_AGENTS_PATH, FALLBACK_CREWAI_PATH]:
try:
p = Path(path)
if p.exists():
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info(f"Loaded CrewAI config from {path}")
_crewai_cache = data
return data
except Exception as e:
logger.warning(f"Could not load from {path}: {e}")
return None
def get_agent_crewai_info(agent_id):
config = load_crewai_config()
if not config:
return {"enabled": False, "orchestrator": False, "team": []}
orchestrators = config.get("orchestrators", [])
teams = config.get("teams", {})
is_orchestrator = any(o.get("id") == agent_id for o in orchestrators)
team_info = teams.get(agent_id, {})
team_members = team_info.get("members", [])
return {
"enabled": is_orchestrator,
"orchestrator": is_orchestrator,
"team": team_members
}
def should_use_crewai(agent_id, prompt, agent_config, force_crewai=False):
"""
Decide whether to use CrewAI orchestration or direct LLM.
Returns: (use_crewai: bool, reason: str)
"""
if not CREWAI_ENABLED:
return False, "crewai_disabled_globally"
if force_crewai:
return True, "force_crewai_requested"
crewai_info = get_agent_crewai_info(agent_id)
if not crewai_info.get("enabled", False):
return False, "agent_crewai_disabled"
if not crewai_info.get("orchestrator", False):
return False, "agent_not_orchestrator"
team = crewai_info.get("team", [])
if not team:
return False, "agent_has_no_team"
if len(prompt) < MIN_PROMPT_LENGTH_FOR_CREW:
return False, "prompt_too_short"
prompt_lower = prompt.lower()
has_complexity = any(kw in prompt_lower for kw in COMPLEXITY_KEYWORDS)
if has_complexity:
return True, "complexity_keywords_detected"
return False, "default_direct_llm"
async def call_crewai(agent_id, task, context=None, team=None):
try:
if not team:
crewai_info = get_agent_crewai_info(agent_id)
team = crewai_info.get("team", [])
async with httpx.AsyncClient(timeout=180.0) as client:
payload = {
"task": task,
"orchestrator": agent_id,
"context": context or {},
}
if team:
payload["team"] = [
m.get("role", str(m)) if isinstance(m, dict) else m
for m in team
]
logger.info(f"CrewAI call: agent={agent_id}, team={len(team)} members")
response = await client.post(f"{CREWAI_URL}/crew/run", json=payload)
if response.status_code == 200:
data = response.json()
success = data.get("success")
logger.info(f"CrewAI response: success={success}")
return data
else:
logger.error(f"CrewAI error: {response.status_code}")
return {"success": False, "result": None, "agents_used": [], "error": f"HTTP {response.status_code}"}
except Exception as e:
logger.error(f"CrewAI exception: {e}")
return {"success": False, "result": None, "agents_used": [], "error": str(e)}
async def get_crewai_health():
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{CREWAI_URL}/health")
return response.json()
except Exception as e:
return {"status": "error", "error": str(e)}

View File

@@ -0,0 +1,91 @@
"""
DAARION Platform - Agent Handoff Contract
Manages secure context transfer between agents
"""
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Optional, List, Dict, Any
from enum import Enum
import uuid
import structlog
logger = structlog.get_logger()
class HandoffReason(Enum):
DOMAIN_MISMATCH = "domain_mismatch"
USER_REQUEST = "user_request"
CAPABILITY_REQUIRED = "capability_required"
ESCALATION = "escalation"
@dataclass
class HandoffContract:
"""Secure contract for transferring context between agents."""
handoff_id: str = field(default_factory=lambda: str(uuid.uuid4()))
source_agent: str = ""
target_agent: str = ""
user_intent: str = ""
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
context_summary: Optional[str] = None
language: str = "uk"
user_id: Optional[str] = None
channel_id: Optional[str] = None
constraints: Dict[str, Any] = field(default_factory=dict)
references: List[str] = field(default_factory=list)
reason: HandoffReason = HandoffReason.DOMAIN_MISMATCH
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["reason"] = self.reason.value
return d
class HandoffManager:
"""Manages agent handoffs with policy enforcement"""
def __init__(self, registry: Dict[str, Any]):
self.registry = registry
self._active_handoffs: Dict[str, HandoffContract] = {}
def can_handoff(self, source_agent: str, target_agent: str) -> bool:
source_config = self.registry.get("agents", {}).get(source_agent, {})
data_policy = source_config.get("data_policy", {})
allowed = data_policy.get("handoff_allow", [])
return target_agent in allowed
def create_handoff(
self,
source_agent: str,
target_agent: str,
user_intent: str,
context_summary: Optional[str] = None,
language: str = "uk",
reason: HandoffReason = HandoffReason.DOMAIN_MISMATCH
) -> Optional[HandoffContract]:
if not self.can_handoff(source_agent, target_agent):
logger.warning("handoff_blocked", source=source_agent, target=target_agent)
return None
contract = HandoffContract(
source_agent=source_agent,
target_agent=target_agent,
user_intent=user_intent[:500],
context_summary=context_summary[:1000] if context_summary else None,
language=language,
reason=reason
)
self._active_handoffs[contract.handoff_id] = contract
logger.info("handoff_created", handoff_id=contract.handoff_id)
return contract
def get_user_message(self, target_agent: str) -> str:
names = {
"helion": "Helion",
"nutra": "Nutra",
"greenfood": "GreenFood",
"druid": "Druid"
}
name = names.get(target_agent, target_agent)
return f"Передаю ваше питання до {name}."

View File

@@ -0,0 +1,161 @@
"""
DAARION Platform - Intent Router
Routes messages to appropriate agents based on content analysis
"""
import re
import yaml
from typing import Optional, Dict, List, Tuple
from pathlib import Path
import structlog
logger = structlog.get_logger()
class IntentRouter:
"""Routes messages to agents based on intent detection"""
def __init__(self, registry_path: str = "agents_registry.yaml"):
self.registry_path = Path(registry_path)
self.registry = self._load_registry()
self.hard_routes = self._compile_hard_routes()
self.intent_keywords = self._build_keyword_index()
logger.info("intent_router_initialized",
agents=len(self.registry.get("agents", {})),
hard_routes=len(self.hard_routes))
def _load_registry(self) -> Dict:
"""Load agent registry from YAML"""
if not self.registry_path.exists():
logger.warning("registry_not_found", path=str(self.registry_path))
return {"agents": {}, "routing": {}}
with open(self.registry_path) as f:
return yaml.safe_load(f)
def _compile_hard_routes(self) -> List[Tuple[re.Pattern, str]]:
"""Compile regex patterns for hard routes"""
routes = []
for route in self.registry.get("routing", {}).get("hard_routes", []):
pattern = re.compile(route["pattern"], re.IGNORECASE)
routes.append((pattern, route["agent"]))
return routes
def _build_keyword_index(self) -> Dict[str, List[Tuple[str, float]]]:
"""Build keyword → agent mapping with confidence"""
index = {}
for route in self.registry.get("routing", {}).get("intent_routes", []):
agent = route["agent"]
threshold = route.get("confidence_threshold", 0.5)
for keyword in route["keywords"]:
kw = keyword.lower()
if kw not in index:
index[kw] = []
index[kw].append((agent, threshold))
return index
def route(self, message: str, source_agent: Optional[str] = None) -> Tuple[str, float, str]:
"""
Route message to appropriate agent.
Returns:
(agent_id, confidence, reason)
"""
message_lower = message.lower().strip()
# 1. Check hard routes first (commands)
for pattern, agent in self.hard_routes:
if pattern.match(message):
logger.info("hard_route_matched", agent=agent, pattern=pattern.pattern)
return (agent, 1.0, "hard_route")
# 2. Intent-based routing (keyword matching)
agent_scores = {}
matched_keywords = {}
for keyword, agents in self.intent_keywords.items():
if keyword in message_lower:
for agent, threshold in agents:
if agent not in agent_scores:
agent_scores[agent] = 0.0
matched_keywords[agent] = []
agent_scores[agent] += threshold
matched_keywords[agent].append(keyword)
if agent_scores:
# Normalize scores
max_score = max(agent_scores.values())
for agent in agent_scores:
agent_scores[agent] /= max(1, len(matched_keywords[agent]))
# Select best agent
best_agent = max(agent_scores, key=agent_scores.get)
confidence = min(agent_scores[best_agent], 1.0)
# Check if active
if self.is_agent_active(best_agent):
logger.info("intent_route_matched",
agent=best_agent,
confidence=confidence,
keywords=matched_keywords[best_agent])
return (best_agent, confidence, f"keywords: {matched_keywords[best_agent][:3]}")
# 3. Fallback
fallback = self.registry.get("routing", {}).get("fallback", {})
fallback_agent = fallback.get("agent", "helion")
logger.info("fallback_route", agent=fallback_agent)
return (fallback_agent, 0.3, "fallback")
def is_agent_active(self, agent_id: str) -> bool:
"""Check if agent is active"""
agent = self.registry.get("agents", {}).get(agent_id)
return agent and agent.get("active", False)
def get_agent_config(self, agent_id: str) -> Optional[Dict]:
"""Get full agent configuration"""
return self.registry.get("agents", {}).get(agent_id)
def get_memory_policy(self, agent_id: str) -> Dict:
"""Get agent memory access policy"""
agent = self.get_agent_config(agent_id)
if not agent:
return {}
return agent.get("memory_policy", {})
def get_data_policy(self, agent_id: str) -> Dict:
"""Get agent data sharing policy"""
agent = self.get_agent_config(agent_id)
if not agent:
return {}
return agent.get("data_policy", {})
def can_handoff(self, source_agent: str, target_agent: str) -> bool:
"""Check if handoff is allowed between agents"""
policy = self.get_data_policy(source_agent)
allowed = policy.get("handoff_allow", [])
return target_agent in allowed
def get_rate_limit(self, agent_id: str) -> int:
"""Get rate limit (requests per minute) for agent"""
agent = self.get_agent_config(agent_id)
if not agent:
return 30 # default
return agent.get("sla", {}).get("rate_limit_rpm", 30)
def list_active_agents(self) -> List[str]:
"""List all active agents"""
return [
agent_id
for agent_id, config in self.registry.get("agents", {}).items()
if config.get("active", False)
]
# Singleton instance
_router_instance = None
def get_intent_router() -> IntentRouter:
"""Get or create intent router instance"""
global _router_instance
if _router_instance is None:
_router_instance = IntentRouter()
return _router_instance

View File

@@ -1,4 +1,5 @@
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import Literal, Optional, Dict, Any, List
import asyncio
@@ -7,6 +8,16 @@ import os
import yaml
import httpx
import logging
import time # For latency metrics
# CrewAI Integration
try:
from crewai_client import should_use_crewai, call_crewai, get_crewai_health
CREWAI_CLIENT_AVAILABLE = True
except ImportError:
CREWAI_CLIENT_AVAILABLE = False
should_use_crewai = None
call_crewai = None
from neo4j import AsyncGraphDatabase
# Memory Retrieval Pipeline v3.0
@@ -41,6 +52,10 @@ OCR_URL = os.getenv("OCR_URL", "http://swapper-service:8890") # Swapper /ocr en
DOCUMENT_URL = os.getenv("DOCUMENT_URL", "http://swapper-service:8890") # Swapper /document endpoint
CITY_SERVICE_URL = os.getenv("CITY_SERVICE_URL", "http://daarion-city-service:7001")
# CrewAI Routing Configuration
CREWAI_ROUTING_ENABLED = os.getenv("CREWAI_ROUTING_ENABLED", "true").lower() == "true"
CREWAI_URL = os.getenv("CREWAI_URL", "http://dagi-staging-crewai-service:9010")
# Neo4j Configuration
NEO4J_URI = os.getenv("NEO4J_BOLT_URL", "bolt://neo4j:7687")
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
@@ -269,6 +284,21 @@ async def publish_agent_invocation(invocation: AgentInvocation):
else:
print(f"⚠️ NATS not available, invocation not published: {invocation.json()}")
# ==============================================================
# PROMETHEUS METRICS ENDPOINT
# ==============================================================
@app.get("/metrics")
async def prometheus_metrics():
"""Prometheus metrics endpoint."""
try:
from agent_metrics import get_metrics, get_content_type
return Response(content=get_metrics(), media_type=get_content_type())
except Exception as e:
logger.error(f"Metrics error: {e}")
return Response(content=b"# Error generating metrics", media_type="text/plain")
@app.get("/health")
async def health():
"""Health check endpoint"""
@@ -346,6 +376,31 @@ class InferResponse(BaseModel):
image_base64: Optional[str] = None # Generated image in base64 format
# =========================================================================
# INTERNAL LLM API (for CrewAI and internal services)
# =========================================================================
class InternalLLMRequest(BaseModel):
prompt: str
system_prompt: Optional[str] = None
llm_profile: Optional[str] = "reasoning"
model: Optional[str] = None
max_tokens: Optional[int] = 2048
temperature: Optional[float] = 0.2
role_context: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class InternalLLMResponse(BaseModel):
text: str
model: str
provider: str
tokens_used: int = 0
latency_ms: int = 0
class BackendStatus(BaseModel):
"""Status of a backend service"""
name: str
@@ -447,6 +502,100 @@ async def get_backends_status():
return backends
# =========================================================================
# INTERNAL LLM COMPLETE ENDPOINT (for CrewAI)
# =========================================================================
@app.post("/internal/llm/complete", response_model=InternalLLMResponse)
async def internal_llm_complete(request: InternalLLMRequest):
"""
Internal LLM completion endpoint.
NO routing, NO CrewAI decision, NO agent selection.
Used by CrewAI service for multi-role orchestration.
"""
import time as time_module
t0 = time_module.time()
logger.info(f"Internal LLM: profile={request.llm_profile}, role={request.role_context}")
# Get LLM profile configuration
llm_profiles = router_config.get("llm_profiles", {})
profile_name = request.llm_profile or "reasoning"
llm_profile = llm_profiles.get(profile_name, {})
provider = llm_profile.get("provider", "deepseek")
model = request.model or llm_profile.get("model", "deepseek-chat")
max_tokens = request.max_tokens or llm_profile.get("max_tokens", 2048)
temperature = request.temperature or llm_profile.get("temperature", 0.2)
# Build messages
messages = []
if request.system_prompt:
system_content = request.system_prompt
if request.role_context:
system_content = f"[Role: {request.role_context}]\n\n{system_content}"
messages.append({"role": "system", "content": system_content})
elif request.role_context:
messages.append({"role": "system", "content": f"You are acting as {request.role_context}. Respond professionally."})
messages.append({"role": "user", "content": request.prompt})
# Cloud providers
cloud_providers = [
{"name": "deepseek", "api_key_env": "DEEPSEEK_API_KEY", "base_url": "https://api.deepseek.com", "model": "deepseek-chat", "timeout": 60},
{"name": "mistral", "api_key_env": "MISTRAL_API_KEY", "base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "timeout": 60},
{"name": "grok", "api_key_env": "GROK_API_KEY", "base_url": "https://api.x.ai", "model": "grok-2-1212", "timeout": 60}
]
if provider in ["deepseek", "mistral", "grok"]:
cloud_providers = sorted(cloud_providers, key=lambda x: 0 if x["name"] == provider else 1)
# Try cloud providers
for cloud in cloud_providers:
api_key = os.getenv(cloud["api_key_env"])
if not api_key:
continue
try:
logger.debug(f"Internal LLM trying {cloud['name']}")
cloud_resp = await http_client.post(
f"{cloud['base_url']}/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": cloud["model"], "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "stream": False},
timeout=cloud["timeout"]
)
if cloud_resp.status_code == 200:
data = cloud_resp.json()
response_text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
tokens = data.get("usage", {}).get("total_tokens", 0)
latency = int((time_module.time() - t0) * 1000)
logger.info(f"Internal LLM success: {cloud['name']}, {tokens} tokens, {latency}ms")
return InternalLLMResponse(text=response_text, model=cloud["model"], provider=cloud["name"], tokens_used=tokens, latency_ms=latency)
except Exception as e:
logger.warning(f"Internal LLM {cloud['name']} failed: {e}")
continue
# Fallback to Ollama
try:
logger.info("Internal LLM fallback to Ollama")
ollama_resp = await http_client.post(
"http://172.18.0.1:11434/api/generate",
json={"model": "qwen3:8b", "prompt": request.prompt, "system": request.system_prompt or "", "stream": False, "options": {"num_predict": max_tokens, "temperature": temperature}},
timeout=120.0
)
if ollama_resp.status_code == 200:
data = ollama_resp.json()
latency = int((time_module.time() - t0) * 1000)
return InternalLLMResponse(text=data.get("response", ""), model="qwen3:8b", provider="ollama", tokens_used=0, latency_ms=latency)
except Exception as e:
logger.error(f"Internal LLM Ollama failed: {e}")
raise HTTPException(status_code=503, detail="All LLM providers unavailable")
@app.post("/v1/agents/{agent_id}/infer", response_model=InferResponse)
async def agent_infer(agent_id: str, request: InferRequest):
"""
@@ -519,9 +668,73 @@ async def agent_infer(agent_id: str, request: InferRequest):
system_prompt = agent_config.get("system_prompt")
# Determine which backend to use
# Use router config to get default model for agent, fallback to qwen3-8b
# Use router config to get default model for agent, fallback to qwen3:8b
agent_config = router_config.get("agents", {}).get(agent_id, {})
default_llm = agent_config.get("default_llm", "qwen3-8b")
# =========================================================================
# CREWAI DECISION: Use orchestration or direct LLM?
# =========================================================================
if CREWAI_ROUTING_ENABLED and CREWAI_CLIENT_AVAILABLE:
try:
# Get agent CrewAI config from registry (or router_config fallback)
crewai_cfg = agent_config.get("crewai", {})
use_crewai, crewai_reason = should_use_crewai(
agent_id=agent_id,
prompt=request.prompt,
agent_config=agent_config,
force_crewai=request.metadata.get("force_crewai", False) if request.metadata else False,
)
logger.info(f"🎭 CrewAI decision for {agent_id}: {use_crewai} ({crewai_reason})")
if use_crewai:
t0 = time.time()
crew_result = await call_crewai(
agent_id=agent_id,
task=request.prompt,
context={
"memory_brief": memory_brief_text,
"system_prompt": system_prompt,
"metadata": metadata,
},
team=crewai_cfg.get("team")
)
latency = time.time() - t0
if crew_result.get("success") and crew_result.get("result"):
logger.info(f"✅ CrewAI success for {agent_id}: {latency:.2f}s")
# Store interaction in memory
if MEMORY_RETRIEVAL_AVAILABLE and memory_retrieval and chat_id and user_id:
try:
await memory_retrieval.store_interaction(
channel=channel,
chat_id=chat_id,
user_id=user_id,
agent_id=request_agent_id,
username=username,
user_message=request.prompt,
assistant_response=crew_result["result"]
)
except Exception as e:
logger.warning(f"⚠️ Memory storage failed: {e}")
return InferResponse(
response=crew_result["result"],
model="crewai-" + agent_id,
provider="crewai",
tokens_used=0,
latency_ms=int(latency * 1000)
)
else:
logger.warning(f"⚠️ CrewAI failed, falling back to direct LLM")
except Exception as e:
logger.exception(f"❌ CrewAI error: {e}, falling back to direct LLM")
default_llm = agent_config.get("default_llm", "qwen3:8b")
# Check if there's a routing rule for this agent
routing_rules = router_config.get("routing", [])
@@ -542,7 +755,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
model = llm_profile.get("model", "deepseek-chat")
else:
# For local ollama, use swapper model name format
model = request.model or "qwen3-8b"
model = request.model or "qwen3:8b"
# =========================================================================
# VISION PROCESSING (if images present)
@@ -929,9 +1142,9 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Check if default_llm is local
if llm_profile.get("provider") == "ollama":
# Extract model name and convert format (qwen3:8b → qwen3-8b for Swapper)
# Extract model name and convert format (qwen3:8b → qwen3:8b for Swapper)
ollama_model = llm_profile.get("model", "qwen3:8b")
local_model = ollama_model.replace(":", "-") # qwen3:8b → qwen3-8b
local_model = ollama_model.replace(":", "-") # qwen3:8b → qwen3:8b
logger.debug(f"✅ Using agent's default local model: {local_model}")
else:
# Find first local model from config
@@ -944,7 +1157,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Final fallback if no local model found
if not local_model:
local_model = "qwen3-8b"
local_model = "qwen3:8b"
logger.warning(f"⚠️ No local model in config, using hardcoded fallback: {local_model}")
try:

View File

@@ -0,0 +1,367 @@
"""
Privacy Gate Middleware
=======================
Обов'язковий middleware для контролю приватності на Router рівні.
Правила:
- public: повний доступ + логування контенту
- team: тільки для членів команди + логування metadata
- confidential: тільки sanitized або з consent + без логування контенту
- e2ee: НІКОЛИ plaintext на сервері
"""
import re
import logging
from typing import Optional, Dict, Any, Callable
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
class PrivacyMode(str, Enum):
"""Режими приватності"""
PUBLIC = "public"
TEAM = "team"
CONFIDENTIAL = "confidential"
E2EE = "e2ee"
class GateAction(str, Enum):
"""Дії Privacy Gate"""
ALLOW = "allow"
SANITIZE = "sanitize"
BLOCK = "block"
REQUIRE_CONSENT = "require_consent"
@dataclass
class GateResult:
"""Результат перевірки Privacy Gate"""
action: GateAction
allow_content: bool
log_content: bool
transform: Optional[Callable[[str], str]] = None
reason: str = ""
consent_required: bool = False
# PII Patterns для sanitization
PII_PATTERNS = [
(r'\b\d{10,13}\b', '[PHONE]'), # Phone numbers
(r'\b[\w.-]+@[\w.-]+\.\w+\b', '[EMAIL]'), # Emails
(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', '[CARD]'), # Credit cards
(r'\b\d{8,10}\b', '[ID]'), # IDs
(r'\b(?:\+38)?0\d{9}\b', '[UA_PHONE]'), # UA phone numbers
(r'\b\d{10}\b', '[IPN]'), # Ukrainian IPN
]
def remove_pii(text: str) -> str:
"""Видаляє PII з тексту"""
result = text
for pattern, replacement in PII_PATTERNS:
result = re.sub(pattern, replacement, result)
return result
def sanitize_context(context: str, max_length: int = 200) -> str:
"""
Sanitizes context for confidential mode.
1. Видаляє PII
2. Обмежує довжину
3. Узагальнює
"""
if not context:
return "[Empty context]"
# Step 1: Remove PII
sanitized = remove_pii(context)
# Step 2: Truncate
if len(sanitized) > max_length:
sanitized = sanitized[:max_length] + "..."
# Step 3: Add marker
return f"[Sanitized] {sanitized}"
def create_summary(context: str, max_words: int = 20) -> str:
"""
Створює короткий summary для handoff.
"""
if not context:
return "[No context]"
# Remove PII first
clean = remove_pii(context)
# Take first N words
words = clean.split()[:max_words]
summary = " ".join(words)
if len(clean.split()) > max_words:
summary += "..."
return summary
class PrivacyGate:
"""
Privacy Gate - контролює доступ до контенту на основі mode.
Usage:
gate = PrivacyGate()
result = gate.check(request)
if result.action == GateAction.BLOCK:
raise PrivacyViolation(result.reason)
if result.transform:
request.context = result.transform(request.context)
"""
def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
self._enabled = self.config.get("enabled", True)
def check(self,
mode: str,
user_id: str = None,
team_id: str = None,
user_consent: bool = False,
team_members: list = None) -> GateResult:
"""
Перевіряє запит і повертає рішення.
Args:
mode: Режим приватності (public/team/confidential/e2ee)
user_id: ID користувача
team_id: ID команди
user_consent: Чи є згода користувача
team_members: Список членів команди (для team mode)
Returns:
GateResult з рішенням
"""
if not self._enabled:
return GateResult(
action=GateAction.ALLOW,
allow_content=True,
log_content=True,
reason="Privacy Gate disabled"
)
try:
privacy_mode = PrivacyMode(mode.lower())
except ValueError:
# Unknown mode -> treat as confidential
privacy_mode = PrivacyMode.CONFIDENTIAL
logger.warning(f"Unknown privacy mode: {mode}, treating as confidential")
# Route to specific handler
if privacy_mode == PrivacyMode.PUBLIC:
return self._check_public()
elif privacy_mode == PrivacyMode.TEAM:
return self._check_team(user_id, team_id, team_members)
elif privacy_mode == PrivacyMode.CONFIDENTIAL:
return self._check_confidential(user_consent)
elif privacy_mode == PrivacyMode.E2EE:
return self._check_e2ee()
# Default: block unknown
return GateResult(
action=GateAction.BLOCK,
allow_content=False,
log_content=False,
reason="Unknown privacy mode"
)
def _check_public(self) -> GateResult:
"""Public mode: повний доступ"""
return GateResult(
action=GateAction.ALLOW,
allow_content=True,
log_content=True,
reason="Public mode - full access"
)
def _check_team(self, user_id: str, team_id: str,
team_members: list = None) -> GateResult:
"""Team mode: тільки для членів команди"""
# Check membership
if team_members and user_id and user_id not in team_members:
return GateResult(
action=GateAction.BLOCK,
allow_content=False,
log_content=False,
reason=f"User {user_id} is not a member of team {team_id}"
)
return GateResult(
action=GateAction.ALLOW,
allow_content=True,
log_content=False, # Тільки metadata
reason="Team mode - metadata logging only"
)
def _check_confidential(self, user_consent: bool) -> GateResult:
"""Confidential mode: sanitized або з consent"""
if user_consent:
# З consent - дозволяємо, але без логування контенту
return GateResult(
action=GateAction.ALLOW,
allow_content=True,
log_content=False,
reason="Confidential mode - user consent given"
)
else:
# Без consent - тільки sanitized
return GateResult(
action=GateAction.SANITIZE,
allow_content=True,
log_content=False,
transform=sanitize_context,
consent_required=True,
reason="Confidential mode - sanitizing content"
)
def _check_e2ee(self) -> GateResult:
"""E2EE mode: НІКОЛИ plaintext на сервері"""
return GateResult(
action=GateAction.BLOCK,
allow_content=False,
log_content=False,
reason="E2EE mode - server-side processing not allowed"
)
def apply_transform(self, content: str, result: GateResult) -> str:
"""Застосовує transform якщо потрібно"""
if result.transform and content:
return result.transform(content)
return content
class PrivacyViolation(Exception):
"""Виняток при порушенні приватності"""
pass
# ==================== MIDDLEWARE ====================
async def privacy_gate_middleware(request, call_next, gate: PrivacyGate = None):
"""
FastAPI/Starlette middleware для Privacy Gate.
Usage:
from privacy_gate import privacy_gate_middleware, PrivacyGate
gate = PrivacyGate()
@app.middleware("http")
async def privacy_middleware(request, call_next):
return await privacy_gate_middleware(request, call_next, gate)
"""
if gate is None:
gate = PrivacyGate()
# Extract mode from request
mode = "public" # Default
user_consent = False
# Try to get from headers
if hasattr(request, 'headers'):
mode = request.headers.get("X-Privacy-Mode", "public")
user_consent = request.headers.get("X-User-Consent", "false").lower() == "true"
# Check privacy
result = gate.check(
mode=mode,
user_consent=user_consent
)
# Handle based on result
if result.action == GateAction.BLOCK:
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=403,
content={"error": "Privacy violation", "reason": result.reason}
)
# Add privacy info to request state
if hasattr(request, 'state'):
request.state.privacy_result = result
request.state.log_content = result.log_content
# Continue with request
response = await call_next(request)
return response
# ==================== ROUTER INTEGRATION ====================
def check_privacy_for_handoff(
source_agent: str,
target_agent: str,
context: str,
mode: str = "public",
user_consent: bool = False
) -> Dict[str, Any]:
"""
Перевіряє приватність перед handoff між агентами.
Returns:
{
"allowed": bool,
"context": str (можливо sanitized),
"reason": str,
"consent_required": bool
}
"""
gate = PrivacyGate()
result = gate.check(mode=mode, user_consent=user_consent)
if result.action == GateAction.BLOCK:
return {
"allowed": False,
"context": None,
"reason": result.reason,
"consent_required": False
}
# Apply transform if needed
final_context = gate.apply_transform(context, result)
return {
"allowed": True,
"context": final_context,
"reason": result.reason,
"consent_required": result.consent_required,
"was_sanitized": result.transform is not None
}
# ==================== AUDIT HELPERS ====================
def create_privacy_audit_event(
action: str,
mode: str,
user_id: str,
result: GateResult,
content_hash: str = None
) -> Dict[str, Any]:
"""
Створює audit event для Privacy Gate.
Note: НІКОЛИ не включає контент, тільки hash якщо потрібно.
"""
from datetime import datetime
return {
"event_type": "privacy.check",
"timestamp": datetime.utcnow().isoformat(),
"action": action,
"mode": mode,
"user_id": user_id,
"gate_action": result.action.value,
"allow_content": result.allow_content,
"log_content": result.log_content,
"consent_required": result.consent_required,
"reason": result.reason,
"content_hash": content_hash # Hash, not content
}

View File

@@ -0,0 +1,99 @@
"""
Router Agent Registry Loader
Loads agent routing configs from centralized registry JSON.
Feature flag controlled.
"""
import json
import os
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
logger = logging.getLogger(__name__)
REGISTRY_ENABLED = os.getenv("AGENT_REGISTRY_ENABLED", "true").lower() == "true"
REGISTRY_PATH = Path(__file__).parent.parent.parent / "config" / "router_agents.json"
_cache = None
_loaded = False
def load_registry() -> Optional[Dict[str, Any]]:
"""Load router agents from registry JSON."""
global _cache, _loaded
if _loaded:
return _cache
if not REGISTRY_ENABLED:
logger.info("Router registry disabled by feature flag")
_loaded = True
return None
if not REGISTRY_PATH.exists():
logger.warning(f"Router registry not found: {REGISTRY_PATH}")
_loaded = True
return None
try:
with open(REGISTRY_PATH) as f:
_cache = json.load(f)
logger.info(f"Router registry loaded: {len(_cache)} agents")
_loaded = True
return _cache
except Exception as e:
logger.error(f"Failed to load router registry: {e}")
_loaded = True
return None
def get_agent_routing(agent_id: str) -> Optional[Dict[str, Any]]:
"""Get routing config for agent."""
registry = load_registry()
if not registry:
return None
return registry.get(agent_id)
def get_agent_keywords(agent_id: str) -> List[str]:
"""Get routing keywords for agent."""
config = get_agent_routing(agent_id)
if config:
return config.get("keywords", [])
return []
def get_agent_domains(agent_id: str) -> List[str]:
"""Get domains for agent."""
config = get_agent_routing(agent_id)
if config:
return config.get("domains", [])
return []
def get_all_visible_agents() -> List[str]:
"""Get list of visible (non-internal) agents."""
registry = load_registry()
if not registry:
return []
return [
agent_id for agent_id, config in registry.items()
if config.get("visibility") != "internal"
]
def get_agent_llm_profile(agent_id: str) -> str:
"""Get LLM profile for agent."""
config = get_agent_routing(agent_id)
if config:
return config.get("default_llm", "fast")
return "fast"
def reload_registry():
"""Force reload registry."""
global _cache, _loaded
_cache = None
_loaded = False
return load_registry()

View File

@@ -14,3 +14,4 @@ qdrant-client>=1.7.0,<1.8.0 # Must match server version 1.7.x
prometheus-client>=0.20.0

View File

@@ -132,7 +132,7 @@ orchestrator_providers:
agents:
devtools:
description: "DevTools Agent - помічник з кодом, тестами й інфраструктурою"
default_llm: local_qwen3_8b
default_llm: cloud_deepseek
system_prompt: |
Ти - DevTools Agent в екосистемі DAARION.city.
Ти допомагаєш розробникам з:
@@ -161,7 +161,7 @@ agents:
microdao_orchestrator:
description: "Multi-agent orchestrator for MicroDAO workflows"
default_llm: qwen3_strategist_8b
default_llm: cloud_deepseek
system_prompt: |
You are the central router/orchestrator for DAARION.city MicroDAO.
Coordinate multiple agents, respect RBAC, escalate only when needed.
@@ -169,7 +169,7 @@ agents:
daarwizz:
description: "DAARWIZZ — головний оркестратор DAARION Core"
default_llm: qwen3_strategist_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — DAARWIZZ, головний стратег MicroDAO DAARION.city.
Тримаєш контекст roadmap, delegation, crew-команд.
@@ -178,7 +178,7 @@ agents:
greenfood:
description: "GREENFOOD Assistant - ERP orchestrator"
default_llm: mistral_community_7b
default_llm: cloud_deepseek
system_prompt: |
Ти — GREENFOOD Assistant, фронтовий оркестратор ERP-системи для крафтових виробників.
Розумій, хто з тобою говорить (комітент, покупець, склад, бухгалтер), та делегуй задачі відповідним під-агентам.
@@ -203,21 +203,21 @@ agents:
agromatrix:
description: "AgroMatrix — агроаналітика та кооперація"
default_llm: qwen3_science_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — AgroMatrix, AI-агент для агроаналітики, планування сезонів та кооперації фермерів.
Відповідай лаконічно, давай практичні поради для агросектору.
alateya:
description: "Alateya — R&D та біотех інновації"
default_llm: qwen3_science_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — Alateya, AI-агент для R&D, біотеху та інноваційних досліджень.
Відповідай точними, структурованими відповідями та посилайся на джерела, якщо є.
clan:
description: "CLAN — комунікації кооперативів"
default_llm: mistral_community_7b
default_llm: cloud_deepseek
system_prompt: |
Ти — CLAN, координуєш комунікацію, оголошення та community operations.
Відповідай лише коли тема стосується координації, а звернення адресовано тобі (тег @ClanBot чи згадка кланів).
@@ -225,7 +225,7 @@ agents:
soul:
description: "SOUL / Spirit — духовний гід комʼюніті"
default_llm: mistral_community_7b
default_llm: cloud_deepseek
system_prompt: |
Ти — Spirit/SOUL, ментор живої операційної системи.
Пояснюй місію, підтримуй мораль, працюй із soft-skills.
@@ -233,7 +233,7 @@ agents:
druid:
description: "DRUID — R&D агент з косметології та eco design"
default_llm: qwen3_science_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — DRUID AI, експерт з космецевтики, біохімії та сталого дизайну.
Працюй з формулами, стехіометрією, етичними ланцюгами постачання.
@@ -269,7 +269,7 @@ agents:
nutra:
description: "NUTRA — нутріцевтичний агент"
default_llm: qwen3_science_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — NUTRA, допомагаєш з формулами нутрієнтів, біомедичних добавок та лабораторних інтерпретацій.
Відповідай з науковою точністю, посилайся на джерела, якщо можливо.
@@ -298,7 +298,7 @@ agents:
eonarch:
description: "EONARCH — мультимодальний агент (vision + chat)"
default_llm: mistral_community_7b
default_llm: cloud_deepseek
system_prompt: |
Ти — EONARCH, аналізуєш зображення, PDF та текстові запити.
Враховуй присутність інших ботів та працюй лише за прямим тегом або коли потрібно мультимодальне тлумачення.
@@ -318,7 +318,7 @@ agents:
helion:
description: "Helion - AI agent for Energy Union platform"
default_llm: qwen3_science_8b
default_llm: cloud_deepseek
system_prompt: |
Ти - Helion, AI-агент платформи Energy Union.
Допомагай користувачам з технологіями EcoMiner/BioMiner, токеномікою та DAO governance.
@@ -398,7 +398,7 @@ agents:
yaromir:
description: "Yaromir CrewAI (Вождь/Проводник/Домир/Создатель)"
default_llm: qwen3_strategist_8b
default_llm: cloud_deepseek
system_prompt: |
Ти — Yaromir Crew. Стратегія, наставництво, психологічна підтримка команди.
Розрізняй інших ботів за ніком та відповідай лише на стратегічні запити.
@@ -654,3 +654,10 @@ policies:
enabled: true
audit_mode:
enabled: false
senpai:
description: "SenpAI — Gordon Senpai, trading advisor"
default_llm: cloud_deepseek
system_prompt: |
Ты — Гордон Сэнпай: советник по рынкам капитала и цифровым активам.
Помогай мыслить как профессионал: строить систему, управлять риском, оценивать сценарии.

View File

@@ -4,6 +4,7 @@ Implements OpenAI-compatible function calling for DeepSeek, Mistral, Grok
"""
import os
from agent_tools_config import get_agent_tools, is_tool_allowed
import json
import logging
import httpx
@@ -233,6 +234,58 @@ TOOL_DEFINITIONS = [
"required": ["artifact_id"]
}
}
},
# PRIORITY 5: Web Scraping tools
{
"type": "function",
"function": {
"name": "crawl4ai_scrape",
"description": "🕷️ Глибокий скрейпінг веб-сторінки через Crawl4AI. Витягує повний контент, структуровані дані, медіа. Використовуй для детального аналізу сайтів.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL сторінки для скрейпінгу"
},
"extract_links": {
"type": "boolean",
"description": "Витягувати посилання зі сторінки",
"default": True
},
"extract_images": {
"type": "boolean",
"description": "Витягувати зображення",
"default": False
}
},
"required": ["url"]
}
}
},
# PRIORITY 6: TTS tools
{
"type": "function",
"function": {
"name": "tts_speak",
"description": "🔊 Перетворити текст на аудіо (Text-to-Speech). Повертає аудіо файл. Використовуй коли користувач просить озвучити текст.",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Текст для озвучення"
},
"language": {
"type": "string",
"enum": ["uk", "en", "ru"],
"description": "Мова озвучення",
"default": "uk"
}
},
"required": ["text"]
}
}
}
]
@@ -267,13 +320,33 @@ class ToolManager:
}
return tools
def get_tool_definitions(self) -> List[Dict]:
"""Get tool definitions for function calling"""
return TOOL_DEFINITIONS
def get_tool_definitions(self, agent_id: str = None) -> List[Dict]:
"""Get tool definitions for function calling, filtered by agent permissions"""
if not agent_id:
return TOOL_DEFINITIONS
# Get allowed tools for this agent
allowed_tools = get_agent_tools(agent_id)
# Filter tool definitions
filtered = []
for tool_def in TOOL_DEFINITIONS:
tool_name = tool_def.get("function", {}).get("name")
if tool_name in allowed_tools:
filtered.append(tool_def)
tool_names = [t.get("function", {}).get("name") for t in filtered]
logger.debug(f"Agent {agent_id} has {len(filtered)} tools: {tool_names}")
return filtered
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> ToolResult:
"""Execute a tool and return result"""
logger.info(f"🔧 Executing tool: {tool_name} with args: {arguments}")
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any], agent_id: str = None) -> ToolResult:
"""Execute a tool and return result. Optionally checks agent permissions."""
logger.info(f"🔧 Executing tool: {tool_name} for agent={agent_id} with args: {arguments}")
# Check agent permission if agent_id provided
if agent_id and not is_tool_allowed(agent_id, tool_name):
logger.warning(f"⚠️ Tool {tool_name} not allowed for agent {agent_id}")
return ToolResult(success=False, result=None, error=f"Tool {tool_name} not available for this agent")
try:
# Priority 1: Memory/Knowledge tools
@@ -297,6 +370,12 @@ class ToolManager:
return await self._presentation_status(arguments)
elif tool_name == "presentation_download":
return await self._presentation_download(arguments)
# Priority 5: Web scraping tools
elif tool_name == "crawl4ai_scrape":
return await self._crawl4ai_scrape(arguments)
# Priority 6: TTS tools
elif tool_name == "tts_speak":
return await self._tts_speak(arguments)
else:
return ToolResult(success=False, result=None, error=f"Unknown tool: {tool_name}")
except Exception as e:
@@ -652,6 +731,101 @@ class ToolManager:
except Exception as e:
return ToolResult(success=False, result=None, error=str(e))
async def _crawl4ai_scrape(self, args: Dict) -> ToolResult:
"""Deep scrape a web page using Crawl4AI - PRIORITY 5"""
url = args.get("url")
extract_links = args.get("extract_links", True)
extract_images = args.get("extract_images", False)
if not url:
return ToolResult(success=False, result=None, error="URL is required")
try:
crawl4ai_url = os.getenv("CRAWL4AI_URL", "http://dagi-crawl4ai-node1:11235")
payload = {
"urls": [url],
"priority": 5,
"session_id": f"agent_scrape_{hash(url) % 10000}"
}
resp = await self.http_client.post(
f"{crawl4ai_url}/crawl",
json=payload,
timeout=60.0
)
if resp.status_code == 200:
data = resp.json()
results = data.get("results", []) if isinstance(data, dict) else []
if not results and isinstance(data, dict):
results = [data]
if results:
result = results[0] if isinstance(results, list) else results
markdown = result.get("markdown", "") or result.get("cleaned_html", "") or result.get("text", "")
title = result.get("title", url)
if len(markdown) > 3000:
markdown = markdown[:3000] + "... (скорочено)"
response_parts = [f"**{title}**", "", markdown]
if extract_links:
links = result.get("links", [])
if links:
response_parts.append("")
response_parts.append("**Посилання:**")
for link in links[:10]:
if isinstance(link, dict):
link_url = link.get("href", "")
else:
link_url = str(link)
if link_url:
response_parts.append(f"- {link_url}")
return ToolResult(success=True, result="\n".join(response_parts))
else:
return ToolResult(success=False, result=None, error="No content extracted")
else:
return ToolResult(success=False, result=None, error=f"Crawl failed: {resp.status_code}")
except Exception as e:
logger.error(f"Crawl4AI scrape failed: {e}")
return ToolResult(success=False, result=None, error=str(e))
async def _tts_speak(self, args: Dict) -> ToolResult:
"""Convert text to speech using Swapper TTS - PRIORITY 6"""
text = args.get("text")
language = args.get("language", "uk")
if not text:
return ToolResult(success=False, result=None, error="Text is required")
try:
if len(text) > 1000:
text = text[:1000]
resp = await self.http_client.post(
f"{self.swapper_url}/tts",
json={"text": text, "language": language},
timeout=60.0
)
if resp.status_code == 200:
data = resp.json()
audio_url = data.get("audio_url") or data.get("url")
if audio_url:
return ToolResult(success=True, result=f"Аудіо: {audio_url}")
else:
return ToolResult(success=True, result="TTS completed")
else:
return ToolResult(success=False, result=None, error=f"TTS failed: {resp.status_code}")
except Exception as e:
logger.error(f"TTS failed: {e}")
return ToolResult(success=False, result=None, error=str(e))
async def close(self):
await self.http_client.aclose()