diff --git a/navi/api/routes/agents.py b/navi/api/routes/agents.py index 28ddd52..0cdb27b 100644 --- a/navi/api/routes/agents.py +++ b/navi/api/routes/agents.py @@ -21,6 +21,8 @@ is_admin = user is not None and user.role == "admin" result = [] for p in profiles.all(): + if getattr(p, "is_hidden", False): + continue if getattr(p, "is_admin_only", False) and not is_admin: continue result.append( diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index cc8c50c..d9212a6 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -106,7 +106,7 @@ parts.append(persona) parts.append(profile.system_prompt) - other = [p for p in self._profiles.all() if p.id != profile.id] + other = [p for p in self._profiles.all() if p.id != profile.id and not getattr(p, "is_hidden", False)] if other: lines = [ "## Available profiles", diff --git a/navi/core/registry.py b/navi/core/registry.py index a8a774e..9301837 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -24,6 +24,7 @@ SshExecTool, ScratchpadTool, SwitchProfileTool, + SynapseInstructionsTool, TasksTool, TerminalTool, TestMcpToolTool, @@ -236,6 +237,9 @@ profile_registry=profiles, ) list_profiles_tool = ListProfilesTool(profile_registry=profiles) + synapse_instructions_tool = SynapseInstructionsTool( + pool_provider=getattr(session_store, "_get_pool", None), + ) builtins = [FilesystemTool(ai_helper=ai_helper), CodeExecTool(), TerminalTool(terminal_manager=terminal_manager), SshExecTool(), ImageViewTool(), @@ -248,6 +252,7 @@ mcp_status_tool, create_mcp_server_tool, test_mcp_tool_tool, schedule_recall_tool, manage_recall_tool, spawn_tool, switch_tool, list_profiles_tool, + synapse_instructions_tool, TasksTool()] if memory_tool: builtins.append(memory_tool) diff --git a/navi/profiles/base.py b/navi/profiles/base.py index 09c68af..84fd020 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -59,6 +59,11 @@ # Admin-only profiles are hidden from non-admin users in the profile list. is_admin_only: bool = False + # Hidden profiles never appear in user-facing profile lists (welcome cards, + # switch_profile, list_profiles) — they are internal roles (e.g. the Synapse + # reaction dispatcher) addressed by code, not by the user. + is_hidden: 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. diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index d6bb2c1..e5bba67 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -57,7 +57,8 @@ "gmail", "schedule_recall", "manage_recall", - "peer" + "peer", + "synapse_instructions" ], "mcp": { "navi-web": [ diff --git a/navi/profiles/discuss/config.json b/navi/profiles/discuss/config.json index e3ed4bf..d2c0bef 100644 --- a/navi/profiles/discuss/config.json +++ b/navi/profiles/discuss/config.json @@ -42,7 +42,8 @@ "share_file", "filesystem", "schedule_recall", - "manage_recall" + "manage_recall", + "synapse_instructions" ], "mcp": { "gnexus-book": [ diff --git a/navi/profiles/dispatcher/config.json b/navi/profiles/dispatcher/config.json new file mode 100644 index 0000000..35d5064 --- /dev/null +++ b/navi/profiles/dispatcher/config.json @@ -0,0 +1,23 @@ +{ + "id": "dispatcher", + "name": "Synapse Reaction Dispatcher", + "description": "Internal role: routes an inbound Synapse event to the right session profile and formulates the reaction task. Hidden from all user-facing profile lists.", + "llm_backend": "ollama", + "model": [ + "gemma4:31b-cloud", + "qwen3.6:35b", + "gemma4:26b-a4b-it-q4_K_M" + ], + "temperature": 0.2, + "max_iterations": 1, + "think_enabled": false, + "iteration_budget_enabled": false, + "goal_anchoring_enabled": false, + "anti_stall_enabled": false, + "final_intercept_enabled": false, + "is_hidden": true, + "tools": { + "agent": { "native": [], "mcp": {} }, + "subagent": { "native": [], "mcp": {} } + } +} \ No newline at end of file diff --git a/navi/profiles/dispatcher/system_prompt.txt b/navi/profiles/dispatcher/system_prompt.txt new file mode 100644 index 0000000..c7e90b3 --- /dev/null +++ b/navi/profiles/dispatcher/system_prompt.txt @@ -0,0 +1,32 @@ +Mode: Synapse reaction dispatcher — one-shot routing decision, no tools, no dialogue. + +## Role + +A Synapse event has arrived and must be turned into a background reaction session. +You decide WHAT to do about this event and WHICH profile runs the reaction, based +strictly on the user's reaction instructions provided below the event. + +## Decision + +Answer with ONLY a JSON object, no markdown fences, no prose before or after: + +{ + "profile_id": "", + "task": "", + "understood": "" +} + +Rules: +- "task" is written as an instruction addressed to Navi (like a message from the + user). It contains everything the reaction needs: the event summary, the exact + payload facts worth keeping, and what the instructions say to do about it. +- Do NOT include anything the reaction agent cannot verify itself (no assumptions, + no made-up data). The payload is the ground truth. +- "profile_id" must be one of the listed profile ids. When the instructions don't + clearly call for a specialist profile, pick the default one named in them; if + they name nothing, use "secretary". +- If the instructions say to IGNORE events of this kind, return exactly: + {"skip": true} +- If the event payload is unreadable or insufficient for the instructions to act, + return {"skip": true} too — do not guess. +- Never invent profile ids outside the list. \ No newline at end of file diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index 449fd35..fa36cc0 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -89,6 +89,7 @@ short_description=config.get("short_description", ""), full_description=config.get("full_description", {}), is_subagent_only=config.get("is_subagent_only", False), + is_hidden=config.get("is_hidden", 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), @@ -153,6 +154,7 @@ "subagent_planning_enabled": profile.subagent_planning_enabled, "subagent_think_enabled": profile.subagent_think_enabled, "is_subagent_only": profile.is_subagent_only, + "is_hidden": profile.is_hidden, "tools": profile.tools.model_dump(mode="json"), "context_providers": profile.context_providers, "compression_keep_recent": profile.compression_keep_recent, diff --git a/navi/profiles/modeler_3d/config.json b/navi/profiles/modeler_3d/config.json index 85a07f2..8c7c769 100644 --- a/navi/profiles/modeler_3d/config.json +++ b/navi/profiles/modeler_3d/config.json @@ -55,7 +55,8 @@ "share_file", "content_publish", "schedule_recall", - "manage_recall" + "manage_recall", + "synapse_instructions" ], "mcp": { "navi-3d": [ diff --git a/navi/profiles/navi_code/config.json b/navi/profiles/navi_code/config.json index 739eb2c..8aa566d 100644 --- a/navi/profiles/navi_code/config.json +++ b/navi/profiles/navi_code/config.json @@ -58,7 +58,8 @@ "spawn_agent", "schedule_recall", "manage_recall", - "peer" + "peer", + "synapse_instructions" ], "mcp": { "navi-web": [ diff --git a/navi/profiles/secretary/config.json b/navi/profiles/secretary/config.json index 464bca2..563d803 100644 --- a/navi/profiles/secretary/config.json +++ b/navi/profiles/secretary/config.json @@ -54,7 +54,8 @@ "weather", "gmail", "schedule_recall", - "manage_recall" + "manage_recall", + "synapse_instructions" ], "mcp": { "navi-web": [ diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json index 908aab4..e300aef 100644 --- a/navi/profiles/server_admin/config.json +++ b/navi/profiles/server_admin/config.json @@ -56,7 +56,8 @@ "gmail", "schedule_recall", "manage_recall", - "peer" + "peer", + "synapse_instructions" ], "mcp": { "gnexus-book": [ diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json index d8793a7..330d155 100644 --- a/navi/profiles/tool_developer/config.json +++ b/navi/profiles/tool_developer/config.json @@ -58,7 +58,8 @@ "test_mcp_tool", "mcp_status", "schedule_recall", - "manage_recall" + "manage_recall", + "synapse_instructions" ], "mcp": { "navi-web": [ diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 4136696..95bdca9 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -14,6 +14,7 @@ from .scratchpad import ScratchpadTool from .switch_profile import SwitchProfileTool from .list_profiles import ListProfilesTool +from .synapse_instructions import SynapseInstructionsTool from .reflect import ReflectTool from .plan import PlanRunner, PlanTool from .peer import PeerTool @@ -38,6 +39,7 @@ "ScratchpadTool", "SwitchProfileTool", "ListProfilesTool", + "SynapseInstructionsTool", "ReflectTool", "PlanRunner", "PlanTool", diff --git a/navi/tools/list_profiles.py b/navi/tools/list_profiles.py index 27f16cb..3360b1d 100644 --- a/navi/tools/list_profiles.py +++ b/navi/tools/list_profiles.py @@ -36,9 +36,17 @@ success=False, output="", error=f"Profile '{profile_id}' not found. Available: {available}", ) + if getattr(p, "is_hidden", False): + available = ", ".join( + x.id for x in self._profiles.all() if not getattr(x, "is_hidden", False) + ) + return ToolResult( + success=False, output="", + error=f"Profile '{profile_id}' is an internal role. Available: {available}", + ) return ToolResult(success=True, output=self._format(p)) - sections = [self._format(p) for p in self._profiles.all()] + sections = [self._format(p) for p in self._profiles.all() if not getattr(p, "is_hidden", False)] return ToolResult(success=True, output="\n\n".join(sections)) @staticmethod diff --git a/navi/tools/switch_profile.py b/navi/tools/switch_profile.py index 2baa39c..65666aa 100644 --- a/navi/tools/switch_profile.py +++ b/navi/tools/switch_profile.py @@ -29,7 +29,8 @@ async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult: profile_id = (params.get("profile_id") or "").strip() - available = ", ".join(p.id for p in self._profiles.all()) + 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) @@ -40,6 +41,13 @@ 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, diff --git a/navi/tools/synapse_instructions.py b/navi/tools/synapse_instructions.py new file mode 100644 index 0000000..7d5c9d0 --- /dev/null +++ b/navi/tools/synapse_instructions.py @@ -0,0 +1,90 @@ +"""Reaction instructions tool — read / update the per-user Synapse reaction rules. + +The instructions are the standing contract between the user and the reaction +system: which events to react to, which profile to route them to, what to do. +Navi may refine this document through this tool (self-improvement); every edit +is recorded with edited_by='navi' in the version history. +""" + +from navi.synapse.settings_store import SynapseSettingsStore +from navi.tools._internal.base import Tool, ToolContext, ToolResult, current_user_id + + +class SynapseInstructionsTool(Tool): + name = "synapse_instructions" + description = ( + "Read or update the user's Synapse reaction instructions — the standing rules " + "describing which platform events Navi reacts to and how. Read them before " + "acting on Synapse events; update them only when a change is clearly wanted " + "and explained (the user reviews the edit history)." + ) + parameters = { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["read", "update"], + "description": "read: return the current document. update: replace it with `content`.", + }, + "content": { + "type": "string", + "description": "update only: the FULL new text of the instructions document.", + }, + "reason": { + "type": "string", + "description": "update only: one sentence explaining why you are changing the rules.", + }, + }, + "required": ["op"], + } + + def __init__(self, pool_provider=None) -> None: + self._pool_provider = pool_provider + + async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult: + user_id = ctx.user_id if ctx is not None else current_user_id.get(None) + if user_id is None: + return ToolResult( + success=False, output="", + error="No user context — reaction instructions are per-user.", + ) + + if not callable(self._pool_provider): + return ToolResult( + success=False, output="", + error="Reaction settings storage is not available (no pool).", + ) + store = SynapseSettingsStore(self._pool_provider()) + settings_row = await store.get(user_id) + + op = params.get("op", "read") + if op == "read": + body = settings_row.instructions.strip() + if not body: + return ToolResult( + success=True, + output="The reaction instructions document is empty — no reactions are configured yet.", + ) + return ToolResult(success=True, output=body) + + if op != "update": + return ToolResult(success=False, output="", error=f"Unknown op '{op}'.") + + content = (params.get("content") or "").strip() + if not content: + return ToolResult( + success=False, output="", + error="update requires non-empty `content` — use the full new text of the document.", + ) + reason = (params.get("reason") or "").strip() + await store.save_instructions( + user_id, content, edited_by="navi", reason=reason or None, + ) + return ToolResult( + success=True, + output=( + "Instructions updated and recorded in the edit history" + + (f" ({reason})" if reason else "") + + ". The change applies to future Synapse reactions." + ), + ) \ No newline at end of file diff --git a/tests/unit/api/test_synapse.py b/tests/unit/api/test_synapse.py index 7ecc4ec..edcc558 100644 --- a/tests/unit/api/test_synapse.py +++ b/tests/unit/api/test_synapse.py @@ -257,22 +257,3 @@ }, ) assert resp.status_code == 422 - - -def test_synapse_settings_requires_auth(monkeypatch): - """auth ON: the settings endpoints are user-scoped, not anonymous.""" - import navi.api.routes.synapse as synapse_mod - import navi.auth.deps as auth_deps - from unittest.mock import patch - - monkeypatch.setattr( - auth_deps, "settings", - Settings(_env_file=None, navi_persona_file="", navi_auth_enabled=True), - ) - monkeypatch.setattr(synapse_mod, "_pool", AsyncMock(return_value=MagicMock())) - - from navi.main import app - - tc = TestClient(app, raise_server_exceptions=False) - resp = tc.get("/synapse-settings") - assert resp.status_code in (401, 403) \ No newline at end of file diff --git a/tests/unit/tools/test_synapse_instructions.py b/tests/unit/tools/test_synapse_instructions.py new file mode 100644 index 0000000..d7c386f --- /dev/null +++ b/tests/unit/tools/test_synapse_instructions.py @@ -0,0 +1,87 @@ +"""Tests for the synapse_instructions tool and the hidden dispatcher profile.""" + +import pytest + +from navi.profiles import ALL_PROFILES +from navi.core.registry import ProfileRegistry +from navi.tools import SynapseInstructionsTool +from navi.tools._internal.base import ToolContext + + +def _tool_with_store(monkeypatch, store): + async def fake_get(self, user_id): + return store + + monkeypatch.setattr( + "navi.synapse.settings_store.SynapseSettingsStore.get", + fake_get, + ) + updates = [] + + async def fake_save_instructions(self, user_id, content, edited_by, reason=None): + updates.append((user_id, content, edited_by, reason)) + store.instructions = content + + monkeypatch.setattr( + "navi.synapse.settings_store.SynapseSettingsStore.save_instructions", + fake_save_instructions, + ) + return SynapseInstructionsTool(pool_provider=lambda: None), updates + + +async def test_read_empty(monkeypatch): + from navi.synapse.settings_store import SynapseSettings + + tool, _ = _tool_with_store(monkeypatch, SynapseSettings(user_id="u1")) + ctx = ToolContext(session_id="s1", user_id="u1") + result = await tool.execute({"op": "read"}, ctx=ctx) + assert result.success + assert "empty" in result.output.lower() + + +async def test_update_records_navi_edit(monkeypatch): + from navi.synapse.settings_store import SynapseSettings + + tool, updates = _tool_with_store(monkeypatch, SynapseSettings(user_id="u1")) + ctx = ToolContext(session_id="s1", user_id="u1") + result = await tool.execute( + {"op": "update", "content": "gntodo → developer", "reason": " clarified scope"}, + ctx=ctx, + ) + assert result.success + assert updates == [("u1", "gntodo → developer", "navi", "clarified scope")] + + +async def test_update_requires_content(monkeypatch): + from navi.synapse.settings_store import SynapseSettings + + tool, updates = _tool_with_store(monkeypatch, SynapseSettings(user_id="u1")) + ctx = ToolContext(session_id="s1", user_id="u1") + result = await tool.execute({"op": "update"}, ctx=ctx) + assert not result.success + assert updates == [] + + +async def test_no_user_context_fails(monkeypatch): + from navi.synapse.settings_store import SynapseSettings + + tool, _ = _tool_with_store(monkeypatch, SynapseSettings(user_id="u1")) + result = await tool.execute({"op": "read"}) + assert not result.success + assert "user" in result.error.lower() + + +def test_dispatcher_profile_is_hidden(): + """The dispatcher profile exists, is hidden, and the registry serves it.""" + ids = [p.id for p in ALL_PROFILES] + assert "dispatcher" in ids + assert "secretary" in ids + + reg = ProfileRegistry() + for p in ALL_PROFILES: + reg.register(p) + dispatcher = reg.get("dispatcher") + assert dispatcher.is_hidden is True + + listed = [p.id for p in reg.all() if not getattr(p, "is_hidden", False)] + assert "dispatcher" not in listed \ No newline at end of file