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:
@@ -8,7 +8,9 @@ import json
|
||||
import re
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
@@ -31,9 +33,17 @@ from behavior_policy import (
|
||||
should_respond,
|
||||
analyze_message,
|
||||
detect_media_question,
|
||||
detect_explicit_request,
|
||||
detect_url,
|
||||
detect_agent_mention,
|
||||
is_no_output_response,
|
||||
record_interaction,
|
||||
record_ack,
|
||||
get_ack_text,
|
||||
is_prober_request,
|
||||
NO_OUTPUT,
|
||||
BehaviorDecision,
|
||||
AGENT_NAME_VARIANTS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +51,56 @@ logger = logging.getLogger(__name__)
|
||||
# Telegram message length limits
|
||||
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
||||
TELEGRAM_SAFE_LENGTH = 3500 # Leave room for formatting
|
||||
# Operator pending state cache (chat_id -> {ts, items})
|
||||
LAST_PENDING_STATE: Dict[str, Dict[str, Any]] = {}
|
||||
PENDING_STATE_TTL = 1800 # 30 minutes
|
||||
|
||||
|
||||
def _pending_state_cleanup():
|
||||
now = time.time()
|
||||
expired = [cid for cid, rec in LAST_PENDING_STATE.items() if now - rec.get('ts', 0) > PENDING_STATE_TTL]
|
||||
for cid in expired:
|
||||
del LAST_PENDING_STATE[cid]
|
||||
|
||||
|
||||
def _get_last_pending(chat_id: str) -> list | None:
|
||||
_pending_state_cleanup()
|
||||
rec = LAST_PENDING_STATE.get(str(chat_id))
|
||||
if not rec:
|
||||
return None
|
||||
return rec.get('items')
|
||||
|
||||
|
||||
def _set_last_pending(chat_id: str, items: list):
|
||||
LAST_PENDING_STATE[str(chat_id)] = {"ts": time.time(), "items": items}
|
||||
|
||||
|
||||
|
||||
def _chunk_text(text: str, max_len: int = 4096):
|
||||
if not text:
|
||||
return [""]
|
||||
chunks = []
|
||||
current = []
|
||||
current_len = 0
|
||||
for line in text.split("\n"):
|
||||
add_len = len(line) + (1 if current else 0)
|
||||
if current_len + add_len <= max_len:
|
||||
current.append(line)
|
||||
current_len += add_len
|
||||
continue
|
||||
if current:
|
||||
chunks.append("\n".join(current))
|
||||
current = []
|
||||
current_len = 0
|
||||
while len(line) > max_len:
|
||||
chunks.append(line[:max_len])
|
||||
line = line[max_len:]
|
||||
current.append(line)
|
||||
current_len = len(line)
|
||||
if current:
|
||||
chunks.append("\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
# Training groups - agents respond to ALL messages without mention requirement
|
||||
TRAINING_GROUP_IDS = {
|
||||
@@ -230,6 +290,54 @@ EONARCH_CONFIG = load_agent_config(
|
||||
default_prompt="Ти — EONARCH, провідник еволюції свідомості в екосистемі DAARION.city. Супроводжуєш людство на шляху трансформації свідомості до колективної мудрості.",
|
||||
)
|
||||
|
||||
# SENPAI (Gordon Senpai) Configuration
|
||||
SENPAI_CONFIG = load_agent_config(
|
||||
agent_id="senpai",
|
||||
name=os.getenv("SENPAI_NAME", "SENPAI"),
|
||||
prompt_path=os.getenv(
|
||||
"SENPAI_PROMPT_PATH",
|
||||
str(Path(__file__).parent / "senpai_prompt.txt"),
|
||||
),
|
||||
telegram_token_env="SENPAI_TELEGRAM_BOT_TOKEN",
|
||||
default_prompt="Ти — Гордон Сенпай (Gordon Senpai), радник з ринків капіталу та цифрових активів. Допомагаєш з трейдингом, ризик-менеджментом, аналізом ринків.",
|
||||
)
|
||||
|
||||
# SOUL / Athena Configuration
|
||||
SOUL_CONFIG = load_agent_config(
|
||||
agent_id="soul",
|
||||
name=os.getenv("SOUL_NAME", "Athena"),
|
||||
prompt_path=os.getenv(
|
||||
"SOUL_PROMPT_PATH",
|
||||
str(Path(__file__).parent / "soul_prompt.txt"),
|
||||
),
|
||||
telegram_token_env="SOUL_TELEGRAM_BOT_TOKEN",
|
||||
default_prompt="Ти — Athena, духовний гід та ментор спільноти DAARION.city. Підтримуєш місію, цінності та зв\'язки між учасниками.",
|
||||
)
|
||||
|
||||
# YAROMIR Configuration
|
||||
YAROMIR_CONFIG = load_agent_config(
|
||||
agent_id="yaromir",
|
||||
name=os.getenv("YAROMIR_NAME", "Yaromir"),
|
||||
prompt_path=os.getenv(
|
||||
"YAROMIR_PROMPT_PATH",
|
||||
str(Path(__file__).parent / "yaromir_prompt.txt"),
|
||||
),
|
||||
telegram_token_env="YAROMIR_TELEGRAM_BOT_TOKEN",
|
||||
default_prompt="Ти — Yaromir, стратег та наставник в екосистемі DAARION.city. Стратегія, наставництво, психологічна підтримка команди.",
|
||||
)
|
||||
|
||||
# SOFIIA (Sophia) Configuration
|
||||
SOFIIA_CONFIG = load_agent_config(
|
||||
agent_id="sofiia",
|
||||
name=os.getenv("SOFIIA_NAME", "Sophia"),
|
||||
prompt_path=os.getenv(
|
||||
"SOFIIA_PROMPT_PATH",
|
||||
str(Path(__file__).parent / "sofiia_prompt.txt"),
|
||||
),
|
||||
telegram_token_env="SOFIIA_TELEGRAM_BOT_TOKEN",
|
||||
default_prompt="Ти — Sophia (Софія), Chief AI Architect та Technical Sovereign екосистеми DAARION.city. Координуєш R&D, архітектуру, безпеку та еволюцію платформи.",
|
||||
)
|
||||
|
||||
# Registry of all agents (для легкого додавання нових агентів)
|
||||
AGENT_REGISTRY: Dict[str, AgentConfig] = {
|
||||
"daarwizz": DAARWIZZ_CONFIG,
|
||||
@@ -241,6 +349,10 @@ AGENT_REGISTRY: Dict[str, AgentConfig] = {
|
||||
"druid": DRUID_CONFIG,
|
||||
"clan": CLAN_CONFIG,
|
||||
"eonarch": EONARCH_CONFIG,
|
||||
"senpai": SENPAI_CONFIG,
|
||||
"soul": SOUL_CONFIG,
|
||||
"yaromir": YAROMIR_CONFIG,
|
||||
"sofiia": SOFIIA_CONFIG,
|
||||
}
|
||||
# 3. Створіть endpoint (опціонально, якщо потрібен окремий webhook):
|
||||
# @router.post("/new_agent/telegram/webhook")
|
||||
@@ -284,8 +396,97 @@ async def druid_telegram_webhook(update: TelegramUpdate):
|
||||
|
||||
|
||||
# AGROMATRIX webhook endpoint
|
||||
|
||||
async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfig) -> Dict[str, Any]:
|
||||
update_id = getattr(update, 'update_id', None) or update.update_id
|
||||
if update_id:
|
||||
if update_id in _PROCESSED_UPDATES:
|
||||
return {"ok": True, "status": "duplicate"}
|
||||
_PROCESSED_UPDATES[update_id] = _time.time()
|
||||
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
|
||||
_dedup_cleanup()
|
||||
|
||||
message = update.message or update.channel_post or {}
|
||||
text = message.get('text') or message.get('caption') or ''
|
||||
if not text:
|
||||
return {"ok": True, "status": "no_text"}
|
||||
|
||||
user = message.get('from', {}) or {}
|
||||
chat = message.get('chat', {}) or {}
|
||||
user_id = str(user.get('id', ''))
|
||||
chat_id = str(chat.get('id', ''))
|
||||
|
||||
# ops mode if operator
|
||||
ops_mode = False
|
||||
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||||
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
|
||||
if op_chat and chat_id == op_chat:
|
||||
ops_mode = True
|
||||
if user_id and user_id in op_ids:
|
||||
ops_mode = True
|
||||
|
||||
trace_id = str(uuid.uuid4())
|
||||
|
||||
# call Stepan directly
|
||||
try:
|
||||
sys.path.insert(0, str(Path('/opt/microdao-daarion')))
|
||||
from crews.agromatrix_crew.run import handle_message
|
||||
started = time.time()
|
||||
last_pending = _get_last_pending(chat_id)
|
||||
response_text = await asyncio.wait_for(
|
||||
asyncio.to_thread(handle_message, text, user_id, chat_id, trace_id, ops_mode, last_pending),
|
||||
timeout=25
|
||||
)
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"Stepan handler error: {e}; trace_id={trace_id}")
|
||||
response_text = f"Помилка обробки. trace_id={trace_id}"
|
||||
duration_ms = 0
|
||||
|
||||
# If JSON, try to show summary
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
summary = parsed.get('summary')
|
||||
if summary:
|
||||
response_text = summary
|
||||
if parsed.get('details'):
|
||||
response_text += "\n(details truncated)"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# chunk and send
|
||||
bot_token = agent_config.get_telegram_token()
|
||||
for chunk in _chunk_text(response_text, max_len=4096):
|
||||
await send_telegram_message(chat_id, chunk, bot_token=bot_token)
|
||||
|
||||
logger.info(f"Stepan reply sent: trace_id={trace_id}, user_id={user_id}, chat_id={chat_id}, update_id={update_id}, duration_ms={duration_ms}")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/agromatrix/telegram/webhook")
|
||||
async def agromatrix_telegram_webhook(update: TelegramUpdate):
|
||||
# Check if this is an operator request (slash command or NL operator intent)
|
||||
message = (update.message or update.channel_post or {})
|
||||
msg_text = message.get('text') or message.get('caption') or ''
|
||||
user = message.get('from', {}) or {}
|
||||
chat = message.get('chat', {}) or {}
|
||||
user_id = str(user.get('id', ''))
|
||||
chat_id = str(chat.get('id', ''))
|
||||
|
||||
is_slash = msg_text.strip().startswith('/')
|
||||
is_ops = False
|
||||
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||||
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
|
||||
if op_chat and chat_id == op_chat:
|
||||
is_ops = True
|
||||
if user_id and user_id in op_ids:
|
||||
is_ops = True
|
||||
|
||||
# Operator NL or slash commands -> handle via Stepan handler
|
||||
if is_slash or is_ops:
|
||||
return await handle_stepan_message(update, AGROMATRIX_CONFIG)
|
||||
|
||||
# General conversation -> standard Router pipeline (like all other agents)
|
||||
return await handle_telegram_webhook(AGROMATRIX_CONFIG, update)
|
||||
|
||||
|
||||
@@ -307,6 +508,30 @@ async def eonarch_telegram_webhook(update: TelegramUpdate):
|
||||
return await handle_telegram_webhook(EONARCH_CONFIG, update)
|
||||
|
||||
|
||||
# SENPAI (Gordon Senpai) webhook endpoint
|
||||
@router.post("/senpai/telegram/webhook")
|
||||
async def senpai_telegram_webhook(update: TelegramUpdate):
|
||||
return await handle_telegram_webhook(SENPAI_CONFIG, update)
|
||||
|
||||
|
||||
# SOUL / Athena webhook endpoint
|
||||
@router.post("/soul/telegram/webhook")
|
||||
async def soul_telegram_webhook(update: TelegramUpdate):
|
||||
return await handle_telegram_webhook(SOUL_CONFIG, update)
|
||||
|
||||
|
||||
# YAROMIR webhook endpoint
|
||||
@router.post("/yaromir/telegram/webhook")
|
||||
async def yaromir_telegram_webhook(update: TelegramUpdate):
|
||||
return await handle_telegram_webhook(YAROMIR_CONFIG, update)
|
||||
|
||||
|
||||
# SOFIIA (Sophia) webhook endpoint
|
||||
@router.post("/sofiia/telegram/webhook")
|
||||
async def sofiia_telegram_webhook(update: TelegramUpdate):
|
||||
return await handle_telegram_webhook(SOFIIA_CONFIG, update)
|
||||
|
||||
|
||||
class DiscordMessage(BaseModel):
|
||||
"""Simplified Discord message model"""
|
||||
content: Optional[str] = None
|
||||
@@ -319,16 +544,32 @@ class DiscordMessage(BaseModel):
|
||||
# DAO Mapping (temporary)
|
||||
# ========================================
|
||||
|
||||
# Map chat/channel ID to DAO ID
|
||||
# TODO: Move to database or config
|
||||
# Map agent_id to DAO ID
|
||||
AGENT_TO_DAO = {
|
||||
"helion": "helion-dao",
|
||||
"greenfood": "greenfood-dao",
|
||||
"agromatrix": "agromatrix-dao",
|
||||
"nutra": "nutra-dao",
|
||||
"druid": "druid-dao",
|
||||
"daarwizz": "daarwizz-dao",
|
||||
"clan": "clan-dao",
|
||||
"alateya": "alateya-dao",
|
||||
"eonarch": "eonarch-dao",
|
||||
"senpai": "senpai-dao",
|
||||
"soul": "soul-dao",
|
||||
"yaromir": "yaromir-dao",
|
||||
}
|
||||
|
||||
# Legacy: Map chat/channel ID to DAO ID
|
||||
CHAT_TO_DAO = {
|
||||
"default": "greenfood-dao",
|
||||
# Add mappings: "telegram:12345": "specific-dao",
|
||||
"default": "daarion-dao",
|
||||
}
|
||||
|
||||
|
||||
def get_dao_id(chat_id: str, source: str) -> str:
|
||||
"""Get DAO ID from chat ID"""
|
||||
def get_dao_id(chat_id: str, source: str, agent_id: str = None) -> str:
|
||||
"""Get DAO ID from agent_id or chat ID"""
|
||||
if agent_id and agent_id in AGENT_TO_DAO:
|
||||
return AGENT_TO_DAO[agent_id]
|
||||
key = f"{source}:{chat_id}"
|
||||
return CHAT_TO_DAO.get(key, CHAT_TO_DAO["default"])
|
||||
|
||||
@@ -419,23 +660,20 @@ def store_response_cache(agent_id: str, chat_id: str, text: str, answer: str) ->
|
||||
|
||||
def _resolve_stt_upload_url() -> str:
|
||||
"""
|
||||
Повертає фінальний endpoint для STT upload, враховуючи налаштування.
|
||||
Дозволяє передати або базовий URL сервісу, або повний шлях до /api/stt/upload.
|
||||
Повертає фінальний endpoint для STT.
|
||||
Swapper service використовує POST /stt з multipart file upload.
|
||||
"""
|
||||
upload_override = os.getenv("STT_SERVICE_UPLOAD_URL")
|
||||
if upload_override:
|
||||
return upload_override.rstrip("/")
|
||||
|
||||
base_url = os.getenv("STT_SERVICE_URL", "http://172.21.0.19:8895").rstrip("/")
|
||||
base_url = os.getenv("STT_SERVICE_URL", "http://swapper-service:8890").rstrip("/")
|
||||
|
||||
if base_url.endswith("/api/stt/upload"):
|
||||
# Swapper endpoint is /stt (not /api/stt/upload)
|
||||
if base_url.endswith("/stt"):
|
||||
return base_url
|
||||
if base_url.endswith("/api/stt"):
|
||||
return f"{base_url}/upload"
|
||||
if base_url.endswith("/api"):
|
||||
return f"{base_url}/stt/upload"
|
||||
|
||||
return f"{base_url}/api/stt/upload"
|
||||
return f"{base_url}/stt"
|
||||
|
||||
|
||||
# ========================================
|
||||
@@ -613,6 +851,7 @@ async def process_photo(
|
||||
"file_id": file_id,
|
||||
"caption": caption,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
return {"ok": True, "skipped": True, "reason": "media_no_question"}
|
||||
|
||||
@@ -704,6 +943,7 @@ async def process_photo(
|
||||
scope="short_term",
|
||||
save_agent_response=not is_service_response(answer_text),
|
||||
agent_metadata={"context": "photo"},
|
||||
username=username,
|
||||
)
|
||||
|
||||
return {"ok": True, "agent": agent_config.agent_id, "model": "specialist_vision_8b"}
|
||||
@@ -858,6 +1098,52 @@ async def process_document(
|
||||
answer_text = f"📄 Отримав документ **{file_name}**, але не вдалося прочитати текст. Можливо, це скановане зображення?"
|
||||
|
||||
logger.info(f"{agent_config.name}: Document processed: {file_name}, doc_id={result.doc_id}")
|
||||
|
||||
# === SAVE TO CHAT HISTORY (CRITICAL: so agent remembers the document) ===
|
||||
user_msg = f"[Документ: {file_name}] Надіслано документ"
|
||||
if update.message.get("caption"):
|
||||
user_msg = f"[Документ: {file_name}] {update.message.get('caption')}"
|
||||
await memory_client.save_chat_turn(
|
||||
agent_id=agent_config.agent_id,
|
||||
team_id=dao_id,
|
||||
user_id=f"tg:{user_id}",
|
||||
message=user_msg,
|
||||
response=answer_text,
|
||||
channel_id=chat_id,
|
||||
scope="short_term",
|
||||
save_agent_response=True,
|
||||
agent_metadata={"context": "document", "file_name": file_name, "doc_id": result.doc_id},
|
||||
username=username,
|
||||
)
|
||||
logger.info(f"{agent_config.name}: Document chat turn saved to memory: {file_name}")
|
||||
# === END SAVE TO CHAT HISTORY ===
|
||||
|
||||
# === AUTO-INGEST: Store document in agent Qdrant _docs collection ===
|
||||
if doc_text:
|
||||
try:
|
||||
import httpx as _httpx
|
||||
router_url = os.getenv("ROUTER_URL", "http://router:8000")
|
||||
async with _httpx.AsyncClient(timeout=60.0) as _client:
|
||||
ingest_resp = await _client.post(
|
||||
f"{router_url}/v1/documents/ingest",
|
||||
json={
|
||||
"agent_id": agent_config.agent_id,
|
||||
"doc_id": result.doc_id,
|
||||
"file_name": file_name,
|
||||
"text": doc_text,
|
||||
"dao_id": dao_id,
|
||||
"user_id": f"tg:{user_id}"
|
||||
}
|
||||
)
|
||||
ingest_data = ingest_resp.json()
|
||||
if ingest_data.get("ok"):
|
||||
logger.info(f"{agent_config.name}: Document ingested to Qdrant: {ingest_data.get('chunks_stored', 0)} chunks")
|
||||
else:
|
||||
logger.warning(f"{agent_config.name}: Document ingest failed: {ingest_data.get('error')}")
|
||||
except Exception as ingest_err:
|
||||
logger.warning(f"{agent_config.name}: Document auto-ingest error: {ingest_err}")
|
||||
# === END AUTO-INGEST ===
|
||||
|
||||
await send_telegram_message(chat_id, answer_text, telegram_token)
|
||||
return {"ok": True, "agent": "parser", "mode": "doc_parse", "doc_id": result.doc_id}
|
||||
|
||||
@@ -937,10 +1223,15 @@ async def process_voice(
|
||||
mime_type or "audio/ogg",
|
||||
)
|
||||
}
|
||||
# Swapper /stt expects: file (multipart), model (form), language (form)
|
||||
form_data = {
|
||||
"model": "whisper-small",
|
||||
"task": "transcribe",
|
||||
}
|
||||
|
||||
logger.info(f"{agent_config.name}: Sending voice to STT endpoint {stt_upload_url}")
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
stt_resp = await client.post(stt_upload_url, files=files)
|
||||
stt_resp = await client.post(stt_upload_url, files=files, data=form_data)
|
||||
stt_resp.raise_for_status()
|
||||
stt_data = stt_resp.json()
|
||||
text = stt_data.get("text", "")
|
||||
@@ -973,6 +1264,21 @@ async def process_voice(
|
||||
# Universal Telegram Webhook Handler
|
||||
# ========================================
|
||||
|
||||
# === UPDATE DEDUPLICATION ===
|
||||
import time as _time
|
||||
_PROCESSED_UPDATES: Dict[int, float] = {} # update_id -> timestamp
|
||||
_DEDUP_MAX_SIZE = 2000
|
||||
_DEDUP_TTL = 300 # 5 minutes
|
||||
|
||||
def _dedup_cleanup():
|
||||
"""Remove old entries from dedup cache."""
|
||||
now = _time.time()
|
||||
expired = [uid for uid, ts in _PROCESSED_UPDATES.items() if now - ts > _DEDUP_TTL]
|
||||
for uid in expired:
|
||||
del _PROCESSED_UPDATES[uid]
|
||||
# === END DEDUPLICATION ===
|
||||
|
||||
|
||||
async def handle_telegram_webhook(
|
||||
agent_config: AgentConfig,
|
||||
update: TelegramUpdate
|
||||
@@ -989,6 +1295,16 @@ async def handle_telegram_webhook(
|
||||
"""
|
||||
# Allow updates without message if they contain photo/voice
|
||||
# The actual message validation happens after multimodal checks
|
||||
# === DEDUP CHECK ===
|
||||
if update.update_id:
|
||||
if update.update_id in _PROCESSED_UPDATES:
|
||||
logger.info(f"🔄 Skipping duplicate update_id={update.update_id} for {agent_config.name}")
|
||||
return {"status": "ok", "skipped": "duplicate_update"}
|
||||
_PROCESSED_UPDATES[update.update_id] = _time.time()
|
||||
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
|
||||
_dedup_cleanup()
|
||||
# === END DEDUP CHECK ===
|
||||
|
||||
if not update.message:
|
||||
if update.channel_post:
|
||||
update.message = update.channel_post
|
||||
@@ -1009,7 +1325,7 @@ async def handle_telegram_webhook(
|
||||
is_sender_bot = bool(from_user.get("is_bot") or (username and username.lower().endswith("bot")))
|
||||
|
||||
# Get DAO ID for this chat
|
||||
dao_id = get_dao_id(chat_id, "telegram")
|
||||
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
|
||||
|
||||
# Оновлюємо факти про користувача/агента для побудови графу пам'яті
|
||||
asyncio.create_task(
|
||||
@@ -1637,6 +1953,7 @@ async def handle_telegram_webhook(
|
||||
"mentioned_bots": mentioned_bots,
|
||||
"requires_complex_reasoning": needs_complex_reasoning,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
return {"ok": True, "agent": agent_config.agent_id, "cached": True}
|
||||
|
||||
@@ -1674,26 +1991,56 @@ async def handle_telegram_webhook(
|
||||
# Fall through to regular chat if RAG query fails
|
||||
|
||||
# ========================================
|
||||
# BEHAVIOR POLICY v1: Check if should respond
|
||||
# BEHAVIOR POLICY v2.1: Check if should respond
|
||||
# Gateway computes has_link and has_explicit_request (source of truth)
|
||||
# ========================================
|
||||
chat_type = chat.get("type", "private")
|
||||
is_private_chat = chat_type == "private"
|
||||
|
||||
# Check if message has media (photo already handled above, check for links)
|
||||
has_link = bool(re.search(r'https?://\S+', text)) if text else False
|
||||
# Gateway: compute has_link (single source of truth)
|
||||
has_link = detect_url(text) if text else False
|
||||
|
||||
respond_decision, respond_reason = should_respond(
|
||||
# Gateway: detect mentioned agents
|
||||
mentioned_agents = []
|
||||
if text:
|
||||
for aid, variants in AGENT_NAME_VARIANTS.items():
|
||||
for v in variants:
|
||||
if v.lower() in text.lower():
|
||||
mentioned_agents.append(aid)
|
||||
break
|
||||
|
||||
# Gateway: compute has_explicit_request (single source of truth)
|
||||
# CONTRACT: imperative OR (? AND (dm OR reply OR mention OR thread))
|
||||
has_explicit_request = detect_explicit_request(
|
||||
text=text,
|
||||
is_dm=is_private_chat,
|
||||
is_reply_to_agent=False, # TODO: detect from Telegram reply_to_message
|
||||
mentioned_agents=mentioned_agents,
|
||||
thread_has_agent_participation=False, # REQUIRED, fail-closed default
|
||||
)
|
||||
|
||||
# Check if this is a prober request (chat_id=0 or user_id=0)
|
||||
is_prober = is_prober_request(chat_id, user_id)
|
||||
|
||||
# SOWA v2.2: 3-level decision (FULL / ACK / SILENT)
|
||||
sowa_decision = analyze_message(
|
||||
text=text,
|
||||
agent_id=agent_config.agent_id,
|
||||
chat_id=chat_id,
|
||||
has_media=has_link, # Links treated as media
|
||||
user_id=str(user_id),
|
||||
has_media=has_link,
|
||||
media_caption=text if has_link else "",
|
||||
is_private_chat=is_private_chat,
|
||||
payload_explicit_request=False,
|
||||
payload_explicit_request=has_explicit_request,
|
||||
payload_has_link=has_link,
|
||||
is_reply_to_agent=False, # TODO: detect from Telegram reply_to_message
|
||||
thread_has_agent_participation=False, # TODO: track per thread
|
||||
)
|
||||
respond_decision = sowa_decision.should_respond
|
||||
respond_reason = sowa_decision.reason
|
||||
|
||||
if not respond_decision:
|
||||
logger.info(f"🔇 SOWA: Agent {agent_config.agent_id} NOT responding. Reason: {respond_reason}")
|
||||
if sowa_decision.action == "SILENT":
|
||||
logger.info(f"\U0001f507 SOWA: Agent {agent_config.agent_id} NOT responding. Reason: {respond_reason}")
|
||||
# Save to memory for context tracking, but don't respond
|
||||
await memory_client.save_chat_turn(
|
||||
agent_id=agent_config.agent_id,
|
||||
@@ -1708,10 +2055,61 @@ async def handle_telegram_webhook(
|
||||
"sowa_skipped": True,
|
||||
"skip_reason": respond_reason,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
return {"ok": True, "skipped": True, "reason": respond_reason}
|
||||
|
||||
logger.info(f"✅ SOWA: Agent {agent_config.agent_id} WILL respond. Reason: {respond_reason}")
|
||||
# ACK: send short presence message WITHOUT calling LLM/Router
|
||||
if sowa_decision.action == "ACK":
|
||||
ack_text = sowa_decision.ack_text or get_ack_text(agent_config.agent_id)
|
||||
logger.info(f"\U0001f44b SOWA ACK: Agent {agent_config.agent_id} sending ACK. Reason: {respond_reason}")
|
||||
|
||||
# Send ACK to Telegram (no LLM call)
|
||||
if not is_prober:
|
||||
token = agent_config.get_telegram_token()
|
||||
if token:
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(url, json={
|
||||
"chat_id": chat_id,
|
||||
"text": ack_text,
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"\U0001f44b ACK sent to chat {chat_id}: {ack_text}")
|
||||
else:
|
||||
logger.warning(f"ACK send failed: {resp.status_code} {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
logger.error(f"ACK send error: {e}")
|
||||
|
||||
# Record ACK for cooldown and interaction tracking
|
||||
record_ack(agent_config.agent_id, str(chat_id))
|
||||
record_interaction(agent_config.agent_id, str(chat_id), str(user_id))
|
||||
|
||||
# Save to memory
|
||||
await memory_client.save_chat_turn(
|
||||
agent_id=agent_config.agent_id,
|
||||
team_id=dao_id,
|
||||
user_id=f"tg:{user_id}",
|
||||
message=text,
|
||||
response=ack_text,
|
||||
channel_id=chat_id,
|
||||
scope="short_term",
|
||||
save_agent_response=True,
|
||||
agent_metadata={
|
||||
"sowa_ack": True,
|
||||
"ack_reason": respond_reason,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
return {"ok": True, "ack": True, "reason": respond_reason}
|
||||
|
||||
# FULL: proceed with LLM/Router call
|
||||
# For prober requests, respond but don't send to Telegram
|
||||
if is_prober:
|
||||
logger.info(f"\U0001f9ea PROBER: Agent {agent_config.agent_id} responding to prober request. Reason: {respond_reason}")
|
||||
else:
|
||||
logger.info(f"\u2705 SOWA: Agent {agent_config.agent_id} WILL respond (FULL). Reason: {respond_reason}")
|
||||
|
||||
# Regular chat mode
|
||||
# Fetch memory context (includes local context as fallback)
|
||||
@@ -1743,11 +2141,11 @@ async def handle_telegram_webhook(
|
||||
|
||||
# Build request to Router
|
||||
system_prompt = agent_config.system_prompt
|
||||
logger.info(f"📝 Helion system_prompt length: {len(system_prompt) if system_prompt else 0} chars")
|
||||
logger.info(f"📝 {agent_config.name} system_prompt length: {len(system_prompt) if system_prompt else 0} chars")
|
||||
if system_prompt:
|
||||
logger.debug(f"System prompt preview: {system_prompt[:200]}...")
|
||||
else:
|
||||
logger.error(f"❌ Helion system_prompt is EMPTY or None!")
|
||||
logger.error(f"❌ {agent_config.name} system_prompt is EMPTY or None!")
|
||||
|
||||
router_request = {
|
||||
"message": message_with_context,
|
||||
@@ -1776,8 +2174,10 @@ async def handle_telegram_webhook(
|
||||
}
|
||||
|
||||
if should_force_concise_reply(text):
|
||||
# IMPORTANT: preserve conversation context! Only append concise instruction
|
||||
router_request["message"] = (
|
||||
f"{text}\n\n(Інструкція: дай максимально коротку відповідь, якщо не просили деталей "
|
||||
router_request["message"]
|
||||
+ "\n\n(Інструкція: дай максимально коротку відповідь, якщо не просили деталей "
|
||||
"і дочекайся додаткового питання.)"
|
||||
)
|
||||
|
||||
@@ -1805,6 +2205,33 @@ async def handle_telegram_webhook(
|
||||
# Check for NO_OUTPUT (LLM decided not to respond)
|
||||
if is_no_output_response(answer_text):
|
||||
logger.info(f"🔇 NO_OUTPUT: Agent {agent_config.agent_id} returned empty/NO_OUTPUT. Not sending to Telegram.")
|
||||
|
||||
# P4: Detect NO_OUTPUT contract violations (extra text after NO_OUTPUT marker)
|
||||
_stripped = (answer_text or "").strip()
|
||||
_has_extra = False
|
||||
if _stripped and "__NO_OUTPUT__" in _stripped:
|
||||
_after_marker = _stripped.split("__NO_OUTPUT__", 1)[-1].strip()
|
||||
if _after_marker:
|
||||
_has_extra = True
|
||||
logger.warning(
|
||||
f"🚨 policy_violation=no_output_extra_text "
|
||||
f"agent={agent_config.agent_id} "
|
||||
f"chat_id={chat_id} "
|
||||
f"extra_text_len={len(_after_marker)} "
|
||||
f"extra_preview={_after_marker[:80]!r}"
|
||||
)
|
||||
elif _stripped and _stripped.lower() not in ("", "no_output", "no output", "silent", "мовчу", "—", ".", "..", "..."):
|
||||
# LLM returned something that looks like NO_OUTPUT but has unexpected content
|
||||
if len(_stripped) > 10:
|
||||
_has_extra = True
|
||||
logger.warning(
|
||||
f"🚨 policy_violation=ambiguous_no_output "
|
||||
f"agent={agent_config.agent_id} "
|
||||
f"chat_id={chat_id} "
|
||||
f"response_len={len(_stripped)} "
|
||||
f"response_preview={_stripped[:80]!r}"
|
||||
)
|
||||
|
||||
# Save to memory for context tracking
|
||||
await memory_client.save_chat_turn(
|
||||
agent_id=agent_config.agent_id,
|
||||
@@ -1818,7 +2245,9 @@ async def handle_telegram_webhook(
|
||||
agent_metadata={
|
||||
"no_output": True,
|
||||
"original_response": answer_text[:100] if answer_text else "",
|
||||
"policy_violation": "no_output_extra_text" if _has_extra else None,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
return {"ok": True, "skipped": True, "reason": "no_output_from_llm"}
|
||||
|
||||
@@ -1826,6 +2255,11 @@ async def handle_telegram_webhook(
|
||||
if len(answer_text) > TELEGRAM_SAFE_LENGTH:
|
||||
answer_text = answer_text[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
|
||||
|
||||
# Skip Telegram sending for prober requests (chat_id=0)
|
||||
if is_prober:
|
||||
logger.info(f"🧪 PROBER: Skipping Telegram send for prober request. Response: {answer_text[:100]}...")
|
||||
return {"ok": True, "agent": agent_config.agent_id, "prober": True, "response_preview": answer_text[:100]}
|
||||
|
||||
# Send image if generated
|
||||
if image_base64:
|
||||
try:
|
||||
@@ -1850,6 +2284,9 @@ async def handle_telegram_webhook(
|
||||
# Send text response only
|
||||
await send_telegram_message(chat_id, answer_text, telegram_token)
|
||||
|
||||
# Record successful interaction for conversation context
|
||||
record_interaction(agent_config.agent_id, chat_id, str(user_id))
|
||||
|
||||
await memory_client.save_chat_turn(
|
||||
agent_id=agent_config.agent_id,
|
||||
team_id=dao_id,
|
||||
@@ -1863,6 +2300,7 @@ async def handle_telegram_webhook(
|
||||
"mentioned_bots": mentioned_bots,
|
||||
"requires_complex_reasoning": needs_complex_reasoning,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
|
||||
store_response_cache(agent_config.agent_id, chat_id, text, answer_text)
|
||||
@@ -1880,6 +2318,15 @@ async def handle_telegram_webhook(
|
||||
# ========================================
|
||||
|
||||
# DAARWIZZ webhook endpoints (both paths for compatibility)
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz():
|
||||
try:
|
||||
from crews.agromatrix_crew.run import handle_message # noqa: F401
|
||||
return {"ok": True, "status": "healthy"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "status": "error", "error": str(e)}
|
||||
|
||||
@router.post("/telegram/webhook")
|
||||
async def telegram_webhook(update: TelegramUpdate):
|
||||
"""Handle Telegram webhook for DAARWIZZ agent (default path)."""
|
||||
@@ -1916,7 +2363,7 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
||||
username = from_user.get("username", "")
|
||||
|
||||
# Get DAO ID for this chat
|
||||
dao_id = get_dao_id(chat_id, "telegram")
|
||||
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
|
||||
|
||||
# Check for /ingest command
|
||||
text = update.message.get("text", "")
|
||||
@@ -2133,6 +2580,7 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
||||
scope="short_term",
|
||||
save_agent_response=not is_service_response(answer_text),
|
||||
agent_metadata={"context": "photo"},
|
||||
username=username,
|
||||
)
|
||||
|
||||
return {"ok": True, "agent": "daarwizz", "model": "specialist_vision_8b"}
|
||||
@@ -2261,7 +2709,7 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
||||
agent_id="daarwizz",
|
||||
team_id=dao_id,
|
||||
channel_id=chat_id,
|
||||
limit=10
|
||||
limit=80
|
||||
)
|
||||
|
||||
# Build request to Router with DAARWIZZ context
|
||||
@@ -2308,6 +2756,7 @@ async def _old_telegram_webhook(update: TelegramUpdate):
|
||||
scope="short_term",
|
||||
save_agent_response=not is_service_response(answer_text),
|
||||
agent_metadata={"context": "legacy_daarwizz"},
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Send response back to Telegram
|
||||
@@ -2357,7 +2806,7 @@ async def discord_webhook(message: DiscordMessage):
|
||||
agent_id="daarwizz",
|
||||
team_id=dao_id,
|
||||
channel_id=channel_id,
|
||||
limit=10
|
||||
limit=80
|
||||
)
|
||||
|
||||
# Build request to Router with DAARWIZZ context
|
||||
@@ -2403,6 +2852,7 @@ async def discord_webhook(message: DiscordMessage):
|
||||
scope="short_term",
|
||||
save_agent_response=not is_service_response(answer_text),
|
||||
agent_metadata={"source": "discord"},
|
||||
username=username,
|
||||
)
|
||||
|
||||
# TODO: Send response back to Discord
|
||||
@@ -2841,7 +3291,7 @@ async def _old_helion_telegram_webhook(update: TelegramUpdate):
|
||||
username = from_user.get("username", "")
|
||||
|
||||
# Get DAO ID for this chat (Energy Union specific)
|
||||
dao_id = get_dao_id(chat_id, "telegram")
|
||||
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
|
||||
|
||||
# Check for /ingest command
|
||||
text = update.message.get("text", "")
|
||||
@@ -3054,6 +3504,7 @@ async def _old_helion_telegram_webhook(update: TelegramUpdate):
|
||||
scope="short_term",
|
||||
save_agent_response=not is_service_response(answer_text),
|
||||
agent_metadata={"context": "photo"},
|
||||
username=username,
|
||||
)
|
||||
|
||||
return {"ok": True, "agent": "helion", "model": "specialist_vision_8b"}
|
||||
@@ -3117,12 +3568,13 @@ async def _old_helion_telegram_webhook(update: TelegramUpdate):
|
||||
|
||||
# Regular chat mode
|
||||
# Fetch memory context (includes local context as fallback)
|
||||
# All agents use limit=80 for full conversation history
|
||||
memory_context = await memory_client.get_context(
|
||||
user_id=f"tg:{user_id}",
|
||||
agent_id="helion",
|
||||
team_id=dao_id,
|
||||
channel_id=chat_id,
|
||||
limit=10
|
||||
limit=80
|
||||
)
|
||||
|
||||
# Build message with conversation context
|
||||
@@ -3190,6 +3642,7 @@ async def _old_helion_telegram_webhook(update: TelegramUpdate):
|
||||
"mentioned_bots": mentioned_bots,
|
||||
"requires_complex_reasoning": needs_complex_reasoning,
|
||||
},
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Send response back to Telegram
|
||||
|
||||
Reference in New Issue
Block a user