Newer
Older
navi-1 / navi / tools / plan.py
"""
plan — agent-invoked planning: think through a task before executing it.

The planner runs as a tool, not as a mandatory pre-turn gate: the agent calls
`plan` itself when a task is non-trivial. It produces a structured execution
plan (milestones + steps with TOOL/AGENT/SELF executors) and auto-populates
the todo.

Fresh planning: call with no arguments before starting execution. Revision:
pass `reason` (what you discovered that invalidates the remaining plan) and
optionally `updated_goal` — the planner then frames the run as a re-plan and
replaces the todo, preserving completed work.

PlanningStatus / PlanReady events are forwarded to the event sink so the UI
shows planning progress and the plan card mid-turn (pattern: switch_profile).
The tool result carries the follow-up instruction: for COMPLEXITY=complex the
agent must present the plan and wait for the user's confirmation; otherwise it
proceeds straight to execution.
"""

from __future__ import annotations

import structlog

from navi.tools._internal.base import (
    Tool,
    ToolContext,
    ToolResult,
    current_event_sink,
    current_plan_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 planning debug logs don't grow session.planning_logs unbounded.
_MAX_PLANNING_LOGS = 20


class PlanRunner:
    """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 `plan` tool via the `current_plan_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

    @property
    def complexity(self) -> str:
        """COMPLEXITY parsed from the latest Phase 1 output ("" when unknown)."""
        return self.planning.last_complexity

    async def plan(
        self, reason: str | None, updated_goal: str | None, event_sink
    ) -> str | None:
        """Run the planner over the live session context.

        With `reason` — revision mode: the reason + current todo + scratchpad
        findings/errors are packed into the re-plan context, Phase 1 frames the
        run as a revision of the existing plan and the todo is replaced.
        Without — fresh planning.

        Returns the new plan text, or None if planning produced no plan.
        """
        session = self._session

        if reason:
            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}"
            )
        else:
            replan_context = None

        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,
                is_replan=bool(reason),
                replan_context=replan_context,
            ):
                if isinstance(ev, PlanReady):
                    plan_text = ev.plan
                    if event_sink is not None:
                        await event_sink.put(ev)
                elif isinstance(ev, PlanningStatus):
                    if event_sink is not None:
                        await event_sink.put(ev)
                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:]
                # AIHelperTokensUsed is internal (wire-None) — no UI consumer
                # in the tool path; token usage stays visible via planning_logs.
        except Exception:
            log.warning("plan.runner_failed", exc_info=True)
            return None

        return plan_text


class PlanTool(Tool):
    name = "plan"
    description = (
        "Run the planner: decompose a task into a structured execution plan (milestones + steps "
        "with TOOL/AGENT/SELF executors) and auto-populate the todo.\n\n"
        "Call this BEFORE starting execution when:\n"
        "- The task is non-trivial: multiple steps, several files/systems, research, or real risk.\n"
        "- The work needs decomposition, ordering, or sub-agent scoping decided up front.\n\n"
        "Skip it for trivial work: single-file edits, one-off commands, questions, casual chat.\n\n"
        "Re-plan mid-task by passing `reason` — what you discovered that invalidates the remaining "
        "plan (a step turned out unnecessary, the real problem differs from the assumed one, new "
        "constraints appeared) — plus optionally `updated_goal`. The new plan replaces the todo; "
        "completed work is preserved in the scratchpad/conversation.\n\n"
        "Costs 2 LLM calls (analysis + execution plan) — use selectively, like `reflect`.\n\n"
        "Do NOT call plan for a single failed step: revise the todo inline instead. For a small "
        "adjustment (drop/merge/reorder 1-2 steps) edit the `todo` directly — plan is for when the "
        "remaining plan's overall structure is wrong (or missing). Use `reflect` when you're unsure "
        "what's wrong (it surfaces assumptions, no plan change); use `plan` when you need a plan."
    )
    parameters = {
        "type": "object",
        "properties": {
            "reason": {
                "type": "string",
                "description": (
                    "Re-plan mode: 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'). Omit for fresh planning."
                ),
            },
            "updated_goal": {
                "type": "string",
                "description": (
                    "Optional, only with `reason`: the new success criterion if the goal itself "
                    "shifted. Omit to keep the original goal."
                ),
            },
        },
        "required": [],
    }

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        reason = (params.get("reason") or "").strip() or None
        updated_goal = (params.get("updated_goal") or "").strip() or None

        runner = current_plan_runner.get(None)
        if runner is None:
            return ToolResult(
                success=False,
                output="",
                error="plan is not available in this context (no active agent run)",
            )

        # Forward planning UI events mid-turn (status line + plan card) —
        # the agent loop drains the sink into the WS stream (switch_profile pattern).
        # tool_ctx.event_sink is None in the agent loop (the ContextVar is the real
        # channel), so fall through to current_event_sink whenever ctx has no sink.
        sink = ctx.event_sink if (ctx and ctx.event_sink) else current_event_sink.get()

        plan_text = await runner.plan(reason, updated_goal, sink)
        if not plan_text:
            if reason:
                return ToolResult(
                    success=False,
                    output="",
                    error="re-planning produced no plan; keep the current plan and continue, or revise the todo inline",
                )
            return ToolResult(
                success=False,
                output="",
                error="planning produced no plan; proceed directly without a plan",
            )

        title = "# Revised plan" if reason else "# Plan"
        if runner.complexity == "complex":
            tail = (
                "The task is COMPLEX. Briefly present this plan to the user — milestones and key "
                "steps in a few sentences — and WAIT for their confirmation before executing. "
                "Do not start executing yet."
            )
        else:
            tail = (
                "The plan is ready and the todo has been populated with its steps. "
                "Proceed with execution now, starting from step 1, tracking progress with the todo "
                "tool. You may summarize the plan in one or two sentences before starting."
            )

        return ToolResult(success=True, output=f"{title}\n\n{plan_text}\n\n---\n{tail}")