from pydantic import BaseModel, Field, field_validator, model_validator
class ToolScopeConfig(BaseModel):
"""Tool set for a single scope (agent or subagent)."""
native: list[str] = Field(default_factory=list)
mcp: dict[str, list[str]] = Field(default_factory=dict)
class ToolConfig(BaseModel):
"""Explicit per-profile tool configuration."""
agent: ToolScopeConfig = Field(default_factory=ToolScopeConfig)
subagent: ToolScopeConfig = Field(default_factory=ToolScopeConfig)
class AgentProfile(BaseModel):
"""
Defines a complete agent configuration.
A profile ties together a system prompt, an LLM backend, a model,
and the set of tools the agent is allowed to use.
"""
model_config = {"extra": "allow"}
id: str
name: str
description: str
system_prompt: str
tools: ToolConfig = Field(default_factory=ToolConfig)
# --- Deprecated: kept for auto-migration from old configs ------------------
# These fields are automatically migrated into ``tools`` by the loader.
# Do not set them in new configs; use ``tools.agent`` / ``tools.subagent``.
enabled_tools: list[str] = Field(default_factory=list)
mcp_servers: dict[str, list[str]] = Field(default_factory=dict)
subagent_tools: list[str] = Field(default_factory=list)
llm_backend: str = "ollama" # backend key, e.g. "ollama", "openai"
# Ordered list of preferred models; first available wins at runtime.
# Accepts a plain string for backward compatibility (auto-wrapped in a list).
model: list[str] = Field(default_factory=lambda: ["gemma4:31b-cloud"])
max_iterations: int = 10
temperature: float = 0.7
top_k: int | None = None
top_p: float | None = None
# Number of CPU threads for local inference. None = Ollama default (physical cores).
# Cloud models ignore this option.
num_thread: int | None = None
planning_enabled: bool = False # if True, run a planning LLM call before the main loop
# Profile discoverability — used for system prompt injection and list_profiles tool.
# short_description: 1-line summary shown in every system prompt to all profiles.
# full_description: structured dict with keys: specialization, when_to_use, key_tools.
short_description: str = ""
full_description: dict = Field(default_factory=dict)
# Admin-only profiles are hidden from non-admin users in the profile list.
is_admin_only: bool = False
# Subagent-only profiles can only be used via spawn_agent — switch_profile
# is blocked. Useful for narrow specialist agents that should never become
# the main session profile.
is_subagent_only: bool = False
# ── Thinking mechanics ────────────────────────────────────────────────────
# Each flag can be set per-profile in config.json to tune the balance
# between reasoning depth and response latency.
# Extended reasoning: passes think=True to the LLM on every main-loop call.
# Disable for latency-sensitive profiles (e.g. smart_home).
think_enabled: bool = True
# Inject remaining iteration count at the end of every LLM context so the
# model knows when to wrap up instead of hitting the limit blindly.
iteration_budget_enabled: bool = True
# ── Planning phases ───────────────────────────────────────────────────────
# planning_mandatory: if True, the DIRECT shortcut is never offered to the
# model — planning always runs in full. If False, the model can output DIRECT
# to skip straight to execution (simple requests bypass planning).
# First-message planning is always forced regardless of this flag.
planning_mandatory: bool = False
# Individual phase switches — allow disabling expensive phases for profiles
# that don't need them.
# Phase 1: task analysis (TASK/GOAL/UNKNOWNS). Entry point for the pipeline.
planning_phase1_enabled: bool = True
# Phase 2: structured review (Critic/Pragmatist/Detailer + Plan Adjustments).
# Adds 1 LLM call only when Phase 1 outputs REFLECT: yes.
planning_phase2_enabled: bool = False
# Phase 3: structured execution plan (numbered steps with TOOL/AGENT/SELF).
planning_phase3_enabled: bool = True
# Inject a goal-reminder system message every N iterations to prevent drift
# on long tasks. N = goal_anchoring_interval.
goal_anchoring_enabled: bool = True
goal_anchoring_interval: int = 5
# Detect when the model is looping without todo progress for
# anti_stall_threshold iterations and inject a hard warning.
anti_stall_enabled: bool = True
anti_stall_threshold: int = 8
# After the model marks a todo step as done, run a lightweight LLM check:
# "did the result actually satisfy the step goal?" Adds ~1 LLM call per step.
step_validation_enabled: bool = False
# When a todo step is marked as failed, trigger a lightweight re-planning
# pass to adjust the remaining steps. Depends on step_validation.
adaptive_replan_enabled: bool = False
# Sub-agent configuration
# subagent_system_prompt: injected as an additional system message for sub-agents,
# after the profile's main system_prompt. Loaded from subagent_system_prompt.txt if present.
subagent_planning_enabled: bool = False
subagent_think_enabled: bool | None = None
subagent_system_prompt: str = ""
# Extra context providers to inject for this profile (by name).
# Global providers (global_provider=True) are always injected regardless of this list.
context_providers: list[str] = Field(default_factory=list)
@field_validator("model", mode="before")
@classmethod
def _coerce_model(cls, v):
if isinstance(v, str):
return [v] if v else ["gemma4:31b-cloud"]
return v
@model_validator(mode="after")
def _migrate_tools(self):
"""Auto-migrate legacy fields into the explicit ``tools`` structure."""
if self.enabled_tools or self.mcp_servers or self.subagent_tools:
if not self.tools.agent.native:
self.tools.agent.native = list(self.enabled_tools)
if not self.tools.agent.mcp:
self.tools.agent.mcp = dict(self.mcp_servers)
if self.subagent_tools:
if not self.tools.subagent.native:
self.tools.subagent.native = list(self.subagent_tools)
if not self.tools.subagent.mcp:
# Infer MCP servers from subagent_tools if they contain mcp__ names
from navi.mcp.tools import is_mcp_tool
inferred: dict[str, list[str]] = {}
for name in self.subagent_tools:
if is_mcp_tool(name):
# e.g. mcp__navi_3d__compile_scad -> server navi_3d
parts = name.split("__", 2)
if len(parts) >= 2:
srv = parts[1]
inferred.setdefault(srv, []).append("*")
self.tools.subagent.mcp = inferred
return self
def get_agent_tools(self) -> ToolScopeConfig:
"""Return the resolved agent tool set."""
return self.tools.agent
def get_subagent_tools(self) -> ToolScopeConfig:
"""Return the resolved subagent tool set."""
if self.tools.subagent.native or self.tools.subagent.mcp:
return self.tools.subagent
# Fallback: if subagent is empty but agent is not, use agent
return self.tools.agent