"""Auto-discover and load profiles from subdirectories.
A profile directory must contain:
config.json — metadata, model settings, enabled_tools
system_prompt.txt — raw system prompt text
Any directory missing either file is silently skipped.
Errors in individual profiles are isolated and logged.
"""
import json
import structlog
from pathlib import Path
from .base import AgentProfile
log = structlog.get_logger()
_REQUIRED_CONFIG_KEYS = {"id", "name", "description", "enabled_tools"}
def _normalize_model(value: object) -> list[str]:
"""Accept str or list[str], always return list[str]."""
if isinstance(value, list):
return [str(m) for m in value] or ["gemma4:31b-cloud"]
return [str(value)] if value else ["gemma4:31b-cloud"]
def load_profiles_from_dir(profiles_dir: str | Path) -> list[AgentProfile]:
"""Load all valid profile directories under profiles_dir."""
base = Path(profiles_dir)
profiles: list[AgentProfile] = []
for entry in sorted(base.iterdir()):
if not entry.is_dir():
continue
config_file = entry / "config.json"
prompt_file = entry / "system_prompt.txt"
if not config_file.exists() or not prompt_file.exists():
continue
try:
config = json.loads(config_file.read_text(encoding="utf-8"))
missing = _REQUIRED_CONFIG_KEYS - config.keys()
if missing:
log.warning(
"profile.loader.missing_keys",
profile_dir=entry.name,
missing=sorted(missing),
)
continue
system_prompt = prompt_file.read_text(encoding="utf-8").strip()
subagent_prompt_file = entry / "subagent_system_prompt.txt"
subagent_system_prompt = (
subagent_prompt_file.read_text(encoding="utf-8").strip()
if subagent_prompt_file.exists()
else ""
)
# planning_phase2_enabled supersedes the old planning_reflect_enabled key.
# If only the old key is present, migrate its value transparently.
phase2_default = config.get("planning_reflect_enabled", False)
profiles.append(AgentProfile(
id=config["id"],
name=config["name"],
description=config["description"],
system_prompt=system_prompt,
enabled_tools=config["enabled_tools"],
llm_backend=config.get("llm_backend", "ollama"),
model=_normalize_model(config.get("model", ["gemma4:31b-cloud"])),
temperature=config.get("temperature", 0.7),
top_k=config.get("top_k", None),
top_p=config.get("top_p", None),
max_iterations=config.get("max_iterations", 20),
planning_enabled=config.get("planning_enabled", False),
planning_mandatory=config.get("planning_mandatory", False),
planning_phase1_enabled=config.get("planning_phase1_enabled", True),
planning_phase2_enabled=config.get("planning_phase2_enabled", phase2_default),
planning_phase3_enabled=config.get("planning_phase3_enabled", True),
short_description=config.get("short_description", ""),
full_description=config.get("full_description", {}),
think_enabled=config.get("think_enabled", True),
iteration_budget_enabled=config.get("iteration_budget_enabled", True),
goal_anchoring_enabled=config.get("goal_anchoring_enabled", True),
goal_anchoring_interval=config.get("goal_anchoring_interval", 5),
anti_stall_enabled=config.get("anti_stall_enabled", True),
anti_stall_threshold=config.get("anti_stall_threshold", 8),
step_validation_enabled=config.get("step_validation_enabled", False),
adaptive_replan_enabled=config.get("adaptive_replan_enabled", False),
subagent_tools=config.get("subagent_tools", []),
subagent_planning_enabled=config.get("subagent_planning_enabled", False),
subagent_system_prompt=subagent_system_prompt,
context_providers=config.get("context_providers", []),
))
log.debug("profile.loader.loaded", profile_id=config["id"])
except Exception as exc:
log.error("profile.loader.error", profile_dir=entry.name, error=str(exc))
return profiles