node1: add universal file tool, gateway document delivery, and sync runbook

This commit is contained in:
Apple
2026-02-15 01:50:37 -08:00
parent dd4b466d79
commit 21576f0ca3
7 changed files with 2207 additions and 131 deletions

View File

@@ -26,45 +26,64 @@ FULL_STANDARD_STACK = [
"presentation_create",
"presentation_status",
"presentation_download",
# File artifacts
"file_tool",
]
# Specialized tools per agent (on top of standard stack)
AGENT_SPECIALIZED_TOOLS = {
# Helion - Energy platform
# Specialized: energy calculations, solar/wind analysis
"helion": [],
"helion": ['comfy_generate_image', 'comfy_generate_video'],
# Alateya - R&D Lab OS
# Specialized: experiment tracking, hypothesis testing
"alateya": [],
"alateya": ['comfy_generate_image', 'comfy_generate_video'],
# Nutra - Health & Nutrition
# Specialized: nutrition calculations, supplement analysis
"nutra": [],
"nutra": ['comfy_generate_image', 'comfy_generate_video'],
# AgroMatrix - Agriculture
# Specialized: crop analysis, weather integration, field mapping
"agromatrix": [],
"agromatrix": ['comfy_generate_image', 'comfy_generate_video'],
# GreenFood - Food & Eco
# Specialized: recipe analysis, eco-scoring
"greenfood": [],
"greenfood": ['comfy_generate_image', 'comfy_generate_video'],
# Druid - Knowledge Search
# Specialized: deep RAG, document comparison
"druid": [],
"druid": ['comfy_generate_image', 'comfy_generate_video'],
# DaarWizz - DAO Coordination
# Specialized: governance tools, voting, treasury
"daarwizz": [],
"daarwizz": ['comfy_generate_image', 'comfy_generate_video'],
# Clan - Community
# Specialized: event management, polls, member tracking
"clan": [],
"clan": ['comfy_generate_image', 'comfy_generate_video'],
# Eonarch - Philosophy & Evolution
# Specialized: concept mapping, timeline analysis
"eonarch": [],
"eonarch": ['comfy_generate_image', 'comfy_generate_video'],
# SenpAI (Gordon Senpai) - Trading & Markets
# Specialized: real-time market data, features, signals
"senpai": ['market_data', 'comfy_generate_image', 'comfy_generate_video'],
# Soul / Athena - Spiritual Mentor
"soul": ['comfy_generate_image', 'comfy_generate_video'],
# Yaromir - Tech Lead
"yaromir": ['comfy_generate_image', 'comfy_generate_video'],
# Sofiia - Chief AI Architect
"sofiia": ['comfy_generate_image', 'comfy_generate_video'],
# Daarion - Media Generation
"daarion": ['comfy_generate_image', 'comfy_generate_video'],
}
# CrewAI team structure per agent (future implementation)

View File

@@ -9,6 +9,7 @@ import re
import yaml
import httpx
import logging
import hashlib
import time # For latency metrics
# CrewAI Integration
@@ -40,6 +41,30 @@ except ImportError:
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
TRUSTED_DOMAINS_CONFIG_PATH = os.getenv("TRUSTED_DOMAINS_CONFIG_PATH", "./trusted_domains.yml")
_trusted_domains_cache: Dict[str, Any] = {"mtime": None, "data": {}}
def _load_trusted_domains_overrides() -> Dict[str, Any]:
"""Load optional trusted domains overrides editable by mentors."""
global _trusted_domains_cache
try:
if not os.path.exists(TRUSTED_DOMAINS_CONFIG_PATH):
return {}
mtime = os.path.getmtime(TRUSTED_DOMAINS_CONFIG_PATH)
if _trusted_domains_cache.get("mtime") == mtime:
return _trusted_domains_cache.get("data") or {}
with open(TRUSTED_DOMAINS_CONFIG_PATH, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
if not isinstance(raw, dict):
raw = {}
_trusted_domains_cache = {"mtime": mtime, "data": raw}
return raw
except Exception as e:
logger.warning(f"⚠️ Failed to load trusted domains overrides: {e}")
return {}
def _strip_dsml_keep_text_before(text: str) -> str:
"""If response contains DSML, return only the part before the first DSML-like tag. Otherwise return empty (caller will use fallback)."""
@@ -69,6 +94,499 @@ def _strip_dsml_keep_text_before(text: str) -> str:
return prefix if len(prefix) > 30 else ""
def _vision_prompt_wants_web(prompt: str) -> bool:
if not prompt:
return False
p = prompt.lower()
markers = [
"знайди", "пошукай", "пошук", "в інтернет", "в інтернеті", "у відкритих джерелах",
"що це", "що на фото", "який це", "яка це", "identify", "find online", "search web",
"назва", "бренд", "виробник", "інструкція", "дозування", "регламент", "де купити", "ціна",
]
return any(m in p for m in markers)
def _vision_answer_uncertain(answer: str) -> bool:
if not answer:
return True
a = answer.lower()
uncertain_markers = [
"ймовірно", "можливо", "схоже", "не впевнений", "не можу визначити", "важко сказати",
"probably", "maybe", "looks like", "not sure", "cannot identify"
]
return any(m in a for m in uncertain_markers)
EMPTY_ANSWER_GUARD_AGENTS = {"devtools", "monitor"}
def _normalize_text_response(text: str) -> str:
return re.sub(r"\s+", " ", str(text or "")).strip()
def _needs_empty_answer_recovery(text: str) -> bool:
normalized = _normalize_text_response(text)
if not normalized:
return True
low = normalized.lower()
if len(normalized) < 8:
return True
meta_markers = (
"the user", "user asked", "i need", "let me", "analysis", "thinking",
"користувач", "потрібно", "спочатку", "сначала"
)
if any(m in low for m in meta_markers) and len(normalized) < 80:
return True
if normalized in {"...", "ok", "done"}:
return True
return False
def _image_response_needs_retry(text: str) -> bool:
normalized = _normalize_text_response(text)
if _needs_empty_answer_recovery(normalized):
return True
low = normalized.lower()
blocked_markers = (
"не можу бачити", "не можу аналізувати зображення", "опишіть фото словами",
"cannot view images", "cannot analyze image", "as a text model"
)
if any(m in low for m in blocked_markers):
return True
return len(normalized) < 24
def _vision_response_is_blurry(text: str) -> bool:
low = _normalize_text_response(text).lower()
if not low:
return False
blurry_markers = (
"розмит", "нечітк", "не дуже чітк", "blur", "blurry", "out of focus", "low quality"
)
return any(m in low for m in blurry_markers)
def _build_image_fallback_response(agent_id: str, prompt: str = "") -> str:
if (agent_id or "").lower() == "agromatrix":
return (
"Фото поки занадто нечітке, тому діагноз неточний. "
"Надішли, будь ласка, 2-3 чіткі фото: загальний вигляд рослини, крупний план проблемної ділянки "
"і (для листка) нижній бік. Якщо можеш, додай культуру та стадію росту."
)
return "Я поки не бачу достатньо деталей на фото. Надішли, будь ласка, чіткіше фото або крупний план об'єкта."
def _sanitize_vision_text_for_user(text: str) -> str:
if not text:
return ""
normalized = re.sub(r"\s+", " ", str(text)).strip()
if not normalized:
return ""
sentences = [seg.strip() for seg in re.split(r"(?<=[.!?])\s+", normalized) if seg.strip()]
meta_markers = (
"okay", "the user", "user sent", "they want", "i need", "let me", "i will",
"first, look at the image", "look at the image", "first, analyze",
"first, looking at the image", "looking at the image",
"хорошо", "користувач", "пользователь", "потрібно", "нужно", "спочатку", "сначала"
)
cleaned = [sent for sent in sentences if not any(m in sent.lower() for m in meta_markers)]
if cleaned:
out = " ".join(cleaned[:3]).strip()
else:
# If text is only meta-reasoning, prefer empty over leaking service text to user.
if any(m in normalized.lower() for m in meta_markers):
return ""
out = " ".join(sentences[:3]).strip()
if len(out) > 700:
out = out[:700].rsplit(" ", 1)[0] + "..."
return out
def _extract_vision_search_facts(text: str, max_chars: int = 220) -> str:
fact = _sanitize_vision_text_for_user(text)
# If sanitizer dropped everything (meta-only), try to recover object phrase.
if not fact and text:
raw = re.sub(r"\s+", " ", str(text)).strip()
raw = re.sub(r"(?i)^okay,?\s*", "", raw)
raw = re.sub(r"(?i)^let\'s\s+see\.?\s*", "", raw)
raw = re.sub(r"(?i)^the user sent (an image|a photo|a picture) of\s+", "", raw)
raw = re.sub(r"(?i)^user sent (an image|a photo|a picture) of\s+", "", raw)
raw = re.sub(r"(?i)^an image of\s+", "", raw)
raw = re.sub(r"(?i)they want.*$", "", raw).strip(" .")
fact = raw
if not fact:
return ""
fact = re.sub(r"(?i)джерела\s*:\s*.*$", "", fact).strip()
fact = re.sub(r"[*_`#\[\]()]", "", fact)
fact = re.sub(r"\s{2,}", " ", fact).strip(" .,")
if len(fact) > max_chars:
fact = fact[:max_chars].rsplit(" ", 1)[0]
return fact
def _build_vision_web_query(prompt: str, vision_text: str) -> str:
# Keep query compact and deterministic for web_search tool.
source_intent = any(k in (prompt or "").lower() for k in ("джерел", "підтвердж", "source", "reference"))
prompt_part = (prompt or "").strip()
# Remove generic question wrappers that pollute search quality.
prompt_part = re.sub(r"(?i)що\s*це\s*на\s*фото\??", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)дай\s*2-?3\s*джерела", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)дай\s*\d+\s*джерел[а-я]*\s*для", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)дай\s*\d+\s*джерел[а-я]*", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)знайди\s*в\s*інтернеті", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)знайди\s*в\s*інтернеті\s*схожі\s*джерела", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)підтвердження", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)якщо\s*не\s*впевнений.*$", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?i)пошукай.*$", "", prompt_part).strip(" .")
prompt_part = re.sub(r"(?iu)\bі\b\.?$", "", prompt_part).strip(" .")
vision_part = _extract_vision_search_facts(vision_text)
if vision_part:
tokens = re.findall(r"[a-zA-Zа-яА-ЯіїєІЇЄ0-9]{3,}", vision_part.lower())
generic_tokens = {
"first", "look", "image", "photo", "picture", "context", "the", "and",
"спочатку", "подивись", "зображення", "фото", "картинка", "контекст",
}
if len(tokens) < 3 or len(vision_part) < 18 or all(t in generic_tokens for t in tokens):
# Too vague entity extraction (e.g., single word "rex") -> skip web enrichment.
return ""
if vision_part:
if prompt_part:
q = f"{vision_part}. контекст: {prompt_part}".strip(" .")
else:
q = vision_part
if source_intent:
q = f"{q} wikipedia encyclopedia"
return q.strip()
return ""
def _compact_web_search_result(raw: str, query: str = "", agent_id: str = "", max_chars: int = 900) -> str:
if not raw:
return ""
text = str(raw).strip()
if not text:
return ""
def _extract_domain(url: str) -> str:
if not url:
return ""
d = url.lower().strip()
d = d.replace("https://", "").replace("http://", "")
d = d.split("/")[0]
if d.startswith("www."):
d = d[4:]
return d
low_signal_tokens = (
"grid maker", "converter", "convert", "download", "wallpaper", "stock photo",
"instagram", "pinterest", "tiktok", "youtube", "facebook", "generator", "meme",
)
low_signal_domains = (
"pinterest.com", "instagram.com", "tiktok.com", "youtube.com",
"facebook.com", "vk.com", "yandex.", "stackexchange.com",
"zhihu.com", "baidu.com",
)
trusted_common_domains = (
"wikipedia.org", "wikidata.org", "britannica.com",
"who.int", "fao.org", "oecd.org", "worldbank.org", "un.org", "europa.eu",
"nature.com", "science.org", "sciencedirect.com", "springer.com",
)
trusted_agro_domains = (
"fao.org", "europa.eu", "ec.europa.eu", "usda.gov", "nass.usda.gov",
"ukragroconsult.com", "minagro.gov.ua", "rada.gov.ua", "kmu.gov.ua",
"agroportal.ua", "latifundist.com", "kurkul.com",
)
trusted_by_agent = {
"agromatrix": trusted_agro_domains,
"alateya": (
"europa.eu", "un.org", "worldbank.org", "oecd.org",
),
"clan": (
"europa.eu", "un.org", "wikipedia.org",
),
"daarwizz": (
"openai.com", "anthropic.com", "mistral.ai", "huggingface.co",
"python.org", "github.com",
),
"devtools": (
"github.com", "docs.python.org", "pypi.org", "docker.com",
"kubernetes.io", "fastapi.tiangolo.com", "postgresql.org",
),
"druid": (
"who.int", "nih.gov", "ncbi.nlm.nih.gov", "wikipedia.org",
),
"eonarch": (
"iea.org", "irena.org", "entsoe.eu", "europa.eu", "worldbank.org",
),
"greenfood": (
"fao.org", "who.int", "efsa.europa.eu", "usda.gov", "ec.europa.eu",
),
"senpai": (
"binance.com", "bybit.com", "coinbase.com", "kraken.com",
"coindesk.com", "cointelegraph.com", "tradingview.com",
"cftc.gov", "sec.gov", "esma.europa.eu",
),
"sofiia": (
"who.int", "nih.gov", "ncbi.nlm.nih.gov", "ema.europa.eu",
"fda.gov", "mayoclinic.org", "nhs.uk",
),
"helion": (
"iea.org", "irena.org", "entsoe.eu", "europa.eu", "worldbank.org",
),
"nutra": (
"fao.org", "who.int", "efsa.europa.eu", "fda.gov",
),
"microdao_orchestrator": (
"openai.com", "anthropic.com", "mistral.ai", "github.com",
"europa.eu", "un.org", "worldbank.org",
),
"monitor": (
"grafana.com", "prometheus.io", "elastic.co", "datadoghq.com",
"opentelemetry.io",
),
"soul": (
"who.int", "nih.gov", "ncbi.nlm.nih.gov", "wikipedia.org",
),
"yaromir": (
"europa.eu", "un.org", "worldbank.org", "wikipedia.org",
),
}
def _norm_domain_entry(value: Any) -> str:
if isinstance(value, dict):
value = value.get("url") or value.get("domain") or ""
value = str(value or "").strip().lower()
if not value:
return ""
value = value.replace("https://", "").replace("http://", "")
value = value.split("/")[0]
if value.startswith("www."):
value = value[4:]
return value
def _norm_domain_list(values: Any) -> List[str]:
out: List[str] = []
if not isinstance(values, list):
return out
for v in values:
d = _norm_domain_entry(v)
if d:
out.append(d)
return out
overrides = _load_trusted_domains_overrides()
extra_low_signal = _norm_domain_list(overrides.get("low_signal_domains"))
if extra_low_signal:
low_signal_domains = tuple(dict.fromkeys([*low_signal_domains, *extra_low_signal]))
extra_common = _norm_domain_list(overrides.get("common_domains"))
if extra_common:
trusted_common_domains = tuple(dict.fromkeys([*trusted_common_domains, *extra_common]))
agents_overrides = overrides.get("agents") if isinstance(overrides.get("agents"), dict) else {}
for a, cfg in agents_overrides.items():
if not isinstance(cfg, dict):
continue
doms = _norm_domain_list(cfg.get("domains"))
if doms:
base = trusted_by_agent.get(str(a).lower(), ())
merged = tuple(dict.fromkeys([*base, *doms]))
trusted_by_agent[str(a).lower()] = merged
agro_query_terms = {
"агро", "agro", "crop", "crops", "fertilizer", "fertilizers",
"field", "soil", "harvest", "yield", "pesticide", "herbicide",
"farm", "farming", "tractor", "зерно", "пшениц", "кукурудз",
"соняшник", "ріпак", "врожай", "ґрунт", "поле", "добрив",
"насіння", "ззр", "фермер",
}
query_terms = {t for t in re.findall(r"[a-zA-Zа-яА-ЯіїєІЇЄ0-9]{3,}", (query or "").lower())}
agro_mode = any(any(k in term for k in agro_query_terms) for term in query_terms)
agent_trusted_domains = trusted_by_agent.get((agent_id or "").lower(), ())
# Parse bullet blocks from tool output.
chunks = []
current = []
for line in text.splitlines():
ln = line.rstrip()
if ln.startswith("- ") and current:
chunks.append("\n".join(current))
current = [ln]
else:
current.append(ln)
if current:
chunks.append("\n".join(current))
scored = []
for chunk in chunks:
lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()]
title = lines[0][2:].strip() if lines and lines[0].startswith("- ") else (lines[0] if lines else "")
url_line = next((ln for ln in lines if ln.lower().startswith("url:")), "")
url = url_line.split(":", 1)[1].strip() if ":" in url_line else ""
domain = _extract_domain(url)
text_blob = " ".join(lines).lower()
if any(x in domain for x in low_signal_domains):
continue
score = 0
for t in query_terms:
if t in text_blob:
score += 2
if any(tok in text_blob for tok in low_signal_tokens):
score -= 3
if domain.endswith(".gov") or domain.endswith(".gov.ua") or domain.endswith(".edu"):
score += 2
if any(domain == d or domain.endswith("." + d) for d in trusted_common_domains):
score += 2
if any(domain == d or domain.endswith("." + d) for d in agent_trusted_domains):
score += 2
if any(domain.endswith(d) for d in ("wikipedia.org", "wikidata.org", "fao.org", "europa.eu")):
score += 2
if agro_mode:
if any(domain == d or domain.endswith("." + d) for d in trusted_agro_domains):
score += 3
else:
score -= 1
if not url:
score -= 1
if len(title) < 6:
score -= 1
scored.append((score, domain, chunk))
def _is_trusted_agro(domain: str) -> bool:
if not domain:
return False
if any(domain == d or domain.endswith("." + d) for d in trusted_common_domains):
return True
return any(domain == d or domain.endswith("." + d) for d in trusted_agro_domains)
scored.sort(key=lambda x: x[0], reverse=True)
kept = []
seen_domains = set()
if agro_mode:
for s, domain, chunk in scored:
if s < 1 or not _is_trusted_agro(domain):
continue
if domain and domain in seen_domains:
continue
if domain:
seen_domains.add(domain)
kept.append(chunk)
if len(kept) >= 3:
break
if kept:
compact = "\n\n".join(kept).strip()
if len(compact) > max_chars:
compact = compact[:max_chars].rstrip() + "..."
return compact
for s, domain, chunk in scored:
if s < 2:
continue
if domain and domain in seen_domains:
continue
if domain:
seen_domains.add(domain)
kept.append(chunk)
if len(kept) >= 3:
break
if not kept:
return ""
compact = "\n\n".join(kept).strip()
if len(compact) > max_chars:
compact = compact[:max_chars].rstrip() + "..."
return compact
def _extract_sources_from_compact(compact: str, max_items: int = 3) -> List[Dict[str, str]]:
if not compact:
return []
items: List[Dict[str, str]] = []
chunks = [c for c in compact.split("\n\n") if c.strip()]
for chunk in chunks:
lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()]
if not lines:
continue
title = lines[0][2:].strip() if lines[0].startswith("- ") else lines[0]
url_line = next((ln for ln in lines if ln.lower().startswith("url:")), "")
url = url_line.split(":", 1)[1].strip() if ":" in url_line else ""
if not url:
continue
items.append({"title": title[:180], "url": url[:500]})
if len(items) >= max_items:
break
return items
def _condition_matches(cond: Dict[str, Any], agent_id: str, metadata: Dict[str, Any]) -> bool:
"""Minimal matcher for router-config `when` conditions."""
if not isinstance(cond, dict):
return True
meta = metadata or {}
if "agent" in cond and cond.get("agent") != agent_id:
return False
if "mode" in cond and meta.get("mode") != cond.get("mode"):
return False
if "metadata_has" in cond:
key = cond.get("metadata_has")
if key not in meta:
return False
if "metadata_equals" in cond:
eq = cond.get("metadata_equals") or {}
for k, v in eq.items():
if meta.get(k) != v:
return False
if "task_type" in cond:
expected = cond.get("task_type")
actual = meta.get("task_type")
if isinstance(expected, list):
if actual not in expected:
return False
elif actual != expected:
return False
if "api_key_available" in cond:
env_name = cond.get("api_key_available")
if not (isinstance(env_name, str) and os.getenv(env_name)):
return False
if "and" in cond:
clauses = cond.get("and") or []
if not isinstance(clauses, list):
return False
for clause in clauses:
if not _condition_matches(clause, agent_id, meta):
return False
return True
def _select_default_llm(agent_id: str, metadata: Dict[str, Any], base_llm: str, routing_rules: List[Dict[str, Any]]) -> str:
"""Select LLM by first matching routing rule with `use_llm`."""
for rule in routing_rules:
when = rule.get("when", {})
if _condition_matches(when, agent_id, metadata):
use_llm = rule.get("use_llm")
if use_llm:
logger.info(f"🎯 Agent {agent_id} routing rule {rule.get('id', '<no-id>')} -> {use_llm}")
return use_llm
return base_llm
app = FastAPI(title="DAARION Router", version="2.0.0")
# Configuration
@@ -404,6 +922,9 @@ class InferResponse(BaseModel):
tokens_used: Optional[int] = None
backend: str
image_base64: Optional[str] = None # Generated image in base64 format
file_base64: Optional[str] = None
file_name: Optional[str] = None
file_mime: Optional[str] = None
@@ -675,13 +1196,14 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Get system prompt from database or config
system_prompt = request.system_prompt
# Debug logging for system prompt
system_prompt_source = "request"
if system_prompt:
logger.info(f"📝 Received system_prompt from request: {len(system_prompt)} chars")
logger.debug(f"System prompt preview: {system_prompt[:200]}...")
else:
logger.warning(f"⚠️ No system_prompt in request for agent {agent_id}, trying to load...")
system_prompt_source = "city_service"
logger.info(f" No system_prompt in request for agent {agent_id}, loading from configured sources")
if not system_prompt:
try:
from prompt_builder import get_agent_system_prompt
@@ -694,8 +1216,26 @@ async def agent_infer(agent_id: str, request: InferRequest):
except Exception as e:
logger.warning(f"⚠️ Could not load prompt from database: {e}")
# Fallback to config
system_prompt_source = "router_config"
agent_config = router_config.get("agents", {}).get(agent_id, {})
system_prompt = agent_config.get("system_prompt")
if not system_prompt:
system_prompt_source = "empty"
logger.warning(f"⚠️ System prompt unavailable for {agent_id}; continuing with provider defaults")
system_prompt_hash = hashlib.sha256((system_prompt or "").encode("utf-8")).hexdigest()[:12]
effective_metadata = dict(metadata)
effective_metadata["system_prompt_hash"] = system_prompt_hash
effective_metadata["system_prompt_source"] = system_prompt_source
effective_metadata["system_prompt_version"] = (
metadata.get("system_prompt_version")
or f"{agent_id}:{system_prompt_hash}"
)
logger.info(
f"🧩 Prompt meta for {agent_id}: source={system_prompt_source}, "
f"version={effective_metadata['system_prompt_version']}, hash={system_prompt_hash}"
)
# Determine which backend to use
# Use router config to get default model for agent, fallback to qwen3:8b
@@ -713,8 +1253,8 @@ async def agent_infer(agent_id: str, request: InferRequest):
agent_id=agent_id,
prompt=request.prompt,
agent_config=agent_config,
force_crewai=request.metadata.get("force_crewai", False) if request.metadata else False,
metadata=effective_metadata,
force_crewai=effective_metadata.get("force_crewai", False),
)
logger.info(f"🎭 CrewAI decision for {agent_id}: {use_crewai} ({crewai_reason})")
@@ -727,7 +1267,12 @@ async def agent_infer(agent_id: str, request: InferRequest):
context={
"memory_brief": memory_brief_text,
"system_prompt": system_prompt,
"metadata": metadata,
"system_prompt_meta": {
"source": system_prompt_source,
"version": effective_metadata.get("system_prompt_version"),
"hash": system_prompt_hash,
},
"metadata": effective_metadata,
},
team=crewai_cfg.get("team")
)
@@ -755,9 +1300,8 @@ async def agent_infer(agent_id: str, request: InferRequest):
return InferResponse(
response=crew_result["result"],
model="crewai-" + agent_id,
provider="crewai",
tokens_used=0,
latency_ms=int(latency * 1000)
backend="crewai",
tokens_used=0
)
else:
logger.warning(f"⚠️ CrewAI failed, falling back to direct LLM")
@@ -765,15 +1309,9 @@ async def agent_infer(agent_id: str, request: InferRequest):
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", [])
for rule in routing_rules:
if rule.get("when", {}).get("agent") == agent_id:
if "use_llm" in rule:
default_llm = rule.get("use_llm")
logger.info(f"🎯 Agent {agent_id} routing to: {default_llm}")
break
default_llm = _select_default_llm(agent_id, metadata, default_llm, routing_rules)
# Get LLM profile configuration
llm_profiles = router_config.get("llm_profiles", {})
@@ -819,15 +1357,114 @@ async def agent_infer(agent_id: str, request: InferRequest):
if vision_resp.status_code == 200:
vision_data = vision_resp.json()
full_response = vision_data.get("text", "")
raw_response = vision_data.get("text", "")
full_response = _sanitize_vision_text_for_user(raw_response)
vision_web_query = ""
vision_sources: List[Dict[str, str]] = []
# Debug: log full response structure
logger.info(f"✅ Vision response: {len(full_response)} chars, success={vision_data.get('success')}, keys={list(vision_data.keys())}")
logger.info(
f"✅ Vision response: raw={len(raw_response)} chars, sanitized={len(full_response)} chars, "
f"success={vision_data.get('success')}, keys={list(vision_data.keys())}"
)
if raw_response and not full_response:
full_response = _extract_vision_search_facts(raw_response, max_chars=280)
if not full_response:
logger.warning(f"⚠️ Empty vision response! Full data: {str(vision_data)[:500]}")
# Optional vision -> web enrichment (soft policy):
# if prompt explicitly asks to search online OR vision answer is uncertain.
if (full_response or raw_response) and TOOL_MANAGER_AVAILABLE and tool_manager:
try:
wants_web = _vision_prompt_wants_web(request.prompt)
uncertain = _vision_answer_uncertain(full_response or raw_response)
if wants_web or uncertain:
query = _build_vision_web_query(request.prompt, full_response or raw_response)
if not query:
logger.info("🔎 Vision web enrich skipped: query not actionable")
else:
vision_web_query = query
search_result = await tool_manager.execute_tool(
"web_search",
{"query": query, "max_results": 3},
agent_id=request_agent_id,
chat_id=chat_id,
user_id=user_id,
)
if search_result and search_result.success and search_result.result:
compact_search = _compact_web_search_result(
search_result.result,
query=query,
agent_id=request_agent_id,
)
if compact_search and "Нічого не знайдено" not in compact_search:
vision_sources = _extract_sources_from_compact(compact_search)
base_text = full_response or "Не вдалося надійно ідентифікувати об'єкт на фото."
full_response = (
f"{base_text}\n\n"
f"Додатково знайшов у відкритих джерелах:\n{compact_search}"
)
logger.info(
"🌐 Vision web enrichment applied "
f"for agent={request_agent_id}, wants_web={wants_web}, uncertain={uncertain}, "
f"sources={len(vision_sources)}"
)
except Exception as e:
logger.warning(f"⚠️ Vision web enrichment failed: {e}")
if vision_web_query:
logger.info(
f"🗂️ Vision enrichment metadata: agent={request_agent_id}, "
f"query='{vision_web_query[:180]}', sources={len(vision_sources)}"
)
# Image quality gate: one soft retry if response looks empty/meta.
if _image_response_needs_retry(full_response):
try:
logger.warning(f"⚠️ Vision quality gate triggered for agent={request_agent_id}, retrying once")
retry_payload = dict(vision_payload)
retry_payload["prompt"] = (
"Опиши зображення по суті: що зображено, ключові деталі, можливий контекст. "
"Відповідай українською 2-4 реченнями, без службових фраз. "
f"Запит користувача: {request.prompt}"
)
retry_resp = await http_client.post(
f"{SWAPPER_URL}/vision",
json=retry_payload,
timeout=120.0
)
if retry_resp.status_code == 200:
retry_data = retry_resp.json()
retry_raw = retry_data.get("text", "")
retry_text = _sanitize_vision_text_for_user(retry_raw)
if retry_raw and not retry_text:
retry_text = _extract_vision_search_facts(retry_raw, max_chars=280)
if retry_text and not _image_response_needs_retry(retry_text):
full_response = retry_text
logger.info(f"✅ Vision quality retry improved response for agent={request_agent_id}")
except Exception as e:
logger.warning(f"⚠️ Vision quality retry failed: {e}")
if _image_response_needs_retry(full_response):
full_response = _build_image_fallback_response(request_agent_id, request.prompt)
elif request_agent_id == "agromatrix" and _vision_response_is_blurry(full_response):
full_response = _build_image_fallback_response(request_agent_id, request.prompt)
# Store vision message in agent-specific memory
if MEMORY_RETRIEVAL_AVAILABLE and memory_retrieval and chat_id and user_id and full_response:
vision_meta: Dict[str, Any] = {}
if vision_web_query:
vision_meta["vision_search_query"] = vision_web_query[:500]
if vision_sources:
vision_meta["vision_sources"] = vision_sources
asyncio.create_task(
memory_retrieval.store_message(
agent_id=request_agent_id,
@@ -836,7 +1473,8 @@ async def agent_infer(agent_id: str, request: InferRequest):
message_text=f"[Image] {request.prompt}",
response_text=full_response,
chat_id=chat_id,
message_type="vision"
message_type="vision",
metadata=vision_meta if vision_meta else None,
)
)
@@ -848,11 +1486,21 @@ async def agent_infer(agent_id: str, request: InferRequest):
)
else:
logger.error(f"❌ Swapper vision error: {vision_resp.status_code} - {vision_resp.text[:200]}")
# Fall through to text processing
return InferResponse(
response=_build_image_fallback_response(request_agent_id, request.prompt),
model="qwen3-vl-8b",
tokens_used=None,
backend="swapper-vision-fallback"
)
except Exception as e:
logger.error(f"❌ Vision processing failed: {e}", exc_info=True)
# Fall through to text processing
return InferResponse(
response=_build_image_fallback_response(request_agent_id, request.prompt),
model="qwen3-vl-8b",
tokens_used=None,
backend="swapper-vision-fallback"
)
# =========================================================================
# SMART LLM ROUTER WITH AUTO-FALLBACK
@@ -881,6 +1529,10 @@ async def agent_infer(agent_id: str, request: InferRequest):
max_tokens = request.max_tokens or llm_profile.get("max_tokens", 2048)
temperature = request.temperature or llm_profile.get("temperature", 0.2)
cloud_provider_names = {"deepseek", "mistral", "grok", "openai", "anthropic"}
allow_cloud = provider in cloud_provider_names
if not allow_cloud:
logger.info(f"☁️ Cloud providers disabled for agent {agent_id}: provider={provider}")
# Define cloud providers with fallback order
cloud_providers = [
{
@@ -905,7 +1557,10 @@ async def agent_infer(agent_id: str, request: InferRequest):
"timeout": 60
}
]
if not allow_cloud:
cloud_providers = []
# If specific provider requested, try it first
if provider in ["deepseek", "mistral", "grok"]:
# Reorder to put requested provider first
@@ -916,7 +1571,7 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Get tool definitions if Tool Manager is available
tools_payload = None
if TOOL_MANAGER_AVAILABLE and tool_manager:
tools_payload = tool_manager.get_tool_definitions()
tools_payload = tool_manager.get_tool_definitions(request_agent_id)
logger.debug(f"🔧 {len(tools_payload)} tools available for function calling")
for cloud in cloud_providers:
@@ -1034,14 +1689,23 @@ async def agent_infer(agent_id: str, request: InferRequest):
except:
tool_args = {}
result = await tool_manager.execute_tool(tool_name, tool_args, agent_id=request_agent_id)
result = await tool_manager.execute_tool(
tool_name,
tool_args,
agent_id=request_agent_id,
chat_id=chat_id,
user_id=user_id,
)
tool_result_dict = {
"tool_call_id": tc.get("id", ""),
"name": tool_name,
"success": result.success,
"result": result.result,
"error": result.error,
"image_base64": result.image_base64 # Store image if generated
"image_base64": result.image_base64, # Store image if generated
"file_base64": result.file_base64,
"file_name": result.file_name,
"file_mime": result.file_mime,
}
if result.image_base64:
logger.info(f"🖼️ Tool {tool_name} generated image: {len(result.image_base64)} chars")
@@ -1149,14 +1813,22 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Check if any tool generated an image
generated_image = None
generated_file_base64 = None
generated_file_name = None
generated_file_mime = None
logger.debug(f"🔍 Checking {len(tool_results)} tool results for images...")
for tr in tool_results:
img_b64 = tr.get("image_base64")
if img_b64:
generated_image = img_b64
logger.info(f"🖼️ Image generated by tool: {tr['name']} ({len(img_b64)} chars)")
break
else:
file_b64 = tr.get("file_base64")
if file_b64 and not generated_file_base64:
generated_file_base64 = file_b64
generated_file_name = tr.get("file_name")
generated_file_mime = tr.get("file_mime")
logger.info(f"📄 File generated by tool: {tr['name']} ({len(file_b64)} chars)")
if not img_b64:
logger.debug(f" Tool {tr['name']}: no image_base64")
logger.info(f"{cloud['name'].upper()} response received, {tokens_used} tokens")
@@ -1179,7 +1851,10 @@ async def agent_infer(agent_id: str, request: InferRequest):
model=cloud["model"],
tokens_used=tokens_used,
backend=f"{cloud['name']}-cloud",
image_base64=generated_image
image_base64=generated_image,
file_base64=generated_file_base64,
file_name=generated_file_name,
file_mime=generated_file_mime,
)
else:
logger.warning(f"⚠️ {cloud['name'].upper()} returned empty response, trying next provider")
@@ -1253,7 +1928,38 @@ async def agent_infer(agent_id: str, request: InferRequest):
if generate_resp.status_code == 200:
data = generate_resp.json()
local_response = data.get("response", "")
local_response = _normalize_text_response(data.get("response", ""))
# Empty-answer gate for selected local top-level agents.
if request_agent_id in EMPTY_ANSWER_GUARD_AGENTS and _needs_empty_answer_recovery(local_response):
logger.warning(f"⚠️ Empty-answer gate triggered for {request_agent_id}, retrying local generate once")
retry_prompt = (
f"{request.prompt}\n\n"
"Відповідай коротко і конкретно (2-5 речень), без службових або мета-фраз."
)
retry_resp = await http_client.post(
f"{SWAPPER_URL}/generate",
json={
"model": local_model,
"prompt": retry_prompt,
"system": system_prompt,
"max_tokens": request.max_tokens,
"temperature": request.temperature,
"stream": False
},
timeout=300.0
)
if retry_resp.status_code == 200:
retry_data = retry_resp.json()
retry_text = _normalize_text_response(retry_data.get("response", ""))
if retry_text and not _needs_empty_answer_recovery(retry_text):
local_response = retry_text
if _needs_empty_answer_recovery(local_response):
local_response = (
"Я не отримав корисну відповідь з першої спроби. "
"Сформулюй запит коротко ще раз, і я відповім конкретно."
)
# Store in agent-specific memory
if MEMORY_RETRIEVAL_AVAILABLE and memory_retrieval and chat_id and user_id and local_response:
@@ -1649,4 +2355,3 @@ async def shutdown_event():
if nc:
await nc.close()
logger.info("🔌 NATS connection closed")

View File

@@ -5,6 +5,9 @@ nats-py==2.6.0
PyYAML==6.0.1
httpx>=0.25.0
neo4j>=5.14.0
openpyxl>=3.1.2
python-docx>=1.1.2
pypdf>=5.1.0
# Memory Retrieval v3.0
asyncpg>=0.29.0

File diff suppressed because it is too large Load Diff