From fb02108e6825e3c0106d0a311d60b2d49da3bfe7 Mon Sep 17 00:00:00 2001 From: "xuyan.wxy" Date: Sun, 14 Jun 2026 21:26:04 +0800 Subject: [PATCH 1/4] support no-llm mode --- apps/claude-code-plugin/scripts/init.sh | 33 ++++++- .../agent/components/scope_controller.py | 31 +++++- src/powermem/cli/commands/config.py | 22 +++-- src/powermem/config_loader.py | 8 +- src/powermem/configs.py | 1 + src/powermem/core/async_memory.py | 23 ++++- src/powermem/core/memory.py | 23 ++++- src/powermem/integrations/llm/config/noop.py | 20 ++++ src/powermem/integrations/llm/factory.py | 1 + src/powermem/integrations/llm/noop.py | 41 ++++++++ .../intelligence/importance_evaluator.py | 6 +- src/powermem/intelligence/memory_optimizer.py | 4 + src/powermem/intelligence/skill_manager.py | 16 +++ .../user_memory/query_rewrite/rewriter.py | 10 +- src/powermem/user_memory/user_memory.py | 14 ++- src/server/models/response.py | 2 +- src/server/utils/health_check.py | 8 ++ tests/integration/test_noop_llm_mode.py | 97 +++++++++++++++++++ tests/unit/test_noop_llm.py | 60 ++++++++++++ 19 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 src/powermem/integrations/llm/config/noop.py create mode 100644 src/powermem/integrations/llm/noop.py create mode 100644 tests/integration/test_noop_llm_mode.py create mode 100644 tests/unit/test_noop_llm.py diff --git a/apps/claude-code-plugin/scripts/init.sh b/apps/claude-code-plugin/scripts/init.sh index 4e1c7e3c2..7c7b69b19 100755 --- a/apps/claude-code-plugin/scripts/init.sh +++ b/apps/claude-code-plugin/scripts/init.sh @@ -209,26 +209,49 @@ if not base_url and not auth_token: base_url = base_url.strip() missing = [] +llm_credentials_missing = False if not provider: missing.append("POWERMEM_INIT_LLM_PROVIDER") + llm_credentials_missing = True if not model: missing.append("POWERMEM_INIT_LLM_MODEL") if provider not in {"ollama", "vllm"}: if provider == "anthropic": if not auth_token and not api_key: + llm_credentials_missing = True missing.append( "ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL, " "ANTHROPIC_API_KEY, or POWERMEM_INIT_LLM_API_KEY" ) if auth_token and not base_url: + llm_credentials_missing = True missing.append("ANTHROPIC_BASE_URL or POWERMEM_INIT_LLM_BASE_URL") elif not api_key: + llm_credentials_missing = True missing.append("POWERMEM_INIT_LLM_API_KEY or LLM_API_KEY") if missing: - print("Missing configuration: " + ", ".join(missing), file=sys.stderr) - print("Run init again with these environment variables set.", file=sys.stderr) - sys.exit(2) + if llm_credentials_missing: + print("No complete LLM configuration found: " + ", ".join(missing)) + print( + "PowerMem will run in no-LLM mode. Basic memory add/search/update/delete " + "will work, while fact extraction, profile extraction, query rewrite, " + "compression, and graph extraction will be skipped." + ) + provider = "noop" + provider_source = "fallback:no complete LLM config" + model = "noop" + model_source = "fallback:no complete LLM config" + api_key = "" + api_key_source = "" + auth_token = "" + auth_token_source = "" + base_url = "" + base_url_source = "" + else: + print("Missing configuration: " + ", ".join(missing), file=sys.stderr) + print("Run init again with these environment variables set.", file=sys.stderr) + sys.exit(2) embedding_provider = env_first("POWERMEM_INIT_EMBEDDING_PROVIDER", "EMBEDDING_PROVIDER") or "default" embedding_provider = embedding_provider.lower() @@ -441,6 +464,10 @@ if provider == 'anthropic': ) sys.exit(1) +if provider == 'noop': + print("LLM validation skipped: no-LLM mode is enabled.") + sys.exit(0) + if provider in {'ollama', 'vllm'} or not (api_key or auth_token): sys.exit(0) diff --git a/src/powermem/agent/components/scope_controller.py b/src/powermem/agent/components/scope_controller.py index b3839c216..6172cc8e3 100644 --- a/src/powermem/agent/components/scope_controller.py +++ b/src/powermem/agent/components/scope_controller.py @@ -39,7 +39,7 @@ def __init__(self, config: Dict[str, Any]): # Extract llm config as dict (handle both ConfigObject and dict) try: - llm_provider = config.llm.provider if hasattr(config, 'llm') else 'mock' + llm_provider = config.llm.provider if hasattr(config, 'llm') else 'noop' llm_config_obj = config.llm.config if hasattr(config, 'llm') else {} # Convert to dict if ConfigObject @@ -49,7 +49,7 @@ def __init__(self, config: Dict[str, Any]): llm_config = dict(llm_config_obj) if llm_config_obj else {} except Exception as e: logger.warning(f"Failed to extract LLM config: {e}") - llm_provider = 'mock' + llm_provider = 'noop' llm_config = {} self.llm = LLMFactory.create(llm_provider, llm_config) @@ -163,6 +163,29 @@ def _initialize_scope_configs(self) -> None: } } + def _default_scope(self) -> MemoryScope: + default_scope = self.multi_agent_config.default_scope + if isinstance(default_scope, MemoryScope): + return default_scope + + default_scope_str = str(default_scope).upper() + fallback_mapping = { + 'PRIVATE': MemoryScope.PRIVATE, + 'PUBLIC': MemoryScope.PUBLIC, + 'AGENT_GROUP': MemoryScope.AGENT_GROUP, + 'AGENT': MemoryScope.AGENT_GROUP, + 'USER_GROUP': MemoryScope.USER_GROUP, + 'USER': MemoryScope.USER_GROUP, + 'RESTRICTED': MemoryScope.RESTRICTED, + } + if default_scope_str in fallback_mapping: + return fallback_mapping[default_scope_str] + + try: + return MemoryScope(str(default_scope).lower()) + except ValueError: + return MemoryScope.PRIVATE + def determine_scope( self, agent_id: str, @@ -181,6 +204,10 @@ def determine_scope( Determined memory scope """ try: + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled; using default memory scope.") + return self._default_scope() + # Extract content from metadata if available content = metadata.get('content', '') if metadata else '' diff --git a/src/powermem/cli/commands/config.py b/src/powermem/cli/commands/config.py index 24ae3fc18..9a953e002 100644 --- a/src/powermem/cli/commands/config.py +++ b/src/powermem/cli/commands/config.py @@ -266,7 +266,7 @@ def _validate_loaded_config(config: Dict[str, Any], strict: bool) -> Dict[str, A if llm_config: provider = llm_config.get("provider", "") inner_config = llm_config.get("config", {}) - if provider not in ["mock", "ollama"]: + if provider not in ["mock", "noop", "ollama"]: api_key = inner_config.get("api_key") if not api_key: (errors if strict else warnings).append( @@ -356,7 +356,7 @@ def _run_connectivity_checks(config: Dict[str, Any]) -> List[str]: return errors llm_provider = (config.get("llm") or {}).get("provider", "") - if llm_provider and llm_provider not in ("mock", "ollama"): + if llm_provider and llm_provider not in ("mock", "noop", "ollama"): try: if hasattr(memory, "llm") and memory.llm: messages = [{"role": "user", "content": "Say 'test' and nothing else."}] @@ -507,7 +507,11 @@ def test_cmd(ctx: CLIContext, component, json_output): print_info("Testing LLM connection...") # Access the LLM through memory memory = ctx.memory - if hasattr(memory, 'llm') and memory.llm: + if ( + hasattr(memory, 'llm') + and memory.llm + and getattr(memory.llm, "is_noop", False) is not True + ): # Try a simple generation messages = [{"role": "user", "content": "Say 'test' and nothing else."}] llm = memory.llm @@ -525,9 +529,9 @@ def test_cmd(ctx: CLIContext, component, json_output): else: results["llm"] = { "status": "skipped", - "message": "LLM not configured or using mock provider" + "message": "LLM not configured or disabled" } - print_warning("LLM: Skipped (not configured)") + print_warning("LLM: Skipped (not configured or disabled)") except Exception as e: results["llm"] = { "status": "failed", @@ -1353,7 +1357,11 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool print_info("Testing LLM connection...") try: memory = test_ctx.memory - if hasattr(memory, "llm") and memory.llm: + if ( + hasattr(memory, "llm") + and memory.llm + and getattr(memory.llm, "is_noop", False) is not True + ): messages = [{"role": "user", "content": "Say 'test' and nothing else."}] llm = memory.llm if hasattr(llm, "generate_response"): @@ -1362,7 +1370,7 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool llm.generate(messages=messages) print_success("LLM: Connected") else: - print_warning("LLM: Skipped (not configured)") + print_warning("LLM: Skipped (not configured or disabled)") except Exception as e: print_error(f"LLM: Failed - {e}") diff --git a/src/powermem/config_loader.py b/src/powermem/config_loader.py index 05d16df1f..d087eff8d 100644 --- a/src/powermem/config_loader.py +++ b/src/powermem/config_loader.py @@ -16,6 +16,7 @@ from powermem.integrations.embeddings.config.providers import CustomEmbeddingConfig from powermem.integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig from powermem.integrations.llm.config.base import BaseLLMConfig +from powermem.integrations.llm.config.noop import NoopConfig # noqa: F401 - register noop provider from powermem.settings import _DEFAULT_ENV_FILE, settings_config from powermem.utils.utils import detect_system_timezone @@ -219,7 +220,12 @@ def to_config(self) -> Dict[str, Any]: # Determine model name llm_model = self.model if llm_model is None: - llm_model = "qwen-plus" if llm_provider == "qwen" else "gpt-4o-mini" + if llm_provider == "qwen": + llm_model = "qwen-plus" + elif llm_provider == "noop": + llm_model = "noop" + else: + llm_model = "gpt-4o-mini" # 1. Get provider config class from registry config_cls = ( diff --git a/src/powermem/configs.py b/src/powermem/configs.py index 1d30a3375..0c1252212 100644 --- a/src/powermem/configs.py +++ b/src/powermem/configs.py @@ -15,6 +15,7 @@ from powermem.integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig import powermem.integrations.embeddings.config.sparse_providers # noqa: F401 — ensures sparse provider registry is populated from powermem.integrations.llm.config.base import BaseLLMConfig +from powermem.integrations.llm.config.noop import NoopConfig # noqa: F401 - keeps noop provider registered from powermem.integrations.llm.config.qwen import QwenConfig from powermem.storage.config.base import BaseVectorStoreConfig, BaseGraphStoreConfig from powermem.storage.config.sqlite import SQLiteConfig # noqa: F401 — keeps SQLite provider registered diff --git a/src/powermem/core/async_memory.py b/src/powermem/core/async_memory.py index 5be0fe687..176d19539 100644 --- a/src/powermem/core/async_memory.py +++ b/src/powermem/core/async_memory.py @@ -249,6 +249,10 @@ def _get_provider(self, component: str, default: str) -> str: else: return self.config.get(component, {}).get('provider', default) + def _is_llm_disabled(self) -> bool: + """Return True when PowerMem is running without LLM-backed features.""" + return self.llm_provider == "noop" or getattr(self.llm, "is_noop", False) is True + def _get_component_config(self, component: str) -> Dict[str, Any]: """ Helper method to get component configuration uniformly. @@ -313,6 +317,10 @@ async def _extract_facts(self, messages: Any) -> List[str]: Returns: List of extracted facts """ + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping fact extraction.") + return [] + try: # Parse messages into conversation format conversation = parse_messages_for_facts(messages) @@ -382,6 +390,10 @@ async def _decide_memory_actions( Returns: List of memory action dictionaries """ + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping memory action planning.") + return [] + try: if not new_facts: logger.debug("No new facts to process") @@ -496,7 +508,10 @@ async def add( agent_id = agent_id or self.agent_id # Check if intelligent memory should be used - use_infer = infer and isinstance(messages, list) and len(messages) > 0 + llm_disabled = self._is_llm_disabled() + use_infer = infer and isinstance(messages, list) and len(messages) > 0 and not llm_disabled + if infer and llm_disabled: + logger.info("LLM is disabled; falling back to simple add mode.") # If not using intelligent memory, fall back to simple mode if not use_infer: @@ -877,6 +892,10 @@ async def _add_to_graph_async( """ if not self.enable_graph: return None + + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping graph extraction.") + return None # Extract content from messages for graph processing if isinstance(messages, str): @@ -1146,7 +1165,7 @@ async def search( }) # Search in graph store - if self.enable_graph: + if self.enable_graph and not self._is_llm_disabled(): filters = {**(filters or {}), "user_id": user_id, "agent_id": agent_id, "run_id": run_id} graph_results = await asyncio.to_thread(self.graph_store.search, query, filters, limit) return {"results": transformed_results, "relations": graph_results} diff --git a/src/powermem/core/memory.py b/src/powermem/core/memory.py index 948eb5240..cd47ee28d 100644 --- a/src/powermem/core/memory.py +++ b/src/powermem/core/memory.py @@ -801,6 +801,10 @@ def _get_provider(self, component: str, default: str) -> str: provider = self.config.get(component, {}).get('provider') return provider if provider is not None else default + def _is_llm_disabled(self) -> bool: + """Return True when PowerMem is running without LLM-backed features.""" + return self.llm_provider == "noop" or getattr(self.llm, "is_noop", False) is True + def _get_component_config(self, component: str) -> Dict[str, Any]: """ Helper method to get component configuration uniformly. @@ -897,6 +901,10 @@ def _extract_facts(self, messages: Any) -> List[str]: Returns: List of extracted facts """ + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping fact extraction.") + return [] + try: # Parse messages into conversation format conversation = parse_messages_for_facts(messages) @@ -965,6 +973,10 @@ def _decide_memory_actions( Returns: List of memory action dictionaries """ + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping memory action planning.") + return [] + try: if not new_facts: logger.debug("No new facts to process") @@ -1110,7 +1122,10 @@ def add( ) # Check if intelligent memory should be used - use_infer = infer and isinstance(messages, list) and len(messages) > 0 + llm_disabled = self._is_llm_disabled() + use_infer = infer and isinstance(messages, list) and len(messages) > 0 and not llm_disabled + if infer and llm_disabled: + logger.info("LLM is disabled; falling back to simple add mode.") # If not using intelligent memory, fall back to simple mode if not use_infer: @@ -1509,6 +1524,10 @@ def _add_to_graph( """ if not self.enable_graph: return None + + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping graph extraction.") + return None # Extract content from messages for graph processing if isinstance(messages, str): @@ -1844,7 +1863,7 @@ def search( }) # Search in graph store - if self.enable_graph: + if self.enable_graph and not self._is_llm_disabled(): filters = {**(filters or {}), "user_id": user_id, "agent_id": agent_id, "run_id": run_id} graph_results = self.graph_store.search(query, filters, limit) return {"results": transformed_results, "relations": graph_results} diff --git a/src/powermem/integrations/llm/config/noop.py b/src/powermem/integrations/llm/config/noop.py new file mode 100644 index 000000000..298c40293 --- /dev/null +++ b/src/powermem/integrations/llm/config/noop.py @@ -0,0 +1,20 @@ +from typing import Dict, Optional, Union + +from pydantic import Field + +from powermem.integrations.llm.config.base import BaseLLMConfig +from powermem.settings import settings_config + + +class NoopConfig(BaseLLMConfig): + """Configuration for the disabled LLM provider.""" + + _provider_name = "noop" + _class_path = "powermem.integrations.llm.noop.NoopLLM" + + model_config = settings_config("LLM_", extra="allow", env_file=None) + + model: Optional[Union[str, Dict]] = Field( + default="noop", + description="Placeholder model name used when LLM features are disabled.", + ) diff --git a/src/powermem/integrations/llm/factory.py b/src/powermem/integrations/llm/factory.py index 45972e492..fb76a0544 100644 --- a/src/powermem/integrations/llm/factory.py +++ b/src/powermem/integrations/llm/factory.py @@ -7,6 +7,7 @@ from powermem.integrations.llm.config.deepseek import DeepSeekConfig from powermem.integrations.llm.config.gemini import GeminiConfig from powermem.integrations.llm.config.langchain import LangchainConfig +from powermem.integrations.llm.config.noop import NoopConfig from powermem.integrations.llm.config.ollama import OllamaConfig from powermem.integrations.llm.config.openai import OpenAIConfig from powermem.integrations.llm.config.openai_structured import OpenAIStructuredConfig diff --git a/src/powermem/integrations/llm/noop.py b/src/powermem/integrations/llm/noop.py new file mode 100644 index 000000000..67bccbc8e --- /dev/null +++ b/src/powermem/integrations/llm/noop.py @@ -0,0 +1,41 @@ +from typing import Dict, List, Optional, Union + +from powermem.integrations.llm import LLMBase +from powermem.integrations.llm.config.base import BaseLLMConfig +from powermem.integrations.llm.config.noop import NoopConfig + + +class NoopLLM(LLMBase): + """LLM implementation used when language-model features are disabled.""" + + is_noop = True + + def __init__(self, config: Optional[Union[BaseLLMConfig, NoopConfig, Dict]] = None): + if config is None: + config = NoopConfig() + elif isinstance(config, dict): + config = NoopConfig(**config) + elif isinstance(config, BaseLLMConfig) and not isinstance(config, NoopConfig): + config = NoopConfig( + model=config.model or "noop", + temperature=config.temperature, + max_tokens=config.max_tokens, + top_p=config.top_p, + top_k=config.top_k, + ) + + super().__init__(config) + + if not self.config.model: + self.config.model = "noop" + + def generate_response( + self, + messages: List[Dict[str, str]], + tools: Optional[List[Dict]] = None, + tool_choice: str = "auto", + **kwargs, + ): + if tools: + return {"content": "", "tool_calls": []} + return "" diff --git a/src/powermem/intelligence/importance_evaluator.py b/src/powermem/intelligence/importance_evaluator.py index e3d236f4c..359c77153 100644 --- a/src/powermem/intelligence/importance_evaluator.py +++ b/src/powermem/intelligence/importance_evaluator.py @@ -171,7 +171,11 @@ def _llm_based_evaluation( if not self.llm: logger.warning("LLM not initialized, falling back to rule-based evaluation") return self._rule_based_evaluation(content, metadata, context) - + + if getattr(self.llm, "is_noop", False) is True: + logger.info("LLM is disabled; using rule-based importance evaluation.") + return self._rule_based_evaluation(content, metadata, context) + try: # Prepare evaluation prompt prompt = self.prompts.get_importance_evaluation_prompt(content, metadata, context) diff --git a/src/powermem/intelligence/memory_optimizer.py b/src/powermem/intelligence/memory_optimizer.py index f0cbd72c2..191b3556b 100644 --- a/src/powermem/intelligence/memory_optimizer.py +++ b/src/powermem/intelligence/memory_optimizer.py @@ -188,6 +188,10 @@ def compress( "errors": 0 } + if getattr(self.llm, "is_noop", False) is True: + logger.info("LLM is disabled; skipping memory compression.") + return stats + try: # 1. Fetch memories memories = self.storage.get_all_memories(user_id=user_id, limit=1000) diff --git a/src/powermem/intelligence/skill_manager.py b/src/powermem/intelligence/skill_manager.py index 69626922a..47b1fbf6e 100644 --- a/src/powermem/intelligence/skill_manager.py +++ b/src/powermem/intelligence/skill_manager.py @@ -42,6 +42,10 @@ def distill( if user_content is None: return [] + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled; skipping skill distillation") + return [] + try: response = self.llm.generate_response( messages=[ @@ -66,6 +70,10 @@ async def adistill( if user_content is None: return [] + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled; skipping skill distillation") + return [] + try: response = await asyncio.to_thread( self.llm.generate_response, @@ -89,6 +97,10 @@ def merge(self, existing: str, new: str) -> Dict[str, Any]: or ``{"action": "skip"}``. Falls back to ``{"action": "skip"}`` on failure. """ + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled; skipping skill merge") + return {"action": "skip"} + try: response = self.llm.generate_response( messages=[ @@ -105,6 +117,10 @@ async def amerge(self, existing: str, new: str) -> Dict[str, Any]: """Async variant of :meth:`merge`.""" import asyncio + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled; skipping skill merge") + return {"action": "skip"} + try: response = await asyncio.to_thread( self.llm.generate_response, diff --git a/src/powermem/user_memory/query_rewrite/rewriter.py b/src/powermem/user_memory/query_rewrite/rewriter.py index e25b45f32..a061030b2 100644 --- a/src/powermem/user_memory/query_rewrite/rewriter.py +++ b/src/powermem/user_memory/query_rewrite/rewriter.py @@ -74,6 +74,15 @@ def rewrite( is_rewritten=False, ) + if getattr(self.llm, "is_noop", False) is True: + logger.debug("LLM is disabled, skipping query rewrite") + return QueryRewriteResult( + original_query=query, + rewritten_query=query, + is_rewritten=False, + metadata={"skipped_reason": "llm_disabled"}, + ) + try: start_time = time.time() @@ -116,4 +125,3 @@ def rewrite( is_rewritten=False, error=str(e) ) - diff --git a/src/powermem/user_memory/user_memory.py b/src/powermem/user_memory/user_memory.py index 9e51d2e05..1224a717c 100644 --- a/src/powermem/user_memory/user_memory.py +++ b/src/powermem/user_memory/user_memory.py @@ -96,6 +96,13 @@ def __init__( logger.info("UserMemory initialized") + def _is_llm_disabled(self) -> bool: + """Return True when profile extraction should not call an LLM.""" + return ( + getattr(self.memory.llm, "is_noop", False) is True + or self.memory.llm_provider == "noop" + ) + def _filter_messages_by_roles( self, messages: Any, @@ -224,6 +231,12 @@ def add( # Step 2: Extract profile information logger.info(f"Step 2: Extracting profile information for user_id: {user_id}, profile_type: {profile_type}") + if self._is_llm_disabled(): + logger.info("LLM is disabled; skipping user profile extraction.") + result = memory_result.copy() + result["profile_extracted"] = False + return result + # Filter messages by roles for profile extraction filtered_messages = self._filter_messages_by_roles( messages=messages, @@ -775,4 +788,3 @@ def delete_profile( except Exception as e: logger.error(f"Failed to delete profile for user_id: {user_id}: {e}") raise - diff --git a/src/server/models/response.py b/src/server/models/response.py index 3fe808f94..d61336be7 100644 --- a/src/server/models/response.py +++ b/src/server/models/response.py @@ -174,7 +174,7 @@ class DependencyStatus(BaseModel): """Response model for dependency health status""" name: str = Field(..., description="Dependency name") - status: str = Field(..., description="Health status: healthy | degraded | unavailable") + status: str = Field(..., description="Health status: healthy | degraded | unavailable | disabled") latency_ms: Optional[float] = Field(None, description="Connection latency in milliseconds") error_message: Optional[str] = Field(None, description="Error message if unhealthy") last_checked: datetime = Field(default_factory=get_current_datetime, description="Last check timestamp") diff --git a/src/server/utils/health_check.py b/src/server/utils/health_check.py index 0cd9315e8..7453ebd53 100644 --- a/src/server/utils/health_check.py +++ b/src/server/utils/health_check.py @@ -82,6 +82,14 @@ async def check_llm() -> DependencyStatus: error_message="LLM provider not configured", last_checked=datetime.utcnow(), ) + + if str(llm_provider).lower() == "noop": + return DependencyStatus( + name="llm", + status="disabled", + error_message="LLM features are disabled by configuration", + last_checked=datetime.utcnow(), + ) # For now, just check if LLM is configured # In the future, could make a test API call diff --git a/tests/integration/test_noop_llm_mode.py b/tests/integration/test_noop_llm_mode.py new file mode 100644 index 000000000..4c4b7f689 --- /dev/null +++ b/tests/integration/test_noop_llm_mode.py @@ -0,0 +1,97 @@ +import uuid + +import pytest + +from powermem import Memory +from powermem.core.async_memory import AsyncMemory +from powermem.user_memory import UserMemory + + +class _FailingGraphStore: + def add(self, *args, **kwargs): + raise AssertionError("graph add should not be called in no-LLM mode") + + def search(self, *args, **kwargs): + raise AssertionError("graph search should not be called in no-LLM mode") + + +def _sqlite_noop_config(tmp_path): + return { + "vector_store": { + "provider": "sqlite", + "config": { + "database_path": str(tmp_path / "noop_memory.db"), + "collection_name": f"noop_memories_{uuid.uuid4().hex[:8]}", + }, + }, + "llm": { + "provider": "noop", + "config": {"model": "noop"}, + }, + "embedder": { + "provider": "mock", + "config": {"embedding_dims": 16}, + }, + } + + +def test_noop_llm_preserves_basic_memory_crud(tmp_path): + memory = Memory(config=_sqlite_noop_config(tmp_path)) + + add_result = memory.add("User likes black coffee", user_id="user_noop") + assert len(add_result["results"]) == 1 + memory_id = add_result["results"][0]["id"] + + search_result = memory.search("coffee", user_id="user_noop") + assert search_result["results"] + + update_result = memory.update(memory_id, "User likes green tea", user_id="user_noop") + assert update_result is not None + + assert memory.delete(memory_id, user_id="user_noop") is True + assert memory.get(memory_id, user_id="user_noop") is None + + +def test_noop_llm_skips_graph_operations(tmp_path): + memory = Memory(config=_sqlite_noop_config(tmp_path)) + memory.enable_graph = True + memory.graph_store = _FailingGraphStore() + + add_result = memory.add("User likes black coffee", user_id="user_graph_noop") + assert len(add_result["results"]) == 1 + assert "relations" not in add_result + + search_result = memory.search("coffee", user_id="user_graph_noop") + assert search_result["results"] + assert "relations" not in search_result + + +@pytest.mark.asyncio +async def test_noop_llm_preserves_async_memory_crud(tmp_path): + memory = AsyncMemory(config=_sqlite_noop_config(tmp_path)) + + add_result = await memory.add("User likes black coffee", user_id="async_user_noop") + assert len(add_result["results"]) == 1 + memory_id = add_result["results"][0]["id"] + + search_result = await memory.search("coffee", user_id="async_user_noop") + assert search_result["results"] + + update_result = await memory.update(memory_id, "User likes green tea", user_id="async_user_noop") + assert update_result is not None + + assert await memory.delete(memory_id, user_id="async_user_noop") is True + assert await memory.get(memory_id, user_id="async_user_noop") is None + + +def test_user_memory_skips_profile_extraction_when_llm_is_noop(tmp_path): + user_memory = UserMemory(config=_sqlite_noop_config(tmp_path)) + + result = user_memory.add( + messages="I prefer Python for backend services.", + user_id="user_profile_noop", + ) + + assert result["results"] + assert result["profile_extracted"] is False + assert "profile_content" not in result diff --git a/tests/unit/test_noop_llm.py b/tests/unit/test_noop_llm.py new file mode 100644 index 000000000..3ddf6c870 --- /dev/null +++ b/tests/unit/test_noop_llm.py @@ -0,0 +1,60 @@ +import powermem.config_loader as config_loader +import powermem.settings as settings +from powermem.intelligence.importance_evaluator import ImportanceEvaluator +from powermem.intelligence.skill_manager import SkillManager +from powermem.integrations.llm.factory import LLMFactory + + +class _NoopRaisingLLM: + is_noop = True + + def generate_response(self, *args, **kwargs): + raise AssertionError("noop LLM should not be called") + + +def test_noop_llm_factory_returns_disabled_provider(): + llm = LLMFactory.create("noop", {}) + + assert getattr(llm, "is_noop", False) is True + assert llm.generate_response(messages=[{"role": "user", "content": "hello"}]) == "" + assert llm.generate_response( + messages=[{"role": "user", "content": "hello"}], + tools=[{"type": "function", "function": {"name": "extract", "parameters": {}}}], + ) == {"content": "", "tool_calls": []} + + +def test_load_config_from_env_supports_noop_llm(monkeypatch): + monkeypatch.setattr(config_loader, "_DEFAULT_ENV_FILE", None, raising=False) + monkeypatch.setattr(settings, "_DEFAULT_ENV_FILE", None, raising=False) + new_config = dict(config_loader.LLMSettings.model_config) + new_config["env_file"] = None + monkeypatch.setattr(config_loader.LLMSettings, "model_config", new_config) + monkeypatch.setenv("LLM_PROVIDER", "noop") + monkeypatch.delenv("LLM_MODEL", raising=False) + + config = config_loader.load_config_from_env() + + assert config["llm"]["provider"] == "noop" + assert config["llm"]["config"]["model"] == "noop" + + +def test_noop_llm_skips_skill_distillation_and_merge(): + manager = SkillManager(_NoopRaisingLLM()) + + assert ( + manager.distill( + [{"role": "user", "content": "Remember this flow"}], + "2026-06-14", + ) + == [] + ) + assert manager.merge("existing skill", "new skill") == {"action": "skip"} + + +def test_noop_llm_uses_rule_based_importance_evaluation(): + evaluator = ImportanceEvaluator({}, {}) + evaluator.set_llm(_NoopRaisingLLM()) + + score = evaluator.evaluate_importance("Important preference: I love Python") + + assert score > 0 From 61b7066af51d8674ec623395a698a53e249d74fc Mon Sep 17 00:00:00 2001 From: "xuyan.wxy" Date: Tue, 16 Jun 2026 01:13:09 +0800 Subject: [PATCH 2/4] Strengthen no-LLM fallback coverage --- tests/unit/test_noop_llm.py | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/unit/test_noop_llm.py b/tests/unit/test_noop_llm.py index 3ddf6c870..d2ea82160 100644 --- a/tests/unit/test_noop_llm.py +++ b/tests/unit/test_noop_llm.py @@ -1,8 +1,14 @@ +from types import SimpleNamespace + +from powermem.agent.components.scope_controller import ScopeController +from powermem.agent.types import MemoryScope import powermem.config_loader as config_loader import powermem.settings as settings from powermem.intelligence.importance_evaluator import ImportanceEvaluator +from powermem.intelligence.memory_optimizer import MemoryOptimizer from powermem.intelligence.skill_manager import SkillManager from powermem.integrations.llm.factory import LLMFactory +from powermem.user_memory.query_rewrite.rewriter import QueryRewriter class _NoopRaisingLLM: @@ -12,6 +18,11 @@ def generate_response(self, *args, **kwargs): raise AssertionError("noop LLM should not be called") +class _FailingStorage: + def get_all_memories(self, *args, **kwargs): + raise AssertionError("storage should not be read when compression is skipped") + + def test_noop_llm_factory_returns_disabled_provider(): llm = LLMFactory.create("noop", {}) @@ -58,3 +69,48 @@ def test_noop_llm_uses_rule_based_importance_evaluation(): score = evaluator.evaluate_importance("Important preference: I love Python") assert score > 0 + + +def test_noop_llm_skips_query_rewrite(): + rewriter = QueryRewriter(_NoopRaisingLLM(), {"enabled": True}) + + result = rewriter.rewrite( + "coffee preference", + profile_content="The user likes black coffee.", + ) + + assert result.original_query == "coffee preference" + assert result.rewritten_query == "coffee preference" + assert result.is_rewritten is False + assert result.metadata == {"skipped_reason": "llm_disabled"} + + +def test_noop_llm_skips_memory_compression(): + optimizer = MemoryOptimizer(_FailingStorage(), _NoopRaisingLLM()) + + assert optimizer.compress(user_id="user-noop") == { + "total_processed": 0, + "clusters_found": 0, + "compressed_count": 0, + "new_memories_created": 0, + "errors": 0, + } + + +def test_noop_llm_scope_controller_uses_default_scope(): + config = SimpleNamespace( + llm=SimpleNamespace(provider="noop", config={}), + agent_memory=SimpleNamespace( + multi_agent_config=SimpleNamespace(default_scope="public") + ), + ) + controller = ScopeController(config) + controller.llm = _NoopRaisingLLM() + + assert ( + controller.determine_scope( + agent_id="agent-noop", + metadata={"content": "This would normally need scope analysis."}, + ) + is MemoryScope.PUBLIC + ) From 6ff6d7a2e35ba571bf040aac34db8c5326c05df0 Mon Sep 17 00:00:00 2001 From: "xuyan.wxy" Date: Tue, 16 Jun 2026 01:39:09 +0800 Subject: [PATCH 3/4] Fix memory import API call signature --- src/server/api/v1/memories.py | 1 - tests/unit/server/test_memory_export_route_order.py | 11 +++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/server/api/v1/memories.py b/src/server/api/v1/memories.py index f0589a740..fe2568821 100644 --- a/src/server/api/v1/memories.py +++ b/src/server/api/v1/memories.py @@ -618,7 +618,6 @@ async def import_memories( result = service.memory.import_memories( source=content, format=fmt, - is_file=False, user_id=user_id, agent_id=agent_id, ) diff --git a/tests/unit/server/test_memory_export_route_order.py b/tests/unit/server/test_memory_export_route_order.py index b5c1e90e4..e77c33bea 100644 --- a/tests/unit/server/test_memory_export_route_order.py +++ b/tests/unit/server/test_memory_export_route_order.py @@ -7,3 +7,14 @@ def test_export_route_is_defined_before_memory_id_route(): assert source.index('@router.get(\n "/export"') < source.index( '@router.get(\n "/{memory_id}"' ) + + +def test_import_route_calls_memory_import_with_supported_signature(): + source = Path("src/server/api/v1/memories.py").read_text(encoding="utf-8") + import_route = source[ + source.index("async def import_memories") : source.index( + 'message=f"Import completed' + ) + ] + + assert "is_file" not in import_route From 508c309e44d2a02e772d4a0a8f1a89f8fa3e8ef2 Mon Sep 17 00:00:00 2001 From: "xuyan.wxy" Date: Tue, 16 Jun 2026 01:42:07 +0800 Subject: [PATCH 4/4] Fix memory import SDK path --- src/powermem/core/memory.py | 2 +- tests/integration/test_noop_llm_mode.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/powermem/core/memory.py b/src/powermem/core/memory.py index cd47ee28d..5f5f3fdaa 100644 --- a/src/powermem/core/memory.py +++ b/src/powermem/core/memory.py @@ -2716,7 +2716,7 @@ def import_memories( mem_agent_id = agent_id or memory.get('agent_id') self.add( - content=memory['content'], + messages=memory['content'], user_id=mem_user_id, agent_id=mem_agent_id, metadata=memory.get('metadata', {}), diff --git a/tests/integration/test_noop_llm_mode.py b/tests/integration/test_noop_llm_mode.py index 4c4b7f689..d914f3ac2 100644 --- a/tests/integration/test_noop_llm_mode.py +++ b/tests/integration/test_noop_llm_mode.py @@ -1,3 +1,4 @@ +import json import uuid import pytest @@ -52,6 +53,28 @@ def test_noop_llm_preserves_basic_memory_crud(tmp_path): assert memory.get(memory_id, user_id="user_noop") is None +def test_noop_llm_imports_memories(tmp_path): + memory = Memory(config=_sqlite_noop_config(tmp_path)) + source = json.dumps( + [ + { + "content": "Imported no-LLM memory about black coffee", + "metadata": {"source": "import-test"}, + } + ] + ) + + result = memory.import_memories( + source=source, + format="json", + user_id="user_import_noop", + ) + + assert result == {"success": 1, "failed": 0} + search_result = memory.search("black coffee", user_id="user_import_noop") + assert search_result["results"] + + def test_noop_llm_skips_graph_operations(tmp_path): memory = Memory(config=_sqlite_noop_config(tmp_path)) memory.enable_graph = True