diff --git a/docs/config.md b/docs/config.md index 26dcf2b..7e136ad 100644 --- a/docs/config.md +++ b/docs/config.md @@ -140,6 +140,9 @@ | `LLM_STREAM_CHUNK_TIMEOUT` | int | `60` | Max seconds between consecutive tokens in a streaming call | | `LLM_COMPLETE_RETRIES` | int | `2` | Attempts on the **last** remaining route entry after a connection error. Entries with a healthy alternative are tried once, so failover stays fast. | | `LLM_RETRY_BACKOFF_SEC` | float | `2` | Base sleep between those attempts (multiplied by the attempt number) | +| `SUBAGENT_TIMEOUT_SECONDS` | float | `600` | Whole-run cap for one `spawn_agent` sub-agent. 300 s used to cut long ops work in half. | +| `SUBAGENT_THINKING_STALL_SECONDS` | float | `60` | Stop a sub-agent that has produced only thinking for this long without text or tool calls (local models degenerate into internal loops) | +| `SUBAGENT_THINKING_STALL_CHARS` | int | `48000` | Same guard, by accumulated thinking size. Was 12k — reachable in a healthy iteration of a cloud reasoning model, so good runs were killed mid-thought. | Large contexts can take 60–90 s to prefill; `LLM_STREAM_FIRST_CHUNK_TIMEOUT=90` covers the common case for local Ollama. Increase (e.g. 180) for very large contexts or slow GPUs; reduce for fast cloud endpoints. diff --git a/docs/profiles.md b/docs/profiles.md index 059066e..1cfe741 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -129,7 +129,7 @@ | Key | Type | Default | Description | |---|---|---|---| -| `subagent_system_prompt` | str | `""` | Inline sub-agent system prompt. If empty, the loader reads `subagent_system_prompt.txt` from the profile directory. Prepended on top of the parent's `system_prompt` when `inherit_system_prompt=True`. | +| `subagent_system_prompt` | str | `""` | Inline sub-agent system prompt. If empty, the loader reads `subagent_system_prompt.txt` from the profile directory. Prepended on top of the parent's `system_prompt` when `inherit_system_prompt=True`. With neither a sub-agent prompt nor `inherit_system_prompt`, the profile's own `system_prompt` is the base — a sub-agent never runs on the bare `[Scope]` anchor alone. | ### Unknown keys diff --git a/manuals/spawn_agent.md b/manuals/spawn_agent.md index 0e5fda5..014165d 100644 --- a/manuals/spawn_agent.md +++ b/manuals/spawn_agent.md @@ -44,20 +44,24 @@ | `profile_id` | no | Which profile to use. Only profiles the current user may use are accepted. Defaults to current session's profile. | | `system_prompt` | no | Role specialisation injected into the sub-agent's system prompt between the executor persona and the briefing (e.g. "You are a security auditor. Report findings by severity."). | | `max_iterations` | no | Tool-call iteration limit (default: **40**). | +| — | — | The whole run is capped at `subagent_timeout_seconds` (default **600 s**), read from settings. | | `background` | no | `true` → detach immediately, return `task_id`; result arrives later as a completion note (use `tasks` to check/wait/cancel). | | `inherit_system_prompt` | no | `true` → the parent profile's full system prompt is the **base layer**, with the sub-agent's own specialisation overlaid on top. Use when the sub-agent must keep the parent's personality, rules and workflow. Default `false`: the sub-agent uses only its own `subagent_system_prompt` and ignores the parent's entirely. | ## Sub-agent system prompt structure -The sub-agent receives a completely separate system prompt — no persona, no orchestrator instructions, no available-profiles block. It is built from up to three parts (separated by `---`): +The sub-agent receives a completely separate system prompt — no orchestrator instructions, no available-profiles block. It is built from these parts (separated by `---`): ``` 1. profile.subagent_system_prompt ← focused executor persona (from subagent_system_prompt.txt) 2. system_prompt param ← role specialisation (if provided) -3. ## Task context\n\n{briefing} ← credentials, instructions (if briefing provided) +3. [Scope] ← always injected: the task is exactly the user message +4. ## Task context\n\n{briefing} ← credentials, instructions (if briefing provided) +5. [Parent session context] ← parent session id and the session files directory ``` -Fallback: if the profile has no `subagent_system_prompt.txt`, uses `profile.system_prompt`. +Fallback: if the profile has no `subagent_system_prompt.txt` and no `system_prompt` param was +given, `profile.system_prompt` is the base — with the same `[Scope]` anchor after it. ## Sub-agent tools diff --git a/navi/config.py b/navi/config.py index 9442840..e154097 100644 --- a/navi/config.py +++ b/navi/config.py @@ -209,6 +209,16 @@ llm_stream_first_chunk_timeout: int = 90 # stream_complete(): max gap between any two subsequent tokens llm_stream_chunk_timeout: int = 60 + + # Sub-agent guards. The thinking bound catches a model that degenerates + # into an endless internal loop; 12k chars was reachable in a healthy + # iteration of a cloud reasoning model, so healthy runs were killed + # mid-thought and reported as stalled. + subagent_thinking_stall_seconds: float = 60.0 + subagent_thinking_stall_chars: int = 48_000 + # Whole sub-agent run. 300s routinely cut a long ops task in half; the + # parent can still stop the run, and a background spawn reports separately. + subagent_timeout_seconds: float = 600.0 # complete()/embed(): attempts per (server, model) on a connection error # (timeout, refused, DNS) before the fallback gives up. A cloud backend times # out on its own schedule, and one ReadTimeout from the only configured diff --git a/navi/core/subagent_runner.py b/navi/core/subagent_runner.py index b5c35dd..6efa77f 100644 --- a/navi/core/subagent_runner.py +++ b/navi/core/subagent_runner.py @@ -32,9 +32,9 @@ # Sub-agents are execution workers. If a sub-agent produces only thinking for a # long time without text or tool calls, local models can degenerate into endless -# internal-token loops and keep the GPU busy with no user-visible progress. -_SUBAGENT_THINKING_STALL_SECONDS = 60.0 -_SUBAGENT_THINKING_STALL_CHARS = 12_000 +# internal-token loops and keep the GPU busy with no user-visible progress. The +# bounds live in settings: a cloud reasoning model legitimately thinks past the +# point where a local one has already lost the thread. # Always injected, whatever profile or custom prompt the caller supplied. A # sub-agent is handed exactly one step and sees a tool set drawn from its @@ -53,6 +53,48 @@ ) +def build_subagent_system_prompt( + profile, + *, + inherit_system_prompt: bool = False, + custom_system_prompt: str | None = None, + briefing: str | None = None, + parent_session_id: str | None = None, +) -> str: + """Assemble a sub-agent's system message. + + A profile with its own ``subagent_system_prompt.txt`` speaks as a worker; + one without it falls back to its main prompt, because a bare scope anchor + gives the model a task and a tool list but no role. The anchor always goes + last in the base so it wins over a persona that suggests widening the task. + """ + 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: + sys_parts.append(custom_system_prompt) + if not sys_parts: + sys_parts.append(profile.system_prompt) + sys_parts.append(_SCOPE_ANCHOR) + if briefing: + sys_parts.append(f"## Task context\n\n{briefing}") + if parent_session_id: + # Generic session plumbing only. Server-specific calling conventions + # (path shapes, session_id handling) belong to the MCP server that + # owns them — in its tool schemas and INSTRUCTIONS, which reach + # every agent, not just sub-agents. + sys_parts.append( + "[Parent session context]\n" + f"Parent Session ID: {parent_session_id}\n" + f"Session files directory: {settings.session_files_dir}/{parent_session_id}/\n" + "For files the user should see, write to this exact session directory. " + "Do not use or invent a subagent_* directory." + ) + return "\n\n---\n\n".join(sys_parts) + + @dataclass(frozen=True) class SubAgentOutcome: """What one sub-agent run produced, and how it ended. @@ -184,31 +226,16 @@ _uinfo_var.set(None) mem = await self._ctx_builder._memory_msg(user_id=user_id) - 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: - sys_parts.append(custom_system_prompt) - sys_parts.append(_SCOPE_ANCHOR) - if briefing: - sys_parts.append(f"## Task context\n\n{briefing}") - if parent_session_id: - # Generic session plumbing only. Server-specific calling conventions - # (path shapes, session_id handling) belong to the MCP server that - # owns them — in its tool schemas and INSTRUCTIONS, which reach - # every agent, not just sub-agents. - sys_parts.append( - "[Parent session context]\n" - f"Parent Session ID: {parent_session_id}\n" - f"Session files directory: {settings.session_files_dir}/{parent_session_id}/\n" - "For files the user should see, write to this exact session directory. " - "Do not use or invent a subagent_* directory." - ) - if not sys_parts: - sys_parts.append(profile.system_prompt) - subagent_sys_msg = Message(role="system", content="\n\n---\n\n".join(sys_parts)) + subagent_sys_msg = Message( + role="system", + content=build_subagent_system_prompt( + profile, + inherit_system_prompt=inherit_system_prompt, + custom_system_prompt=custom_system_prompt, + briefing=briefing, + parent_session_id=parent_session_id, + ), + ) context: list[Message] = [] if context_transfer: @@ -360,8 +387,8 @@ accumulated_thinking += chunk.thinking thinking_elapsed = time.monotonic() - thinking_started_at if ( - thinking_elapsed >= _SUBAGENT_THINKING_STALL_SECONDS - or len(accumulated_thinking) >= _SUBAGENT_THINKING_STALL_CHARS + thinking_elapsed >= settings.subagent_thinking_stall_seconds + or len(accumulated_thinking) >= settings.subagent_thinking_stall_chars ): thinking_stalled_reason = ( "Sub-agent produced only thinking output for " diff --git a/navi/profiles/assistant/subagent_system_prompt.txt b/navi/profiles/assistant/subagent_system_prompt.txt new file mode 100644 index 0000000..d9b6f0b --- /dev/null +++ b/navi/profiles/assistant/subagent_system_prompt.txt @@ -0,0 +1,17 @@ +You are a focused sub-agent of a general-purpose assistant. 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. +- Use the tools you are given: read and write files, run code or commands, search the web. Read a file before you modify it. +- Write files the user should see into the session directory named in [Parent session context]. Never invent or use another directory. +- Verify what you produced — re-read the file, run the script, open the result — and report what the check showed. +- If something fails, read the error, fix it, and run it again. Report only what you could not resolve. +- Do not ask for clarification. Make reasonable choices and proceed. +- Do not address the user. Your output goes to the main agent. +- Return concise evidence, not narration: exact values, the command output that matters, short excerpts. Dump raw output only for a failure or a value the main agent must cite. + +End your response with: +## Summary +- What was done (one or two sentences) +- Evidence: files written, commands run and their result +- Anything unresolved (or "none") diff --git a/navi/profiles/designer_3d/subagent_system_prompt.txt b/navi/profiles/designer_3d/subagent_system_prompt.txt new file mode 100644 index 0000000..cf83d5f --- /dev/null +++ b/navi/profiles/designer_3d/subagent_system_prompt.txt @@ -0,0 +1,18 @@ +You are a focused sub-agent of a 3D model designer. 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. +- Geometry is authored as OpenSCAD source (`.scad`). Write it into the session directory named in [Parent session context]; never invent or use another directory. +- Read an existing model before changing it. Keep the object's real-world orientation, dimensions in millimetres, and physical connections between parts — not just the shape on screen. +- Work only with the tools in your list. If a tool you would need to compile or validate geometry is not there, do not substitute a Python mesh pipeline or another generator: write the source, say in the summary what could not be verified, and let the main agent run the check. +- If a command or compile fails, read the error, fix the source and retry. Report only what you could not resolve. +- Do not ask for clarification. Make reasonable design choices and proceed. +- Do not address the user. Your output goes to the main agent. +- Return concise evidence, not narration. + +End your response with: +## Summary +- Files written (exact paths) +- What was modelled and its key dimensions +- What was verified, and what could not be +- Anything unresolved (or "none") diff --git a/navi/tools/spawn_agent.py b/navi/tools/spawn_agent.py index 666bc31..72edd4e 100644 --- a/navi/tools/spawn_agent.py +++ b/navi/tools/spawn_agent.py @@ -8,6 +8,7 @@ import structlog +from navi.config import settings from navi.exceptions import ProfileNotFound from navi.profiles.base import admin_only_blocked @@ -250,7 +251,7 @@ inherit_system_prompt=inherit_system_prompt, context_transfer=context_transfer or None, parent_session_id=parent_sid, - timeout_seconds=300.0, + timeout_seconds=settings.subagent_timeout_seconds, ) log.info("spawn_agent.done", profile_id=profile_id, status=outcome.status, result_len=len(outcome.text)) diff --git a/tests/unit/core/test_subagent_prompt.py b/tests/unit/core/test_subagent_prompt.py new file mode 100644 index 0000000..bc0676a --- /dev/null +++ b/tests/unit/core/test_subagent_prompt.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace + +import pytest + +from navi.config import Settings, settings +from navi.core.subagent_runner import _SCOPE_ANCHOR, build_subagent_system_prompt + + +def profile(system_prompt="You are the main agent.", subagent_system_prompt=""): + return SimpleNamespace(system_prompt=system_prompt, subagent_system_prompt=subagent_system_prompt) + + +class TestBuildSubagentSystemPrompt: + def test_uses_the_worker_prompt_when_the_profile_has_one(self): + text = build_subagent_system_prompt( + profile("main persona", "You are a worker.") + ) + + assert "You are a worker." in text + assert "main persona" not in text + assert _SCOPE_ANCHOR in text + + def test_falls_back_to_the_main_prompt_without_a_worker_prompt(self): + """The anchor used to be appended unconditionally, which made the + fallback dead code: a profile without a subagent prompt reached the + model as a bare scope rule with no role.""" + text = build_subagent_system_prompt(profile("main persona")) + + assert "main persona" in text + assert _SCOPE_ANCHOR in text + assert text.index("main persona") < text.index(_SCOPE_ANCHOR) + + def test_inherit_adds_the_main_prompt_on_top_of_the_worker_prompt(self): + text = build_subagent_system_prompt( + profile("main persona", "You are a worker."), + inherit_system_prompt=True, + ) + + assert "main persona" in text + assert "You are a worker." in text + + def test_custom_prompt_sits_before_the_anchor(self): + text = build_subagent_system_prompt( + profile("main persona"), + custom_system_prompt="You are a security auditor.", + ) + + assert "security auditor" in text + assert text.index("security auditor") < text.index(_SCOPE_ANCHOR) + + def test_briefing_and_parent_context_are_appended_last(self): + text = build_subagent_system_prompt( + profile("main persona"), + briefing="Host: 10.0.0.5", + parent_session_id="s1", + ) + + assert "## Task context" in text + assert "Host: 10.0.0.5" in text + assert f"{settings.session_files_dir}/s1/" in text + assert text.index(_SCOPE_ANCHOR) < text.index("## Task context") + + def test_every_part_is_separated(self): + text = build_subagent_system_prompt( + profile("main persona", "You are a worker."), + custom_system_prompt="Custom.", + briefing="Brief.", + ) + + assert text.count("\n\n---\n\n") == 3 + + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("subagent_thinking_stall_chars", 48_000), + ("subagent_timeout_seconds", 600.0), + ], + ) + def test_settings_raise_the_old_guards(self, name, expected): + # 12k thinking chars / 300s cut healthy cloud-model runs; a regression + # here silently turns every long reasoning step into "stalled". + assert Settings.model_fields[name].default == expected diff --git a/tests/unit/tools/test_spawn_agent.py b/tests/unit/tools/test_spawn_agent.py index 972570e..e3d4581 100644 --- a/tests/unit/tools/test_spawn_agent.py +++ b/tests/unit/tools/test_spawn_agent.py @@ -1,5 +1,6 @@ import pytest +from navi.config import settings from navi.core.session import InMemorySessionStore from navi.core.subagent_runner import SubAgentOutcome from navi.tools._internal.base import ToolContext @@ -63,6 +64,23 @@ @pytest.mark.anyio +async def test_spawn_agent_runs_with_the_configured_timeout(monkeypatch, spawn_tool): + """A 300s cap cut long ops work in half; the bound now comes from settings.""" + tool, _, _ = spawn_tool + captured = {} + + async def fake_run_ephemeral(self, **kwargs): + captured.update(kwargs) + return SubAgentOutcome(text="done", status="ok") + + monkeypatch.setattr("navi.core.agent.Agent.run_ephemeral", fake_run_ephemeral) + + await tool.execute({"task": "long op"}, ctx=ToolContext()) + + assert captured["timeout_seconds"] == settings.subagent_timeout_seconds + + +@pytest.mark.anyio async def test_spawn_agent_rejects_unknown_profile(spawn_tool): tool, _, _ = spawn_tool