diff --git a/navi/api/deps.py b/navi/api/deps.py index 32646c1..d1a3f56 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -29,7 +29,10 @@ def get_registries() -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: global _registries if _registries is None: - _registries = build_default_registries(memory_store=_memory_store) + _registries = build_default_registries( + memory_store=_memory_store, + session_store=_session_store, + ) return _registries diff --git a/navi/core/agent.py b/navi/core/agent.py index 4f2d7be..bc09aad 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -59,7 +59,7 @@ class Agent: def __init__( self, - session_store: SessionStore, + session_store: "SessionStore | None", profile_registry: ProfileRegistry, tool_registry: ToolRegistry, backend_registry: BackendRegistry, @@ -144,6 +144,68 @@ await self._sessions.save(session) raise MaxIterationsReached(profile.max_iterations) + async def run_ephemeral( + self, + user_message: str, + profile_id: str, + max_iterations: int = 20, + exclude_tools: list[str] | None = None, + ) -> str: + """ + Run a sub-agent loop without a persistent session. + + Intended for spawning from tools (e.g. SpawnAgentTool). + No DB reads/writes — uses a temporary in-memory context. + Tools listed in exclude_tools are stripped from the tool list + (use this to prevent recursion: exclude 'spawn_agent'). + """ + profile = self._profiles.get(profile_id) + exclude = set(exclude_tools or []) + tools = [t for t in self._tool_list(profile.enabled_tools) if t.name not in exclude] + tool_schemas = [t.schema() for t in tools] + llm = self._get_backend(profile.llm_backend) + + mem = await self._memory_msg() + + context: list[Message] = [ + Message(role="system", content=self._build_system_prompt(profile.system_prompt)) + ] + context.append(Message(role="user", content=user_message, + created_at=datetime.now(timezone.utc))) + + log.info("agent.subagent.start", profile_id=profile_id, max_iterations=max_iterations) + + for iteration in range(max_iterations): + log.debug("agent.subagent.iteration", iteration=iteration) + response = await llm.complete( + self._with_memory(context, mem), + tools=tool_schemas if tools else None, + temperature=profile.temperature, + model=profile.model, + ) + + if response.finish_reason == "stop" or not response.tool_calls: + content = response.content or "" + log.info("agent.subagent.complete", iterations=iteration + 1, + result_len=len(content)) + return content + + assistant_msg = Message( + role="assistant", + content=response.content, + tool_calls=response.tool_calls, + ) + context.append(assistant_msg) + + tool_results, image_injections = await self._execute_tool_calls( + response.tool_calls, tools + ) + context.extend(tool_results) + context.extend(image_injections) + + log.warning("agent.subagent.max_iterations", max_iterations=max_iterations) + return "[Sub-agent reached iteration limit without a final answer]" + async def run_stream( self, session_id: str, user_message: str, images: list[str] | None = None ) -> AsyncGenerator[AgentEvent, None]: diff --git a/navi/core/registry.py b/navi/core/registry.py index 1a93068..54f2726 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -13,6 +13,7 @@ ImageViewTool, MemoryForgetTool, MemorySearchTool, + SpawnAgentTool, SshExecTool, TerminalTool, Tool, @@ -95,6 +96,7 @@ def build_default_registries( memory_store=None, + session_store=None, ) -> tuple[ToolRegistry, ProfileRegistry, BackendRegistry]: """Build and populate registries with all built-in components.""" @@ -122,6 +124,17 @@ for p in ALL_PROFILES: profiles.register(p) + # SpawnAgentTool registered after profiles so it can resolve profile names. + # session_store may be None at build time (injected separately in deps.py). + spawn_tool = SpawnAgentTool( + profile_registry=profiles, + tool_registry=tools, + backend_registry=None, # patched below after backends are built + session_store=session_store, + memory_store=memory_store, + ) + tools.register(spawn_tool, builtin=True) + backends = BackendRegistry() backends.register( "ollama", @@ -130,5 +143,7 @@ host=settings.ollama_host, ), ) + # Patch backend registry into spawn_tool now that it's available + spawn_tool._backend_registry = backends return tools, profiles, backends diff --git a/navi/profiles/secretary.py b/navi/profiles/secretary.py index 7d85b41..142943e 100644 --- a/navi/profiles/secretary.py +++ b/navi/profiles/secretary.py @@ -15,7 +15,8 @@ 6. image_view — whenever an image path or URL is mentioned. Output style: concise, structured. When researching, include sources. Match tone and format to what was asked.""", - enabled_tools=["web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent"], model="gemma4:e4b-it-q8_0", temperature=0.7, + max_iterations=30, ) diff --git a/navi/profiles/server_admin.py b/navi/profiles/server_admin.py index d60c405..093a711 100644 --- a/navi/profiles/server_admin.py +++ b/navi/profiles/server_admin.py @@ -17,7 +17,8 @@ Workflow: gather data first (logs, status, metrics), diagnose, then act. Before destructive or irreversible operations, state what you're about to do and why.""", - enabled_tools=["terminal", "filesystem", "http_request", "web_view", "web_search", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["terminal", "filesystem", "http_request", "web_view", "web_search", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent"], model="gemma4:e4b-it-q8_0", temperature=0.2, + max_iterations=30, ) diff --git a/navi/profiles/smart_home.py b/navi/profiles/smart_home.py index a62e9fc..aeff95a 100644 --- a/navi/profiles/smart_home.py +++ b/navi/profiles/smart_home.py @@ -17,7 +17,8 @@ Before writing any HA config to disk, validate structure in code_exec. Before toggling devices or triggering automations, confirm if the action is irreversible.""", - enabled_tools=["http_request", "web_view", "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual"], + enabled_tools=["http_request", "web_view", "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", "memory_search", "memory_forget", "reload_tools", "write_tool", "list_tools", "tool_manual", "spawn_agent"], model="gemma4:e4b-it-q8_0", temperature=0.3, + max_iterations=30, ) diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 5348808..831c59e 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -4,6 +4,7 @@ from .http_request import HttpRequestTool from .image_view import ImageViewTool from .ssh_exec import SshExecTool +from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool from .memory_forget import MemoryForgetTool from .memory_search import MemorySearchTool @@ -23,4 +24,5 @@ "WebViewTool", "MemorySearchTool", "MemoryForgetTool", + "SpawnAgentTool", ] diff --git a/navi/tools/spawn_agent.py b/navi/tools/spawn_agent.py new file mode 100644 index 0000000..b483aa5 --- /dev/null +++ b/navi/tools/spawn_agent.py @@ -0,0 +1,137 @@ +""" +spawn_agent — delegates a focused sub-task to an isolated agent instance. + +The sub-agent runs its own tool-calling loop with a clean context window. +It cannot spawn further sub-agents (recursion is blocked via exclude_tools). +The result is returned as a plain text summary. +""" + +import structlog + +from .base import Tool, ToolResult, current_session_id + +log = structlog.get_logger() + + +class SpawnAgentTool(Tool): + name = "spawn_agent" + description = ( + "Spawn an isolated sub-agent to execute a focused multi-step task. " + "The sub-agent gets a clean context window — it sees only the task " + "description and the briefing you provide, not the current conversation. " + "Returns a text summary of what was done and the outcome. " + "\n\nWHEN TO USE: when a sub-task requires 3+ sequential tool calls on " + "an isolated subsystem (configure a server, research a topic, process files), " + "or when failure/retry should not pollute the current context. " + "\nWHEN NOT TO USE: for single tool calls — call the tool directly instead. " + "\nALWAYS write a detailed briefing — the sub-agent has no access to your " + "conversation history. Include all relevant facts: IPs, credentials, " + "file paths, goals, constraints." + ) + parameters = { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": ( + "Clear, self-contained description of what the sub-agent must accomplish. " + "Be specific: include success criteria and expected output format." + ), + }, + "briefing": { + "type": "string", + "description": ( + "Key context from the current conversation the sub-agent needs: " + "IPs, credentials, file paths, prior findings, constraints. " + "Do not assume the sub-agent knows anything else." + ), + }, + "profile_id": { + "type": "string", + "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." + ), + }, + "max_iterations": { + "type": "integer", + "description": "Maximum tool-call iterations for the sub-agent (default: 20).", + }, + }, + "required": ["task"], + } + + def __init__( + self, + profile_registry, + tool_registry, + backend_registry, + session_store, + memory_store=None, + ) -> None: + self._profile_registry = profile_registry + self._tool_registry = tool_registry + self._backend_registry = backend_registry + self._session_store = session_store + self._memory_store = memory_store + + async def execute(self, params: dict) -> ToolResult: + # Import here to avoid module-level circular import + from navi.core.agent import Agent + + task = params["task"].strip() + briefing = params.get("briefing", "").strip() + max_iterations = int(params.get("max_iterations") or 20) + + # Resolve profile: explicit override → parent session's profile → first available + profile_id = params.get("profile_id", "").strip() + if not profile_id: + profile_id = await self._resolve_parent_profile() + + user_message = task + if briefing: + user_message = ( + f"## Context\n\n{briefing}\n\n" + f"---\n\n" + f"## Task\n\n{task}" + ) + + log.info("spawn_agent.start", profile_id=profile_id, max_iterations=max_iterations, + task_preview=task[:80]) + + agent = Agent( + session_store=None, # ephemeral — no DB access + profile_registry=self._profile_registry, + tool_registry=self._tool_registry, + backend_registry=self._backend_registry, + workers=[], # no post-response workers for sub-agents + memory_store=self._memory_store, + ) + + try: + result = await agent.run_ephemeral( + user_message=user_message, + profile_id=profile_id, + max_iterations=max_iterations, + exclude_tools=["spawn_agent"], # prevent recursion + ) + log.info("spawn_agent.done", profile_id=profile_id, result_len=len(result)) + return ToolResult(success=True, output=result) + except Exception as e: + log.error("spawn_agent.error", error=str(e), exc_info=True) + return ToolResult(success=False, output=f"Sub-agent failed: {e}", error=str(e)) + + async def _resolve_parent_profile(self) -> str: + """Return the profile of the current parent session, or fallback to first profile.""" + session_id = current_session_id.get() + if session_id and self._session_store: + try: + session = await self._session_store.get(session_id) + if session: + return session.profile_id + except Exception: + pass + # Fallback: first registered profile + all_profiles = self._profile_registry.all() + return all_profiles[0].id if all_profiles else "secretary" diff --git a/persona.txt b/persona.txt index 4686ab0..f88d98b 100644 --- a/persona.txt +++ b/persona.txt @@ -22,6 +22,19 @@ write_tool reports success or the exact error. If there is an error, fix the code and call write_tool again. The tool is available from the NEXT user message. To enable it in a profile, add the name to enabled_tools in navi/profiles/.py. +DELEGATION: +You can delegate focused sub-tasks to isolated sub-agents via spawn_agent. Each sub-agent runs its own tool-calling loop with a clean context window — it sees only what you give it. + +When to spawn: when a sub-task requires 3+ sequential tool calls on an isolated subsystem (configure a server, research a topic, process a set of files), or when failure and retry should not pollute your current context. + +When NOT to spawn: for a single tool call — just call the tool directly. Spawning has overhead. + +Mandatory briefing: the sub-agent has zero access to your conversation. Include every fact it needs: IPs, credentials, file paths, prior findings, exact goals, output format expected. + +Profile selection: choose the profile best suited to the sub-task. server_admin for remote ops, secretary for research, smart_home for home automation. Defaults to current profile if not specified. + +After each sub-agent returns, synthesise its result in context before spawning the next one. Do not spawn multiple agents before reviewing each result. + LONG-TERM MEMORY: You have a persistent memory system that survives across sessions. A summary of what you know about the user may be injected above under "What I remember about the user" — read it at the start of each session.