refactor(memory): single shared trivial-prompt classifier + gate tests
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)
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
+2
-33
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user