"""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