diff --git a/manuals/spawn_agent.md b/manuals/spawn_agent.md index 937c378..3a31b9e 100644 --- a/manuals/spawn_agent.md +++ b/manuals/spawn_agent.md @@ -4,26 +4,45 @@ Delegates a focused multi-step sub-task to an isolated agent instance with its own tool-calling loop and a clean context window. Returns the sub-agent's complete final response as a tool result. -**CRITICAL: spawn_agent is SYNCHRONOUS.** It blocks until the sub-agent fully completes. -When the call returns, the work is done. There is no background process. +**CRITICAL: spawn_agent is SYNCHRONOUS.** It blocks until the sub-agent fully completes (or times out after 5 minutes). ## Parameters -- `task` (string, required) — what the sub-agent must accomplish, including success criteria and expected output format -- `briefing` (string) — all context from the current conversation the sub-agent needs -- `profile_id` (string) — which profile to use (`secretary`, `server_admin`, `smart_home`); defaults to current session's profile -- `max_iterations` (integer) — tool-call iteration limit (default: 20) -## Before you spawn: write a plan +| Parameter | Required | Description | +|-----------|----------|-------------| +| `task` | yes | Self-contained task description including all context, credentials, expected output format. End with: "Complete ALL assigned work before responding. Your output is final." | +| `profile_id` | no | Which profile to use (`secretary`, `server_admin`, `developer`). Defaults to current session's profile. | +| `system_prompt` | no | Custom system prompt injected into the sub-agent on top of the profile's built-in subagent prompt. Use to set a specific role, output format, or constraints for this task. | +| `max_iterations` | no | Tool-call iteration limit (default: 20). | -For any task involving multiple sub-agents, state the plan in your response first: +## Context transfer — automatic + +Before spawning, write key context to your scratchpad section `context_transfer`: ``` -Plan: -1. Agent A — audit network exposure on host X -2. Agent B — check auth config on host Y (independent of A) -3. Agent C — cross-reference findings from A and B → needs A and B results first +scratchpad(op="write", section="context_transfer", content="Host: 192.168.1.75\nUser: gmikcon\n...") +``` +This section is **automatically injected** into the sub-agent at the start of its context. +No need to repeat it in `task`. + +## Sub-agent tools + +Sub-agents receive a focused tool set (not all profile tools): + +| Profile | Sub-agent tools | +|---------|----------------| +| `secretary` | scratchpad, reflect, web_search, web_view, http_request, filesystem, code_exec, image_view, memory, share_file, weather | +| `server_admin` | scratchpad, reflect, web_search, http_request, filesystem, code_exec, terminal, ssh_exec, image_view, share_file | +| `developer` | scratchpad, reflect, web_search, web_view, http_request, filesystem, code_exec, terminal, image_view, reload_tools, test_tool, share_file | + +## Result format + +The result always starts with a status line: +``` +[STATUS: completed] ← sub-agent finished normally +[STATUS: limit_reached] ← hit max_iterations without a final response ``` -This prevents wasted spawns, wrong ordering, and missed dependencies. +Read the status before deciding what to do next. If `limit_reached`, the partial result may still be useful, or you may need to spawn again with a more focused task. ## Writing a good task @@ -31,54 +50,37 @@ > "Check the server security." Good: -> "Audit SSH configuration on the server. Check: PasswordAuthentication, PermitRootLogin, -> AllowUsers, Port, key-only auth enforcement. Return a list of findings with severity -> (critical/warning/info) and suggested fix for each." +> "Audit SSH configuration on 192.168.1.75 (user: gmikcon, password: getroot). +> Check: PasswordAuthentication, PermitRootLogin, AllowUsers, Port. +> Return a list of findings with severity (critical/warning/info) and suggested fix for each. +> Complete ALL assigned work before responding. Your output is final." Include: - Exactly what to do (not vague goals) - What to return and in what format - What "done" looks like -## Writing a good briefing +## Using system_prompt -The sub-agent knows nothing. Include everything it needs: -- Host IPs/hostnames, SSH credentials, API endpoints, tokens -- File paths to read or write -- Prior findings it should build on -- Any constraints or things to avoid +Use `system_prompt` to give the sub-agent a specific role or output contract: -**End every briefing with:** -> "Complete ALL assigned work before responding. Your output is final." - -## Choosing a profile - -| Profile | Best for | -|---------|----------| -| `secretary` | Research, writing, file analysis, web lookups | -| `server_admin` | Remote ops, SSH, monitoring, infra changes | -| `smart_home` | Home Assistant, IoT, automations | +```json +{ + "task": "Check CPU temperature and memory usage on the host.", + "profile_id": "server_admin", + "system_prompt": "You are a system metrics collector. Report all values in a structured table. Include: metric name, current value, unit, status (ok/warn/crit). No prose." +} +``` ## After the result arrives The user cannot see sub-agent output — you must present the findings yourself. -1. Read the result fully before deciding what to do next. -2. Extract the key findings and present them clearly. -3. Decide if another spawn is needed based on what you received, not assumptions. -4. If the result is incomplete or unclear, either spawn again with a corrected task or handle it inline. +1. Check `[STATUS: ...]` first. +2. Extract key findings and present them clearly to the user. +3. If `limit_reached`, decide: retry with a smaller task, or handle inline. ## What the sub-agent cannot do - Spawn further sub-agents (recursion is blocked) -- Access your conversation history -- Persist sessions or memory across calls - -## Example - -```json -{ - "task": "Read tools/get_current_datetime.py and tools/user_notes.py. For each file report: purpose, any external dependencies, potential security issues. Return as a structured list per file.", - "briefing": "Project root is /home/user/Projects/navi-1. Both files are small user-defined tools. No credentials needed — filesystem access is unrestricted.\n\nComplete ALL assigned work before responding. Your output is final.", - "profile_id": "secretary" -} -``` +- Access your conversation history (only context_transfer scratchpad section) +- Use administrative tools: todo, switch_profile, list_profiles, reload_tools (except developer), delete_tool, email_manager diff --git a/navi/core/agent.py b/navi/core/agent.py index 3273fd0..72363eb 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -241,15 +241,28 @@ profile_id: str, max_iterations: int = 20, exclude_tools: list[str] | None = None, - ) -> str: + custom_system_prompt: str | None = None, + context_transfer: str | None = None, + timeout_seconds: float = 300.0, + ) -> tuple[str, bool]: """ Run a sub-agent loop without a persistent session. + Returns (result_text, completed_normally). + completed_normally is False if the sub-agent hit the iteration limit or timed out. + 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'). + + custom_system_prompt: injected after the profile's system prompt (overrides + profile.subagent_system_prompt if provided). + context_transfer: text passed from the parent's scratchpad context_transfer + section, injected as a priming exchange before the task message. + timeout_seconds: wall-clock timeout for the entire sub-agent run. """ + import time as _time import uuid as _uuid # Give each sub-agent its own scratchpad namespace so parallel or # sequential sub-agents don't clobber each other's working notes. @@ -259,32 +272,72 @@ profile = self._profiles.get(profile_id) _model_var.set(profile.model) exclude = set(exclude_tools or []) - tools = [t for t in self._tool_list(profile.enabled_tools) if t.name not in exclude] + + # Use dedicated subagent_tools if configured, else fall back to enabled_tools. + tool_source = profile.subagent_tools if profile.subagent_tools else profile.enabled_tools + tools = [t for t in self._tool_list(tool_source) 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() - # Sub-agent context: only user/assistant/tool messages — system is injected dynamically. - context: list[Message] = [ - Message(role="user", content=user_message, created_at=datetime.now(timezone.utc)) - ] + # Build subagent system prompt: profile.system_prompt + subagent-specific prompt. + # No persona, no profiles block — subagents are focused executors. + effective_subagent_prompt = custom_system_prompt or profile.subagent_system_prompt + subagent_sys_content = profile.system_prompt + if effective_subagent_prompt: + subagent_sys_content = subagent_sys_content + "\n\n---\n\n" + effective_subagent_prompt + subagent_sys_msg = Message(role="system", content=subagent_sys_content) + + # Build initial context. + # If context_transfer is provided, inject it as a priming exchange so the + # sub-agent has the parent's working state from the start. + context: list[Message] = [] + if context_transfer: + context.append(Message( + role="user", + content=f"## Context from parent agent\n\n{context_transfer}", + )) + context.append(Message( + role="assistant", + content="Understood. I have the context. Ready to begin the task.", + )) + context.append(Message(role="user", content=user_message, created_at=datetime.now(timezone.utc))) # Read the event sink set by the parent run_stream() for this tool call. # If None (e.g. called from run(), not run_stream()), events are silently dropped. sink = current_event_sink.get() - log.info("agent.subagent.start", profile_id=profile_id, max_iterations=max_iterations) + log.info("agent.subagent.start", profile_id=profile_id, max_iterations=max_iterations, + tools=len(tools), planning=profile.subagent_planning_enabled) stop_event = current_stop_event.get() tool_map = {t.name: t for t in tools} _sub_tokens: int = 0 # tokens from the final LLM call _sub_tool_count: int = 0 # total tool calls across all iterations + _start_time = _time.monotonic() + accumulated_text = "" + # ── Optional planning phase ──────────────────────────────────────────── + if profile.subagent_planning_enabled: + async for _ev in self._run_planning(context, profile, llm, mem, tool_schemas): + if isinstance(_ev, AIHelperTokensUsed): + pass # token accounting only, not forwarded + elif sink is not None: + await sink.put(_ev) + + # ── Tool-calling loop ────────────────────────────────────────────────── for iteration in range(max_iterations): if stop_event and stop_event.is_set(): - return accumulated_text if iteration > 0 else "" + return accumulated_text, False + + elapsed = _time.monotonic() - _start_time + if elapsed >= timeout_seconds: + log.warning("agent.subagent.timeout", elapsed=elapsed, timeout=timeout_seconds) + if sink is not None: + await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count)) + return accumulated_text or "[Sub-agent timed out]", False log.debug("agent.subagent.iteration", iteration=iteration) @@ -293,7 +346,11 @@ turn_tool_calls: list[ToolCallRequest] | None = None turn_tokens: int | None = None - built_ctx = self._build_context(context, profile, mem) + # Build context inline — no persona or profiles block for subagents. + built_ctx: list[Message] = [subagent_sys_msg] + if mem: + built_ctx.append(mem) + built_ctx.extend(m for m in context if m.role != "system") self._check_context_size(built_ctx) async for chunk in _iter_stream_guarded( @@ -317,7 +374,7 @@ turn_tool_calls = chunk.tool_calls if stop_event and stop_event.is_set(): - return accumulated_text + return accumulated_text, False if not turn_tool_calls: log.info("agent.subagent.complete", iterations=iteration + 1, @@ -328,7 +385,7 @@ token_count=_sub_tokens, tool_call_count=_sub_tool_count, )) - return accumulated_text + return accumulated_text, True # Emit accumulated thinking before tool calls if accumulated_thinking and sink is not None: @@ -382,7 +439,7 @@ log.warning("agent.subagent.max_iterations", max_iterations=max_iterations) if sink is not None: await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count)) - return "[Sub-agent reached iteration limit without a final answer]" + return accumulated_text or "[Sub-agent reached iteration limit without a final answer]", False async def run_stream( self, session_id: str, user_message: str, images: list[str] | None = None @@ -463,7 +520,7 @@ # injected into session.context as an assistant message so the model # naturally continues from it, and emitted as PlanReady for the UI. if profile.planning_enabled: - async for _ev in self._run_planning(session, profile, llm, mem, tool_schemas): + async for _ev in self._run_planning(session.context, profile, llm, mem, tool_schemas, messages=session.messages): if isinstance(_ev, AIHelperTokensUsed): # Accumulate planning token usage into the turn total (not forwarded to WS) _subagent_tokens += _ev.total @@ -672,11 +729,12 @@ async def _run_planning( self, - session, + context: "list[Message]", profile, llm: LLMBackend, mem: "Message | None", tool_schemas: list | None = None, + messages: "list[Message] | None" = None, ): """ Three-phase planning (async generator): @@ -743,7 +801,7 @@ phase1_ctx: list[Message] = [phase1_system] if mem: phase1_ctx.append(mem) - phase1_ctx.extend(m for m in session.context if m.role != "system") + phase1_ctx.extend(m for m in context if m.role != "system") try: r1 = await asyncio.wait_for( @@ -905,8 +963,9 @@ # Inject plan into context so the main loop continues from it, # and into messages (with is_plan flag) so the UI can render a plan card after reload. - session.context.append(Message(role="assistant", content=plan_text)) - session.messages.append(Message(role="assistant", content=plan_text, is_plan=True)) + context.append(Message(role="assistant", content=plan_text)) + if messages is not None: + messages.append(Message(role="assistant", content=plan_text, is_plan=True)) log.debug("agent.plan_ready", phases=3, length=len(plan_text)) yield PlanReady(plan=plan_text) diff --git a/navi/profiles/base.py b/navi/profiles/base.py index e97a348..96051cc 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -25,3 +25,13 @@ # full_description: structured dict with keys: specialization, when_to_use, key_tools. short_description: str = "" full_description: dict = field(default_factory=dict) + + # Sub-agent configuration + # subagent_tools: tool names available to sub-agents spawned from this profile. + # If empty, falls back to enabled_tools minus dangerous/irrelevant ones. + # subagent_planning_enabled: if True, sub-agents run the planning phase before their tool loop. + # subagent_system_prompt: injected as an additional system message for sub-agents, + # after the profile's main system_prompt. Loaded from subagent_system_prompt.txt if present. + subagent_tools: list[str] = field(default_factory=list) + subagent_planning_enabled: bool = False + subagent_system_prompt: str = "" diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index d4e7123..0a1c6be 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -13,6 +13,14 @@ "temperature": 0.2, "max_iterations": 35, "planning_enabled": true, + "subagent_planning_enabled": false, + "subagent_tools": [ + "scratchpad", "reflect", + "web_search", "web_view", "http_request", + "filesystem", "code_exec", "terminal", "image_view", + "reload_tools", "test_tool", + "share_file" + ], "enabled_tools": [ "todo", "scratchpad", "reflect", "switch_profile", "list_profiles", "web_search", "web_view", "http_request", diff --git a/navi/profiles/developer/subagent_system_prompt.txt b/navi/profiles/developer/subagent_system_prompt.txt new file mode 100644 index 0000000..b1ca616 --- /dev/null +++ b/navi/profiles/developer/subagent_system_prompt.txt @@ -0,0 +1,15 @@ +You are a focused tool development sub-agent. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. + +Rules: +- Complete ALL assigned work: write the file, run test_tool, fix until it passes. Never stop before the test passes. +- Never skip test_tool. A tool that is not tested is not done. +- If test_tool fails, read the error, fix the file, run test_tool again. Repeat until passing. +- Return the final file content and the exact test_tool output verbatim in your response. +- Do not ask for clarification. Make reasonable implementation choices and proceed. +- Do not address the user. Your output goes to the main agent. + +End your response with: +## Summary +- File written: +- Test result: passed / failed (with error if failed) +- What the tool does (one sentence) \ No newline at end of file diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index 6eb0bc5..7441c0d 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -45,6 +45,13 @@ system_prompt = prompt_file.read_text(encoding="utf-8").strip() + subagent_prompt_file = entry / "subagent_system_prompt.txt" + subagent_system_prompt = ( + subagent_prompt_file.read_text(encoding="utf-8").strip() + if subagent_prompt_file.exists() + else "" + ) + profiles.append(AgentProfile( id=config["id"], name=config["name"], @@ -58,6 +65,9 @@ planning_enabled=config.get("planning_enabled", False), short_description=config.get("short_description", ""), full_description=config.get("full_description", {}), + subagent_tools=config.get("subagent_tools", []), + subagent_planning_enabled=config.get("subagent_planning_enabled", False), + subagent_system_prompt=subagent_system_prompt, )) log.debug("profile.loader.loaded", profile_id=config["id"]) diff --git a/navi/profiles/secretary/config.json b/navi/profiles/secretary/config.json index 0b88f1d..9363c4f 100644 --- a/navi/profiles/secretary/config.json +++ b/navi/profiles/secretary/config.json @@ -13,6 +13,15 @@ "temperature": 0.5, "max_iterations": 25, "planning_enabled": true, + "subagent_planning_enabled": false, + "subagent_tools": [ + "scratchpad", "reflect", + "web_search", "web_view", "http_request", + "filesystem", "code_exec", "image_view", + "memory", + "share_file", + "weather" + ], "enabled_tools": [ "todo", "scratchpad", "reflect", "switch_profile", "list_profiles", "web_search", "web_view", "http_request", diff --git a/navi/profiles/secretary/subagent_system_prompt.txt b/navi/profiles/secretary/subagent_system_prompt.txt new file mode 100644 index 0000000..46a0967 --- /dev/null +++ b/navi/profiles/secretary/subagent_system_prompt.txt @@ -0,0 +1,15 @@ +You are a focused research and analysis sub-agent. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. + +Rules: +- Complete ALL assigned work before writing your response. Do not stop halfway. +- Use your tools. Read files, search the web, run code — do the actual work, not a plan to do it. +- Be precise: exact values, file contents, command outputs — not paraphrased summaries. +- Do not ask for clarification. Make reasonable assumptions and proceed. +- Do not address the user. Your output goes to the main agent. + +End your response with: +## Summary +- What was done +- Key findings (exact values, data, file contents if relevant) +- Artifacts created or modified (paths) +- Errors encountered (if any, and whether resolved) \ No newline at end of file diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json index 36c567c..9ce7728 100644 --- a/navi/profiles/server_admin/config.json +++ b/navi/profiles/server_admin/config.json @@ -13,6 +13,13 @@ "temperature": 0.2, "max_iterations": 20, "planning_enabled": true, + "subagent_planning_enabled": false, + "subagent_tools": [ + "scratchpad", "reflect", + "web_search", "http_request", + "filesystem", "code_exec", "terminal", "ssh_exec", "image_view", + "share_file" + ], "enabled_tools": [ "todo", "scratchpad", "reflect", "switch_profile", "list_profiles", "web_search", "web_view", "http_request", diff --git a/navi/profiles/server_admin/subagent_system_prompt.txt b/navi/profiles/server_admin/subagent_system_prompt.txt new file mode 100644 index 0000000..66290f6 --- /dev/null +++ b/navi/profiles/server_admin/subagent_system_prompt.txt @@ -0,0 +1,15 @@ +You are a focused infrastructure operations sub-agent. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. + +Rules: +- Complete ALL assigned work before writing your response. Execute, don't just plan. +- Run the commands. Read the actual output. Fix errors you encounter before reporting. +- Report exact command output, values, error messages — not paraphrases. +- Use the credentials and host information provided without modification. +- Do not ask for clarification. Proceed with what you have. +- Do not address the user. Your output goes to the main agent. + +End your response with: +## Summary +- Commands run and key results +- Current system state (what was found / what changed) +- Errors encountered and whether they were resolved \ No newline at end of file diff --git a/navi/tools/scratchpad.py b/navi/tools/scratchpad.py index 81df02a..15559a6 100644 --- a/navi/tools/scratchpad.py +++ b/navi/tools/scratchpad.py @@ -7,6 +7,11 @@ _pads: dict[str, dict[str, str]] = {} +def get_section(session_id: str, section: str) -> str: + """Read one scratchpad section for the given session. Returns '' if absent.""" + return _pads.get(session_id, {}).get(section, "") + + class ScratchpadTool(Tool): name = "scratchpad" description = ( diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index d579bcf..460ce15 100644 --- a/navi/tools/share_file.py +++ b/navi/tools/share_file.py @@ -24,9 +24,7 @@ "Use after generating or producing a file the user will want to keep (report, archive, export, etc.). " "IMPORTANT — path must be an ABSOLUTE path (e.g. /home/user/file.zip). " "If you only know a relative path, resolve it first: use filesystem(action='info') or " - "terminal('realpath ') to get the absolute path, then call share_file. " - "After a successful call the result contains a URL — you MUST include that URL in your reply " - "to the user as a clickable markdown link: [filename](url)." + "terminal('realpath ') to get the absolute path, then call share_file." ) parameters = { "type": "object", @@ -84,6 +82,10 @@ return ToolResult( success=True, - output=f"Download ready: {dest.name} ({_fmt_size(size)})\nURL: {url}", + output=( + f"Download ready: {dest.name} ({_fmt_size(size)})\n" + f"URL: {url}\n\n" + f"→ Include this link in your reply: [{dest.name}]({url})" + ), metadata={"url": url, "filename": dest.name, "size": size}, ) diff --git a/navi/tools/spawn_agent.py b/navi/tools/spawn_agent.py index bf8b7fe..f8ada8d 100644 --- a/navi/tools/spawn_agent.py +++ b/navi/tools/spawn_agent.py @@ -24,10 +24,12 @@ "USE when a task requires 2+ tool calls forming one logical unit: " "research a topic, audit a module, configure a server, process a file set.\n" "DO NOT USE for a single tool call — call the tool directly.\n\n" - "BRIEFING must be fully self-contained: the sub-agent knows nothing about " + "The task field must be fully self-contained: the sub-agent knows nothing about " "your conversation. Include IPs, credentials, file paths, prior results, " "expected output format. End with: " - "'Complete ALL assigned work before responding. Your output is final.'" + "'Complete ALL assigned work before responding. Your output is final.'\n\n" + "Context transfer: before spawning, write key context to your scratchpad " + "section 'context_transfer' — it is automatically injected into the sub-agent." ) parameters = { "type": "object", @@ -35,17 +37,10 @@ "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": ( - "All context the sub-agent needs — it has zero knowledge of your conversation. " - "Include: IPs, credentials, file paths, prior findings, constraints, " - "expected output format. End with: " - "'Complete ALL assigned work before responding. Your output is final.'" + "Self-contained description of what the sub-agent must accomplish. " + "Include: exact goal, success criteria, expected output format, " + "all credentials / file paths / prior findings needed. " + "End with: 'Complete ALL assigned work before responding. Your output is final.'" ), }, "profile_id": { @@ -53,12 +48,21 @@ "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." + "'secretary' for research, 'developer' for tool writing." + ), + }, + "system_prompt": { + "type": "string", + "description": ( + "Optional custom system prompt injected into the sub-agent in addition " + "to the profile's default. Use to set a specific role, constraints, or " + "output format requirements for this particular task. " + "If omitted, the profile's built-in subagent prompt is used." ), }, "max_iterations": { "type": "integer", - "description": "Maximum tool-call iterations for the sub-agent (default: 100).", + "description": "Maximum tool-call iterations for the sub-agent (default: 20).", }, }, "required": ["task"], @@ -81,26 +85,24 @@ async def execute(self, params: dict) -> ToolResult: # Import here to avoid module-level circular import from navi.core.agent import Agent + from navi.tools.scratchpad import get_section task = params["task"].strip() - briefing = params.get("briefing", "").strip() - max_iterations = int(params.get("max_iterations") or 100) + custom_system_prompt = (params.get("system_prompt") or "").strip() or None + 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}" - ) + # 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 "" log.info("spawn_agent.start", profile_id=profile_id, max_iterations=max_iterations, - task_preview=task[:80]) + task_preview=task[:80], has_context_transfer=bool(context_transfer), + has_custom_prompt=bool(custom_system_prompt)) agent = Agent( session_store=None, # ephemeral — no DB access @@ -112,19 +114,26 @@ ) try: - result = await agent.run_ephemeral( - user_message=user_message, + result_text, completed = await agent.run_ephemeral( + user_message=task, profile_id=profile_id, max_iterations=max_iterations, exclude_tools=["spawn_agent"], # prevent recursion + custom_system_prompt=custom_system_prompt, + context_transfer=context_transfer or None, + timeout_seconds=300.0, ) - log.info("spawn_agent.done", profile_id=profile_id, result_len=len(result)) + status = "completed" if completed else "limit_reached" + log.info("spawn_agent.done", profile_id=profile_id, status=status, + result_len=len(result_text)) output = ( + f"[STATUS: {status}]\n" "[Sub-agent result — the USER CANNOT SEE THIS. " "You must present the key findings in your own final response.]\n\n" - + result + + result_text ) - return ToolResult(success=True, output=output) + return ToolResult(success=completed, output=output, + metadata={"status": status, "result_len": len(result_text)}) 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))