diff --git a/navi/core/registry.py b/navi/core/registry.py index 681b905..511891c 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -236,6 +236,8 @@ switch_tool = SwitchProfileTool( session_store=session_store, profile_registry=profiles, + tool_registry=tools, + mcp_manager=mcp_manager, ) list_profiles_tool = ListProfilesTool(profile_registry=profiles) synapse_instructions_tool = SynapseInstructionsTool( diff --git a/navi/tools/switch_profile.py b/navi/tools/switch_profile.py index 397543b..c8d1d50 100644 --- a/navi/tools/switch_profile.py +++ b/navi/tools/switch_profile.py @@ -2,6 +2,18 @@ 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" @@ -9,7 +21,8 @@ "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 (tools + system prompt) becomes active from the NEXT user message. " + "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 = { @@ -23,9 +36,47 @@ "required": ["profile_id"], } - def __init__(self, session_store, profile_registry) -> None: + 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() @@ -70,6 +121,10 @@ 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 @@ -77,16 +132,28 @@ # 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. - sink = ctx.event_sink if ctx else current_event_sink.get() + # 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)) - return ToolResult( - success=True, - output=( - f"Switched to profile '{profile.name}' ({profile_id}). " - f"Its tools and system prompt are active from the next message." - ), - ) + 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)) diff --git a/tests/unit/tools/test_switch_profile.py b/tests/unit/tools/test_switch_profile.py index 4c740fe..d1e3d30 100644 --- a/tests/unit/tools/test_switch_profile.py +++ b/tests/unit/tools/test_switch_profile.py @@ -8,10 +8,12 @@ import pytest +from navi.core.registry import ProfileRegistry, ToolRegistry from navi.core.session import InMemorySessionStore -from navi.tools._internal.base import ToolContext +from navi.profiles.base import ToolConfig, ToolScopeConfig +from navi.tools._internal.base import ToolContext, current_event_sink from navi.tools.switch_profile import SwitchProfileTool -from tests.conftest_factory import make_profile_registry +from tests.conftest_factory import FakeTool, make_profile, make_profile_registry class RecordingStore(InMemorySessionStore): @@ -115,3 +117,87 @@ assert result.success is False assert store.profile_updates == [] assert store.saves == 0 + + +@pytest.mark.asyncio +async def test_event_reaches_the_contextvar_sink_when_ctx_has_none(): + """The agent loop builds tool_ctx with event_sink=None (the ContextVar is the + real channel), so `if ctx else` silently dropped the event and the profile + badge in the header never moved.""" + store = RecordingStore() + session = await _make_session(store) + sink: list = [] + + class _Sink: + async def put(self, event): + sink.append(event) + + token = current_event_sink.set(_Sink()) + try: + result = await _tool(store).execute( + {"profile_id": "secretary"}, + ctx=ToolContext(user_id="u1", session_id=session.id, event_sink=None), + ) + finally: + current_event_sink.reset(token) + + assert result.success is True + assert [e.profile_id for e in sink] == ["secretary"] + + +def _registry(*names: str) -> ToolRegistry: + reg = ToolRegistry() + for name in names: + reg.register(FakeTool(name)) + return reg + + +def _profiles(**scopes: list[str]) -> ProfileRegistry: + reg = ProfileRegistry() + for profile_id, tools in scopes.items(): + reg.register(make_profile( + profile_id, + tools=ToolConfig(agent=ToolScopeConfig(native=list(tools))), + )) + return reg + + +@pytest.mark.asyncio +async def test_output_names_the_gained_and_lost_tools(monkeypatch): + """The model called reload_tools after leaving tool_developer because nothing + told it what the new profile has — report the delta instead.""" + monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", list) + store = RecordingStore() + session = await _make_session(store, profile_id="developer") + tool = SwitchProfileTool( + session_store=store, + profile_registry=_profiles( + developer=["shared", "reload_tools"], + secretary=["shared", "send_email"], + ), + tool_registry=_registry("shared", "reload_tools", "send_email"), + ) + + result = await tool.execute( + {"profile_id": "secretary"}, ctx=ToolContext(user_id="u1", session_id=session.id) + ) + + assert result.success is True + assert "Tools available now: 2" in result.output + assert "Newly available: send_email" in result.output + assert "No longer available: reload_tools" in result.output + assert "next step of this turn" in result.output + + +@pytest.mark.asyncio +async def test_output_survives_without_a_tool_registry(): + """No registry wired (legacy/test construction) — the switch still reports.""" + store = RecordingStore() + session = await _make_session(store) + + result = await _tool(store).execute( + {"profile_id": "secretary"}, ctx=ToolContext(user_id="u1", session_id=session.id) + ) + + assert result.success is True + assert "Tools available now" not in result.output