"""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, ToolConfig
log = structlog.get_logger()
_REQUIRED_CONFIG_KEYS = {"id", "name", "description"}
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)
# New explicit tool config or legacy migration
_tools_raw = config.get("tools")
_tools = ToolConfig.model_validate(_tools_raw) if _tools_raw else ToolConfig()
# Legacy fields still accepted for auto-migration
_enabled_tools = config.get("enabled_tools", [])
_mcp_servers = config.get("mcp_servers", {})
_subagent_tools = config.get("subagent_tools", [])
profiles.append(AgentProfile(
id=config["id"],
name=config["name"],
description=config["description"],
system_prompt=system_prompt,
tools=_tools,
enabled_tools=_enabled_tools,
mcp_servers=_mcp_servers,
subagent_tools=_subagent_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),
num_thread=config.get("num_thread", 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", {}),
is_subagent_only=config.get("is_subagent_only", False),
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_planning_enabled=config.get("subagent_planning_enabled", False),
subagent_think_enabled=config.get("subagent_think_enabled", None),
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
def save_profile_to_dir(profile: AgentProfile, profiles_dir: str | Path) -> None:
"""Write a profile back to its directory on disk.
Updates config.json and system_prompt.txt. Does not touch
subagent_system_prompt.txt unless the field is non-empty.
"""
base = Path(profiles_dir)
profile_dir = base / profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
config = {
"id": profile.id,
"name": profile.name,
"description": profile.description,
"short_description": profile.short_description,
"full_description": profile.full_description,
"llm_backend": profile.llm_backend,
"model": profile.model,
"temperature": profile.temperature,
"max_iterations": profile.max_iterations,
"top_k": profile.top_k,
"top_p": profile.top_p,
"num_thread": profile.num_thread,
"planning_enabled": profile.planning_enabled,
"planning_mandatory": profile.planning_mandatory,
"planning_phase1_enabled": profile.planning_phase1_enabled,
"planning_phase2_enabled": profile.planning_phase2_enabled,
"planning_phase3_enabled": profile.planning_phase3_enabled,
"think_enabled": profile.think_enabled,
"iteration_budget_enabled": profile.iteration_budget_enabled,
"goal_anchoring_enabled": profile.goal_anchoring_enabled,
"goal_anchoring_interval": profile.goal_anchoring_interval,
"anti_stall_enabled": profile.anti_stall_enabled,
"anti_stall_threshold": profile.anti_stall_threshold,
"step_validation_enabled": profile.step_validation_enabled,
"adaptive_replan_enabled": profile.adaptive_replan_enabled,
"subagent_planning_enabled": profile.subagent_planning_enabled,
"subagent_think_enabled": profile.subagent_think_enabled,
"is_subagent_only": profile.is_subagent_only,
"tools": profile.tools.model_dump(mode="json"),
"context_providers": profile.context_providers,
}
config_file = profile_dir / "config.json"
config_file.write_text(
json.dumps(config, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
prompt_file = profile_dir / "system_prompt.txt"
prompt_file.write_text(profile.system_prompt + "\n", encoding="utf-8")
subagent_file = profile_dir / "subagent_system_prompt.txt"
if profile.subagent_system_prompt:
subagent_file.write_text(profile.subagent_system_prompt + "\n", encoding="utf-8")
elif subagent_file.exists():
subagent_file.unlink()
log.info("profile.saved", profile_id=profile.id, dir=str(profile_dir))