Newer
Older
navi-1 / navi / core / anti_stall.py
"""Anti-stall monitoring for the Agent loop."""

from __future__ import annotations

import json
from dataclasses import dataclass, field

from navi.llm.base import Message, ToolCallRequest


@dataclass
class AntiStallMonitor:
    """Tracks stall signals across iterations and builds intervention messages.

    Two independent stall signals:
    - No todo progress: consecutive iterations without a todo status change.
    - Repeated tool calls: identical tool signatures across consecutive turns.

    The former adaptive re-plan nudges (queued messages on failed steps / long
    steps) were retired together with the mandatory planning gate: the model
    is trusted to call `plan` (or revise the todo) on its own when execution
    shows the approach is wrong.
    """

    profile: object  # AgentProfile — avoid circular import
    stall_no_todo: int = 0
    stall_repeat_tools: int = 0
    prev_tool_sigs: frozenset = field(default_factory=frozenset)
    _todo_snapshot: frozenset | None = field(default=None, repr=False)

    async def init(self, session_id: str) -> None:
        """Capture the initial todo snapshot so the first post_turn() can detect change."""
        from navi.tools.todo import get_task_snapshot
        self._todo_snapshot = await get_task_snapshot(session_id)

    async def pre_turn(self, session_id: str, iteration: int) -> Message | None:
        """Return a system message to inject before the LLM call, or None."""
        if self.profile.anti_stall_enabled and iteration > 0:
            stalled = (
                self.stall_no_todo >= self.profile.anti_stall_threshold
                or self.stall_repeat_tools >= self.profile.anti_stall_threshold
            )
            if stalled:
                reason = (
                    f"no todo progress for {self.stall_no_todo} iterations"
                    if self.stall_no_todo >= self.profile.anti_stall_threshold
                    else f"identical tool calls repeated {self.stall_repeat_tools} times"
                )
                return Message(
                    role="system",
                    content=(
                        f"[Anti-stall warning — {reason}] "
                        "You are repeating the same actions without making progress. "
                        "Stop and reconsider: change your approach, try a different tool, "
                        "mark the current step as failed and move on, or ask the user for guidance."
                    ),
                )

        return None

    async def post_turn(self, session_id: str, tool_calls: list[ToolCallRequest]) -> None:
        """Update stall counters after tool execution."""
        from navi.tools.todo import get_task_snapshot

        if not self.profile.anti_stall_enabled:
            return

        # Todo progress signal
        before = self._todo_snapshot
        current = await get_task_snapshot(session_id)
        if before is not None:
            if current != before:
                self.stall_no_todo = 0
            else:
                self.stall_no_todo += 1
        self._todo_snapshot = current

        # Repeated tool call signal
        cur_sigs = frozenset(
            (tc.name, json.dumps(tc.arguments, sort_keys=True))
            for tc in (tool_calls or [])
        )
        if cur_sigs and cur_sigs == self.prev_tool_sigs:
            self.stall_repeat_tools += 1
        else:
            self.stall_repeat_tools = 0
        self.prev_tool_sigs = cur_sigs