mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-04-09 20:42:35 +00:00
Some checks failed
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* chore: update memory management and dependencies - Enhance the memory system by introducing a unified memory API that consolidates short-term, long-term, entity, and external memory functionalities. - Update the `.gitignore` to exclude new memory-related files and blog directories. - Modify `conftest.py` to handle missing imports for vcr stubs more gracefully. - Add new development dependencies in `pyproject.toml` for testing and memory management. - Refactor the `Crew` class to utilize the new unified memory system, replacing deprecated memory attributes. - Implement memory context injection in `LiteAgent` to improve memory recall during agent execution. - Update documentation to reflect changes in memory usage and configuration. * feat: introduce Memory TUI for enhanced memory management - Add a new command to the CLI for launching a Textual User Interface (TUI) to browse and recall memories. - Implement the MemoryTUI class to facilitate user interaction with memory scopes and records. - Enhance the unified memory API by adding a method to list records within a specified scope. - Update `pyproject.toml` to include the `textual` dependency for TUI functionality. - Ensure proper error handling for missing dependencies when accessing the TUI. * feat: implement consolidation flow for memory management - Introduce the ConsolidationFlow class to handle the decision-making process for inserting, updating, or deleting memory records based on new content. - Add new data models: ConsolidationAction and ConsolidationPlan to structure the actions taken during consolidation. - Enhance the memory types with new fields for consolidation thresholds and limits. - Update the unified memory API to utilize the new consolidation flow for managing memory records. - Implement embedding functionality for new content to facilitate similarity checks. - Refactor existing memory analysis methods to integrate with the consolidation process. - Update translations to include prompts for consolidation actions and user interactions. * feat: enhance Memory TUI with Rich markup and improved UI elements - Update the MemoryTUI class to utilize Rich markup for better visual representation of memory scope information. - Introduce a color palette for consistent branding across the TUI interface. - Refactor the CSS styles to improve the layout and aesthetics of the memory browsing experience. - Enhance the display of memory entries, including better formatting for records and importance ratings. - Implement loading indicators and error messages with Rich styling for improved user feedback during recall operations. - Update the action bindings and navigation prompts for a more intuitive user experience. * feat: enhance Crew class memory management and configuration - Update the Crew class to allow for more flexible memory configurations by accepting Memory, MemoryScope, or MemorySlice instances. - Refactor memory initialization logic to support custom memory configurations while maintaining backward compatibility. - Improve documentation for memory-related fields to clarify usage and expectations. - Introduce a recall oversample factor to optimize memory recall processes. - Update related memory types and configurations to ensure consistency across the memory management system. * chore: update dependency overrides and enhance memory management - Added an override for the 'rich' dependency to allow compatibility with 'textual' requirements. - Updated the 'pyproject.toml' and 'uv.lock' files to reflect the new dependency specifications. - Refactored the Crew class to simplify memory configuration handling by allowing any type for the memory attribute. - Improved error messages in the CLI for missing 'textual' dependency to guide users on installation. - Introduced new packages and dependencies in the project to enhance functionality and maintain compatibility. * refactor: enhance thread safety in flow management - Updated LockedListProxy and LockedDictProxy to subclass list and dict respectively, ensuring compatibility with libraries requiring strict type checks. - Improved documentation to clarify the purpose of these proxies and their thread-safe operations. - Ensured that all mutations are protected by locks while reads delegate to the underlying data structures, enhancing concurrency safety. * chore: update dependency versions and improve Python compatibility - Downgraded 'vcrpy' dependency to version 7.0.0 for compatibility. - Enhanced 'uv.lock' to include more granular resolution markers for Python versions and implementations, ensuring better compatibility across different environments. - Updated 'urllib3' and 'selenium' dependencies to specify versions based on Python implementation, improving stability and performance. - Removed deprecated resolution markers for 'fastembed' and streamlined its dependencies for better clarity. * fix linter * chore: update uv.lock for improved dependency management and memory management enhancements - Incremented revision number in uv.lock to reflect changes. - Added a new development dependency group in uv.lock, specifying versions for tools like pytest, mypy, and pre-commit to streamline development workflows. - Enhanced error handling in CLI memory functions to provide clearer feedback on missing dependencies. - Refactored memory management classes to improve type hints and maintainability, ensuring better compatibility with future updates. * fix tests * refactor: remove obsolete RAGStorage tests and clean up error handling - Deleted outdated tests for RAGStorage that were no longer relevant, including tests for client failures, save operation failures, and reset failures. - Cleaned up the test suite to focus on current functionality and improve maintainability. - Ensured that remaining tests continue to validate the expected behavior of knowledge storage components. * fix test * fix texts * fix tests * forcing new commit * fix: add location parameter to Google Vertex embedder configuration for memory integration tests * debugging CI * adding debugging for CI * refactor: remove unnecessary logging for memory checks in agent execution - Eliminated redundant logging statements related to memory checks in the Agent and CrewAgentExecutor classes. - Simplified the memory retrieval logic by directly checking for available memory without logging intermediate states. - Improved code readability and maintainability by reducing clutter in the logging output. * udpating desp * feat: enhance thread safety in LockedListProxy and LockedDictProxy - Added equality comparison methods (__eq__ and __ne__) to LockedListProxy and LockedDictProxy to allow for safe comparison of their contents. - Implemented consistent locking mechanisms to prevent deadlocks during comparisons. - Improved the overall robustness of these proxy classes in multi-threaded environments. * feat: enhance memory functionality in Flows documentation and memory system - Added a new section on memory usage within Flows, detailing built-in methods for storing and recalling memories. - Included an example of a Research and Analyze Flow demonstrating the integration of memory for accumulating knowledge over time. - Updated the Memory documentation to clarify the unified memory system and its capabilities, including adaptive-depth recall and composite scoring. - Introduced a new configuration parameter, `recall_oversample_factor`, to improve the effectiveness of memory retrieval processes. * update docs * refactor: improve memory record handling and pagination in unified memory system - Simplified the `get_record` method in the Memory class by directly accessing the storage's `get_record` method. - Enhanced the `list_records` method to include an `offset` parameter for pagination, allowing users to skip a specified number of records. - Updated documentation for both methods to clarify their functionality and parameters, improving overall code clarity and usability. * test: update memory scope assertions in unified memory tests - Modified assertions in `test_lancedb_list_scopes_get_scope_info` and `test_memory_list_scopes_info_tree` to check for the presence of the "/team" scope instead of the root scope. - Clarified comments to indicate that `list_scopes` returns child scopes rather than the root itself, enhancing test clarity and accuracy. * feat: integrate memory tools for agents and crews - Added functionality to inject memory tools into agents during initialization, enhancing their ability to recall and remember information mid-task. - Implemented a new `_add_memory_tools` method in the Crew class to facilitate the addition of memory tools when memory is available. - Introduced `RecallMemoryTool` and `RememberTool` classes in a new `memory_tools.py` file, providing agents with active recall and memory storage capabilities. - Updated English translations to include descriptions for the new memory tools, improving user guidance on their usage. * refactor: streamline memory recall functionality across agents and tools - Removed the 'depth' parameter from memory recall calls in LiteAgent and Agent classes, simplifying the recall process. - Updated the MemoryTUI to use 'deep' depth by default for more comprehensive memory retrieval. - Enhanced the MemoryScope and MemorySlice classes to default to 'deep' depth, improving recall accuracy. - Introduced a new 'recall_queries' field in QueryAnalysis to optimize semantic vector searches with targeted phrases. - Updated documentation and comments to reflect changes in memory recall behavior and parameters. * refactor: optimize memory management in flow classes - Enhanced memory auto-creation logic in Flow class to prevent unnecessary Memory instance creation for internal flows (RecallFlow, ConsolidationFlow) by introducing a _skip_auto_memory flag. - Removed the deprecated time_hints field from QueryAnalysis and replaced it with a more flexible time_filter field to better handle time-based queries. - Updated documentation and comments to reflect changes in memory handling and query analysis structure, improving clarity and usability. * updates tests * feat: introduce EncodingFlow for enhanced memory encoding pipeline - Added a new EncodingFlow class to orchestrate the encoding process for memory, integrating LLM analysis and embedding. - Updated the Memory class to utilize EncodingFlow for saving content, improving the overall memory management and conflict resolution. - Enhanced the unified memory module to include the new EncodingFlow in its public API, facilitating better memory handling. - Updated tests to ensure proper functionality of the new encoding flow and its integration with existing memory features. * refactor: optimize memory tool integration and recall flow - Streamlined the addition of memory tools in the Agent class by using list comprehension for cleaner code. - Enhanced the RecallFlow class to build task lists more efficiently with list comprehensions, improving readability and performance. - Updated the RecallMemoryTool to utilize list comprehensions for formatting memory results, simplifying the code structure. - Adjusted test assertions in LiteAgent to reflect the default behavior of memory recall depth, ensuring clarity in expected outcomes. * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * chore: gen missing cassette * fix * test: enhance memory extraction test by mocking recall to prevent LLM calls Updated the test for memory extraction to include a mock for the recall method, ensuring that the test focuses on the save path without invoking external LLM calls. This improves test reliability and clarity. * refactor: enhance memory handling by adding agent role parameter Updated memory storage methods across multiple classes to include an optional `agent_role` parameter, improving the context of stored memories. Additionally, modified the initialization of several flow classes to suppress flow events, enhancing performance and reducing unnecessary event triggers. * feat: enhance agent memory functionality with recall and save mechanisms Implemented memory context injection during agent kickoff, allowing for memory recall before execution and passive saving of results afterward. Added new methods to handle memory saving and retrieval, including error handling for memory operations. Updated the BaseAgent class to support dynamic memory resolution and improved memory record structure with source and privacy attributes for better provenance tracking. * test * feat: add utility method to simplify tools field in console formatter Introduced a new static method `_simplify_tools_field` in the console formatter to transform the 'tools' field from full tool objects to a comma-separated string of tool names. This enhancement improves the readability of tool information in the output. * refactor: improve lazy initialization of LLM and embedder in Memory class Refactored the Memory class to implement lazy initialization for the LLM and embedder, ensuring they are only created when first accessed. This change enhances the robustness of the Memory class by preventing initialization failures when constructed without an API key. Additionally, updated error handling to provide clearer guidance for users on resolving initialization issues. * refactor: consolidate memory saving methods for improved efficiency Refactored memory handling across multiple classes to replace individual memory saving calls with a batch method, `remember_many`, enhancing performance and reducing redundancy. Updated related tools and schemas to support single and multiple item memory operations, ensuring a more streamlined interface for memory interactions. Additionally, improved documentation and test coverage for the new functionality. * feat: enhance MemoryTUI with improved layout and entry handling Updated the MemoryTUI class to incorporate a new vertical layout, adding an OptionList for displaying entries and enhancing the detail view for selected records. Introduced methods for populating entry and recall lists, improving user interaction and data presentation. Additionally, refined CSS styles for better visual organization and focus handling. * fix test * feat: inject memory tools into LiteAgent for enhanced functionality Added logic to the LiteAgent class to inject memory tools if memory is configured, ensuring that memory tools are only added if they are not already present. This change improves the agent's capability to utilize memory effectively during execution. * feat: add synchronous execution method to ConsolidationFlow for improved integration Introduced a new `run_sync()` method in the ConsolidationFlow class to facilitate procedural execution of the consolidation pipeline without relying on asynchronous event loops. Updated the EncodingFlow class to utilize this method for conflict resolution, ensuring compatibility within its async context. This change enhances the flow's ability to manage memory records effectively during nested executions. * refactor: update ConsolidationFlow and EncodingFlow for improved async handling Removed the synchronous `run_sync()` method from ConsolidationFlow and refactored the consolidate method in EncodingFlow to be asynchronous. This change allows for direct awaiting of the ConsolidationFlow's kickoff method, enhancing compatibility within the async event loop and preventing nested asyncio.run() issues. Additionally, updated the execution plan to listen for multiple paths, streamlining the consolidation process. * fix: update flow documentation and remove unused ConsolidationFlow Corrected the comment in Flow class regarding internal flows, replacing "ConsolidationFlow" with "EncodingFlow". Removed the ConsolidationFlow class as it is no longer needed, streamlining the memory handling process. Updated related imports and ensured that the memory module reflects these changes, enhancing clarity and maintainability. * feat: enhance memory handling with background saving and query analysis optimization Implemented a background saving mechanism in the Memory class to allow non-blocking memory operations, improving performance during high-load scenarios. Added a query analysis threshold to skip LLM calls for short queries, optimizing recall efficiency. Updated related methods and documentation to reflect these changes, ensuring a more responsive and efficient memory management system. * fix test * fix test * fix: handle synchronous fallback for save operations in Memory class Updated the Memory class to implement a synchronous fallback mechanism for save operations when the background thread pool is shut down. This change ensures that late save requests still succeed, improving reliability in memory management during shutdown scenarios. * feat: implement HITL learning features in human feedback decorator Added support for learning from human feedback in the human feedback decorator. Introduced parameters to enable lesson distillation and pre-review of outputs based on past feedback. Updated related tests to ensure proper functionality of the learning mechanism, including memory interactions and default LLM usage. --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
999 lines
34 KiB
Python
999 lines
34 KiB
Python
"""Tests for unified memory: types, storage, Memory, MemoryScope, MemorySlice, Flow integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from crewai.utilities.printer import Printer
|
|
from crewai.memory.types import (
|
|
MemoryConfig,
|
|
MemoryMatch,
|
|
MemoryRecord,
|
|
ScopeInfo,
|
|
compute_composite_score,
|
|
)
|
|
|
|
|
|
# --- Types ---
|
|
|
|
|
|
def test_memory_record_defaults() -> None:
|
|
r = MemoryRecord(content="hello")
|
|
assert r.content == "hello"
|
|
assert r.scope == "/"
|
|
assert r.categories == []
|
|
assert r.importance == 0.5
|
|
assert r.embedding is None
|
|
assert r.id is not None
|
|
assert isinstance(r.created_at, datetime)
|
|
|
|
|
|
def test_memory_match() -> None:
|
|
r = MemoryRecord(content="x", scope="/a")
|
|
m = MemoryMatch(record=r, score=0.9, match_reasons=["semantic"])
|
|
assert m.record.content == "x"
|
|
assert m.score == 0.9
|
|
assert m.match_reasons == ["semantic"]
|
|
|
|
|
|
def test_scope_info() -> None:
|
|
i = ScopeInfo(path="/", record_count=5, categories=["c1"], child_scopes=["/a"])
|
|
assert i.path == "/"
|
|
assert i.record_count == 5
|
|
assert i.categories == ["c1"]
|
|
assert i.child_scopes == ["/a"]
|
|
|
|
|
|
def test_memory_config() -> None:
|
|
c = MemoryConfig()
|
|
assert c.recency_weight == 0.3
|
|
assert c.semantic_weight == 0.5
|
|
assert c.importance_weight == 0.2
|
|
|
|
|
|
# --- LanceDB storage ---
|
|
|
|
|
|
@pytest.fixture
|
|
def lancedb_path(tmp_path: Path) -> Path:
|
|
return tmp_path / "mem"
|
|
|
|
|
|
def test_lancedb_save_search(lancedb_path: Path) -> None:
|
|
from crewai.memory.storage.lancedb_storage import LanceDBStorage
|
|
|
|
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
|
|
r = MemoryRecord(
|
|
content="test content",
|
|
scope="/foo",
|
|
categories=["cat1"],
|
|
importance=0.8,
|
|
embedding=[0.1, 0.2, 0.3, 0.4],
|
|
)
|
|
storage.save([r])
|
|
results = storage.search(
|
|
[0.1, 0.2, 0.3, 0.4],
|
|
scope_prefix="/foo",
|
|
limit=5,
|
|
)
|
|
assert len(results) == 1
|
|
rec, score = results[0]
|
|
assert rec.content == "test content"
|
|
assert rec.scope == "/foo"
|
|
assert score >= 0.0
|
|
|
|
|
|
def test_lancedb_delete_count(lancedb_path: Path) -> None:
|
|
from crewai.memory.storage.lancedb_storage import LanceDBStorage
|
|
|
|
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
|
|
r = MemoryRecord(content="x", scope="/", embedding=[0.0] * 4)
|
|
storage.save([r])
|
|
assert storage.count() == 1
|
|
n = storage.delete(scope_prefix="/")
|
|
assert n >= 1
|
|
assert storage.count() == 0
|
|
|
|
|
|
def test_lancedb_list_scopes_get_scope_info(lancedb_path: Path) -> None:
|
|
from crewai.memory.storage.lancedb_storage import LanceDBStorage
|
|
|
|
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
|
|
storage.save([
|
|
MemoryRecord(content="a", scope="/", embedding=[0.0] * 4),
|
|
MemoryRecord(content="b", scope="/team", embedding=[0.0] * 4),
|
|
])
|
|
scopes = storage.list_scopes("/")
|
|
assert "/team" in scopes # list_scopes returns children, not root itself
|
|
info = storage.get_scope_info("/")
|
|
assert info.record_count >= 1
|
|
assert info.path == "/"
|
|
|
|
|
|
# --- Memory class (with mock embedder, no LLM for explicit remember) ---
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_embedder() -> MagicMock:
|
|
"""Embedder mock that returns one embedding per input text (batch-aware)."""
|
|
m = MagicMock()
|
|
m.side_effect = lambda texts: [[0.1] * 1536 for _ in texts]
|
|
return m
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_with_storage(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
import os
|
|
os.environ.pop("OPENAI_API_KEY", None)
|
|
|
|
|
|
def test_memory_remember_recall_shallow(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
m = Memory(
|
|
storage=str(tmp_path / "db"),
|
|
llm=MagicMock(),
|
|
embedder=mock_embedder,
|
|
)
|
|
# Explicit scope/categories/importance so no LLM analysis
|
|
r = m.remember(
|
|
"We decided to use Python.",
|
|
scope="/project",
|
|
categories=["decision"],
|
|
importance=0.7,
|
|
)
|
|
assert r.content == "We decided to use Python."
|
|
assert r.scope == "/project"
|
|
|
|
matches = m.recall("Python decision", scope="/project", limit=5, depth="shallow")
|
|
assert len(matches) >= 1
|
|
assert "Python" in matches[0].record.content or "python" in matches[0].record.content.lower()
|
|
|
|
|
|
def test_memory_forget(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
m = Memory(storage=str(tmp_path / "db2"), llm=MagicMock(), embedder=mock_embedder)
|
|
m.remember("To forget", scope="/x", categories=[], importance=0.5, metadata={})
|
|
assert m._storage.count("/x") >= 1
|
|
n = m.forget(scope="/x")
|
|
assert n >= 1
|
|
assert m._storage.count("/x") == 0
|
|
|
|
|
|
def test_memory_scope_slice(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
mem = Memory(storage=str(tmp_path / "db3"), llm=MagicMock(), embedder=mock_embedder)
|
|
sc = mem.scope("/agent/1")
|
|
assert sc._root in ("/agent/1", "/agent/1/")
|
|
sl = mem.slice(["/a", "/b"], read_only=True)
|
|
assert sl._read_only is True
|
|
assert "/a" in sl._scopes and "/b" in sl._scopes
|
|
|
|
|
|
def test_memory_list_scopes_info_tree(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
m = Memory(storage=str(tmp_path / "db4"), llm=MagicMock(), embedder=mock_embedder)
|
|
m.remember("Root", scope="/", categories=[], importance=0.5, metadata={})
|
|
m.remember("Team note", scope="/team", categories=[], importance=0.5, metadata={})
|
|
scopes = m.list_scopes("/")
|
|
assert "/team" in scopes # list_scopes returns children, not root itself
|
|
info = m.info("/")
|
|
assert info.record_count >= 1
|
|
tree = m.tree("/", max_depth=2)
|
|
assert "/" in tree or "0 records" in tree or "1 records" in tree
|
|
|
|
|
|
# --- MemoryScope ---
|
|
|
|
|
|
def test_memory_scope_remember_recall(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.memory.memory_scope import MemoryScope
|
|
|
|
mem = Memory(storage=str(tmp_path / "db5"), llm=MagicMock(), embedder=mock_embedder)
|
|
scope = MemoryScope(mem, "/crew/1")
|
|
scope.remember("Scoped note", scope="/", categories=[], importance=0.5, metadata={})
|
|
results = scope.recall("note", limit=5, depth="shallow")
|
|
assert len(results) >= 1
|
|
|
|
|
|
# --- MemorySlice recall (read-only) ---
|
|
|
|
|
|
def test_memory_slice_recall(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.memory.memory_scope import MemorySlice
|
|
|
|
mem = Memory(storage=str(tmp_path / "db6"), llm=MagicMock(), embedder=mock_embedder)
|
|
mem.remember("In scope A", scope="/a", categories=[], importance=0.5, metadata={})
|
|
sl = MemorySlice(mem, ["/a"], read_only=True)
|
|
matches = sl.recall("scope", limit=5, depth="shallow")
|
|
assert isinstance(matches, list)
|
|
|
|
|
|
def test_memory_slice_remember_raises_when_read_only(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.memory.memory_scope import MemorySlice
|
|
|
|
mem = Memory(storage=str(tmp_path / "db7"), llm=MagicMock(), embedder=mock_embedder)
|
|
sl = MemorySlice(mem, ["/a"], read_only=True)
|
|
with pytest.raises(PermissionError):
|
|
sl.remember("x", scope="/a")
|
|
|
|
|
|
# --- Flow memory ---
|
|
|
|
|
|
def test_flow_has_default_memory() -> None:
|
|
"""Flow auto-creates a Memory instance when none is provided."""
|
|
from crewai.flow.flow import Flow
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
class DefaultFlow(Flow):
|
|
pass
|
|
|
|
f = DefaultFlow()
|
|
assert f.memory is not None
|
|
assert isinstance(f.memory, Memory)
|
|
|
|
|
|
def test_flow_recall_remember_raise_when_memory_explicitly_none() -> None:
|
|
"""Flow raises ValueError when memory is explicitly set to None."""
|
|
from crewai.flow.flow import Flow
|
|
|
|
class NoMemoryFlow(Flow):
|
|
memory = None
|
|
|
|
f = NoMemoryFlow()
|
|
# Explicitly set to None after __init__ auto-creates
|
|
f.memory = None
|
|
with pytest.raises(ValueError, match="No memory configured"):
|
|
f.recall("query")
|
|
with pytest.raises(ValueError, match="No memory configured"):
|
|
f.remember("content")
|
|
|
|
|
|
def test_flow_recall_remember_with_memory(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
from crewai.flow.flow import Flow
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
mem = Memory(storage=str(tmp_path / "flow_db"), llm=MagicMock(), embedder=mock_embedder)
|
|
|
|
class FlowWithMemory(Flow):
|
|
memory = mem
|
|
|
|
f = FlowWithMemory()
|
|
f.remember("Flow remembered this", scope="/flow", categories=[], importance=0.6, metadata={})
|
|
results = f.recall("remembered", limit=5, depth="shallow")
|
|
assert len(results) >= 1
|
|
|
|
|
|
# --- extract_memories ---
|
|
|
|
|
|
def test_memory_extract_memories_returns_list_from_llm(tmp_path: Path) -> None:
|
|
"""Memory.extract_memories() delegates to LLM and returns list of strings."""
|
|
from crewai.memory.analyze import ExtractedMemories
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
mock_llm = MagicMock()
|
|
mock_llm.supports_function_calling.return_value = True
|
|
mock_llm.call.return_value = ExtractedMemories(
|
|
memories=["We use Python for the backend.", "API rate limit is 100/min."]
|
|
)
|
|
|
|
mem = Memory(
|
|
storage=str(tmp_path / "extract_db"),
|
|
llm=mock_llm,
|
|
embedder=MagicMock(return_value=[[0.1] * 1536]),
|
|
)
|
|
result = mem.extract_memories("Task: Build API. Result: We used Python and set rate limit 100/min.")
|
|
assert result == ["We use Python for the backend.", "API rate limit is 100/min."]
|
|
mock_llm.call.assert_called_once()
|
|
call_kw = mock_llm.call.call_args[1]
|
|
assert call_kw.get("response_model") == ExtractedMemories
|
|
|
|
|
|
def test_memory_extract_memories_empty_content_returns_empty_list(tmp_path: Path) -> None:
|
|
"""Memory.extract_memories() with empty/whitespace content returns [] without calling LLM."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
mock_llm = MagicMock()
|
|
mem = Memory(storage=str(tmp_path / "empty_db"), llm=mock_llm, embedder=MagicMock())
|
|
assert mem.extract_memories("") == []
|
|
assert mem.extract_memories(" \n ") == []
|
|
mock_llm.call.assert_not_called()
|
|
|
|
|
|
def test_executor_save_to_memory_calls_extract_then_remember_per_item() -> None:
|
|
"""_save_to_memory calls memory.extract_memories(raw) then memory.remember(m) for each."""
|
|
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
|
|
from crewai.agents.parser import AgentFinish
|
|
|
|
mock_memory = MagicMock()
|
|
mock_memory.extract_memories.return_value = ["Fact A.", "Fact B."]
|
|
|
|
mock_agent = MagicMock()
|
|
mock_agent.memory = mock_memory
|
|
mock_agent._logger = MagicMock()
|
|
mock_agent.role = "Researcher"
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.description = "Do research"
|
|
mock_task.expected_output = "A report"
|
|
|
|
class MinimalExecutor(CrewAgentExecutorMixin):
|
|
crew = None
|
|
agent = mock_agent
|
|
task = mock_task
|
|
iterations = 0
|
|
max_iter = 1
|
|
messages = []
|
|
_i18n = MagicMock()
|
|
_printer = Printer()
|
|
|
|
executor = MinimalExecutor()
|
|
executor._save_to_memory(
|
|
AgentFinish(thought="", output="We found X and Y.", text="We found X and Y.")
|
|
)
|
|
|
|
raw_expected = "Task: Do research\nAgent: Researcher\nExpected result: A report\nResult: We found X and Y."
|
|
mock_memory.extract_memories.assert_called_once_with(raw_expected)
|
|
mock_memory.remember_many.assert_called_once()
|
|
saved_contents = mock_memory.remember_many.call_args.args[0]
|
|
assert saved_contents == ["Fact A.", "Fact B."]
|
|
|
|
|
|
def test_executor_save_to_memory_skips_delegation_output() -> None:
|
|
"""_save_to_memory does nothing when output contains delegate action."""
|
|
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
|
|
from crewai.agents.parser import AgentFinish
|
|
from crewai.utilities.string_utils import sanitize_tool_name
|
|
|
|
mock_memory = MagicMock()
|
|
mock_agent = MagicMock()
|
|
mock_agent.memory = mock_memory
|
|
mock_agent._logger = MagicMock()
|
|
mock_task = MagicMock(description="Task", expected_output="Out")
|
|
|
|
class MinimalExecutor(CrewAgentExecutorMixin):
|
|
crew = None
|
|
agent = mock_agent
|
|
task = mock_task
|
|
iterations = 0
|
|
max_iter = 1
|
|
messages = []
|
|
_i18n = MagicMock()
|
|
_printer = Printer()
|
|
|
|
delegate_text = f"Action: {sanitize_tool_name('Delegate work to coworker')}"
|
|
full_text = delegate_text + " rest"
|
|
executor = MinimalExecutor()
|
|
executor._save_to_memory(
|
|
AgentFinish(thought="", output=full_text, text=full_text)
|
|
)
|
|
|
|
mock_memory.extract_memories.assert_not_called()
|
|
mock_memory.remember.assert_not_called()
|
|
|
|
|
|
def test_memory_scope_extract_memories_delegates() -> None:
|
|
"""MemoryScope.extract_memories delegates to underlying Memory."""
|
|
from crewai.memory.memory_scope import MemoryScope
|
|
|
|
mock_memory = MagicMock()
|
|
mock_memory.extract_memories.return_value = ["Scoped fact."]
|
|
scope = MemoryScope(mock_memory, "/agent/1")
|
|
result = scope.extract_memories("Some content")
|
|
mock_memory.extract_memories.assert_called_once_with("Some content")
|
|
assert result == ["Scoped fact."]
|
|
|
|
|
|
def test_memory_slice_extract_memories_delegates() -> None:
|
|
"""MemorySlice.extract_memories delegates to underlying Memory."""
|
|
from crewai.memory.memory_scope import MemorySlice
|
|
|
|
mock_memory = MagicMock()
|
|
mock_memory.extract_memories.return_value = ["Sliced fact."]
|
|
sl = MemorySlice(mock_memory, ["/a", "/b"], read_only=True)
|
|
result = sl.extract_memories("Some content")
|
|
mock_memory.extract_memories.assert_called_once_with("Some content")
|
|
assert result == ["Sliced fact."]
|
|
|
|
|
|
def test_flow_extract_memories_raises_when_memory_explicitly_none() -> None:
|
|
"""Flow.extract_memories raises ValueError when memory is explicitly set to None."""
|
|
from crewai.flow.flow import Flow
|
|
|
|
f = Flow()
|
|
f.memory = None
|
|
with pytest.raises(ValueError, match="No memory configured"):
|
|
f.extract_memories("some content")
|
|
|
|
|
|
def test_flow_extract_memories_delegates_when_memory_present() -> None:
|
|
"""Flow.extract_memories delegates to flow memory and returns list."""
|
|
from crewai.flow.flow import Flow
|
|
|
|
mock_memory = MagicMock()
|
|
mock_memory.extract_memories.return_value = ["Flow fact 1.", "Flow fact 2."]
|
|
|
|
class FlowWithMemory(Flow):
|
|
memory = mock_memory
|
|
|
|
f = FlowWithMemory()
|
|
result = f.extract_memories("content here")
|
|
mock_memory.extract_memories.assert_called_once_with("content here")
|
|
assert result == ["Flow fact 1.", "Flow fact 2."]
|
|
|
|
|
|
# --- Composite scoring ---
|
|
|
|
|
|
def test_composite_score_brand_new_memory() -> None:
|
|
"""Brand-new memory has decay ~ 1.0; composite = 0.5*0.8 + 0.3*1.0 + 0.2*0.7 = 0.84."""
|
|
config = MemoryConfig()
|
|
record = MemoryRecord(
|
|
content="test",
|
|
scope="/",
|
|
importance=0.7,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
score, reasons = compute_composite_score(record, 0.8, config)
|
|
assert 0.82 <= score <= 0.86
|
|
assert "semantic" in reasons
|
|
assert "recency" in reasons
|
|
assert "importance" in reasons
|
|
|
|
|
|
def test_composite_score_old_memory_decayed() -> None:
|
|
"""Memory 60 days old (2 half-lives) has decay = 0.25; composite ~ 0.575."""
|
|
config = MemoryConfig(recency_half_life_days=30)
|
|
old_date = datetime.utcnow() - timedelta(days=60)
|
|
record = MemoryRecord(
|
|
content="old",
|
|
scope="/",
|
|
importance=0.5,
|
|
created_at=old_date,
|
|
)
|
|
score, reasons = compute_composite_score(record, 0.8, config)
|
|
assert 0.55 <= score <= 0.60
|
|
assert "semantic" in reasons
|
|
assert "recency" not in reasons # decay 0.25 is not > 0.5
|
|
|
|
|
|
def test_composite_score_reranks_results(
|
|
tmp_path: Path, mock_embedder: MagicMock
|
|
) -> None:
|
|
"""Same semantic score: high-importance recent memory ranks first."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
# Use same dim as default LanceDB (1536) so storage does not overwrite embedding
|
|
emb = [0.1] * 1536
|
|
mem = Memory(
|
|
storage=str(tmp_path / "rerank_db"),
|
|
llm=MagicMock(),
|
|
embedder=MagicMock(return_value=[emb]),
|
|
)
|
|
# Save both records directly to storage (bypass encoding flow)
|
|
# to test composite scoring in isolation without consolidation merging them.
|
|
record_high = MemoryRecord(
|
|
content="Important decision",
|
|
scope="/",
|
|
categories=[],
|
|
importance=1.0,
|
|
embedding=emb,
|
|
)
|
|
mem._storage.save([record_high])
|
|
old = datetime.utcnow() - timedelta(days=90)
|
|
record_low = MemoryRecord(
|
|
content="Old trivial note",
|
|
scope="/",
|
|
importance=0.1,
|
|
created_at=old,
|
|
embedding=emb,
|
|
)
|
|
mem._storage.save([record_low])
|
|
|
|
matches = mem.recall("decision", scope="/", limit=5, depth="shallow")
|
|
assert len(matches) >= 2
|
|
# Top result should be the high-importance recent one (stored via remember)
|
|
assert "Important" in matches[0].record.content or "important" in matches[0].record.content.lower()
|
|
|
|
|
|
def test_composite_score_match_reasons_populated() -> None:
|
|
"""match_reasons includes recency for fresh, importance for high-importance; omits for old/low."""
|
|
config = MemoryConfig()
|
|
fresh_high = MemoryRecord(
|
|
content="x",
|
|
importance=0.9,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
score1, reasons1 = compute_composite_score(fresh_high, 0.5, config)
|
|
assert "semantic" in reasons1
|
|
assert "recency" in reasons1
|
|
assert "importance" in reasons1
|
|
|
|
old_low = MemoryRecord(
|
|
content="y",
|
|
importance=0.1,
|
|
created_at=datetime.utcnow() - timedelta(days=60),
|
|
)
|
|
score2, reasons2 = compute_composite_score(old_low, 0.5, config)
|
|
assert "semantic" in reasons2
|
|
assert "recency" not in reasons2
|
|
assert "importance" not in reasons2
|
|
|
|
|
|
def test_composite_score_custom_config() -> None:
|
|
"""Zero recency/importance weights => composite equals semantic score."""
|
|
config = MemoryConfig(
|
|
recency_weight=0.0,
|
|
semantic_weight=1.0,
|
|
importance_weight=0.0,
|
|
)
|
|
record = MemoryRecord(
|
|
content="any",
|
|
importance=0.9,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
score, reasons = compute_composite_score(record, 0.73, config)
|
|
assert score == pytest.approx(0.73, rel=1e-5)
|
|
assert "semantic" in reasons
|
|
|
|
|
|
# --- LLM fallback ---
|
|
|
|
|
|
def test_analyze_for_save_llm_failure_returns_defaults() -> None:
|
|
"""When LLM raises, analyze_for_save returns safe defaults."""
|
|
from crewai.memory.analyze import MemoryAnalysis, analyze_for_save
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
llm.call.side_effect = RuntimeError("API rate limit")
|
|
result = analyze_for_save(
|
|
"some content",
|
|
existing_scopes=["/", "/project"],
|
|
existing_categories=["cat1"],
|
|
llm=llm,
|
|
)
|
|
assert isinstance(result, MemoryAnalysis)
|
|
assert result.suggested_scope == "/"
|
|
assert result.categories == []
|
|
assert result.importance == 0.5
|
|
assert result.extracted_metadata.entities == []
|
|
assert result.extracted_metadata.dates == []
|
|
assert result.extracted_metadata.topics == []
|
|
|
|
|
|
def test_extract_memories_llm_failure_returns_raw() -> None:
|
|
"""When LLM raises, extract_memories_from_content returns [content]."""
|
|
from crewai.memory.analyze import extract_memories_from_content
|
|
|
|
llm = MagicMock()
|
|
llm.call.side_effect = RuntimeError("Network error")
|
|
content = "Task result: We chose PostgreSQL."
|
|
result = extract_memories_from_content(content, llm)
|
|
assert result == [content]
|
|
|
|
|
|
def test_analyze_query_llm_failure_returns_defaults() -> None:
|
|
"""When LLM raises, analyze_query returns safe defaults with available scopes."""
|
|
from crewai.memory.analyze import QueryAnalysis, analyze_query
|
|
|
|
llm = MagicMock()
|
|
llm.call.side_effect = RuntimeError("Timeout")
|
|
result = analyze_query(
|
|
"what did we decide?",
|
|
available_scopes=["/", "/project", "/team", "/company", "/other", "/extra"],
|
|
scope_info=None,
|
|
llm=llm,
|
|
)
|
|
assert isinstance(result, QueryAnalysis)
|
|
assert result.keywords == []
|
|
assert result.complexity == "simple"
|
|
assert result.suggested_scopes == ["/", "/project", "/team", "/company", "/other"]
|
|
|
|
|
|
def test_remember_survives_llm_failure(
|
|
tmp_path: Path, mock_embedder: MagicMock
|
|
) -> None:
|
|
"""When the LLM raises during parallel_analyze, remember() still saves with defaults."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
llm = MagicMock()
|
|
llm.call.side_effect = RuntimeError("LLM unavailable")
|
|
mem = Memory(
|
|
storage=str(tmp_path / "fallback_db"),
|
|
llm=llm,
|
|
embedder=mock_embedder,
|
|
)
|
|
record = mem.remember("We decided to use PostgreSQL.")
|
|
assert record.content == "We decided to use PostgreSQL."
|
|
assert record.scope == "/"
|
|
assert record.categories == []
|
|
assert record.importance == 0.5
|
|
assert record.id is not None
|
|
assert mem._storage.count() == 1
|
|
|
|
|
|
# --- Agent.kickoff() memory integration ---
|
|
|
|
|
|
def test_agent_kickoff_memory_recall_and_save(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
"""Agent.kickoff() with memory should recall before execution and save after."""
|
|
from unittest.mock import Mock, patch
|
|
|
|
from crewai.agent.core import Agent
|
|
from crewai.llm import LLM
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.types.usage_metrics import UsageMetrics
|
|
|
|
# Create a real memory with mock embedder
|
|
mem = Memory(
|
|
storage=str(tmp_path / "agent_kickoff_db"),
|
|
llm=MagicMock(),
|
|
embedder=mock_embedder,
|
|
)
|
|
|
|
# Pre-populate a memory record
|
|
mem.remember("The team uses PostgreSQL.", scope="/", categories=["database"], importance=0.8)
|
|
|
|
# Create mock LLM for the agent
|
|
mock_llm = Mock(spec=LLM)
|
|
mock_llm.call.return_value = "Final Answer: PostgreSQL is the database."
|
|
mock_llm.stop = []
|
|
mock_llm.supports_stop_words.return_value = False
|
|
mock_llm.supports_function_calling.return_value = False
|
|
mock_llm.get_token_usage_summary.return_value = UsageMetrics(
|
|
total_tokens=10, prompt_tokens=5, completion_tokens=5,
|
|
cached_prompt_tokens=0, successful_requests=1,
|
|
)
|
|
|
|
agent = Agent(
|
|
role="Tester",
|
|
goal="Test memory integration",
|
|
backstory="You test things.",
|
|
llm=mock_llm,
|
|
memory=mem,
|
|
verbose=False,
|
|
)
|
|
|
|
# Mock recall to verify it's called, but return real results
|
|
with patch.object(mem, "recall", wraps=mem.recall) as recall_mock, \
|
|
patch.object(mem, "extract_memories", return_value=["PostgreSQL is used."]) as extract_mock, \
|
|
patch.object(mem, "remember_many", wraps=mem.remember_many) as remember_many_mock:
|
|
result = agent.kickoff("What database do we use?")
|
|
|
|
assert result is not None
|
|
assert result.raw is not None
|
|
|
|
# Verify recall was called (passive memory injection)
|
|
recall_mock.assert_called_once()
|
|
|
|
# Verify extract_memories and remember_many were called (passive batch save)
|
|
extract_mock.assert_called_once()
|
|
raw_content = extract_mock.call_args.args[0]
|
|
assert "Input:" in raw_content
|
|
assert "Agent:" in raw_content
|
|
assert "Result:" in raw_content
|
|
|
|
# remember_many was called with the extracted memories
|
|
remember_many_mock.assert_called_once()
|
|
saved_contents = remember_many_mock.call_args.args[0]
|
|
assert "PostgreSQL is used." in saved_contents
|
|
|
|
|
|
# --- Batch EncodingFlow tests ---
|
|
|
|
|
|
def test_batch_embed_single_call(tmp_path: Path) -> None:
|
|
"""remember_many with 3 items should call the embedder exactly once with all 3 texts."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
embedder = MagicMock()
|
|
embedder.side_effect = lambda texts: [[0.1] * 1536 for _ in texts]
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
mem.remember_many(
|
|
["Fact A.", "Fact B.", "Fact C."],
|
|
scope="/test",
|
|
categories=["test"],
|
|
importance=0.5,
|
|
)
|
|
mem.drain_writes() # wait for background save
|
|
# The embedder should have been called exactly once with all 3 texts
|
|
embedder.assert_called_once()
|
|
texts_arg = embedder.call_args.args[0]
|
|
assert len(texts_arg) == 3
|
|
assert texts_arg == ["Fact A.", "Fact B.", "Fact C."]
|
|
|
|
|
|
def test_intra_batch_dedup_drops_near_identical(tmp_path: Path) -> None:
|
|
"""remember_many with 3 identical strings should store only 1 record."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
embedder = MagicMock()
|
|
# All identical embeddings -> cosine similarity = 1.0
|
|
embedder.side_effect = lambda texts: [[0.5] * 1536 for _ in texts]
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
mem.remember_many(
|
|
[
|
|
"CrewAI ensures reliable operation.",
|
|
"CrewAI ensures reliable operation.",
|
|
"CrewAI ensures reliable operation.",
|
|
],
|
|
scope="/test",
|
|
categories=["reliability"],
|
|
importance=0.7,
|
|
)
|
|
mem.drain_writes() # wait for background save
|
|
assert mem._storage.count() == 1
|
|
|
|
|
|
def test_intra_batch_dedup_keeps_merely_similar(tmp_path: Path) -> None:
|
|
"""remember_many with distinct items should keep all of them."""
|
|
from crewai.memory.unified_memory import Memory
|
|
import math
|
|
|
|
# Return different embeddings for different texts
|
|
call_count = 0
|
|
|
|
def varying_embedder(texts: list[str]) -> list[list[float]]:
|
|
nonlocal call_count
|
|
result = []
|
|
for i, _ in enumerate(texts):
|
|
# Create orthogonal-ish embeddings so similarity is low
|
|
emb = [0.0] * 1536
|
|
idx = (call_count + i) % 1536
|
|
emb[idx] = 1.0
|
|
result.append(emb)
|
|
call_count += len(texts)
|
|
return result
|
|
|
|
embedder = MagicMock(side_effect=varying_embedder)
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
mem.remember_many(
|
|
["CrewAI handles complex tasks.", "Python is the best language."],
|
|
scope="/test",
|
|
categories=["tech"],
|
|
importance=0.6,
|
|
)
|
|
mem.drain_writes() # wait for background save
|
|
assert mem._storage.count() == 2
|
|
|
|
|
|
def test_batch_consolidation_deduplicates_against_storage(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Pre-insert a record, then remember_many with same + new content."""
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.memory.analyze import ConsolidationPlan
|
|
|
|
emb = [0.1] * 1536
|
|
embedder = MagicMock()
|
|
embedder.side_effect = lambda texts: [emb for _ in texts]
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = True
|
|
# After intra-batch dedup (identical embeddings), only 1 item survives.
|
|
# That item hits parallel_analyze which calls analyze_for_consolidation.
|
|
# The single-item call returns a ConsolidationPlan directly.
|
|
llm.call.return_value = ConsolidationPlan(
|
|
actions=[], insert_new=False, insert_reason="duplicate"
|
|
)
|
|
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
# Pre-insert
|
|
from crewai.memory.types import MemoryRecord
|
|
|
|
mem._storage.save([
|
|
MemoryRecord(content="CrewAI is great.", scope="/test", importance=0.7, embedding=emb),
|
|
])
|
|
assert mem._storage.count() == 1
|
|
|
|
# remember_many with the same content + a new one (all identical embeddings)
|
|
mem.remember_many(
|
|
["CrewAI is great.", "CrewAI is wonderful."],
|
|
scope="/test",
|
|
categories=["review"],
|
|
importance=0.7,
|
|
)
|
|
mem.drain_writes() # wait for background save
|
|
# Intra-batch dedup fires: same embedding = 1.0 >= 0.98, so item 1 is dropped.
|
|
# The remaining item finds the pre-existing record (similarity 1.0 >= 0.85).
|
|
# LLM says don't insert -> no new records. Total stays at 1.
|
|
assert mem._storage.count() == 1
|
|
|
|
|
|
def test_parallel_find_similar_runs_all_searches(tmp_path: Path) -> None:
|
|
"""remember_many with 3 distinct items should run 3 storage searches."""
|
|
from unittest.mock import patch
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
call_count = 0
|
|
|
|
def distinct_embedder(texts: list[str]) -> list[list[float]]:
|
|
"""Return unique embeddings per text so dedup doesn't drop them."""
|
|
nonlocal call_count
|
|
result = []
|
|
for i, _ in enumerate(texts):
|
|
emb = [0.0] * 1536
|
|
emb[(call_count + i) % 1536] = 1.0
|
|
result.append(emb)
|
|
call_count += len(texts)
|
|
return result
|
|
|
|
embedder = MagicMock(side_effect=distinct_embedder)
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
with patch.object(mem._storage, "search", wraps=mem._storage.search) as search_mock:
|
|
mem.remember_many(
|
|
["Alpha fact.", "Beta fact.", "Gamma fact."],
|
|
scope="/test",
|
|
categories=["test"],
|
|
importance=0.5,
|
|
)
|
|
mem.drain_writes() # wait for background save
|
|
# All 3 items should trigger a storage search
|
|
assert search_mock.call_count == 3
|
|
|
|
|
|
def test_single_remember_uses_batch_flow(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
"""Single remember() should work through the batch flow (batch of 1)."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=mock_embedder)
|
|
|
|
record = mem.remember(
|
|
"Single fact.",
|
|
scope="/project",
|
|
categories=["decision"],
|
|
importance=0.8,
|
|
)
|
|
assert record is not None
|
|
assert record.content == "Single fact."
|
|
assert record.scope == "/project"
|
|
assert record.importance == 0.8
|
|
assert mem._storage.count() == 1
|
|
|
|
|
|
def test_parallel_analyze_runs_concurrent_calls(tmp_path: Path) -> None:
|
|
"""remember_many with 3 items needing LLM should make 3 concurrent LLM calls."""
|
|
from unittest.mock import call
|
|
from crewai.memory.unified_memory import Memory
|
|
from crewai.memory.analyze import MemoryAnalysis, ExtractedMetadata
|
|
|
|
call_count = 0
|
|
|
|
def distinct_embedder(texts: list[str]) -> list[list[float]]:
|
|
"""Return unique embeddings per text so dedup doesn't drop them."""
|
|
nonlocal call_count
|
|
result = []
|
|
for i, _ in enumerate(texts):
|
|
emb = [0.0] * 1536
|
|
emb[(call_count + i) % 1536] = 1.0
|
|
result.append(emb)
|
|
call_count += len(texts)
|
|
return result
|
|
|
|
embedder = MagicMock(side_effect=distinct_embedder)
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = True
|
|
# Return a valid MemoryAnalysis for field resolution calls
|
|
llm.call.return_value = MemoryAnalysis(
|
|
suggested_scope="/inferred",
|
|
categories=["auto"],
|
|
importance=0.6,
|
|
extracted_metadata=ExtractedMetadata(),
|
|
)
|
|
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
# No scope/categories/importance -> all 3 need field resolution (Group C)
|
|
mem.remember_many(["Fact A.", "Fact B.", "Fact C."])
|
|
mem.drain_writes() # wait for background save
|
|
# Each item triggers one analyze_for_save call -> 3 parallel LLM calls
|
|
assert llm.call.call_count == 3
|
|
assert mem._storage.count() == 3
|
|
|
|
|
|
# --- Non-blocking save tests ---
|
|
|
|
|
|
def test_remember_many_returns_immediately(tmp_path: Path) -> None:
|
|
"""remember_many() should return an empty list immediately (non-blocking)."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
call_count = 0
|
|
|
|
def distinct_embedder(texts: list[str]) -> list[list[float]]:
|
|
nonlocal call_count
|
|
result = []
|
|
for i, _ in enumerate(texts):
|
|
emb = [0.0] * 1536
|
|
emb[(call_count + i) % 1536] = 1.0
|
|
result.append(emb)
|
|
call_count += len(texts)
|
|
return result
|
|
|
|
embedder = MagicMock(side_effect=distinct_embedder)
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=embedder)
|
|
|
|
result = mem.remember_many(
|
|
["Fact A.", "Fact B."],
|
|
scope="/test",
|
|
categories=["test"],
|
|
importance=0.5,
|
|
)
|
|
# Returns immediately with empty list (save is in background)
|
|
assert result == []
|
|
# After draining, records should exist
|
|
mem.drain_writes()
|
|
assert mem._storage.count() == 2
|
|
|
|
|
|
def test_recall_drains_pending_writes(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
"""recall() should automatically wait for pending background saves."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=mock_embedder)
|
|
|
|
# Submit a background save
|
|
mem.remember_many(
|
|
["Python is great."],
|
|
scope="/test",
|
|
categories=["lang"],
|
|
importance=0.7,
|
|
)
|
|
# Recall should drain the pending save first, then find the record
|
|
matches = mem.recall("Python", scope="/test", limit=5, depth="shallow")
|
|
assert len(matches) >= 1
|
|
assert "Python" in matches[0].record.content
|
|
|
|
|
|
def test_close_drains_and_shuts_down(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
|
"""close() should drain pending saves and shut down the pool."""
|
|
from crewai.memory.unified_memory import Memory
|
|
|
|
llm = MagicMock()
|
|
llm.supports_function_calling.return_value = False
|
|
mem = Memory(storage=str(tmp_path / "db"), llm=llm, embedder=mock_embedder)
|
|
|
|
mem.remember_many(
|
|
["Important fact."],
|
|
scope="/test",
|
|
categories=["test"],
|
|
importance=0.9,
|
|
)
|
|
mem.close()
|
|
# After close, records should be persisted
|
|
assert mem._storage.count() == 1
|