diff --git a/navi/core/agent.py b/navi/core/agent.py index a99a86b..0cc7a33 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -41,6 +41,7 @@ Tool, ToolContext, current_event_sink, + current_replan_runner, current_stop_event, current_user_role, current_user_info, @@ -51,6 +52,7 @@ from .compressor import ContextCompressor, should_compress from .context_builder import ContextBuilder from .planning import PlanningEngine +from navi.tools.replan import ReplanRunner from .stream_guard import _iter_stream_guarded from .subagent_runner import SubAgentRunner from .tool_utils import build_tool_list @@ -750,10 +752,22 @@ user_info=current_user_info.get(), cwd=_cwd_var.get(), ) - async for _ev in self._execute_tools_with_sink( - turn_tool_calls, tools, turn_ctx, session, stop_event, tool_ctx - ): - yield _ev + # Expose a per-iteration ReplanRunner so the `replan` tool can re-run + # the planner over the live session. Constructed inside the loop (not + # once before) using this iteration's profile/llm/tool_schemas so a + # post-switch_profile replan uses the reloaded profile. reset on exit + # so the runner never leaks beyond the tool-execution window. + _replan_runner = ReplanRunner( + self._planning, session, profile, llm, mem, tool_schemas + ) + _replan_token = current_replan_runner.set(_replan_runner) + try: + async for _ev in self._execute_tools_with_sink( + turn_tool_calls, tools, turn_ctx, session, stop_event, tool_ctx + ): + yield _ev + finally: + current_replan_runner.reset(_replan_token) # The todo tool may have been called this turn (set/update/clear). # Re-read and push the latest state to the UI's side panel. diff --git a/navi/core/planning.py b/navi/core/planning.py index 79f8dc9..4cf7c13 100644 --- a/navi/core/planning.py +++ b/navi/core/planning.py @@ -56,6 +56,8 @@ system_prompt_override: str | None = None, is_subagent: bool = False, force_plan: bool = False, + is_replan: bool = False, + replan_context: str | None = None, ) -> AsyncGenerator: """Planning pipeline (async generator): @@ -88,6 +90,23 @@ if _mcp_msg: _base_sys = _base_sys + "\n\n---\n\n" + (_mcp_msg.content or "") + # Re-plan framing: when the agent calls `replan` mid-task, ReplanRunner packs + # the reason + current todo + scratchpad findings/errors into replan_context. + # We frame it here so Phase 1 revises the existing plan instead of starting + # fresh, while reusing the same analysis → review → plan pipeline. The DIRECT + # shortcut and observe-skip are suppressed for re-plans (gated above/below on + # is_replan) so a stale plan always yields a new plan. + _replan_block = "" + _has_replan_ctx = bool(is_replan and replan_context) + if _has_replan_ctx: + _replan_block = ( + "[RE-PLAN]\n\n" + "You are revising an existing plan mid-task — NOT starting fresh. The original plan became stale because of what you discovered during execution (not because a step failed — that is handled by [Adaptive re-plan]). " + "The current todo and scratchpad below reflect real progress: preserve already-completed work, replace only the remaining steps, and account for what changed. " + "Treat the reason and updated goal (if any) as the new direction.\n\n" + f"{replan_context}\n\n---\n\n" + ) + # ── Phase 1: Task analysis ──────────────────────────────────────────── analysis: str = "" if profile.planning_phase1_enabled: @@ -97,11 +116,20 @@ content=( _base_sys + "\n\n---\n\n" - "[PLANNING — PHASE 1: ANALYSIS]\n\n" - "Read the user's latest request.\n\n" + + _replan_block + + "[PLANNING — PHASE 1: ANALYSIS]\n\n" + + ( + "Read the user's latest request.\n\n" + if not is_replan else + ( + "Re-plan based on the [RE-PLAN] context above plus the user's original request.\n\n" + if _has_replan_ctx else + "Re-plan the task: revise the existing plan based on the user's original request and current progress.\n\n" + ) + ) + ( "" - if force_plan else + if (force_plan or is_replan) else "CRITICAL DIRECT shortcut — use it whenever possible:\n" "- If the user's message is a greeting (hello, hi, thanks, good morning, " "'how are you', 'what\\'s up', 'привет', 'как дела', 'спасибо') or any " @@ -214,7 +242,7 @@ # Independent of force_plan (force_plan only suppresses the DIRECT # shortcut); "look at X" on the first message should still not plan. _is_observe = bool(re.search(r"MODE\s*:\s*observe", analysis, re.IGNORECASE)) - if profile.observe_skips_plan_enabled and _is_observe and not is_subagent: + if profile.observe_skips_plan_enabled and _is_observe and not is_subagent and not is_replan: log.debug("agent.planning_observe_skip") _dbg["result"] = "observe_skip" if not is_subagent: diff --git a/navi/core/registry.py b/navi/core/registry.py index 6cedd2a..1ed9217 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -15,6 +15,7 @@ ManageRecallTool, MemoryTool, ReflectTool, + ReplanTool, ScheduleRecallTool, SpawnAgentTool, SshExecTool, @@ -235,6 +236,7 @@ ShareFileTool(), ContentPublishTool(), TodoTool(kv_store=kv_store), ScratchpadTool(kv_store=kv_store), ReflectTool(ai_helper=ai_helper), + ReplanTool(), reload_tool, list_tool, manual_tool, mcp_status_tool, create_mcp_server_tool, test_mcp_tool_tool, schedule_recall_tool, manage_recall_tool, diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index 6327a61..3b9d9a6 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -43,6 +43,7 @@ "todo", "scratchpad", "reflect", + "replan", "switch_profile", "list_profiles", "filesystem", diff --git a/navi/profiles/developer/system_prompt.txt b/navi/profiles/developer/system_prompt.txt index ccd22e2..a29668b 100644 --- a/navi/profiles/developer/system_prompt.txt +++ b/navi/profiles/developer/system_prompt.txt @@ -83,6 +83,7 @@ - **Sub-agent handoff** — before `spawn_agent`, write what the sub-agent needs (files, snippets, how to verify) into the `context_transfer` scratchpad section; it's injected into the sub-agent automatically. The sub-agent does NOT inherit your short-term memory. - **`schedule_recall`** — when a task may hit the iteration limit, or has a wait/poll cycle (build, deploy, log watch), schedule a recall with a self-instruction naming specific tools/files (future-you has the tools, not your memory). Use `immediate` to continue after the limit or offload heavy work headlessly; chain recalls for multi-phase work. Only one pending recall per session — `manage_recall cancel` before a new one. - **`reflect`** — before a genuinely complex plan or when stuck (repeated tool failures), call `reflect` to surface assumptions/gaps. It costs 3 LLM calls — use selectively, not on routine edits. +- **`replan`** — when the **structure** of the remaining plan is stale because of what you discovered mid-task (a step is unnecessary, the real problem differs from the assumed one, new constraints appeared — NOT because a step failed, that's `[Adaptive re-plan]`), call `replan` with a short `reason` (what changed) and optional `updated_goal`. It re-runs the planner over your current context + `todo` + `scratchpad` findings/errors and replaces the plan and `todo`. Distinguish from: `[Adaptive re-plan]` (a step failed — revise the `todo` inline, no planner call), a small `todo` edit (drop/merge/reorder 1-2 steps — edit inline, no planner call), and `reflect` (you're unsure what's wrong — surfaces assumptions, no plan change). Costs 1–3 LLM calls — use only when the remaining steps no longer fit as a whole. - **`memory`** — global cross-project facts (prefs, environment); not a substitute for `scratchpad` (session) or `docs/` (project). ### System signals you'll see diff --git a/navi/profiles/navi_code/config.json b/navi/profiles/navi_code/config.json index cc57617..840a249 100644 --- a/navi/profiles/navi_code/config.json +++ b/navi/profiles/navi_code/config.json @@ -44,6 +44,7 @@ "todo", "scratchpad", "reflect", + "replan", "switch_profile", "list_profiles", "filesystem", diff --git a/navi/profiles/navi_code/system_prompt.txt b/navi/profiles/navi_code/system_prompt.txt index ac61d32..d7fd26f 100644 --- a/navi/profiles/navi_code/system_prompt.txt +++ b/navi/profiles/navi_code/system_prompt.txt @@ -98,6 +98,7 @@ - **Sub-agent handoff** — before `spawn_agent`, write what the sub-agent needs (files, snippets, how to verify) into the `context_transfer` scratchpad section; it's injected into the sub-agent automatically. The sub-agent does NOT inherit your short-term memory. - **`schedule_recall`** — when a task may hit the iteration limit, or has a wait/poll cycle (build, deploy, log watch), schedule a recall with a self-instruction naming specific tools/files (future-you has the tools, not your memory). Use `immediate` to continue after the limit or offload heavy work headlessly; chain recalls for multi-phase work. Only one pending recall per session — `manage_recall cancel` before a new one. - **`reflect`** — before a genuinely complex plan or when stuck (repeated tool failures), call `reflect` to surface assumptions/gaps. It costs 3 LLM calls — use selectively, not on routine edits. +- **`replan`** — when the **structure** of the remaining plan is stale because of what you discovered mid-task (a step is unnecessary, the real problem differs from the assumed one, new constraints appeared — NOT because a step failed, which you handle by revising the `todo` inline), call `replan` with a short `reason` (what changed) and optional `updated_goal`. It re-runs the planner over your current context + `todo` + `scratchpad` findings/errors and replaces the plan and `todo`. Distinguish from: `[Adaptive re-plan]` (a step failed — revise the `todo` inline, no planner call), a small `todo` edit (drop/merge/reorder 1-2 steps — edit inline, no planner call), and `reflect` (you're unsure what's wrong — surfaces assumptions, no plan change). Costs 1–3 LLM calls — use only when the remaining steps no longer fit as a whole. - **`memory`** — global cross-project facts (prefs, environment); not a substitute for `scratchpad` (session) or `docs/`/`NAVI.md` (project). ### System signals you'll see diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json index f4b1d2f..35cba73 100644 --- a/navi/profiles/tool_developer/config.json +++ b/navi/profiles/tool_developer/config.json @@ -43,6 +43,7 @@ "todo", "scratchpad", "reflect", + "replan", "switch_profile", "list_profiles", "filesystem", diff --git a/navi/profiles/tool_developer/system_prompt.txt b/navi/profiles/tool_developer/system_prompt.txt index 1f8b4df..98a6a0c 100644 --- a/navi/profiles/tool_developer/system_prompt.txt +++ b/navi/profiles/tool_developer/system_prompt.txt @@ -201,6 +201,7 @@ - **Sub-agent handoff** — before `spawn_agent`, write what the sub-agent needs (server name, paths, tool specs, how to verify) into the `context_transfer` scratchpad section; it's injected into the sub-agent automatically. The sub-agent does NOT inherit your short-term memory. - **`schedule_recall`** — when a task may hit the iteration limit, or has a wait/poll cycle (build, deploy, log watch), schedule a recall with a self-instruction naming specific tools/files (future-you has the tools, not your memory). Use `immediate` to continue after the limit or offload heavy work headlessly; chain recalls for multi-phase work. Only one pending recall per session — `manage_recall cancel` before a new one. - **`reflect`** — before a genuinely complex plan or when stuck (repeated tool failures), call `reflect` to surface assumptions/gaps. It costs 3 LLM calls — use selectively, not on routine edits. +- **`replan`** — when the **structure** of the remaining plan is stale because of what you discovered mid-task (a step is unnecessary, the real problem differs from the assumed one, new constraints appeared — NOT because a step failed, that's `[Adaptive re-plan]`), call `replan` with a short `reason` (what changed) and optional `updated_goal`. It re-runs the planner over your current context + `todo` + `scratchpad` findings/errors and replaces the plan and `todo`. Distinguish from: `[Adaptive re-plan]` (a step failed — revise the `todo` inline, no planner call), a small `todo` edit (drop/merge/reorder 1-2 steps — edit inline, no planner call), and `reflect` (you're unsure what's wrong — surfaces assumptions, no plan change). Costs 1–3 LLM calls — use only when the remaining steps no longer fit as a whole. - **`memory`** — global cross-project facts (prefs, environment); not a substitute for `scratchpad` (session) or `docs/` (project). ### System signals you'll see diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index db7bde1..268a66f 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -15,6 +15,7 @@ from .switch_profile import SwitchProfileTool from .list_profiles import ListProfilesTool from .reflect import ReflectTool +from .replan import ReplanRunner, ReplanTool __all__ = [ "Tool", @@ -35,4 +36,6 @@ "SwitchProfileTool", "ListProfilesTool", "ReflectTool", + "ReplanRunner", + "ReplanTool", ] diff --git a/navi/tools/_internal/base.py b/navi/tools/_internal/base.py index fda757b..be83b30 100644 --- a/navi/tools/_internal/base.py +++ b/navi/tools/_internal/base.py @@ -60,6 +60,14 @@ "current_working_directory", default=None ) +# Set by run_stream() inside the tool-execution window. Holds the per-run ReplanRunner +# bound to the current session/profile/llm, so the `replan` tool can re-run the planner +# over the live session context + todo + scratchpad. None outside an agent run (e.g. +# sub-agent runs, which don't expose replan) — the tool reports unavailable then. +current_replan_runner: ContextVar[object | None] = ContextVar( + "current_replan_runner", default=None +) + @dataclass class ToolContext: diff --git a/navi/tools/replan.py b/navi/tools/replan.py new file mode 100644 index 0000000..7f5c8d5 --- /dev/null +++ b/navi/tools/replan.py @@ -0,0 +1,179 @@ +""" +replan — re-run the planner mid-task when the current plan is stale. + +Use when discoveries during execution invalidate the original plan (a step turned +out to be unnecessary, the real problem is different from the assumed one, new +constraints appeared). This is NOT for failed steps — those are handled by the +[Adaptive re-plan] signal, which nudges you to revise the todo inline without +re-running the planner. + +Integrated approach: replan reuses the same PlanningEngine pipeline (analysis → +review → execution plan) with `is_replan=True`, which frames Phase 1 as a revision +of the existing plan and suppresses the DIRECT shortcut and observe-skip so a +new plan is always produced. The current todo + scratchpad findings/errors are +packed into the re-plan context so the new plan accounts for real progress. The +new plan replaces the todo (set_tasks) — completed work is preserved in the +scratchpad/conversation, but the remaining steps are overwritten. + +Costs 1–3 LLM calls (Phase 1 always; Phase 2 only if REFLECT: yes; Phase 3 +always). Use selectively, like `reflect`. +""" + +from __future__ import annotations + +import structlog + +from navi.tools._internal.base import Tool, ToolContext, ToolResult, current_replan_runner +from navi.tools.scratchpad import get_section as scratchpad_get_section +from navi.tools.todo import render_todo_lines + +log = structlog.get_logger() + +# Mirror agent.py's cap so re-plan debug logs don't grow session.planning_logs unbounded. +_MAX_PLANNING_LOGS = 20 + + +class ReplanRunner: + """Per-run helper bound to the live session/profile/llm. + + Constructed inside the agent run_stream loop (so it sees the current + iteration's profile/llm/tool_schemas — correct after switch_profile) and + exposed to the `replan` tool via the `current_replan_runner` ContextVar. + """ + + def __init__(self, planning_engine, session, profile, llm, mem, tool_schemas) -> None: + self._planning = planning_engine + self._session = session + self._profile = profile + self._llm = llm + self._mem = mem + self._tool_schemas = tool_schemas + + async def replan(self, reason: str, updated_goal: str | None) -> str | None: + """Re-run the planner over the live session context + todo + scratchpad. + + Returns the new plan text, or None if planning produced no plan. The + planner mutates session.context/messages (appends the new plan + execute + prompt) and replaces the todo via set_tasks. + """ + session = self._session + todo_lines = await render_todo_lines(session.id) + findings = await scratchpad_get_section(session.id, "findings", user_id=session.user_id) + errors = await scratchpad_get_section(session.id, "errors", user_id=session.user_id) + + todo_block = "\n".join(todo_lines) if todo_lines else "(empty — no todo steps)" + findings_block = findings.strip() if findings and findings.strip() else "(none)" + errors_block = errors.strip() if errors and errors.strip() else "(none)" + goal_line = updated_goal.strip() if updated_goal and updated_goal.strip() else "(not specified — keep the original goal)" + + replan_context = ( + f"Reason for re-plan: {reason}\n" + f"Updated goal: {goal_line}\n\n" + f"Current todo:\n{todo_block}\n\n" + f"Scratchpad — findings:\n{findings_block}\n\n" + f"Scratchpad — errors:\n{errors_block}" + ) + + plan_text: str | None = None + try: + # Lazy import: navi.core.events pulls in navi.core.__init__ → registry → + # navi.tools, which would cycle if done at module load. At runtime every + # module is already imported, so this is safe. + from navi.core.events import ( + AIHelperTokensUsed, + PlanReady, + PlanningDebugData, + PlanningStatus, + ) + + async for ev in self._planning.run( + session.context, + self._profile, + self._llm, + self._mem, + self._tool_schemas, + messages=session.messages, + force_plan=True, + is_replan=True, + replan_context=replan_context, + ): + if isinstance(ev, PlanReady): + plan_text = ev.plan + elif isinstance(ev, PlanningDebugData): + session.planning_logs.append(ev.log) + if len(session.planning_logs) > _MAX_PLANNING_LOGS: + session.planning_logs = session.planning_logs[-_MAX_PLANNING_LOGS:] + # PlanningStatus / AIHelperTokensUsed are swallowed — replan is a + # tool call, not a top-level turn, so we don't re-yield UI events. + except Exception: + log.warning("replan.runner_failed", exc_info=True) + return None + + return plan_text + + +class ReplanTool(Tool): + name = "replan" + description = ( + "Re-plan mid-task when the current plan is stale because of what you discovered during execution — " + "NOT because a step failed (that is handled by the [Adaptive re-plan] signal).\n\n" + "Call this when:\n" + "- A step turned out to be unnecessary or the real problem differs from the assumed one.\n" + "- New constraints, files, or facts surfaced that change the remaining work.\n" + "- The remaining steps no longer lead to the goal.\n\n" + "It re-runs the planner over your current context + todo + scratchpad findings/errors and " + "REPLACES the plan and todo with the new steps. Costs 1–3 LLM calls — use selectively, like `reflect`.\n\n" + "Do NOT call replan for a single failed step: revise the todo inline instead. " + "For a small adjustment (drop/merge/reorder 1-2 steps) edit the `todo` directly — replan is for when the remaining plan's overall structure is wrong. " + "Use `reflect` when you're unsure what's wrong (it surfaces assumptions, no plan change); use `replan` when you know what changed and need a new plan." + ) + parameters = { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": ( + "What changed — the discovery that makes the current plan stale. " + "One or two sentences, concrete (e.g. 'the config is TOML not JSON, so the parser step is wrong')." + ), + }, + "updated_goal": { + "type": "string", + "description": ( + "Optional. The new success criterion if the goal itself shifted. " + "Omit to keep the original goal." + ), + }, + }, + "required": ["reason"], + } + + async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult: + reason = (params.get("reason") or "").strip() + updated_goal = (params.get("updated_goal") or "").strip() or None + + if not reason: + return ToolResult(success=False, output="", error="reason is required") + + runner = current_replan_runner.get(None) + if runner is None: + return ToolResult( + success=False, + output="", + error="replan is not available in this context (no active agent run)", + ) + + plan_text = await runner.replan(reason, updated_goal) + if not plan_text: + return ToolResult( + success=False, + output="", + error="re-planning produced no plan; keep the current plan and continue, or revise the todo inline", + ) + + output = ( + f"# Revised plan\n\n{plan_text}\n\n---\n" + "Plan revised. The todo has been replaced with the new steps. " + "Continue from step 1 of the new plan; do not resume the old steps." + ) + return ToolResult(success=True, output=output) \ No newline at end of file diff --git a/tests/unit/tools/test_replan.py b/tests/unit/tools/test_replan.py new file mode 100644 index 0000000..c7f0c86 --- /dev/null +++ b/tests/unit/tools/test_replan.py @@ -0,0 +1,320 @@ +"""Unit tests for navi.tools.replan — ReplanTool + ReplanRunner. + +Covers: +- ReplanTool schema/param requirements and the no-runner / missing-reason guards. +- ReplanRunner packs reason + goal + todo + scratchpad into replan_context, + re-runs PlanningEngine with is_replan=True (DIRECT + observe-skip suppressed), + captures PlanReady.plan, logs PlanningDebugData, and replaces the todo. +- The re-plan prompt framing ([RE-PLAN] block, re-plan opening line) is present + and the DIRECT shortcut / observe-skip are suppressed for is_replan. +""" + +import pytest + +from navi.core.events import PlanReady +from navi.core.planning import PlanningEngine +from navi.llm.base import LLMResponse, Message +from navi.tools._internal.base import ( + ToolContext, + current_replan_runner, + current_session_id, + current_user_id, +) +from navi.tools.replan import ReplanRunner, ReplanTool +from navi.tools import scratchpad as scratchpad_mod +from navi.tools import todo as todo_mod +from tests.conftest_factory import FakePool, make_profile + + +class RecordingLLM: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + async def complete(self, messages, **kwargs): + self.calls.append(messages) + return LLMResponse( + content=self.responses.pop(0), + tool_calls=None, + finish_reason="stop", + ) + + +class FakeContextBuilder: + def build_system_prompt(self, profile): + return "base system prompt" + + def _mcp_context_msg(self, profile=None): + return None + + +class FakeKvStore: + """In-memory KV store for todo + scratchpad tests.""" + + def __init__(self): + self._data: dict[tuple, str] = {} + + async def get(self, user_id, session_id, scope, key): + return self._data.get((user_id or "", session_id, scope, key)) + + async def set(self, user_id, session_id, scope, key, value): + self._data[(user_id or "", session_id, scope, key)] = value + + async def get_all(self, user_id, session_id, scope): + return { + k[3]: v + for k, v in self._data.items() + if k[:3] == (user_id or "", session_id, scope) + } + + async def delete(self, user_id, session_id, scope, key): + self._data.pop((user_id or "", session_id, scope, key), None) + + async def clear_scope(self, user_id, session_id, scope): + keys = [k for k in self._data if k[:3] == (user_id or "", session_id, scope)] + for k in keys: + del self._data[k] + + +class FakeSession: + def __init__(self, sid="sess_replan", user_id="u1"): + self.id = sid + self.user_id = user_id + self.context: list[Message] = [] + self.messages: list[Message] = [] + self.planning_logs: list[dict] = [] + + +@pytest.fixture(autouse=True) +def _fake_kv(): + store = FakeKvStore() + todo_mod._kv_store = store + scratchpad_mod._kv_store = store + yield store + todo_mod._kv_store = None + scratchpad_mod._kv_store = None + + +_REPLAN_ANALYSIS = ( + "TASK: revised task\n" + "GOAL: revised goal\n" + "MODE: act\n" + "UNKNOWNS: NONE\nRESOURCES: NONE\nKNOWLEDGE SOURCE ASSESSMENT: NONE\n" + "KNOWLEDGE CAPTURE: NONE\nCOMPLEXITY: simple\nSUBTASKS:\n1. New step\n" + "REFLECT: no\nCOMMITMENTS: none" +) +_REPLAN_PLAN = ( + "## Plan\n\n" + "**Task:** revised task\n**Goal:** revised goal\n\n" + "**Steps:**\n" + "1. New step one → TOOL: filesystem\n" + "2. New step two → SELF\n\n" + "**Parallel:** NONE\n**Risks:** NONE" +) + + +def _make_runner(store, responses, session=None, profile=None): + session = session or FakeSession() + profile = profile or make_profile("developer", planning_phase2_enabled=False) + llm = RecordingLLM(responses) + engine = PlanningEngine(FakeContextBuilder()) + runner = ReplanRunner(engine, session, profile, llm, mem=None, tool_schemas=[]) + return runner, session, llm, engine, profile + + +# ── ReplanTool schema ─────────────────────────────────────────────────────── + + +class TestReplanToolSchema: + def test_name_and_required_params(self): + t = ReplanTool() + assert t.name == "replan" + assert t.parameters["required"] == ["reason"] + assert "reason" in t.parameters["properties"] + assert "updated_goal" in t.parameters["properties"] + + async def test_missing_reason_returns_error(self): + t = ReplanTool() + result = await t.execute({}, ToolContext()) + assert result.success is False + assert "reason is required" in (result.error or "") + + async def test_no_runner_returns_error(self): + # No current_replan_runner set in this context. + t = ReplanTool() + result = await t.execute({"reason": "plan is stale"}, ToolContext()) + assert result.success is False + assert "not available" in (result.error or "") + + +# ── ReplanRunner + PlanningEngine is_replan integration ────────────────────── + + +class TestReplanRunner: + async def test_replan_runs_planner_with_is_replan_and_returns_plan(self): + runner, session, llm, engine, profile = _make_runner( + None, [_REPLAN_ANALYSIS, _REPLAN_PLAN] + ) + + plan = await runner.replan("config is TOML not JSON", None) + + assert plan == _REPLAN_PLAN + # Phase 1 + Phase 3 ran (phase2 disabled). + assert len(llm.calls) == 2 + # Phase 1 prompt carries the [RE-PLAN] framing and the reason. + phase1_prompt = llm.calls[0][0].content + assert "[RE-PLAN]" in phase1_prompt + assert "config is TOML not JSON" in phase1_prompt + # DIRECT shortcut suppressed for re-plan. + assert "CRITICAL DIRECT shortcut" not in phase1_prompt + # Re-plan opening line used instead of the fresh-request one. + assert "Re-plan based on the [RE-PLAN] context" in phase1_prompt + assert "Read the user's latest request." not in phase1_prompt + + async def test_replan_context_includes_todo_findings_errors(self): + # Seed a todo + scratchpad sections the runner must pack into context. + # ContextVars (current_session_id / current_user_id) must be set for the + # whole flow — render_todo_lines reads user_id from current_user_id, and + # planning.run's set_tasks scopes by current_session_id. In production the + # WS handler / run_stream set these; here we set them explicitly. + sid_tok = current_session_id.set("sess_replan") + uid_tok = current_user_id.set("u1") + try: + from navi.tools.todo import set_tasks + await set_tasks("sess_replan", ["Old step one", "Old step two"]) + await scratchpad_mod._kv_store.set("u1", "sess_replan", "scratchpad", "findings", "config parser expects TOML") + await scratchpad_mod._kv_store.set("u1", "sess_replan", "scratchpad", "errors", "JSON parse failed at line 3") + + runner, session, llm, engine, profile = _make_runner( + None, [_REPLAN_ANALYSIS, _REPLAN_PLAN], session=FakeSession(sid="sess_replan", user_id="u1") + ) + + plan = await runner.replan("config is TOML not JSON", "parse config correctly") + + assert plan == _REPLAN_PLAN + phase1_prompt = llm.calls[0][0].content + # The packed context surfaces all four pieces. + assert "Reason for re-plan: config is TOML not JSON" in phase1_prompt + assert "Updated goal: parse config correctly" in phase1_prompt + assert "Old step one" in phase1_prompt + assert "config parser expects TOML" in phase1_prompt + assert "JSON parse failed at line 3" in phase1_prompt + finally: + current_user_id.reset(uid_tok) + current_session_id.reset(sid_tok) + + async def test_replan_replaces_todo_with_new_steps(self): + from navi.tools.todo import _load_tasks, set_tasks + + sid_tok = current_session_id.set("sess_replan") + uid_tok = current_user_id.set("u1") + try: + await set_tasks("sess_replan", ["Old step one", "Old step two"]) + assert [t.text for t in await _load_tasks("sess_replan")] == ["Old step one", "Old step two"] + + runner, session, llm, engine, profile = _make_runner( + None, [_REPLAN_ANALYSIS, _REPLAN_PLAN], session=FakeSession(sid="sess_replan") + ) + + plan = await runner.replan("stale plan", None) + + assert plan == _REPLAN_PLAN + # planning.run's set_tasks replaced the todo with the new plan steps. + # _parse_plan_steps keeps the full step line including the executor tag. + tasks = await _load_tasks("sess_replan") + assert [t.text for t in tasks] == [ + "New step one → TOOL: filesystem", + "New step two → SELF", + ] + finally: + current_user_id.reset(uid_tok) + current_session_id.reset(sid_tok) + + async def test_replan_logs_planning_debug_data_to_session(self): + runner, session, llm, engine, profile = _make_runner( + None, [_REPLAN_ANALYSIS, _REPLAN_PLAN] + ) + + await runner.replan("stale plan", None) + + # PlanningDebugData log captured (is_subagent=False for replan). + assert len(session.planning_logs) == 1 + assert session.planning_logs[0]["result"] == "plan" + + async def test_replan_appends_plan_and_prompt_to_session_context(self): + runner, session, llm, engine, profile = _make_runner( + None, [_REPLAN_ANALYSIS, _REPLAN_PLAN] + ) + + await runner.replan("stale plan", None) + + # New plan assistant message + execute prompt appended. + plan_msgs = [m for m in session.context if m.role == "assistant"] + assert any(_REPLAN_PLAN in (m.content or "") for m in plan_msgs) + prompt_msgs = [m for m in session.context if m.role == "user" and "Plan is ready" in (m.content or "")] + assert len(prompt_msgs) == 1 + + async def test_replan_returns_none_on_planner_failure(self): + # First LLM call raises → runner catches and returns None. + class _BoomLLM: + async def complete(self, messages, **kwargs): + raise RuntimeError("llm down") + + session = FakeSession() + profile = make_profile("developer", planning_phase2_enabled=False) + engine = PlanningEngine(FakeContextBuilder()) + runner = ReplanRunner(engine, session, profile, _BoomLLM(), mem=None, tool_schemas=[]) + + plan = await runner.replan("stale plan", None) + assert plan is None + + +# ── PlanningEngine is_replan prompt/observe-skip behavior ──────────────────── + + +class TestPlanningIsReplan: + async def test_is_replan_suppresses_observe_skip(self): + """An observe-classified analysis must NOT skip Phase 3 when is_replan — + a stale plan always needs a new plan.""" + profile = make_profile( + "developer", + planning_phase2_enabled=False, + observe_skips_plan_enabled=True, + ) + observe_analysis = ( + "TASK: look at X\nGOAL: report X\nMODE: observe\n" + "UNKNOWNS: NONE\nRESOURCES: NONE\nKNOWLEDGE SOURCE ASSESSMENT: NONE\n" + "KNOWLEDGE CAPTURE: NONE\nCOMPLEXITY: simple\nSUBTASKS:\n1. List\n" + "REFLECT: no\nCOMMITMENTS: none" + ) + llm = RecordingLLM([observe_analysis, _REPLAN_PLAN]) + engine = PlanningEngine(FakeContextBuilder()) + context = [Message(role="user", content="look at X")] + + events = [] + async for ev in engine.run( + context, profile, llm, mem=None, tool_schemas=[], + force_plan=True, is_replan=True, replan_context="Reason for re-plan: stale", + ): + events.append(ev) + + # Observe-skip suppressed → Phase 3 ran, plan produced. + assert len(llm.calls) == 2 + assert any(isinstance(e, PlanReady) for e in events) + + async def test_replan_context_omitted_does_not_inject_block(self): + """is_replan without replan_context must not inject an empty [RE-PLAN] block.""" + profile = make_profile("developer", planning_phase2_enabled=False) + llm = RecordingLLM(["DIRECT"]) # force_plan+is_replan suppress DIRECT → needs full analysis, but one call is enough to inspect prompt + engine = PlanningEngine(FakeContextBuilder()) + context = [Message(role="user", content="hi")] + + # is_replan=True but replan_context=None + async for _ev in engine.run( + context, profile, llm, mem=None, tool_schemas=[], + force_plan=True, is_replan=True, replan_context=None, + ): + pass + + phase1_prompt = llm.calls[0][0].content + assert "[RE-PLAN]" not in phase1_prompt \ No newline at end of file