diff --git a/docs/profiles.md b/docs/profiles.md index 11e7edf..e85ae1a 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -82,6 +82,7 @@ | `context_providers` | list[str] | `[]` | Extra context providers to inject for this profile (by name). Global providers are always injected. | | `mcp_servers` | dict | `{}` | MCP servers referenced by this profile. Format: `{"server_name": ["group1", "group2"]}` or `{"server_name": ["*"]}` for all tools. | | `is_admin_only` | bool | `false` | If `true`, profile is hidden from non-admin users in the profile list. | +| `is_subagent_only` | bool | `false` | If `true`, profile 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/docs/tools.md b/docs/tools.md index bb793d4..dc6e936 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -24,7 +24,7 @@ | `ListToolsTool` | `list_tools` | Return the live tool list from registry | | `ToolManualTool` | `tool_manual` | Return manuals/{name}.md or auto-generate from schema | | `MemoryTool` | `memory` | Unified memory tool: save, search, and forget facts | -| `SpawnAgentTool` | `spawn_agent` | Spawn an isolated subagent (blocking). Optional `profile_id` selects another profile; omitted means parent profile | +| `SpawnAgentTool` | `spawn_agent` | Spawn an isolated subagent (blocking). Optional `profile_id` selects another profile; omitted means parent profile. `inherit_system_prompt=true` prepends the parent profile's full system prompt as a base layer for the subagent | | `SwitchProfileTool` | `switch_profile` | Switch the active profile for a session | | `ListProfilesTool` | `list_profiles` | List all available profiles | | `ShareFileTool` | `share_file` | Copy an existing local file into session files and return a download link | diff --git a/navi/core/agent.py b/navi/core/agent.py index 46a6f67..d37ef52 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -323,6 +323,7 @@ exclude_tools: list[str] | None = None, briefing: str | None = None, custom_system_prompt: str | None = None, + inherit_system_prompt: bool = False, context_transfer: str | None = None, parent_session_id: str | None = None, timeout_seconds: float = 300.0, @@ -338,10 +339,13 @@ Tools listed in exclude_tools are stripped from the tool list (use this to prevent recursion: exclude 'spawn_agent'). - System prompt structure (completely separate from the parent's system prompt): - 1. profile.subagent_system_prompt — focused executor persona - 2. custom_system_prompt — optional role specialisation for this task - 3. briefing — task context (credentials, paths, instructions) + System prompt structure: + Default (inherit_system_prompt=False): only subagent-specific prompts: + 1. profile.subagent_system_prompt — focused executor persona + 2. custom_system_prompt — optional role specialisation for this task + 3. briefing — task context (credentials, paths, instructions) + Inherit mode (inherit_system_prompt=True): parent's system prompt as base layer, + then subagent specialisation on top. context_transfer: text from the parent's scratchpad context_transfer section, injected as a priming exchange before the task message. @@ -393,10 +397,14 @@ _uinfo_var.set(None) mem = await self._ctx_builder._memory_msg(user_id=user_id) - # Build subagent system prompt — completely separate from the parent's system prompt. - # No persona, no orchestrator instructions, no profiles block. - # Structure: executor persona → role specialisation → task context (briefing) + # Build subagent system prompt. + # Default (inherit=False): completely separate from the parent's system prompt. + # Structure: subagent persona → role specialisation → task context + # Inherit mode (inherit=True): parent's system prompt as base layer, + # then subagent specialisation on top. sys_parts: list[str] = [] + if inherit_system_prompt: + sys_parts.append(profile.system_prompt) if profile.subagent_system_prompt: sys_parts.append(profile.subagent_system_prompt) if custom_system_prompt: diff --git a/navi/profiles/base.py b/navi/profiles/base.py index 83a3037..fabbbc2 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -38,6 +38,11 @@ # 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. diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index 0cc9a01..a4b5c34 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -83,6 +83,7 @@ planning_phase3_enabled=config.get("planning_phase3_enabled", True), short_description=config.get("short_description", ""), full_description=config.get("full_description", {}), + is_subagent_only=config.get("is_subagent_only", 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), @@ -145,6 +146,7 @@ "subagent_tools": profile.subagent_tools, "subagent_planning_enabled": profile.subagent_planning_enabled, "subagent_think_enabled": profile.subagent_think_enabled, + "is_subagent_only": profile.is_subagent_only, "enabled_tools": profile.enabled_tools, "context_providers": profile.context_providers, "mcp_servers": profile.mcp_servers, diff --git a/navi/tools/list_profiles.py b/navi/tools/list_profiles.py index d8fc06c..24c250d 100644 --- a/navi/tools/list_profiles.py +++ b/navi/tools/list_profiles.py @@ -44,7 +44,8 @@ @staticmethod def _format(p) -> str: fd = p.full_description or {} - lines = [f"## {p.name} [{p.id}]"] + tag = " [subagent only]" if getattr(p, "is_subagent_only", False) else "" + lines = [f"## {p.name} [{p.id}]{tag}"] if p.short_description: lines.append(p.short_description) if fd.get("specialization"): diff --git a/navi/tools/spawn_agent.py b/navi/tools/spawn_agent.py index 3e3f626..04d7c8c 100644 --- a/navi/tools/spawn_agent.py +++ b/navi/tools/spawn_agent.py @@ -73,6 +73,17 @@ "type": "integer", "description": "Maximum tool-call iterations for the sub-agent (default: 40).", }, + "inherit_system_prompt": { + "type": "boolean", + "description": ( + "If true, the sub-agent starts with the parent profile's full system prompt " + "as a base layer, then overlays the sub-agent's own specialisation on top. " + "Use this when you want the sub-agent to keep the parent's personality, rules, " + "and workflow, plus any extra instructions from 'system_prompt' or 'briefing'. " + "If false (default), the sub-agent uses only its own subagent_system_prompt, " + "ignoring the parent's system prompt entirely." + ), + }, }, "required": ["task"], } @@ -102,6 +113,7 @@ briefing = (params.get("briefing") or "").strip() or None custom_system_prompt = (params.get("system_prompt") or "").strip() or None max_iterations = int(params.get("max_iterations") or 40) + inherit_system_prompt = bool(params.get("inherit_system_prompt", False)) # task → user message (what to do) # briefing → system prompt (context, credentials, instructions) @@ -158,6 +170,7 @@ exclude_tools=["spawn_agent"], # prevent recursion briefing=briefing, custom_system_prompt=custom_system_prompt, + inherit_system_prompt=inherit_system_prompt, context_transfer=context_transfer or None, parent_session_id=parent_sid, timeout_seconds=300.0, diff --git a/navi/tools/switch_profile.py b/navi/tools/switch_profile.py index d4e1db5..16643a2 100644 --- a/navi/tools/switch_profile.py +++ b/navi/tools/switch_profile.py @@ -40,6 +40,14 @@ error=f"Profile '{profile_id}' not found. Available: {available}", ) + 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 = current_session_id.get() if not sid: return ToolResult(success=False, output="", error="No active session context.")