Newer
Older
navi-1 / navi / profiles / loader.py
"""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))