diff --git a/docs/agent.md b/docs/agent.md index 5a44707..cbceaf6 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -11,7 +11,11 @@ Non-streaming. Full tool-calling loop, returns final text. No planning phase. ### `run_ephemeral(user_message, profile_id)` → `str` -Non-persistent subagent. No DB reads/writes. Temporary in-memory context. Called by `SpawnAgentTool`. Uses session ID `subagent_` to isolate scratchpad. +Non-persistent subagent. No DB reads/writes. Temporary in-memory context. Called by `SpawnAgentTool`. + +`spawn_agent.profile_id` is optional. If omitted, `SpawnAgentTool` resolves the parent session's current profile. If provided, the subagent uses the selected profile's model, `subagent_system_prompt`, planning flags, and tool set. Its tools come from that profile's `subagent_tools`, falling back to `enabled_tools` when `subagent_tools` is empty. + +When spawned from a persistent parent session, session-aware tools run under the parent session id so file tools resolve the user's session directory rather than a `subagent_*` directory. --- diff --git a/docs/profiles.md b/docs/profiles.md index 6943065..ca55ffe 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -39,6 +39,8 @@ | `enabled_tools` | list[str] | **required** | Tool names available in the main loop | | `subagent_tools` | list[str] | `[]` | Tools available to sub-agents spawned from this profile. Falls back to `enabled_tools` if empty. | +`spawn_agent` may receive an optional `profile_id`. If omitted, the subagent uses the parent session's current profile. If provided, the subagent uses the selected profile's model, prompt, planning flags, and `subagent_tools`/`enabled_tools` fallback. + ### Thinking mechanics | Key | Type | Default | Description | diff --git a/docs/tools.md b/docs/tools.md index 5dc5410..691327b 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -27,7 +27,7 @@ | `MemorySaveTool` | `memory_save` | Save a fact to long-term memory | | `MemorySearchTool` | `memory_search` | Search long-term memory facts | | `MemoryForgetTool` | `memory_forget` | Delete a fact from long-term memory | -| `SpawnAgentTool` | `spawn_agent` | Spawn an isolated subagent (blocking, synchronous from caller's view) | +| `SpawnAgentTool` | `spawn_agent` | Spawn an isolated subagent (blocking). Optional `profile_id` selects another profile; omitted means parent profile | | `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/profiles/developer/system_prompt.txt b/navi/profiles/developer/system_prompt.txt index d1e7c4c..93e7baf 100644 --- a/navi/profiles/developer/system_prompt.txt +++ b/navi/profiles/developer/system_prompt.txt @@ -31,6 +31,7 @@ - Exact files to modify and what to change. - Relevant existing code snippets or patterns to follow. - How to test/verify the result. +- Omit `profile_id` to use this developer profile. Set `profile_id` only when the delegated step clearly needs another profile's prompt, model, and tools. - End with: "Complete all assigned work. Return: summary of changes, test output." --- diff --git a/navi/profiles/secretary/system_prompt.txt b/navi/profiles/secretary/system_prompt.txt index e2b2cd2..e0031a5 100644 --- a/navi/profiles/secretary/system_prompt.txt +++ b/navi/profiles/secretary/system_prompt.txt @@ -60,9 +60,10 @@ NOT: one `spawn_agent` call that combines both X and Y research, because that is one call for two steps. ### Briefing sub-agents -spawn_agent takes three content fields: +spawn_agent takes these fields: - `task`: goal for this one step + expected output format + "Complete ALL assigned work before responding. Your output is final." - `briefing`: credentials, file paths, constraints, step-by-step instructions for this sub-agent. +- `profile_id`: optional profile override for the sub-agent. Omit it to use the parent session's current profile. Set it only when the step clearly belongs to another profile; that profile controls the sub-agent's prompt, model, and tools. - `system_prompt`: optional role specialisation (e.g. "You are a data analyst. Return results as a structured table."). --- diff --git a/navi/profiles/server_admin/system_prompt.txt b/navi/profiles/server_admin/system_prompt.txt index caa9ab8..dfd3edf 100644 --- a/navi/profiles/server_admin/system_prompt.txt +++ b/navi/profiles/server_admin/system_prompt.txt @@ -59,9 +59,10 @@ NOT: one `spawn_agent` call that combines SSH audit and disk check, because that is two steps. ### Briefing sub-agents -spawn_agent takes three content fields: +spawn_agent takes these fields: - `task`: what to accomplish in this one step + expected output format + "Complete ALL assigned work before responding. Your output is final." - `briefing`: hostname/IP, credentials, exact commands or checks to run, constraints. +- `profile_id`: optional profile override for the sub-agent. Omit it to use the parent session's current profile. Set it only when the step clearly belongs to another profile; that profile controls the sub-agent's prompt, model, and tools. - `system_prompt`: optional role specialisation (e.g. "You are a security auditor. Report findings by severity: critical / warning / info."). ### Scratchpad discipline diff --git a/navi/profiles/tool_developer/system_prompt.txt b/navi/profiles/tool_developer/system_prompt.txt index d10f01c..6afa924 100644 --- a/navi/profiles/tool_developer/system_prompt.txt +++ b/navi/profiles/tool_developer/system_prompt.txt @@ -33,6 +33,7 @@ - The relevant tool file format requirements from the template below. - Any relevant imports or patterns from existing tools. - The exact `test_tool` call to validate it. +- Omit `profile_id` to use this tool_developer profile. Set `profile_id` only when the delegated step clearly needs another profile's prompt, model, and tools. - End with an instruction to write the tool file under `tools/`, test it with `test_tool`, fix until passing, and return file path, tool contract, key implementation notes, and test result. After it returns: read the file yourself, run `test_tool` yourself, then `reload_tools`. diff --git a/navi/tools/spawn_agent.py b/navi/tools/spawn_agent.py index 53463e2..8f1214c 100644 --- a/navi/tools/spawn_agent.py +++ b/navi/tools/spawn_agent.py @@ -8,6 +8,8 @@ import structlog +from navi.exceptions import ProfileNotFound + from .base import Tool, ToolResult, current_session_id log = structlog.get_logger() @@ -25,6 +27,10 @@ "USER CANNOT SEE sub-agent output — synthesise findings into your own response.\n\n" "USE when a step requires 3+ tool calls to complete as a single logical unit.\n" "DO NOT USE for a single tool call — call the tool directly.\n\n" + "PROFILE SELECTION: omit profile_id to use the parent session's current profile. " + "Set profile_id only when the sub-task clearly belongs to another profile. " + "The sub-agent uses that selected profile's subagent_tools, falling back to " + "enabled_tools when subagent_tools is empty.\n\n" ) parameters = { "type": "object", @@ -49,7 +55,9 @@ "description": ( "Profile to use for the sub-agent. Defaults to the current session's " "profile. Override to specialise: e.g. 'server_admin' for remote ops, " - "'secretary' for research, 'developer' for tool writing." + "'secretary' for research, 'developer' for code work, " + "'tool_developer' for Navi tool implementation. The selected profile " + "determines the sub-agent's model, prompt, and available tools." ), }, "system_prompt": { @@ -101,15 +109,34 @@ profile_id = params.get("profile_id", "").strip() if not profile_id: profile_id = await self._resolve_parent_profile() + try: + selected_profile = self._profile_registry.get(profile_id) + except ProfileNotFound: + available = ", ".join(p.id for p in self._profile_registry.all()) + return ToolResult( + success=False, + output=( + f"Unknown sub-agent profile_id: {profile_id!r}. " + f"Available profiles: {available or '(none)'}." + ), + error=f"unknown_profile:{profile_id}", + ) # Read parent scratchpad context_transfer section and pass it to the sub-agent. parent_sid = current_session_id.get() context_transfer = get_section(parent_sid, "context_transfer") if parent_sid else "" + tool_source = ( + selected_profile.subagent_tools + if selected_profile.subagent_tools + else selected_profile.enabled_tools + ) + log.info("spawn_agent.start", profile_id=profile_id, max_iterations=max_iterations, task_preview=task[:80], has_briefing=bool(briefing), has_context_transfer=bool(context_transfer), - has_system_prompt=bool(custom_system_prompt)) + has_system_prompt=bool(custom_system_prompt), + subagent_tools=len(tool_source)) agent = Agent( session_store=None, # ephemeral — no DB access diff --git a/persona.txt b/persona.txt index a201bcb..e00472c 100644 --- a/persona.txt +++ b/persona.txt @@ -64,6 +64,7 @@ spawn_agent fields: - task (required): goal for THIS ONE STEP, success criteria, expected output format. - briefing (optional): credentials, file paths, constraints, step-by-step instructions — injected as system-level context into the sub-agent. +- profile_id (optional): profile for the sub-agent. Omit it to use the parent session's current profile. Set it only when the sub-task clearly belongs to another profile; that profile controls the sub-agent's prompt, model, and tools. - system_prompt (optional): role specialisation for this task (e.g. "You are a security auditor. Report by severity."). End every task field with: diff --git a/tests/unit/tools/test_spawn_agent.py b/tests/unit/tools/test_spawn_agent.py new file mode 100644 index 0000000..107c434 --- /dev/null +++ b/tests/unit/tools/test_spawn_agent.py @@ -0,0 +1,79 @@ +import pytest + +from navi.core.session import InMemorySessionStore +from navi.tools.base import current_session_id +from navi.tools.spawn_agent import SpawnAgentTool +from tests.conftest_factory import ( + FakeLLMBackend, + make_profile_registry, + make_registry_with_tools, +) +from navi.core.registry import BackendRegistry + + +@pytest.fixture +def spawn_tool(): + profiles = make_profile_registry() + tools = make_registry_with_tools() + backends = BackendRegistry() + backends.register("ollama", FakeLLMBackend(responses=["done"])) + store = InMemorySessionStore() + tool = SpawnAgentTool(profiles, tools, backends, store) + return tool, profiles, store + + +@pytest.mark.anyio +async def test_spawn_agent_uses_explicit_profile(monkeypatch, spawn_tool): + tool, _, _ = spawn_tool + captured = {} + + async def fake_run_ephemeral(self, **kwargs): + captured.update(kwargs) + return "developer result", True + + monkeypatch.setattr("navi.core.agent.Agent.run_ephemeral", fake_run_ephemeral) + + result = await tool.execute({ + "task": "inspect code", + "profile_id": "developer", + }) + + assert result.success is True + assert captured["profile_id"] == "developer" + assert "developer result" in result.output + + +@pytest.mark.anyio +async def test_spawn_agent_defaults_to_parent_profile(monkeypatch, spawn_tool): + tool, _, store = spawn_tool + session = await store.create("secretary") + token = current_session_id.set(session.id) + captured = {} + + async def fake_run_ephemeral(self, **kwargs): + captured.update(kwargs) + return "secretary result", True + + monkeypatch.setattr("navi.core.agent.Agent.run_ephemeral", fake_run_ephemeral) + + try: + result = await tool.execute({"task": "research this"}) + finally: + current_session_id.reset(token) + + assert result.success is True + assert captured["profile_id"] == "secretary" + + +@pytest.mark.anyio +async def test_spawn_agent_rejects_unknown_profile(spawn_tool): + tool, _, _ = spawn_tool + + result = await tool.execute({ + "task": "do work", + "profile_id": "missing_profile", + }) + + assert result.success is False + assert result.error == "unknown_profile:missing_profile" + assert "Available profiles" in result.output