diff --git a/navi/core/agent.py b/navi/core/agent.py index 25af328..12090d8 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -18,6 +18,7 @@ import asyncio import base64 import io +import re import time from datetime import datetime, timezone from pathlib import Path @@ -74,6 +75,62 @@ _TOOL_DONE = object() +_CASUAL_WORDS = frozenset({ + # Russian greetings/social + "привет", "здравствуй", "здравствуйте", "хай", "хелло", "хеллоу", + "как", "дела", "делишки", "ты", "вы", "поживаешь", "поживаете", + "жизнь", "сам", "сама", + "спасибо", "спс", "пока", "bye", "goodbye", + "доброе", "утро", "добрый", "день", "вечер", "спокойной", "ночи", + "ок", "окей", "ладно", "давай", + # English greetings/social + "hi", "hello", "hey", "hola", "bonjour", + "how", "are", "you", "it", "going", "things", "what", "up", "s", + "thanks", "thank", "thx", + "good", "morning", "afternoon", "evening", "night", "see", "cya", + "ok", "okay", + # Common fillers that keep a social phrase social + "a", "an", "the", "and", "too", "very", "much", "today", "now", + "there", "here", "again", "well", "oh", "ah", "um", + "в", "и", "а", "но", "же", "тоже", "очень", "сегодня", "сейчас", + "ну", "вот", "тут", "ещё", "раз", "ка", + "is", "am", "are", "do", "does", "did", "be", "been", "being", + "man", "dude", "bro", "mate", "friend", "dear", +}) + + +def _is_casual_message(text: str) -> bool: + """Fast heuristic: obvious social/greeting chat that doesn't need planning. + + Conservative by design. Returns True only for short, tool-free social + utterances (e.g. 'привет', 'как дела', 'спасибо'). Anything that looks + like a command, URL, path, or multi-step request is treated as non-casual. + """ + if not text: + return True + # Tool/action markers and URLs disqualify the message immediately. + if any(marker in text for marker in ("@", "!", "http://", "https://", "file://")): + return False + # Path-like or command-like fragments. + if any(fragment in text for fragment in ("/home", "/tmp", "/etc", "./", "../", "~/", "\\", ".py ", ".txt", ".md", ".json")): + return False + # A bare leading slash is a command/path indicator. + if text.strip().startswith("/"): + return False + # Long messages are very unlikely to be pure social greetings. + if len(text) > 100: + return False + stripped = text.strip() + # Very short messages are treated as casual by default. + if len(stripped) <= 10: + return True + words = [w for w in re.findall(r"\b\w+\b", stripped.lower()) if len(w) > 1] + if not words: + return True + casual_count = sum(1 for w in words if w in _CASUAL_WORDS) + return casual_count / len(words) >= 0.5 + + async def _todo_progress_message(session_id: str, *, first_iteration: bool = False) -> "Message | None": """Build a compact system reminder with current todo state and update discipline.""" from navi.tools.todo import get_progress_message @@ -261,9 +318,12 @@ # on subsequent messages uses the profile's planning_enabled flag. # force_plan suppresses the DIRECT shortcut: first message is always forced, # and planning_mandatory extends that to every subsequent message. + # Casual greetings are exempt from planning even on the first message. _is_first_message = sum(1 for m in session.messages if m.role == "user") == 1 - _force_plan = _is_first_message or profile.planning_mandatory - if _is_first_message or profile.planning_enabled: + _is_casual = _is_casual_message(context_content) and not profile.planning_mandatory + _force_plan = (_is_first_message and not _is_casual) or profile.planning_mandatory + if (_is_first_message or profile.planning_enabled) and not _is_casual: + log.debug("agent.planning_enter", session_id=session_id, first_message=_is_first_message, planning_enabled=profile.planning_enabled, force_plan=_force_plan) async for _ev in self._planning.run(session.context, profile, llm, mem, tool_schemas, messages=session.messages, force_plan=_force_plan): if isinstance(_ev, AIHelperTokensUsed): turn_ctx.subagent_tokens += _ev.completion_tokens diff --git a/navi/core/compressor.py b/navi/core/compressor.py index b519f91..bcffeaa 100644 --- a/navi/core/compressor.py +++ b/navi/core/compressor.py @@ -16,7 +16,7 @@ from navi.llm.base import LLMBackend, Message from navi.config import settings -from navi.events import ContextCompressed +from .events import ContextCompressed _SUMMARIZE_SYSTEM = ( "You are summarizing a conversation history to free up context space. " diff --git a/navi/core/planning.py b/navi/core/planning.py index 5cc6a48..784d518 100644 --- a/navi/core/planning.py +++ b/navi/core/planning.py @@ -102,8 +102,19 @@ + ( "" if force_plan else - "If it is a simple question, casual conversation, or answerable in one step " - "without tools — respond with exactly: DIRECT\n\n" + "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 " + "casual/social chat — output exactly: DIRECT\n" + "- If the user asks a simple question you can answer from your existing " + "knowledge without any tools (general facts, definitions, simple math) — " + "output exactly: DIRECT\n" + "- If the user gives a one-step instruction that needs no tool (e.g., 'stop', " + "'continue', 'ok') — output exactly: DIRECT\n" + "- Only build a full plan when tools, files, web, research, or multi-step " + "execution are actually needed.\n\n" + "Output must be literally the single word DIRECT (uppercase), nothing else. " + "No TASK, no GOAL, no STEPS, no analysis for trivial messages.\n\n" ) + available_tools_block + "Knowledge store rules (critical):\n" diff --git a/tests/unit/core/test_agent.py b/tests/unit/core/test_agent.py index efd60f3..ce3a1c2 100644 --- a/tests/unit/core/test_agent.py +++ b/tests/unit/core/test_agent.py @@ -9,7 +9,7 @@ import pytest import pytest_asyncio -from navi.core.agent import Agent +from navi.core.agent import Agent, _is_casual_message from navi.core.events import ( StreamEnd, StreamStopped, @@ -318,3 +318,44 @@ result, ok = await agent.run_ephemeral("task", profile_id="test") assert ok is False assert "thinking" in result.lower() or "stall" in result.lower() + + +# ─── _is_casual_message heuristic tests ────────────────────────────────────── + + +class TestIsCasualMessage: + def test_greetings_are_casual(self): + assert _is_casual_message("привет") + assert _is_casual_message("hi") + assert _is_casual_message("Hello") + assert _is_casual_message("здравствуйте") + + def test_social_phrases_are_casual(self): + assert _is_casual_message("как дела?") + assert _is_casual_message("how are you") + assert _is_casual_message("спасибо") + assert _is_casual_message("thanks") + assert _is_casual_message("пока") + + def test_very_short_messages_are_casual(self): + assert _is_casual_message("ok") + assert _is_casual_message("да") + assert _is_casual_message("yo") + + def test_tool_or_command_markers_are_not_casual(self): + assert not _is_casual_message("/help") + assert not _is_casual_message("!restart") + assert not _is_casual_message("ping @user") + + def test_urls_and_paths_are_not_casual(self): + assert not _is_casual_message("https://example.com") + assert not _is_casual_message("read /home/user/file.py") + assert not _is_casual_message("~/notes.md") + + def test_long_messages_are_not_casual(self): + assert not _is_casual_message("привет, расскажи подробно как настроить сервер и что для этого нужно сделать") + + def test_actual_task_requests_are_not_casual(self): + assert not _is_casual_message("напиши скрипт для бэкапа") + assert not _is_casual_message("проверь почему не работает ssh") + assert not _is_casual_message("what is the weather in Berlin tomorrow") diff --git a/tests/unit/core/test_planning.py b/tests/unit/core/test_planning.py index 3af5e83..a3ab60b 100644 --- a/tests/unit/core/test_planning.py +++ b/tests/unit/core/test_planning.py @@ -111,6 +111,23 @@ assert "knowledge persistence checkpoint" in phase3_prompt assert "Do not plan unavailable MCP tool calls" in phase3_prompt + async def test_planning_prompt_includes_direct_shortcut(self): + profile = make_profile( + "developer", + planning_phase2_enabled=False, + ) + llm = RecordingLLM(["DIRECT"]) + engine = PlanningEngine(FakeContextBuilder()) + context = [Message(role="user", content="hello")] + + async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]): + pass + + phase1_prompt = llm.calls[0][0].content + assert "CRITICAL DIRECT shortcut" in phase1_prompt + assert "greeting" in phase1_prompt + assert "DIRECT (uppercase)" in phase1_prompt + async def test_planning_prompt_omits_mcp_when_profile_has_no_mcp_servers(self): profile = make_profile( "developer",