diff --git a/docs/auth.md b/docs/auth.md index b71067c..c312a2b 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -135,8 +135,9 @@ permission: there is no permission that grants it, and a `user` cannot obtain it by acquiring any permission. The rule lives in one predicate, `admin_only_blocked(profile, role)` in `navi/profiles/base.py`, consulted by every place a profile can be reached — the -profile list, session creation, `switch_profile`, `list_profiles`, `spawn_agent`, the -"Available profiles" block of the system prompt, and the Synapse reaction router — which +profile list, session creation, `switch_profile`, `list_profiles`, `list_tools` (when a +profile is named), `spawn_agent`, the "Available profiles" block of the system prompt, and +the Synapse reaction router — which asks it with the role of the account the event was delivered for, not with a default. An unknown or absent role is treated as non-admin. diff --git a/docs/mechanics.md b/docs/mechanics.md index e837700..8d84b8d 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -84,7 +84,7 @@ |---|---|---|---|---| | **System prompt caching** | Caches built system prompt per profile ID to avoid rebuilding on every turn. Provides `invalidate_system_prompt_cache()`. | None | `context_builder.py` | ✅ | | **Persona + profile construction** | Prepends global `NAVI_PERSONA` to profile's `system_prompt`, separated by `---`. | `NAVI_PERSONA_FILE` | `context_builder.py` | ✅ | -| **Cross-profile awareness** | Appends `## Available profiles` block listing all other profiles with descriptions. | None | `context_builder.py` | ⚠️ | +| **Cross-profile awareness** | Appends `## Available profiles` block listing all other profiles with descriptions, plus the switch-vs-delegate pointer (`profiles_block()`, shared with the `/agents/prompts` debug view). | None | `context_builder.py` | ✅ | | **Memory summary message** | Injects `## What I remember about the user` if memory store has a summary. | None | `context_builder.py` | ✅ | | **Memory facts message** | Searches memory facts from user message. Skips messages ≤20 chars or <2 words. Limits: 1 fact for <50 chars, 2 for ≤150, 3 otherwise. Deduplicates. | None | `context_builder.py` | ❌ | | **Memory facts scope filter** | When `scope_boundary_enabled=True`, drops memory facts whose value is an absolute path outside the session `cwd` (e.g. a stale `project_root` pointing at another project). Facts inside the cwd and non-path facts are kept. No facts are deleted — only injection is filtered. | `profile.scope_boundary_enabled` | `context_builder.py` | ✅ | diff --git a/docs/profiles.md b/docs/profiles.md index 1cfe741..4762952 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -400,4 +400,24 @@ `switch_profile` tool repoints the session with one narrow `UPDATE sessions SET profile_id` (`PgSessionStore.set_profile()` — never a full `save()`, which would hand out sequence numbers the running turn is still claiming). After each tool execution batch, `run_stream()` compares the stored profile against the profile the run is **bound** to — not against `session.profile_id`, since the store's write leaves those two equal again — and reloads profile + tools when they differ. Takes effect on the next LLM call. For the same reason `save()` leaves the `profile_id` column alone on conflict: a mid-run save carries the profile the run started with and must not write a switch back. -Rules (in persona): don't switch for a single off-topic question; switch when the domain clearly changes; never switch back and forth repeatedly. +Rules live in the persona (`PROFILES AND TOOLS` / `SWITCHING PROFILES`), once, rather than +in each profile prompt: + +- **Switch vs delegate.** Switch when the task itself is another profile's work from the + start — the user asks `assistant` for a 3D model. When another profile's tools are + needed only as a side piece of the current task (writing a document and needing a part + rendered), the session profile stays and the piece is delegated with + `spawn_agent(profile_id=...)`. +- **Reconnaissance order.** The `## Available profiles` block gives one line per profile; + `list_profiles` adds specialization, when-to-use and key tools; `list_tools(profile_id=...)` + is the only view of a profile's *actual* toolset; `tool_manual` documents one tool. The + block is built by `profiles_block()` in `context_builder.py`, shared with the admin + `/agents/prompts` debug view so the two cannot drift. +- **Mechanics.** The switch applies from the next LLM call in the same turn, and + `switch_profile` must be called alone — a tool in the same batch still runs against the + old toolset. Conversation context carries over. Don't switch for a single off-topic + question; never ping-pong between profiles. +- **Access.** Hidden, admin-only-for-a-non-admin and subagent-only profiles cannot be + switched to (`switch_profile` refuses them; subagent-only ones are reachable only + through `spawn_agent`). `list_tools` applies the same role gate when a profile is named + explicitly, so it cannot be used to enumerate profiles the account may not use. diff --git a/navi/api/routes/agents.py b/navi/api/routes/agents.py index 0b43e98..bb891bf 100644 --- a/navi/api/routes/agents.py +++ b/navi/api/routes/agents.py @@ -14,6 +14,7 @@ from navi.auth import User from navi.config import settings from navi.core import ProfileRegistry, ToolRegistry +from navi.core.context_builder import profiles_block from navi.mcp import McpManager, load_mcp_servers from navi.profiles.base import admin_only_blocked @@ -106,22 +107,16 @@ sections.append({"label": "profile", "content": profile.system_prompt}) - other = [p for p in all_profiles if p.id != profile.id] + # Hidden roles never appear in a real prompt, for any role; admin-only + # ones do for this endpoint's caller, who is an admin. + other = [ + p for p in all_profiles + if p.id != profile.id and not getattr(p, "is_hidden", False) + ] if other: - lines = [ - "## Available profiles", - f"Current: **{profile.id}**", - ] - for p in other: - desc = p.short_description or p.description - lines.append(f"· {p.id}: {desc}") - lines.append( - "→ Switch profiles on your own judgment — do not ask for permission. " - "When a task clearly fits another profile, call switch_profile immediately, " - "then inform the user which profile is now active and why. " - "Use list_profiles if you need details about a profile's capabilities." + sections.append( + {"label": "profiles block", "content": profiles_block(profile.id, other)} ) - sections.append({"label": "profiles block", "content": "\n".join(lines)}) full = "\n\n---\n\n".join(s["content"] for s in sections) result.append( diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index 1d561bf..cd8d5f6 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -182,6 +182,29 @@ return "\n\n".join(lines) +def profiles_block(current_id: str, others: list) -> str: + """The "Available profiles" block: the cross-profile map, plus how to act on it. + + The block is built in one place because the admin prompt-debug view renders + it too — two copies used to tell the model different things about switching. + The rules themselves live in the persona; this is the short reminder beside + the list, where the model is about to make the choice. + """ + lines = ["## Available profiles", f"Current: **{current_id}**"] + for profile in others: + desc = profile.short_description or profile.description + lines.append(f"· {profile.id}: {desc}") + lines.append( + "→ Decide on your own — do not ask permission. The task you were given belongs " + "to another profile's domain → `switch_profile`, called alone, then say which " + "profile is active and why. Another profile's tools are needed only for a side " + "piece of your own task → `spawn_agent(profile_id=…)` and keep this session's " + "profile. Details: `list_profiles`; what a candidate can actually call: " + "`list_tools(profile_id=…)`." + ) + return "\n".join(lines) + + async def render_todo_lines(session_id: str) -> list[str]: """Return a list of formatted todo lines for goal anchoring.""" try: @@ -243,20 +266,7 @@ and not admin_only_blocked(p, role) ] if other: - lines = [ - "## Available profiles", - f"Current: **{profile.id}**", - ] - for p in other: - desc = p.short_description or p.description - lines.append(f"· {p.id}: {desc}") - lines.append( - "→ Switch profiles on your own judgment — do not ask for permission. " - "When a task clearly fits another profile, call switch_profile immediately, " - "then inform the user which profile is now active and why. " - "Use list_profiles if you need details about a profile's capabilities." - ) - parts.append("\n".join(lines)) + parts.append(profiles_block(profile.id, other)) result = "\n\n---\n\n".join(parts) self._system_prompt_cache[cache_key] = result diff --git a/navi/profiles/assistant/system_prompt.txt b/navi/profiles/assistant/system_prompt.txt index 18b9c14..d58ff59 100644 --- a/navi/profiles/assistant/system_prompt.txt +++ b/navi/profiles/assistant/system_prompt.txt @@ -22,8 +22,10 @@ forbidden as calling `ssh` yourself. The web tools are for public internet pages, not for probing the network. - Your tool list is **complete and final**. A tool that is not in it does not exist for - you: do not try to reconstruct it with a script, do not look for it by another name, - and do not ask some other profile to run it on your behalf. + you: do not try to reconstruct it with a script, and do not look for it by another + name. Switching to, or delegating to, a profile from the Available profiles list is + normal — that profile carries its own boundaries — but no profile may be used to get + around the boundaries above. - Some MCP servers run on a **personal key** belonging to the user. A server without one is simply not connected to this account — that is normal, not a fault, and not something to work around. diff --git a/navi/profiles/designer_3d/system_prompt.txt b/navi/profiles/designer_3d/system_prompt.txt index d7d187f..7d8d487 100644 --- a/navi/profiles/designer_3d/system_prompt.txt +++ b/navi/profiles/designer_3d/system_prompt.txt @@ -24,8 +24,10 @@ forbidden as calling `ssh` yourself. The web tools are for public internet pages, not for probing the network. - Your tool list is **complete and final**. A tool that is not in it does not exist for - you: do not try to reconstruct it with a script, do not look for it by another name, - and do not ask some other profile to run it on your behalf. + you: do not try to reconstruct it with a script, and do not look for it by another + name. Switching to, or delegating to, a profile from the Available profiles list is + normal — that profile carries its own boundaries — but no profile may be used to get + around the boundaries above. - Some MCP servers run on a **personal key** belonging to the user. A server without one is simply not connected to this account — that is normal, not a fault, and not something to work around. diff --git a/navi/tools/list_profiles.py b/navi/tools/list_profiles.py index ce3fa52..582b60d 100644 --- a/navi/tools/list_profiles.py +++ b/navi/tools/list_profiles.py @@ -9,7 +9,8 @@ description = ( "List all available agent profiles with their specialization, use cases, and key tools. " "Call this before switch_profile when you need details about what a profile does " - "or to confirm which profile best fits the current task." + "or to confirm which profile best fits the current task. For what a profile can " + "actually call — its real toolset — follow with list_tools(profile_id='...')." ) parameters = { "type": "object", diff --git a/navi/tools/list_tools.py b/navi/tools/list_tools.py index c0b66c7..dff9dc6 100644 --- a/navi/tools/list_tools.py +++ b/navi/tools/list_tools.py @@ -3,8 +3,15 @@ from __future__ import annotations from navi.mcp.tools import build_mcp_name +from navi.profiles.base import admin_only_blocked -from ._internal.base import Tool, ToolContext, ToolResult, current_profile_id +from ._internal.base import ( + Tool, + ToolContext, + ToolResult, + current_profile_id, + current_user_role, +) NATIVE_SOURCE = "native" SCOPE_AGENT = "agent" @@ -32,7 +39,9 @@ "profile you are running as — name another with profile_id. scope picks that " "profile's agent or subagent toolset (spawn_agent runs the latter). Names only " "by default — narrow it with query (substring over names and descriptions) or " - "add verbose for descriptions. For one tool's full docs use tool_manual." + "add verbose for descriptions. For one tool's full docs use tool_manual. " + "With profile_id it also answers 'what would profile X give me', so call it " + "before switch_profile when the choice depends on the toolset." ) parameters = { "type": "object", @@ -113,6 +122,28 @@ success=False, output=f"Profile '{profile_id}' not found.", error="profile_not_found" ) + # A profile the account may not use is not inspectable by name either — + # the same role gate list_profiles applies, so asking "what would + # developer give me" as a regular user cannot enumerate hidden profiles. + # The defaulted case is the profile the run executes as: always allowed. + if not defaulted: + role = ctx.user_role if ctx else current_user_role.get() + if getattr(profile, "is_hidden", False): + return ToolResult( + success=False, + output=f"Profile '{profile_id}' is an internal role.", + error="hidden_profile", + ) + if admin_only_blocked(profile, role): + return ToolResult( + success=False, + output=( + f"Profile '{profile_id}' requires admin access. " + "list_profiles shows the profiles available to this account." + ), + error="admin_only", + ) + scope = (params.get("scope") or SCOPE_AGENT).strip().lower() if scope not in (SCOPE_AGENT, SCOPE_SUBAGENT): return ToolResult( diff --git a/navi/tools/switch_profile.py b/navi/tools/switch_profile.py index 3d3b6cd..404fca5 100644 --- a/navi/tools/switch_profile.py +++ b/navi/tools/switch_profile.py @@ -27,11 +27,15 @@ 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." + "Call this on your own judgment when the task you were given is itself another " + "profile's work — do not ask the user for permission first. If another profile's " + "tools are needed only for a side piece of your own task, do not switch: delegate " + "that piece with spawn_agent(profile_id='...') and keep this session's profile. " + "Compare toolsets first with list_tools(profile_id='...') when the fit depends on " + "them. 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", diff --git a/persona.txt b/persona.txt index 4101295..54b18f1 100644 --- a/persona.txt +++ b/persona.txt @@ -20,13 +20,21 @@ 4. Relevant source files, tool schemas, command output, or web research. Ask the user only after these sources do not contain the needed information or the decision is genuinely theirs to make. -PROFILE SWITCHING: -Each session has an active profile — it defines your available tools and system instructions. When the user's task clearly belongs to a different domain, call switch_profile. Rules: -- Don't switch for a single off-topic question. Switch when the session is clearly moving into a different domain. -- The switch takes effect from the NEXT user message — your current tools are still available for this turn. -- After switching, tell the user which profile is now active and what it's for. -- Never switch back and forth repeatedly within one conversation. -- When unsure which profile fits, call list_profiles. +PROFILES AND TOOLS: +Each session runs as one profile — it sets your system instructions and the tools you can call. The "Available profiles" block in these instructions lists every profile this account may use, with yours marked Current. + +Scouting before you decide: +- The one-line descriptions are usually enough. If the fit is still unclear, call list_profiles (optionally profile_id=...) for its specialization, when to use it and key tools. +- To see what a profile can actually call — not just what it says it is for — call list_tools(profile_id="..."); verbose=true adds descriptions. Compare it with list_tools() for your own set: what you gain and what you lose. +- For one tool's usage or schema, tool_manual(""). For an MCP server's own instructions, tool_manual(""). + +SWITCHING PROFILES: +- Switch when the task you were given is itself another profile's work — the user asks for 3D design while you are the assistant, or for server work while you are the developer — and the session is going to stay in that domain. +- Spawn a sub-agent instead when the need for another profile's tools arises as a side task inside your own work: you are writing a document and need a part rendered, or debugging code and need a 3D model checked. Delegate that piece with spawn_agent(profile_id="..."); the session keeps its profile and you integrate the result into your own answer. +- The switch takes effect from the NEXT STEP of this turn: the new profile's system prompt and tool list are in force from your next LLM call. Call switch_profile alone — a tool called in the same batch still runs against the old toolset. Conversation context carries over, and the new profile stays for later turns until you switch again. +- After switching, tell the user which profile is now active and why. +- Don't switch for a single off-topic question, and never ping-pong between profiles in one conversation. +- A profile absent from the Available profiles block is not available to this account: do not call switch_profile for it and do not promise it. Sub-agent-only profiles are reached only through spawn_agent. FILE LOCATIONS: You have two different file areas. Do not confuse them. diff --git a/tests/unit/core/test_context_builder.py b/tests/unit/core/test_context_builder.py index 734e4e0..358be4a 100644 --- a/tests/unit/core/test_context_builder.py +++ b/tests/unit/core/test_context_builder.py @@ -47,6 +47,13 @@ prompt = builder.build_system_prompt(profile) assert "## Available profiles" in prompt assert "developer" in prompt + # The block must say how to scout and when to switch rather than + # delegate: descriptions are not the toolset, and a side task is not a + # reason to repoint the session. + assert "list_profiles" in prompt + assert "list_tools(profile_id=" in prompt + assert "spawn_agent(profile_id=" in prompt + assert "switch_profile" in prompt def test_cache_returns_same_object(self): reg = make_profile_registry() diff --git a/tests/unit/tools/test_list_tools.py b/tests/unit/tools/test_list_tools.py index c12f0e9..325942b 100644 --- a/tests/unit/tools/test_list_tools.py +++ b/tests/unit/tools/test_list_tools.py @@ -79,6 +79,19 @@ ) +def admin_profile(profile_id: str = "server_admin", *, hidden: bool = False): + return make_profile( + profile_id, + is_admin_only=True, + is_hidden=hidden, + enabled_tools=[], + tools=ToolConfig( + agent=ToolScopeConfig(native=["ssh_exec"]), + subagent=ToolScopeConfig(), + ), + ) + + @pytest.mark.asyncio async def test_lists_only_the_requested_profiles_tools(): profiles = make_profiles( @@ -315,3 +328,67 @@ result = await make_tool(registry, profiles).execute({"profile_id": "assistant"}) assert "Hand-written manuals" not in result.output + + +class TestRoleGateOnNamedProfiles: + """A profile the account may not use is not inspectable by name either. + + list_profiles already hides admin-only profiles from regular users; without + the same gate here, 'what would developer give me' was a way around it. + """ + + @pytest.mark.asyncio + async def test_a_regular_user_is_refused_an_admin_profile(self): + profiles = make_profiles( + make_profile_with("assistant", ["todo"]), + admin_profile(), + ) + registry = make_registry(("todo", "notes"), ("ssh_exec", "runs commands")) + + result = await make_tool(registry, profiles).execute( + {"profile_id": "server_admin"}, ToolContext(user_role="user"), + ) + + assert result.success is False + assert result.error == "admin_only" + assert "ssh_exec" not in result.output + + @pytest.mark.asyncio + async def test_an_admin_can_inspect_it(self): + profiles = make_profiles( + make_profile_with("assistant", ["todo"]), + admin_profile(), + ) + registry = make_registry(("todo", "notes"), ("ssh_exec", "runs commands")) + + result = await make_tool(registry, profiles).execute( + {"profile_id": "server_admin"}, ToolContext(user_role="admin"), + ) + + assert result.success is True + assert "ssh_exec" in result.output + + @pytest.mark.asyncio + async def test_a_hidden_profile_is_refused_even_to_an_admin(self): + profiles = make_profiles(admin_profile("dispatcher", hidden=True)) + + result = await make_tool(make_registry(), profiles).execute( + {"profile_id": "dispatcher"}, ToolContext(user_role="admin"), + ) + + assert result.success is False + assert result.error == "hidden_profile" + + @pytest.mark.asyncio + async def test_the_current_profile_needs_no_gate(self): + # No profile_id: the run reports on the profile it executes as, which it + # is by definition allowed to use — even for an admin-only profile. + profiles = make_profiles(admin_profile()) + registry = make_registry(("ssh_exec", "runs commands")) + + result = await make_tool(registry, profiles).execute( + {}, ToolContext(profile_id="server_admin", user_role="admin"), + ) + + assert result.success is True + assert "ssh_exec" in result.output diff --git a/webclient/docs/architecture.md b/webclient/docs/architecture.md index 2458bc9..30b742e 100644 --- a/webclient/docs/architecture.md +++ b/webclient/docs/architecture.md @@ -71,7 +71,7 @@ - `config.json` — model, temperature, enabled tools list, planning flag - `system_prompt.txt` — the domain-specific system prompt -The global personality (`persona.txt`) is prepended to every profile's system prompt. Profile switches take effect on the next LLM call within the same run, and fully on the next user message. +The global personality (`persona.txt`) is prepended to every profile's system prompt. Profile switches take effect on the next LLM call within the same run — the loop reloads profile and tools after the tool batch that held `switch_profile`. ## Long-term memory diff --git a/webclient/docs/profiles.md b/webclient/docs/profiles.md index eb2e9db..582e3c5 100644 --- a/webclient/docs/profiles.md +++ b/webclient/docs/profiles.md @@ -27,7 +27,8 @@ The agent can switch profiles mid-session using the `switch_profile` tool. When this happens: 1. The server emits `profile_switched` event before the tool completes. -2. The new profile takes effect from the **next user message**. +2. The new profile takes effect from the **next LLM call**, in the same turn: the running + loop reloads profile and tools after the tool batch. 3. The client should update the profile indicator in the UI immediately on receiving `profile_switched`. The `session_id` is preserved across profile switches — only the profile changes.