Merge branch 'main' into gl/feat/agent-skills

This commit is contained in:
Greyson Lalonde
2026-03-05 19:52:10 -05:00
245 changed files with 45164 additions and 28245 deletions

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"Pillow~=10.4.0",
"pypdf~=4.0.0",
"Pillow~=12.1.1",
"pypdf~=6.7.5",
"python-magic>=0.4.27",
"aiocache~=0.12.3",
"aiofiles~=24.1.0",

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.9.3"
__version__ = "1.10.1"

View File

@@ -8,12 +8,10 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"lancedb~=0.5.4",
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.9.3",
"lancedb~=0.5.4",
"crewai==1.10.1",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -110,7 +108,7 @@ stagehand = [
"stagehand>=0.4.1",
]
github = [
"gitpython==3.1.38",
"gitpython>=3.1.41,<4",
"PyGithub==1.59.1",
]
rag = [

View File

@@ -291,4 +291,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.9.3"
__version__ = "1.10.1"

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from pydantic.types import StringConstraints
import requests
load_dotenv()

View File

@@ -1,7 +1,7 @@
import os
from crewai import Agent, Crew, Task
from multion_tool import MultiOnTool # type: ignore[import-not-found]
from multion_tool import MultiOnTool # type: ignore[import-not-found]
os.environ["OPENAI_API_KEY"] = "Your Key"

View File

@@ -17,11 +17,11 @@ Usage:
import os
from crewai import Agent, Crew, Process, Task
from crewai.utilities.printer import Printer
from dotenv import load_dotenv
from stagehand.schemas import AvailableModel # type: ignore[import-untyped]
from crewai import Agent, Crew, Process, Task
from crewai_tools import StagehandTool

View File

@@ -20117,18 +20117,6 @@
"humanized_name": "Web Automation Tool",
"init_params_schema": {
"$defs": {
"AvailableModel": {
"enum": [
"gpt-4o",
"gpt-4o-mini",
"claude-3-5-sonnet-latest",
"claude-3-7-sonnet-latest",
"computer-use-preview",
"gemini-2.0-flash"
],
"title": "AvailableModel",
"type": "string"
},
"EnvVar": {
"properties": {
"default": {
@@ -20206,17 +20194,6 @@
"default": null,
"title": "Model Api Key"
},
"model_name": {
"anyOf": [
{
"$ref": "#/$defs/AvailableModel"
},
{
"type": "null"
}
],
"default": "claude-3-7-sonnet-latest"
},
"project_id": {
"anyOf": [
{

View File

@@ -21,11 +21,13 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
# Data Handling
"chromadb~=1.1.0",
"tokenizers~=0.20.3",
"tokenizers>=0.21,<1",
"openpyxl~=3.1.5",
# Authentication and Security
"python-dotenv~=1.1.1",
"pyjwt>=2.9.0,<3",
# TUI
"textual>=7.5.0",
# Configuration and Utils
"click~=8.1.7",
"appdirs~=1.4.4",
@@ -36,10 +38,12 @@ dependencies = [
"json5~=0.10.0",
"portalocker~=2.7.0",
"pydantic-settings~=2.10.1",
"httpx~=0.28.1",
"mcp~=1.26.0",
"uv~=0.9.13",
"aiosqlite~=0.21.0",
"pyyaml~=6.0",
"lancedb>=0.29.2",
]
[project.urls]
@@ -50,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.9.3",
"crewai-tools==1.10.1",
]
embeddings = [
"tiktoken~=0.8.0"
@@ -63,7 +67,7 @@ openpyxl = [
]
mem0 = ["mem0ai~=0.1.94"]
docling = [
"docling~=2.63.0",
"docling~=2.75.0",
]
qdrant = [
"qdrant-client[fastembed]~=1.14.3",
@@ -85,7 +89,7 @@ bedrock = [
"boto3~=1.40.45",
]
google-genai = [
"google-genai~=1.49.0",
"google-genai~=1.65.0",
]
azure-ai-inference = [
"azure-ai-inference~=1.0.0b9",

View File

@@ -40,7 +40,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.9.3"
__version__ = "1.10.1"
_telemetry_submitted = False
@@ -71,6 +71,25 @@ def _track_install_async() -> None:
_track_install_async()
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),
}
def __getattr__(name: str) -> Any:
"""Lazily import heavy modules (e.g. Memory → lancedb) on first access."""
if name in _LAZY_IMPORTS:
module_path, attr = _LAZY_IMPORTS[name]
import importlib
mod = importlib.import_module(module_path)
val = getattr(mod, attr)
globals()[name] = val
return val
raise AttributeError(f"module 'crewai' has no attribute {name!r}")
__all__ = [
"LLM",
"Agent",
@@ -80,6 +99,7 @@ __all__ = [
"Flow",
"Knowledge",
"LLMGuardrail",
"Memory",
"Process",
"Task",
"TaskOutput",

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import MutableMapping
import concurrent.futures
from functools import lru_cache
import ssl
import time
@@ -138,14 +139,17 @@ def fetch_agent_card(
ttl_hash = int(time.time() // cache_ttl)
return _fetch_agent_card_cached(endpoint, auth_hash, timeout, ttl_hash)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coro = afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout)
try:
return loop.run_until_complete(
afetch_agent_card(endpoint=endpoint, auth=auth, timeout=timeout)
)
finally:
loop.close()
asyncio.get_running_loop()
has_running_loop = True
except RuntimeError:
has_running_loop = False
if has_running_loop:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
async def afetch_agent_card(
@@ -203,14 +207,17 @@ def _fetch_agent_card_cached(
"""Cached sync version of fetch_agent_card."""
auth = _auth_store.get(auth_hash)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coro = _afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout)
try:
return loop.run_until_complete(
_afetch_agent_card_impl(endpoint=endpoint, auth=auth, timeout=timeout)
)
finally:
loop.close()
asyncio.get_running_loop()
has_running_loop = True
except RuntimeError:
has_running_loop = False
if has_running_loop:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
@cached(ttl=300, serializer=PickleSerializer()) # type: ignore[untyped-decorator]

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Callable, MutableMapping
import concurrent.futures
from contextlib import asynccontextmanager
import logging
from typing import TYPE_CHECKING, Any, Final, Literal
@@ -194,56 +195,43 @@ def execute_a2a_delegation(
Returns:
TaskStateResult with status, result/error, history, and agent_card.
Raises:
RuntimeError: If called from an async context with a running event loop.
"""
coro = aexecute_a2a_delegation(
endpoint=endpoint,
auth=auth,
timeout=timeout,
task_description=task_description,
context=context,
context_id=context_id,
task_id=task_id,
reference_task_ids=reference_task_ids,
metadata=metadata,
extensions=extensions,
conversation_history=conversation_history,
agent_id=agent_id,
agent_role=agent_role,
agent_branch=agent_branch,
response_model=response_model,
turn_number=turn_number,
updates=updates,
from_task=from_task,
from_agent=from_agent,
skill_id=skill_id,
client_extensions=client_extensions,
transport=transport,
accepted_output_modes=accepted_output_modes,
input_files=input_files,
)
try:
asyncio.get_running_loop()
raise RuntimeError(
"execute_a2a_delegation() cannot be called from an async context. "
"Use 'await aexecute_a2a_delegation()' instead."
)
except RuntimeError as e:
if "no running event loop" not in str(e).lower():
raise
has_running_loop = True
except RuntimeError:
has_running_loop = False
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
aexecute_a2a_delegation(
endpoint=endpoint,
auth=auth,
timeout=timeout,
task_description=task_description,
context=context,
context_id=context_id,
task_id=task_id,
reference_task_ids=reference_task_ids,
metadata=metadata,
extensions=extensions,
conversation_history=conversation_history,
agent_id=agent_id,
agent_role=agent_role,
agent_branch=agent_branch,
response_model=response_model,
turn_number=turn_number,
updates=updates,
from_task=from_task,
from_agent=from_agent,
skill_id=skill_id,
client_extensions=client_extensions,
transport=transport,
accepted_output_modes=accepted_output_modes,
input_files=input_files,
)
)
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
finally:
loop.close()
if has_running_loop:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
async def aexecute_a2a_delegation(

View File

@@ -9,11 +9,9 @@ import time
from typing import (
TYPE_CHECKING,
Any,
Final,
Literal,
cast,
)
from urllib.parse import urlparse
from pydantic import (
BaseModel,
@@ -63,17 +61,8 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.lite_agent_output import LiteAgentOutput
from crewai.llms.base_llm import BaseLLM
from crewai.mcp import (
MCPClient,
MCPServerConfig,
MCPServerHTTP,
MCPServerSSE,
MCPServerStdio,
)
from crewai.mcp.transports.http import HTTPTransport
from crewai.mcp.transports.sse import SSETransport
from crewai.mcp.transports.stdio import StdioTransport
from crewai.memory.contextual.contextual_memory import ContextualMemory
from crewai.mcp import MCPServerConfig
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.skills.loader import activate_skill, discover_skills
@@ -116,18 +105,8 @@ if TYPE_CHECKING:
from crewai.utilities.types import LLMMessage
# MCP Connection timeout constants (in seconds)
MCP_CONNECTION_TIMEOUT: Final[int] = 10
MCP_TOOL_EXECUTION_TIMEOUT: Final[int] = 30
MCP_DISCOVERY_TIMEOUT: Final[int] = 15
MCP_MAX_RETRIES: Final[int] = 3
_passthrough_exceptions: tuple[type[Exception], ...] = ()
# Simple in-memory cache for MCP tool schemas (duration: 5 minutes)
_mcp_schema_cache: dict[str, Any] = {}
_cache_ttl: Final[int] = 300 # 5 minutes
class Agent(BaseAgent):
"""Represents an agent in a system.
@@ -159,7 +138,7 @@ class Agent(BaseAgent):
model_config = ConfigDict()
_times_executed: int = PrivateAttr(default=0)
_mcp_clients: list[Any] = PrivateAttr(default_factory=list)
_mcp_resolver: MCPToolResolver | None = PrivateAttr(default=None)
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
max_execution_time: int | None = Field(
default=None,
@@ -345,19 +324,12 @@ class Agent(BaseAgent):
self.skills = resolved if resolved else None
def _is_any_available_memory(self) -> bool:
"""Check if any memory is available."""
if not self.crew:
return False
memory_attributes = [
"memory",
"_short_term_memory",
"_long_term_memory",
"_entity_memory",
"_external_memory",
]
return any(getattr(self.crew, attr) for attr in memory_attributes)
"""Check if unified memory is available (agent or crew)."""
if getattr(self, "memory", None):
return True
if self.crew and getattr(self.crew, "_memory", None):
return True
return False
def _supports_native_tool_calling(self, tools: list[BaseTool]) -> bool:
"""Check if the LLM supports native function calling with the given tools.
@@ -421,15 +393,16 @@ class Agent(BaseAgent):
memory = ""
try:
contextual_memory = ContextualMemory(
self.crew._short_term_memory,
self.crew._long_term_memory,
self.crew._entity_memory,
self.crew._external_memory,
agent=self,
task=task,
unified_memory = getattr(self, "memory", None) or (
getattr(self.crew, "_memory", None) if self.crew else None
)
memory = contextual_memory.build_context_for_task(task, context or "")
if unified_memory is not None:
query = task.description
matches = unified_memory.recall(query, limit=5)
if matches:
memory = "Relevant memories:\n" + "\n".join(
m.format() for m in matches
)
if memory.strip() != "":
task_prompt += self.i18n.slice("memory").format(memory=memory)
@@ -660,17 +633,16 @@ class Agent(BaseAgent):
memory = ""
try:
contextual_memory = ContextualMemory(
self.crew._short_term_memory,
self.crew._long_term_memory,
self.crew._entity_memory,
self.crew._external_memory,
agent=self,
task=task,
)
memory = await contextual_memory.abuild_context_for_task(
task, context or ""
unified_memory = getattr(self, "memory", None) or (
getattr(self.crew, "_memory", None) if self.crew else None
)
if unified_memory is not None:
query = task.description
matches = unified_memory.recall(query, limit=5)
if matches:
memory = "Relevant memories:\n" + "\n".join(
m.format() for m in matches
)
if memory.strip() != "":
task_prompt += self.i18n.slice("memory").format(memory=memory)
@@ -910,7 +882,11 @@ class Agent(BaseAgent):
respect_context_window=self.respect_context_window,
request_within_rpm_limit=rpm_limit_fn,
callbacks=[TokenCalcHandler(self._token_process)],
response_model=task.response_model if task else None,
response_model=(
task.response_model or task.output_pydantic or task.output_json
)
if task
else None,
)
def _update_executor_parameters(
@@ -939,7 +915,11 @@ class Agent(BaseAgent):
self.agent_executor.stop = stop_words
self.agent_executor.tools_names = get_tool_names(tools)
self.agent_executor.tools_description = render_text_description_and_args(tools)
self.agent_executor.response_model = task.response_model if task else None
self.agent_executor.response_model = (
(task.response_model or task.output_pydantic or task.output_json)
if task
else None
)
self.agent_executor.tools_handler = self.tools_handler
self.agent_executor.request_within_rpm_limit = rpm_limit_fn
@@ -972,544 +952,17 @@ class Agent(BaseAgent):
def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
"""Convert MCP server references/configs to CrewAI tools.
Supports both string references (backwards compatible) and structured
configuration objects (MCPServerStdio, MCPServerHTTP, MCPServerSSE).
Args:
mcps: List of MCP server references (strings) or configurations.
Returns:
List of BaseTool instances from MCP servers.
Delegates to :class:`~crewai.mcp.tool_resolver.MCPToolResolver`.
"""
all_tools = []
clients = []
for mcp_config in mcps:
if isinstance(mcp_config, str):
tools = self._get_mcp_tools_from_string(mcp_config)
else:
tools, client = self._get_native_mcp_tools(mcp_config)
if client:
clients.append(client)
all_tools.extend(tools)
# Store clients for cleanup
self._mcp_clients.extend(clients)
return all_tools
self._cleanup_mcp_clients()
self._mcp_resolver = MCPToolResolver(agent=self, logger=self._logger)
return self._mcp_resolver.resolve(mcps)
def _cleanup_mcp_clients(self) -> None:
"""Cleanup MCP client connections after task execution."""
if not self._mcp_clients:
return
async def _disconnect_all() -> None:
for client in self._mcp_clients:
if client and hasattr(client, "connected") and client.connected:
await client.disconnect()
try:
asyncio.run(_disconnect_all())
except Exception as e:
self._logger.log("error", f"Error during MCP client cleanup: {e}")
finally:
self._mcp_clients.clear()
def _get_mcp_tools_from_string(self, mcp_ref: str) -> list[BaseTool]:
"""Get tools from legacy string-based MCP references.
This method maintains backwards compatibility with string-based
MCP references (https://... and crewai-amp:...).
Args:
mcp_ref: String reference to MCP server.
Returns:
List of BaseTool instances.
"""
if mcp_ref.startswith("crewai-amp:"):
return self._get_amp_mcp_tools(mcp_ref)
if mcp_ref.startswith("https://"):
return self._get_external_mcp_tools(mcp_ref)
return []
def _get_external_mcp_tools(self, mcp_ref: str) -> list[BaseTool]:
"""Get tools from external HTTPS MCP server with graceful error handling."""
from crewai.tools.mcp_tool_wrapper import MCPToolWrapper
# Parse server URL and optional tool name
if "#" in mcp_ref:
server_url, specific_tool = mcp_ref.split("#", 1)
else:
server_url, specific_tool = mcp_ref, None
server_params = {"url": server_url}
server_name = self._extract_server_name(server_url)
try:
# Get tool schemas with timeout and error handling
tool_schemas = self._get_mcp_tool_schemas(server_params)
if not tool_schemas:
self._logger.log(
"warning", f"No tools discovered from MCP server: {server_url}"
)
return []
tools = []
for tool_name, schema in tool_schemas.items():
# Skip if specific tool requested and this isn't it
if specific_tool and tool_name != specific_tool:
continue
try:
wrapper = MCPToolWrapper(
mcp_server_params=server_params,
tool_name=tool_name,
tool_schema=schema,
server_name=server_name,
)
tools.append(wrapper)
except Exception as e:
self._logger.log(
"warning",
f"Failed to create MCP tool wrapper for {tool_name}: {e}",
)
continue
if specific_tool and not tools:
self._logger.log(
"warning",
f"Specific tool '{specific_tool}' not found on MCP server: {server_url}",
)
return cast(list[BaseTool], tools)
except Exception as e:
self._logger.log(
"warning", f"Failed to connect to MCP server {server_url}: {e}"
)
return []
def _get_native_mcp_tools(
self, mcp_config: MCPServerConfig
) -> tuple[list[BaseTool], Any | None]:
"""Get tools from MCP server using structured configuration.
This method creates an MCP client based on the configuration type,
connects to the server, discovers tools, applies filtering, and
returns wrapped tools along with the client instance for cleanup.
Args:
mcp_config: MCP server configuration (MCPServerStdio, MCPServerHTTP, or MCPServerSSE).
Returns:
Tuple of (list of BaseTool instances, MCPClient instance for cleanup).
"""
from crewai.tools.base_tool import BaseTool
from crewai.tools.mcp_native_tool import MCPNativeTool
transport: StdioTransport | HTTPTransport | SSETransport
if isinstance(mcp_config, MCPServerStdio):
transport = StdioTransport(
command=mcp_config.command,
args=mcp_config.args,
env=mcp_config.env,
)
server_name = f"{mcp_config.command}_{'_'.join(mcp_config.args)}"
elif isinstance(mcp_config, MCPServerHTTP):
transport = HTTPTransport(
url=mcp_config.url,
headers=mcp_config.headers,
streamable=mcp_config.streamable,
)
server_name = self._extract_server_name(mcp_config.url)
elif isinstance(mcp_config, MCPServerSSE):
transport = SSETransport(
url=mcp_config.url,
headers=mcp_config.headers,
)
server_name = self._extract_server_name(mcp_config.url)
else:
raise ValueError(f"Unsupported MCP server config type: {type(mcp_config)}")
client = MCPClient(
transport=transport,
cache_tools_list=mcp_config.cache_tools_list,
)
async def _setup_client_and_list_tools() -> list[dict[str, Any]]:
"""Async helper to connect and list tools in same event loop."""
try:
if not client.connected:
await client.connect()
tools_list = await client.list_tools()
try:
await client.disconnect()
# Small delay to allow background tasks to finish cleanup
# This helps prevent "cancel scope in different task" errors
# when asyncio.run() closes the event loop
await asyncio.sleep(0.1)
except Exception as e:
self._logger.log("error", f"Error during disconnect: {e}")
return tools_list
except Exception as e:
if client.connected:
await client.disconnect()
await asyncio.sleep(0.1)
raise RuntimeError(
f"Error during setup client and list tools: {e}"
) from e
try:
try:
asyncio.get_running_loop()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, _setup_client_and_list_tools()
)
tools_list = future.result()
except RuntimeError:
try:
tools_list = asyncio.run(_setup_client_and_list_tools())
except RuntimeError as e:
error_msg = str(e).lower()
if "cancel scope" in error_msg or "task" in error_msg:
raise ConnectionError(
"MCP connection failed due to event loop cleanup issues. "
"This may be due to authentication errors or server unavailability."
) from e
except asyncio.CancelledError as e:
raise ConnectionError(
"MCP connection was cancelled. This may indicate an authentication "
"error or server unavailability."
) from e
if mcp_config.tool_filter:
filtered_tools = []
for tool in tools_list:
if callable(mcp_config.tool_filter):
try:
from crewai.mcp.filters import ToolFilterContext
context = ToolFilterContext(
agent=self,
server_name=server_name,
run_context=None,
)
if mcp_config.tool_filter(context, tool): # type: ignore[call-arg, arg-type]
filtered_tools.append(tool)
except (TypeError, AttributeError):
if mcp_config.tool_filter(tool): # type: ignore[call-arg, arg-type]
filtered_tools.append(tool)
else:
# Not callable - include tool
filtered_tools.append(tool)
tools_list = filtered_tools
tools = []
for tool_def in tools_list:
tool_name = tool_def.get("name", "")
if not tool_name:
continue
# Convert inputSchema to Pydantic model if present
args_schema = None
if tool_def.get("inputSchema"):
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
tool_schema = {
"description": tool_def.get("description", ""),
"args_schema": args_schema,
}
try:
native_tool = MCPNativeTool(
mcp_client=client,
tool_name=tool_name,
tool_schema=tool_schema,
server_name=server_name,
)
tools.append(native_tool)
except Exception as e:
self._logger.log("error", f"Failed to create native MCP tool: {e}")
continue
return cast(list[BaseTool], tools), client
except Exception as e:
if client.connected:
asyncio.run(client.disconnect())
raise RuntimeError(f"Failed to get native MCP tools: {e}") from e
def _get_amp_mcp_tools(self, amp_ref: str) -> list[BaseTool]:
"""Get tools from CrewAI AMP MCP marketplace."""
# Parse: "crewai-amp:mcp-name" or "crewai-amp:mcp-name#tool_name"
amp_part = amp_ref.replace("crewai-amp:", "")
if "#" in amp_part:
mcp_name, specific_tool = amp_part.split("#", 1)
else:
mcp_name, specific_tool = amp_part, None
# Call AMP API to get MCP server URLs
mcp_servers = self._fetch_amp_mcp_servers(mcp_name)
tools = []
for server_config in mcp_servers:
server_ref = server_config["url"]
if specific_tool:
server_ref += f"#{specific_tool}"
server_tools = self._get_external_mcp_tools(server_ref)
tools.extend(server_tools)
return tools
@staticmethod
def _extract_server_name(server_url: str) -> str:
"""Extract clean server name from URL for tool prefixing."""
parsed = urlparse(server_url)
domain = parsed.netloc.replace(".", "_")
path = parsed.path.replace("/", "_").strip("_")
return f"{domain}_{path}" if path else domain
def _get_mcp_tool_schemas(
self, server_params: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""Get tool schemas from MCP server for wrapper creation with caching."""
server_url = server_params["url"]
# Check cache first
cache_key = server_url
current_time = time.time()
if cache_key in _mcp_schema_cache:
cached_data, cache_time = _mcp_schema_cache[cache_key]
if current_time - cache_time < _cache_ttl:
self._logger.log(
"debug", f"Using cached MCP tool schemas for {server_url}"
)
return cached_data # type: ignore[no-any-return]
try:
schemas = asyncio.run(self._get_mcp_tool_schemas_async(server_params))
# Cache successful results
_mcp_schema_cache[cache_key] = (schemas, current_time)
return schemas
except Exception as e:
# Log warning but don't raise - this allows graceful degradation
self._logger.log(
"warning", f"Failed to get MCP tool schemas from {server_url}: {e}"
)
return {}
async def _get_mcp_tool_schemas_async(
self, server_params: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""Async implementation of MCP tool schema retrieval with timeouts and retries."""
server_url = server_params["url"]
return await self._retry_mcp_discovery(
self._discover_mcp_tools_with_timeout, server_url
)
async def _retry_mcp_discovery(
self, operation_func: Any, server_url: str
) -> dict[str, dict[str, Any]]:
"""Retry MCP discovery operation with exponential backoff, avoiding try-except in loop."""
last_error = None
for attempt in range(MCP_MAX_RETRIES):
# Execute single attempt outside try-except loop structure
result, error, should_retry = await self._attempt_mcp_discovery(
operation_func, server_url
)
# Success case - return immediately
if result is not None:
return result
# Non-retryable error - raise immediately
if not should_retry:
raise RuntimeError(error)
# Retryable error - continue with backoff
last_error = error
if attempt < MCP_MAX_RETRIES - 1:
wait_time = 2**attempt # Exponential backoff
await asyncio.sleep(wait_time)
raise RuntimeError(
f"Failed to discover MCP tools after {MCP_MAX_RETRIES} attempts: {last_error}"
)
@staticmethod
async def _attempt_mcp_discovery(
operation_func: Any, server_url: str
) -> tuple[dict[str, dict[str, Any]] | None, str, bool]:
"""Attempt single MCP discovery operation and return (result, error_message, should_retry)."""
try:
result = await operation_func(server_url)
return result, "", False
except ImportError:
return (
None,
"MCP library not available. Please install with: pip install mcp",
False,
)
except asyncio.TimeoutError:
return (
None,
f"MCP discovery timed out after {MCP_DISCOVERY_TIMEOUT} seconds",
True,
)
except Exception as e:
error_str = str(e).lower()
# Classify errors as retryable or non-retryable
if "authentication" in error_str or "unauthorized" in error_str:
return None, f"Authentication failed for MCP server: {e!s}", False
if "connection" in error_str or "network" in error_str:
return None, f"Network connection failed: {e!s}", True
if "json" in error_str or "parsing" in error_str:
return None, f"Server response parsing error: {e!s}", True
return None, f"MCP discovery error: {e!s}", False
async def _discover_mcp_tools_with_timeout(
self, server_url: str
) -> dict[str, dict[str, Any]]:
"""Discover MCP tools with timeout wrapper."""
return await asyncio.wait_for(
self._discover_mcp_tools(server_url), timeout=MCP_DISCOVERY_TIMEOUT
)
async def _discover_mcp_tools(self, server_url: str) -> dict[str, dict[str, Any]]:
"""Discover tools from MCP server with proper timeout handling."""
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async with streamablehttp_client(server_url) as (read, write, _):
async with ClientSession(read, write) as session:
# Initialize the connection with timeout
await asyncio.wait_for(
session.initialize(), timeout=MCP_CONNECTION_TIMEOUT
)
# List available tools with timeout
tools_result = await asyncio.wait_for(
session.list_tools(),
timeout=MCP_DISCOVERY_TIMEOUT - MCP_CONNECTION_TIMEOUT,
)
schemas = {}
for tool in tools_result.tools:
args_schema = None
if hasattr(tool, "inputSchema") and tool.inputSchema:
args_schema = self._json_schema_to_pydantic(
sanitize_tool_name(tool.name), tool.inputSchema
)
schemas[sanitize_tool_name(tool.name)] = {
"description": getattr(tool, "description", ""),
"args_schema": args_schema,
}
return schemas
def _json_schema_to_pydantic(
self, tool_name: str, json_schema: dict[str, Any]
) -> type:
"""Convert JSON Schema to Pydantic model for tool arguments.
Args:
tool_name: Name of the tool (used for model naming)
json_schema: JSON Schema dict with 'properties', 'required', etc.
Returns:
Pydantic BaseModel class
"""
from pydantic import Field, create_model
properties = json_schema.get("properties", {})
required_fields = json_schema.get("required", [])
field_definitions: dict[str, Any] = {}
for field_name, field_schema in properties.items():
field_type = self._json_type_to_python(field_schema)
field_description = field_schema.get("description", "")
is_required = field_name in required_fields
if is_required:
field_definitions[field_name] = (
field_type,
Field(..., description=field_description),
)
else:
field_definitions[field_name] = (
field_type | None,
Field(default=None, description=field_description),
)
model_name = f"{tool_name.replace('-', '_').replace(' ', '_')}Schema"
return create_model(model_name, **field_definitions) # type: ignore[no-any-return]
def _json_type_to_python(self, field_schema: dict[str, Any]) -> type:
"""Convert JSON Schema type to Python type.
Args:
field_schema: JSON Schema field definition
Returns:
Python type
"""
json_type = field_schema.get("type")
if "anyOf" in field_schema:
types: list[type] = []
for option in field_schema["anyOf"]:
if "const" in option:
types.append(str)
else:
types.append(self._json_type_to_python(option))
unique_types = list(set(types))
if len(unique_types) > 1:
result: Any = unique_types[0]
for t in unique_types[1:]:
result = result | t
return result # type: ignore[no-any-return]
return unique_types[0]
type_mapping: dict[str | None, type] = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
}
return type_mapping.get(json_type, Any)
@staticmethod
def _fetch_amp_mcp_servers(mcp_name: str) -> list[dict[str, Any]]:
"""Fetch MCP server configurations from CrewAI AMP API."""
# TODO: Implement AMP API call to "integrations/mcps" endpoint
# Should return list of server configs with URLs
return []
if self._mcp_resolver is not None:
self._mcp_resolver.cleanup()
self._mcp_resolver = None
@staticmethod
def get_multimodal_tools() -> Sequence[BaseTool]:
@@ -1741,15 +1194,32 @@ class Agent(BaseAgent):
# Process platform apps and MCP tools
if self.apps:
platform_tools = self.get_platform_tools(self.apps)
if platform_tools and self.tools is not None:
if platform_tools:
if self.tools is None:
self.tools = []
self.tools.extend(platform_tools)
if self.mcps:
mcps = self.get_mcp_tools(self.mcps)
if mcps and self.tools is not None:
if mcps:
if self.tools is None:
self.tools = []
self.tools.extend(mcps)
# Prepare tools
raw_tools: list[BaseTool] = self.tools or []
# Inject memory tools for standalone kickoff (crew path handles its own)
agent_memory = getattr(self, "memory", None)
if agent_memory is not None:
from crewai.tools.memory_tools import create_memory_tools
existing_names = {sanitize_tool_name(t.name) for t in raw_tools}
raw_tools.extend(
mt
for mt in create_memory_tools(agent_memory)
if sanitize_tool_name(mt.name) not in existing_names
)
parsed_tools = parse_tools(raw_tools)
# Build agent_info for backward-compatible event emission
@@ -1824,6 +1294,49 @@ class Agent(BaseAgent):
if input_files:
all_files.update(input_files)
# Inject memory context for standalone kickoff (recall before execution)
if agent_memory is not None:
try:
crewai_event_bus.emit(
self,
event=MemoryRetrievalStartedEvent(
task_id=None,
source_type="agent_kickoff",
from_agent=self,
),
)
start_time = time.time()
matches = agent_memory.recall(formatted_messages, limit=20)
memory_block = ""
if matches:
memory_block = "Relevant memories:\n" + "\n".join(
m.format() for m in matches
)
if memory_block:
formatted_messages += "\n\n" + self.i18n.slice("memory").format(
memory=memory_block
)
crewai_event_bus.emit(
self,
event=MemoryRetrievalCompletedEvent(
task_id=None,
memory_content=memory_block,
retrieval_time_ms=(time.time() - start_time) * 1000,
source_type="agent_kickoff",
from_agent=self,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryRetrievalFailedEvent(
task_id=None,
source_type="agent_kickoff",
from_agent=self,
error=str(e),
),
)
# Build the input dict for the executor
inputs: dict[str, Any] = {
"input": formatted_messages,
@@ -1894,6 +1407,9 @@ class Agent(BaseAgent):
response_format=response_format,
)
# Save to memory after execution (passive save)
self._save_kickoff_to_memory(messages, output.raw)
crewai_event_bus.emit(
self,
event=LiteAgentExecutionCompletedEvent(
@@ -1914,6 +1430,32 @@ class Agent(BaseAgent):
)
raise
def _save_kickoff_to_memory(
self, messages: str | list[LLMMessage], output_text: str
) -> None:
"""Save kickoff result to memory. No-op if agent has no memory."""
agent_memory = getattr(self, "memory", None)
if agent_memory is None:
return
try:
if isinstance(messages, str):
input_str = messages
else:
input_str = (
"\n".join(
str(msg.get("content", ""))
for msg in messages
if msg.get("content")
)
or "User request"
)
raw = f"Input: {input_str}\nAgent: {self.role}\nResult: {output_text}"
extracted = agent_memory.extract_memories(raw)
if extracted:
agent_memory.remember_many(extracted)
except Exception as e:
self._logger.log("error", f"Failed to save kickoff result to memory: {e}")
def _execute_and_build_output(
self,
executor: AgentExecutor,
@@ -2196,6 +1738,9 @@ class Agent(BaseAgent):
response_format=response_format,
)
# Save to memory after async execution (passive save)
self._save_kickoff_to_memory(messages, output.raw)
crewai_event_bus.emit(
self,
event=LiteAgentExecutionCompletedEvent(

View File

@@ -5,7 +5,8 @@ from collections.abc import Callable
from copy import copy as shallow_copy
from hashlib import md5
from pathlib import Path
from typing import Any, Literal
import re
from typing import Any, Final, Literal
import uuid
from pydantic import (
@@ -38,6 +39,11 @@ from crewai.utilities.rpm_controller import RPMController
from crewai.utilities.string_utils import interpolate_only
_SLUG_RE: Final[re.Pattern[str]] = re.compile(
r"^(?:crewai-amp:)?[a-zA-Z0-9][a-zA-Z0-9_-]*(?:#\w+)?$"
)
PlatformApp = Literal[
"asana",
"box",
@@ -199,7 +205,15 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
)
mcps: list[str | MCPServerConfig] | None = Field(
default=None,
description="List of MCP server references. Supports 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools.",
description="List of MCP server references. Supports 'https://server.com/path' for external servers and bare slugs like 'notion' for connected MCP integrations. Use '#tool_name' suffix for specific tools.",
)
memory: Any = Field(
default=None,
description=(
"Enable agent memory. Pass True for default Memory(), "
"or a Memory/MemoryScope/MemorySlice instance for custom configuration. "
"If not set, falls back to crew memory."
),
)
skills: list[Path | Skill] | None = Field(
default=None,
@@ -274,14 +288,16 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
validated_mcps: list[str | MCPServerConfig] = []
for mcp in mcps:
if isinstance(mcp, str):
if mcp.startswith(("https://", "crewai-amp:")):
if mcp.startswith("https://"):
validated_mcps.append(mcp)
elif _SLUG_RE.match(mcp):
validated_mcps.append(mcp)
else:
raise ValueError(
f"Invalid MCP reference: {mcp}. "
"String references must start with 'https://' or 'crewai-amp:'"
f"Invalid MCP reference: {mcp!r}. "
"String references must be an 'https://' URL or a valid "
"slug (e.g. 'notion', 'notion#search', 'crewai-amp:notion')."
)
elif isinstance(mcp, (MCPServerConfig)):
validated_mcps.append(mcp)
else:
@@ -335,6 +351,17 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
self._token_process = TokenProcess()
return self
@model_validator(mode="after")
def resolve_memory(self) -> Self:
"""Resolve memory field: True creates a default Memory(), instance is used as-is."""
if self.memory is True:
from crewai.memory.unified_memory import Memory
self.memory = Memory()
elif self.memory is False:
self.memory = None
return self
@property
def key(self) -> str:
source = [

View File

@@ -1,13 +1,8 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from crewai.agents.parser import AgentFinish
from crewai.memory.entity.entity_memory_item import EntityMemoryItem
from crewai.memory.long_term.long_term_memory_item import LongTermMemoryItem
from crewai.utilities.converter import ConverterError
from crewai.utilities.evaluators.task_evaluator import TaskEvaluator
from crewai.utilities.printer import Printer
from crewai.utilities.string_utils import sanitize_tool_name
@@ -30,110 +25,29 @@ class CrewAgentExecutorMixin:
_i18n: I18N
_printer: Printer = Printer()
def _create_short_term_memory(self, output: AgentFinish) -> None:
"""Create and save a short-term memory item if conditions are met."""
def _save_to_memory(self, output: AgentFinish) -> None:
"""Save task result to unified memory (memory or crew._memory)."""
memory = getattr(self.agent, "memory", None) or (
getattr(self.crew, "_memory", None) if self.crew else None
)
if memory is None or not self.task or getattr(memory, "_read_only", False):
return
if (
self.crew
and self.agent
and self.task
and f"Action: {sanitize_tool_name('Delegate work to coworker')}"
not in output.text
f"Action: {sanitize_tool_name('Delegate work to coworker')}"
in output.text
):
try:
if (
hasattr(self.crew, "_short_term_memory")
and self.crew._short_term_memory
):
self.crew._short_term_memory.save(
value=output.text,
metadata={
"observation": self.task.description,
},
)
except Exception as e:
self.agent._logger.log(
"error", f"Failed to add to short term memory: {e}"
)
def _create_external_memory(self, output: AgentFinish) -> None:
"""Create and save a external-term memory item if conditions are met."""
if (
self.crew
and self.agent
and self.task
and hasattr(self.crew, "_external_memory")
and self.crew._external_memory
):
try:
self.crew._external_memory.save(
value=output.text,
metadata={
"description": self.task.description,
"messages": self.messages,
},
)
except Exception as e:
self.agent._logger.log(
"error", f"Failed to add to external memory: {e}"
)
def _create_long_term_memory(self, output: AgentFinish) -> None:
"""Create and save long-term and entity memory items based on evaluation."""
if (
self.crew
and self.crew._long_term_memory
and self.crew._entity_memory
and self.task
and self.agent
):
try:
ltm_agent = TaskEvaluator(self.agent)
evaluation = ltm_agent.evaluate(self.task, output.text)
if isinstance(evaluation, ConverterError):
return
long_term_memory = LongTermMemoryItem(
task=self.task.description,
agent=self.agent.role,
quality=evaluation.quality,
datetime=str(time.time()),
expected_output=self.task.expected_output,
metadata={
"suggestions": evaluation.suggestions,
"quality": evaluation.quality,
},
)
self.crew._long_term_memory.save(long_term_memory)
entity_memories = [
EntityMemoryItem(
name=entity.name,
type=entity.type,
description=entity.description,
relationships="\n".join(
[f"- {r}" for r in entity.relationships]
),
)
for entity in evaluation.entities
]
if entity_memories:
self.crew._entity_memory.save(entity_memories)
except AttributeError as e:
self.agent._logger.log(
"error", f"Missing attributes for long term memory: {e}"
)
except Exception as e:
self.agent._logger.log(
"error", f"Failed to add to long term memory: {e}"
)
elif (
self.crew
and self.crew._long_term_memory
and self.crew._entity_memory is None
):
if self.agent and self.agent.verbose:
self._printer.print(
content="Long term memory is enabled, but entity memory is not enabled. Please configure entity memory or set memory=True to automatically enable it.",
color="bold_yellow",
)
return
try:
raw = (
f"Task: {self.task.description}\n"
f"Agent: {self.agent.role}\n"
f"Expected result: {self.task.expected_output}\n"
f"Result: {output.text}"
)
extracted = memory.extract_memories(raw)
if extracted:
memory.remember_many(extracted, agent_role=self.agent.role)
except Exception as e:
self.agent._logger.log(
"error", f"Failed to save to memory: {e}"
)

View File

@@ -1,5 +1,4 @@
from crewai.agents.cache.cache_handler import CacheHandler
__all__ = ["CacheHandler"]

View File

@@ -6,7 +6,11 @@ and memory management.
from __future__ import annotations
import asyncio
from collections.abc import Callable
import contextvars
from concurrent.futures import ThreadPoolExecutor, as_completed
import inspect
import logging
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -47,6 +51,7 @@ from crewai.utilities.agent_utils import (
handle_unknown_error,
has_reached_max_iterations,
is_context_length_exceeded,
parse_tool_call_args,
process_llm_response,
track_delegation_if_needed,
)
@@ -234,9 +239,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if self.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
self._create_short_term_memory(formatted_answer)
self._create_long_term_memory(formatted_answer)
self._create_external_memory(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}
def _inject_multimodal_files(self, inputs: dict[str, Any] | None = None) -> None:
@@ -485,8 +488,8 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
# No tools available, fall back to simple LLM call
return self._invoke_loop_native_no_tools()
openai_tools, available_functions = convert_tools_to_openai_schema(
self.original_tools
openai_tools, available_functions, self._tool_name_mapping = (
convert_tools_to_openai_schema(self.original_tools)
)
while True:
@@ -687,30 +690,141 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
Returns:
AgentFinish if tool has result_as_answer=True, None otherwise.
"""
from datetime import datetime
import json
from crewai.events import crewai_event_bus
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
if not tool_calls:
return None
# Only process the FIRST tool call for sequential execution with reflection
tool_call = tool_calls[0]
parsed_calls = [
parsed
for tool_call in tool_calls
if (parsed := self._parse_native_tool_call(tool_call)) is not None
]
if not parsed_calls:
return None
# Extract tool call info - handle OpenAI-style, Anthropic-style, and Gemini-style
original_tools_by_name: dict[str, Any] = dict(self._tool_name_mapping)
if len(parsed_calls) > 1:
has_result_as_answer_in_batch = any(
bool(
original_tools_by_name.get(func_name)
and getattr(
original_tools_by_name.get(func_name), "result_as_answer", False
)
)
for _, func_name, _ in parsed_calls
)
has_max_usage_count_in_batch = any(
bool(
original_tools_by_name.get(func_name)
and getattr(
original_tools_by_name.get(func_name),
"max_usage_count",
None,
)
is not None
)
for _, func_name, _ in parsed_calls
)
# Preserve historical sequential behavior for result_as_answer batches.
# Also avoid threading around usage counters for max_usage_count tools.
if has_result_as_answer_in_batch or has_max_usage_count_in_batch:
logger.debug(
"Skipping parallel native execution because batch includes result_as_answer or max_usage_count tool"
)
else:
execution_plan: list[
tuple[str, str, str | dict[str, Any], Any | None]
] = []
for call_id, func_name, func_args in parsed_calls:
original_tool = original_tools_by_name.get(func_name)
execution_plan.append(
(call_id, func_name, func_args, original_tool)
)
self._append_assistant_tool_calls_message(
[
(call_id, func_name, func_args)
for call_id, func_name, func_args, _ in execution_plan
]
)
max_workers = min(8, len(execution_plan))
ordered_results: list[dict[str, Any] | None] = [None] * len(
execution_plan
)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(
contextvars.copy_context().run,
self._execute_single_native_tool_call,
call_id=call_id,
func_name=func_name,
func_args=func_args,
available_functions=available_functions,
original_tool=original_tool,
should_execute=True,
): idx
for idx, (
call_id,
func_name,
func_args,
original_tool,
) in enumerate(execution_plan)
}
for future in as_completed(futures):
idx = futures[future]
ordered_results[idx] = future.result()
for execution_result in ordered_results:
if not execution_result:
continue
tool_finish = self._append_tool_result_and_check_finality(
execution_result
)
if tool_finish:
return tool_finish
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message: LLMMessage = {
"role": "user",
"content": reasoning_prompt,
}
self.messages.append(reasoning_message)
return None
# Sequential behavior: process only first tool call, then force reflection.
call_id, func_name, func_args = parsed_calls[0]
self._append_assistant_tool_calls_message([(call_id, func_name, func_args)])
execution_result = self._execute_single_native_tool_call(
call_id=call_id,
func_name=func_name,
func_args=func_args,
available_functions=available_functions,
original_tool=original_tools_by_name.get(func_name),
should_execute=True,
)
tool_finish = self._append_tool_result_and_check_finality(execution_result)
if tool_finish:
return tool_finish
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message = {
"role": "user",
"content": reasoning_prompt,
}
self.messages.append(reasoning_message)
return None
def _parse_native_tool_call(
self, tool_call: Any
) -> tuple[str, str, str | dict[str, Any]] | None:
if hasattr(tool_call, "function"):
# OpenAI-style: has .function.name and .function.arguments
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
func_name = sanitize_tool_name(tool_call.function.name)
func_args = tool_call.function.arguments
elif hasattr(tool_call, "function_call") and tool_call.function_call:
# Gemini-style: has .function_call.name and .function_call.args
return call_id, func_name, tool_call.function.arguments
if hasattr(tool_call, "function_call") and tool_call.function_call:
call_id = f"call_{id(tool_call)}"
func_name = sanitize_tool_name(tool_call.function_call.name)
func_args = (
@@ -718,13 +832,12 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if tool_call.function_call.args
else {}
)
elif hasattr(tool_call, "name") and hasattr(tool_call, "input"):
# Anthropic format: has .name and .input (ToolUseBlock)
return call_id, func_name, func_args
if hasattr(tool_call, "name") and hasattr(tool_call, "input"):
call_id = getattr(tool_call, "id", f"call_{id(tool_call)}")
func_name = sanitize_tool_name(tool_call.name)
func_args = tool_call.input # Already a dict in Anthropic
elif isinstance(tool_call, dict):
# Support OpenAI "id", Bedrock "toolUseId", or generate one
return call_id, func_name, tool_call.input
if isinstance(tool_call, dict):
call_id = (
tool_call.get("id")
or tool_call.get("toolUseId")
@@ -735,10 +848,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
func_info.get("name", "") or tool_call.get("name", "")
)
func_args = func_info.get("arguments", "{}") or tool_call.get("input", {})
else:
return None
return call_id, func_name, func_args
return None
def _append_assistant_tool_calls_message(
self,
parsed_calls: list[tuple[str, str, str | dict[str, Any]]],
) -> None:
import json
# Append assistant message with single tool call
assistant_message: LLMMessage = {
"role": "assistant",
"content": None,
@@ -753,42 +871,54 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
else json.dumps(func_args),
},
}
for call_id, func_name, func_args in parsed_calls
],
}
self.messages.append(assistant_message)
# Parse arguments for the single tool call
if isinstance(func_args, str):
try:
args_dict = json.loads(func_args)
except json.JSONDecodeError:
args_dict = {}
else:
args_dict = func_args
def _execute_single_native_tool_call(
self,
*,
call_id: str,
func_name: str,
func_args: str | dict[str, Any],
available_functions: dict[str, Callable[..., Any]],
original_tool: Any | None = None,
should_execute: bool = True,
) -> dict[str, Any]:
from datetime import datetime
import json
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
# Find original tool by matching sanitized name (needed for cache_function and result_as_answer)
args_dict, parse_error = parse_tool_call_args(func_args, func_name, call_id, original_tool)
if parse_error is not None:
return parse_error
original_tool = None
for tool in self.original_tools or []:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
if original_tool is None:
for tool in self.original_tools or []:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
# Check if tool has reached max usage count
max_usage_reached = False
if original_tool:
if (
hasattr(original_tool, "max_usage_count")
and original_tool.max_usage_count is not None
and original_tool.current_usage_count >= original_tool.max_usage_count
):
max_usage_reached = True
if not should_execute and original_tool:
max_usage_reached = True
elif (
should_execute
and original_tool
and (max_count := getattr(original_tool, "max_usage_count", None))
is not None
and getattr(original_tool, "current_usage_count", 0) >= max_count
):
max_usage_reached = True
# Check cache before executing
from_cache = False
result: str = "Tool not found"
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
cached_result = self.tools_handler.cache.read(
@@ -802,7 +932,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
)
from_cache = True
# Emit tool usage started event
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
started_at = datetime.now()
crewai_event_bus.emit(
self,
@@ -818,14 +948,18 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
track_delegation_if_needed(func_name, args_dict, self.task)
# Find the structured tool for hook context
structured_tool: CrewStructuredTool | None = None
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
# Execute before_tool_call hooks
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -849,58 +983,48 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
color="red",
)
# If hook blocked execution, set result and skip tool execution
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
# Execute the tool (only if not cached, not at max usage, and not blocked by hook)
elif not from_cache and not max_usage_reached:
result = "Tool not found"
if func_name in available_functions:
try:
tool_func = available_functions[func_name]
raw_result = tool_func(**args_dict)
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if (
original_tool
and hasattr(original_tool, "cache_function")
and callable(original_tool.cache_function)
):
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
# Convert to string for message
result = (
str(raw_result)
if not isinstance(raw_result, str)
else raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
if self.task:
self.task.increment_tools_errors()
crewai_event_bus.emit(
self,
event=ToolUsageErrorEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
error=e,
),
)
error_event_emitted = True
elif max_usage_reached and original_tool:
# Return error message when max usage limit is reached
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
elif not from_cache and func_name in available_functions:
try:
raw_result = available_functions[func_name](**args_dict)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if (
original_tool
and hasattr(original_tool, "cache_function")
and callable(original_tool.cache_function)
):
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
result = (
str(raw_result) if not isinstance(raw_result, str) else raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
if self.task:
self.task.increment_tools_errors()
crewai_event_bus.emit(
self,
event=ToolUsageErrorEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
error=e,
),
)
error_event_emitted = True
after_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -940,7 +1064,23 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
),
)
# Append tool result message
return {
"call_id": call_id,
"func_name": func_name,
"result": result,
"from_cache": from_cache,
"original_tool": original_tool,
}
def _append_tool_result_and_check_finality(
self, execution_result: dict[str, Any]
) -> AgentFinish | None:
call_id = cast(str, execution_result["call_id"])
func_name = cast(str, execution_result["func_name"])
result = cast(str, execution_result["result"])
from_cache = cast(bool, execution_result["from_cache"])
original_tool = execution_result["original_tool"]
tool_message: LLMMessage = {
"role": "tool",
"tool_call_id": call_id,
@@ -949,7 +1089,6 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
}
self.messages.append(tool_message)
# Log the tool execution
if self.agent and self.agent.verbose:
cache_info = " (from cache)" if from_cache else ""
self._printer.print(
@@ -962,20 +1101,11 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
):
# Return immediately with tool result as final answer
return AgentFinish(
thought="Tool result is the final answer",
output=result,
text=result,
)
# Inject post-tool reasoning prompt to enforce analysis
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message: LLMMessage = {
"role": "user",
"content": reasoning_prompt,
}
self.messages.append(reasoning_message)
return None
async def ainvoke(self, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -1011,9 +1141,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if self.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(formatted_answer)
self._create_short_term_memory(formatted_answer)
self._create_long_term_memory(formatted_answer)
self._create_external_memory(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}
async def _ainvoke_loop(self) -> AgentFinish:
@@ -1137,7 +1265,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer, tool_result
)
self._invoke_step_callback(formatted_answer) # type: ignore[arg-type]
await self._ainvoke_step_callback(formatted_answer) # type: ignore[arg-type]
self._append_message(formatted_answer.text) # type: ignore[union-attr]
except OutputParserError as e:
@@ -1190,8 +1318,8 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if not self.original_tools:
return await self._ainvoke_loop_native_no_tools()
openai_tools, available_functions = convert_tools_to_openai_schema(
self.original_tools
openai_tools, available_functions, self._tool_name_mapping = (
convert_tools_to_openai_schema(self.original_tools)
)
while True:
@@ -1252,7 +1380,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
output=answer,
text=answer,
)
self._invoke_step_callback(formatted_answer)
await self._ainvoke_step_callback(formatted_answer)
self._append_message(answer) # Save final answer to messages
self._show_logs(formatted_answer)
return formatted_answer
@@ -1264,7 +1392,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
output=answer,
text=output_json,
)
self._invoke_step_callback(formatted_answer)
await self._ainvoke_step_callback(formatted_answer)
self._append_message(output_json)
self._show_logs(formatted_answer)
return formatted_answer
@@ -1275,7 +1403,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
output=str(answer),
text=str(answer),
)
self._invoke_step_callback(formatted_answer)
await self._ainvoke_step_callback(formatted_answer)
self._append_message(str(answer)) # Save final answer to messages
self._show_logs(formatted_answer)
return formatted_answer
@@ -1369,13 +1497,28 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
def _invoke_step_callback(
self, formatted_answer: AgentAction | AgentFinish
) -> None:
"""Invoke step callback.
"""Invoke step callback (sync context).
Args:
formatted_answer: Current agent response.
"""
if self.step_callback:
self.step_callback(formatted_answer)
cb_result = self.step_callback(formatted_answer)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
async def _ainvoke_step_callback(
self, formatted_answer: AgentAction | AgentFinish
) -> None:
"""Invoke step callback (async context).
Args:
formatted_answer: Current agent response.
"""
if self.step_callback:
cb_result = self.step_callback(formatted_answer)
if inspect.iscoroutine(cb_result):
await cb_result
def _append_message(
self, text: str, role: Literal["user", "assistant", "system"] = "assistant"

View File

@@ -1,5 +1,4 @@
from crewai.cli.authentication.main import AuthenticationCommand
__all__ = ["AuthenticationCommand"]

View File

@@ -2,8 +2,8 @@ import time
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
import httpx
from pydantic import BaseModel, Field
import requests
from rich.console import Console
from crewai.cli.authentication.utils import validate_jwt_token
@@ -98,7 +98,7 @@ class AuthenticationCommand:
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = requests.post(
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
@@ -130,7 +130,7 @@ class AuthenticationCommand:
attempts = 0
while True and attempts < 10:
response = requests.post(
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
@@ -149,7 +149,7 @@ class AuthenticationCommand:
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise requests.HTTPError(
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)

View File

@@ -1,6 +1,7 @@
from importlib.metadata import version as get_version
import os
import subprocess
from typing import Any
import click
@@ -179,9 +180,19 @@ def log_tasks_outputs() -> None:
@crewai.command()
@click.option("-l", "--long", is_flag=True, help="Reset LONG TERM memory")
@click.option("-s", "--short", is_flag=True, help="Reset SHORT TERM memory")
@click.option("-e", "--entities", is_flag=True, help="Reset ENTITIES memory")
@click.option("-m", "--memory", is_flag=True, help="Reset MEMORY")
@click.option(
"-l", "--long", is_flag=True, hidden=True,
help="[Deprecated: use --memory] Reset memory",
)
@click.option(
"-s", "--short", is_flag=True, hidden=True,
help="[Deprecated: use --memory] Reset memory",
)
@click.option(
"-e", "--entities", is_flag=True, hidden=True,
help="[Deprecated: use --memory] Reset memory",
)
@click.option("-kn", "--knowledge", is_flag=True, help="Reset KNOWLEDGE storage")
@click.option(
"-akn", "--agent-knowledge", is_flag=True, help="Reset AGENT KNOWLEDGE storage"
@@ -191,6 +202,7 @@ def log_tasks_outputs() -> None:
)
@click.option("-a", "--all", is_flag=True, help="Reset ALL memories")
def reset_memories(
memory: bool,
long: bool,
short: bool,
entities: bool,
@@ -200,13 +212,22 @@ def reset_memories(
all: bool,
) -> None:
"""
Reset the crew memories (long, short, entity, latest_crew_kickoff_ouputs, knowledge, agent_knowledge). This will delete all the data saved.
Reset the crew memories (memory, knowledge, agent_knowledge, kickoff_outputs). This will delete all the data saved.
"""
try:
# Treat legacy flags as --memory with a deprecation warning
if long or short or entities:
legacy_used = [
f for f, v in [("--long", long), ("--short", short), ("--entities", entities)] if v
]
click.echo(
f"Warning: {', '.join(legacy_used)} {'is' if len(legacy_used) == 1 else 'are'} "
"deprecated. Use --memory (-m) instead. All memory is now unified."
)
memory = True
memory_types = [
long,
short,
entities,
memory,
knowledge,
agent_knowledge,
kickoff_outputs,
@@ -218,12 +239,73 @@ def reset_memories(
)
return
reset_memories_command(
long, short, entities, knowledge, agent_knowledge, kickoff_outputs, all
memory, knowledge, agent_knowledge, kickoff_outputs, all
)
except Exception as e:
click.echo(f"An error occurred while resetting memories: {e}", err=True)
@crewai.command()
@click.option(
"--storage-path",
type=str,
default=None,
help="Path to LanceDB memory directory. If omitted, uses ./.crewai/memory.",
)
@click.option(
"--embedder-provider",
type=str,
default=None,
help="Embedder provider for recall queries (e.g. openai, google-vertex, cohere, ollama).",
)
@click.option(
"--embedder-model",
type=str,
default=None,
help="Embedder model name (e.g. text-embedding-3-small, gemini-embedding-001).",
)
@click.option(
"--embedder-config",
type=str,
default=None,
help='Full embedder config as JSON (e.g. \'{"provider": "cohere", "config": {"model_name": "embed-v4.0"}}\').',
)
def memory(
storage_path: str | None,
embedder_provider: str | None,
embedder_model: str | None,
embedder_config: str | None,
) -> None:
"""Open the Memory TUI to browse scopes and recall memories."""
try:
from crewai.cli.memory_tui import MemoryTUI
except ImportError as exc:
click.echo(
"Textual is required for the memory TUI but could not be imported. "
"Try reinstalling crewai or: pip install textual"
)
raise SystemExit(1) from exc
# Build embedder spec from CLI flags.
embedder_spec: dict[str, Any] | None = None
if embedder_config:
import json as _json
try:
embedder_spec = _json.loads(embedder_config)
except _json.JSONDecodeError as exc:
click.echo(f"Invalid --embedder-config JSON: {exc}")
raise SystemExit(1) from exc
elif embedder_provider:
cfg: dict[str, str] = {}
if embedder_model:
cfg["model_name"] = embedder_model
embedder_spec = {"provider": embedder_provider, "config": cfg}
app = MemoryTUI(storage_path=storage_path, embedder_config=embedder_spec)
app.run()
@crewai.command()
@click.option(
"-n",

View File

@@ -1,5 +1,6 @@
import requests
from requests.exceptions import JSONDecodeError
import json
import httpx
from rich.console import Console
from crewai.cli.authentication.token import get_auth_token
@@ -30,16 +31,16 @@ class PlusAPIMixin:
console.print("Run 'crewai login' to sign up/login.", style="bold green")
raise SystemExit from None
def _validate_response(self, response: requests.Response) -> None:
def _validate_response(self, response: httpx.Response) -> None:
"""
Handle and display error messages from API responses.
Args:
response (requests.Response): The response from the Plus API
response (httpx.Response): The response from the Plus API
"""
try:
json_response = response.json()
except (JSONDecodeError, ValueError):
except (json.JSONDecodeError, ValueError):
console.print(
"Failed to parse response from Enterprise API failed. Details:",
style="bold red",
@@ -62,7 +63,7 @@ class PlusAPIMixin:
)
raise SystemExit
if not response.ok:
if not response.is_success:
console.print(
"Request to Enterprise API failed. Details:", style="bold red"
)

View File

@@ -69,7 +69,7 @@ ENV_VARS: dict[str, list[dict[str, Any]]] = {
},
{
"prompt": "Enter your AWS Region Name (press Enter to skip)",
"key_name": "AWS_REGION_NAME",
"key_name": "AWS_DEFAULT_REGION",
},
],
"azure": [

View File

@@ -143,7 +143,7 @@ def create_folder_structure(
(folder_path / "src" / folder_name).mkdir(parents=True)
(folder_path / "src" / folder_name / "tools").mkdir(parents=True)
(folder_path / "src" / folder_name / "config").mkdir(parents=True)
# Copy AGENTS.md to project root (top-level projects only)
package_dir = Path(__file__).parent
agents_md_src = package_dir / "templates" / "AGENTS.md"

View File

@@ -1,5 +1,5 @@
import shutil
from pathlib import Path
import shutil
import click

View File

@@ -1,7 +1,7 @@
import json
from typing import Any, cast
import requests
from requests.exceptions import JSONDecodeError, RequestException
import httpx
from rich.console import Console
from crewai.cli.authentication.main import Oauth2Settings, ProviderFactory
@@ -47,12 +47,12 @@ class EnterpriseConfigureCommand(BaseCommand):
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
response = requests.get(oauth_endpoint, timeout=30, headers=headers)
response = httpx.get(oauth_endpoint, timeout=30, headers=headers)
response.raise_for_status()
try:
oauth_config = response.json()
except JSONDecodeError as e:
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON response from {oauth_endpoint}") from e
self._validate_oauth_config(oauth_config)
@@ -62,7 +62,7 @@ class EnterpriseConfigureCommand(BaseCommand):
)
return cast(dict[str, Any], oauth_config)
except RequestException as e:
except httpx.HTTPError as e:
raise ValueError(f"Failed to connect to enterprise URL: {e!s}") from e
except Exception as e:
raise ValueError(f"Error fetching OAuth2 configuration: {e!s}") from e

View File

@@ -0,0 +1,410 @@
"""Textual TUI for browsing and recalling unified memory."""
from __future__ import annotations
import asyncio
from typing import Any
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Footer, Header, Input, OptionList, Static, Tree
# -- CrewAI brand palette --
_PRIMARY = "#eb6658" # coral
_SECONDARY = "#1F7982" # teal
_TERTIARY = "#ffffff" # white
def _format_scope_info(info: Any) -> str:
"""Format ScopeInfo with Rich markup."""
return (
f"[bold {_PRIMARY}]{info.path}[/]\n\n"
f"[dim]Records:[/] [bold]{info.record_count}[/]\n"
f"[dim]Categories:[/] {', '.join(info.categories) or 'none'}\n"
f"[dim]Oldest:[/] {info.oldest_record or '-'}\n"
f"[dim]Newest:[/] {info.newest_record or '-'}\n"
f"[dim]Children:[/] {', '.join(info.child_scopes) or 'none'}"
)
class MemoryTUI(App[None]):
"""TUI to browse memory scopes and run recall queries."""
TITLE = "CrewAI Memory"
SUB_TITLE = "Browse scopes and recall memories"
CSS = f"""
Header {{
background: {_PRIMARY};
color: {_TERTIARY};
}}
Footer {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
Footer > .footer-key--key {{
background: {_PRIMARY};
color: {_TERTIARY};
}}
Horizontal {{
height: 1fr;
}}
#scope-tree {{
width: 30%;
padding: 1 2;
background: {_SECONDARY} 8%;
border-right: solid {_SECONDARY};
}}
#scope-tree:focus > .tree--cursor {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
#scope-tree > .tree--guides {{
color: {_SECONDARY} 50%;
}}
#scope-tree > .tree--guides-hover {{
color: {_PRIMARY};
}}
#scope-tree > .tree--guides-selected {{
color: {_SECONDARY};
}}
#right-panel {{
width: 70%;
padding: 0 1;
}}
#info-panel {{
height: 2fr;
padding: 1 2;
overflow-y: auto;
border: round {_SECONDARY};
}}
#info-panel:focus {{
border: round {_PRIMARY};
}}
#info-panel LoadingIndicator {{
color: {_PRIMARY};
}}
#entry-list {{
height: 1fr;
border: round {_SECONDARY};
padding: 0 1;
scrollbar-color: {_PRIMARY};
}}
#entry-list:focus {{
border: round {_PRIMARY};
}}
#entry-list > .option-list--option-highlighted {{
background: {_SECONDARY};
color: {_TERTIARY};
}}
#recall-input {{
margin: 0 1 1 1;
border: tall {_SECONDARY};
}}
#recall-input:focus {{
border: tall {_PRIMARY};
}}
"""
def __init__(
self,
storage_path: str | None = None,
embedder_config: dict[str, Any] | None = None,
) -> None:
super().__init__()
self._memory: Any = None
self._init_error: str | None = None
self._selected_scope: str = "/"
self._entries: list[Any] = []
self._view_mode: str = "list" # "list" | "recall"
self._recall_matches: list[Any] = []
self._last_scope_info: Any = None
self._custom_embedder = embedder_config is not None
try:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
from crewai.memory.unified_memory import Memory
storage = LanceDBStorage(path=storage_path) if storage_path else LanceDBStorage()
embedder = None
if embedder_config is not None:
from crewai.rag.embeddings.factory import build_embedder
embedder = build_embedder(embedder_config)
self._memory = Memory(storage=storage, embedder=embedder) if embedder else Memory(storage=storage)
except Exception as e:
self._init_error = str(e)
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
with Horizontal():
yield self._build_scope_tree()
initial = (
self._init_error
if self._init_error
else "Select a scope or type a recall query."
)
with Vertical(id="right-panel"):
yield Static(initial, id="info-panel")
yield OptionList(id="entry-list")
yield Input(
placeholder="Type a query and press Enter to recall...",
id="recall-input",
)
yield Footer()
def on_mount(self) -> None:
"""Set initial border titles on mounted widgets."""
self.query_one("#info-panel", Static).border_title = "Detail"
self.query_one("#entry-list", OptionList).border_title = "Entries"
def _build_scope_tree(self) -> Tree[str]:
tree: Tree[str] = Tree("/", id="scope-tree")
if self._memory is None:
tree.root.data = "/"
tree.root.label = "/ (0 records)"
return tree
info = self._memory.info("/")
tree.root.label = f"/ ({info.record_count} records)"
tree.root.data = "/"
self._add_children(tree.root, "/", depth=0, max_depth=3)
tree.root.expand()
return tree
def _add_children(
self,
parent_node: Tree.Node[str],
path: str,
depth: int,
max_depth: int,
) -> None:
if depth >= max_depth or self._memory is None:
return
info = self._memory.info(path)
for child in info.child_scopes:
child_info = self._memory.info(child)
label = f"{child} ({child_info.record_count})"
node = parent_node.add(label, data=child)
self._add_children(node, child, depth + 1, max_depth)
# -- Populating the OptionList -------------------------------------------
def _populate_entry_list(self) -> None:
"""Clear the OptionList and fill it with the current scope's entries."""
option_list = self.query_one("#entry-list", OptionList)
option_list.clear_options()
for record in self._entries:
date_str = record.created_at.strftime("%Y-%m-%d")
preview = (
(record.content[:80] + "")
if len(record.content) > 80
else record.content
)
label = (
f"{date_str} "
f"[bold]{record.importance:.1f}[/] "
f"{preview}"
)
option_list.add_option(label)
def _populate_recall_list(self) -> None:
"""Clear the OptionList and fill it with the current recall matches."""
option_list = self.query_one("#entry-list", OptionList)
option_list.clear_options()
if not self._recall_matches:
return
for m in self._recall_matches:
preview = (
(m.record.content[:80] + "")
if len(m.record.content) > 80
else m.record.content
)
label = (
f"[bold]\\[{m.score:.2f}][/] "
f"{preview} "
f"[dim]scope={m.record.scope}[/]"
)
option_list.add_option(label)
# -- Detail rendering ----------------------------------------------------
def _format_record_detail(self, record: Any, context_line: str = "") -> str:
"""Format a full MemoryRecord as Rich markup for the detail view.
Args:
record: A MemoryRecord instance.
context_line: Optional header line shown above the fields
(e.g. "Entry 3 of 47").
Returns:
A Rich-markup string with all meaningful record fields.
"""
sep = f"[bold {_PRIMARY}]{'' * 44}[/]"
lines: list[str] = []
if context_line:
lines.append(context_line)
lines.append("")
# -- Fields block --
lines.append(f"[dim]ID:[/] {record.id}")
lines.append(f"[dim]Scope:[/] [bold]{record.scope}[/]")
lines.append(f"[dim]Importance:[/] [bold]{record.importance:.2f}[/]")
lines.append(
f"[dim]Created:[/] "
f"{record.created_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append(
f"[dim]Last accessed:[/] "
f"{record.last_accessed.strftime('%Y-%m-%d %H:%M:%S')}"
)
lines.append(
f"[dim]Categories:[/] "
f"{', '.join(record.categories) if record.categories else 'none'}"
)
lines.append(f"[dim]Source:[/] {record.source or '-'}")
lines.append(f"[dim]Private:[/] {'Yes' if record.private else 'No'}")
# -- Content block --
lines.append(f"\n{sep}")
lines.append("[bold]Content[/]\n")
lines.append(record.content)
# -- Metadata block --
if record.metadata:
lines.append(f"\n{sep}")
lines.append("[bold]Metadata[/]\n")
for k, v in record.metadata.items():
lines.append(f"[dim]{k}:[/] {v}")
return "\n".join(lines)
# -- Event handlers ------------------------------------------------------
def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
"""Load entries for the selected scope and populate the OptionList."""
path = event.node.data if event.node.data is not None else "/"
self._selected_scope = path
self._view_mode = "list"
panel = self.query_one("#info-panel", Static)
if self._memory is None:
panel.update(self._init_error or "No memory loaded.")
return
display_limit = 1000
info = self._memory.info(path)
self._last_scope_info = info
self._entries = self._memory.list_records(scope=path, limit=display_limit)
panel.update(_format_scope_info(info))
panel.border_title = "Detail"
entry_list = self.query_one("#entry-list", OptionList)
capped = info.record_count > display_limit
count_label = (
f"Entries (showing {display_limit} of {info.record_count} — display limit)"
if capped
else f"Entries ({len(self._entries)})"
)
entry_list.border_title = count_label
self._populate_entry_list()
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
"""Live-update the info panel with the detail of the highlighted entry."""
panel = self.query_one("#info-panel", Static)
idx = event.option_index
if self._view_mode == "list":
if idx < len(self._entries):
record = self._entries[idx]
total = len(self._entries)
context = (
f"[bold {_PRIMARY}]Entry {idx + 1} of {total}[/] "
f"[dim]in[/] [bold]{self._selected_scope}[/]"
)
panel.border_title = f"Entry {idx + 1} of {total}"
panel.update(self._format_record_detail(record, context_line=context))
elif self._view_mode == "recall":
if idx < len(self._recall_matches):
match = self._recall_matches[idx]
total = len(self._recall_matches)
panel.border_title = f"Match {idx + 1} of {total}"
score_color = _PRIMARY if match.score >= 0.5 else "dim"
header_lines: list[str] = [
f"[bold {_PRIMARY}]Recall Match {idx + 1} of {total}[/]\n",
f"[dim]Score:[/] [{score_color}][bold]{match.score:.2f}[/][/]",
(
f"[dim]Match reasons:[/] "
f"{', '.join(match.match_reasons) if match.match_reasons else '-'}"
),
(
f"[dim]Evidence gaps:[/] "
f"{', '.join(match.evidence_gaps) if match.evidence_gaps else 'none'}"
),
f"\n[bold {_PRIMARY}]{'' * 44}[/]",
]
record_detail = self._format_record_detail(match.record)
header_lines.append(record_detail)
panel.update("\n".join(header_lines))
def on_input_submitted(self, event: Input.Submitted) -> None:
query = event.value.strip()
if not query:
return
if self._memory is None:
panel = self.query_one("#info-panel", Static)
panel.update(self._init_error or "No memory loaded. Cannot recall.")
return
self.run_worker(self._do_recall(query), exclusive=True)
async def _do_recall(self, query: str) -> None:
"""Execute a recall query and display results in the OptionList."""
panel = self.query_one("#info-panel", Static)
panel.loading = True
try:
scope = (
self._selected_scope
if self._selected_scope != "/"
else None
)
loop = asyncio.get_event_loop()
matches = await loop.run_in_executor(
None,
lambda: self._memory.recall(
query, scope=scope, limit=10, depth="deep"
),
)
self._recall_matches = matches or []
self._view_mode = "recall"
if not self._recall_matches:
panel.update("[dim]No memories found.[/]")
self.query_one("#entry-list", OptionList).clear_options()
return
info_lines: list[str] = []
info_lines.append(
"[dim italic]Searched the full dataset"
+ (f" within [bold]{scope}[/]" if scope else "")
+ " using the recall flow (semantic + recency + importance).[/]\n"
)
if not self._custom_embedder:
info_lines.append(
"[dim italic]Note: Using default OpenAI embedder. "
"If memories were created with a different embedder, "
"pass --embedder-provider to match.[/]\n"
)
info_lines.append(
f"[bold]Recall Results[/] [dim]"
f"({len(self._recall_matches)} matches)[/]\n"
f"[dim]Navigate the list below to view details.[/]"
)
panel.update("\n".join(info_lines))
panel.border_title = "Recall Detail"
entry_list = self.query_one("#entry-list", OptionList)
entry_list.border_title = f"Recall Results ({len(self._recall_matches)})"
self._populate_recall_list()
except Exception as e:
panel.update(f"[bold red]Error:[/] {e}")
finally:
panel.loading = False

View File

@@ -1,4 +1,4 @@
from requests import HTTPError
from httpx import HTTPStatusError
from rich.console import Console
from rich.table import Table
@@ -10,11 +10,11 @@ console = Console()
class OrganizationCommand(BaseCommand, PlusAPIMixin):
def __init__(self):
def __init__(self) -> None:
BaseCommand.__init__(self)
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
def list(self):
def list(self) -> None:
try:
response = self.plus_api_client.get_organizations()
response.raise_for_status()
@@ -33,7 +33,7 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
table.add_row(org["name"], org["uuid"])
console.print(table)
except HTTPError as e:
except HTTPStatusError as e:
if e.response.status_code == 401:
console.print(
"You are not logged in to any organization. Use 'crewai login' to login.",
@@ -50,7 +50,7 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
)
raise SystemExit(1) from e
def switch(self, org_id):
def switch(self, org_id: str) -> None:
try:
response = self.plus_api_client.get_organizations()
response.raise_for_status()
@@ -72,7 +72,7 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
f"Successfully switched to {org['name']} ({org['uuid']})",
style="bold green",
)
except HTTPError as e:
except HTTPStatusError as e:
if e.response.status_code == 401:
console.print(
"You are not logged in to any organization. Use 'crewai login' to login.",
@@ -87,7 +87,7 @@ class OrganizationCommand(BaseCommand, PlusAPIMixin):
console.print(f"Failed to switch organization: {e!s}", style="bold red")
raise SystemExit(1) from e
def current(self):
def current(self) -> None:
settings = Settings()
if settings.org_uuid:
console.print(

View File

@@ -3,7 +3,6 @@ from typing import Any
from urllib.parse import urljoin
import httpx
import requests
from crewai.cli.config import Settings
from crewai.cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
@@ -23,14 +22,15 @@ class PlusAPI:
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str) -> None:
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
@@ -43,16 +43,21 @@ class PlusAPI:
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> requests.Response:
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
session = requests.Session()
session.trust_env = False
return session.request(method, url, headers=self.headers, **kwargs)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(self) -> requests.Response:
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login")
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> requests.Response:
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
@@ -68,7 +73,7 @@ class PlusAPI:
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
) -> requests.Response:
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
@@ -79,54 +84,52 @@ class PlusAPI:
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> requests.Response:
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> requests.Response:
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> requests.Response:
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> requests.Response:
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(
self, uuid: str, log_type: str = "deployment"
) -> requests.Response:
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> requests.Response:
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> requests.Response:
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> requests.Response:
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> requests.Response:
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> requests.Response:
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> requests.Response:
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
@@ -136,7 +139,7 @@ class PlusAPI:
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
@@ -145,7 +148,7 @@ class PlusAPI:
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
@@ -155,7 +158,7 @@ class PlusAPI:
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
@@ -165,7 +168,7 @@ class PlusAPI:
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
@@ -175,7 +178,7 @@ class PlusAPI:
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
@@ -185,7 +188,7 @@ class PlusAPI:
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> requests.Response:
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
@@ -193,13 +196,20 @@ class PlusAPI:
timeout=30,
)
def get_triggers(self) -> requests.Response:
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(
self, app_slug: str, trigger_slug: str
) -> requests.Response:
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"

View File

@@ -8,7 +8,7 @@ from typing import Any
import certifi
import click
import requests
import httpx
from crewai.cli.constants import JSON_URL, MODELS, PROVIDERS
@@ -165,20 +165,20 @@ def fetch_provider_data(cache_file: Path) -> dict[str, Any] | None:
ssl_config = os.environ["SSL_CERT_FILE"] = certifi.where()
try:
response = requests.get(JSON_URL, stream=True, timeout=60, verify=ssl_config)
response.raise_for_status()
data = download_data(response)
with open(cache_file, "w") as f:
json.dump(data, f)
return data
except requests.RequestException as e:
with httpx.stream("GET", JSON_URL, timeout=60, verify=ssl_config) as response:
response.raise_for_status()
data = download_data(response)
with open(cache_file, "w") as f:
json.dump(data, f)
return data
except httpx.HTTPError as e:
click.secho(f"Error fetching provider data: {e}", fg="red")
except json.JSONDecodeError:
click.secho("Error parsing provider data. Invalid JSON format.", fg="red")
return None
def download_data(response: requests.Response) -> dict[str, Any]:
def download_data(response: httpx.Response) -> dict[str, Any]:
"""Downloads data from a given HTTP response and returns the JSON content.
Args:
@@ -194,7 +194,7 @@ def download_data(response: requests.Response) -> dict[str, Any]:
with click.progressbar(
length=total_size, label="Downloading", show_pos=True
) as bar:
for chunk in response.iter_content(block_size):
for chunk in response.iter_bytes(block_size):
if chunk:
data_chunks.append(chunk)
bar.update(len(chunk))

View File

@@ -2,43 +2,61 @@ import subprocess
import click
from crewai.cli.utils import get_crews
from crewai.cli.utils import get_crews, get_flows
from crewai.flow import Flow
def _reset_flow_memory(flow: Flow) -> None:
"""Reset memory for a single flow instance.
Handles Memory, MemoryScope (both have .reset()), and MemorySlice
(delegates to the underlying ._memory). Silently succeeds when the
storage directory does not exist yet (nothing to reset).
Args:
flow: The flow instance whose memory should be reset.
"""
mem = flow.memory
if mem is None:
return
try:
if hasattr(mem, "reset"):
mem.reset()
elif hasattr(mem, "_memory") and hasattr(mem._memory, "reset"):
mem._memory.reset()
except (FileNotFoundError, OSError):
pass
def reset_memories_command(
long,
short,
entity,
knowledge,
agent_knowledge,
kickoff_outputs,
all,
memory: bool,
knowledge: bool,
agent_knowledge: bool,
kickoff_outputs: bool,
all: bool,
) -> None:
"""
Reset the crew memories.
"""Reset the crew and flow memories.
Args:
long (bool): Whether to reset the long-term memory.
short (bool): Whether to reset the short-term memory.
entity (bool): Whether to reset the entity memory.
kickoff_outputs (bool): Whether to reset the latest kickoff task outputs.
all (bool): Whether to reset all memories.
knowledge (bool): Whether to reset the knowledge.
agent_knowledge (bool): Whether to reset the agents knowledge.
memory: Whether to reset the unified memory.
knowledge: Whether to reset the knowledge.
agent_knowledge: Whether to reset the agents knowledge.
kickoff_outputs: Whether to reset the latest kickoff task outputs.
all: Whether to reset all memories.
"""
try:
if not any(
[long, short, entity, kickoff_outputs, knowledge, agent_knowledge, all]
):
if not any([memory, kickoff_outputs, knowledge, agent_knowledge, all]):
click.echo(
"No memory type specified. Please specify at least one type to reset."
)
return
crews = get_crews()
if not crews:
raise ValueError("No crew found.")
flows = get_flows()
if not crews and not flows:
raise ValueError("No crew or flow found.")
for crew in crews:
if all:
crew.reset_memories(command_type="all")
@@ -46,20 +64,10 @@ def reset_memories_command(
f"[Crew ({crew.name if crew.name else crew.id})] Reset memories command has been completed."
)
continue
if long:
crew.reset_memories(command_type="long")
if memory:
crew.reset_memories(command_type="memory")
click.echo(
f"[Crew ({crew.name if crew.name else crew.id})] Long term memory has been reset."
)
if short:
crew.reset_memories(command_type="short")
click.echo(
f"[Crew ({crew.name if crew.name else crew.id})] Short term memory has been reset."
)
if entity:
crew.reset_memories(command_type="entity")
click.echo(
f"[Crew ({crew.name if crew.name else crew.id})] Entity memory has been reset."
f"[Crew ({crew.name if crew.name else crew.id})] Memory has been reset."
)
if kickoff_outputs:
crew.reset_memories(command_type="kickoff_outputs")
@@ -77,6 +85,20 @@ def reset_memories_command(
f"[Crew ({crew.name if crew.name else crew.id})] Agents knowledge has been reset."
)
for flow in flows:
flow_name = flow.name or flow.__class__.__name__
if all:
_reset_flow_memory(flow)
click.echo(
f"[Flow ({flow_name})] Reset memories command has been completed."
)
continue
if memory:
_reset_flow_memory(flow)
click.echo(
f"[Flow ({flow_name})] Memory has been reset."
)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while resetting the memories: {e}", err=True)
click.echo(e.output, err=True)

View File

@@ -1,7 +1,6 @@
from crewai import Agent, Crew, Process, Task
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List
# If you want to run a snippet of code before or after the crew starts,
# you can use the @before_kickoff and @after_kickoff decorators
# https://docs.crewai.com/concepts/crews#example-crew-class-with-decorators
@@ -10,8 +9,8 @@ from typing import List
class {{crew_name}}():
"""{{crew_name}} crew"""
agents: List[BaseAgent]
tasks: List[Task]
agents: list[BaseAgent]
tasks: list[Task]
# Learn more about YAML configuration files here:
# Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.9.3"
"crewai[tools]==1.10.1"
]
[project.scripts]

View File

@@ -1,5 +1,3 @@
from typing import List
from crewai import Agent, Crew, Process, Task
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.project import CrewBase, agent, crew, task
@@ -13,8 +11,8 @@ from crewai.project import CrewBase, agent, crew, task
class PoemCrew:
"""Poem Crew"""
agents: List[BaseAgent]
tasks: List[Task]
agents: list[BaseAgent]
tasks: list[Task]
# Learn more about YAML configuration files here:
# Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.9.3"
"crewai[tools]==1.10.1"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]>=0.203.1"
"crewai[tools]==1.10.1"
]
[tool.crewai]

View File

@@ -23,6 +23,7 @@ from crewai.cli.utils import (
tree_copy,
tree_find_and_replace,
)
from crewai.events.listeners.tracing.utils import get_user_id
console = Console()
@@ -169,7 +170,9 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
console.print(f"Successfully installed {handle}", style="bold green")
def login(self) -> None:
login_response = self.plus_api_client.login_to_tool_repository()
login_response = self.plus_api_client.login_to_tool_repository(
user_identifier=get_user_id()
)
if login_response.status_code != 200:
console.print(

View File

@@ -386,6 +386,109 @@ def fetch_crews(module_attr: Any) -> list[Crew]:
return crew_instances
def get_flow_instance(module_attr: Any) -> Flow | None:
"""Check if a module attribute is a user-defined Flow subclass and return an instance.
Args:
module_attr: An attribute from a loaded module.
Returns:
A Flow instance if the attribute is a valid user-defined Flow subclass,
None otherwise.
"""
if (
isinstance(module_attr, type)
and issubclass(module_attr, Flow)
and module_attr is not Flow
):
try:
return module_attr()
except Exception:
return None
return None
_SKIP_DIRS = frozenset(
{".venv", "venv", ".git", "__pycache__", "node_modules", ".tox", ".nox"}
)
def get_flows(flow_path: str = "main.py") -> list[Flow]:
"""Get the flow instances from project files.
Walks the project directory looking for files matching ``flow_path``
(default ``main.py``), loads each module, and extracts Flow subclass
instances. Directories that are clearly not user source code (virtual
environments, ``.git``, etc.) are pruned to avoid noisy import errors.
Args:
flow_path: Filename to search for (default ``main.py``).
Returns:
A list of discovered Flow instances.
"""
flow_instances: list[Flow] = []
try:
current_dir = os.getcwd()
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
src_dir = os.path.join(current_dir, "src")
if os.path.isdir(src_dir) and src_dir not in sys.path:
sys.path.insert(0, src_dir)
search_paths = [".", "src"] if os.path.isdir("src") else ["."]
for search_path in search_paths:
for root, dirs, files in os.walk(search_path):
dirs[:] = [
d
for d in dirs
if d not in _SKIP_DIRS and not d.startswith(".")
]
if flow_path in files and "cli/templates" not in root:
file_os_path = os.path.join(root, flow_path)
try:
spec = importlib.util.spec_from_file_location(
"flow_module", file_os_path
)
if not spec or not spec.loader:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
for attr_name in dir(module):
module_attr = getattr(module, attr_name)
try:
if flow_instance := get_flow_instance(
module_attr
):
flow_instances.append(flow_instance)
except Exception: # noqa: S112
continue
if flow_instances:
break
except Exception: # noqa: S112
continue
except (ImportError, AttributeError):
continue
if flow_instances:
break
except Exception: # noqa: S110
pass
return flow_instances
def is_valid_tool(obj: Any) -> bool:
from crewai.tools.base_tool import Tool

View File

@@ -84,10 +84,6 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.memory.entity.entity_memory import EntityMemory
from crewai.memory.external.external_memory import ExternalMemory
from crewai.memory.long_term.long_term_memory import LongTermMemory
from crewai.memory.short_term.short_term_memory import ShortTermMemory
from crewai.process import Process
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.rag.types import SearchResult
@@ -175,10 +171,7 @@ class Crew(FlowTrackable, BaseModel):
_logger: Logger = PrivateAttr()
_file_handler: FileHandler = PrivateAttr()
_cache_handler: InstanceOf[CacheHandler] = PrivateAttr(default_factory=CacheHandler)
_short_term_memory: InstanceOf[ShortTermMemory] | None = PrivateAttr()
_long_term_memory: InstanceOf[LongTermMemory] | None = PrivateAttr()
_entity_memory: InstanceOf[EntityMemory] | None = PrivateAttr()
_external_memory: InstanceOf[ExternalMemory] | None = PrivateAttr()
_memory: Any = PrivateAttr(default=None) # Unified Memory | MemoryScope
_train: bool | None = PrivateAttr(default=False)
_train_iteration: int | None = PrivateAttr()
_inputs: dict[str, Any] | None = PrivateAttr(default=None)
@@ -196,25 +189,12 @@ class Crew(FlowTrackable, BaseModel):
agents: list[BaseAgent] = Field(default_factory=list)
process: Process = Field(default=Process.sequential)
verbose: bool = Field(default=False)
memory: bool = Field(
memory: bool | Any = Field(
default=False,
description="If crew should use memory to store memories of it's execution",
)
short_term_memory: InstanceOf[ShortTermMemory] | None = Field(
default=None,
description="An Instance of the ShortTermMemory to be used by the Crew",
)
long_term_memory: InstanceOf[LongTermMemory] | None = Field(
default=None,
description="An Instance of the LongTermMemory to be used by the Crew",
)
entity_memory: InstanceOf[EntityMemory] | None = Field(
default=None,
description="An Instance of the EntityMemory to be used by the Crew",
)
external_memory: InstanceOf[ExternalMemory] | None = Field(
default=None,
description="An Instance of the ExternalMemory to be used by the Crew",
description=(
"Enable crew memory. Pass True for default Memory(), "
"or a Memory/MemoryScope/MemorySlice instance for custom configuration."
),
)
embedder: EmbedderConfig | None = Field(
default=None,
@@ -377,31 +357,23 @@ class Crew(FlowTrackable, BaseModel):
return self
def _initialize_default_memories(self) -> None:
self._long_term_memory = self._long_term_memory or LongTermMemory()
self._short_term_memory = self._short_term_memory or ShortTermMemory(
crew=self,
embedder_config=self.embedder,
)
self._entity_memory = self.entity_memory or EntityMemory(
crew=self, embedder_config=self.embedder
)
@model_validator(mode="after")
def create_crew_memory(self) -> Crew:
"""Initialize private memory attributes."""
self._external_memory = (
# External memory does not support a default value since it was
# designed to be managed entirely externally
self.external_memory.set_crew(self) if self.external_memory else None
)
"""Initialize unified memory, respecting crew embedder config."""
if self.memory is True:
from crewai.memory.unified_memory import Memory
self._long_term_memory = self.long_term_memory
self._short_term_memory = self.short_term_memory
self._entity_memory = self.entity_memory
embedder = None
if self.embedder is not None:
from crewai.rag.embeddings.factory import build_embedder
if self.memory:
self._initialize_default_memories()
embedder = build_embedder(cast(dict[str, Any], self.embedder))
self._memory = Memory(embedder=embedder)
elif self.memory:
# User passed a Memory / MemoryScope / MemorySlice instance
self._memory = self.memory
else:
self._memory = None
return self
@@ -773,6 +745,9 @@ class Crew(FlowTrackable, BaseModel):
)
raise
finally:
# Ensure all background memory saves complete before returning
if self._memory is not None and hasattr(self._memory, "drain_writes"):
self._memory.drain_writes()
clear_files(self.id)
detach(token)
@@ -1328,6 +1303,11 @@ class Crew(FlowTrackable, BaseModel):
if agent and (hasattr(agent, "mcps") and getattr(agent, "mcps", None)):
tools = self._add_mcp_tools(task, tools)
# Add memory tools if memory is available (agent or crew level)
resolved_memory = getattr(agent, "memory", None) or self._memory
if resolved_memory is not None:
tools = self._add_memory_tools(tools, resolved_memory)
files = get_all_files(self.id, task.id)
if files:
supported_types: list[str] = []
@@ -1435,6 +1415,20 @@ class Crew(FlowTrackable, BaseModel):
return self._merge_tools(tools, cast(list[BaseTool], code_tools))
return tools
def _add_memory_tools(self, tools: list[BaseTool], memory: Any) -> list[BaseTool]:
"""Add recall and remember tools when memory is available.
Args:
tools: Current list of tools.
memory: The resolved Memory, MemoryScope, or MemorySlice instance.
Returns:
Updated list with memory tools added.
"""
from crewai.tools.memory_tools import create_memory_tools
return self._merge_tools(tools, create_memory_tools(memory))
def _add_file_tools(
self, tools: list[BaseTool], files: dict[str, Any]
) -> list[BaseTool]:
@@ -1679,10 +1673,7 @@ class Crew(FlowTrackable, BaseModel):
"_execution_span",
"_file_handler",
"_cache_handler",
"_short_term_memory",
"_long_term_memory",
"_entity_memory",
"_external_memory",
"_memory",
"agents",
"tasks",
"knowledge_sources",
@@ -1716,18 +1707,8 @@ class Crew(FlowTrackable, BaseModel):
copied_data = self.model_dump(exclude=exclude)
copied_data = {k: v for k, v in copied_data.items() if v is not None}
if self.short_term_memory:
copied_data["short_term_memory"] = self.short_term_memory.model_copy(
deep=True
)
if self.long_term_memory:
copied_data["long_term_memory"] = self.long_term_memory.model_copy(
deep=True
)
if self.entity_memory:
copied_data["entity_memory"] = self.entity_memory.model_copy(deep=True)
if self.external_memory:
copied_data["external_memory"] = self.external_memory.model_copy(deep=True)
if getattr(self, "_memory", None):
copied_data["memory"] = self._memory
copied_data.pop("agents", None)
copied_data.pop("tasks", None)
@@ -1858,23 +1839,24 @@ class Crew(FlowTrackable, BaseModel):
Args:
command_type: Type of memory to reset.
Valid options: 'long', 'short', 'entity', 'knowledge', 'agent_knowledge'
'kickoff_outputs', or 'all'
Valid options: 'memory', 'knowledge', 'agent_knowledge',
'kickoff_outputs', or 'all'. Legacy names 'long', 'short',
'entity', 'external' are treated as 'memory'.
Raises:
ValueError: If an invalid command type is provided.
RuntimeError: If memory reset operation fails.
"""
legacy_memory = frozenset(["long", "short", "entity", "external"])
if command_type in legacy_memory:
command_type = "memory"
valid_types = frozenset(
[
"long",
"short",
"entity",
"memory",
"knowledge",
"agent_knowledge",
"kickoff_outputs",
"all",
"external",
]
)
@@ -1980,25 +1962,10 @@ class Crew(FlowTrackable, BaseModel):
) + agent_knowledges
return {
"short": {
"system": getattr(self, "_short_term_memory", None),
"memory": {
"system": getattr(self, "_memory", None),
"reset": default_reset,
"name": "Short Term",
},
"entity": {
"system": getattr(self, "_entity_memory", None),
"reset": default_reset,
"name": "Entity",
},
"external": {
"system": getattr(self, "_external_memory", None),
"reset": default_reset,
"name": "External",
},
"long": {
"system": getattr(self, "_long_term_memory", None),
"reset": default_reset,
"name": "Long Term",
"name": "Memory",
},
"kickoff_outputs": {
"system": getattr(self, "_task_output_handler", None),

View File

@@ -1,5 +1,4 @@
from crewai.crews.crew_output import CrewOutput
__all__ = ["CrewOutput"]

View File

@@ -63,6 +63,7 @@ from crewai.events.types.logging_events import (
AgentLogsStartedEvent,
)
from crewai.events.types.mcp_events import (
MCPConfigFetchFailedEvent,
MCPConnectionCompletedEvent,
MCPConnectionFailedEvent,
MCPConnectionStartedEvent,
@@ -173,6 +174,7 @@ __all__ = [
"LiteAgentExecutionCompletedEvent",
"LiteAgentExecutionErrorEvent",
"LiteAgentExecutionStartedEvent",
"MCPConfigFetchFailedEvent",
"MCPConnectionCompletedEvent",
"MCPConnectionFailedEvent",
"MCPConnectionStartedEvent",

View File

@@ -23,4 +23,3 @@ class BaseEventListener(ABC):
Args:
crewai_event_bus: The event bus to register listeners on.
"""
pass

View File

@@ -68,6 +68,7 @@ from crewai.events.types.logging_events import (
AgentLogsStartedEvent,
)
from crewai.events.types.mcp_events import (
MCPConfigFetchFailedEvent,
MCPConnectionCompletedEvent,
MCPConnectionFailedEvent,
MCPConnectionStartedEvent,
@@ -665,6 +666,16 @@ class EventListener(BaseEventListener):
event.error_type,
)
@crewai_event_bus.on(MCPConfigFetchFailedEvent)
def on_mcp_config_fetch_failed(
_: Any, event: MCPConfigFetchFailedEvent
) -> None:
self.formatter.handle_mcp_config_fetch_failed(
event.slug,
event.error,
event.error_type,
)
@crewai_event_bus.on(MCPToolExecutionStartedEvent)
def on_mcp_tool_execution_started(
_: Any, event: MCPToolExecutionStartedEvent

View File

@@ -67,6 +67,7 @@ from crewai.events.types.llm_guardrail_events import (
LLMGuardrailStartedEvent,
)
from crewai.events.types.mcp_events import (
MCPConfigFetchFailedEvent,
MCPConnectionCompletedEvent,
MCPConnectionFailedEvent,
MCPConnectionStartedEvent,
@@ -181,4 +182,5 @@ EventTypes = (
| MCPToolExecutionStartedEvent
| MCPToolExecutionCompletedEvent
| MCPToolExecutionFailedEvent
| MCPConfigFetchFailedEvent
)

View File

@@ -15,6 +15,7 @@ from crewai.cli.plus_api import PlusAPI
from crewai.cli.version import get_crewai_version
from crewai.events.listeners.tracing.types import TraceEvent
from crewai.events.listeners.tracing.utils import (
get_user_id,
is_tracing_enabled_in_context,
should_auto_collect_first_time_traces,
)
@@ -67,7 +68,7 @@ class TraceBatchManager:
api_key=get_auth_token(),
)
except AuthError:
self.plus_api = PlusAPI(api_key="")
self.plus_api = PlusAPI()
self.ephemeral_trace_url = None
def initialize_batch(
@@ -120,7 +121,6 @@ class TraceBatchManager:
payload = {
"trace_id": self.current_batch.batch_id,
"execution_type": execution_metadata.get("execution_type", "crew"),
"user_identifier": execution_metadata.get("user_context", None),
"execution_context": {
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
"crew_name": execution_metadata.get("crew_name", None),
@@ -140,6 +140,7 @@ class TraceBatchManager:
}
if use_ephemeral:
payload["ephemeral_trace_id"] = self.current_batch.batch_id
payload["user_identifier"] = get_user_id()
response = (
self.plus_api.initialize_ephemeral_trace_batch(payload)

View File

@@ -120,6 +120,52 @@ class FlowPlotEvent(FlowEvent):
type: str = "flow_plot"
class FlowInputRequestedEvent(FlowEvent):
"""Event emitted when a flow requests user input via ``Flow.ask()``.
This event is emitted before the flow suspends waiting for user input,
allowing UI frameworks and observability tools to know when a flow
needs user interaction.
Attributes:
flow_name: Name of the flow requesting input.
method_name: Name of the flow method that called ``ask()``.
message: The question or prompt being shown to the user.
metadata: Optional metadata sent with the question (e.g., user ID,
channel, session context).
"""
method_name: str
message: str
metadata: dict[str, Any] | None = None
type: str = "flow_input_requested"
class FlowInputReceivedEvent(FlowEvent):
"""Event emitted when user input is received after ``Flow.ask()``.
This event is emitted after the user provides input (or the request
times out), allowing UI frameworks and observability tools to track
input collection.
Attributes:
flow_name: Name of the flow that received input.
method_name: Name of the flow method that called ``ask()``.
message: The original question or prompt.
response: The user's response, or None if timed out / unavailable.
metadata: Optional metadata sent with the question.
response_metadata: Optional metadata from the provider about the
response (e.g., who responded, thread ID, timestamps).
"""
method_name: str
message: str
response: str | None = None
metadata: dict[str, Any] | None = None
response_metadata: dict[str, Any] | None = None
type: str = "flow_input_received"
class HumanFeedbackRequestedEvent(FlowEvent):
"""Event emitted when human feedback is requested.

View File

@@ -86,3 +86,11 @@ class LLMStreamChunkEvent(LLMEventBase):
tool_call: ToolCall | None = None
call_type: LLMCallType | None = None
response_id: str | None = None
class LLMThinkingChunkEvent(LLMEventBase):
"""Event emitted when a thinking/reasoning chunk is received from a thinking model"""
type: str = "llm_thinking_chunk"
chunk: str
response_id: str | None = None

View File

@@ -83,3 +83,16 @@ class MCPToolExecutionFailedEvent(MCPEvent):
error_type: str | None = None # "timeout", "validation", "server_error", etc.
started_at: datetime | None = None
failed_at: datetime | None = None
class MCPConfigFetchFailedEvent(BaseEvent):
"""Event emitted when fetching an AMP MCP server config fails.
This covers cases where the slug is not connected, the API call
failed, or native MCP resolution failed after config was fetched.
"""
type: str = "mcp_config_fetch_failed"
slug: str
error: str
error_type: str | None = None # "not_connected", "api_error", "connection_failed"

View File

@@ -170,16 +170,16 @@ To enable tracing, do any one of these:
"""Create standardized status content with consistent formatting."""
content = Text()
content.append(f"{title}\n", style=f"{status_style} bold")
content.append("Name: \n", style="white")
content.append("Name: ", style="white")
content.append(f"{name}\n", style=status_style)
for label, value in fields.items():
content.append(f"{label}: \n", style="white")
content.append(f"{label}: ", style="white")
content.append(
f"{value}\n", style=fields.get(f"{label}_style", status_style)
)
if tool_args:
content.append("Tool Args: \n", style="white")
content.append("Tool Args: ", style="white")
content.append(f"{tool_args}\n", style=status_style)
return content
@@ -737,6 +737,27 @@ To enable tracing, do any one of these:
self.print_panel(content, title, style)
@staticmethod
def _simplify_tools_field(fields: dict[str, Any]) -> dict[str, Any]:
"""Simplify the tools field to show only tool names instead of full definitions.
Args:
fields: Dictionary of fields that may contain a 'tools' key with
full tool objects.
Returns:
The fields dictionary with 'tools' replaced by a comma-separated
string of tool names.
"""
if "tools" in fields:
tools = fields["tools"]
if tools:
tool_names = [getattr(t, "name", str(t)) for t in tools]
fields["tools"] = ", ".join(tool_names) if tool_names else "None"
else:
fields["tools"] = "None"
return fields
def handle_lite_agent_execution(
self,
lite_agent_role: str,
@@ -748,6 +769,8 @@ To enable tracing, do any one of these:
if not self.verbose:
return
fields = self._simplify_tools_field(fields)
if status == "started":
self.create_lite_agent_branch(lite_agent_role)
if fields:
@@ -1489,6 +1512,34 @@ To enable tracing, do any one of these:
self.print(panel)
self.print()
def handle_mcp_config_fetch_failed(
self,
slug: str,
error: str = "",
error_type: str | None = None,
) -> None:
"""Handle MCP config fetch failed event (AMP resolution failures)."""
if not self.verbose:
return
content = Text()
content.append("MCP Config Fetch Failed\n\n", style="red bold")
content.append("Server: ", style="white")
content.append(f"{slug}\n", style="red")
if error_type:
content.append("Error Type: ", style="white")
content.append(f"{error_type}\n", style="red")
if error:
content.append("\nError: ", style="white bold")
error_preview = error[:500] + "..." if len(error) > 500 else error
content.append(f"{error_preview}\n", style="red")
panel = self.create_panel(content, "❌ MCP Config Failed", "red")
self.print(panel)
self.print()
def handle_mcp_tool_execution_started(
self,
server_name: str,

View File

@@ -1,7 +1,11 @@
from __future__ import annotations
import asyncio
import contextvars
from collections.abc import Callable, Coroutine
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
import inspect
import json
import threading
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -49,6 +53,8 @@ from crewai.hooks.types import (
BeforeLLMCallHookCallable,
BeforeLLMCallHookType,
)
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.utilities.agent_utils import (
convert_tools_to_openai_schema,
enforce_rpm_limit,
@@ -63,6 +69,7 @@ from crewai.utilities.agent_utils import (
has_reached_max_iterations,
is_context_length_exceeded,
is_inside_event_loop,
parse_tool_call_args,
process_llm_response,
track_delegation_if_needed,
)
@@ -81,8 +88,6 @@ if TYPE_CHECKING:
from crewai.crew import Crew
from crewai.llms.base_llm import BaseLLM
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_types import ToolResult
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
@@ -298,6 +303,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
super().__init__(
suppress_flow_events=True,
tracing=current_tracing if current_tracing else None,
max_method_calls=self.max_iter * 10,
)
self._flow_initialized = True
@@ -317,7 +323,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
def _setup_native_tools(self) -> None:
"""Convert tools to OpenAI schema format for native function calling."""
if self.original_tools:
self._openai_tools, self._available_functions = (
self._openai_tools, self._available_functions, self._tool_name_mapping = (
convert_tools_to_openai_schema(self.original_tools)
)
@@ -399,7 +405,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
self._setup_native_tools()
return "initialized"
@listen("force_final_answer")
@listen("max_iterations_exceeded")
def force_final_answer(self) -> Literal["agent_finished"]:
"""Force agent to provide final answer when max iterations exceeded."""
formatted_answer = handle_max_iterations_exceeded(
@@ -590,21 +596,19 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
def execute_tool_action(self) -> Literal["tool_completed", "tool_result_is_final"]:
"""Execute the tool action and handle the result."""
action = cast(AgentAction, self.state.current_answer)
fingerprint_context = {}
if (
self.agent
and hasattr(self.agent, "security_config")
and hasattr(self.agent.security_config, "fingerprint")
):
fingerprint_context = {
"agent_fingerprint": str(self.agent.security_config.fingerprint)
}
try:
action = cast(AgentAction, self.state.current_answer)
# Extract fingerprint context for tool execution
fingerprint_context = {}
if (
self.agent
and hasattr(self.agent, "security_config")
and hasattr(self.agent.security_config, "fingerprint")
):
fingerprint_context = {
"agent_fingerprint": str(self.agent.security_config.fingerprint)
}
# Execute the tool
tool_result = execute_tool_and_check_finality(
agent_action=action,
fingerprint_context=fingerprint_context,
@@ -618,24 +622,19 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
function_calling_llm=self.function_calling_llm,
crew=self.crew,
)
except Exception as e:
if self.agent and self.agent.verbose:
self._printer.print(
content=f"Error in tool execution: {e}", color="red"
)
if self.task:
self.task.increment_tools_errors()
# Handle agent action and append observation to messages
result = self._handle_agent_action(action, tool_result)
self.state.current_answer = result
error_observation = f"\nObservation: Error executing tool: {e}"
action.text += error_observation
action.result = str(e)
self._append_message_to_state(action.text)
# Invoke step callback if configured
self._invoke_step_callback(result)
# Append result message to conversation state
if hasattr(result, "text"):
self._append_message_to_state(result.text)
# Check if tool result became a final answer (result_as_answer flag)
if isinstance(result, AgentFinish):
self.state.is_finished = True
return "tool_result_is_final"
# Inject post-tool reasoning prompt to enforce analysis
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message: LLMMessage = {
"role": "user",
@@ -645,12 +644,26 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
return "tool_completed"
except Exception as e:
error_text = Text()
error_text.append("❌ Error in tool execution: ", style="red bold")
error_text.append(str(e), style="red")
self._console.print(error_text)
raise
result = self._handle_agent_action(action, tool_result)
self.state.current_answer = result
self._invoke_step_callback(result)
if hasattr(result, "text"):
self._append_message_to_state(result.text)
if isinstance(result, AgentFinish):
self.state.is_finished = True
return "tool_result_is_final"
reasoning_prompt = self._i18n.slice("post_tool_reasoning")
reasoning_message_post: LLMMessage = {
"role": "user",
"content": reasoning_prompt,
}
self.state.messages.append(reasoning_message_post)
return "tool_completed"
@listen("native_tool_calls")
def execute_native_tool(
@@ -668,9 +681,12 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
if not self.state.pending_tool_calls:
return "native_tool_completed"
pending_tool_calls = list(self.state.pending_tool_calls)
self.state.pending_tool_calls.clear()
# Group all tool calls into a single assistant message
tool_calls_to_report = []
for tool_call in self.state.pending_tool_calls:
for tool_call in pending_tool_calls:
info = extract_tool_call_info(tool_call)
if not info:
continue
@@ -695,202 +711,99 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
"content": None,
"tool_calls": tool_calls_to_report,
}
if all(
type(tc).__qualname__ == "Part" for tc in self.state.pending_tool_calls
):
assistant_message["raw_tool_call_parts"] = list(
self.state.pending_tool_calls
)
if all(type(tc).__qualname__ == "Part" for tc in pending_tool_calls):
assistant_message["raw_tool_call_parts"] = list(pending_tool_calls)
self.state.messages.append(assistant_message)
# Now execute each tool
while self.state.pending_tool_calls:
tool_call = self.state.pending_tool_calls.pop(0)
info = extract_tool_call_info(tool_call)
if not info:
continue
runnable_tool_calls = [
tool_call
for tool_call in pending_tool_calls
if extract_tool_call_info(tool_call) is not None
]
should_parallelize = self._should_parallelize_native_tool_calls(
runnable_tool_calls
)
call_id, func_name, func_args = info
# Parse arguments
if isinstance(func_args, str):
try:
args_dict = json.loads(func_args)
except json.JSONDecodeError:
args_dict = {}
else:
args_dict = func_args
# Get agent_key for event tracking
agent_key = (
getattr(self.agent, "key", "unknown") if self.agent else "unknown"
)
# Find original tool by matching sanitized name (needed for cache_function and result_as_answer)
original_tool = None
for tool in self.original_tools or []:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
# Check if tool has reached max usage count
max_usage_reached = False
if (
original_tool
and original_tool.max_usage_count is not None
and original_tool.current_usage_count >= original_tool.max_usage_count
):
max_usage_reached = True
# Check cache before executing
from_cache = False
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
execution_results: list[dict[str, Any]] = []
if should_parallelize:
max_workers = min(8, len(runnable_tool_calls))
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_idx = {
pool.submit(contextvars.copy_context().run, self._execute_single_native_tool_call, tool_call): idx
for idx, tool_call in enumerate(runnable_tool_calls)
}
ordered_results: list[dict[str, Any] | None] = [None] * len(
runnable_tool_calls
)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
from_cache = True
# Emit tool usage started event
started_at = datetime.now()
crewai_event_bus.emit(
self,
event=ToolUsageStartedEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
),
)
error_event_emitted = False
track_delegation_if_needed(func_name, args_dict, self.task)
structured_tool: CrewStructuredTool | None = None
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=self.agent,
task=self.task,
crew=self.crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
hook_result = hook(before_hook_context)
if hook_result is False:
hook_blocked = True
break
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
elif not from_cache and not max_usage_reached:
result = "Tool not found"
if func_name in self._available_functions:
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
# Convert to string for message
result = (
str(raw_result)
if not isinstance(raw_result, str)
else raw_result
)
ordered_results[idx] = future.result()
except Exception as e:
result = f"Error executing tool: {e}"
if self.task:
self.task.increment_tools_errors()
# Emit tool usage error event
crewai_event_bus.emit(
self,
event=ToolUsageErrorEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
error=e,
),
)
error_event_emitted = True
elif max_usage_reached and original_tool:
# Return error message when max usage limit is reached
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
tool_call = runnable_tool_calls[idx]
info = extract_tool_call_info(tool_call)
call_id = info[0] if info else "unknown"
func_name = info[1] if info else "unknown"
ordered_results[idx] = {
"call_id": call_id,
"func_name": func_name,
"result": f"Error executing tool: {e}",
"from_cache": False,
"original_tool": None,
}
execution_results = [
result for result in ordered_results if result is not None
]
else:
# Execute sequentially so result_as_answer tools can short-circuit
# immediately without running remaining calls.
for tool_call in runnable_tool_calls:
execution_result = self._execute_single_native_tool_call(tool_call)
call_id = cast(str, execution_result["call_id"])
func_name = cast(str, execution_result["func_name"])
result = cast(str, execution_result["result"])
from_cache = cast(bool, execution_result["from_cache"])
original_tool = execution_result["original_tool"]
# Execute after_tool_call hooks (even if blocked, to allow logging/monitoring)
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=self.agent,
task=self.task,
crew=self.crew,
tool_result=result,
)
after_hooks = get_after_tool_call_hooks()
try:
for after_hook in after_hooks:
after_hook_result = after_hook(after_hook_context)
if after_hook_result is not None:
result = after_hook_result
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
tool_message: LLMMessage = {
"role": "tool",
"tool_call_id": call_id,
"name": func_name,
"content": result,
}
self.state.messages.append(tool_message)
# Log the tool execution
if self.agent and self.agent.verbose:
cache_info = " (from cache)" if from_cache else ""
self._printer.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
color="green",
)
if not error_event_emitted:
crewai_event_bus.emit(
self,
event=ToolUsageFinishedEvent(
if (
original_tool
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
):
self.state.current_answer = AgentFinish(
thought="Tool result is the final answer",
output=result,
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
started_at=started_at,
finished_at=datetime.now(),
),
)
text=result,
)
self.state.is_finished = True
return "tool_result_is_final"
# Append tool result message
tool_message: LLMMessage = {
return "native_tool_completed"
for execution_result in execution_results:
call_id = cast(str, execution_result["call_id"])
func_name = cast(str, execution_result["func_name"])
result = cast(str, execution_result["result"])
from_cache = cast(bool, execution_result["from_cache"])
original_tool = execution_result["original_tool"]
tool_message = {
"role": "tool",
"tool_call_id": call_id,
"name": func_name,
@@ -922,6 +835,249 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
return "native_tool_completed"
def _should_parallelize_native_tool_calls(self, tool_calls: list[Any]) -> bool:
"""Determine if native tool calls are safe to run in parallel."""
if len(tool_calls) <= 1:
return False
for tool_call in tool_calls:
info = extract_tool_call_info(tool_call)
if not info:
continue
_, func_name, _ = info
mapping = getattr(self, "_tool_name_mapping", None)
original_tool: BaseTool | None = None
if mapping and func_name in mapping:
mapped = mapping[func_name]
if isinstance(mapped, BaseTool):
original_tool = mapped
if original_tool is None:
for tool in self.original_tools or []:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
if not original_tool:
continue
if getattr(original_tool, "result_as_answer", False):
return False
if getattr(original_tool, "max_usage_count", None) is not None:
return False
return True
def _execute_single_native_tool_call(self, tool_call: Any) -> dict[str, Any]:
"""Execute a single native tool call and return metadata/result."""
info = extract_tool_call_info(tool_call)
if not info:
call_id = (
getattr(tool_call, "id", None)
or (tool_call.get("id") if isinstance(tool_call, dict) else None)
or "unknown"
)
return {
"call_id": call_id,
"func_name": "unknown",
"result": "Error: Invalid native tool call format",
"from_cache": False,
"original_tool": None,
}
call_id, func_name, func_args = info
# Parse arguments
parsed_args, parse_error = parse_tool_call_args(func_args, func_name, call_id)
if parse_error is not None:
return parse_error
args_dict: dict[str, Any] = parsed_args or {}
# Get agent_key for event tracking
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
original_tool: BaseTool | None = None
mapping = getattr(self, "_tool_name_mapping", None)
if mapping and func_name in mapping:
mapped = mapping[func_name]
if isinstance(mapped, BaseTool):
original_tool = mapped
if original_tool is None:
for tool in self.original_tools or []:
if sanitize_tool_name(tool.name) == func_name:
original_tool = tool
break
# Check if tool has reached max usage count
max_usage_reached = False
if (
original_tool
and original_tool.max_usage_count is not None
and original_tool.current_usage_count >= original_tool.max_usage_count
):
max_usage_reached = True
# Check cache before executing
from_cache = False
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
from_cache = True
# Emit tool usage started event
started_at = datetime.now()
crewai_event_bus.emit(
self,
event=ToolUsageStartedEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
),
)
error_event_emitted = False
track_delegation_if_needed(func_name, args_dict, self.task)
structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=self.agent,
task=self.task,
crew=self.crew,
)
before_hooks = get_before_tool_call_hooks()
try:
for hook in before_hooks:
hook_result = hook(before_hook_context)
if hook_result is False:
hook_blocked = True
break
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
elif not from_cache and not max_usage_reached:
result = "Tool not found"
if func_name in self._available_functions:
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
should_cache = True
if original_tool:
should_cache = original_tool.cache_function(
args_dict, raw_result
)
if should_cache:
self.tools_handler.cache.add(
tool=func_name, input=input_str, output=raw_result
)
# Convert to string for message
result = (
str(raw_result)
if not isinstance(raw_result, str)
else raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
if self.task:
self.task.increment_tools_errors()
# Emit tool usage error event
crewai_event_bus.emit(
self,
event=ToolUsageErrorEvent(
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
error=e,
),
)
error_event_emitted = True
elif max_usage_reached and original_tool:
# Return error message when max usage limit is reached
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
# Execute after_tool_call hooks (even if blocked, to allow logging/monitoring)
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
agent=self.agent,
task=self.task,
crew=self.crew,
tool_result=result,
)
after_hooks = get_after_tool_call_hooks()
try:
for after_hook in after_hooks:
after_hook_result = after_hook(after_hook_context)
if after_hook_result is not None:
result = after_hook_result
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
)
if not error_event_emitted:
crewai_event_bus.emit(
self,
event=ToolUsageFinishedEvent(
output=result,
tool_name=func_name,
tool_args=args_dict,
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
started_at=started_at,
finished_at=datetime.now(),
),
)
return {
"call_id": call_id,
"func_name": func_name,
"result": result,
"from_cache": from_cache,
"original_tool": original_tool,
}
def _extract_tool_name(self, tool_call: Any) -> str:
"""Extract tool name from various tool call formats."""
if hasattr(tool_call, "function"):
@@ -954,11 +1110,11 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
def check_max_iterations(
self,
) -> Literal[
"force_final_answer", "continue_reasoning", "continue_reasoning_native"
"max_iterations_exceeded", "continue_reasoning", "continue_reasoning_native"
]:
"""Check if max iterations reached before proceeding with reasoning."""
if has_reached_max_iterations(self.state.iterations, self.max_iter):
return "force_final_answer"
return "max_iterations_exceeded"
if self.state.use_native_tools:
return "continue_reasoning_native"
return "continue_reasoning"
@@ -1106,9 +1262,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
if self.state.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
self._create_short_term_memory(formatted_answer)
self._create_long_term_memory(formatted_answer)
self._create_external_memory(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}
@@ -1191,9 +1345,7 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
if self.state.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(formatted_answer)
self._create_short_term_memory(formatted_answer)
self._create_long_term_memory(formatted_answer)
self._create_external_memory(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}
@@ -1256,7 +1408,9 @@ class AgentExecutor(Flow[AgentReActState], CrewAgentExecutorMixin):
formatted_answer: Current agent response.
"""
if self.step_callback:
self.step_callback(formatted_answer)
cb_result = self.step_callback(formatted_answer)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
def _append_message_to_state(
self, text: str, role: Literal["user", "assistant", "system"] = "assistant"

View File

@@ -7,6 +7,7 @@ from crewai.flow.async_feedback import (
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.flow_config import flow_config
from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback
from crewai.flow.input_provider import InputProvider, InputResponse
from crewai.flow.persistence import persist
from crewai.flow.visualization import (
FlowStructure,
@@ -22,6 +23,8 @@ __all__ = [
"HumanFeedbackPending",
"HumanFeedbackProvider",
"HumanFeedbackResult",
"InputProvider",
"InputResponse",
"PendingFeedbackContext",
"and_",
"build_flow_structure",

View File

@@ -1,7 +1,8 @@
"""Default provider implementations for human feedback.
"""Default provider implementations for human feedback and user input.
This module provides the ConsoleProvider, which is the default synchronous
provider that collects feedback via console input.
provider that collects both feedback (for ``@human_feedback``) and user input
(for ``Flow.ask()``) via console.
"""
from __future__ import annotations
@@ -16,20 +17,23 @@ if TYPE_CHECKING:
class ConsoleProvider:
"""Default synchronous console-based feedback provider.
"""Default synchronous console-based provider for feedback and input.
This provider blocks execution and waits for console input from the user.
It displays the method output with formatting and prompts for feedback.
It serves two purposes:
- **Feedback** (``request_feedback``): Used by ``@human_feedback`` to
display method output and collect review feedback.
- **Input** (``request_input``): Used by ``Flow.ask()`` to prompt the
user with a question and collect a response.
This is the default provider used when no custom provider is specified
in the @human_feedback decorator.
in the ``@human_feedback`` decorator or on the Flow's ``input_provider``.
Example:
Example (feedback):
```python
from crewai.flow.async_feedback import ConsoleProvider
# Explicitly use console provider
@human_feedback(
message="Review this:",
provider=ConsoleProvider(),
@@ -37,9 +41,20 @@ class ConsoleProvider:
def my_method(self):
return "Content to review"
```
Example (input):
```python
from crewai.flow import Flow, start
class MyFlow(Flow):
@start()
def gather_info(self):
topic = self.ask("What topic should we research?")
return topic
```
"""
def __init__(self, verbose: bool = True):
def __init__(self, verbose: bool = True) -> None:
"""Initialize the console provider.
Args:
@@ -124,3 +139,55 @@ class ConsoleProvider:
finally:
# Resume live updates
formatter.resume_live_updates()
def request_input(
self,
message: str,
flow: Flow[Any],
metadata: dict[str, Any] | None = None,
) -> str | None:
"""Request user input via console (blocking).
Displays the prompt message with formatting and waits for the user
to type their response. Used by ``Flow.ask()``.
Unlike ``request_feedback``, this method does not display an
"OUTPUT FOR REVIEW" panel or emit feedback-specific events (those
are handled by ``ask()`` itself).
Args:
message: The question or prompt to display to the user.
flow: The Flow instance requesting input.
metadata: Optional metadata from the caller. Ignored by the
console provider (console has no concept of user routing).
Returns:
The user's input as a stripped string. Returns empty string
if user presses Enter without input. Never returns None
(console input is always available).
"""
from crewai.events.event_listener import event_listener
# Pause live updates during human input
formatter = event_listener.formatter
formatter.pause_live_updates()
try:
console = formatter.console
if self.verbose:
console.print()
console.print(message, style="yellow")
console.print()
response = input(">>> \n").strip()
else:
response = input(f"{message} ").strip()
# Add line break after input so formatter output starts clean
console.print()
return response
finally:
# Resume live updates
formatter.resume_live_updates()

View File

@@ -10,13 +10,15 @@ import asyncio
from collections.abc import (
Callable,
ItemsView,
Iterable,
Iterator,
KeysView,
Sequence,
ValuesView,
)
from concurrent.futures import Future
from concurrent.futures import Future, ThreadPoolExecutor
import copy
import enum
import inspect
import logging
import threading
@@ -27,8 +29,10 @@ from typing import (
Generic,
Literal,
ParamSpec,
SupportsIndex,
TypeVar,
cast,
overload,
)
from uuid import uuid4
@@ -77,7 +81,12 @@ from crewai.flow.flow_wrappers import (
StartMethod,
)
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.types import FlowExecutionData, FlowMethodName, PendingListenerKey
from crewai.flow.types import (
FlowExecutionData,
FlowMethodName,
InputHistoryEntry,
PendingListenerKey,
)
from crewai.flow.utils import (
_extract_all_methods,
_extract_all_methods_recursive,
@@ -416,13 +425,17 @@ def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition
return {"type": AND_CONDITION, "conditions": processed_conditions}
class LockedListProxy(Generic[T]):
class LockedListProxy(list, Generic[T]): # type: ignore[type-arg]
"""Thread-safe proxy for list operations.
Wraps a list and uses a lock for all mutating operations.
Subclasses ``list`` so that ``isinstance(proxy, list)`` returns True,
which is required by libraries like LanceDB and Pydantic that do strict
type checks. All mutations go through the lock; reads delegate to the
underlying list.
"""
def __init__(self, lst: list[T], lock: threading.Lock) -> None:
super().__init__() # empty builtin list; all access goes through self._list
self._list = lst
self._lock = lock
@@ -430,11 +443,11 @@ class LockedListProxy(Generic[T]):
with self._lock:
self._list.append(item)
def extend(self, items: list[T]) -> None:
def extend(self, items: Iterable[T]) -> None:
with self._lock:
self._list.extend(items)
def insert(self, index: int, item: T) -> None:
def insert(self, index: SupportsIndex, item: T) -> None:
with self._lock:
self._list.insert(index, item)
@@ -442,7 +455,7 @@ class LockedListProxy(Generic[T]):
with self._lock:
self._list.remove(item)
def pop(self, index: int = -1) -> T:
def pop(self, index: SupportsIndex = -1) -> T:
with self._lock:
return self._list.pop(index)
@@ -450,15 +463,23 @@ class LockedListProxy(Generic[T]):
with self._lock:
self._list.clear()
def __setitem__(self, index: int, value: T) -> None:
@overload
def __setitem__(self, index: SupportsIndex, value: T) -> None: ...
@overload
def __setitem__(self, index: slice, value: Iterable[T]) -> None: ...
def __setitem__(self, index: Any, value: Any) -> None:
with self._lock:
self._list[index] = value
def __delitem__(self, index: int) -> None:
def __delitem__(self, index: SupportsIndex | slice) -> None:
with self._lock:
del self._list[index]
def __getitem__(self, index: int) -> T:
@overload
def __getitem__(self, index: SupportsIndex) -> T: ...
@overload
def __getitem__(self, index: slice) -> list[T]: ...
def __getitem__(self, index: Any) -> Any:
return self._list[index]
def __len__(self) -> int:
@@ -476,14 +497,31 @@ class LockedListProxy(Generic[T]):
def __bool__(self) -> bool:
return bool(self._list)
def __eq__(self, other: object) -> bool:
"""Compare based on the underlying list contents."""
if isinstance(other, LockedListProxy):
# Avoid deadlocks by acquiring locks in a consistent order.
first, second = (self, other) if id(self) <= id(other) else (other, self)
with first._lock:
with second._lock:
return first._list == second._list
with self._lock:
return self._list == other
class LockedDictProxy(Generic[T]):
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg]
"""Thread-safe proxy for dict operations.
Wraps a dict and uses a lock for all mutating operations.
Subclasses ``dict`` so that ``isinstance(proxy, dict)`` returns True,
which is required by libraries like Pydantic that do strict type checks.
All mutations go through the lock; reads delegate to the underlying dict.
"""
def __init__(self, d: dict[str, T], lock: threading.Lock) -> None:
super().__init__() # empty builtin dict; all access goes through self._dict
self._dict = d
self._lock = lock
@@ -495,11 +533,11 @@ class LockedDictProxy(Generic[T]):
with self._lock:
del self._dict[key]
def pop(self, key: str, *default: T) -> T:
def pop(self, key: str, *default: T) -> T: # type: ignore[override]
with self._lock:
return self._dict.pop(key, *default)
def update(self, other: dict[str, T]) -> None:
def update(self, other: dict[str, T]) -> None: # type: ignore[override]
with self._lock:
self._dict.update(other)
@@ -507,7 +545,7 @@ class LockedDictProxy(Generic[T]):
with self._lock:
self._dict.clear()
def setdefault(self, key: str, default: T) -> T:
def setdefault(self, key: str, default: T) -> T: # type: ignore[override]
with self._lock:
return self._dict.setdefault(key, default)
@@ -523,16 +561,16 @@ class LockedDictProxy(Generic[T]):
def __contains__(self, key: object) -> bool:
return key in self._dict
def keys(self) -> KeysView[str]:
def keys(self) -> KeysView[str]: # type: ignore[override]
return self._dict.keys()
def values(self) -> ValuesView[T]:
def values(self) -> ValuesView[T]: # type: ignore[override]
return self._dict.values()
def items(self) -> ItemsView[str, T]:
def items(self) -> ItemsView[str, T]: # type: ignore[override]
return self._dict.items()
def get(self, key: str, default: T | None = None) -> T | None:
def get(self, key: str, default: T | None = None) -> T | None: # type: ignore[override]
return self._dict.get(key, default)
def __repr__(self) -> str:
@@ -541,6 +579,20 @@ class LockedDictProxy(Generic[T]):
def __bool__(self) -> bool:
return bool(self._dict)
def __eq__(self, other: object) -> bool:
"""Compare based on the underlying dict contents."""
if isinstance(other, LockedDictProxy):
# Avoid deadlocks by acquiring locks in a consistent order.
first, second = (self, other) if id(self) <= id(other) else (other, self)
with first._lock:
with second._lock:
return first._dict == second._dict
with self._lock:
return self._dict == other
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
class StateProxy(Generic[T]):
"""Proxy that provides thread-safe access to flow state.
@@ -640,6 +692,7 @@ class FlowMeta(type):
condition_type = getattr(
attr_value, "__condition_type__", OR_CONDITION
)
if (
hasattr(attr_value, "__trigger_condition__")
and attr_value.__trigger_condition__ is not None
@@ -700,6 +753,10 @@ class Flow(Generic[T], metaclass=FlowMeta):
name: str | None = None
tracing: bool | None = None
stream: bool = False
memory: Any = (
None # Memory | MemoryScope | MemorySlice | None; auto-created if not set
)
input_provider: Any = None # InputProvider | None; per-flow override for self.ask()
def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]:
class _FlowGeneric(cls): # type: ignore
@@ -713,6 +770,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
persistence: FlowPersistence | None = None,
tracing: bool | None = None,
suppress_flow_events: bool = False,
max_method_calls: int = 100,
**kwargs: Any,
) -> None:
"""Initialize a new Flow instance.
@@ -721,6 +779,7 @@ class Flow(Generic[T], metaclass=FlowMeta):
persistence: Optional persistence backend for storing flow states
tracing: Whether to enable tracing. True=always enable, False=always disable, None=check environment/user settings
suppress_flow_events: Whether to suppress flow event emissions (internal use)
max_method_calls: Maximum times a single method can be called per execution before raising RecursionError
**kwargs: Additional state values to initialize or override
"""
# Initialize basic instance attributes
@@ -736,6 +795,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
self._completed_methods: set[FlowMethodName] = (
set()
) # Track completed methods for reload
self._method_call_counts: dict[FlowMethodName, int] = {}
self._max_method_calls = max_method_calls
self._persistence: FlowPersistence | None = persistence
self._is_execution_resuming: bool = False
self._event_futures: list[Future[None]] = []
@@ -746,6 +807,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
self._pending_feedback_context: PendingFeedbackContext | None = None
self.suppress_flow_events: bool = suppress_flow_events
# User input history (for self.ask())
self._input_history: list[InputHistoryEntry] = []
# Initialize state with initial values
self._state = self._create_initial_state()
self.tracing = tracing
@@ -767,6 +831,14 @@ class Flow(Generic[T], metaclass=FlowMeta):
),
)
# Auto-create memory if not provided at class or instance level.
# Internal flows (RecallFlow, EncodingFlow) set _skip_auto_memory
# to avoid creating a wasteful standalone Memory instance.
if self.memory is None and not getattr(self, "_skip_auto_memory", False):
from crewai.memory.unified_memory import Memory
self.memory = Memory()
# Register all flow-related methods
for method_name in dir(self):
if not method_name.startswith("_"):
@@ -777,6 +849,63 @@ class Flow(Generic[T], metaclass=FlowMeta):
method = method.__get__(self, self.__class__)
self._methods[method.__name__] = method
def recall(self, query: str, **kwargs: Any) -> Any:
"""Recall relevant memories. Delegates to this flow's memory.
Args:
query: Natural language query.
**kwargs: Passed to memory.recall (e.g. scope, categories, limit, depth).
Returns:
Result of memory.recall(query, **kwargs).
Raises:
ValueError: If no memory is configured for this flow.
"""
if self.memory is None:
raise ValueError("No memory configured for this flow")
return self.memory.recall(query, **kwargs)
def remember(self, content: str | list[str], **kwargs: Any) -> Any:
"""Store one or more items in memory.
Pass a single string for synchronous save (returns the MemoryRecord).
Pass a list of strings for non-blocking batch save (returns immediately).
Args:
content: Text or list of texts to remember.
**kwargs: Passed to memory.remember / remember_many
(e.g. scope, categories, metadata, importance).
Returns:
MemoryRecord for single item, empty list for batch (background save).
Raises:
ValueError: If no memory is configured for this flow.
"""
if self.memory is None:
raise ValueError("No memory configured for this flow")
if isinstance(content, list):
return self.memory.remember_many(content, **kwargs)
return self.memory.remember(content, **kwargs)
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from content. Delegates to this flow's memory.
Args:
content: Raw text (e.g. task + result dump).
Returns:
List of short, self-contained memory statements.
Raises:
ValueError: If no memory is configured for this flow.
"""
if self.memory is None:
raise ValueError("No memory configured for this flow")
result: list[str] = self.memory.extract_memories(content)
return result
def _mark_or_listener_fired(self, listener_name: FlowMethodName) -> bool:
"""Mark an OR listener as fired atomically.
@@ -1246,8 +1375,10 @@ class Flow(Generic[T], metaclass=FlowMeta):
ValueError: If structured state model lacks 'id' field
TypeError: If state is neither BaseModel nor dictionary
"""
init_state = self.initial_state
# Handle case where initial_state is None but we have a type parameter
if self.initial_state is None and hasattr(self, "_initial_state_t"):
if init_state is None and hasattr(self, "_initial_state_t"):
state_type = self._initial_state_t
if isinstance(state_type, type):
if issubclass(state_type, FlowState):
@@ -1271,12 +1402,12 @@ class Flow(Generic[T], metaclass=FlowMeta):
return cast(T, {"id": str(uuid4())})
# Handle case where no initial state is provided
if self.initial_state is None:
if init_state is None:
return cast(T, {"id": str(uuid4())})
# Handle case where initial_state is a type (class)
if isinstance(self.initial_state, type):
state_class: type[T] = self.initial_state
if isinstance(init_state, type):
state_class = init_state
if issubclass(state_class, FlowState):
return state_class()
if issubclass(state_class, BaseModel):
@@ -1287,19 +1418,19 @@ class Flow(Generic[T], metaclass=FlowMeta):
if not getattr(model_instance, "id", None):
object.__setattr__(model_instance, "id", str(uuid4()))
return model_instance
if self.initial_state is dict:
if init_state is dict:
return cast(T, {"id": str(uuid4())})
# Handle dictionary instance case
if isinstance(self.initial_state, dict):
new_state = dict(self.initial_state) # Copy to avoid mutations
if isinstance(init_state, dict):
new_state = dict(init_state) # Copy to avoid mutations
if "id" not in new_state:
new_state["id"] = str(uuid4())
return cast(T, new_state)
# Handle BaseModel instance case
if isinstance(self.initial_state, BaseModel):
model = cast(BaseModel, self.initial_state)
if isinstance(init_state, BaseModel):
model = cast(BaseModel, init_state)
if not hasattr(model, "id"):
raise ValueError("Flow state model must have an 'id' field")
@@ -1613,7 +1744,12 @@ class Flow(Generic[T], metaclass=FlowMeta):
async def _run_flow() -> Any:
return await self.kickoff_async(inputs, input_files)
return asyncio.run(_run_flow())
try:
asyncio.get_running_loop()
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, _run_flow()).result()
except RuntimeError:
return asyncio.run(_run_flow())
async def kickoff_async(
self,
@@ -1697,9 +1833,15 @@ class Flow(Generic[T], metaclass=FlowMeta):
self._method_outputs.clear()
self._pending_and_listeners.clear()
self._clear_or_listeners()
self._method_call_counts.clear()
else:
# We're restoring from persistence, set the flag
self._is_execution_resuming = True
# Only enter resumption mode if there are completed methods to
# replay. When _completed_methods is empty (e.g. a pure
# state-reload via kickoff(inputs={"id": ...})), the flow
# executes from scratch and the flag would incorrectly
# suppress cyclic re-execution on the second iteration.
if self._completed_methods:
self._is_execution_resuming = True
if inputs:
# Override the id in the state if it exists in inputs
@@ -1872,6 +2014,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
return final_output
finally:
# Ensure all background memory saves complete before returning
if self.memory is not None and hasattr(self.memory, "drain_writes"):
self.memory.drain_writes()
if request_id_token is not None:
current_flow_request_id.reset(request_id_token)
if flow_id_token is not None:
@@ -2014,15 +2159,24 @@ class Flow(Generic[T], metaclass=FlowMeta):
if future:
self._event_futures.append(future)
if asyncio.iscoroutinefunction(method):
result = await method(*args, **kwargs)
else:
# Run sync methods in thread pool for isolation
# This allows Agent.kickoff() to work synchronously inside Flow methods
import contextvars
# Set method name in context so ask() can read it without
# stack inspection. Must happen before copy_context() so the
# value propagates into the thread pool for sync methods.
from crewai.flow.flow_context import current_flow_method_name
ctx = contextvars.copy_context()
result = await asyncio.to_thread(ctx.run, method, *args, **kwargs)
method_name_token = current_flow_method_name.set(method_name)
try:
if asyncio.iscoroutinefunction(method):
result = await method(*args, **kwargs)
else:
# Run sync methods in thread pool for isolation
# This allows Agent.kickoff() to work synchronously inside Flow methods
import contextvars
ctx = contextvars.copy_context()
result = await asyncio.to_thread(ctx.run, method, *args, **kwargs)
finally:
current_flow_method_name.reset(method_name_token)
# Auto-await coroutines returned from sync methods (enables AgentExecutor pattern)
if asyncio.iscoroutine(result):
@@ -2055,6 +2209,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
from crewai.flow.async_feedback.types import HumanFeedbackPending
if isinstance(e, HumanFeedbackPending):
e.context.method_name = method_name
# Auto-save pending feedback (create default persistence if needed)
if self._persistence is None:
from crewai.flow.persistence import SQLiteFlowPersistence
@@ -2154,14 +2310,23 @@ class Flow(Generic[T], metaclass=FlowMeta):
router_name, router_input, current_triggering_event_id
)
if router_result: # Only add non-None results
router_results.append(FlowMethodName(str(router_result)))
router_result_str = (
router_result.value
if isinstance(router_result, enum.Enum)
else str(router_result)
)
router_results.append(FlowMethodName(router_result_str))
# If this was a human_feedback router, map the outcome to the feedback
if self.last_human_feedback is not None:
router_result_to_feedback[str(router_result)] = (
router_result_to_feedback[router_result_str] = (
self.last_human_feedback
)
current_trigger = (
FlowMethodName(str(router_result))
FlowMethodName(
router_result.value
if isinstance(router_result, enum.Enum)
else str(router_result)
)
if router_result is not None
else FlowMethodName("") # Update for next iteration of router chain
)
@@ -2410,6 +2575,16 @@ class Flow(Generic[T], metaclass=FlowMeta):
- Skips execution if method was already completed (e.g., after reload)
- Catches and logs any exceptions during execution, preventing individual listener failures from breaking the entire flow
"""
count = self._method_call_counts.get(listener_name, 0) + 1
if count > self._max_method_calls:
raise RecursionError(
f"Method '{listener_name}' has been called {self._max_method_calls} times in "
f"this flow execution, which indicates an infinite loop. "
f"This commonly happens when a @listen label matches the "
f"method's own name."
)
self._method_call_counts[listener_name] = count
if listener_name in self._completed_methods:
if self._is_execution_resuming:
# During resumption, skip execution but continue listeners
@@ -2428,8 +2603,12 @@ class Flow(Generic[T], metaclass=FlowMeta):
return (None, None)
# For cyclic flows, clear from completed to allow re-execution
self._completed_methods.discard(listener_name)
# Also clear from fired OR listeners for cyclic flows
self._discard_or_listener(listener_name)
# Clear ALL fired OR listeners so they can fire again in the new cycle.
# This mirrors what _execute_start_method does for start-method cycles.
# Only discarding the individual listener is insufficient because
# downstream or_() listeners (e.g., method_a listening to
# or_(handler_a, handler_b)) would remain suppressed across iterations.
self._clear_or_listeners()
try:
method = self._methods[listener_name]
@@ -2473,6 +2652,206 @@ class Flow(Generic[T], metaclass=FlowMeta):
logger.error(f"Error executing listener {listener_name}: {e}")
raise
# ── User Input (self.ask) ────────────────────────────────────────
def _resolve_input_provider(self) -> Any:
"""Resolve the input provider using the priority chain.
Resolution order:
1. ``self.input_provider`` (per-flow override)
2. ``flow_config.input_provider`` (global default)
3. ``ConsoleInputProvider()`` (built-in fallback)
Returns:
An object implementing the ``InputProvider`` protocol.
"""
from crewai.flow.async_feedback.providers import ConsoleProvider
from crewai.flow.flow_config import flow_config
if self.input_provider is not None:
return self.input_provider
if flow_config.input_provider is not None:
return flow_config.input_provider
return ConsoleProvider()
def _checkpoint_state_for_ask(self) -> None:
"""Auto-checkpoint flow state before waiting for user input.
If persistence is configured, saves the current state so that
``self.state`` is recoverable even if the process crashes while
waiting for input.
This is best-effort: if persistence is not configured, this is a no-op.
"""
if self._persistence is None:
return
try:
state_data = (
self._state
if isinstance(self._state, dict)
else self._state.model_dump()
)
self._persistence.save_state(
flow_uuid=self.flow_id,
method_name="_ask_checkpoint",
state_data=state_data,
)
except Exception:
logger.debug("Failed to checkpoint state before ask()", exc_info=True)
def ask(
self,
message: str,
timeout: float | None = None,
metadata: dict[str, Any] | None = None,
) -> str | None:
"""Request input from the user during flow execution.
Blocks the current thread until the user provides input or the
timeout expires. Works in both sync and async flow methods (the
flow framework runs sync methods in a thread pool via
``asyncio.to_thread``, so the event loop stays free).
Timeout ensures flows always terminate. When timeout expires,
``None`` is returned, enabling the pattern::
while (msg := self.ask("You: ", timeout=300)) is not None:
process(msg)
Before waiting for input, the current ``self.state`` is automatically
checkpointed to persistence (if configured) for durability.
Args:
message: The question or prompt to display to the user.
timeout: Maximum seconds to wait for input. ``None`` means
wait indefinitely. When timeout expires, returns ``None``.
Note: timeout is best-effort for the provider call --
``ask()`` returns ``None`` promptly, but the underlying
``request_input()`` may continue running in a background
thread until it completes naturally. Network providers
should implement their own internal timeouts.
metadata: Optional metadata to send to the input provider,
such as user ID, channel, session context. The provider
can use this to route the question to the right recipient.
Returns:
The user's input as a string, or ``None`` on timeout, disconnect,
or provider error. Empty string ``""`` means the user pressed
Enter without typing (intentional empty input).
Example:
```python
class MyFlow(Flow):
@start()
def gather_info(self):
topic = self.ask(
"What topic should we research?",
metadata={"user_id": "u123", "channel": "#research"},
)
if topic is None:
return "No input received"
return topic
```
"""
from concurrent.futures import (
ThreadPoolExecutor,
TimeoutError as FuturesTimeoutError,
)
from datetime import datetime
from crewai.events.types.flow_events import (
FlowInputReceivedEvent,
FlowInputRequestedEvent,
)
from crewai.flow.flow_context import current_flow_method_name
from crewai.flow.input_provider import InputResponse
method_name = current_flow_method_name.get("unknown")
# Emit input requested event
crewai_event_bus.emit(
self,
FlowInputRequestedEvent(
type="flow_input_requested",
flow_name=self.name or self.__class__.__name__,
method_name=method_name,
message=message,
metadata=metadata,
),
)
# Auto-checkpoint state before waiting
self._checkpoint_state_for_ask()
provider = self._resolve_input_provider()
raw: str | InputResponse | None = None
try:
if timeout is not None:
# Manual executor management to avoid shutdown(wait=True)
# deadlock when the provider call outlives the timeout.
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(
provider.request_input, message, self, metadata
)
try:
raw = future.result(timeout=timeout)
except FuturesTimeoutError:
future.cancel()
raw = None
finally:
# wait=False so we don't block if the provider is still
# running (e.g. input() stuck waiting for user).
# cancel_futures=True cleans up any queued-but-not-started tasks.
executor.shutdown(wait=False, cancel_futures=True)
else:
raw = provider.request_input(message, self, metadata=metadata)
except KeyboardInterrupt:
raise
except Exception:
logger.debug("Input provider error in ask()", exc_info=True)
raw = None
# Normalize provider response: str, InputResponse, or None
response: str | None = None
response_metadata: dict[str, Any] | None = None
if isinstance(raw, InputResponse):
response = raw.text
response_metadata = raw.metadata
elif isinstance(raw, str):
response = raw
else:
response = None
# Record in history
self._input_history.append(
{
"message": message,
"response": response,
"method_name": method_name,
"timestamp": datetime.now(),
"metadata": metadata,
"response_metadata": response_metadata,
}
)
# Emit input received event
crewai_event_bus.emit(
self,
FlowInputReceivedEvent(
type="flow_input_received",
flow_name=self.name or self.__class__.__name__,
method_name=method_name,
message=message,
response=response,
metadata=metadata,
response_metadata=response_metadata,
),
)
return response
def _request_human_feedback(
self,
message: str,

View File

@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import HumanFeedbackProvider
from crewai.flow.input_provider import InputProvider
class FlowConfig:
@@ -20,10 +21,15 @@ class FlowConfig:
hitl_provider: The human-in-the-loop feedback provider.
Defaults to None (uses console input).
Can be overridden by deployments at startup.
input_provider: The input provider used by ``Flow.ask()``.
Defaults to None (uses ``ConsoleProvider``).
Can be overridden by
deployments at startup.
"""
def __init__(self) -> None:
self._hitl_provider: HumanFeedbackProvider | None = None
self._input_provider: InputProvider | None = None
@property
def hitl_provider(self) -> Any:
@@ -35,6 +41,32 @@ class FlowConfig:
"""Set the HITL provider."""
self._hitl_provider = provider
@property
def input_provider(self) -> Any:
"""Get the configured input provider for ``Flow.ask()``.
Returns:
The configured InputProvider instance, or None if not set
(in which case ``ConsoleInputProvider`` is used as default).
"""
return self._input_provider
@input_provider.setter
def input_provider(self, provider: Any) -> None:
"""Set the input provider for ``Flow.ask()``.
Args:
provider: An object implementing the ``InputProvider`` protocol.
Example:
```python
from crewai.flow import flow_config
flow_config.input_provider = WebSocketInputProvider(...)
```
"""
self._input_provider = provider
# Singleton instance
flow_config = FlowConfig()

View File

@@ -14,3 +14,7 @@ current_flow_request_id: contextvars.ContextVar[str | None] = contextvars.Contex
current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_id", default=None
)
current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar(
"flow_method_name", default="unknown"
)

View File

@@ -62,6 +62,8 @@ from datetime import datetime
from functools import wraps
from typing import TYPE_CHECKING, Any, TypeVar
from pydantic import BaseModel, Field
from crewai.flow.flow_wrappers import FlowMethod
@@ -132,10 +134,12 @@ class HumanFeedbackConfig:
message: str
emit: Sequence[str] | None = None
llm: str | BaseLLM | None = None
llm: str | BaseLLM | None = "gpt-4o-mini"
default_outcome: str | None = None
metadata: dict[str, Any] | None = None
provider: HumanFeedbackProvider | None = None
learn: bool = False
learn_source: str = "hitl"
class HumanFeedbackMethod(FlowMethod[Any, Any]):
@@ -155,13 +159,36 @@ class HumanFeedbackMethod(FlowMethod[Any, Any]):
__human_feedback_config__: HumanFeedbackConfig | None = None
class PreReviewResult(BaseModel):
"""Structured output from the HITL pre-review LLM call."""
improved_output: str = Field(
description="The improved version of the output with past human feedback lessons applied.",
)
class DistilledLessons(BaseModel):
"""Structured output from the HITL lesson distillation LLM call."""
lessons: list[str] = Field(
default_factory=list,
description=(
"Generalizable lessons extracted from the human feedback. "
"Each lesson should be a reusable rule or preference. "
"Return an empty list if the feedback contains no generalizable guidance."
),
)
def human_feedback(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
default_outcome: str | None = None,
metadata: dict[str, Any] | None = None,
provider: HumanFeedbackProvider | None = None,
learn: bool = False,
learn_source: str = "hitl"
) -> Callable[[F], F]:
"""Decorator for Flow methods that require human feedback.
@@ -256,7 +283,9 @@ def human_feedback(
if not llm:
raise ValueError(
"llm is required when emit is specified. "
"Provide an LLM model string (e.g., 'gpt-4o-mini') or a BaseLLM instance."
"Provide an LLM model string (e.g., 'gpt-4o-mini') or a BaseLLM instance. "
"See the CrewAI Human-in-the-Loop (HITL) documentation for more information: "
"https://docs.crewai.com/en/learn/human-feedback-in-flows"
)
if default_outcome is not None and default_outcome not in emit:
raise ValueError(
@@ -269,6 +298,101 @@ def human_feedback(
def decorator(func: F) -> F:
"""Inner decorator that wraps the function."""
# -- HITL learning helpers (only used when learn=True) --------
def _get_hitl_prompt(key: str) -> str:
"""Read a HITL prompt from the i18n translations."""
from crewai.utilities.i18n import get_i18n
return get_i18n().slice(key)
def _resolve_llm_instance() -> Any:
"""Resolve the ``llm`` parameter to a BaseLLM instance.
Uses the SAME model specified in the decorator so pre-review,
distillation, and outcome collapsing all share one model.
"""
if llm is None:
from crewai.llm import LLM
return LLM(model="gpt-4o-mini")
if isinstance(llm, str):
from crewai.llm import LLM
return LLM(model=llm)
return llm # already a BaseLLM instance
def _pre_review_with_lessons(
flow_instance: Flow[Any], method_output: Any
) -> Any:
"""Recall past HITL lessons and use LLM to pre-review the output."""
try:
query = f"human feedback lessons for {func.__name__}: {method_output!s}"
matches = flow_instance.memory.recall(
query, source=learn_source
)
if not matches:
return method_output
lessons = "\n".join(f"- {m.record.content}" for m in matches)
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_pre_review_user").format(
output=str(method_output),
lessons=lessons,
)
messages = [
{"role": "system", "content": _get_hitl_prompt("hitl_pre_review_system")},
{"role": "user", "content": prompt},
]
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=PreReviewResult)
if isinstance(response, PreReviewResult):
return response.improved_output
return PreReviewResult.model_validate(response).improved_output
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
return method_output # fallback to raw output on any failure
def _distill_and_store_lessons(
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
) -> None:
"""Extract generalizable lessons from output + feedback, store in memory."""
try:
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_distill_user").format(
method_name=func.__name__,
output=str(method_output),
feedback=raw_feedback,
)
messages = [
{"role": "system", "content": _get_hitl_prompt("hitl_distill_system")},
{"role": "user", "content": prompt},
]
lessons: list[str] = []
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=DistilledLessons)
if isinstance(response, DistilledLessons):
lessons = response.lessons
else:
lessons = DistilledLessons.model_validate(response).lessons
else:
response = llm_inst.call(messages)
if isinstance(response, str):
lessons = [
line.strip("- ").strip()
for line in response.strip().split("\n")
if line.strip() and line.strip() != "NONE"
]
if lessons:
flow_instance.memory.remember_many(lessons, source=learn_source)
except Exception: # noqa: S110
pass # non-critical: don't fail the flow because lesson storage failed
# -- Core feedback helpers ------------------------------------
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
"""Request feedback using provider or default console."""
from crewai.flow.async_feedback.types import PendingFeedbackContext
@@ -353,28 +477,40 @@ def human_feedback(
# Async wrapper
@wraps(func)
async def async_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
# Execute the original method
method_output = await func(self, *args, **kwargs)
# Request human feedback (may raise HumanFeedbackPending)
raw_feedback = _request_feedback(self, method_output)
# Pre-review: apply past HITL lessons before human sees it
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
# Process and return
return _process_feedback(self, method_output, raw_feedback)
raw_feedback = _request_feedback(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
# Distill: extract lessons from output + feedback, store in memory
if learn and getattr(self, "memory", None) is not None and raw_feedback.strip():
_distill_and_store_lessons(self, method_output, raw_feedback)
return result
wrapper: Any = async_wrapper
else:
# Sync wrapper
@wraps(func)
def sync_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
# Execute the original method
method_output = func(self, *args, **kwargs)
# Request human feedback (may raise HumanFeedbackPending)
raw_feedback = _request_feedback(self, method_output)
# Pre-review: apply past HITL lessons before human sees it
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
# Process and return
return _process_feedback(self, method_output, raw_feedback)
raw_feedback = _request_feedback(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
# Distill: extract lessons from output + feedback, store in memory
if learn and getattr(self, "memory", None) is not None and raw_feedback.strip():
_distill_and_store_lessons(self, method_output, raw_feedback)
return result
wrapper = sync_wrapper
@@ -397,6 +533,8 @@ def human_feedback(
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source
)
wrapper.__is_flow_method__ = True

View File

@@ -0,0 +1,151 @@
"""Input provider protocol for Flow.ask().
This module provides the InputProvider protocol and InputResponse dataclass
used by Flow.ask() to request input from users during flow execution.
The default implementation is ``ConsoleProvider`` (from
``crewai.flow.async_feedback.providers``), which serves both feedback
and input collection via console.
Example (default console input):
```python
from crewai.flow import Flow, start
class MyFlow(Flow):
@start()
def gather_info(self):
topic = self.ask("What topic should we research?")
return topic
```
Example (custom provider with metadata):
```python
from crewai.flow import Flow, start
from crewai.flow.input_provider import InputProvider, InputResponse
class SlackProvider:
def request_input(self, message, flow, metadata=None):
channel = metadata.get("channel", "#general") if metadata else "#general"
thread = self.post_question(channel, message)
reply = self.wait_for_reply(thread)
return InputResponse(
text=reply.text,
metadata={"responded_by": reply.user_id, "thread_id": thread.id},
)
class MyFlow(Flow):
input_provider = SlackProvider()
@start()
def gather_info(self):
topic = self.ask("What topic?", metadata={"channel": "#research"})
return topic
```
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from crewai.flow.flow import Flow
@dataclass
class InputResponse:
"""Response from an InputProvider, optionally carrying metadata.
Simple providers can just return a string from ``request_input()``.
Providers that need to send metadata back (e.g., who responded,
thread ID, external timestamps) return an ``InputResponse`` instead.
``ask()`` normalizes both cases -- callers always get ``str | None``.
The response metadata is stored in ``_input_history`` and emitted
in ``FlowInputReceivedEvent``.
Attributes:
text: The user's input text, or None if unavailable.
metadata: Optional metadata from the provider about the response
(e.g., who responded, thread ID, timestamps).
Example:
```python
class MyProvider:
def request_input(self, message, flow, metadata=None):
response = get_response_from_external_system(message)
return InputResponse(
text=response.text,
metadata={"responded_by": response.user_id},
)
```
"""
text: str | None
metadata: dict[str, Any] | None = field(default=None)
@runtime_checkable
class InputProvider(Protocol):
"""Protocol for user input collection strategies.
Implement this protocol to create custom input providers that integrate
with external systems like websockets, web UIs, Slack, or custom APIs.
The default provider is ``ConsoleProvider``, which blocks waiting for
console input via Python's built-in ``input()`` function.
Providers are always synchronous. The flow framework runs sync methods
in a thread pool (via ``asyncio.to_thread``), so ``ask()`` never blocks
the event loop even inside async flow methods.
Providers can return either:
- ``str | None`` for simple cases (no response metadata)
- ``InputResponse`` when they need to send metadata back with the answer
Example (simple):
```python
class SimpleProvider:
def request_input(self, message: str, flow: Flow) -> str | None:
return input(message)
```
Example (with metadata):
```python
class SlackProvider:
def request_input(self, message, flow, metadata=None):
channel = metadata.get("channel") if metadata else "#general"
reply = self.post_and_wait(channel, message)
return InputResponse(
text=reply.text,
metadata={"responded_by": reply.user_id},
)
```
"""
def request_input(
self,
message: str,
flow: Flow[Any],
metadata: dict[str, Any] | None = None,
) -> str | InputResponse | None:
"""Request input from the user.
Args:
message: The question or prompt to display to the user.
flow: The Flow instance requesting input. Can be used to
access flow state, name, or other context.
metadata: Optional metadata from the caller, such as user ID,
channel, session context, etc. Providers can use this to
route the question to the right recipient.
Returns:
The user's input as a string, an ``InputResponse`` with text
and optional response metadata, or None if input is unavailable
(e.g., user cancelled, connection dropped).
"""
...

View File

@@ -4,6 +4,7 @@ This module contains TypedDict definitions and type aliases used throughout
the Flow system.
"""
from datetime import datetime
from typing import (
Annotated,
Any,
@@ -101,6 +102,30 @@ class FlowData(TypedDict):
flow_methods_attributes: list[FlowMethodData]
class InputHistoryEntry(TypedDict):
"""A single entry in the flow's input history from ``self.ask()``.
Each call to ``Flow.ask()`` appends one entry recording the question,
the user's response, which method asked, and any metadata exchanged
between the caller and the input provider.
Attributes:
message: The question or prompt that was displayed to the user.
response: The user's response, or None on timeout/error.
method_name: The flow method that called ``ask()``.
timestamp: When the input was received.
metadata: Metadata sent with the question (caller to provider).
response_metadata: Metadata received with the answer (provider to caller).
"""
message: str
response: str | None
method_name: str
timestamp: datetime
metadata: dict[str, Any] | None
response_metadata: dict[str, Any] | None
class FlowExecutionData(TypedDict):
"""Flow execution data.

View File

@@ -5,6 +5,7 @@ from collections.abc import Callable
from functools import wraps
import inspect
import json
import time
from types import MethodType
from typing import (
TYPE_CHECKING,
@@ -49,9 +50,19 @@ from crewai.events.types.agent_events import (
LiteAgentExecutionStartedEvent,
)
from crewai.events.types.logging_events import AgentLogsExecutionEvent
from crewai.events.types.memory_events import (
MemoryRetrievalCompletedEvent,
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
)
from crewai.flow.flow_trackable import FlowTrackable
from crewai.hooks.llm_hooks import get_after_llm_call_hooks, get_before_llm_call_hooks
from crewai.hooks.types import AfterLLMCallHookType, BeforeLLMCallHookType
from crewai.hooks.types import (
AfterLLMCallHookCallable,
AfterLLMCallHookType,
BeforeLLMCallHookCallable,
BeforeLLMCallHookType,
)
from crewai.lite_agent_output import LiteAgentOutput
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
@@ -244,6 +255,10 @@ class LiteAgent(FlowTrackable, BaseModel):
description="A2A (Agent-to-Agent) configuration for delegating tasks to remote agents. "
"Can be a single A2AConfig/A2AClientConfig/A2AServerConfig, or a list of configurations.",
)
memory: bool | Any | None = Field(
default=None,
description="If True, use default Memory(). If Memory/MemoryScope/MemorySlice, use it for recall and remember.",
)
tools_results: list[dict[str, Any]] = Field(
default_factory=list, description="Results of the tools used by the agent."
)
@@ -260,12 +275,13 @@ class LiteAgent(FlowTrackable, BaseModel):
_guardrail: GuardrailCallable | None = PrivateAttr(default=None)
_guardrail_retry_count: int = PrivateAttr(default=0)
_callbacks: list[TokenCalcHandler] = PrivateAttr(default_factory=list)
_before_llm_call_hooks: list[BeforeLLMCallHookType] = PrivateAttr(
default_factory=get_before_llm_call_hooks
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = (
PrivateAttr(default_factory=get_before_llm_call_hooks)
)
_after_llm_call_hooks: list[AfterLLMCallHookType] = PrivateAttr(
default_factory=get_after_llm_call_hooks
_after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = (
PrivateAttr(default_factory=get_after_llm_call_hooks)
)
_memory: Any = PrivateAttr(default=None)
@model_validator(mode="after")
def emit_deprecation_warning(self) -> Self:
@@ -363,6 +379,19 @@ class LiteAgent(FlowTrackable, BaseModel):
return self
@model_validator(mode="after")
def resolve_memory(self) -> Self:
"""Resolve memory field to _memory: default Memory() when True, else user instance or None."""
if self.memory is True:
from crewai.memory.unified_memory import Memory
object.__setattr__(self, "_memory", Memory())
elif self.memory is not None and self.memory is not False:
object.__setattr__(self, "_memory", self.memory)
else:
object.__setattr__(self, "_memory", None)
return self
@field_validator("guardrail", mode="before")
@classmethod
def validate_guardrail_function(
@@ -416,12 +445,16 @@ class LiteAgent(FlowTrackable, BaseModel):
return self.role
@property
def before_llm_call_hooks(self) -> list[BeforeLLMCallHookType]:
def before_llm_call_hooks(
self,
) -> list[BeforeLLMCallHookType | BeforeLLMCallHookCallable]:
"""Get the before_llm_call hooks for this agent."""
return self._before_llm_call_hooks
@property
def after_llm_call_hooks(self) -> list[AfterLLMCallHookType]:
def after_llm_call_hooks(
self,
) -> list[AfterLLMCallHookType | AfterLLMCallHookCallable]:
"""Get the after_llm_call hooks for this agent."""
return self._after_llm_call_hooks
@@ -455,6 +488,20 @@ class LiteAgent(FlowTrackable, BaseModel):
Returns:
LiteAgentOutput: The result of the agent execution.
"""
# Inject memory tools once if memory is configured (mirrors Agent._prepare_kickoff)
if self._memory is not None:
from crewai.tools.memory_tools import create_memory_tools
from crewai.utilities.string_utils import sanitize_tool_name
existing_names = {sanitize_tool_name(t.name) for t in self._parsed_tools}
memory_tools = [
mt
for mt in create_memory_tools(self._memory)
if sanitize_tool_name(mt.name) not in existing_names
]
if memory_tools:
self._parsed_tools = self._parsed_tools + parse_tools(memory_tools)
# Create agent info for event emission
agent_info = {
"id": self.id,
@@ -474,6 +521,7 @@ class LiteAgent(FlowTrackable, BaseModel):
self._messages = self._format_messages(
messages, response_format=response_format, input_files=input_files
)
self._inject_memory_context()
return self._execute_core(
agent_info=agent_info, response_format=response_format
@@ -496,6 +544,77 @@ class LiteAgent(FlowTrackable, BaseModel):
)
raise e
def _get_last_user_content(self) -> str:
"""Get the last user message content from _messages for recall/input."""
for msg in reversed(self._messages):
if msg.get("role") == "user":
content = msg.get("content")
return content if isinstance(content, str) else ""
return ""
def _inject_memory_context(self) -> None:
"""Recall relevant memories and append to the system message. No-op if _memory is None."""
if self._memory is None:
return
query = self._get_last_user_content()
crewai_event_bus.emit(
self,
event=MemoryRetrievalStartedEvent(
task_id=None,
source_type="lite_agent",
),
)
start_time = time.time()
memory_block = ""
try:
matches = self._memory.recall(query, limit=10)
if matches:
memory_block = "Relevant memories:\n" + "\n".join(
f"- {m.record.content}" for m in matches
)
if memory_block:
formatted = self.i18n.slice("memory").format(memory=memory_block)
if self._messages and self._messages[0].get("role") == "system":
existing_content = self._messages[0].get("content", "")
if not isinstance(existing_content, str):
existing_content = ""
self._messages[0]["content"] = existing_content + "\n\n" + formatted
crewai_event_bus.emit(
self,
event=MemoryRetrievalCompletedEvent(
task_id=None,
memory_content=memory_block,
retrieval_time_ms=(time.time() - start_time) * 1000,
source_type="lite_agent",
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryRetrievalFailedEvent(
task_id=None,
source_type="lite_agent",
error=str(e),
),
)
def _save_to_memory(self, output_text: str) -> None:
"""Extract discrete memories from the run and remember each. No-op if _memory is None or read-only."""
if self._memory is None or getattr(self._memory, "_read_only", False):
return
input_str = self._get_last_user_content() or "User request"
try:
raw = f"Input: {input_str}\nAgent: {self.role}\nResult: {output_text}"
extracted = self._memory.extract_memories(raw)
if extracted:
self._memory.remember_many(extracted, agent_role=self.role)
except Exception as e:
if self.verbose:
self._printer.print(
content=f"Failed to save to memory: {e}",
color="yellow",
)
def _execute_core(
self, agent_info: dict[str, Any], response_format: type[BaseModel] | None = None
) -> LiteAgentOutput:
@@ -510,11 +629,20 @@ class LiteAgent(FlowTrackable, BaseModel):
)
# Execute the agent using invoke loop
agent_finish = self._invoke_loop()
active_response_format = response_format or self.response_format
agent_finish = self._invoke_loop(response_model=active_response_format)
if self._memory is not None:
output_text = (
agent_finish.output.model_dump_json()
if isinstance(agent_finish.output, BaseModel)
else agent_finish.output
)
self._save_to_memory(output_text)
formatted_result: BaseModel | None = None
active_response_format = response_format or self.response_format
if active_response_format:
if isinstance(agent_finish.output, BaseModel):
formatted_result = agent_finish.output
elif active_response_format:
try:
model_schema = generate_model_description(active_response_format)
schema = json.dumps(model_schema, indent=2)
@@ -546,8 +674,13 @@ class LiteAgent(FlowTrackable, BaseModel):
usage_metrics = self._token_process.get_summary()
# Create output
raw_output = (
agent_finish.output.model_dump_json()
if isinstance(agent_finish.output, BaseModel)
else agent_finish.output
)
output = LiteAgentOutput(
raw=agent_finish.output,
raw=raw_output,
pydantic=formatted_result,
agent_role=self.role,
usage_metrics=usage_metrics.model_dump() if usage_metrics else None,
@@ -724,10 +857,15 @@ class LiteAgent(FlowTrackable, BaseModel):
return formatted_messages
def _invoke_loop(self) -> AgentFinish:
def _invoke_loop(
self, response_model: type[BaseModel] | None = None
) -> AgentFinish:
"""
Run the agent's thought process until it reaches a conclusion or max iterations.
Args:
response_model: Optional Pydantic model for native structured output.
Returns:
AgentFinish: The final result of the agent execution.
"""
@@ -756,12 +894,19 @@ class LiteAgent(FlowTrackable, BaseModel):
printer=self._printer,
from_agent=self,
executor_context=self,
response_model=response_model,
verbose=self.verbose,
)
except Exception as e:
raise e
if isinstance(answer, BaseModel):
formatted_answer = AgentFinish(
thought="", output=answer, text=answer.model_dump_json()
)
break
formatted_answer = process_llm_response(
cast(str, answer), self.use_stop_words
)
@@ -787,7 +932,7 @@ class LiteAgent(FlowTrackable, BaseModel):
)
self._append_message(formatted_answer.text, role="assistant")
except OutputParserError as e: # noqa: PERF203
except OutputParserError as e:
if self.verbose:
self._printer.print(
content="Failed to parse LLM output. Retrying...",

View File

@@ -419,8 +419,22 @@ class LLM(BaseLLM):
# FALLBACK to LiteLLM
if not LITELLM_AVAILABLE:
logger.error("LiteLLM is not available, falling back to LiteLLM")
raise ImportError("Fallback to LiteLLM is not available") from None
native_list = ", ".join(SUPPORTED_NATIVE_PROVIDERS)
error_msg = (
f"Unable to initialize LLM with model '{model}'. "
f"The model did not match any supported native provider "
f"({native_list}), and the LiteLLM fallback package is not "
f"installed.\n\n"
f"To fix this, either:\n"
f" 1. Install LiteLLM for broad model support: "
f"uv add 'crewai[litellm]'\n"
f"or\n"
f"pip install litellm\n\n"
f"For more details, see: "
f"https://docs.crewai.com/en/learn/llm-connections"
)
logger.error(error_msg)
raise ImportError(error_msg) from None
instance = object.__new__(cls)
super(LLM, instance).__init__(model=model, is_litellm=True, **kwargs)

View File

@@ -26,6 +26,7 @@ from crewai.events.types.llm_events import (
LLMCallStartedEvent,
LLMCallType,
LLMStreamChunkEvent,
LLMThinkingChunkEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
@@ -368,9 +369,6 @@ class BaseLLM(ABC):
"""Emit LLM call started event."""
from crewai.utilities.serialization import to_serializable
if not hasattr(crewai_event_bus, "emit"):
raise ValueError("crewai_event_bus does not have an emit method") from None
crewai_event_bus.emit(
self,
event=LLMCallStartedEvent(
@@ -416,9 +414,6 @@ class BaseLLM(ABC):
from_agent: Agent | None = None,
) -> None:
"""Emit LLM call failed event."""
if not hasattr(crewai_event_bus, "emit"):
raise ValueError("crewai_event_bus does not have an emit method") from None
crewai_event_bus.emit(
self,
event=LLMCallFailedEvent(
@@ -449,9 +444,6 @@ class BaseLLM(ABC):
call_type: The type of LLM call (LLM_CALL or TOOL_CALL).
response_id: Unique ID for a particular LLM response, chunks have same response_id.
"""
if not hasattr(crewai_event_bus, "emit"):
raise ValueError("crewai_event_bus does not have an emit method") from None
crewai_event_bus.emit(
self,
event=LLMStreamChunkEvent(
@@ -465,6 +457,32 @@ class BaseLLM(ABC):
),
)
def _emit_thinking_chunk_event(
self,
chunk: str,
from_task: Task | None = None,
from_agent: Agent | None = None,
response_id: str | None = None,
) -> None:
"""Emit thinking/reasoning chunk event from a thinking model.
Args:
chunk: The thinking text content.
from_task: The task that initiated the call.
from_agent: The agent that initiated the call.
response_id: Unique ID for a particular LLM response.
"""
crewai_event_bus.emit(
self,
event=LLMThinkingChunkEvent(
chunk=chunk,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
call_id=get_current_call_id(),
),
)
def _handle_tool_execution(
self,
function_name: str,

View File

@@ -234,7 +234,7 @@ class BedrockCompletion(BaseLLM):
aws_access_key_id: str | None = None,
aws_secret_access_key: str | None = None,
aws_session_token: str | None = None,
region_name: str = "us-east-1",
region_name: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
top_p: float | None = None,
@@ -287,15 +287,6 @@ class BedrockCompletion(BaseLLM):
**kwargs,
)
# Initialize Bedrock client with proper configuration
session = Session(
aws_access_key_id=aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=aws_secret_access_key
or os.getenv("AWS_SECRET_ACCESS_KEY"),
aws_session_token=aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
region_name=region_name,
)
# Configure client with timeouts and retries following AWS best practices
config = Config(
read_timeout=300,
@@ -306,8 +297,12 @@ class BedrockCompletion(BaseLLM):
tcp_keepalive=True,
)
self.client = session.client("bedrock-runtime", config=config)
self.region_name = region_name
self.region_name = (
region_name
or os.getenv("AWS_DEFAULT_REGION")
or os.getenv("AWS_REGION_NAME")
or "us-east-1"
)
self.aws_access_key_id = aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID")
self.aws_secret_access_key = aws_secret_access_key or os.getenv(
@@ -315,6 +310,16 @@ class BedrockCompletion(BaseLLM):
)
self.aws_session_token = aws_session_token or os.getenv("AWS_SESSION_TOKEN")
# Initialize Bedrock client with proper configuration
session = Session(
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
aws_session_token=self.aws_session_token,
region_name=self.region_name,
)
self.client = session.client("bedrock-runtime", config=config)
self._async_exit_stack = AsyncExitStack() if AIOBOTOCORE_AVAILABLE else None
self._async_client_initialized = False

View File

@@ -61,6 +61,7 @@ class GeminiCompletion(BaseLLM):
interceptor: BaseInterceptor[Any, Any] | None = None,
use_vertexai: bool | None = None,
response_format: type[BaseModel] | None = None,
thinking_config: types.ThinkingConfig | None = None,
**kwargs: Any,
):
"""Initialize Google Gemini chat completion client.
@@ -93,6 +94,10 @@ class GeminiCompletion(BaseLLM):
api_version="v1" is automatically configured.
response_format: Pydantic model for structured output. Used as default when
response_model is not passed to call()/acall() methods.
thinking_config: ThinkingConfig for thinking models (gemini-2.5+, gemini-3+).
Controls thought output via include_thoughts, thinking_budget,
and thinking_level. When None, thinking models automatically
get include_thoughts=True so thought content is surfaced.
**kwargs: Additional parameters
"""
if interceptor is not None:
@@ -139,6 +144,14 @@ class GeminiCompletion(BaseLLM):
version_match and float(version_match.group(1)) >= 2.0
)
self.thinking_config = thinking_config
if (
self.thinking_config is None
and version_match
and float(version_match.group(1)) >= 2.5
):
self.thinking_config = types.ThinkingConfig(include_thoughts=True)
@property
def stop(self) -> list[str]:
"""Get stop sequences sent to the API."""
@@ -520,6 +533,9 @@ class GeminiCompletion(BaseLLM):
if self.safety_settings:
config_params["safety_settings"] = self.safety_settings
if self.thinking_config is not None:
config_params["thinking_config"] = self.thinking_config
return types.GenerateContentConfig(**config_params)
def _convert_tools_for_interference( # type: ignore[override]
@@ -618,9 +634,17 @@ class GeminiCompletion(BaseLLM):
function_response_part = types.Part.from_function_response(
name=tool_name, response=response_data
)
contents.append(
types.Content(role="user", parts=[function_response_part])
)
if (
contents
and contents[-1].role == "user"
and contents[-1].parts
and contents[-1].parts[-1].function_response is not None
):
contents[-1].parts.append(function_response_part)
else:
contents.append(
types.Content(role="user", parts=[function_response_part])
)
elif role == "assistant" and message.get("tool_calls"):
raw_parts: list[Any] | None = message.get("raw_tool_call_parts")
if raw_parts and all(isinstance(p, types.Part) for p in raw_parts):
@@ -894,7 +918,7 @@ class GeminiCompletion(BaseLLM):
content = self._extract_text_from_response(response)
effective_response_model = None if self.tools else response_model
if not effective_response_model:
if not response_model:
content = self._apply_stop_words(content)
return self._finalize_completion_response(
@@ -931,15 +955,6 @@ class GeminiCompletion(BaseLLM):
if chunk.usage_metadata:
usage_data = self._extract_token_usage(chunk)
if chunk.text:
full_response += chunk.text
self._emit_stream_chunk_event(
chunk=chunk.text,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
)
if chunk.candidates:
candidate = chunk.candidates[0]
if candidate.content and candidate.content.parts:
@@ -976,6 +991,21 @@ class GeminiCompletion(BaseLLM):
call_type=LLMCallType.TOOL_CALL,
response_id=response_id,
)
elif part.thought and part.text:
self._emit_thinking_chunk_event(
chunk=part.text,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
)
elif part.text:
full_response += part.text
self._emit_stream_chunk_event(
chunk=part.text,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
)
return full_response, function_calls, usage_data
@@ -1329,7 +1359,7 @@ class GeminiCompletion(BaseLLM):
text_parts = [
part.text
for part in candidate.content.parts
if hasattr(part, "text") and part.text
if part.text and not part.thought
]
return "".join(text_parts)

View File

@@ -18,6 +18,7 @@ from crewai.mcp.filters import (
create_dynamic_tool_filter,
create_static_tool_filter,
)
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.mcp.transports.base import BaseTransport, TransportType
@@ -28,6 +29,7 @@ __all__ = [
"MCPServerHTTP",
"MCPServerSSE",
"MCPServerStdio",
"MCPToolResolver",
"StaticToolFilter",
"ToolFilter",
"ToolFilterContext",

View File

@@ -6,7 +6,7 @@ from contextlib import AsyncExitStack
from datetime import datetime
import logging
import time
from typing import Any
from typing import Any, NamedTuple
from typing_extensions import Self
@@ -34,6 +34,13 @@ from crewai.mcp.transports.stdio import StdioTransport
from crewai.utilities.string_utils import sanitize_tool_name
class _MCPToolResult(NamedTuple):
"""Internal result from an MCP tool call, carrying the ``isError`` flag."""
content: str
is_error: bool
# MCP Connection timeout constants (in seconds)
MCP_CONNECTION_TIMEOUT = 30 # Increased for slow servers
MCP_TOOL_EXECUTION_TIMEOUT = 30
@@ -420,6 +427,7 @@ class MCPClient:
return [
{
"name": sanitize_tool_name(tool.name),
"original_name": tool.name,
"description": getattr(tool, "description", ""),
"inputSchema": getattr(tool, "inputSchema", {}),
}
@@ -461,29 +469,46 @@ class MCPClient:
)
try:
result = await self._retry_operation(
tool_result: _MCPToolResult = await self._retry_operation(
lambda: self._call_tool_impl(tool_name, cleaned_arguments),
timeout=self.execution_timeout,
)
completed_at = datetime.now()
execution_duration_ms = (completed_at - started_at).total_seconds() * 1000
crewai_event_bus.emit(
self,
MCPToolExecutionCompletedEvent(
server_name=server_name,
server_url=server_url,
transport_type=transport_type,
tool_name=tool_name,
tool_args=cleaned_arguments,
result=result,
started_at=started_at,
completed_at=completed_at,
execution_duration_ms=execution_duration_ms,
),
)
finished_at = datetime.now()
execution_duration_ms = (finished_at - started_at).total_seconds() * 1000
return result
if tool_result.is_error:
crewai_event_bus.emit(
self,
MCPToolExecutionFailedEvent(
server_name=server_name,
server_url=server_url,
transport_type=transport_type,
tool_name=tool_name,
tool_args=cleaned_arguments,
error=tool_result.content,
error_type="tool_error",
started_at=started_at,
failed_at=finished_at,
),
)
else:
crewai_event_bus.emit(
self,
MCPToolExecutionCompletedEvent(
server_name=server_name,
server_url=server_url,
transport_type=transport_type,
tool_name=tool_name,
tool_args=cleaned_arguments,
result=tool_result.content,
started_at=started_at,
completed_at=finished_at,
execution_duration_ms=execution_duration_ms,
),
)
return tool_result.content
except Exception as e:
failed_at = datetime.now()
error_type = (
@@ -564,23 +589,27 @@ class MCPClient:
return cleaned
async def _call_tool_impl(self, tool_name: str, arguments: dict[str, Any]) -> Any:
async def _call_tool_impl(
self, tool_name: str, arguments: dict[str, Any]
) -> _MCPToolResult:
"""Internal implementation of call_tool."""
result = await asyncio.wait_for(
self.session.call_tool(tool_name, arguments),
timeout=self.execution_timeout,
)
is_error = getattr(result, "isError", False) or False
# Extract result content
if hasattr(result, "content") and result.content:
if isinstance(result.content, list) and len(result.content) > 0:
content_item = result.content[0]
if hasattr(content_item, "text"):
return str(content_item.text)
return str(content_item)
return str(result.content)
return _MCPToolResult(str(content_item.text), is_error)
return _MCPToolResult(str(content_item), is_error)
return _MCPToolResult(str(result.content), is_error)
return str(result)
return _MCPToolResult(str(result), is_error)
async def list_prompts(self) -> list[dict[str, Any]]:
"""List available prompts from MCP server.

View File

@@ -0,0 +1,592 @@
"""MCP tool resolution for CrewAI agents.
This module extracts all MCP-related tool resolution logic from the Agent class
into a standalone MCPToolResolver. It handles three flavours of MCP reference:
1. Native configs: MCPServerStdio / MCPServerHTTP / MCPServerSSE objects.
2. HTTPS URLs: e.g. "https://mcp.example.com/api"
3. AMP references: e.g. "notion" or "notion#search" (legacy "crewai-amp:" prefix also works)
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any, Final, cast
from urllib.parse import urlparse
from crewai.mcp.client import MCPClient
from crewai.mcp.config import (
MCPServerConfig,
MCPServerHTTP,
MCPServerSSE,
MCPServerStdio,
)
from crewai.mcp.transports.http import HTTPTransport
from crewai.mcp.transports.sse import SSETransport
from crewai.mcp.transports.stdio import StdioTransport
if TYPE_CHECKING:
from crewai.tools.base_tool import BaseTool
from crewai.utilities.logger import Logger
MCP_CONNECTION_TIMEOUT: Final[int] = 10
MCP_TOOL_EXECUTION_TIMEOUT: Final[int] = 30
MCP_DISCOVERY_TIMEOUT: Final[int] = 15
MCP_MAX_RETRIES: Final[int] = 3
_mcp_schema_cache: dict[str, Any] = {}
_cache_ttl: Final[int] = 300 # 5 minutes
class MCPToolResolver:
"""Resolves MCP server references / configs into CrewAI ``BaseTool`` instances.
Typical lifecycle::
resolver = MCPToolResolver(agent=my_agent, logger=my_agent._logger)
tools = resolver.resolve(my_agent.mcps)
# … agent executes tasks using *tools* …
resolver.cleanup()
The resolver owns the MCP client connections it creates and is responsible
for tearing them down via :meth:`cleanup`.
"""
def __init__(self, agent: Any, logger: Logger) -> None:
self._agent = agent
self._logger = logger
self._clients: list[Any] = []
@property
def clients(self) -> list[Any]:
return list(self._clients)
def resolve(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
"""Convert MCP server references/configs to CrewAI tools."""
all_tools: list[BaseTool] = []
amp_refs: list[tuple[str, str | None]] = []
for mcp_config in mcps:
if isinstance(mcp_config, str) and mcp_config.startswith("https://"):
all_tools.extend(self._resolve_external(mcp_config))
elif isinstance(mcp_config, str):
amp_refs.append(self._parse_amp_ref(mcp_config))
else:
tools, client = self._resolve_native(mcp_config)
all_tools.extend(tools)
if client:
self._clients.append(client)
if amp_refs:
tools, clients = self._resolve_amp(amp_refs)
all_tools.extend(tools)
self._clients.extend(clients)
return all_tools
def cleanup(self) -> None:
"""Disconnect all MCP client connections."""
if not self._clients:
return
async def _disconnect_all() -> None:
for client in self._clients:
if client and hasattr(client, "connected") and client.connected:
await client.disconnect()
try:
asyncio.run(_disconnect_all())
except Exception as e:
self._logger.log("error", f"Error during MCP client cleanup: {e}")
finally:
self._clients.clear()
@staticmethod
def _parse_amp_ref(mcp_config: str) -> tuple[str, str | None]:
"""Parse an AMP reference into *(slug, optional tool name)*.
Accepts both bare slugs (``"notion"``, ``"notion#search"``) and the
legacy ``"crewai-amp:notion"`` form.
"""
bare = mcp_config.removeprefix("crewai-amp:")
slug, _, specific_tool = bare.partition("#")
return slug, specific_tool or None
def _resolve_amp(
self, amp_refs: list[tuple[str, str | None]]
) -> tuple[list[BaseTool], list[Any]]:
"""Fetch AMP configs in bulk and return their tools and clients.
Resolves each unique slug only once (single connection per server),
then applies per-ref tool filters to select specific tools.
"""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.mcp_events import MCPConfigFetchFailedEvent
unique_slugs = list(dict.fromkeys(slug for slug, _ in amp_refs))
amp_configs_map = self._fetch_amp_mcp_configs(unique_slugs)
all_tools: list[BaseTool] = []
all_clients: list[Any] = []
resolved_cache: dict[str, tuple[list[BaseTool], Any | None]] = {}
for slug in unique_slugs:
config_dict = amp_configs_map.get(slug)
if not config_dict:
crewai_event_bus.emit(
self,
MCPConfigFetchFailedEvent(
slug=slug,
error=f"Config for '{slug}' not found. Make sure it is connected in your account.",
error_type="not_connected",
),
)
continue
mcp_server_config = self._build_mcp_config_from_dict(config_dict)
try:
tools, client = self._resolve_native(mcp_server_config)
resolved_cache[slug] = (tools, client)
if client:
all_clients.append(client)
except Exception as e:
crewai_event_bus.emit(
self,
MCPConfigFetchFailedEvent(
slug=slug,
error=str(e),
error_type="connection_failed",
),
)
for slug, specific_tool in amp_refs:
cached = resolved_cache.get(slug)
if not cached:
continue
slug_tools, _ = cached
if specific_tool:
all_tools.extend(
t for t in slug_tools if t.name.endswith(f"_{specific_tool}")
)
else:
all_tools.extend(slug_tools)
return all_tools, all_clients
def _fetch_amp_mcp_configs(self, slugs: list[str]) -> dict[str, dict[str, Any]]:
"""Fetch MCP server configurations via CrewAI+ API.
Sends a GET request to the CrewAI+ mcps/configs endpoint with
comma-separated slugs. CrewAI+ proxies the request to crewai-oauth.
API-level failures return ``{}``; individual slugs will then
surface as ``MCPConfigFetchFailedEvent`` in :meth:`_resolve_amp`.
"""
import httpx
try:
from crewai_tools.tools.crewai_platform_tools.misc import (
get_platform_integration_token,
)
from crewai.cli.plus_api import PlusAPI
plus_api = PlusAPI(api_key=get_platform_integration_token())
response = plus_api.get_mcp_configs(slugs)
if response.status_code == 200:
configs: dict[str, dict[str, Any]] = response.json().get("configs", {})
return configs
self._logger.log(
"debug",
f"Failed to fetch MCP configs: HTTP {response.status_code}",
)
return {}
except httpx.HTTPError as e:
self._logger.log("debug", f"Failed to fetch MCP configs: {e}")
return {}
except Exception as e:
self._logger.log("debug", f"Cannot fetch AMP MCP configs: {e}")
return {}
def _resolve_external(self, mcp_ref: str) -> list[BaseTool]:
"""Resolve an HTTPS MCP server URL into tools."""
from crewai.tools.mcp_tool_wrapper import MCPToolWrapper
if "#" in mcp_ref:
server_url, specific_tool = mcp_ref.split("#", 1)
else:
server_url, specific_tool = mcp_ref, None
server_params = {"url": server_url}
server_name = self._extract_server_name(server_url)
try:
tool_schemas = self._get_mcp_tool_schemas(server_params)
if not tool_schemas:
self._logger.log(
"warning", f"No tools discovered from MCP server: {server_url}"
)
return []
tools = []
for tool_name, schema in tool_schemas.items():
if specific_tool and tool_name != specific_tool:
continue
try:
wrapper = MCPToolWrapper(
mcp_server_params=server_params,
tool_name=tool_name,
tool_schema=schema,
server_name=server_name,
)
tools.append(wrapper)
except Exception as e:
self._logger.log(
"warning",
f"Failed to create MCP tool wrapper for {tool_name}: {e}",
)
continue
if specific_tool and not tools:
self._logger.log(
"warning",
f"Specific tool '{specific_tool}' not found on MCP server: {server_url}",
)
return cast(list[BaseTool], tools)
except Exception as e:
self._logger.log(
"warning", f"Failed to connect to MCP server {server_url}: {e}"
)
return []
def _resolve_native(
self, mcp_config: MCPServerConfig
) -> tuple[list[BaseTool], Any | None]:
"""Resolve an ``MCPServerConfig`` into tools, returning the client for cleanup."""
from crewai.tools.base_tool import BaseTool
from crewai.tools.mcp_native_tool import MCPNativeTool
transport: StdioTransport | HTTPTransport | SSETransport
if isinstance(mcp_config, MCPServerStdio):
transport = StdioTransport(
command=mcp_config.command,
args=mcp_config.args,
env=mcp_config.env,
)
server_name = f"{mcp_config.command}_{'_'.join(mcp_config.args)}"
elif isinstance(mcp_config, MCPServerHTTP):
transport = HTTPTransport(
url=mcp_config.url,
headers=mcp_config.headers,
streamable=mcp_config.streamable,
)
server_name = self._extract_server_name(mcp_config.url)
elif isinstance(mcp_config, MCPServerSSE):
transport = SSETransport(
url=mcp_config.url,
headers=mcp_config.headers,
)
server_name = self._extract_server_name(mcp_config.url)
else:
raise ValueError(f"Unsupported MCP server config type: {type(mcp_config)}")
client = MCPClient(
transport=transport,
cache_tools_list=mcp_config.cache_tools_list,
)
async def _setup_client_and_list_tools() -> list[dict[str, Any]]:
try:
if not client.connected:
await client.connect()
tools_list = await client.list_tools()
try:
await client.disconnect()
await asyncio.sleep(0.1)
except Exception as e:
self._logger.log("error", f"Error during disconnect: {e}")
return tools_list
except Exception as e:
if client.connected:
await client.disconnect()
await asyncio.sleep(0.1)
raise RuntimeError(
f"Error during setup client and list tools: {e}"
) from e
try:
try:
asyncio.get_running_loop()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, _setup_client_and_list_tools()
)
tools_list = future.result()
except RuntimeError:
try:
tools_list = asyncio.run(_setup_client_and_list_tools())
except RuntimeError as e:
error_msg = str(e).lower()
if "cancel scope" in error_msg or "task" in error_msg:
raise ConnectionError(
"MCP connection failed due to event loop cleanup issues. "
"This may be due to authentication errors or server unavailability."
) from e
except asyncio.CancelledError as e:
raise ConnectionError(
"MCP connection was cancelled. This may indicate an authentication "
"error or server unavailability."
) from e
if mcp_config.tool_filter:
filtered_tools = []
for tool in tools_list:
if callable(mcp_config.tool_filter):
try:
from crewai.mcp.filters import ToolFilterContext
context = ToolFilterContext(
agent=self._agent,
server_name=server_name,
run_context=None,
)
if mcp_config.tool_filter(context, tool): # type: ignore[call-arg, arg-type]
filtered_tools.append(tool)
except (TypeError, AttributeError):
if mcp_config.tool_filter(tool): # type: ignore[call-arg, arg-type]
filtered_tools.append(tool)
else:
filtered_tools.append(tool)
tools_list = filtered_tools
tools = []
for tool_def in tools_list:
tool_name = tool_def.get("name", "")
original_tool_name = tool_def.get("original_name", tool_name)
if not tool_name:
continue
args_schema = None
if tool_def.get("inputSchema"):
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
tool_schema = {
"description": tool_def.get("description", ""),
"args_schema": args_schema,
}
try:
native_tool = MCPNativeTool(
mcp_client=client,
tool_name=tool_name,
tool_schema=tool_schema,
server_name=server_name,
original_tool_name=original_tool_name,
)
tools.append(native_tool)
except Exception as e:
self._logger.log("error", f"Failed to create native MCP tool: {e}")
continue
return cast(list[BaseTool], tools), client
except Exception as e:
if client.connected:
asyncio.run(client.disconnect())
raise RuntimeError(f"Failed to get native MCP tools: {e}") from e
@staticmethod
def _build_mcp_config_from_dict(
config_dict: dict[str, Any],
) -> MCPServerConfig:
"""Convert a config dict from crewai-oauth into an MCPServerConfig."""
config_type = config_dict.get("type", "http")
if config_type == "sse":
return MCPServerSSE(
url=config_dict["url"],
headers=config_dict.get("headers"),
cache_tools_list=config_dict.get("cache_tools_list", False),
)
return MCPServerHTTP(
url=config_dict["url"],
headers=config_dict.get("headers"),
streamable=config_dict.get("streamable", True),
cache_tools_list=config_dict.get("cache_tools_list", False),
)
@staticmethod
def _extract_server_name(server_url: str) -> str:
"""Extract clean server name from URL for tool prefixing."""
parsed = urlparse(server_url)
domain = parsed.netloc.replace(".", "_")
path = parsed.path.replace("/", "_").strip("_")
return f"{domain}_{path}" if path else domain
def _get_mcp_tool_schemas(
self, server_params: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""Get tool schemas from MCP server with caching."""
server_url = server_params["url"]
cache_key = server_url
current_time = time.time()
if cache_key in _mcp_schema_cache:
cached_data, cache_time = _mcp_schema_cache[cache_key]
if current_time - cache_time < _cache_ttl:
self._logger.log(
"debug", f"Using cached MCP tool schemas for {server_url}"
)
return cached_data # type: ignore[no-any-return]
try:
schemas = asyncio.run(self._get_mcp_tool_schemas_async(server_params))
_mcp_schema_cache[cache_key] = (schemas, current_time)
return schemas
except Exception as e:
self._logger.log(
"warning", f"Failed to get MCP tool schemas from {server_url}: {e}"
)
return {}
async def _get_mcp_tool_schemas_async(
self, server_params: dict[str, Any]
) -> dict[str, dict[str, Any]]:
"""Async implementation of MCP tool schema retrieval."""
server_url = server_params["url"]
return await self._retry_mcp_discovery(
self._discover_mcp_tools_with_timeout, server_url
)
async def _retry_mcp_discovery(
self, operation_func: Any, server_url: str
) -> dict[str, dict[str, Any]]:
"""Retry MCP discovery with exponential backoff."""
last_error = None
for attempt in range(MCP_MAX_RETRIES):
result, error, should_retry = await self._attempt_mcp_discovery(
operation_func, server_url
)
if result is not None:
return result
if not should_retry:
raise RuntimeError(error)
last_error = error
if attempt < MCP_MAX_RETRIES - 1:
wait_time = 2**attempt
await asyncio.sleep(wait_time)
raise RuntimeError(
f"Failed to discover MCP tools after {MCP_MAX_RETRIES} attempts: {last_error}"
)
@staticmethod
async def _attempt_mcp_discovery(
operation_func: Any, server_url: str
) -> tuple[dict[str, dict[str, Any]] | None, str, bool]:
"""Attempt single MCP discovery; returns *(result, error_message, should_retry)*."""
try:
result = await operation_func(server_url)
return result, "", False
except ImportError:
return (
None,
"MCP library not available. Please install with: pip install mcp",
False,
)
except asyncio.TimeoutError:
return (
None,
f"MCP discovery timed out after {MCP_DISCOVERY_TIMEOUT} seconds",
True,
)
except Exception as e:
error_str = str(e).lower()
if "authentication" in error_str or "unauthorized" in error_str:
return None, f"Authentication failed for MCP server: {e!s}", False
if "connection" in error_str or "network" in error_str:
return None, f"Network connection failed: {e!s}", True
if "json" in error_str or "parsing" in error_str:
return None, f"Server response parsing error: {e!s}", True
return None, f"MCP discovery error: {e!s}", False
async def _discover_mcp_tools_with_timeout(
self, server_url: str
) -> dict[str, dict[str, Any]]:
"""Discover MCP tools with timeout wrapper."""
return await asyncio.wait_for(
self._discover_mcp_tools(server_url), timeout=MCP_DISCOVERY_TIMEOUT
)
async def _discover_mcp_tools(self, server_url: str) -> dict[str, dict[str, Any]]:
"""Discover tools from an MCP server (HTTPS / streamable-HTTP path)."""
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from crewai.utilities.string_utils import sanitize_tool_name
async with streamablehttp_client(server_url) as (read, write, _):
async with ClientSession(read, write) as session:
await asyncio.wait_for(
session.initialize(), timeout=MCP_CONNECTION_TIMEOUT
)
tools_result = await asyncio.wait_for(
session.list_tools(),
timeout=MCP_DISCOVERY_TIMEOUT - MCP_CONNECTION_TIMEOUT,
)
schemas = {}
for tool in tools_result.tools:
args_schema = None
if hasattr(tool, "inputSchema") and tool.inputSchema:
args_schema = self._json_schema_to_pydantic(
sanitize_tool_name(tool.name), tool.inputSchema
)
schemas[sanitize_tool_name(tool.name)] = {
"description": getattr(tool, "description", ""),
"args_schema": args_schema,
}
return schemas
@staticmethod
def _json_schema_to_pydantic(tool_name: str, json_schema: dict[str, Any]) -> type:
"""Convert JSON Schema to a Pydantic model for tool arguments."""
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
model_name = f"{tool_name.replace('-', '_').replace(' ', '_')}Schema"
return create_model_from_schema(
json_schema,
model_name=model_name,
enrich_descriptions=True,
)

View File

@@ -1,13 +1,53 @@
from crewai.memory.entity.entity_memory import EntityMemory
from crewai.memory.external.external_memory import ExternalMemory
from crewai.memory.long_term.long_term_memory import LongTermMemory
from crewai.memory.short_term.short_term_memory import ShortTermMemory
"""Memory module: unified Memory with LLM analysis and pluggable storage.
Heavy dependencies are lazily imported so that
``import crewai`` does not initialise at runtime — critical for
Celery pre-fork and similar deployment patterns.
"""
from __future__ import annotations
from typing import Any
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.types import (
MemoryMatch,
MemoryRecord,
ScopeInfo,
compute_composite_score,
embed_text,
embed_texts,
)
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),
"EncodingFlow": ("crewai.memory.encoding_flow", "EncodingFlow"),
}
def __getattr__(name: str) -> Any:
"""Lazily import Memory / EncodingFlow to avoid pulling in lancedb at import time."""
if name in _LAZY_IMPORTS:
import importlib
module_path, attr = _LAZY_IMPORTS[name]
mod = importlib.import_module(module_path)
val = getattr(mod, attr)
globals()[name] = val
return val
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"EntityMemory",
"ExternalMemory",
"LongTermMemory",
"ShortTermMemory",
"EncodingFlow",
"Memory",
"MemoryMatch",
"MemoryRecord",
"MemoryScope",
"MemorySlice",
"ScopeInfo",
"compute_composite_score",
"embed_text",
"embed_texts",
]

View File

@@ -0,0 +1,371 @@
"""LLM-powered analysis for memory save and recall."""
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from crewai.memory.types import MemoryRecord, ScopeInfo
from crewai.utilities.i18n import get_i18n
_logger = logging.getLogger(__name__)
class ExtractedMetadata(BaseModel):
"""Fixed schema for LLM-extracted metadata (OpenAI requires additionalProperties: false)."""
model_config = ConfigDict(extra="forbid")
entities: list[str] = Field(
default_factory=list,
description="Entities (people, orgs, places) mentioned in the content.",
)
dates: list[str] = Field(
default_factory=list,
description="Dates or time references in the content.",
)
topics: list[str] = Field(
default_factory=list,
description="Topics or themes in the content.",
)
class MemoryAnalysis(BaseModel):
"""LLM output for analyzing content before saving to memory."""
suggested_scope: str = Field(
description="Best matching existing scope or new path (e.g. /company/decisions).",
)
categories: list[str] = Field(
default_factory=list,
description="Categories for the memory (prefer existing, add new if needed).",
)
importance: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Importance score from 0.0 to 1.0.",
)
extracted_metadata: ExtractedMetadata = Field(
default_factory=ExtractedMetadata,
description="Entities, dates, topics extracted from the content.",
)
class QueryAnalysis(BaseModel):
"""LLM output for analyzing a recall query."""
keywords: list[str] = Field(
default_factory=list,
description="Key entities or keywords for filtering.",
)
suggested_scopes: list[str] = Field(
default_factory=list,
description="Scope paths to search (subset of available scopes).",
)
complexity: str = Field(
default="simple",
description="One of 'simple' (single fact) or 'complex' (aggregation/reasoning).",
)
recall_queries: list[str] = Field(
default_factory=list,
description=(
"1-3 short, targeted search phrases distilled from the query. "
"Each should be a concise question or keyword phrase optimized "
"for semantic vector search. If the query is already short and "
"focused, return it as a single item."
),
)
time_filter: str | None = Field(
default=None,
description=(
"If the query references a specific time period (e.g. 'last week', "
"'yesterday', 'in January'), return an ISO 8601 date string representing "
"the earliest date that results should match (e.g. '2026-02-01'). "
"Return null if no time constraint is implied."
),
)
class ExtractedMemories(BaseModel):
"""LLM output for extracting discrete memories from raw content."""
memories: list[str] = Field(
default_factory=list,
description="List of discrete, self-contained memory statements extracted from the content.",
)
class ConsolidationAction(BaseModel):
"""A single action in a consolidation plan."""
model_config = ConfigDict(extra="forbid")
action: str = Field(
description="One of 'keep', 'update', or 'delete'.",
)
record_id: str = Field(
description="ID of the existing record this action applies to.",
)
new_content: str | None = Field(
default=None,
description="Updated content text. Required when action is 'update'.",
)
reason: str = Field(
default="",
description="Brief reason for this action.",
)
class ConsolidationPlan(BaseModel):
"""LLM output for consolidating new content with existing memories."""
model_config = ConfigDict(extra="forbid")
actions: list[ConsolidationAction] = Field(
default_factory=list,
description="Actions to take on existing records (keep/update/delete).",
)
insert_new: bool = Field(
default=True,
description="Whether to also insert the new content as a separate record.",
)
insert_reason: str = Field(
default="",
description="Why the new content should or should not be inserted.",
)
def _get_prompt(key: str) -> str:
"""Retrieve a memory prompt from the i18n translations.
Args:
key: The prompt key under the "memory" section.
Returns:
The prompt string.
"""
return get_i18n().memory(key)
def extract_memories_from_content(content: str, llm: Any) -> list[str]:
"""Use the LLM to extract discrete memory statements from raw content.
This is a pure helper: it does NOT store anything. Callers should call
memory.remember() on each returned string to persist them.
On LLM failure, returns the full content as a single memory so callers
still persist something rather than dropping the output.
Args:
content: Raw text (e.g. task description + result dump).
llm: The LLM instance to use.
Returns:
List of short, self-contained memory statements (or [content] on failure).
"""
if not (content or "").strip():
return []
user = _get_prompt("extract_memories_user").format(content=content)
messages = [
{"role": "system", "content": _get_prompt("extract_memories_system")},
{"role": "user", "content": user},
]
try:
if getattr(llm, "supports_function_calling", lambda: False)():
response = llm.call(messages, response_model=ExtractedMemories)
if isinstance(response, ExtractedMemories):
return response.memories
return ExtractedMemories.model_validate(response).memories
response = llm.call(messages)
if isinstance(response, ExtractedMemories):
return response.memories
if isinstance(response, str):
data = json.loads(response)
return ExtractedMemories.model_validate(data).memories
return ExtractedMemories.model_validate(response).memories
except Exception as e:
_logger.warning(
"Memory extraction failed, storing full content as single memory: %s",
e,
exc_info=False,
)
return [content]
def analyze_query(
query: str,
available_scopes: list[str],
scope_info: ScopeInfo | None,
llm: Any,
) -> QueryAnalysis:
"""Use the LLM to analyze a recall query.
On LLM failure, returns safe defaults so recall degrades to plain vector search.
Args:
query: The user's recall query.
available_scopes: Scope paths that exist in the store.
scope_info: Optional info about the current scope.
llm: The LLM instance to use.
Returns:
QueryAnalysis with keywords, suggested_scopes, complexity, recall_queries, time_filter.
"""
scope_desc = ""
if scope_info:
scope_desc = f"Current scope has {scope_info.record_count} records, categories: {scope_info.categories}"
user = _get_prompt("query_user").format(
query=query,
available_scopes=available_scopes or ["/"],
scope_desc=scope_desc,
)
messages = [
{"role": "system", "content": _get_prompt("query_system")},
{"role": "user", "content": user},
]
try:
if getattr(llm, "supports_function_calling", lambda: False)():
response = llm.call(messages, response_model=QueryAnalysis)
if isinstance(response, QueryAnalysis):
return response
return QueryAnalysis.model_validate(response)
response = llm.call(messages)
if isinstance(response, QueryAnalysis):
return response
if isinstance(response, str):
data = json.loads(response)
return QueryAnalysis.model_validate(data)
return QueryAnalysis.model_validate(response)
except Exception as e:
_logger.warning(
"Query analysis failed, using defaults (complexity=simple): %s",
e,
exc_info=False,
)
scopes = (available_scopes or ["/"])[:5]
return QueryAnalysis(
keywords=[],
suggested_scopes=scopes,
complexity="simple",
recall_queries=[query],
)
_SAVE_DEFAULTS = MemoryAnalysis(
suggested_scope="/",
categories=[],
importance=0.5,
extracted_metadata=ExtractedMetadata(),
)
def analyze_for_save(
content: str,
existing_scopes: list[str],
existing_categories: list[str],
llm: Any,
) -> MemoryAnalysis:
"""Infer scope, categories, importance, and metadata for a single memory.
Uses the small ``MemoryAnalysis`` schema (4 fields) for fast LLM response.
On failure, returns safe defaults so the memory still gets persisted.
Args:
content: The memory content to analyze.
existing_scopes: Current scope paths in the memory store.
existing_categories: Current categories in use.
llm: The LLM instance to use.
Returns:
MemoryAnalysis with suggested_scope, categories, importance, extracted_metadata.
"""
user = _get_prompt("save_user").format(
content=content,
existing_scopes=existing_scopes or ["/"],
existing_categories=existing_categories or [],
)
messages = [
{"role": "system", "content": _get_prompt("save_system")},
{"role": "user", "content": user},
]
try:
if getattr(llm, "supports_function_calling", lambda: False)():
response = llm.call(messages, response_model=MemoryAnalysis)
if isinstance(response, MemoryAnalysis):
return response
return MemoryAnalysis.model_validate(response)
response = llm.call(messages)
if isinstance(response, MemoryAnalysis):
return response
if isinstance(response, str):
data = json.loads(response)
return MemoryAnalysis.model_validate(data)
return MemoryAnalysis.model_validate(response)
except Exception as e:
_logger.warning(
"Memory save analysis failed, using defaults: %s", e, exc_info=False,
)
return _SAVE_DEFAULTS
_CONSOLIDATION_DEFAULT = ConsolidationPlan(actions=[], insert_new=True)
def analyze_for_consolidation(
new_content: str,
existing_records: list[MemoryRecord],
llm: Any,
) -> ConsolidationPlan:
"""Decide insert/update/delete for a single memory against similar existing records.
Uses the small ``ConsolidationPlan`` schema (3 fields) for fast LLM response.
On failure, returns a safe default (insert_new=True) so the memory still gets persisted.
Args:
new_content: The new content to store.
existing_records: Existing records that are semantically similar.
llm: The LLM instance to use.
Returns:
ConsolidationPlan with actions per record and whether to insert the new content.
"""
if not existing_records:
return ConsolidationPlan(actions=[], insert_new=True)
records_lines: list[str] = []
for r in existing_records:
created = r.created_at.isoformat() if r.created_at else ""
records_lines.append(
f"- id={r.id} | scope={r.scope} | importance={r.importance:.2f} | created={created}\n"
f" content: {r.content[:200]}{'...' if len(r.content) > 200 else ''}"
)
user = _get_prompt("consolidation_user").format(
new_content=new_content,
records_summary="\n\n".join(records_lines),
)
messages = [
{"role": "system", "content": _get_prompt("consolidation_system")},
{"role": "user", "content": user},
]
try:
if getattr(llm, "supports_function_calling", lambda: False)():
response = llm.call(messages, response_model=ConsolidationPlan)
if isinstance(response, ConsolidationPlan):
return response
return ConsolidationPlan.model_validate(response)
response = llm.call(messages)
if isinstance(response, ConsolidationPlan):
return response
if isinstance(response, str):
data = json.loads(response)
return ConsolidationPlan.model_validate(data)
return ConsolidationPlan.model_validate(response)
except Exception as e:
_logger.warning(
"Consolidation analysis failed, defaulting to insert: %s", e, exc_info=False,
)
return _CONSOLIDATION_DEFAULT

View File

@@ -1,254 +0,0 @@
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from crewai.memory import (
EntityMemory,
ExternalMemory,
LongTermMemory,
ShortTermMemory,
)
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.task import Task
class ContextualMemory:
"""Aggregates and retrieves context from multiple memory sources."""
def __init__(
self,
stm: ShortTermMemory,
ltm: LongTermMemory,
em: EntityMemory,
exm: ExternalMemory,
agent: Agent | None = None,
task: Task | None = None,
) -> None:
self.stm = stm
self.ltm = ltm
self.em = em
self.exm = exm
self.agent = agent
self.task = task
if self.stm is not None:
self.stm.agent = self.agent
self.stm.task = self.task
if self.ltm is not None:
self.ltm.agent = self.agent
self.ltm.task = self.task
if self.em is not None:
self.em.agent = self.agent
self.em.task = self.task
if self.exm is not None:
self.exm.agent = self.agent
self.exm.task = self.task
def build_context_for_task(self, task: Task, context: str) -> str:
"""Build contextual information for a task synchronously.
Args:
task: The task to build context for.
context: Additional context string.
Returns:
Formatted context string from all memory sources.
"""
query = f"{task.description} {context}".strip()
if query == "":
return ""
context_parts = [
self._fetch_ltm_context(task.description),
self._fetch_stm_context(query),
self._fetch_entity_context(query),
self._fetch_external_context(query),
]
return "\n".join(filter(None, context_parts))
async def abuild_context_for_task(self, task: Task, context: str) -> str:
"""Build contextual information for a task asynchronously.
Args:
task: The task to build context for.
context: Additional context string.
Returns:
Formatted context string from all memory sources.
"""
query = f"{task.description} {context}".strip()
if query == "":
return ""
# Fetch all contexts concurrently
results = await asyncio.gather(
self._afetch_ltm_context(task.description),
self._afetch_stm_context(query),
self._afetch_entity_context(query),
self._afetch_external_context(query),
)
return "\n".join(filter(None, results))
def _fetch_stm_context(self, query: str) -> str:
"""
Fetches recent relevant insights from STM related to the task's description and expected_output,
formatted as bullet points.
"""
if self.stm is None:
return ""
stm_results = self.stm.search(query)
formatted_results = "\n".join(
[f"- {result['content']}" for result in stm_results]
)
return f"Recent Insights:\n{formatted_results}" if stm_results else ""
def _fetch_ltm_context(self, task: str) -> str | None:
"""
Fetches historical data or insights from LTM that are relevant to the task's description and expected_output,
formatted as bullet points.
"""
if self.ltm is None:
return ""
ltm_results = self.ltm.search(task, latest_n=2)
if not ltm_results:
return None
formatted_results = [
suggestion
for result in ltm_results
for suggestion in result["metadata"]["suggestions"]
]
formatted_results = list(dict.fromkeys(formatted_results))
formatted_results = "\n".join([f"- {result}" for result in formatted_results]) # type: ignore # Incompatible types in assignment (expression has type "str", variable has type "list[str]")
return f"Historical Data:\n{formatted_results}" if ltm_results else ""
def _fetch_entity_context(self, query: str) -> str:
"""
Fetches relevant entity information from Entity Memory related to the task's description and expected_output,
formatted as bullet points.
"""
if self.em is None:
return ""
em_results = self.em.search(query)
formatted_results = "\n".join(
[f"- {result['content']}" for result in em_results]
)
return f"Entities:\n{formatted_results}" if em_results else ""
def _fetch_external_context(self, query: str) -> str:
"""
Fetches and formats relevant information from External Memory.
Args:
query (str): The search query to find relevant information.
Returns:
str: Formatted information as bullet points, or an empty string if none found.
"""
if self.exm is None:
return ""
external_memories = self.exm.search(query)
if not external_memories:
return ""
formatted_memories = "\n".join(
f"- {result['content']}" for result in external_memories
)
return f"External memories:\n{formatted_memories}"
async def _afetch_stm_context(self, query: str) -> str:
"""Fetch recent relevant insights from STM asynchronously.
Args:
query: The search query.
Returns:
Formatted insights as bullet points, or empty string if none found.
"""
if self.stm is None:
return ""
stm_results = await self.stm.asearch(query)
formatted_results = "\n".join(
[f"- {result['content']}" for result in stm_results]
)
return f"Recent Insights:\n{formatted_results}" if stm_results else ""
async def _afetch_ltm_context(self, task: str) -> str | None:
"""Fetch historical data from LTM asynchronously.
Args:
task: The task description to search for.
Returns:
Formatted historical data as bullet points, or None if none found.
"""
if self.ltm is None:
return ""
ltm_results = await self.ltm.asearch(task, latest_n=2)
if not ltm_results:
return None
formatted_results = [
suggestion
for result in ltm_results
for suggestion in result["metadata"]["suggestions"]
]
formatted_results = list(dict.fromkeys(formatted_results))
formatted_results = "\n".join([f"- {result}" for result in formatted_results]) # type: ignore # Incompatible types in assignment (expression has type "str", variable has type "list[str]")
return f"Historical Data:\n{formatted_results}" if ltm_results else ""
async def _afetch_entity_context(self, query: str) -> str:
"""Fetch relevant entity information asynchronously.
Args:
query: The search query.
Returns:
Formatted entity information as bullet points, or empty string if none found.
"""
if self.em is None:
return ""
em_results = await self.em.asearch(query)
formatted_results = "\n".join(
[f"- {result['content']}" for result in em_results]
)
return f"Entities:\n{formatted_results}" if em_results else ""
async def _afetch_external_context(self, query: str) -> str:
"""Fetch relevant information from External Memory asynchronously.
Args:
query: The search query.
Returns:
Formatted information as bullet points, or empty string if none found.
"""
if self.exm is None:
return ""
external_memories = await self.exm.asearch(query)
if not external_memories:
return ""
formatted_memories = "\n".join(
f"- {result['content']}" for result in external_memories
)
return f"External memories:\n{formatted_memories}"

View File

@@ -0,0 +1,444 @@
"""Batch-native encoding flow: full save pipeline for one or more memories.
Orchestrates the encoding side of memory in a single Flow with 5 steps:
1. Batch embed (ONE embedder call for all items)
2. Intra-batch dedup (cosine matrix, drop near-exact duplicates)
3. Parallel find similar (concurrent storage searches)
4. Parallel analyze (N concurrent LLM calls -- field resolution + consolidation)
5. Execute plans (batch re-embed updates + bulk insert)
"""
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
import math
from typing import Any
from uuid import uuid4
from pydantic import BaseModel, Field
from crewai.flow.flow import Flow, listen, start
from crewai.memory.analyze import (
ConsolidationPlan,
MemoryAnalysis,
analyze_for_consolidation,
analyze_for_save,
)
from crewai.memory.types import MemoryConfig, MemoryRecord, embed_texts
# ---------------------------------------------------------------------------
# State models
# ---------------------------------------------------------------------------
class ItemState(BaseModel):
"""Per-item tracking within a batch."""
content: str = ""
# Caller-provided (None = infer via LLM)
scope: str | None = None
categories: list[str] | None = None
metadata: dict[str, Any] | None = None
importance: float | None = None
source: str | None = None
private: bool = False
# Resolved values
resolved_scope: str = "/"
resolved_categories: list[str] = Field(default_factory=list)
resolved_metadata: dict[str, Any] = Field(default_factory=dict)
resolved_importance: float = 0.5
resolved_source: str | None = None
resolved_private: bool = False
# Embedding
embedding: list[float] = Field(default_factory=list)
# Intra-batch dedup
dropped: bool = False
# Consolidation
similar_records: list[MemoryRecord] = Field(default_factory=list)
top_similarity: float = 0.0
plan: ConsolidationPlan | None = None
result_record: MemoryRecord | None = None
class EncodingState(BaseModel):
"""Batch-level state for the encoding flow."""
id: str = Field(default_factory=lambda: str(uuid4()))
items: list[ItemState] = Field(default_factory=list)
# Aggregate stats
records_inserted: int = 0
records_updated: int = 0
records_deleted: int = 0
items_dropped_dedup: int = 0
# ---------------------------------------------------------------------------
# Flow
# ---------------------------------------------------------------------------
class EncodingFlow(Flow[EncodingState]):
"""Batch-native encoding pipeline for memory.remember() / remember_many().
Processes N items through 5 sequential steps, maximising parallelism:
- ONE embedder call for all items
- N concurrent storage searches
- N concurrent individual LLM calls (field resolution + consolidation)
- ONE batch re-embed for updates + ONE bulk storage write
"""
_skip_auto_memory: bool = True
initial_state = EncodingState
def __init__(
self,
storage: Any,
llm: Any,
embedder: Any,
config: MemoryConfig | None = None,
) -> None:
super().__init__(suppress_flow_events=True)
self._storage = storage
self._llm = llm
self._embedder = embedder
self._config = config or MemoryConfig()
# ------------------------------------------------------------------
# Step 1: Batch embed (ONE embedder call)
# ------------------------------------------------------------------
@start()
def batch_embed(self) -> None:
"""Embed all items in a single embedder call."""
items = list(self.state.items)
texts = [item.content for item in items]
embeddings = embed_texts(self._embedder, texts)
for item, emb in zip(items, embeddings, strict=False):
item.embedding = emb
# ------------------------------------------------------------------
# Step 2: Intra-batch dedup (cosine similarity matrix)
# ------------------------------------------------------------------
@listen(batch_embed)
def intra_batch_dedup(self) -> None:
"""Drop near-exact duplicates within the batch."""
items = list(self.state.items)
if len(items) <= 1:
return
threshold = self._config.batch_dedup_threshold
n = len(items)
for j in range(1, n):
if items[j].dropped or not items[j].embedding:
continue
for i in range(j):
if items[i].dropped or not items[i].embedding:
continue
sim = self._cosine_similarity(items[i].embedding, items[j].embedding)
if sim >= threshold:
items[j].dropped = True
self.state.items_dropped_dedup += 1
break
@staticmethod
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Compute cosine similarity between two vectors."""
if len(a) != len(b) or not a:
return 0.0
dot = sum(x * y for x, y in zip(a, b, strict=False))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
# ------------------------------------------------------------------
# Step 3: Parallel find similar (concurrent storage searches)
# ------------------------------------------------------------------
@listen(intra_batch_dedup)
def parallel_find_similar(self) -> None:
"""Search storage for similar records, concurrently for all active items."""
items = list(self.state.items)
active = [(i, item) for i, item in enumerate(items) if not item.dropped and item.embedding]
if not active:
return
def _search_one(item: ItemState) -> list[tuple[MemoryRecord, float]]:
scope_prefix = item.scope if item.scope and item.scope.strip("/") else None
return self._storage.search(
item.embedding,
scope_prefix=scope_prefix,
categories=None,
limit=self._config.consolidation_limit,
min_score=0.0,
)
if len(active) == 1:
_, item = active[0]
raw = _search_one(item)
item.similar_records = [r for r, _ in raw]
item.top_similarity = float(raw[0][1]) if raw else 0.0
else:
with ThreadPoolExecutor(max_workers=min(len(active), 8)) as pool:
futures = [(i, item, pool.submit(_search_one, item)) for i, item in active]
for _, item, future in futures:
raw = future.result()
item.similar_records = [r for r, _ in raw]
item.top_similarity = float(raw[0][1]) if raw else 0.0
# ------------------------------------------------------------------
# Step 4: Parallel analyze (N concurrent LLM calls)
# ------------------------------------------------------------------
@listen(parallel_find_similar)
def parallel_analyze(self) -> None:
"""Field resolution + consolidation via parallel individual LLM calls.
Classifies each active item into one of four groups:
- Group A: fields provided + no similar records -> fast insert, 0 LLM calls.
- Group B: fields provided + similar records above threshold -> 1 consolidation call.
- Group C: fields missing + no similar records -> 1 field-resolution call.
- Group D: fields missing + similar records above threshold -> 2 concurrent calls.
All LLM calls across all items run in parallel via ThreadPoolExecutor.
"""
items = list(self.state.items)
threshold = self._config.consolidation_threshold
# Pre-fetch scope/category info (shared across all field-resolution calls)
any_needs_fields = any(
not it.dropped
and (it.scope is None or it.categories is None or it.importance is None)
for it in items
)
existing_scopes: list[str] = []
existing_categories: list[str] = []
if any_needs_fields:
existing_scopes = self._storage.list_scopes("/") or ["/"]
existing_categories = list(
self._storage.list_categories(scope_prefix=None).keys()
)
# Classify items and submit LLM calls
save_futures: dict[int, Future[MemoryAnalysis]] = {}
consol_futures: dict[int, Future[ConsolidationPlan]] = {}
pool = ThreadPoolExecutor(max_workers=10)
try:
for i, item in enumerate(items):
if item.dropped:
continue
fields_provided = (
item.scope is not None
and item.categories is not None
and item.importance is not None
)
has_similar = item.top_similarity >= threshold
if fields_provided and not has_similar:
# Group A: fast path
self._apply_defaults(item)
item.plan = ConsolidationPlan(actions=[], insert_new=True)
elif fields_provided and has_similar:
# Group B: consolidation only
self._apply_defaults(item)
consol_futures[i] = pool.submit(
analyze_for_consolidation,
item.content, list(item.similar_records), self._llm,
)
elif not fields_provided and not has_similar:
# Group C: field resolution only
save_futures[i] = pool.submit(
analyze_for_save,
item.content, existing_scopes, existing_categories, self._llm,
)
else:
# Group D: both in parallel
save_futures[i] = pool.submit(
analyze_for_save,
item.content, existing_scopes, existing_categories, self._llm,
)
consol_futures[i] = pool.submit(
analyze_for_consolidation,
item.content, list(item.similar_records), self._llm,
)
# Collect field-resolution results
for i, future in save_futures.items():
analysis = future.result()
item = items[i]
item.resolved_scope = item.scope or analysis.suggested_scope or "/"
item.resolved_categories = (
item.categories
if item.categories is not None
else analysis.categories
)
item.resolved_importance = (
item.importance
if item.importance is not None
else analysis.importance
)
item.resolved_metadata = dict(
item.metadata or {},
**(
analysis.extracted_metadata.model_dump()
if analysis.extracted_metadata
else {}
),
)
item.resolved_source = item.source
item.resolved_private = item.private
# If no consolidation future, it's Group C -> insert
if i not in consol_futures:
item.plan = ConsolidationPlan(actions=[], insert_new=True)
# Collect consolidation results
for i, future in consol_futures.items():
items[i].plan = future.result()
finally:
pool.shutdown(wait=False)
def _apply_defaults(self, item: ItemState) -> None:
"""Apply caller values with config defaults (fast path)."""
item.resolved_scope = item.scope or "/"
item.resolved_categories = item.categories or []
item.resolved_metadata = item.metadata or {}
item.resolved_importance = (
item.importance
if item.importance is not None
else self._config.default_importance
)
item.resolved_source = item.source
item.resolved_private = item.private
# ------------------------------------------------------------------
# Step 5: Execute plans (batch re-embed + bulk insert)
# ------------------------------------------------------------------
@listen(parallel_analyze)
def execute_plans(self) -> None:
"""Apply all consolidation plans with batch re-embedding and bulk insert.
Actions are deduplicated across items before applying: when multiple
items reference the same existing record (e.g. both want to delete it),
only the first action is applied. This prevents LanceDB commit
conflicts from two operations targeting the same record.
"""
items = list(self.state.items)
now = datetime.utcnow()
# --- Deduplicate actions across all items ---
# Multiple items may reference the same existing record (because their
# similar_records overlap). Collect one action per record_id, first wins.
# Also build a map from record_id to the original MemoryRecord for updates.
dedup_deletes: set[str] = set() # record_ids to delete
dedup_updates: dict[str, tuple[int, str]] = {} # record_id -> (item_idx, new_content)
all_similar: dict[str, MemoryRecord] = {} # record_id -> MemoryRecord
for i, item in enumerate(items):
if item.dropped or item.plan is None:
continue
for r in item.similar_records:
if r.id not in all_similar:
all_similar[r.id] = r
for action in item.plan.actions:
rid = action.record_id
if action.action == "delete" and rid not in dedup_deletes and rid not in dedup_updates:
dedup_deletes.add(rid)
elif action.action == "update" and action.new_content and rid not in dedup_deletes and rid not in dedup_updates:
dedup_updates[rid] = (i, action.new_content)
# --- Batch re-embed all update contents in ONE call ---
update_list = list(dedup_updates.items()) # [(record_id, (item_idx, new_content)), ...]
update_embeddings: list[list[float]] = []
if update_list:
update_contents = [content for _, (_, content) in update_list]
update_embeddings = embed_texts(self._embedder, update_contents)
update_emb_map: dict[str, list[float]] = {}
for (rid, _), emb in zip(update_list, update_embeddings, strict=False):
update_emb_map[rid] = emb
# --- Apply all storage mutations under one lock ---
# Hold the write lock for the entire delete + update + insert sequence
# so no other pipeline can interleave and cause version conflicts.
# The lock is reentrant (RLock), so the individual storage methods
# can re-acquire it without deadlocking.
# Collect records to insert (outside lock -- pure data assembly)
to_insert: list[tuple[int, MemoryRecord]] = []
for i, item in enumerate(items):
if item.dropped or item.plan is None:
continue
if item.plan.insert_new:
to_insert.append((i, MemoryRecord(
content=item.content,
scope=item.resolved_scope,
categories=item.resolved_categories,
metadata=item.resolved_metadata,
importance=item.resolved_importance,
embedding=item.embedding if item.embedding else None,
source=item.resolved_source,
private=item.resolved_private,
)))
# All storage mutations under one lock so no other pipeline can
# interleave and cause version conflicts. The lock is reentrant
# (RLock) so the individual storage methods re-acquire it safely.
updated_records: dict[str, MemoryRecord] = {}
with self._storage.write_lock:
if dedup_deletes:
self._storage.delete(record_ids=list(dedup_deletes))
self.state.records_deleted += len(dedup_deletes)
for rid, (_item_idx, new_content) in dedup_updates.items():
existing = all_similar.get(rid)
if existing is not None:
new_emb = update_emb_map.get(rid, [])
updated = MemoryRecord(
id=existing.id,
content=new_content,
scope=existing.scope,
categories=existing.categories,
metadata=existing.metadata,
importance=existing.importance,
created_at=existing.created_at,
last_accessed=now,
embedding=new_emb if new_emb else existing.embedding,
)
self._storage.update(updated)
self.state.records_updated += 1
updated_records[rid] = updated
if to_insert:
records = [r for _, r in to_insert]
self._storage.save(records)
self.state.records_inserted += len(records)
for idx, record in to_insert:
items[idx].result_record = record
# Set result_record for non-insert items (after lock, using updated_records)
for _i, item in enumerate(items):
if item.dropped or item.plan is None or item.plan.insert_new:
continue
if item.result_record is not None:
continue
first_updated = next(
(
updated_records[a.record_id]
for a in item.plan.actions
if a.action == "update" and a.record_id in updated_records
),
None,
)
item.result_record = (
first_updated
if first_updated is not None
else (item.similar_records[0] if item.similar_records else None)
)

View File

@@ -1,404 +0,0 @@
import time
from typing import Any
from pydantic import PrivateAttr
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.memory.entity.entity_memory_item import EntityMemoryItem
from crewai.memory.memory import Memory
from crewai.memory.storage.rag_storage import RAGStorage
class EntityMemory(Memory):
"""
EntityMemory class for managing structured information about entities
and their relationships using SQLite storage.
Inherits from the Memory class.
"""
_memory_provider: str | None = PrivateAttr()
def __init__(
self,
crew: Any = None,
embedder_config: Any = None,
storage: Any = None,
path: str | None = None,
) -> None:
memory_provider = None
if embedder_config and isinstance(embedder_config, dict):
memory_provider = embedder_config.get("provider")
if memory_provider == "mem0":
try:
from crewai.memory.storage.mem0_storage import Mem0Storage
except ImportError as e:
raise ImportError(
"Mem0 is not installed. Please install it with `pip install mem0ai`."
) from e
config = (
embedder_config.get("config")
if embedder_config and isinstance(embedder_config, dict)
else None
)
storage = Mem0Storage(type="short_term", crew=crew, config=config) # type: ignore[no-untyped-call]
else:
storage = (
storage
if storage
else RAGStorage(
type="entities",
allow_reset=True,
embedder_config=embedder_config,
crew=crew,
path=path,
)
)
super().__init__(storage=storage)
self._memory_provider = memory_provider
def save(
self,
value: EntityMemoryItem | list[EntityMemoryItem],
metadata: dict[str, Any] | None = None,
) -> None:
"""Saves one or more entity items into the SQLite storage.
Args:
value: Single EntityMemoryItem or list of EntityMemoryItems to save.
metadata: Optional metadata dict (included for supertype compatibility but not used).
Notes:
The metadata parameter is included to satisfy the supertype signature but is not
used - entity metadata is extracted from the EntityMemoryItem objects themselves.
"""
if not value:
return
items = value if isinstance(value, list) else [value]
is_batch = len(items) > 1
metadata = {"entity_count": len(items)} if is_batch else items[0].metadata
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
metadata=metadata,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
saved_count = 0
errors = []
def save_single_item(item: EntityMemoryItem) -> tuple[bool, str | None]:
"""Save a single item and return success status."""
try:
if self._memory_provider == "mem0":
data = f"""
Remember details about the following entity:
Name: {item.name}
Type: {item.type}
Entity Description: {item.description}
"""
else:
data = f"{item.name}({item.type}): {item.description}"
super(EntityMemory, self).save(data, item.metadata)
return True, None
except Exception as e:
return False, f"{item.name}: {e!s}"
try:
for item in items:
success, error = save_single_item(item)
if success:
saved_count += 1
else:
errors.append(error)
if is_batch:
emit_value = f"Saved {saved_count} entities"
metadata = {"entity_count": saved_count, "errors": errors}
else:
emit_value = f"{items[0].name}({items[0].type}): {items[0].description}"
metadata = items[0].metadata
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=emit_value,
metadata=metadata,
save_time_ms=(time.time() - start_time) * 1000,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
if errors:
raise Exception(
f"Partial save: {len(errors)} failed out of {len(items)}"
)
except Exception as e:
fail_metadata = (
{"entity_count": len(items), "saved": saved_count}
if is_batch
else items[0].metadata
)
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
metadata=fail_metadata,
error=str(e),
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
def search(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search entity memory for relevant entries.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = super().search(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="entity_memory",
),
)
raise
async def asave(
self,
value: EntityMemoryItem | list[EntityMemoryItem],
metadata: dict[str, Any] | None = None,
) -> None:
"""Save entity items asynchronously.
Args:
value: Single EntityMemoryItem or list of EntityMemoryItems to save.
metadata: Optional metadata dict (not used, for signature compatibility).
"""
if not value:
return
items = value if isinstance(value, list) else [value]
is_batch = len(items) > 1
metadata = {"entity_count": len(items)} if is_batch else items[0].metadata
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
metadata=metadata,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
saved_count = 0
errors: list[str | None] = []
async def save_single_item(item: EntityMemoryItem) -> tuple[bool, str | None]:
"""Save a single item asynchronously."""
try:
if self._memory_provider == "mem0":
data = f"""
Remember details about the following entity:
Name: {item.name}
Type: {item.type}
Entity Description: {item.description}
"""
else:
data = f"{item.name}({item.type}): {item.description}"
await super(EntityMemory, self).asave(data, item.metadata)
return True, None
except Exception as e:
return False, f"{item.name}: {e!s}"
try:
for item in items:
success, error = await save_single_item(item)
if success:
saved_count += 1
else:
errors.append(error)
if is_batch:
emit_value = f"Saved {saved_count} entities"
metadata = {"entity_count": saved_count, "errors": errors}
else:
emit_value = f"{items[0].name}({items[0].type}): {items[0].description}"
metadata = items[0].metadata
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=emit_value,
metadata=metadata,
save_time_ms=(time.time() - start_time) * 1000,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
if errors:
raise Exception(
f"Partial save: {len(errors)} failed out of {len(items)}"
)
except Exception as e:
fail_metadata = (
{"entity_count": len(items), "saved": saved_count}
if is_batch
else items[0].metadata
)
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
metadata=fail_metadata,
error=str(e),
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
async def asearch(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search entity memory asynchronously.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = await super().asearch(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="entity_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="entity_memory",
),
)
raise
def reset(self) -> None:
try:
self.storage.reset()
except Exception as e:
raise Exception(
f"An error occurred while resetting the entity memory: {e}"
) from e

View File

@@ -1,12 +0,0 @@
class EntityMemoryItem:
def __init__(
self,
name: str,
type: str,
description: str,
relationships: str,
):
self.name = name
self.type = type
self.description = description
self.metadata = {"relationships": relationships}

View File

@@ -1,301 +0,0 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.memory.external.external_memory_item import ExternalMemoryItem
from crewai.memory.memory import Memory
from crewai.memory.storage.interface import Storage
from crewai.rag.embeddings.types import ProviderSpec
if TYPE_CHECKING:
from crewai.memory.storage.mem0_storage import Mem0Storage
class ExternalMemory(Memory):
def __init__(self, storage: Storage | None = None, **data: Any):
super().__init__(storage=storage, **data)
@staticmethod
def _configure_mem0(crew: Any, config: dict[str, Any]) -> Mem0Storage:
from crewai.memory.storage.mem0_storage import Mem0Storage
return Mem0Storage(type="external", crew=crew, config=config) # type: ignore[no-untyped-call]
@staticmethod
def external_supported_storages() -> dict[str, Any]:
return {
"mem0": ExternalMemory._configure_mem0,
}
@staticmethod
def create_storage(
crew: Any, embedder_config: dict[str, Any] | ProviderSpec | None
) -> Storage:
if not embedder_config:
raise ValueError("embedder_config is required")
if "provider" not in embedder_config:
raise ValueError("embedder_config must include a 'provider' key")
provider = embedder_config["provider"]
supported_storages = ExternalMemory.external_supported_storages()
if provider not in supported_storages:
raise ValueError(f"Provider {provider} not supported")
storage: Storage = supported_storages[provider](
crew, embedder_config.get("config", {})
)
return storage
def save(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
"""Saves a value into the external storage."""
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=value,
metadata=metadata,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
item = ExternalMemoryItem(
value=value,
metadata=metadata,
agent=self.agent.role if self.agent else None,
)
super().save(value=item.value, metadata=item.metadata)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=value,
metadata=metadata,
save_time_ms=(time.time() - start_time) * 1000,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=value,
metadata=metadata,
error=str(e),
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
def search(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search external memory for relevant entries.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = super().search(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="external_memory",
),
)
raise
async def asave(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
"""Save a value to external memory asynchronously.
Args:
value: The value to save.
metadata: Optional metadata to associate with the value.
"""
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=value,
metadata=metadata,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
item = ExternalMemoryItem(
value=value,
metadata=metadata,
agent=self.agent.role if self.agent else None,
)
await super().asave(value=item.value, metadata=item.metadata)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=value,
metadata=metadata,
save_time_ms=(time.time() - start_time) * 1000,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=value,
metadata=metadata,
error=str(e),
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
async def asearch(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search external memory asynchronously.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = await super().asearch(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="external_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="external_memory",
),
)
raise
def reset(self) -> None:
self.storage.reset()
def set_crew(self, crew: Any) -> ExternalMemory:
super().set_crew(crew)
if not self.storage:
self.storage = self.create_storage(crew, self.embedder_config) # type: ignore[arg-type]
return self

View File

@@ -1,13 +0,0 @@
from typing import Any
class ExternalMemoryItem:
def __init__(
self,
value: Any,
metadata: dict[str, Any] | None = None,
agent: str | None = None,
):
self.value = value
self.metadata = metadata
self.agent = agent

View File

@@ -1,255 +0,0 @@
import time
from typing import Any
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.memory.long_term.long_term_memory_item import LongTermMemoryItem
from crewai.memory.memory import Memory
from crewai.memory.storage.ltm_sqlite_storage import LTMSQLiteStorage
class LongTermMemory(Memory):
"""
LongTermMemory class for managing cross runs data related to overall crew's
execution and performance.
Inherits from the Memory class and utilizes an instance of a class that
adheres to the Storage for data storage, specifically working with
LongTermMemoryItem instances.
"""
def __init__(
self,
storage: LTMSQLiteStorage | None = None,
path: str | None = None,
) -> None:
if not storage:
storage = LTMSQLiteStorage(db_path=path) if path else LTMSQLiteStorage()
super().__init__(storage=storage)
def save(self, item: LongTermMemoryItem) -> None: # type: ignore # BUG?: Signature of "save" incompatible with supertype "Memory"
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
metadata = item.metadata
metadata.update(
{"agent": item.agent, "expected_output": item.expected_output}
)
self.storage.save(
task_description=item.task,
score=metadata["quality"],
metadata=metadata,
datetime=item.datetime,
)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
save_time_ms=(time.time() - start_time) * 1000,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
error=str(e),
source_type="long_term_memory",
),
)
raise
def search( # type: ignore[override]
self,
task: str,
latest_n: int = 3,
) -> list[dict[str, Any]]:
"""Search long-term memory for relevant entries.
Args:
task: The task description to search for.
latest_n: Maximum number of results to return.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=task,
limit=latest_n,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = self.storage.load(task, latest_n)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=task,
results=results,
limit=latest_n,
query_time_ms=(time.time() - start_time) * 1000,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results or []
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=task,
limit=latest_n,
error=str(e),
source_type="long_term_memory",
),
)
raise
async def asave(self, item: LongTermMemoryItem) -> None: # type: ignore[override]
"""Save an item to long-term memory asynchronously.
Args:
item: The LongTermMemoryItem to save.
"""
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
metadata = item.metadata
metadata.update(
{"agent": item.agent, "expected_output": item.expected_output}
)
await self.storage.asave(
task_description=item.task,
score=metadata["quality"],
metadata=metadata,
datetime=item.datetime,
)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
save_time_ms=(time.time() - start_time) * 1000,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=item.task,
metadata=item.metadata,
agent_role=item.agent,
error=str(e),
source_type="long_term_memory",
),
)
raise
async def asearch( # type: ignore[override]
self,
task: str,
latest_n: int = 3,
) -> list[dict[str, Any]]:
"""Search long-term memory asynchronously.
Args:
task: The task description to search for.
latest_n: Maximum number of results to return.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=task,
limit=latest_n,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = await self.storage.aload(task, latest_n)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=task,
results=results,
limit=latest_n,
query_time_ms=(time.time() - start_time) * 1000,
source_type="long_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return results or []
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=task,
limit=latest_n,
error=str(e),
source_type="long_term_memory",
),
)
raise
def reset(self) -> None:
"""Reset long-term memory."""
self.storage.reset()

View File

@@ -1,19 +0,0 @@
from typing import Any
class LongTermMemoryItem:
def __init__(
self,
agent: str,
task: str,
expected_output: str,
datetime: str,
quality: int | float | None = None,
metadata: dict[str, Any] | None = None,
):
self.task = task
self.agent = agent
self.quality = quality
self.datetime = datetime
self.expected_output = expected_output
self.metadata = metadata if metadata is not None else {}

View File

@@ -1,121 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
from crewai.rag.embeddings.types import EmbedderConfig
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.task import Task
class Memory(BaseModel):
"""Base class for memory, supporting agent tags and generic metadata."""
embedder_config: EmbedderConfig | dict[str, Any] | None = None
crew: Any | None = None
storage: Any
_agent: Agent | None = None
_task: Task | None = None
def __init__(self, storage: Any, **data: Any):
super().__init__(storage=storage, **data)
@property
def task(self) -> Task | None:
"""Get the current task associated with this memory."""
return self._task
@task.setter
def task(self, task: Task | None) -> None:
"""Set the current task associated with this memory."""
self._task = task
@property
def agent(self) -> Agent | None:
"""Get the current agent associated with this memory."""
return self._agent
@agent.setter
def agent(self, agent: Agent | None) -> None:
"""Set the current agent associated with this memory."""
self._agent = agent
def save(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
"""Save a value to memory.
Args:
value: The value to save.
metadata: Optional metadata to associate with the value.
"""
metadata = metadata or {}
self.storage.save(value, metadata)
async def asave(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
"""Save a value to memory asynchronously.
Args:
value: The value to save.
metadata: Optional metadata to associate with the value.
"""
metadata = metadata or {}
await self.storage.asave(value, metadata)
def search(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search memory for relevant entries.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
results: list[Any] = self.storage.search(
query=query, limit=limit, score_threshold=score_threshold
)
return results
async def asearch(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search memory for relevant entries asynchronously.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
results: list[Any] = await self.storage.asearch(
query=query, limit=limit, score_threshold=score_threshold
)
return results
def set_crew(self, crew: Any) -> Memory:
"""Set the crew for this memory instance."""
self.crew = crew
return self

View File

@@ -0,0 +1,272 @@
"""Scoped and sliced views over unified Memory."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from crewai.memory.unified_memory import Memory
from crewai.memory.types import (
_RECALL_OVERSAMPLE_FACTOR,
MemoryMatch,
MemoryRecord,
ScopeInfo,
)
class MemoryScope:
"""View of Memory restricted to a root path. All operations are scoped under that path."""
def __init__(self, memory: Memory, root_path: str) -> None:
"""Initialize scope.
Args:
memory: The underlying Memory instance.
root_path: Root path for this scope (e.g. /agent/1).
"""
self._memory = memory
self._root = root_path.rstrip("/") or ""
if self._root and not self._root.startswith("/"):
self._root = "/" + self._root
def _scope_path(self, scope: str | None) -> str:
if not scope or scope == "/":
return self._root or "/"
s = scope.rstrip("/")
if not s.startswith("/"):
s = "/" + s
if not self._root:
return s
base = self._root.rstrip("/")
return f"{base}{s}"
def remember(
self,
content: str,
scope: str | None = "/",
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
) -> MemoryRecord:
"""Remember content; scope is relative to this scope's root."""
path = self._scope_path(scope)
return self._memory.remember(
content,
scope=path,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
)
def recall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: str = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Recall within this scope (root path and below)."""
search_scope = self._scope_path(scope) if scope else (self._root or "/")
return self._memory.recall(
query,
scope=search_scope,
categories=categories,
limit=limit,
depth=depth,
source=source,
include_private=include_private,
)
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from content; delegates to underlying Memory."""
return self._memory.extract_memories(content)
def forget(
self,
scope: str | None = None,
categories: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
record_ids: list[str] | None = None,
) -> int:
"""Forget within this scope."""
prefix = self._scope_path(scope) if scope else (self._root or "/")
return self._memory.forget(
scope=prefix,
categories=categories,
older_than=older_than,
metadata_filter=metadata_filter,
record_ids=record_ids,
)
def list_scopes(self, path: str = "/") -> list[str]:
"""List child scopes under path (relative to this scope's root)."""
full = self._scope_path(path)
return self._memory.list_scopes(full)
def info(self, path: str = "/") -> ScopeInfo:
"""Info for path under this scope."""
full = self._scope_path(path)
return self._memory.info(full)
def tree(self, path: str = "/", max_depth: int = 3) -> str:
"""Tree under path within this scope."""
full = self._scope_path(path)
return self._memory.tree(full, max_depth=max_depth)
def list_categories(self, path: str | None = None) -> dict[str, int]:
"""Categories in this scope; path None means this scope root."""
full = self._scope_path(path) if path else (self._root or "/")
return self._memory.list_categories(full)
def reset(self, scope: str | None = None) -> None:
"""Reset within this scope."""
prefix = self._scope_path(scope) if scope else (self._root or "/")
self._memory.reset(scope=prefix)
def subscope(self, path: str) -> MemoryScope:
"""Return a narrower scope under this scope."""
child = path.strip("/")
if not child:
return MemoryScope(self._memory, self._root or "/")
base = self._root.rstrip("/") or ""
new_root = f"{base}/{child}" if base else f"/{child}"
return MemoryScope(self._memory, new_root)
class MemorySlice:
"""View over multiple scopes: recall searches all, remember is a no-op when read_only."""
def __init__(
self,
memory: Memory,
scopes: list[str],
categories: list[str] | None = None,
read_only: bool = True,
) -> None:
"""Initialize slice.
Args:
memory: The underlying Memory instance.
scopes: List of scope paths to include.
categories: Optional category filter for recall.
read_only: If True, remember() is a silent no-op.
"""
self._memory = memory
self._scopes = [s.rstrip("/") or "/" for s in scopes]
self._categories = categories
self._read_only = read_only
def remember(
self,
content: str,
scope: str,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
) -> MemoryRecord | None:
"""Remember into an explicit scope. No-op when read_only=True."""
if self._read_only:
return None
return self._memory.remember(
content,
scope=scope,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
)
def recall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: str = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Recall across all slice scopes; results merged and re-ranked."""
cats = categories or self._categories
all_matches: list[MemoryMatch] = []
for sc in self._scopes:
matches = self._memory.recall(
query,
scope=sc,
categories=cats,
limit=limit * _RECALL_OVERSAMPLE_FACTOR,
depth=depth,
source=source,
include_private=include_private,
)
all_matches.extend(matches)
seen_ids: set[str] = set()
unique: list[MemoryMatch] = []
for m in sorted(all_matches, key=lambda x: x.score, reverse=True):
if m.record.id not in seen_ids:
seen_ids.add(m.record.id)
unique.append(m)
if len(unique) >= limit:
break
return unique
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from content; delegates to underlying Memory."""
return self._memory.extract_memories(content)
def list_scopes(self, path: str = "/") -> list[str]:
"""List scopes across all slice roots."""
out: list[str] = []
for sc in self._scopes:
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
out.extend(self._memory.list_scopes(full))
return sorted(set(out))
def info(self, path: str = "/") -> ScopeInfo:
"""Aggregate info across slice scopes (record counts summed)."""
total_records = 0
all_categories: set[str] = set()
oldest: datetime | None = None
newest: datetime | None = None
children: list[str] = []
for sc in self._scopes:
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
inf = self._memory.info(full)
total_records += inf.record_count
all_categories.update(inf.categories)
if inf.oldest_record:
oldest = inf.oldest_record if oldest is None else min(oldest, inf.oldest_record)
if inf.newest_record:
newest = inf.newest_record if newest is None else max(newest, inf.newest_record)
children.extend(inf.child_scopes)
return ScopeInfo(
path=path,
record_count=total_records,
categories=sorted(all_categories),
oldest_record=oldest,
newest_record=newest,
child_scopes=sorted(set(children)),
)
def list_categories(self, path: str | None = None) -> dict[str, int]:
"""Categories and counts across slice scopes."""
counts: dict[str, int] = {}
for sc in self._scopes:
full = (f"{sc.rstrip('/')}{path}" if sc != "/" else path) if path else sc
for k, v in self._memory.list_categories(full).items():
counts[k] = counts.get(k, 0) + v
return counts

View File

@@ -0,0 +1,358 @@
"""RLM-inspired intelligent recall flow for memory retrieval.
Implements adaptive-depth retrieval with:
- LLM query distillation into targeted sub-queries
- Time-based filtering from temporal hints
- Parallel multi-query, multi-scope search
- Confidence-based routing with iterative deepening (budget loop)
- Evidence gap tracking propagated to results
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Any
from uuid import uuid4
from pydantic import BaseModel, Field
from crewai.flow.flow import Flow, listen, router, start
from crewai.memory.analyze import QueryAnalysis, analyze_query
from crewai.memory.types import (
_RECALL_OVERSAMPLE_FACTOR,
MemoryConfig,
MemoryMatch,
MemoryRecord,
compute_composite_score,
embed_texts,
)
class RecallState(BaseModel):
"""State for the recall flow."""
id: str = Field(default_factory=lambda: str(uuid4()))
query: str = ""
scope: str | None = None
categories: list[str] | None = None
time_cutoff: datetime | None = None
source: str | None = None
include_private: bool = False
limit: int = 10
query_embeddings: list[tuple[str, list[float]]] = Field(default_factory=list)
query_analysis: QueryAnalysis | None = None
candidate_scopes: list[str] = Field(default_factory=list)
chunk_findings: list[Any] = Field(default_factory=list)
evidence_gaps: list[str] = Field(default_factory=list)
confidence: float = 0.0
final_results: list[MemoryMatch] = Field(default_factory=list)
exploration_budget: int = 1
class RecallFlow(Flow[RecallState]):
"""RLM-inspired intelligent memory recall flow.
Analyzes the query via LLM to produce targeted sub-queries and filters,
embeds each sub-query, searches across candidate scopes in parallel,
and iteratively deepens exploration when confidence is low.
"""
_skip_auto_memory: bool = True
initial_state = RecallState
def __init__(
self,
storage: Any,
llm: Any,
embedder: Any,
config: MemoryConfig | None = None,
) -> None:
super().__init__(suppress_flow_events=True)
self._storage = storage
self._llm = llm
self._embedder = embedder
self._config = config or MemoryConfig()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _merged_categories(self) -> list[str] | None:
"""Return caller-supplied categories, or None if empty."""
return self.state.categories or None
def _do_search(self) -> list[dict[str, Any]]:
"""Run parallel search across (embeddings x scopes) with filters.
Populates ``state.chunk_findings`` and ``state.confidence``.
Returns the findings list.
"""
search_categories = self._merged_categories()
def _search_one(
embedding: list[float], scope: str
) -> tuple[str, list[tuple[MemoryRecord, float]]]:
raw = self._storage.search(
embedding,
scope_prefix=scope,
categories=search_categories,
limit=self.state.limit * _RECALL_OVERSAMPLE_FACTOR,
min_score=0.0,
)
# Post-filter by time cutoff
if self.state.time_cutoff and raw:
raw = [
(r, s) for r, s in raw if r.created_at >= self.state.time_cutoff
]
# Privacy filter
if not self.state.include_private and raw:
raw = [
(r, s) for r, s in raw
if not r.private or r.source == self.state.source
]
return scope, raw
# Build (embedding, scope) task list
tasks: list[tuple[list[float], str]] = [
(embedding, scope)
for _query_text, embedding in self.state.query_embeddings
for scope in self.state.candidate_scopes
]
findings: list[dict[str, Any]] = []
if len(tasks) <= 1:
for emb, sc in tasks:
scope, results = _search_one(emb, sc)
if results:
top_composite, _ = compute_composite_score(
results[0][0], results[0][1], self._config
)
findings.append({
"scope": scope,
"results": results,
"top_score": top_composite,
})
else:
with ThreadPoolExecutor(max_workers=min(len(tasks), 4)) as pool:
futures = {
pool.submit(_search_one, emb, sc): (emb, sc)
for emb, sc in tasks
}
for future in as_completed(futures):
scope, results = future.result()
if results:
top_composite, _ = compute_composite_score(
results[0][0], results[0][1], self._config
)
findings.append({
"scope": scope,
"results": results,
"top_score": top_composite,
})
self.state.chunk_findings = findings
self.state.confidence = max(
(f["top_score"] for f in findings), default=0.0
)
return findings
# ------------------------------------------------------------------
# Flow steps
# ------------------------------------------------------------------
@start()
def analyze_query_step(self) -> QueryAnalysis:
"""Analyze the query, embed distilled sub-queries, extract filters.
Short queries (below ``query_analysis_threshold`` characters) skip
the LLM call entirely and embed the raw query directly -- saving
~1-3s per recall. Longer queries (e.g. full task descriptions)
benefit from LLM distillation into targeted sub-queries.
Sub-queries are embedded in a single batch ``embed_texts()`` call
rather than sequential ``embed_text()`` calls.
"""
self.state.exploration_budget = self._config.exploration_budget
query_len = len(self.state.query)
skip_llm = query_len < self._config.query_analysis_threshold
if skip_llm:
# Short query: skip LLM, embed raw query directly
analysis = QueryAnalysis(
keywords=[],
suggested_scopes=[],
complexity="simple",
recall_queries=[self.state.query],
)
self.state.query_analysis = analysis
else:
# Long query: use LLM to distill sub-queries and extract filters
available = self._storage.list_scopes(self.state.scope or "/")
if not available:
available = ["/"]
scope_info = (
self._storage.get_scope_info(self.state.scope or "/")
if self.state.scope
else None
)
analysis = analyze_query(
self.state.query,
available,
scope_info,
self._llm,
)
self.state.query_analysis = analysis
# Parse time_filter into a datetime cutoff
if analysis.time_filter:
try:
self.state.time_cutoff = datetime.fromisoformat(analysis.time_filter)
except ValueError:
pass
# Batch-embed all sub-queries in ONE call
queries = analysis.recall_queries if analysis.recall_queries else [self.state.query]
queries = queries[:3]
embeddings = embed_texts(self._embedder, queries)
pairs: list[tuple[str, list[float]]] = [
(q, emb) for q, emb in zip(queries, embeddings, strict=False) if emb
]
if not pairs:
# Fallback: embed the raw query if distilled queries all failed
fallback_emb = embed_texts(self._embedder, [self.state.query])
if fallback_emb and fallback_emb[0]:
pairs = [(self.state.query, fallback_emb[0])]
self.state.query_embeddings = pairs
return analysis
@listen(analyze_query_step)
def filter_and_chunk(self) -> list[str]:
"""Select candidate scopes based on LLM analysis."""
analysis = self.state.query_analysis
scope_prefix = (self.state.scope or "/").rstrip("/") or "/"
if analysis and analysis.suggested_scopes:
candidates = [s for s in analysis.suggested_scopes if s]
else:
candidates = self._storage.list_scopes(scope_prefix)
if not candidates:
info = self._storage.get_scope_info(scope_prefix)
if info.record_count > 0:
candidates = [scope_prefix]
else:
candidates = [scope_prefix]
self.state.candidate_scopes = candidates[:20]
return self.state.candidate_scopes
@listen(filter_and_chunk)
def search_chunks(self) -> list[Any]:
"""Initial parallel search across (embeddings x scopes) with filters."""
return self._do_search()
@router(search_chunks)
def decide_depth(self) -> str:
"""Route based on confidence, complexity, and remaining budget."""
analysis = self.state.query_analysis
if (
analysis
and analysis.complexity == "complex"
and self.state.confidence < self._config.complex_query_threshold
):
if self.state.exploration_budget > 0:
return "explore_deeper"
if self.state.confidence >= self._config.confidence_threshold_high:
return "synthesize"
if (
self.state.exploration_budget > 0
and self.state.confidence < self._config.confidence_threshold_low
):
return "explore_deeper"
return "synthesize"
@listen("explore_deeper")
def recursive_exploration(self) -> list[Any]:
"""Feed top results back to LLM for deeper context extraction.
Decrements the exploration budget so the loop terminates.
"""
self.state.exploration_budget -= 1
enhanced = []
for finding in self.state.chunk_findings:
if not finding.get("results"):
continue
content_parts = [r[0].content for r in finding["results"][:5]]
chunk_text = "\n---\n".join(content_parts)
prompt = (
f"Query: {self.state.query}\n\n"
f"Relevant memory excerpts:\n{chunk_text}\n\n"
"Extract the most relevant information for the query. "
"If something is missing, say what's missing in one short line."
)
try:
response = self._llm.call([{"role": "user", "content": prompt}])
if isinstance(response, str) and "missing" in response.lower():
self.state.evidence_gaps.append(response[:200])
enhanced.append({
"scope": finding["scope"],
"extraction": response,
"results": finding["results"],
})
except Exception:
enhanced.append({
"scope": finding["scope"],
"extraction": "",
"results": finding["results"],
})
self.state.chunk_findings = enhanced
return enhanced
@listen(recursive_exploration)
def re_search(self) -> list[Any]:
"""Re-search after exploration to update confidence for the router loop."""
return self._do_search()
@router(re_search)
def re_decide_depth(self) -> str:
"""Re-evaluate depth after re-search. Same logic as decide_depth."""
return self.decide_depth()
@listen("synthesize")
def synthesize_results(self) -> list[MemoryMatch]:
"""Deduplicate, composite-score, rank, and attach evidence gaps."""
seen_ids: set[str] = set()
matches: list[MemoryMatch] = []
for finding in self.state.chunk_findings:
if not isinstance(finding, dict):
continue
results = finding.get("results", [])
if not isinstance(results, list):
continue
for item in results:
if isinstance(item, (list, tuple)) and len(item) >= 2:
record, score = item[0], item[1]
else:
continue
if isinstance(record, MemoryRecord) and record.id not in seen_ids:
seen_ids.add(record.id)
composite, reasons = compute_composite_score(
record, float(score), self._config
)
matches.append(
MemoryMatch(
record=record,
score=composite,
match_reasons=reasons,
)
)
matches.sort(key=lambda m: m.score, reverse=True)
self.state.final_results = matches[: self.state.limit]
# Attach evidence gaps to the first result so callers can inspect them
if self.state.evidence_gaps and self.state.final_results:
self.state.final_results[0].evidence_gaps = list(self.state.evidence_gaps)
return self.state.final_results

View File

@@ -1,318 +0,0 @@
from __future__ import annotations
import time
from typing import Any
from pydantic import PrivateAttr
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.memory.memory import Memory
from crewai.memory.short_term.short_term_memory_item import ShortTermMemoryItem
from crewai.memory.storage.rag_storage import RAGStorage
class ShortTermMemory(Memory):
"""
ShortTermMemory class for managing transient data related to immediate tasks
and interactions.
Inherits from the Memory class and utilizes an instance of a class that
adheres to the Storage for data storage, specifically working with
MemoryItem instances.
"""
_memory_provider: str | None = PrivateAttr()
def __init__(
self,
crew: Any = None,
embedder_config: Any = None,
storage: Any = None,
path: str | None = None,
) -> None:
memory_provider = None
if embedder_config and isinstance(embedder_config, dict):
memory_provider = embedder_config.get("provider")
if memory_provider == "mem0":
try:
from crewai.memory.storage.mem0_storage import Mem0Storage
except ImportError as e:
raise ImportError(
"Mem0 is not installed. Please install it with `pip install mem0ai`."
) from e
config = (
embedder_config.get("config")
if embedder_config and isinstance(embedder_config, dict)
else None
)
storage = Mem0Storage(type="short_term", crew=crew, config=config) # type: ignore[no-untyped-call]
else:
storage = (
storage
if storage
else RAGStorage(
type="short_term",
embedder_config=embedder_config,
crew=crew,
path=path,
)
)
super().__init__(storage=storage)
self._memory_provider = memory_provider
def save(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=value,
metadata=metadata,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
item = ShortTermMemoryItem(
data=value,
metadata=metadata,
agent=self.agent.role if self.agent else None,
)
if self._memory_provider == "mem0":
item.data = (
f"Remember the following insights from Agent run: {item.data}"
)
super().save(value=item.data, metadata=item.metadata)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=value,
metadata=metadata,
# agent_role=agent,
save_time_ms=(time.time() - start_time) * 1000,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=value,
metadata=metadata,
error=str(e),
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
def search(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search short-term memory for relevant entries.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = self.storage.search(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return list(results)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="short_term_memory",
),
)
raise
async def asave(
self,
value: Any,
metadata: dict[str, Any] | None = None,
) -> None:
"""Save a value to short-term memory asynchronously.
Args:
value: The value to save.
metadata: Optional metadata to associate with the value.
"""
crewai_event_bus.emit(
self,
event=MemorySaveStartedEvent(
value=value,
metadata=metadata,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
item = ShortTermMemoryItem(
data=value,
metadata=metadata,
agent=self.agent.role if self.agent else None,
)
if self._memory_provider == "mem0":
item.data = (
f"Remember the following insights from Agent run: {item.data}"
)
await super().asave(value=item.data, metadata=item.metadata)
crewai_event_bus.emit(
self,
event=MemorySaveCompletedEvent(
value=value,
metadata=metadata,
save_time_ms=(time.time() - start_time) * 1000,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemorySaveFailedEvent(
value=value,
metadata=metadata,
error=str(e),
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
raise
async def asearch(
self,
query: str,
limit: int = 5,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search short-term memory asynchronously.
Args:
query: The search query.
limit: Maximum number of results to return.
score_threshold: Minimum similarity score for results.
Returns:
List of matching memory entries.
"""
crewai_event_bus.emit(
self,
event=MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
start_time = time.time()
try:
results = await self.storage.asearch(
query=query, limit=limit, score_threshold=score_threshold
)
crewai_event_bus.emit(
self,
event=MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=score_threshold,
query_time_ms=(time.time() - start_time) * 1000,
source_type="short_term_memory",
from_agent=self.agent,
from_task=self.task,
),
)
return list(results)
except Exception as e:
crewai_event_bus.emit(
self,
event=MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=score_threshold,
error=str(e),
source_type="short_term_memory",
),
)
raise
def reset(self) -> None:
try:
self.storage.reset()
except Exception as e:
raise Exception(
f"An error occurred while resetting the short-term memory: {e}"
) from e

View File

@@ -1,13 +0,0 @@
from typing import Any
class ShortTermMemoryItem:
def __init__(
self,
data: Any,
agent: str | None = None,
metadata: dict[str, Any] | None = None,
):
self.data = data
self.agent = agent
self.metadata = metadata if metadata is not None else {}

View File

@@ -0,0 +1,179 @@
"""Storage backend protocol for the unified memory system."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Protocol, runtime_checkable
from crewai.memory.types import MemoryRecord, ScopeInfo
@runtime_checkable
class StorageBackend(Protocol):
"""Protocol for pluggable memory storage backends."""
def save(self, records: list[MemoryRecord]) -> None:
"""Save memory records to storage.
Args:
records: List of memory records to persist.
"""
...
def search(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[MemoryRecord, float]]:
"""Search for memories by vector similarity with optional filters.
Args:
query_embedding: Embedding vector for the query.
scope_prefix: Optional scope path prefix to filter results.
categories: Optional list of categories to filter by.
metadata_filter: Optional metadata key-value filter.
limit: Maximum number of results to return.
min_score: Minimum similarity score threshold.
Returns:
List of (MemoryRecord, score) tuples ordered by relevance.
"""
...
def delete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
"""Delete memories matching the given criteria.
Args:
scope_prefix: Optional scope path prefix.
categories: Optional list of categories.
record_ids: Optional list of record IDs to delete.
older_than: Optional cutoff datetime (delete older records).
metadata_filter: Optional metadata key-value filter.
Returns:
Number of records deleted.
"""
...
def update(self, record: MemoryRecord) -> None:
"""Update an existing record. Replaces the record with the same ID."""
...
def get_record(self, record_id: str) -> MemoryRecord | None:
"""Return a single record by ID, or None if not found.
Args:
record_id: The unique ID of the record.
Returns:
The MemoryRecord, or None if no record with that ID exists.
"""
...
def list_records(
self,
scope_prefix: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[MemoryRecord]:
"""List records in a scope, newest first.
Args:
scope_prefix: Optional scope path prefix to filter by.
limit: Maximum number of records to return.
offset: Number of records to skip (for pagination).
Returns:
List of MemoryRecord, ordered by created_at descending.
"""
...
def get_scope_info(self, scope: str) -> ScopeInfo:
"""Get information about a scope.
Args:
scope: The scope path.
Returns:
ScopeInfo with record count, categories, date range, child scopes.
"""
...
def list_scopes(self, parent: str = "/") -> list[str]:
"""List immediate child scopes under a parent path.
Args:
parent: Parent scope path (default root).
Returns:
List of immediate child scope paths.
"""
...
def list_categories(self, scope_prefix: str | None = None) -> dict[str, int]:
"""List categories and their counts within a scope.
Args:
scope_prefix: Optional scope to limit to (None = global).
Returns:
Mapping of category name to record count.
"""
...
def count(self, scope_prefix: str | None = None) -> int:
"""Count records in scope (and subscopes).
Args:
scope_prefix: Optional scope path (None = all).
Returns:
Number of records.
"""
...
def reset(self, scope_prefix: str | None = None) -> None:
"""Reset (delete all) memories in scope.
Args:
scope_prefix: Optional scope path (None = reset all).
"""
...
async def asave(self, records: list[MemoryRecord]) -> None:
"""Save memory records asynchronously."""
...
async def asearch(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[MemoryRecord, float]]:
"""Search for memories asynchronously."""
...
async def adelete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
"""Delete memories asynchronously."""
...

View File

@@ -1,16 +0,0 @@
from typing import Any
class Storage:
"""Abstract base class defining the storage interface"""
def save(self, value: Any, metadata: dict[str, Any]) -> None:
pass
def search(
self, query: str, limit: int, score_threshold: float
) -> dict[str, Any] | list[Any]:
return {}
def reset(self) -> None:
pass

View File

@@ -0,0 +1,655 @@
"""LanceDB storage backend for the unified memory system."""
from __future__ import annotations
from datetime import datetime
import json
import logging
import os
from pathlib import Path
import threading
import time
from typing import Any, ClassVar
import lancedb
from crewai.memory.types import MemoryRecord, ScopeInfo
_logger = logging.getLogger(__name__)
# Default embedding vector dimensionality (matches OpenAI text-embedding-3-small).
# Used when creating new tables and for zero-vector placeholder scans.
# Callers can override via the ``vector_dim`` constructor parameter.
DEFAULT_VECTOR_DIM = 1536
# Safety cap on the number of rows returned by a single scan query.
# Prevents unbounded memory use when scanning large tables for scope info,
# listing, or deletion. Internal only -- not user-configurable.
_SCAN_ROWS_LIMIT = 50_000
# Retry settings for LanceDB commit conflicts (optimistic concurrency).
# Under heavy write load (many concurrent saves), the table version can
# advance rapidly. 5 retries with 0.2s base delay (0.2 + 0.4 + 0.8 + 1.6 + 3.2 = 6.2s max)
# gives enough headroom to catch up with version advancement.
_MAX_RETRIES = 5
_RETRY_BASE_DELAY = 0.2 # seconds; doubles on each retry
class LanceDBStorage:
"""LanceDB-backed storage for the unified memory system."""
# Class-level registry: maps resolved database path -> shared write lock.
# When multiple Memory instances (e.g. agent + crew) independently create
# LanceDBStorage pointing at the same directory, they share one lock so
# their writes don't conflict.
# Uses RLock (reentrant) so callers can hold the lock for a batch of
# operations while the individual methods re-acquire it without deadlocking.
_path_locks: ClassVar[dict[str, threading.RLock]] = {}
_path_locks_guard: ClassVar[threading.Lock] = threading.Lock()
def __init__(
self,
path: str | Path | None = None,
table_name: str = "memories",
vector_dim: int | None = None,
compact_every: int = 100,
) -> None:
"""Initialize LanceDB storage.
Args:
path: Directory path for the LanceDB database. Defaults to
``$CREWAI_STORAGE_DIR/memory`` if the env var is set,
otherwise ``db_storage_path() / memory`` (platform data dir).
table_name: Name of the table for memory records.
vector_dim: Dimensionality of the embedding vector. When ``None``
(default), the dimension is auto-detected from the existing
table schema or from the first saved embedding.
compact_every: Number of ``save()`` calls between automatic
background compactions. Each ``save()`` creates one new
fragment file; compaction merges them, keeping query
performance consistent. Set to 0 to disable.
"""
if path is None:
storage_dir = os.environ.get("CREWAI_STORAGE_DIR")
if storage_dir:
path = Path(storage_dir) / "memory"
else:
from crewai.utilities.paths import db_storage_path
path = Path(db_storage_path()) / "memory"
self._path = Path(path)
self._path.mkdir(parents=True, exist_ok=True)
self._table_name = table_name
self._db = lancedb.connect(str(self._path))
# On macOS and Linux the default per-process open-file limit is 256.
# A LanceDB table stores one file per fragment (one fragment per save()
# call by default). With hundreds of fragments, a single full-table
# scan opens all of them simultaneously, exhausting the limit.
# Raise it proactively so scans on large tables never hit OS error 24.
try:
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
if soft < 4096:
resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 4096), hard))
except Exception: # noqa: S110
pass # Windows or already at the max hard limit — safe to ignore
self._compact_every = compact_every
self._save_count = 0
# Get or create a shared write lock for this database path.
resolved = str(self._path.resolve())
with LanceDBStorage._path_locks_guard:
if resolved not in LanceDBStorage._path_locks:
LanceDBStorage._path_locks[resolved] = threading.RLock()
self._write_lock = LanceDBStorage._path_locks[resolved]
# Try to open an existing table and infer dimension from its schema.
# If no table exists yet, defer creation until the first save so the
# dimension can be auto-detected from the embedder's actual output.
try:
self._table: lancedb.table.Table | None = self._db.open_table(self._table_name)
self._vector_dim: int = self._infer_dim_from_table(self._table)
# Best-effort: create the scope index if it doesn't exist yet.
self._ensure_scope_index()
# Compact in the background if the table has accumulated many
# fragments from previous runs (each save() creates one).
self._compact_if_needed()
except Exception:
self._table = None
self._vector_dim = vector_dim or 0 # 0 = not yet known
# Explicit dim provided: create the table immediately if it doesn't exist.
if self._table is None and vector_dim is not None:
self._vector_dim = vector_dim
self._table = self._create_table(vector_dim)
@property
def write_lock(self) -> threading.RLock:
"""The shared reentrant write lock for this database path.
Callers can acquire this to hold the lock across multiple storage
operations (e.g. delete + update + save as one atomic batch).
Individual methods also acquire it internally, but since it's
reentrant (RLock), the same thread won't deadlock.
"""
return self._write_lock
@staticmethod
def _infer_dim_from_table(table: lancedb.table.Table) -> int:
"""Read vector dimension from an existing table's schema."""
schema = table.schema
for field in schema:
if field.name == "vector":
try:
return field.type.list_size
except Exception:
break
return DEFAULT_VECTOR_DIM
def _retry_write(self, op: str, *args: Any, **kwargs: Any) -> Any:
"""Execute a table operation with retry on LanceDB commit conflicts.
Args:
op: Method name on the table object (e.g. "add", "delete").
*args, **kwargs: Passed to the table method.
LanceDB uses optimistic concurrency: if two transactions overlap,
the second to commit fails with an ``OSError`` containing
"Commit conflict". This helper retries with exponential backoff,
refreshing the table reference before each retry so the retried
call uses the latest committed version (not a stale reference).
"""
delay = _RETRY_BASE_DELAY
for attempt in range(_MAX_RETRIES + 1):
try:
return getattr(self._table, op)(*args, **kwargs)
except OSError as e: # noqa: PERF203
if "Commit conflict" not in str(e) or attempt >= _MAX_RETRIES:
raise
_logger.debug(
"LanceDB commit conflict on %s (attempt %d/%d), retrying in %.1fs",
op, attempt + 1, _MAX_RETRIES, delay,
)
# Refresh table to pick up the latest version before retrying.
# The next getattr(self._table, op) will use the fresh table.
try:
self._table = self._db.open_table(self._table_name)
except Exception: # noqa: S110
pass # table refresh is best-effort
time.sleep(delay)
delay *= 2
return None # unreachable, but satisfies type checker
def _create_table(self, vector_dim: int) -> lancedb.table.Table:
"""Create a new table with the given vector dimension."""
placeholder = [
{
"id": "__schema_placeholder__",
"content": "",
"scope": "/",
"categories_str": "[]",
"metadata_str": "{}",
"importance": 0.5,
"created_at": datetime.utcnow().isoformat(),
"last_accessed": datetime.utcnow().isoformat(),
"source": "",
"private": False,
"vector": [0.0] * vector_dim,
}
]
table = self._db.create_table(self._table_name, placeholder)
table.delete("id = '__schema_placeholder__'")
return table
def _ensure_scope_index(self) -> None:
"""Create a BTREE scalar index on the ``scope`` column if not present.
A scalar index lets LanceDB skip a full table scan when filtering by
scope prefix, which is the hot path for ``list_records``,
``get_scope_info``, and ``list_scopes``. The call is best-effort:
if the table is empty or the index already exists the exception is
swallowed silently.
"""
if self._table is None:
return
try:
self._table.create_scalar_index("scope", index_type="BTREE", replace=False)
except Exception: # noqa: S110
pass # index already exists, table empty, or unsupported version
# ------------------------------------------------------------------
# Automatic background compaction
# ------------------------------------------------------------------
def _compact_if_needed(self) -> None:
"""Spawn a background compaction on startup.
Called whenever an existing table is opened so that fragments
accumulated in previous sessions are silently merged before the
first query. ``optimize()`` returns quickly when the table is
already compact, so the cost is negligible in the common case.
"""
if self._table is None or self._compact_every <= 0:
return
self._compact_async()
def _compact_async(self) -> None:
"""Fire-and-forget: compact the table in a daemon background thread."""
threading.Thread(
target=self._compact_safe,
daemon=True,
name="lancedb-compact",
).start()
def _compact_safe(self) -> None:
"""Run ``table.optimize()`` in a background thread, absorbing errors."""
try:
if self._table is not None:
self._table.optimize()
# Refresh the scope index so new fragments are covered.
self._ensure_scope_index()
except Exception:
_logger.debug("LanceDB background compaction failed", exc_info=True)
def _ensure_table(self, vector_dim: int | None = None) -> lancedb.table.Table:
"""Return the table, creating it lazily if needed.
Args:
vector_dim: Dimension hint (e.g. from the first embedding).
Falls back to the stored ``_vector_dim`` or ``DEFAULT_VECTOR_DIM``.
"""
if self._table is not None:
return self._table
dim = vector_dim or self._vector_dim or DEFAULT_VECTOR_DIM
self._vector_dim = dim
self._table = self._create_table(dim)
return self._table
def _record_to_row(self, record: MemoryRecord) -> dict[str, Any]:
return {
"id": record.id,
"content": record.content,
"scope": record.scope,
"categories_str": json.dumps(record.categories),
"metadata_str": json.dumps(record.metadata),
"importance": record.importance,
"created_at": record.created_at.isoformat(),
"last_accessed": record.last_accessed.isoformat(),
"source": record.source or "",
"private": record.private,
"vector": record.embedding if record.embedding else [0.0] * self._vector_dim,
}
def _row_to_record(self, row: dict[str, Any]) -> MemoryRecord:
def _parse_dt(val: Any) -> datetime:
if val is None:
return datetime.utcnow()
if isinstance(val, datetime):
return val
s = str(val)
return datetime.fromisoformat(s.replace("Z", "+00:00"))
return MemoryRecord(
id=str(row["id"]),
content=str(row["content"]),
scope=str(row["scope"]),
categories=json.loads(row["categories_str"]) if row.get("categories_str") else [],
metadata=json.loads(row["metadata_str"]) if row.get("metadata_str") else {},
importance=float(row.get("importance", 0.5)),
created_at=_parse_dt(row.get("created_at")),
last_accessed=_parse_dt(row.get("last_accessed")),
embedding=row.get("vector"),
source=row.get("source") or None,
private=bool(row.get("private", False)),
)
def save(self, records: list[MemoryRecord]) -> None:
if not records:
return
# Auto-detect dimension from the first real embedding.
dim = None
for r in records:
if r.embedding and len(r.embedding) > 0:
dim = len(r.embedding)
break
is_new_table = self._table is None
with self._write_lock:
self._ensure_table(vector_dim=dim)
rows = [self._record_to_row(r) for r in records]
for r in rows:
if r["vector"] is None or len(r["vector"]) != self._vector_dim:
r["vector"] = [0.0] * self._vector_dim
self._retry_write("add", rows)
# Create the scope index on the first save so it covers the initial dataset.
if is_new_table:
self._ensure_scope_index()
# Auto-compact every N saves so fragment files don't pile up.
self._save_count += 1
if self._compact_every > 0 and self._save_count % self._compact_every == 0:
self._compact_async()
def update(self, record: MemoryRecord) -> None:
"""Update a record by ID. Preserves created_at, updates last_accessed."""
with self._write_lock:
self._ensure_table()
safe_id = str(record.id).replace("'", "''")
self._retry_write("delete", f"id = '{safe_id}'")
row = self._record_to_row(record)
if row["vector"] is None or len(row["vector"]) != self._vector_dim:
row["vector"] = [0.0] * self._vector_dim
self._retry_write("add", [row])
def touch_records(self, record_ids: list[str]) -> None:
"""Update last_accessed to now for the given record IDs.
Uses a single batch ``table.update()`` call instead of N
delete-and-re-add cycles, which is both faster and avoids
unnecessary write amplification.
Args:
record_ids: IDs of records to touch.
"""
if not record_ids or self._table is None:
return
with self._write_lock:
now = datetime.utcnow().isoformat()
safe_ids = [str(rid).replace("'", "''") for rid in record_ids]
ids_expr = ", ".join(f"'{rid}'" for rid in safe_ids)
self._retry_write(
"update",
where=f"id IN ({ids_expr})",
values={"last_accessed": now},
)
def get_record(self, record_id: str) -> MemoryRecord | None:
"""Return a single record by ID, or None if not found."""
if self._table is None:
return None
safe_id = str(record_id).replace("'", "''")
rows = self._table.search().where(f"id = '{safe_id}'").limit(1).to_list()
if not rows:
return None
return self._row_to_record(rows[0])
def search(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[MemoryRecord, float]]:
if self._table is None:
return []
query = self._table.search(query_embedding)
if scope_prefix is not None and scope_prefix.strip("/"):
prefix = scope_prefix.rstrip("/")
like_val = prefix + "%"
query = query.where(f"scope LIKE '{like_val}'")
results = query.limit(limit * 3 if (categories or metadata_filter) else limit).to_list()
out: list[tuple[MemoryRecord, float]] = []
for row in results:
record = self._row_to_record(row)
if categories and not any(c in record.categories for c in categories):
continue
if metadata_filter and not all(record.metadata.get(k) == v for k, v in metadata_filter.items()):
continue
distance = row.get("_distance", 0.0)
score = 1.0 / (1.0 + float(distance)) if distance is not None else 1.0
if score >= min_score:
out.append((record, score))
if len(out) >= limit:
break
return out[:limit]
def delete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
if self._table is None:
return 0
with self._write_lock:
if record_ids and not (categories or metadata_filter):
before = self._table.count_rows()
ids_expr = ", ".join(f"'{rid}'" for rid in record_ids)
self._retry_write("delete", f"id IN ({ids_expr})")
return before - self._table.count_rows()
if categories or metadata_filter:
rows = self._scan_rows(scope_prefix)
to_delete: list[str] = []
for row in rows:
record = self._row_to_record(row)
if categories and not any(c in record.categories for c in categories):
continue
if metadata_filter and not all(record.metadata.get(k) == v for k, v in metadata_filter.items()):
continue
if older_than and record.created_at >= older_than:
continue
to_delete.append(record.id)
if not to_delete:
return 0
before = self._table.count_rows()
ids_expr = ", ".join(f"'{rid}'" for rid in to_delete)
self._retry_write("delete", f"id IN ({ids_expr})")
return before - self._table.count_rows()
conditions = []
if scope_prefix is not None and scope_prefix.strip("/"):
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
conditions.append(f"scope LIKE '{prefix}%' OR scope = '/'")
if older_than is not None:
conditions.append(f"created_at < '{older_than.isoformat()}'")
if not conditions:
before = self._table.count_rows()
self._retry_write("delete", "id != ''")
return before - self._table.count_rows()
where_expr = " AND ".join(conditions)
before = self._table.count_rows()
self._retry_write("delete", where_expr)
return before - self._table.count_rows()
def _scan_rows(
self,
scope_prefix: str | None = None,
limit: int = _SCAN_ROWS_LIMIT,
columns: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Scan rows optionally filtered by scope prefix.
Uses a full table scan (no vector query) so the limit is applied after
the scope filter, not to ANN candidates before filtering.
Args:
scope_prefix: Optional scope path prefix to filter by.
limit: Maximum number of rows to return (applied after filtering).
columns: Optional list of column names to fetch. Pass only the
columns you need for metadata operations to avoid reading the
heavy ``vector`` column unnecessarily.
"""
if self._table is None:
return []
q = self._table.search()
if scope_prefix is not None and scope_prefix.strip("/"):
q = q.where(f"scope LIKE '{scope_prefix.rstrip('/')}%'")
if columns is not None:
q = q.select(columns)
return q.limit(limit).to_list()
def list_records(
self, scope_prefix: str | None = None, limit: int = 200, offset: int = 0
) -> list[MemoryRecord]:
"""List records in a scope, newest first.
Args:
scope_prefix: Optional scope path prefix to filter by.
limit: Maximum number of records to return.
offset: Number of records to skip (for pagination).
Returns:
List of MemoryRecord, ordered by created_at descending.
"""
rows = self._scan_rows(scope_prefix, limit=limit + offset)
records = [self._row_to_record(r) for r in rows]
records.sort(key=lambda r: r.created_at, reverse=True)
return records[offset : offset + limit]
def get_scope_info(self, scope: str) -> ScopeInfo:
scope = scope.rstrip("/") or "/"
prefix = scope if scope != "/" else ""
if prefix and not prefix.startswith("/"):
prefix = "/" + prefix
rows = self._scan_rows(
prefix or None,
columns=["scope", "categories_str", "created_at"],
)
if not rows:
return ScopeInfo(
path=scope or "/",
record_count=0,
categories=[],
oldest_record=None,
newest_record=None,
child_scopes=[],
)
categories_set: set[str] = set()
oldest: datetime | None = None
newest: datetime | None = None
child_prefix = (prefix + "/") if prefix else "/"
children: set[str] = set()
for row in rows:
sc = str(row.get("scope", ""))
if child_prefix and sc.startswith(child_prefix):
rest = sc[len(child_prefix):]
first_component = rest.split("/", 1)[0]
if first_component:
children.add(child_prefix + first_component)
try:
cat_str = row.get("categories_str") or "[]"
categories_set.update(json.loads(cat_str))
except Exception: # noqa: S110
pass
created = row.get("created_at")
if created:
dt = datetime.fromisoformat(str(created).replace("Z", "+00:00")) if isinstance(created, str) else created
if isinstance(dt, datetime):
if oldest is None or dt < oldest:
oldest = dt
if newest is None or dt > newest:
newest = dt
return ScopeInfo(
path=scope or "/",
record_count=len(rows),
categories=sorted(categories_set),
oldest_record=oldest,
newest_record=newest,
child_scopes=sorted(children),
)
def list_scopes(self, parent: str = "/") -> list[str]:
parent = parent.rstrip("/") or ""
prefix = (parent + "/") if parent else "/"
rows = self._scan_rows(prefix if prefix != "/" else None, columns=["scope"])
children: set[str] = set()
for row in rows:
sc = str(row.get("scope", ""))
if sc.startswith(prefix) and sc != (prefix.rstrip("/") or "/"):
rest = sc[len(prefix):]
first_component = rest.split("/", 1)[0]
if first_component:
children.add(prefix + first_component)
return sorted(children)
def list_categories(self, scope_prefix: str | None = None) -> dict[str, int]:
rows = self._scan_rows(scope_prefix, columns=["categories_str"])
counts: dict[str, int] = {}
for row in rows:
cat_str = row.get("categories_str") or "[]"
try:
parsed = json.loads(cat_str)
except Exception: # noqa: S112
continue
for c in parsed:
counts[c] = counts.get(c, 0) + 1
return counts
def count(self, scope_prefix: str | None = None) -> int:
if self._table is None:
return 0
if scope_prefix is None or scope_prefix.strip("/") == "":
return self._table.count_rows()
info = self.get_scope_info(scope_prefix)
return info.record_count
def reset(self, scope_prefix: str | None = None) -> None:
if scope_prefix is None or scope_prefix.strip("/") == "":
if self._table is not None:
self._db.drop_table(self._table_name)
self._table = None
# Dimension is preserved; table will be recreated on next save.
return
if self._table is None:
return
prefix = scope_prefix.rstrip("/")
if prefix:
self._table.delete(f"scope >= '{prefix}' AND scope < '{prefix}/\uFFFF'")
def optimize(self) -> None:
"""Compact the table synchronously and refresh the scope index.
Under normal usage this is called automatically in the background
(every ``compact_every`` saves and on startup when the table is
fragmented). Call this explicitly only when you need the compaction
to be complete before the next operation — for example immediately
after a large bulk import, before a latency-sensitive recall.
It is a no-op if the table does not exist.
"""
if self._table is None:
return
self._table.optimize()
self._ensure_scope_index()
async def asave(self, records: list[MemoryRecord]) -> None:
self.save(records)
async def asearch(
self,
query_embedding: list[float],
scope_prefix: str | None = None,
categories: list[str] | None = None,
metadata_filter: dict[str, Any] | None = None,
limit: int = 10,
min_score: float = 0.0,
) -> list[tuple[MemoryRecord, float]]:
return self.search(
query_embedding,
scope_prefix=scope_prefix,
categories=categories,
metadata_filter=metadata_filter,
limit=limit,
min_score=min_score,
)
async def adelete(
self,
scope_prefix: str | None = None,
categories: list[str] | None = None,
record_ids: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
) -> int:
return self.delete(
scope_prefix=scope_prefix,
categories=categories,
record_ids=record_ids,
older_than=older_than,
metadata_filter=metadata_filter,
)

View File

@@ -1,215 +0,0 @@
import json
from pathlib import Path
import sqlite3
from typing import Any
import aiosqlite
from crewai.utilities import Printer
from crewai.utilities.paths import db_storage_path
class LTMSQLiteStorage:
"""SQLite storage class for long-term memory data."""
def __init__(self, db_path: str | None = None, verbose: bool = True) -> None:
"""Initialize the SQLite storage.
Args:
db_path: Optional path to the database file.
verbose: Whether to print error messages.
"""
if db_path is None:
db_path = str(Path(db_storage_path()) / "long_term_memory_storage.db")
self.db_path = db_path
self._verbose = verbose
self._printer: Printer = Printer()
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._initialize_db()
def _initialize_db(self) -> None:
"""Initialize the SQLite database and create LTM table."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS long_term_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_description TEXT,
metadata TEXT,
datetime TEXT,
score REAL
)
"""
)
conn.commit()
except sqlite3.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred during database initialization: {e}",
color="red",
)
def save(
self,
task_description: str,
metadata: dict[str, Any],
datetime: str,
score: int | float,
) -> None:
"""Saves data to the LTM table with error handling."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO long_term_memories (task_description, metadata, datetime, score)
VALUES (?, ?, ?, ?)
""",
(task_description, json.dumps(metadata), datetime, score),
)
conn.commit()
except sqlite3.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while saving to LTM: {e}",
color="red",
)
def load(self, task_description: str, latest_n: int) -> list[dict[str, Any]] | None:
"""Queries the LTM table by task description with error handling."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT metadata, datetime, score
FROM long_term_memories
WHERE task_description = ?
ORDER BY datetime DESC, score ASC
LIMIT {latest_n}
""", # nosec # noqa: S608
(task_description,),
)
rows = cursor.fetchall()
if rows:
return [
{
"metadata": json.loads(row[0]),
"datetime": row[1],
"score": row[2],
}
for row in rows
]
except sqlite3.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while querying LTM: {e}",
color="red",
)
return None
def reset(self) -> None:
"""Resets the LTM table with error handling."""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM long_term_memories")
conn.commit()
except sqlite3.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while deleting all rows in LTM: {e}",
color="red",
)
async def asave(
self,
task_description: str,
metadata: dict[str, Any],
datetime: str,
score: int | float,
) -> None:
"""Save data to the LTM table asynchronously.
Args:
task_description: Description of the task.
metadata: Metadata associated with the memory.
datetime: Timestamp of the memory.
score: Quality score of the memory.
"""
try:
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(
"""
INSERT INTO long_term_memories (task_description, metadata, datetime, score)
VALUES (?, ?, ?, ?)
""",
(task_description, json.dumps(metadata), datetime, score),
)
await conn.commit()
except aiosqlite.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while saving to LTM: {e}",
color="red",
)
async def aload(
self, task_description: str, latest_n: int
) -> list[dict[str, Any]] | None:
"""Query the LTM table by task description asynchronously.
Args:
task_description: Description of the task to search for.
latest_n: Maximum number of results to return.
Returns:
List of matching memory entries or None if error occurs.
"""
try:
async with aiosqlite.connect(self.db_path) as conn:
cursor = await conn.execute(
f"""
SELECT metadata, datetime, score
FROM long_term_memories
WHERE task_description = ?
ORDER BY datetime DESC, score ASC
LIMIT {latest_n}
""", # nosec # noqa: S608
(task_description,),
)
rows = await cursor.fetchall()
if rows:
return [
{
"metadata": json.loads(row[0]),
"datetime": row[1],
"score": row[2],
}
for row in rows
]
except aiosqlite.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while querying LTM: {e}",
color="red",
)
return None
async def areset(self) -> None:
"""Reset the LTM table asynchronously."""
try:
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute("DELETE FROM long_term_memories")
await conn.commit()
except aiosqlite.Error as e:
if self._verbose:
self._printer.print(
content=f"MEMORY ERROR: An error occurred while deleting all rows in LTM: {e}",
color="red",
)

View File

@@ -1,230 +0,0 @@
from collections import defaultdict
from collections.abc import Iterable
import os
import re
from typing import Any
from mem0 import Memory, MemoryClient # type: ignore[import-untyped,import-not-found]
from crewai.memory.storage.interface import Storage
from crewai.rag.chromadb.utils import _sanitize_collection_name
MAX_AGENT_ID_LENGTH_MEM0 = 255
class Mem0Storage(Storage):
"""
Extends Storage to handle embedding and searching across entities using Mem0.
"""
def __init__(self, type, crew=None, config=None):
super().__init__()
self._validate_type(type)
self.memory_type = type
self.crew = crew
self.config = config or {}
self._extract_config_values()
self._initialize_memory()
def _validate_type(self, type):
supported_types = {"short_term", "long_term", "entities", "external"}
if type not in supported_types:
raise ValueError(
f"Invalid type '{type}' for Mem0Storage. "
f"Must be one of: {', '.join(supported_types)}"
)
def _extract_config_values(self):
self.mem0_run_id = self.config.get("run_id")
self.includes = self.config.get("includes")
self.excludes = self.config.get("excludes")
self.custom_categories = self.config.get("custom_categories")
self.infer = self.config.get("infer", True)
def _initialize_memory(self):
api_key = self.config.get("api_key") or os.getenv("MEM0_API_KEY")
org_id = self.config.get("org_id")
project_id = self.config.get("project_id")
local_config = self.config.get("local_mem0_config")
if api_key:
self.memory = (
MemoryClient(api_key=api_key, org_id=org_id, project_id=project_id)
if org_id and project_id
else MemoryClient(api_key=api_key)
)
if self.custom_categories:
self.memory.update_project(custom_categories=self.custom_categories)
else:
self.memory = (
Memory.from_config(local_config)
if local_config and len(local_config)
else Memory()
)
def _create_filter_for_search(self):
"""
Returns:
dict: A filter dictionary containing AND conditions for querying data.
- Includes user_id and agent_id if both are present.
- Includes user_id if only user_id is present.
- Includes agent_id if only agent_id is present.
- Includes run_id if memory_type is 'short_term' and
mem0_run_id is present.
"""
filter = defaultdict(list)
if self.memory_type == "short_term" and self.mem0_run_id:
filter["AND"].append({"run_id": self.mem0_run_id})
else:
user_id = self.config.get("user_id", "")
agent_id = self.config.get("agent_id", "")
if user_id and agent_id:
filter["OR"].append({"user_id": user_id})
filter["OR"].append({"agent_id": agent_id})
elif user_id:
filter["AND"].append({"user_id": user_id})
elif agent_id:
filter["AND"].append({"agent_id": agent_id})
return filter
def save(self, value: Any, metadata: dict[str, Any]) -> None:
def _last_content(messages: Iterable[dict[str, Any]], role: str) -> str:
return next(
(
m.get("content", "")
for m in reversed(list(messages))
if m.get("role") == role
),
"",
)
conversations = []
messages = metadata.pop("messages", None)
if messages:
last_user = _last_content(messages, "user")
last_assistant = _last_content(messages, "assistant")
if user_msg := self._get_user_message(last_user):
conversations.append({"role": "user", "content": user_msg})
if assistant_msg := self._get_assistant_message(last_assistant):
conversations.append({"role": "assistant", "content": assistant_msg})
else:
conversations.append({"role": "assistant", "content": value})
user_id = self.config.get("user_id", "")
base_metadata = {
"short_term": "short_term",
"long_term": "long_term",
"entities": "entity",
"external": "external",
}
# Shared base params
params: dict[str, Any] = {
"metadata": {"type": base_metadata[self.memory_type], **metadata},
"infer": self.infer,
}
# MemoryClient-specific overrides
if isinstance(self.memory, MemoryClient):
params["includes"] = self.includes
params["excludes"] = self.excludes
params["output_format"] = "v1.1"
params["version"] = "v2"
if self.memory_type == "short_term" and self.mem0_run_id:
params["run_id"] = self.mem0_run_id
if user_id:
params["user_id"] = user_id
if agent_id := self.config.get("agent_id", self._get_agent_name()):
params["agent_id"] = agent_id
self.memory.add(conversations, **params)
def search(
self, query: str, limit: int = 5, score_threshold: float = 0.6
) -> list[Any]:
params = {
"query": query,
"limit": limit,
"version": "v2",
"output_format": "v1.1",
}
if user_id := self.config.get("user_id", ""):
params["user_id"] = user_id
memory_type_map = {
"short_term": {"type": "short_term"},
"long_term": {"type": "long_term"},
"entities": {"type": "entity"},
"external": {"type": "external"},
}
if self.memory_type in memory_type_map:
params["metadata"] = memory_type_map[self.memory_type]
if self.memory_type == "short_term":
params["run_id"] = self.mem0_run_id
# Discard the filters for now since we create the filters
# automatically when the crew is created.
params["filters"] = self._create_filter_for_search()
params["threshold"] = score_threshold
if isinstance(self.memory, Memory):
del params["metadata"], params["version"], params["output_format"]
if params.get("run_id"):
del params["run_id"]
results = self.memory.search(**params)
# This makes it compatible for Contextual Memory to retrieve
for result in results["results"]:
result["content"] = result["memory"]
return [r for r in results["results"]]
def reset(self):
if self.memory:
self.memory.reset()
def _sanitize_role(self, role: str) -> str:
"""
Sanitizes agent roles to ensure valid directory names.
"""
return role.replace("\n", "").replace(" ", "_").replace("/", "_")
def _get_agent_name(self) -> str:
if not self.crew:
return ""
agents = self.crew.agents
agents = [self._sanitize_role(agent.role) for agent in agents]
agents = "_".join(agents)
return _sanitize_collection_name(
name=agents, max_collection_length=MAX_AGENT_ID_LENGTH_MEM0
)
def _get_assistant_message(self, text: str) -> str:
marker = "Final Answer:"
if marker in text:
return text.split(marker, 1)[1].strip()
return text
def _get_user_message(self, text: str) -> str:
pattern = r"User message:\s*(.*)"
match = re.search(pattern, text)
if match:
return match.group(1).strip()
return text

View File

@@ -1,315 +0,0 @@
from __future__ import annotations
import logging
import traceback
from typing import TYPE_CHECKING, Any, cast
import warnings
from crewai.rag.chromadb.config import ChromaDBConfig
from crewai.rag.chromadb.types import ChromaEmbeddingFunctionWrapper
from crewai.rag.config.utils import get_rag_client
from crewai.rag.embeddings.factory import build_embedder
from crewai.rag.factory import create_client
from crewai.rag.storage.base_rag_storage import BaseRAGStorage
from crewai.utilities.constants import MAX_FILE_NAME_LENGTH
from crewai.utilities.paths import db_storage_path
if TYPE_CHECKING:
from crewai.crew import Crew
from crewai.rag.core.base_client import BaseClient
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
from crewai.rag.embeddings.types import ProviderSpec
from crewai.rag.types import BaseRecord
class RAGStorage(BaseRAGStorage):
"""
Extends Storage to handle embeddings for memory entries, improving
search efficiency.
"""
def __init__(
self,
type: str,
allow_reset: bool = True,
embedder_config: ProviderSpec | BaseEmbeddingsProvider[Any] | None = None,
crew: Crew | None = None,
path: str | None = None,
) -> None:
super().__init__(type, allow_reset, embedder_config, crew)
crew_agents = crew.agents if crew else []
sanitized_roles = [self._sanitize_role(agent.role) for agent in crew_agents]
agents_str = "_".join(sanitized_roles)
self.agents = agents_str
self.storage_file_name = self._build_storage_file_name(type, agents_str)
self.type = type
self._client: BaseClient | None = None
self.allow_reset = allow_reset
self.path = path
warnings.filterwarnings(
"ignore",
message=r".*'model_fields'.*is deprecated.*",
module=r"^chromadb(\.|$)",
)
if self.embedder_config:
embedding_function = build_embedder(self.embedder_config)
try:
_ = embedding_function(["test"])
except Exception as e:
provider = (
self.embedder_config["provider"]
if isinstance(self.embedder_config, dict)
else self.embedder_config.__class__.__name__.replace(
"Provider", ""
).lower()
)
raise ValueError(
f"Failed to initialize embedder. Please check your configuration or connection.\n"
f"Provider: {provider}\n"
f"Error: {e}"
) from e
batch_size = None
if (
isinstance(self.embedder_config, dict)
and "config" in self.embedder_config
):
nested_config = self.embedder_config["config"]
if isinstance(nested_config, dict):
batch_size = nested_config.get("batch_size")
if batch_size is not None:
config = ChromaDBConfig(
embedding_function=cast(
ChromaEmbeddingFunctionWrapper, embedding_function
),
batch_size=cast(int, batch_size),
)
else:
config = ChromaDBConfig(
embedding_function=cast(
ChromaEmbeddingFunctionWrapper, embedding_function
)
)
if self.path:
config.settings.persist_directory = self.path
self._client = create_client(config)
def _get_client(self) -> BaseClient:
"""Get the appropriate client - instance-specific or global."""
return self._client if self._client else get_rag_client()
def _sanitize_role(self, role: str) -> str:
"""
Sanitizes agent roles to ensure valid directory names.
"""
return role.replace("\n", "").replace(" ", "_").replace("/", "_")
@staticmethod
def _build_storage_file_name(type: str, file_name: str) -> str:
"""
Ensures file name does not exceed max allowed by OS
"""
base_path = f"{db_storage_path()}/{type}"
if len(file_name) > MAX_FILE_NAME_LENGTH:
logging.warning(
f"Trimming file name from {len(file_name)} to {MAX_FILE_NAME_LENGTH} characters."
)
file_name = file_name[:MAX_FILE_NAME_LENGTH]
return f"{base_path}/{file_name}"
def save(self, value: Any, metadata: dict[str, Any]) -> None:
"""Save a value to storage.
Args:
value: The value to save.
metadata: Metadata to associate with the value.
"""
try:
client = self._get_client()
collection_name = (
f"memory_{self.type}_{self.agents}"
if self.agents
else f"memory_{self.type}"
)
client.get_or_create_collection(collection_name=collection_name)
document: BaseRecord = {"content": value}
if metadata:
document["metadata"] = metadata
batch_size = None
if (
self.embedder_config
and isinstance(self.embedder_config, dict)
and "config" in self.embedder_config
):
nested_config = self.embedder_config["config"]
if isinstance(nested_config, dict):
batch_size = nested_config.get("batch_size")
if batch_size is not None:
client.add_documents(
collection_name=collection_name,
documents=[document],
batch_size=cast(int, batch_size),
)
else:
client.add_documents(
collection_name=collection_name, documents=[document]
)
except Exception as e:
logging.error(
f"Error during {self.type} save: {e!s}\n{traceback.format_exc()}"
)
async def asave(self, value: Any, metadata: dict[str, Any]) -> None:
"""Save a value to storage asynchronously.
Args:
value: The value to save.
metadata: Metadata to associate with the value.
"""
try:
client = self._get_client()
collection_name = (
f"memory_{self.type}_{self.agents}"
if self.agents
else f"memory_{self.type}"
)
await client.aget_or_create_collection(collection_name=collection_name)
document: BaseRecord = {"content": value}
if metadata:
document["metadata"] = metadata
batch_size = None
if (
self.embedder_config
and isinstance(self.embedder_config, dict)
and "config" in self.embedder_config
):
nested_config = self.embedder_config["config"]
if isinstance(nested_config, dict):
batch_size = nested_config.get("batch_size")
if batch_size is not None:
await client.aadd_documents(
collection_name=collection_name,
documents=[document],
batch_size=cast(int, batch_size),
)
else:
await client.aadd_documents(
collection_name=collection_name, documents=[document]
)
except Exception as e:
logging.error(
f"Error during {self.type} async save: {e!s}\n{traceback.format_exc()}"
)
def search(
self,
query: str,
limit: int = 5,
filter: dict[str, Any] | None = None,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search for matching entries in storage.
Args:
query: The search query.
limit: Maximum number of results to return.
filter: Optional metadata filter.
score_threshold: Minimum similarity score for results.
Returns:
List of matching entries.
"""
try:
client = self._get_client()
collection_name = (
f"memory_{self.type}_{self.agents}"
if self.agents
else f"memory_{self.type}"
)
return client.search(
collection_name=collection_name,
query=query,
limit=limit,
metadata_filter=filter,
score_threshold=score_threshold,
)
except Exception as e:
logging.error(
f"Error during {self.type} search: {e!s}\n{traceback.format_exc()}"
)
return []
async def asearch(
self,
query: str,
limit: int = 5,
filter: dict[str, Any] | None = None,
score_threshold: float = 0.6,
) -> list[Any]:
"""Search for matching entries in storage asynchronously.
Args:
query: The search query.
limit: Maximum number of results to return.
filter: Optional metadata filter.
score_threshold: Minimum similarity score for results.
Returns:
List of matching entries.
"""
try:
client = self._get_client()
collection_name = (
f"memory_{self.type}_{self.agents}"
if self.agents
else f"memory_{self.type}"
)
return await client.asearch(
collection_name=collection_name,
query=query,
limit=limit,
metadata_filter=filter,
score_threshold=score_threshold,
)
except Exception as e:
logging.error(
f"Error during {self.type} async search: {e!s}\n{traceback.format_exc()}"
)
return []
def reset(self) -> None:
try:
client = self._get_client()
collection_name = (
f"memory_{self.type}_{self.agents}"
if self.agents
else f"memory_{self.type}"
)
client.delete_collection(collection_name=collection_name)
except Exception as e:
if "attempt to write a readonly database" in str(
e
) or "does not exist" in str(e):
# Ignore readonly database and collection not found errors (already reset)
pass
else:
raise Exception(
f"An error occurred while resetting the {self.type} memory: {e}"
) from e

View File

@@ -0,0 +1,385 @@
"""Data types for the unified memory system."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from pydantic import BaseModel, Field
# When searching the vector store, we ask for more results than the caller
# requested so that post-search steps (composite scoring, deduplication,
# category filtering) have enough candidates to fill the final result set.
# For example, if the caller asks for 10 results and this is 2, we fetch 20
# from the vector store and then trim down after scoring.
_RECALL_OVERSAMPLE_FACTOR = 2
class MemoryRecord(BaseModel):
"""A single memory entry stored in the memory system."""
id: str = Field(
default_factory=lambda: str(uuid4()),
description="Unique identifier for the memory record.",
)
content: str = Field(description="The textual content of the memory.")
scope: str = Field(
default="/",
description="Hierarchical path organizing the memory (e.g. /company/team/user).",
)
categories: list[str] = Field(
default_factory=list,
description="Categories or tags for the memory.",
)
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Arbitrary metadata associated with the memory.",
)
importance: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Importance score from 0.0 to 1.0, affects retrieval ranking.",
)
created_at: datetime = Field(
default_factory=datetime.utcnow,
description="When the memory was created.",
)
last_accessed: datetime = Field(
default_factory=datetime.utcnow,
description="When the memory was last accessed.",
)
embedding: list[float] | None = Field(
default=None,
description="Vector embedding for semantic search. Computed on save if not provided.",
)
source: str | None = Field(
default=None,
description=(
"Origin of this memory (e.g. user ID, session ID). "
"Used for provenance tracking and privacy filtering."
),
)
private: bool = Field(
default=False,
description=(
"If True, this memory is only visible to recall requests from the same source, "
"or when include_private=True is passed."
),
)
class MemoryMatch(BaseModel):
"""A memory record with relevance score from a recall operation."""
record: MemoryRecord = Field(description="The matched memory record.")
score: float = Field(
description="Combined relevance score (semantic, recency, importance).",
)
match_reasons: list[str] = Field(
default_factory=list,
description="Reasons for the match (e.g. semantic, recency, importance).",
)
evidence_gaps: list[str] = Field(
default_factory=list,
description="Information the system looked for but could not find.",
)
def format(self) -> str:
"""Format this match as a human-readable string including metadata.
Returns:
A multi-line string with score, content, categories, and non-empty
metadata fields.
"""
lines = [f"- (score={self.score:.2f}) {self.record.content}"]
if self.record.categories:
lines.append(f" categories: {', '.join(self.record.categories)}")
if self.record.metadata:
for key, value in self.record.metadata.items():
if value is not None:
lines.append(f" {key}: {value}")
return "\n".join(lines)
class ScopeInfo(BaseModel):
"""Information about a scope in the memory hierarchy."""
path: str = Field(description="The scope path (e.g. /company/engineering).")
record_count: int = Field(
default=0,
description="Number of records in this scope (including subscopes if applicable).",
)
categories: list[str] = Field(
default_factory=list,
description="Categories used in this scope.",
)
oldest_record: datetime | None = Field(
default=None,
description="Timestamp of the oldest record in this scope.",
)
newest_record: datetime | None = Field(
default=None,
description="Timestamp of the newest record in this scope.",
)
child_scopes: list[str] = Field(
default_factory=list,
description="Immediate child scope paths.",
)
class MemoryConfig(BaseModel):
"""Internal configuration for memory scoring, consolidation, and recall behavior.
Users configure these values via ``Memory(...)`` keyword arguments.
This model is not part of the public API -- it exists so that the config
can be passed as a single object to RecallFlow, EncodingFlow, and
compute_composite_score.
"""
# -- Composite score weights --
# The recall composite score is:
# semantic_weight * similarity + recency_weight * decay + importance_weight * importance
# These should sum to ~1.0 for intuitive 0-1 scoring.
recency_weight: float = Field(
default=0.3,
ge=0.0,
le=1.0,
description=(
"Weight for recency in the composite relevance score. "
"Higher values favor recently created memories over older ones."
),
)
semantic_weight: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description=(
"Weight for semantic similarity in the composite relevance score. "
"Higher values make recall rely more on vector-search closeness."
),
)
importance_weight: float = Field(
default=0.2,
ge=0.0,
le=1.0,
description=(
"Weight for explicit importance in the composite relevance score. "
"Higher values make high-importance memories surface more often."
),
)
recency_half_life_days: int = Field(
default=30,
ge=1,
description=(
"Number of days for the recency score to halve (exponential decay). "
"Lower values make memories lose relevance faster; higher values "
"keep old memories relevant longer."
),
)
# -- Consolidation (on save) --
consolidation_threshold: float = Field(
default=0.85,
ge=0.0,
le=1.0,
description=(
"Semantic similarity above which the consolidation flow is triggered "
"when saving new content. The LLM then decides whether to merge, "
"update, or delete overlapping records. Set to 1.0 to disable."
),
)
consolidation_limit: int = Field(
default=5,
ge=1,
description=(
"Maximum number of existing records to compare against when checking "
"for consolidation during a save."
),
)
batch_dedup_threshold: float = Field(
default=0.98,
ge=0.0,
le=1.0,
description=(
"Cosine similarity threshold for dropping near-exact duplicates "
"within a single remember_many() batch. Only items with similarity "
">= this value are dropped. Set very high (0.98) to avoid "
"discarding useful memories that are merely similar."
),
)
# -- Save defaults --
default_importance: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description=(
"Importance assigned to new memories when no explicit value is given "
"and the LLM analysis path is skipped (i.e. all fields provided by "
"the caller)."
),
)
# -- Recall depth control --
# The RecallFlow router uses these thresholds to decide between returning
# results immediately ("synthesize") and doing an extra LLM-driven
# exploration round ("explore_deeper").
confidence_threshold_high: float = Field(
default=0.8,
ge=0.0,
le=1.0,
description=(
"When recall confidence is at or above this value, results are "
"returned directly without deeper exploration."
),
)
confidence_threshold_low: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description=(
"When recall confidence is below this value and exploration budget "
"remains, a deeper LLM-driven exploration round is triggered."
),
)
complex_query_threshold: float = Field(
default=0.7,
ge=0.0,
le=1.0,
description=(
"For queries classified as 'complex' by the LLM, deeper exploration "
"is triggered when confidence is below this value."
),
)
exploration_budget: int = Field(
default=1,
ge=0,
description=(
"Number of LLM-driven exploration rounds allowed during deep recall. "
"0 means recall always uses direct vector search only; higher values "
"allow more thorough but slower retrieval."
),
)
recall_oversample_factor: int = Field(
default=_RECALL_OVERSAMPLE_FACTOR,
ge=1,
description=(
"When searching the vector store, fetch this many times more results "
"than the caller requested so that post-search steps (composite "
"scoring, deduplication, category filtering) have enough candidates "
"to fill the final result set."
),
)
query_analysis_threshold: int = Field(
default=250,
ge=0,
description=(
"Character count threshold for LLM query analysis during deep recall. "
"Queries shorter than this are embedded directly without an LLM call "
"to distill sub-queries or infer scopes (saving ~1-3s). Longer queries "
"(e.g. full task descriptions) benefit from LLM distillation. "
"Set to 0 to always use LLM analysis."
),
)
def embed_text(embedder: Any, text: str) -> list[float]:
"""Embed a single text string and return a list of floats.
Args:
embedder: Callable that accepts a list of strings and returns embeddings.
text: The text to embed.
Returns:
List of floats representing the embedding, or empty list on failure.
"""
if not text or not text.strip():
return []
result = embedder([text])
if not result:
return []
first = result[0]
if hasattr(first, "tolist"):
return list(first.tolist())
if isinstance(first, list):
return [float(x) for x in first]
return list(first)
def embed_texts(embedder: Any, texts: list[str]) -> list[list[float]]:
"""Embed multiple texts in a single API call.
The embedder already accepts ``list[str]``, so this just calls it once
with the full batch and normalises the output format.
Args:
embedder: Callable that accepts a list of strings and returns embeddings.
texts: List of texts to embed.
Returns:
List of embeddings, one per input text. Empty texts produce empty lists.
"""
if not texts:
return []
# Filter out empty texts, remembering their positions
valid: list[tuple[int, str]] = [
(i, t) for i, t in enumerate(texts) if t and t.strip()
]
if not valid:
return [[] for _ in texts]
result = embedder([t for _, t in valid])
embeddings: list[list[float]] = [[] for _ in texts]
for (orig_idx, _), emb in zip(valid, result, strict=False):
if hasattr(emb, "tolist"):
embeddings[orig_idx] = emb.tolist()
elif isinstance(emb, list):
embeddings[orig_idx] = [float(x) for x in emb]
else:
embeddings[orig_idx] = list(emb)
return embeddings
def compute_composite_score(
record: MemoryRecord,
semantic_score: float,
config: MemoryConfig,
) -> tuple[float, list[str]]:
"""Compute a weighted composite relevance score from semantic, recency, and importance.
composite = w_semantic * semantic + w_recency * decay + w_importance * importance
where decay = 0.5^(age_days / half_life_days).
Args:
record: The memory record (provides created_at and importance).
semantic_score: Raw semantic similarity from vector search, in [0, 1].
config: Weights and recency half-life.
Returns:
Tuple of (composite_score, match_reasons). match_reasons includes
"semantic" always; "recency" if decay > 0.5; "importance" if record.importance > 0.5.
"""
age_seconds = (datetime.utcnow() - record.created_at).total_seconds()
age_days = max(age_seconds / 86400.0, 0.0)
decay = 0.5 ** (age_days / config.recency_half_life_days)
composite = (
config.semantic_weight * semantic_score
+ config.recency_weight * decay
+ config.importance_weight * record.importance
)
reasons: list[str] = ["semantic"]
if decay > 0.5:
reasons.append("recency")
if record.importance > 0.5:
reasons.append("importance")
return composite, reasons

View File

@@ -0,0 +1,869 @@
"""Unified Memory class: single intelligent memory with LLM analysis and pluggable storage."""
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
import threading
import time
from typing import TYPE_CHECKING, Any, Literal
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.llms.base_llm import BaseLLM
from crewai.memory.analyze import extract_memories_from_content
from crewai.memory.recall_flow import RecallFlow
from crewai.memory.storage.backend import StorageBackend
from crewai.memory.types import (
MemoryConfig,
MemoryMatch,
MemoryRecord,
ScopeInfo,
compute_composite_score,
embed_text,
)
from crewai.rag.embeddings.factory import build_embedder
from crewai.rag.embeddings.providers.openai.types import OpenAIProviderSpec
if TYPE_CHECKING:
from chromadb.utils.embedding_functions.openai_embedding_function import (
OpenAIEmbeddingFunction,
)
def _default_embedder() -> OpenAIEmbeddingFunction:
"""Build default OpenAI embedder for memory."""
spec: OpenAIProviderSpec = {"provider": "openai", "config": {}}
return build_embedder(spec)
class Memory:
"""Unified memory: standalone, LLM-analyzed, with intelligent recall flow.
Works without agent/crew. Uses LLM to infer scope, categories, importance on save.
Uses RecallFlow for adaptive-depth recall. Supports scope/slice views and
pluggable storage (LanceDB default).
"""
def __init__(
self,
llm: BaseLLM | str = "gpt-4o-mini",
storage: StorageBackend | str = "lancedb",
embedder: Any = None,
# -- Scoring weights --
# These three weights control how recall results are ranked.
# The composite score is: semantic_weight * similarity + recency_weight * decay + importance_weight * importance.
# They should sum to ~1.0 for intuitive scoring.
recency_weight: float = 0.3,
semantic_weight: float = 0.5,
importance_weight: float = 0.2,
# How quickly old memories lose relevance. The recency score halves every
# N days (exponential decay). Lower = faster forgetting; higher = longer relevance.
recency_half_life_days: int = 30,
# -- Consolidation --
# When remembering new content, if an existing record has similarity >= this
# threshold, the LLM is asked to merge/update/delete. Set to 1.0 to disable.
consolidation_threshold: float = 0.85,
# Max existing records to compare against when checking for consolidation.
consolidation_limit: int = 5,
# -- Save defaults --
# Importance assigned to new memories when no explicit value is given and
# the LLM analysis path is skipped (all fields provided by the caller).
default_importance: float = 0.5,
# -- Recall depth control --
# These thresholds govern the RecallFlow router that decides between
# returning results immediately ("synthesize") vs. doing an extra
# LLM-driven exploration round ("explore_deeper").
# confidence >= confidence_threshold_high => always synthesize
# confidence < confidence_threshold_low => explore deeper (if budget > 0)
# complex query + confidence < complex_query_threshold => explore deeper
confidence_threshold_high: float = 0.8,
confidence_threshold_low: float = 0.5,
complex_query_threshold: float = 0.7,
# How many LLM-driven exploration rounds the RecallFlow is allowed to run.
# 0 = always shallow (vector search only); higher = more thorough but slower.
exploration_budget: int = 1,
# Queries shorter than this skip LLM analysis (saving ~1-3s).
# Longer queries (full task descriptions) benefit from LLM distillation.
query_analysis_threshold: int = 200,
# When True, all write operations (remember, remember_many) are silently
# skipped. Useful for sharing a read-only view of memory across agents
# without any of them persisting new memories.
read_only: bool = False,
) -> None:
"""Initialize Memory.
Args:
llm: LLM for analysis (model name or BaseLLM instance).
storage: Backend: "lancedb" or a StorageBackend instance.
embedder: Embedding callable, provider config dict, or None (default OpenAI).
recency_weight: Weight for recency in the composite relevance score.
semantic_weight: Weight for semantic similarity in the composite relevance score.
importance_weight: Weight for importance in the composite relevance score.
recency_half_life_days: Recency score halves every N days (exponential decay).
consolidation_threshold: Similarity above which consolidation is triggered on save.
consolidation_limit: Max existing records to compare during consolidation.
default_importance: Default importance when not provided or inferred.
confidence_threshold_high: Recall confidence above which results are returned directly.
confidence_threshold_low: Recall confidence below which deeper exploration is triggered.
complex_query_threshold: For complex queries, explore deeper below this confidence.
exploration_budget: Number of LLM-driven exploration rounds during deep recall.
query_analysis_threshold: Queries shorter than this skip LLM analysis during deep recall.
read_only: If True, remember() and remember_many() are silent no-ops.
"""
self._read_only = read_only
self._config = MemoryConfig(
recency_weight=recency_weight,
semantic_weight=semantic_weight,
importance_weight=importance_weight,
recency_half_life_days=recency_half_life_days,
consolidation_threshold=consolidation_threshold,
consolidation_limit=consolidation_limit,
default_importance=default_importance,
confidence_threshold_high=confidence_threshold_high,
confidence_threshold_low=confidence_threshold_low,
complex_query_threshold=complex_query_threshold,
exploration_budget=exploration_budget,
query_analysis_threshold=query_analysis_threshold,
)
# Store raw config for lazy initialization. LLM and embedder are only
# built on first access so that Memory() never fails at construction
# time (e.g. when auto-created by Flow without an API key set).
self._llm_config: BaseLLM | str = llm
self._llm_instance: BaseLLM | None = None if isinstance(llm, str) else llm
self._embedder_config: Any = embedder
self._embedder_instance: Any = (
embedder
if (embedder is not None and not isinstance(embedder, dict))
else None
)
if isinstance(storage, str):
from crewai.memory.storage.lancedb_storage import LanceDBStorage
self._storage = LanceDBStorage() if storage == "lancedb" else LanceDBStorage(path=storage)
else:
self._storage = storage
# Background save queue. max_workers=1 serializes saves to avoid
# concurrent storage mutations (two saves finding the same similar
# record and both trying to update/delete it). Within each save,
# the parallel LLM calls still run on their own thread pool.
self._save_pool = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="memory-save"
)
self._pending_saves: list[Future[Any]] = []
self._pending_lock = threading.Lock()
_MEMORY_DOCS_URL = "https://docs.crewai.com/concepts/memory"
@property
def _llm(self) -> BaseLLM:
"""Lazy LLM initialization -- only created when first needed."""
if self._llm_instance is None:
from crewai.llm import LLM
try:
model_name = (
self._llm_config
if isinstance(self._llm_config, str)
else str(self._llm_config)
)
self._llm_instance = LLM(model=model_name)
except Exception as e:
raise RuntimeError(
f"Memory requires an LLM for analysis but initialization failed: {e}\n\n"
"To fix this, do one of the following:\n"
" - Set OPENAI_API_KEY for the default model (gpt-4o-mini)\n"
' - Pass a different model: Memory(llm="anthropic/claude-3-haiku-20240307")\n'
' - Pass any LLM instance: Memory(llm=LLM(model="your-model"))\n'
" - To skip LLM analysis, pass all fields explicitly to remember()\n"
' and use depth="shallow" for recall.\n\n'
f"Docs: {self._MEMORY_DOCS_URL}"
) from e
return self._llm_instance
@property
def _embedder(self) -> Any:
"""Lazy embedder initialization -- only created when first needed."""
if self._embedder_instance is None:
try:
if isinstance(self._embedder_config, dict):
self._embedder_instance = build_embedder(self._embedder_config)
else:
self._embedder_instance = _default_embedder()
except Exception as e:
raise RuntimeError(
f"Memory requires an embedder for vector search but initialization failed: {e}\n\n"
"To fix this, do one of the following:\n"
" - Set OPENAI_API_KEY for the default embedder (text-embedding-3-small)\n"
' - Pass a different embedder: Memory(embedder={{"provider": "google", "config": {{...}}}})\n'
" - Pass a callable: Memory(embedder=my_embedding_function)\n\n"
f"Docs: {self._MEMORY_DOCS_URL}"
) from e
return self._embedder_instance
# ------------------------------------------------------------------
# Background write queue
# ------------------------------------------------------------------
def _submit_save(self, fn: Any, *args: Any, **kwargs: Any) -> Future[Any]:
"""Submit a save operation to the background thread pool.
The future is tracked so that ``drain_writes()`` can wait for it.
If the pool has been shut down (e.g. after ``close()``), the save
runs synchronously as a fallback so late saves still succeed.
"""
try:
future: Future[Any] = self._save_pool.submit(fn, *args, **kwargs)
except RuntimeError:
# Pool shut down -- run synchronously as fallback
future = Future()
try:
result = fn(*args, **kwargs)
future.set_result(result)
except Exception as exc:
future.set_exception(exc)
return future
with self._pending_lock:
self._pending_saves.append(future)
future.add_done_callback(self._on_save_done)
return future
def _on_save_done(self, future: Future[Any]) -> None:
"""Remove a completed future from the pending list and emit failure event if needed.
This callback must never raise -- it runs from the thread pool's
internal machinery during process shutdown when executors and the
event bus may already be closed.
"""
try:
with self._pending_lock:
try:
self._pending_saves.remove(future)
except ValueError:
pass # already removed
exc = future.exception()
if exc is not None:
crewai_event_bus.emit(
self,
MemorySaveFailedEvent(
value="background save",
error=str(exc),
source_type="unified_memory",
),
)
except Exception: # noqa: S110
pass # swallow everything during shutdown
def drain_writes(self) -> None:
"""Block until all pending background saves have completed.
Called automatically by ``recall()`` and should be called by the
crew at shutdown to ensure no saves are lost.
"""
with self._pending_lock:
pending = list(self._pending_saves)
for future in pending:
future.result() # blocks until done; re-raises exceptions
def close(self) -> None:
"""Drain pending saves and shut down the background thread pool."""
self.drain_writes()
self._save_pool.shutdown(wait=True)
def _encode_batch(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
) -> list[MemoryRecord]:
"""Run the batch EncodingFlow for one or more items. No event emission.
This is the core encoding logic shared by ``remember()`` and
``remember_many()``. Events are managed by the calling method.
"""
from crewai.memory.encoding_flow import EncodingFlow
flow = EncodingFlow(
storage=self._storage,
llm=self._llm,
embedder=self._embedder,
config=self._config,
)
items_input = [
{
"content": c,
"scope": scope,
"categories": categories,
"metadata": metadata,
"importance": importance,
"source": source,
"private": private,
}
for c in contents
]
flow.kickoff(inputs={"items": items_input})
return [
item.result_record
for item in flow.state.items
if not item.dropped and item.result_record is not None
]
def remember(
self,
content: str,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
) -> MemoryRecord | None:
"""Store a single item in memory (synchronous).
Routes through the same serialized save pool as ``remember_many``
to prevent races, but blocks until the save completes so the caller
gets the ``MemoryRecord`` back immediately.
Args:
content: Text to remember.
scope: Optional scope path; inferred if None.
categories: Optional categories; inferred if None.
metadata: Optional metadata; merged with LLM-extracted if inferred.
importance: Optional importance 0-1; inferred if None.
source: Optional provenance identifier (e.g. user ID, session ID).
private: If True, only visible to recall from the same source.
agent_role: Optional agent role for event metadata.
Returns:
The created MemoryRecord, or None if this memory is read-only.
Raises:
Exception: On save failure (events emitted).
"""
if self._read_only:
return None
_source_type = "unified_memory"
try:
crewai_event_bus.emit(
self,
MemorySaveStartedEvent(
value=content,
metadata=metadata,
source_type=_source_type,
),
)
start = time.perf_counter()
# Submit through the save pool for proper serialization,
# then immediately wait for the result.
future = self._submit_save(
self._encode_batch,
[content],
scope,
categories,
metadata,
importance,
source,
private,
)
records = future.result()
record = records[0] if records else None
elapsed_ms = (time.perf_counter() - start) * 1000
crewai_event_bus.emit(
self,
MemorySaveCompletedEvent(
value=content,
metadata=metadata or {},
agent_role=agent_role,
save_time_ms=elapsed_ms,
source_type=_source_type,
),
)
return record
except Exception as e:
crewai_event_bus.emit(
self,
MemorySaveFailedEvent(
value=content,
metadata=metadata,
error=str(e),
source_type=_source_type,
),
)
raise
def remember_many(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
) -> list[MemoryRecord]:
"""Store multiple items in memory (non-blocking).
The encoding pipeline runs in a background thread. This method
returns immediately so the caller (e.g. agent) is not blocked.
A ``MemorySaveStartedEvent`` is emitted immediately; the
``MemorySaveCompletedEvent`` is emitted when the background
save finishes.
Any subsequent ``recall()`` call will automatically wait for
pending saves to complete before searching (read barrier).
Args:
contents: List of text items to remember.
scope: Optional scope applied to all items.
categories: Optional categories applied to all items.
metadata: Optional metadata applied to all items.
importance: Optional importance applied to all items.
source: Optional provenance identifier applied to all items.
private: Privacy flag applied to all items.
agent_role: Optional agent role for event metadata.
Returns:
Empty list (records are not available until the background save completes).
"""
if not contents or self._read_only:
return []
self._submit_save(
self._background_encode_batch,
contents,
scope,
categories,
metadata,
importance,
source,
private,
agent_role,
)
return []
def _background_encode_batch(
self,
contents: list[str],
scope: str | None,
categories: list[str] | None,
metadata: dict[str, Any] | None,
importance: float | None,
source: str | None,
private: bool,
agent_role: str | None,
) -> list[MemoryRecord]:
"""Run the encoding pipeline in a background thread with event emission.
Both started and completed events are emitted here (in the background
thread) so they pair correctly on the event bus scope stack.
All ``emit`` calls are wrapped in try/except to handle the case where
the event bus shuts down before the background save finishes (e.g.
during process exit).
"""
try:
crewai_event_bus.emit(
self,
MemorySaveStartedEvent(
value=f"{len(contents)} memories (background)",
metadata=metadata,
source_type="unified_memory",
),
)
except RuntimeError:
pass # event bus shut down during process exit
try:
start = time.perf_counter()
records = self._encode_batch(
contents, scope, categories, metadata, importance, source, private
)
elapsed_ms = (time.perf_counter() - start) * 1000
except RuntimeError:
# The encoding pipeline uses asyncio.run() -> to_thread() internally.
# If the process is shutting down, the default executor is closed and
# to_thread raises "cannot schedule new futures after shutdown".
# Silently abandon the save -- the process is exiting anyway.
return []
try:
crewai_event_bus.emit(
self,
MemorySaveCompletedEvent(
value=f"{len(records)} memories saved",
metadata=metadata or {},
agent_role=agent_role,
save_time_ms=elapsed_ms,
source_type="unified_memory",
),
)
except RuntimeError:
pass # event bus shut down during process exit
return records
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from a raw content blob using the LLM.
This is a pure helper -- it does NOT store anything.
Call remember() on each returned string to persist them.
Args:
content: Raw text (e.g. task + result dump).
Returns:
List of short, self-contained memory statements.
"""
return extract_memories_from_content(content, self._llm)
def recall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: Literal["shallow", "deep"] = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Retrieve relevant memories.
``shallow`` embeds the query directly and runs a single vector search.
``deep`` (default) uses the RecallFlow: the LLM distills the query into
targeted sub-queries, selects scopes, searches in parallel, and applies
confidence-based routing for optional deeper exploration.
Args:
query: Natural language query.
scope: Optional scope prefix to search within.
categories: Optional category filter.
limit: Max number of results.
depth: "shallow" for direct vector search, "deep" for intelligent flow.
source: Optional provenance filter. Private records are only visible
when this matches the record's source.
include_private: If True, all private records are visible regardless of source.
Returns:
List of MemoryMatch, ordered by relevance.
"""
# Read barrier: wait for any pending background saves to finish
# so that the search sees all persisted records.
self.drain_writes()
_source = "unified_memory"
try:
crewai_event_bus.emit(
self,
MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=None,
source_type=_source,
),
)
start = time.perf_counter()
if depth == "shallow":
embedding = embed_text(self._embedder, query)
if not embedding:
results: list[MemoryMatch] = []
else:
raw = self._storage.search(
embedding,
scope_prefix=scope,
categories=categories,
limit=limit,
min_score=0.0,
)
# Privacy filter
if not include_private:
raw = [
(r, s)
for r, s in raw
if not r.private or r.source == source
]
results = []
for r, s in raw:
composite, reasons = compute_composite_score(r, s, self._config)
results.append(
MemoryMatch(
record=r,
score=composite,
match_reasons=reasons,
)
)
results.sort(key=lambda m: m.score, reverse=True)
else:
flow = RecallFlow(
storage=self._storage,
llm=self._llm,
embedder=self._embedder,
config=self._config,
)
flow.kickoff(
inputs={
"query": query,
"scope": scope,
"categories": categories or [],
"limit": limit,
"source": source,
"include_private": include_private,
}
)
results = flow.state.final_results
# Update last_accessed for recalled records
if results:
try:
touch = getattr(self._storage, "touch_records", None)
if touch is not None:
touch([m.record.id for m in results])
except Exception: # noqa: S110
pass # Non-critical: don't fail recall because of touch
elapsed_ms = (time.perf_counter() - start) * 1000
crewai_event_bus.emit(
self,
MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=None,
query_time_ms=elapsed_ms,
source_type=_source,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=None,
error=str(e),
source_type=_source,
),
)
raise
def forget(
self,
scope: str | None = None,
categories: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
record_ids: list[str] | None = None,
) -> int:
"""Delete memories matching criteria.
Returns:
Number of records deleted.
"""
return self._storage.delete(
scope_prefix=scope,
categories=categories,
record_ids=record_ids,
older_than=older_than,
metadata_filter=metadata_filter,
)
def update(
self,
record_id: str,
content: str | None = None,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
) -> MemoryRecord:
"""Update an existing memory record by ID.
Args:
record_id: ID of the record to update.
content: New content; re-embedded if provided.
scope: New scope path.
categories: New categories.
metadata: New metadata.
importance: New importance score.
Returns:
The updated MemoryRecord.
Raises:
ValueError: If the record is not found.
"""
existing = self._storage.get_record(record_id)
if existing is None:
raise ValueError(f"Record not found: {record_id}")
now = datetime.utcnow()
updates: dict[str, Any] = {"last_accessed": now}
if content is not None:
updates["content"] = content
embedding = embed_text(self._embedder, content)
updates["embedding"] = embedding if embedding else existing.embedding
if scope is not None:
updates["scope"] = scope
if categories is not None:
updates["categories"] = categories
if metadata is not None:
updates["metadata"] = metadata
if importance is not None:
updates["importance"] = importance
updated = existing.model_copy(update=updates)
self._storage.update(updated)
return updated
def scope(self, path: str) -> Any:
"""Return a scoped view of this memory."""
from crewai.memory.memory_scope import MemoryScope
return MemoryScope(memory=self, root_path=path)
def slice(
self,
scopes: list[str],
categories: list[str] | None = None,
read_only: bool = True,
) -> Any:
"""Return a multi-scope view (slice) of this memory."""
from crewai.memory.memory_scope import MemorySlice
return MemorySlice(
memory=self,
scopes=scopes,
categories=categories,
read_only=read_only,
)
def list_scopes(self, path: str = "/") -> list[str]:
"""List immediate child scopes under path."""
return self._storage.list_scopes(path)
def list_records(
self, scope: str | None = None, limit: int = 200, offset: int = 0
) -> list[MemoryRecord]:
"""List records in a scope, newest first.
Args:
scope: Optional scope path prefix to filter by.
limit: Maximum number of records to return.
offset: Number of records to skip (for pagination).
"""
return self._storage.list_records(
scope_prefix=scope, limit=limit, offset=offset
)
def info(self, path: str = "/") -> ScopeInfo:
"""Return scope info for path."""
return self._storage.get_scope_info(path)
def tree(self, path: str = "/", max_depth: int = 3) -> str:
"""Return a formatted tree of scopes (string)."""
lines: list[str] = []
def _walk(p: str, depth: int, prefix: str) -> None:
if depth > max_depth:
return
info = self._storage.get_scope_info(p)
lines.append(f"{prefix}{p or '/'} ({info.record_count} records)")
for child in info.child_scopes[:20]:
_walk(child, depth + 1, prefix + " ")
_walk(path.rstrip("/") or "/", 0, "")
return "\n".join(lines) if lines else f"{path or '/'} (0 records)"
def list_categories(self, path: str | None = None) -> dict[str, int]:
"""List categories and counts; path=None means global."""
return self._storage.list_categories(scope_prefix=path)
def reset(self, scope: str | None = None) -> None:
"""Reset (delete all) memories in scope. None = all."""
self._storage.reset(scope_prefix=scope)
async def aextract_memories(self, content: str) -> list[str]:
"""Async variant of extract_memories."""
return self.extract_memories(content)
async def aremember(
self,
content: str,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
) -> MemoryRecord | None:
"""Async remember: delegates to sync for now."""
return self.remember(
content,
scope=scope,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
)
async def aremember_many(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
) -> list[MemoryRecord]:
"""Async remember_many: delegates to sync for now."""
return self.remember_many(
contents,
scope=scope,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
agent_role=agent_role,
)
async def arecall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: Literal["shallow", "deep"] = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Async recall: delegates to sync for now."""
return self.recall(
query,
scope=scope,
categories=categories,
limit=limit,
depth=depth,
source=source,
include_private=include_private,
)

View File

@@ -216,6 +216,10 @@ def build_embedder_from_dict(
def build_embedder_from_dict(spec: ONNXProviderSpec) -> ONNXMiniLM_L6_V2: ...
@overload
def build_embedder_from_dict(spec: dict[str, Any]) -> EmbeddingFunction[Any]: ...
def build_embedder_from_dict(spec): # type: ignore[no-untyped-def]
"""Build an embedding function instance from a dictionary specification.
@@ -341,6 +345,10 @@ def build_embedder(spec: Text2VecProviderSpec) -> Text2VecEmbeddingFunction: ...
def build_embedder(spec: ONNXProviderSpec) -> ONNXMiniLM_L6_V2: ...
@overload
def build_embedder(spec: dict[str, Any]) -> EmbeddingFunction[Any]: ...
def build_embedder(spec): # type: ignore[no-untyped-def]
"""Build an embedding function from either a provider spec or a provider instance.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from concurrent.futures import Future
from copy import copy as shallow_copy
import datetime
@@ -585,16 +586,29 @@ class Task(BaseModel):
self._post_agent_execution(agent)
if not self._guardrails and not self._guardrail:
if isinstance(result, BaseModel):
raw = result.model_dump_json()
if self.output_pydantic:
pydantic_output = result
json_output = None
elif self.output_json:
pydantic_output = None
json_output = result.model_dump()
else:
pydantic_output = None
json_output = None
elif not self._guardrails and not self._guardrail:
raw = result
pydantic_output, json_output = self._export_output(result)
else:
raw = result
pydantic_output, json_output = None, None
task_output = TaskOutput(
name=self.name or self.description,
description=self.description,
expected_output=self.expected_output,
raw=result,
raw=raw,
pydantic=pydantic_output,
json_dict=json_output,
agent=agent.role,
@@ -624,11 +638,15 @@ class Task(BaseModel):
self.end_time = datetime.datetime.now()
if self.callback:
self.callback(self.output)
cb_result = self.callback(self.output)
if inspect.isawaitable(cb_result):
await cb_result
crew = self.agent.crew # type: ignore[union-attr]
if crew and crew.task_callback and crew.task_callback != self.callback:
crew.task_callback(self.output)
cb_result = crew.task_callback(self.output)
if inspect.isawaitable(cb_result):
await cb_result
if self.output_file:
content = (
@@ -682,16 +700,29 @@ class Task(BaseModel):
self._post_agent_execution(agent)
if not self._guardrails and not self._guardrail:
if isinstance(result, BaseModel):
raw = result.model_dump_json()
if self.output_pydantic:
pydantic_output = result
json_output = None
elif self.output_json:
pydantic_output = None
json_output = result.model_dump()
else:
pydantic_output = None
json_output = None
elif not self._guardrails and not self._guardrail:
raw = result
pydantic_output, json_output = self._export_output(result)
else:
raw = result
pydantic_output, json_output = None, None
task_output = TaskOutput(
name=self.name or self.description,
description=self.description,
expected_output=self.expected_output,
raw=result,
raw=raw,
pydantic=pydantic_output,
json_dict=json_output,
agent=agent.role,
@@ -722,11 +753,15 @@ class Task(BaseModel):
self.end_time = datetime.datetime.now()
if self.callback:
self.callback(self.output)
cb_result = self.callback(self.output)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
crew = self.agent.crew # type: ignore[union-attr]
if crew and crew.task_callback and crew.task_callback != self.callback:
crew.task_callback(self.output)
cb_result = crew.task_callback(self.output)
if inspect.iscoroutine(cb_result):
asyncio.run(cb_result)
if self.output_file:
content = (

View File

@@ -1,5 +1,4 @@
from crewai.telemetry.telemetry import Telemetry
__all__ = ["Telemetry"]

View File

@@ -173,6 +173,12 @@ class Telemetry:
self._original_handlers: dict[int, Any] = {}
if threading.current_thread() is not threading.main_thread():
logger.debug(
"Skipping signal handler registration: not running in main thread"
)
return
self._register_signal_handler(signal.SIGTERM, SigTermEvent, shutdown=True)
self._register_signal_handler(signal.SIGINT, SigIntEvent, shutdown=True)
if hasattr(signal, "SIGHUP"):

View File

@@ -1,7 +1,6 @@
from crewai.tools.base_tool import BaseTool, EnvVar, tool
__all__ = [
"BaseTool",
"EnvVar",

View File

@@ -23,7 +23,7 @@ from pydantic import (
)
from typing_extensions import TypeIs
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.structured_tool import CrewStructuredTool, build_schema_hint
from crewai.utilities.printer import Printer
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.string_utils import sanitize_tool_name
@@ -150,14 +150,39 @@ class BaseTool(BaseModel, ABC):
super().model_post_init(__context)
def _validate_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Validate keyword arguments against args_schema if present.
Args:
kwargs: The keyword arguments to validate.
Returns:
Validated (and possibly coerced) keyword arguments.
Raises:
ValueError: If validation against args_schema fails.
"""
if self.args_schema is not None and self.args_schema.model_fields:
try:
validated = self.args_schema.model_validate(kwargs)
return validated.model_dump()
except Exception as e:
hint = build_schema_hint(self.args_schema)
raise ValueError(
f"Tool '{self.name}' arguments validation failed: {e}{hint}"
) from e
return kwargs
def run(
self,
*args: Any,
**kwargs: Any,
) -> Any:
if not args:
kwargs = self._validate_kwargs(kwargs)
result = self._run(*args, **kwargs)
# If _run is async, we safely run it
if asyncio.iscoroutine(result):
result = asyncio.run(result)
@@ -179,6 +204,8 @@ class BaseTool(BaseModel, ABC):
Returns:
The result of the tool execution.
"""
if not args:
kwargs = self._validate_kwargs(kwargs)
result = await self._arun(*args, **kwargs)
self.current_usage_count += 1
return result
@@ -331,6 +358,9 @@ class Tool(BaseTool, Generic[P, R]):
Returns:
The result of the tool execution.
"""
if not args:
kwargs = self._validate_kwargs(kwargs) # type: ignore[assignment]
result = self.func(*args, **kwargs)
if asyncio.iscoroutine(result):
@@ -361,6 +391,8 @@ class Tool(BaseTool, Generic[P, R]):
Returns:
The result of the tool execution.
"""
if not args:
kwargs = self._validate_kwargs(kwargs) # type: ignore[assignment]
result = await self._arun(*args, **kwargs)
self.current_usage_count += 1
return result

View File

@@ -27,14 +27,16 @@ class MCPNativeTool(BaseTool):
tool_name: str,
tool_schema: dict[str, Any],
server_name: str,
original_tool_name: str | None = None,
) -> None:
"""Initialize native MCP tool.
Args:
mcp_client: MCPClient instance with active session.
tool_name: Original name of the tool on the MCP server.
tool_name: Name of the tool (may be prefixed).
tool_schema: Schema information for the tool.
server_name: Name of the MCP server for prefixing.
original_tool_name: Original name of the tool on the MCP server.
"""
# Create tool name with server prefix to avoid conflicts
prefixed_name = f"{server_name}_{tool_name}"
@@ -57,7 +59,7 @@ class MCPNativeTool(BaseTool):
# Set instance attributes after super().__init__
self._mcp_client = mcp_client
self._original_tool_name = tool_name
self._original_tool_name = original_tool_name or tool_name
self._server_name = server_name
# self._logger = logging.getLogger(__name__)

Some files were not shown because too many files have changed in this diff Show More