352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""
|
|
CrewAI Registry Loader - Variant A (Profiles per Agent)
|
|
Loads team configurations from crewai_teams.yml with profile support.
|
|
"""
|
|
import os
|
|
import json
|
|
import yaml
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CREWAI_AGENTS_PATH = os.getenv("CREWAI_AGENTS_PATH", "/app/config/crewai_agents.json")
|
|
CREWAI_TEAMS_PATH = os.getenv("CREWAI_TEAMS_PATH", "/app/config/crewai_teams.yml")
|
|
CREWAI_TEAMS_GENERATED_PATH = os.getenv("CREWAI_TEAMS_GENERATED_PATH", "/app/config/crewai_teams.generated.yml")
|
|
ROLES_BASE_PATH = os.getenv("ROLES_BASE_PATH", "/app/config/roles")
|
|
|
|
# Example: "agromatrix=agx" means refs under agromatrix/* also try agx/*
|
|
ROLE_NAMESPACE_OVERRIDES_RAW = os.getenv("ROLE_NAMESPACE_OVERRIDES", "agromatrix=agx")
|
|
ROLE_NAMESPACE_OVERRIDES = {}
|
|
for part in ROLE_NAMESPACE_OVERRIDES_RAW.split(","):
|
|
part = part.strip()
|
|
if not part or "=" not in part:
|
|
continue
|
|
src, dst = part.split("=", 1)
|
|
src = src.strip()
|
|
dst = dst.strip()
|
|
if src and dst:
|
|
ROLE_NAMESPACE_OVERRIDES[src] = dst
|
|
|
|
_teams_config = None
|
|
_agents_config = None
|
|
|
|
|
|
def _normalize_prompt_ref(prompt_ref: str) -> str:
|
|
ref = (prompt_ref or "").strip().lstrip("/")
|
|
while ref.startswith("roles/"):
|
|
ref = ref[len("roles/"):]
|
|
return ref
|
|
|
|
|
|
def _build_prompt_candidates(prompt_ref: str) -> List[Path]:
|
|
base = Path(ROLES_BASE_PATH)
|
|
ref = _normalize_prompt_ref(prompt_ref)
|
|
candidates: List[Path] = []
|
|
|
|
if ref:
|
|
candidates.append(base / ref)
|
|
|
|
parts = ref.split("/", 1)
|
|
if parts and parts[0] in ROLE_NAMESPACE_OVERRIDES and len(parts) > 1:
|
|
mapped = f"{ROLE_NAMESPACE_OVERRIDES[parts[0]]}/{parts[1]}"
|
|
candidates.append(base / mapped)
|
|
|
|
# Legacy fallback (in case a ref is already relative but not under roles/*)
|
|
raw_ref = (prompt_ref or "").strip().lstrip("/")
|
|
if raw_ref and raw_ref != ref:
|
|
candidates.append(base / raw_ref)
|
|
|
|
# Deduplicate while preserving order
|
|
unique: List[Path] = []
|
|
seen = set()
|
|
for p in candidates:
|
|
k = str(p)
|
|
if k in seen:
|
|
continue
|
|
seen.add(k)
|
|
unique.append(p)
|
|
return unique
|
|
|
|
|
|
def resolve_prompt_path(prompt_ref: str) -> Path:
|
|
for candidate in _build_prompt_candidates(prompt_ref):
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def load_agents_config():
|
|
"""Load basic agent config from crewai_agents.json"""
|
|
global _agents_config
|
|
if _agents_config is None:
|
|
try:
|
|
with open(CREWAI_AGENTS_PATH, "r") as f:
|
|
_agents_config = json.load(f)
|
|
logger.info(f"Loaded agents config from {CREWAI_AGENTS_PATH}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load agents config: {e}")
|
|
_agents_config = {}
|
|
return _agents_config
|
|
|
|
|
|
def load_teams_config():
|
|
"""Load teams/profiles config with generated-over-legacy merge."""
|
|
global _teams_config
|
|
if _teams_config is None:
|
|
try:
|
|
with open(CREWAI_TEAMS_PATH, "r") as f:
|
|
legacy = yaml.safe_load(f) or {}
|
|
merged = dict(legacy)
|
|
version = merged.get("version", "unknown")
|
|
logger.info(f"Loaded legacy teams config v{version} from {CREWAI_TEAMS_PATH}")
|
|
|
|
generated = {}
|
|
gen_path = Path(CREWAI_TEAMS_GENERATED_PATH)
|
|
if gen_path.exists():
|
|
with open(gen_path, "r") as f:
|
|
generated = yaml.safe_load(f) or {}
|
|
logger.info(f"Loaded generated teams config from {CREWAI_TEAMS_GENERATED_PATH}")
|
|
|
|
# Merge strategy: generated overrides legacy for same (agent, profile).
|
|
# Missing agents/profiles continue to work from legacy file.
|
|
if generated:
|
|
skip_keys = {"schema_version", "version", "description"}
|
|
for key, val in generated.items():
|
|
if key in skip_keys:
|
|
continue
|
|
if not isinstance(val, dict):
|
|
merged[key] = val
|
|
continue
|
|
|
|
legacy_agent = merged.get(key, {})
|
|
if not isinstance(legacy_agent, dict):
|
|
legacy_agent = {}
|
|
|
|
merged_agent = dict(legacy_agent)
|
|
|
|
gen_profiles = val.get("profiles", {})
|
|
if isinstance(gen_profiles, dict):
|
|
legacy_profiles = legacy_agent.get("profiles", {})
|
|
if not isinstance(legacy_profiles, dict):
|
|
legacy_profiles = {}
|
|
combined_profiles = dict(legacy_profiles)
|
|
for profile_name, profile_cfg in gen_profiles.items():
|
|
if profile_name in combined_profiles:
|
|
logger.info(
|
|
f"Generated teams override legacy profile: {key}.{profile_name}"
|
|
)
|
|
combined_profiles[profile_name] = profile_cfg
|
|
merged_agent["profiles"] = combined_profiles
|
|
|
|
if "default_profile" in val:
|
|
merged_agent["default_profile"] = val["default_profile"]
|
|
if "profile_hints" in val:
|
|
merged_agent["profile_hints"] = val["profile_hints"]
|
|
|
|
merged[key] = merged_agent
|
|
|
|
_teams_config = merged
|
|
merged_version = _teams_config.get("version", "unknown")
|
|
logger.info(f"Effective teams config v{merged_version} loaded (legacy+generated merge)")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load teams config: {e}")
|
|
_teams_config = {}
|
|
return _teams_config
|
|
|
|
|
|
def load_role_prompt(prompt_ref: str) -> str:
|
|
"""Load role prompt from .md file with normalized path resolution."""
|
|
if not prompt_ref:
|
|
return ""
|
|
|
|
resolved = resolve_prompt_path(prompt_ref)
|
|
if not resolved:
|
|
tried = ", ".join(str(p) for p in _build_prompt_candidates(prompt_ref))
|
|
logger.warning(f"Role prompt not found: ref={prompt_ref}; tried=[{tried}]")
|
|
return f"# Role: {prompt_ref}\n(prompt file missing)"
|
|
|
|
try:
|
|
return resolved.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
logger.error(f"Error loading role prompt {resolved}: {e}")
|
|
return ""
|
|
|
|
|
|
def validate_required_prompts(strict: bool = False) -> Dict[str, Any]:
|
|
"""Validate all team/synthesis prompt refs are resolvable."""
|
|
config = load_teams_config()
|
|
missing = []
|
|
skip_keys = {"schema_version", "version", "description"}
|
|
|
|
for agent_id, agent_cfg in config.items():
|
|
if agent_id in skip_keys or not isinstance(agent_cfg, dict):
|
|
continue
|
|
profiles = agent_cfg.get("profiles", {})
|
|
if not isinstance(profiles, dict):
|
|
continue
|
|
|
|
for profile_name, profile_cfg in profiles.items():
|
|
if not isinstance(profile_cfg, dict):
|
|
continue
|
|
|
|
synthesis = profile_cfg.get("synthesis", {}) or {}
|
|
synth_ref = synthesis.get("system_prompt_ref", "")
|
|
if synth_ref and not resolve_prompt_path(synth_ref):
|
|
missing.append(f"{agent_id}.{profile_name}.synthesis -> {synth_ref}")
|
|
|
|
for member in profile_cfg.get("team", []) or []:
|
|
ref = (member or {}).get("system_prompt_ref", "")
|
|
if ref and not resolve_prompt_path(ref):
|
|
mid = (member or {}).get("id", "unknown")
|
|
missing.append(f"{agent_id}.{profile_name}.{mid} -> {ref}")
|
|
|
|
if missing:
|
|
msg = f"Missing CrewAI role prompts: {len(missing)}"
|
|
if strict:
|
|
sample = "; ".join(missing[:8])
|
|
raise RuntimeError(f"{msg}. Examples: {sample}")
|
|
logger.warning(f"{msg}. Examples: {'; '.join(missing[:8])}")
|
|
|
|
return {
|
|
"missing_count": len(missing),
|
|
"missing": missing,
|
|
}
|
|
|
|
|
|
def get_agent_profiles(agent_id: str) -> list:
|
|
"""Get list of available profiles for an agent"""
|
|
config = load_teams_config()
|
|
agent_cfg = config.get(agent_id, {})
|
|
profiles = agent_cfg.get("profiles", {})
|
|
return list(profiles.keys())
|
|
|
|
|
|
def get_default_profile(agent_id: str) -> str:
|
|
"""Get default profile name for an agent"""
|
|
config = load_teams_config()
|
|
agent_cfg = config.get(agent_id, {})
|
|
return agent_cfg.get("default_profile", "default")
|
|
|
|
|
|
def get_profile_hints(agent_id: str) -> dict:
|
|
"""Get profile selection hints (keywords) for an agent"""
|
|
config = load_teams_config()
|
|
agent_cfg = config.get(agent_id, {})
|
|
return agent_cfg.get("profile_hints", {})
|
|
|
|
|
|
def select_profile(agent_id: str, prompt: str) -> str:
|
|
"""Select appropriate profile based on prompt keywords"""
|
|
hints = get_profile_hints(agent_id)
|
|
prompt_lower = prompt.lower()
|
|
|
|
for profile_name, keywords in hints.items():
|
|
for kw in keywords:
|
|
if kw.lower() in prompt_lower:
|
|
logger.info(f"Selected profile {profile_name} for {agent_id} (matched: {kw})")
|
|
return profile_name
|
|
|
|
return get_default_profile(agent_id)
|
|
|
|
|
|
def get_profile_config(agent_id: str, profile: str = None) -> dict:
|
|
"""Get full profile configuration for an agent"""
|
|
config = load_teams_config()
|
|
agent_cfg = config.get(agent_id, {})
|
|
profiles = agent_cfg.get("profiles", {})
|
|
|
|
if profile is None:
|
|
profile = get_default_profile(agent_id)
|
|
|
|
return profiles.get(profile, {})
|
|
|
|
|
|
def get_team_members(agent_id: str, profile: str = None) -> list:
|
|
"""Get team members with resolved prompts"""
|
|
profile_cfg = get_profile_config(agent_id, profile)
|
|
team = profile_cfg.get("team", [])
|
|
|
|
resolved = []
|
|
for member in team:
|
|
resolved_member = dict(member)
|
|
prompt_ref = member.get("system_prompt_ref", "")
|
|
resolved_member["system_prompt"] = load_role_prompt(prompt_ref)
|
|
resolved.append(resolved_member)
|
|
|
|
return resolved
|
|
|
|
|
|
def get_synthesis_config(agent_id: str, profile: str = None) -> dict:
|
|
"""Get synthesis config with resolved prompt"""
|
|
profile_cfg = get_profile_config(agent_id, profile)
|
|
synthesis = profile_cfg.get("synthesis", {})
|
|
|
|
if synthesis:
|
|
prompt_ref = synthesis.get("system_prompt_ref", "")
|
|
synthesis = dict(synthesis)
|
|
synthesis["system_prompt"] = load_role_prompt(prompt_ref)
|
|
|
|
return synthesis
|
|
|
|
|
|
def get_team_settings(agent_id: str, profile: str = None) -> dict:
|
|
"""Get team execution settings"""
|
|
profile_cfg = get_profile_config(agent_id, profile)
|
|
return {
|
|
"team_name": profile_cfg.get("team_name", f"{agent_id} team"),
|
|
"parallel_roles": profile_cfg.get("parallel_roles", True),
|
|
"max_concurrency": profile_cfg.get("max_concurrency", 3)
|
|
}
|
|
|
|
|
|
def get_delegation_config(agent_id: str, profile: str = None) -> dict:
|
|
"""Get delegation config for an agent"""
|
|
profile_cfg = get_profile_config(agent_id, profile)
|
|
return profile_cfg.get("delegation", {"enabled": False})
|
|
|
|
|
|
def can_delegate_to(agent_id: str, target_agent_id: str, profile: str = None) -> bool:
|
|
"""Check if agent can delegate to target"""
|
|
deleg = get_delegation_config(agent_id, profile)
|
|
if not deleg.get("enabled"):
|
|
return False
|
|
if deleg.get("forbid_self") and target_agent_id == agent_id:
|
|
return False
|
|
allowed = deleg.get("allow_top_level_agents", [])
|
|
return target_agent_id in allowed
|
|
|
|
|
|
def is_orchestrator(agent_id: str) -> bool:
|
|
"""Check if agent has orchestrator capability"""
|
|
config = load_teams_config()
|
|
return agent_id in config and "profiles" in config.get(agent_id, {})
|
|
|
|
|
|
def get_all_agents_summary() -> dict:
|
|
"""Get summary of all agents and their profiles"""
|
|
config = load_teams_config()
|
|
summary = {}
|
|
|
|
skip_keys = ["schema_version", "version", "description"]
|
|
|
|
for agent_id, agent_cfg in config.items():
|
|
if agent_id in skip_keys:
|
|
continue
|
|
if not isinstance(agent_cfg, dict):
|
|
continue
|
|
|
|
profiles = agent_cfg.get("profiles", {})
|
|
summary[agent_id] = {
|
|
"profiles": list(profiles.keys()),
|
|
"default_profile": agent_cfg.get("default_profile", "default"),
|
|
"has_hints": bool(agent_cfg.get("profile_hints"))
|
|
}
|
|
|
|
# Add role counts per profile
|
|
for pname, pcfg in profiles.items():
|
|
summary[agent_id][f"{pname}_roles"] = len(pcfg.get("team", []))
|
|
|
|
return summary
|