"""Built-in tool for switching the active agent profile mid-session."""
from navi.tools._internal.base import Tool, ToolContext, ToolResult, current_event_sink, current_session_id
# Tool names are listed in the result so the model knows what it just gained and
# what it must stop calling. Long MCP-heavy profiles get truncated; list_tools
# is the full view.
_MAX_LISTED = 30
def _shorten(names: list[str]) -> str:
shown = ", ".join(names[:_MAX_LISTED])
if len(names) > _MAX_LISTED:
shown += f", … (+{len(names) - _MAX_LISTED} more)"
return shown
class SwitchProfileTool(Tool):
name = "switch_profile"
description = (
"Switch this session to a different agent profile. "
"Call this on your own judgment when the task fits another profile's specialization — "
"do not ask the user for permission first. "
"The new profile's system prompt and tools are in force from the next step of this same turn. "
"Call it on its own: a tool called in the same batch still runs against the old toolset. "
"After switching, briefly inform the user: which profile is now active and why you switched."
)
parameters = {
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "ID of the profile to switch to.",
}
},
"required": ["profile_id"],
}
def __init__(
self,
session_store,
profile_registry,
tool_registry=None,
mcp_manager=None,
) -> None:
self._sessions = session_store
self._profiles = profile_registry
self._tools = tool_registry
self._mcp_manager = mcp_manager
def _tool_names(self, profile) -> list[str] | None:
"""Tool names a profile resolves to, or None when no registry is wired."""
if self._tools is None:
return None
from navi.core.tool_utils import build_tool_list
try:
scope = profile.get_agent_tools()
tools = build_tool_list(scope.native, scope.mcp, self._tools, self._mcp_manager)
except Exception:
return None
return [t.name for t in tools]
def _tool_delta(self, before_id: str, after_profile) -> tuple[list[str], list[str], int | None]:
"""(gained, lost, total) for the switch, or ([], [], None) if unresolved."""
after = self._tool_names(after_profile)
if after is None:
return [], [], None
try:
before = self._tool_names(self._profiles.get(before_id)) or []
except Exception:
before = []
after_set = set(after)
before_set = set(before)
return (
[n for n in after if n not in before_set],
[n for n in before if n not in after_set],
len(after),
)
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
profile_id = (params.get("profile_id") or "").strip()
visible = [p for p in self._profiles.all() if not getattr(p, "is_hidden", False)]
available = ", ".join(p.id for p in visible)
try:
profile = self._profiles.get(profile_id)
except Exception:
return ToolResult(
success=False,
output="",
error=f"Profile '{profile_id}' not found. Available: {available}",
)
if getattr(profile, "is_hidden", False):
return ToolResult(
success=False,
output=f"Profile '{profile_id}' is an internal role and not switchable. Available: {available}",
error="hidden_profile",
)
if getattr(profile, "is_subagent_only", False):
return ToolResult(
success=False,
output=f"Profile '{profile_id}' is a sub-agent specialist and cannot be switched to directly. "
f"Use spawn_agent(profile_id='{profile_id}') to delegate tasks to it.",
error="subagent_only",
)
sid = ctx.session_id if ctx else current_session_id.get()
if not sid:
return ToolResult(success=False, output="", error="No active session context.")
session = await self._sessions.get(sid)
if session is None:
return ToolResult(success=False, output="", error="Session not found.")
if session.profile_id == profile_id:
return ToolResult(
success=True,
output=f"Already on profile '{profile.name}' — no change.",
)
# Read the profile we are leaving before the repoint: for a pending
# session set_profile() mutates this very object.
previous_id = session.profile_id
# Narrow UPDATE — deliberately not save(). This tool runs in the middle of
# an agent turn that holds its own Session object; saving the copy we just
# loaded would write to the session behind that turn's back and hand out
# sequence numbers it is still claiming. The run picks the new profile up
# after the turn (agent.py: profile_reloaded).
await self._sessions.set_profile(sid, profile_id)
# Notify the client immediately so it can update the UI (profile badge).
# tool_ctx.event_sink is None inside the agent loop — the ContextVar is the
# real channel — so fall through to it whenever ctx carries no sink.
sink = ctx.event_sink if (ctx and ctx.event_sink) else current_event_sink.get()
if sink is not None:
from navi.core.events import ProfileSwitched
await sink.put(ProfileSwitched(profile_id=profile_id, profile_name=profile.name))
gained, lost, total = self._tool_delta(previous_id, profile)
lines = [
f"Switched to profile '{profile.name}' ({profile_id}). "
+ "Its system prompt and tools are in force from the next step of this turn."
]
if total is not None:
lines.append(f"Tools available now: {total}.")
if gained:
lines.append(f"Newly available: {_shorten(gained)}.")
if lost:
lines.append(
f"No longer available: {_shorten(lost)} — calling one of these now fails "
f"with 'tool not found'."
)
if not gained and not lost:
lines.append("The available toolset is unchanged.")
return ToolResult(success=True, output=" ".join(lines))