#!/usr/bin/env python3 """Agent config loader and validator. Loads YAML config files from telegram/agents/*.yaml, validates required fields, resolves file paths. Used by bot.py and future agent_runner.py. Epimetheus owns this module. """ import logging import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Optional logger = logging.getLogger("tg.agent_config") SECRETS_DIR = "/opt/teleo-eval/secrets" WORKTREE_DIR = "/opt/teleo-eval/workspaces/main" REQUIRED_FIELDS = ["name", "handle", "bot_token_file", "pentagon_agent_id", "domain"] REQUIRED_VOICE_FIELDS = ["voice_summary", "voice_definition"] REQUIRED_KB_FIELDS = ["kb_scope"] @dataclass class AgentConfig: """Validated agent configuration loaded from YAML.""" name: str handle: str x_handle: Optional[str] bot_token_file: str pentagon_agent_id: str domain: str kb_scope_primary: list[str] voice_summary: str voice_definition: str domain_expertise: str learnings_file: str opsec_additional_patterns: list[str] = field(default_factory=list) response_model: str = "anthropic/claude-opus-4-6" triage_model: str = "anthropic/claude-haiku-4.5" max_tokens: int = 1024 max_response_per_user_per_hour: int = 30 def to_dict(self) -> dict: """Convert to dict for passing to build_system_prompt.""" return { "name": self.name, "handle": self.handle, "x_handle": self.x_handle, "domain": self.domain, "voice_definition": self.voice_definition, "voice_summary": self.voice_summary, "domain_expertise": self.domain_expertise, "pentagon_agent_id": self.pentagon_agent_id, } @property def bot_token_path(self) -> str: return os.path.join(SECRETS_DIR, self.bot_token_file) @property def learnings_path(self) -> str: return os.path.join(WORKTREE_DIR, self.learnings_file) @property def handle_regex(self) -> re.Pattern: """Regex matching this agent's @handle with optional @botname suffix.""" clean = self.handle.lstrip("@") return re.compile(rf"@{re.escape(clean)}(?:@\w+)?", re.IGNORECASE) def load_agent_config(config_path: str) -> AgentConfig: """Load and validate an agent YAML config file. Raises ValueError on validation failure. """ import yaml with open(config_path) as f: raw = yaml.safe_load(f) errors = [] # Required fields for fld in REQUIRED_FIELDS + REQUIRED_VOICE_FIELDS: if fld not in raw or not raw[fld]: errors.append(f"Missing required field: {fld}") # KB scope kb_scope = raw.get("kb_scope", {}) if not isinstance(kb_scope, dict) or "primary" not in kb_scope: errors.append("Missing kb_scope.primary (list of primary domain dirs)") elif not isinstance(kb_scope["primary"], list) or len(kb_scope["primary"]) == 0: errors.append("kb_scope.primary must be a non-empty list") # Learnings file if "learnings_file" not in raw: errors.append("Missing required field: learnings_file") if errors: raise ValueError( f"Agent config validation failed ({config_path}):\n" + "\n".join(f" - {e}" for e in errors) ) return AgentConfig( name=raw["name"], handle=raw["handle"], x_handle=raw.get("x_handle"), bot_token_file=raw["bot_token_file"], pentagon_agent_id=raw["pentagon_agent_id"], domain=raw["domain"], kb_scope_primary=kb_scope["primary"], voice_summary=raw["voice_summary"], voice_definition=raw["voice_definition"], domain_expertise=raw.get("domain_expertise", ""), learnings_file=raw["learnings_file"], opsec_additional_patterns=raw.get("opsec_additional_patterns", []), response_model=raw.get("response_model", "anthropic/claude-opus-4-6"), triage_model=raw.get("triage_model", "anthropic/claude-haiku-4.5"), max_tokens=raw.get("max_tokens", 1024), max_response_per_user_per_hour=raw.get("max_response_per_user_per_hour", 30), ) def validate_agent_config(config_path: str) -> list[str]: """Validate config file and check runtime dependencies. Returns list of warnings (empty = all good). Raises ValueError on hard failures. """ config = load_agent_config(config_path) warnings = [] # Check bot token file exists if not os.path.exists(config.bot_token_path): warnings.append(f"Bot token file not found: {config.bot_token_path}") # Check primary KB dirs exist for d in config.kb_scope_primary: full = os.path.join(WORKTREE_DIR, d) if not os.path.isdir(full): warnings.append(f"KB scope dir not found: {full}") # Check learnings file parent dir exists learnings_dir = os.path.dirname(config.learnings_path) if not os.path.isdir(learnings_dir): warnings.append(f"Learnings dir not found: {learnings_dir}") # Validate OPSEC patterns compile for i, pattern in enumerate(config.opsec_additional_patterns): try: re.compile(pattern, re.IGNORECASE) except re.error as e: warnings.append(f"Invalid OPSEC regex pattern [{i}]: {e}") return warnings