"""
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"