From c093492b067628def6b1866deb3c6bc84059ef24 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:42:02 +0530 Subject: [PATCH] refactor(memory): single shared trivial-prompt classifier + gate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase fold on the salvaged gate: - is_trivial_prompt/TRIVIAL_PROMPT_RE move to agent/memory_provider (the ABC both the core gate and providers already import) — one source of truth; honcho's _TRIVIAL_PROMPT_RE now aliases it, turn_context and the queue_prefetch_all warm path (a sibling site main grew after the PR's base) both use it - tests: gate tests at the prefetch call site (mutation-checked), shared classifier tests incl. prefix-collision guards (k8s/yolo/note/supper), and honcho dialectic-machinery tests re-driven with a substantive prompt ("hello" became trivial by design — those tests exercise thread cadence, not the classifier) --- agent/memory_provider.py | 38 +++++++++++++++++++++++++++++ agent/turn_context.py | 35 ++------------------------ plugins/memory/honcho/__init__.py | 14 ++++------- run_agent.py | 14 ++++++++--- tests/agent/test_memory_provider.py | 20 +++++++++++++++ tests/agent/test_turn_context.py | 32 ++++++++++++++++++++++++ tests/honcho_plugin/test_session.py | 6 ++--- 7 files changed, 110 insertions(+), 49 deletions(-) diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 4210a4c252..a73a9a475e 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -34,12 +34,50 @@ Optional hooks (override to opt in): from __future__ import annotations import logging +import re from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) +# Prompts that carry no semantic signal — trivial acknowledgements, greetings, +# slash commands, empty input. Single source of truth shared by the core +# per-turn prefetch gate (agent/turn_context.py, run_agent.py) and provider- +# side classifiers (plugins/memory/honcho) so the two can never drift apart. +# The alternation is anchored and may only be followed by whitespace or +# punctuation, so words that merely START with a trivial word ("k8s", "yolo", +# "note", "hindsight") do NOT match, while trailing-punctuation variants +# ("hi!", "hey.", "thanks :)", "done???") do. +TRIVIAL_PROMPT_RE = re.compile( + r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|' + r'hi|hey|hello|yo|sup|' + r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)' + r'[\s!?.:;,"' + "'" + r'~\u2018\u2019\u201c\u201d\u2014\u2013\u2026()\[\]{}<>*&^%$#@!+=`\u00a0]*$', + re.IGNORECASE, +) + + +def is_trivial_prompt(text: Optional[str]) -> bool: + """Return True if a user prompt is too trivial to warrant memory recall. + + Empty/whitespace-only input, slash commands, and bare greetings or + acknowledgements (with optional trailing punctuation) all count as + trivial. Callers use this to skip memory-provider prefetch/injection + on turns that carry no semantic signal — saving a blocking network + round-trip and preventing stale user-model context from derailing + one-word replies. + """ + if not text: + return True + stripped = text.strip() + if not stripped: + return True + if stripped.startswith("/"): + return True + return bool(TRIVIAL_PROMPT_RE.match(stripped)) + + class MemoryProvider(ABC): """Abstract base class for memory providers.""" diff --git a/agent/turn_context.py b/agent/turn_context.py index a341698cd4..def497b158 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -25,7 +25,6 @@ move-and-name refactor with no semantic change. from __future__ import annotations import logging -import re import threading import time import uuid @@ -42,6 +41,7 @@ from agent.conversation_compression import ( from agent.context_engine import automatic_compaction_status_message from agent.iteration_budget import IterationBudget from agent.memory_manager import build_memory_context_block +from agent.memory_provider import is_trivial_prompt from agent.model_metadata import ( estimate_messages_tokens_rough, estimate_request_tokens_rough, @@ -50,37 +50,6 @@ from agent.model_metadata import ( logger = logging.getLogger(__name__) -# Trivial user-input pattern — used to gate the per-turn memory-provider -# prefetch on short, semantically-empty queries. Covers greetings, -# acknowledgements, and trailing-punctuation variants (e.g. "hi!", "hey.", -# "thanks :)"). -_RE_TRIVIAL_USER_QUERY = re.compile( - r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|' - r'hi|hey|hello|yo|sup|' - r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)' - r'[\s!?.:;,"' + "'" + r'~\u2018\u2019\u201c\u201d\u2014\u2013\u2026()\[\]{}<>*&^%$#@!+=`\u00a0]*$', - re.IGNORECASE, -) - - -def _is_trivial_user_query(query: str) -> bool: - """Return True if the query is a greeting or too trivial to warrant prefetch. - - Strips leading/trailing whitespace first, then checks against - _RE_TRIVIAL_USER_QUERY which permits common trailing punctuation - (exclamation, question mark, emoji, etc.) so variants like "hi!", - "hey.", and "thanks :)" register as trivial. - """ - if not query: - return True - stripped = query.strip() - if not stripped: - return True - if stripped.startswith("/"): - return True - return bool(_RE_TRIVIAL_USER_QUERY.match(stripped)) - - def compose_user_api_content( content: Any, ext_prefetch_cache: str, @@ -1191,7 +1160,7 @@ def build_turn_context( if agent._memory_manager: try: _query = original_user_message if isinstance(original_user_message, str) else "" - if not _is_trivial_user_query(_query): + if not is_trivial_prompt(_query): ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or "" except Exception: pass diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index ffbf657cdb..d6a2d3f806 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -23,7 +23,7 @@ import time from typing import Any, Callable, Dict, List, Optional from agent.memory_manager import sanitize_context -from agent.memory_provider import MemoryProvider +from agent.memory_provider import TRIVIAL_PROMPT_RE, MemoryProvider from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -1198,14 +1198,10 @@ class HonchoMemoryProvider(MemoryProvider): # Prompts that carry no semantic signal — trivial acknowledgements, greetings, # slash commands, empty input. Skipping injection here saves tokens and prevents - # stale user-model context from derailing one-word replies. - _TRIVIAL_PROMPT_RE = re.compile( - r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|' - r'hi|hey|hello|yo|sup|' - r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)' - r'[\s!?.:;,~]*$', - re.IGNORECASE, - ) + # stale user-model context from derailing one-word replies. The pattern is + # shared with the core prefetch gate (agent/memory_provider.TRIVIAL_PROMPT_RE) + # so the provider-side classifier and the core gate can never drift apart. + _TRIVIAL_PROMPT_RE = TRIVIAL_PROMPT_RE @classmethod def _is_trivial_prompt(cls, text: str) -> bool: diff --git a/run_agent.py b/run_agent.py index c00fcf60de..953a127323 100644 --- a/run_agent.py +++ b/run_agent.py @@ -148,6 +148,7 @@ from tools.browser_tool import cleanup_browser # Agent internals extracted to agent/ package for modularity from agent.memory_manager import sanitize_context +from agent.memory_provider import is_trivial_prompt from agent.error_classifier import FailoverReason from agent.redact import redact_sensitive_text from agent.message_content import flatten_message_text @@ -4106,10 +4107,15 @@ class AIAgent: response_text, **sync_kwargs, ) - self._memory_manager.queue_prefetch_all( - user_text, - session_id=self.session_id or "", - ) + # Sibling of the build_turn_context() prefetch gate: warming the + # next turn's recall with a trivial prompt ("hi", "thanks") keys + # provider searches on zero-signal text — skip it. The sync above + # still runs so the turn itself is persisted. + if not is_trivial_prompt(user_text): + self._memory_manager.queue_prefetch_all( + user_text, + session_id=self.session_id or "", + ) except Exception: pass diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 8b16dd442d..d11cfc026d 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -1138,3 +1138,23 @@ class TestMemoryInjectionRejectsMalformedSchema: names = {t["function"]["name"] for t in agent.tools} assert names == {"good_tool"} assert agent.valid_tool_names == {"good_tool"} + + +class TestTrivialPromptClassifier: + """is_trivial_prompt — the shared gate for core prefetch + provider injection.""" + + def test_trivial_variants(self): + from agent.memory_provider import is_trivial_prompt + + for t in ("hi", "HI!", "hey.", "hello", "yo", "sup~", "thanks :)", + "done???", "ok", "yes.", "k", "", " ", "/help", "lgtm"): + assert is_trivial_prompt(t), f"expected trivial: {t!r}" + + def test_substantive_and_prefix_collisions_pass_through(self): + from agent.memory_provider import is_trivial_prompt + + # Words that merely START with a trivial word must not match. + for t in ("k8s", "yolo", "hive", "note", "supper", "hind", + "hello world", "ok so what's next", "what's my name", + "hey can you check the logs", "continue the migration plan"): + assert not is_trivial_prompt(t), f"expected non-trivial: {t!r}" diff --git a/tests/agent/test_turn_context.py b/tests/agent/test_turn_context.py index 9a0afb4f2c..cf5ff86abf 100644 --- a/tests/agent/test_turn_context.py +++ b/tests/agent/test_turn_context.py @@ -208,6 +208,38 @@ def test_returns_turn_context_with_user_message_appended(): assert ctx.active_system_prompt == "SYSTEM" +# ── Trivial-prompt prefetch gate (PR #25350 salvage) ───────────────────────── +# +# The prologue is the ONLY place the per-turn synchronous +# memory_manager.prefetch_all() fires; a bare greeting must not block the +# turn on provider network round-trips, while a substantive question must +# still prefetch. These assert the gate at the call site (the classifier +# itself is covered in tests/agent/test_memory_provider.py). + + +def _agent_with_memory_manager(): + agent = _FakeAgent() + mm = MagicMock() + mm.prefetch_all.return_value = "REMEMBERED CONTEXT" + agent._memory_manager = mm + return agent, mm + + +def test_prefetch_skipped_for_trivial_user_message(): + agent, mm = _agent_with_memory_manager() + ctx = _build(agent, user_message="hi!") + mm.prefetch_all.assert_not_called() + assert ctx.ext_prefetch_cache == "" + + +def test_prefetch_runs_for_substantive_user_message(): + agent, mm = _agent_with_memory_manager() + query = "what did we decide about the deploy pipeline?" + ctx = _build(agent, user_message=query) + mm.prefetch_all.assert_called_once_with(query) + assert ctx.ext_prefetch_cache == "REMEMBERED CONTEXT" + + def test_turn_start_replaces_stale_parent_history_with_compression_child(): agent = _FakeAgent() stale_history = [{"role": "user", "content": "stale parent"}] diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index cd8e1bb51a..6583fdc800 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -884,7 +884,7 @@ class TestDialecticCadenceAdvancesOnSuccess: provider._turn_count = 5 provider._last_dialectic_turn = 0 - provider.queue_prefetch("hello") + provider.queue_prefetch("what changed in the repo today") if provider._prefetch_thread: provider._prefetch_thread.join(timeout=2.0) @@ -909,7 +909,7 @@ class TestDialecticCadenceAdvancesOnSuccess: provider._prefetch_thread = fresh provider._prefetch_thread_started_at = _time.monotonic() # fresh start - provider.queue_prefetch("hello") + provider.queue_prefetch("what changed in the repo today") # Should have short-circuited — no new dialectic call assert provider._manager.dialectic_query.call_count == 0 hold.set() @@ -1015,7 +1015,7 @@ class TestDialecticLiveness: # timeout=2.0, multiplier=2.0, so anything older than 4s is stale p._prefetch_thread_started_at = 0.0 # very old (1970 monotonic baseline) - p.queue_prefetch("hello") + p.queue_prefetch("what changed in the repo today") # New thread should have been spawned since stuck one is stale assert p._prefetch_thread is not stuck, "stale thread must be recycled" if p._prefetch_thread: