agentify-core 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentify_core-0.2.0/agentify_core.egg-info → agentify_core-0.3.0}/PKG-INFO +13 -7
- {agentify_core-0.2.0 → agentify_core-0.3.0}/README.md +14 -29
- {agentify_core-0.2.0 → agentify_core-0.3.0}/README_PYPI.md +2 -1
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/__init__.py +1 -1
- agentify_core-0.3.0/agentify/mcp/__init__.py +3 -0
- agentify_core-0.3.0/agentify/mcp/adapter.py +46 -0
- agentify_core-0.3.0/agentify/mcp/client.py +141 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/interfaces.py +2 -2
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/service.py +5 -1
- agentify_core-0.3.0/agentify/memory/stores/__init__.py +10 -0
- agentify_core-0.3.0/agentify/memory/stores/elastic_store.py +244 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/stores/in_memory_store.py +6 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/stores/redis_store.py +45 -0
- agentify_core-0.3.0/agentify/memory/stores/sqlite_store.py +196 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0/agentify_core.egg-info}/PKG-INFO +13 -7
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify_core.egg-info/SOURCES.txt +8 -1
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify_core.egg-info/requires.txt +11 -3
- {agentify_core-0.2.0 → agentify_core-0.3.0}/pyproject.toml +16 -7
- agentify_core-0.3.0/requirements.txt +26 -0
- agentify_core-0.3.0/tests/test_mcp.py +124 -0
- agentify_core-0.3.0/tests/test_verify_hooks.py +69 -0
- agentify_core-0.2.0/agentify/memory/stores/__init__.py +0 -5
- agentify_core-0.2.0/requirements.txt +0 -18
- {agentify_core-0.2.0 → agentify_core-0.3.0}/LICENSE +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/MANIFEST.in +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/core/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/core/agent.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/core/callbacks.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/core/config.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/core/tool.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/prompts/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/prompts/assistant.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/calculator.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/filesystem.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/planning.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/time.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/extensions/tools/weather.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/llm/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/llm/client.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/memory/policies.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/multi_agent/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/multi_agent/hierarchical.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/multi_agent/pipeline.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/multi_agent/team.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/multi_agent/tool_wrapper.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/utils/__init__.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify/utils/style.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify_core.egg-info/dependency_links.txt +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/agentify_core.egg-info/top_level.txt +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/setup.cfg +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/tests/test_filesystem_tools.py +0 -0
- {agentify_core-0.2.0 → agentify_core-0.3.0}/tests/test_planning_tool.py +0 -0
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentify-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Framework-agnostic AI agent library for building single and multi-agent systems
|
|
5
|
-
Author-email: Fabian M <
|
|
5
|
+
Author-email: Fabian M <fabianmp_98@hotmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/fa8i/Agentify
|
|
8
8
|
Project-URL: Repository, https://github.com/fa8i/Agentify
|
|
9
9
|
Project-URL: Bug Tracker, https://github.com/fa8i/Agentify/issues
|
|
10
|
-
Keywords: agent,multi-agent,ai,llm,openai,framework
|
|
10
|
+
Keywords: agentify,agentify-core,agent,multi-agent,ai,llm,openai,framework
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -22,19 +21,25 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
22
21
|
Requires-Python: >=3.10
|
|
23
22
|
Description-Content-Type: text/markdown
|
|
24
23
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: openai
|
|
26
|
-
Requires-Dist: python-dotenv
|
|
27
|
-
Requires-Dist: Pillow
|
|
24
|
+
Requires-Dist: openai
|
|
25
|
+
Requires-Dist: python-dotenv
|
|
26
|
+
Requires-Dist: Pillow
|
|
28
27
|
Provides-Extra: redis
|
|
29
28
|
Requires-Dist: redis>=4.0.0; extra == "redis"
|
|
29
|
+
Provides-Extra: elastic
|
|
30
|
+
Requires-Dist: elasticsearch>=8.0.0; extra == "elastic"
|
|
30
31
|
Provides-Extra: tools
|
|
31
32
|
Requires-Dist: requests>=2.25.0; extra == "tools"
|
|
33
|
+
Provides-Extra: mcp
|
|
34
|
+
Requires-Dist: mcp; extra == "mcp"
|
|
32
35
|
Provides-Extra: ui
|
|
33
36
|
Requires-Dist: gradio==5.49.1; extra == "ui"
|
|
34
37
|
Provides-Extra: all
|
|
35
38
|
Requires-Dist: redis>=4.0.0; extra == "all"
|
|
39
|
+
Requires-Dist: elasticsearch>=8.0.0; extra == "all"
|
|
36
40
|
Requires-Dist: requests>=2.25.0; extra == "all"
|
|
37
41
|
Requires-Dist: gradio==5.49.1; extra == "all"
|
|
42
|
+
Requires-Dist: mcp; extra == "all"
|
|
38
43
|
Provides-Extra: dev
|
|
39
44
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
40
45
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
@@ -47,6 +52,7 @@ Dynamic: license-file
|
|
|
47
52
|
|
|
48
53
|
# Agentify
|
|
49
54
|
|
|
55
|
+
|
|
50
56
|
**Independent AI agent library based on the OpenAI SDK**
|
|
51
57
|
|
|
52
58
|
Agentify is a Python library for building and orchestrating AI agents, from simple assistants to complex multi-agent systems. It targets the OpenAI-compatible Chat Completions interface, enabling support for multiple providers through a configurable `base_url` (OpenAI, Azure OpenAI, DeepSeek, Gemini, etc.). Agentify offers a streamlined, independent set of primitives for memory, tools, and coordination so you can focus on product logic without being tied to heavy frameworks.
|
|
@@ -1,40 +1,25 @@
|
|
|
1
1
|
# Agentify
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
[](https://pypi.org/project/agentify-core/)
|
|
4
|
+
[](https://pepy.tech/project/agentify-core)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://pypi.org/project/agentify-core/)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
**Independent AI agent library based on the OpenAI SDK**
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
- **Orchestration-first design**: a uniform `run()` interface for agents, teams, pipelines and hierarchies makes it straightforward to compose and refactor flows.
|
|
12
|
-
- **Providers**: switch between OpenAI, Gemini, Azure OpenAI, DeepSeek, Claude and others without changing your agent code.
|
|
10
|
+
Agentify is a Python library for building AI agents and multi-agent systems. Built on the OpenAI-compatible Chat Completions interface, it supports multiple providers (OpenAI, Azure, DeepSeek, Gemini, Claude) with clear abstractions for memory, tools, and orchestration—no heavy framework lock-in.
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
## Key Features
|
|
16
14
|
|
|
17
|
-
- **
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- **
|
|
24
|
-
|
|
25
|
-
"Chain of Thought" in conversation history, and log reasoning steps in real-time for visibility.
|
|
26
|
-
|
|
27
|
-
- **Tools and actions**
|
|
28
|
-
Simple `@tool` decorator for creating tools from functions with automatic JSON Schema generation, or type-annotated tool interface for custom implementations.
|
|
29
|
-
|
|
30
|
-
- **Observability hooks**
|
|
31
|
-
Callback system for logging, monitoring and debugging agent behaviour across complex flows.
|
|
32
|
-
|
|
33
|
-
- **I/O capabilities**
|
|
34
|
-
Streaming support for real-time responses and vision/image models for multimodal interactions.
|
|
35
|
-
|
|
36
|
-
- **Async & Parallel Execution**
|
|
37
|
-
Built-in `async/await` support (`arun()`) for all agents and flows. Automatically executes independent tool calls in parallel (e.g., fetching data from 3 APIs simultaneously), significantly reducing latency.
|
|
15
|
+
- **Multi-agent orchestration**: Teams, pipelines, hierarchies, and dynamic sub-agent spawning
|
|
16
|
+
- **Memory service**: Pluggable backends (in-memory, SQLite, Redis, Elasticsearch) with policies (TTL, limits, token budgets)
|
|
17
|
+
- **Tools**: `@tool` decorator for auto-schema generation, or custom tool classes. Built-in file I/O, planning, weather, and more
|
|
18
|
+
- **MCP Integration**: Easy connection to MCP servers via StdIO (local) or SSE/HTTP (remote) to use external tools
|
|
19
|
+
- **Reasoning models**: Configure thinking depth, store chain-of-thought, real-time reasoning logs
|
|
20
|
+
- **Async & parallel**: `arun()` support with automatic parallel tool and agent execution
|
|
21
|
+
- **Observability**: Callback system for monitoring and debugging
|
|
22
|
+
- **Advanced capabilities**: Dynamic workflows, file/directory operations, complex state management
|
|
38
23
|
|
|
39
24
|
|
|
40
25
|
## Installation
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Agentify
|
|
2
2
|
|
|
3
|
+
|
|
3
4
|
**Independent AI agent library based on the OpenAI SDK**
|
|
4
5
|
|
|
5
6
|
Agentify is a Python library for building and orchestrating AI agents, from simple assistants to complex multi-agent systems. It targets the OpenAI-compatible Chat Completions interface, enabling support for multiple providers through a configurable `base_url` (OpenAI, Azure OpenAI, DeepSeek, Gemini, etc.). Agentify offers a streamlined, independent set of primitives for memory, tools, and coordination so you can focus on product logic without being tied to heavy frameworks.
|
|
@@ -112,4 +113,4 @@ MIT License - see the repository for details.
|
|
|
112
113
|
|
|
113
114
|
## Author
|
|
114
115
|
|
|
115
|
-
**Fabian Melchor** - [fabianmp_98@hotmail.com](mailto:fabianmp_98@hotmail.com)
|
|
116
|
+
**Fabian Melchor** - [fabianmp_98@hotmail.com](mailto:fabianmp_98@hotmail.com)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Adapter to convert MCP tools into Agentify Tool objects."""
|
|
2
|
+
from typing import Any, Callable, List
|
|
3
|
+
from mcp import ClientSession
|
|
4
|
+
from agentify.core.tool import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def convert_mcp_tools_to_agentify(
|
|
8
|
+
session: ClientSession,
|
|
9
|
+
mcp_tools: List[Any],
|
|
10
|
+
) -> List[Tool]:
|
|
11
|
+
"""Transforms MCP tools into Agentify-compatible Tool objects."""
|
|
12
|
+
agentify_tools: List[Tool] = []
|
|
13
|
+
|
|
14
|
+
for m_tool in mcp_tools:
|
|
15
|
+
schema = {
|
|
16
|
+
"name": m_tool.name,
|
|
17
|
+
"description": m_tool.description or "",
|
|
18
|
+
"parameters": m_tool.inputSchema,
|
|
19
|
+
}
|
|
20
|
+
wrapper = _create_tool_wrapper(session, m_tool.name)
|
|
21
|
+
agentify_tools.append(Tool(schema=schema, func=wrapper))
|
|
22
|
+
|
|
23
|
+
return agentify_tools
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _create_tool_wrapper(session: ClientSession, tool_name: str) -> Callable[..., Any]:
|
|
27
|
+
"""Creates an async wrapper function that calls the MCP server."""
|
|
28
|
+
|
|
29
|
+
async def _mcp_tool_wrapper(**kwargs: Any) -> Any:
|
|
30
|
+
result = await session.call_tool(tool_name, arguments=kwargs)
|
|
31
|
+
|
|
32
|
+
output_parts = []
|
|
33
|
+
if result.content:
|
|
34
|
+
for item in result.content:
|
|
35
|
+
if item.type == "text":
|
|
36
|
+
output_parts.append(item.text)
|
|
37
|
+
elif item.type == "image":
|
|
38
|
+
output_parts.append(f"[Image: {item.mimeType}]")
|
|
39
|
+
elif item.type == "resource":
|
|
40
|
+
output_parts.append(f"[Resource: {item.resource.uri}]")
|
|
41
|
+
|
|
42
|
+
return "\n".join(output_parts)
|
|
43
|
+
|
|
44
|
+
_mcp_tool_wrapper.__name__ = tool_name
|
|
45
|
+
_mcp_tool_wrapper.__doc__ = f"MCP Tool: {tool_name}"
|
|
46
|
+
return _mcp_tool_wrapper
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Async Context Manager for MCP Client connections."""
|
|
2
|
+
import os
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack
|
|
6
|
+
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
from mcp.client.stdio import stdio_client
|
|
9
|
+
from mcp.client.sse import sse_client
|
|
10
|
+
|
|
11
|
+
from agentify.mcp.adapter import convert_mcp_tools_to_agentify
|
|
12
|
+
from agentify.core.tool import Tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Transport(Enum):
|
|
16
|
+
"""Internal enum for transport type."""
|
|
17
|
+
STDIO = auto()
|
|
18
|
+
SSE = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MCPConnection(AbstractAsyncContextManager):
|
|
22
|
+
"""Manages MCP server connections via StdIO or SSE transport.
|
|
23
|
+
|
|
24
|
+
Use the factory methods to create connections:
|
|
25
|
+
- `MCPConnection.stdio(...)` for local process servers
|
|
26
|
+
- `MCPConnection.sse(...)` for remote HTTP/SSE servers
|
|
27
|
+
|
|
28
|
+
Example (StdIO):
|
|
29
|
+
async with MCPConnection.stdio(command="uvx", args=["mcp-server-fetch"]) as mcp:
|
|
30
|
+
tools = await mcp.get_tools()
|
|
31
|
+
|
|
32
|
+
Example (SSE):
|
|
33
|
+
async with MCPConnection.sse(url="http://localhost:8080/sse") as mcp:
|
|
34
|
+
tools = await mcp.get_tools()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Private constructor. Use factory methods instead."""
|
|
39
|
+
self._transport: Optional[_Transport] = None
|
|
40
|
+
self._session: Optional[ClientSession] = None
|
|
41
|
+
self._exit_stack: Optional[AsyncExitStack] = None
|
|
42
|
+
# StdIO params
|
|
43
|
+
self._stdio_params: Optional[StdioServerParameters] = None
|
|
44
|
+
# SSE params
|
|
45
|
+
self._sse_url: Optional[str] = None
|
|
46
|
+
self._sse_headers: Optional[Dict[str, Any]] = None
|
|
47
|
+
self._sse_timeout: float = 5.0
|
|
48
|
+
self._sse_read_timeout: float = 300.0
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def stdio(
|
|
52
|
+
cls,
|
|
53
|
+
command: str,
|
|
54
|
+
args: List[str],
|
|
55
|
+
env: Optional[Dict[str, str]] = None
|
|
56
|
+
) -> "MCPConnection":
|
|
57
|
+
"""Create a connection to a local MCP server via StdIO.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
command: The command to run (e.g., "python", "uvx").
|
|
61
|
+
args: Arguments to pass to the command.
|
|
62
|
+
env: Optional environment variables for the process.
|
|
63
|
+
"""
|
|
64
|
+
instance = cls()
|
|
65
|
+
instance._transport = _Transport.STDIO
|
|
66
|
+
instance._stdio_params = StdioServerParameters(
|
|
67
|
+
command=command,
|
|
68
|
+
args=args,
|
|
69
|
+
env=env or os.environ.copy()
|
|
70
|
+
)
|
|
71
|
+
return instance
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def sse(
|
|
75
|
+
cls,
|
|
76
|
+
url: str,
|
|
77
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
78
|
+
timeout: float = 5.0,
|
|
79
|
+
sse_read_timeout: float = 300.0,
|
|
80
|
+
) -> "MCPConnection":
|
|
81
|
+
"""Create a connection to a remote MCP server via SSE/HTTP.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
url: The SSE endpoint URL.
|
|
85
|
+
headers: Optional HTTP headers (e.g., for authentication).
|
|
86
|
+
timeout: HTTP timeout for regular operations.
|
|
87
|
+
sse_read_timeout: Timeout for SSE read operations.
|
|
88
|
+
"""
|
|
89
|
+
instance = cls()
|
|
90
|
+
instance._transport = _Transport.SSE
|
|
91
|
+
instance._sse_url = url
|
|
92
|
+
instance._sse_headers = headers
|
|
93
|
+
instance._sse_timeout = timeout
|
|
94
|
+
instance._sse_read_timeout = sse_read_timeout
|
|
95
|
+
return instance
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> "MCPConnection":
|
|
98
|
+
self._exit_stack = AsyncExitStack()
|
|
99
|
+
try:
|
|
100
|
+
if self._transport == _Transport.STDIO:
|
|
101
|
+
read, write = await self._exit_stack.enter_async_context(
|
|
102
|
+
stdio_client(self._stdio_params)
|
|
103
|
+
)
|
|
104
|
+
elif self._transport == _Transport.SSE:
|
|
105
|
+
read, write = await self._exit_stack.enter_async_context(
|
|
106
|
+
sse_client(
|
|
107
|
+
url=self._sse_url,
|
|
108
|
+
headers=self._sse_headers,
|
|
109
|
+
timeout=self._sse_timeout,
|
|
110
|
+
sse_read_timeout=self._sse_read_timeout,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError("Transport not configured. Use MCPConnection.stdio() or MCPConnection.sse().")
|
|
115
|
+
|
|
116
|
+
self._session = await self._exit_stack.enter_async_context(
|
|
117
|
+
ClientSession(read, write)
|
|
118
|
+
)
|
|
119
|
+
await self._session.initialize()
|
|
120
|
+
return self
|
|
121
|
+
except Exception:
|
|
122
|
+
await self.aclose()
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
126
|
+
await self.aclose()
|
|
127
|
+
|
|
128
|
+
async def aclose(self) -> None:
|
|
129
|
+
"""Gracefully closes the MCP connection."""
|
|
130
|
+
if self._exit_stack:
|
|
131
|
+
await self._exit_stack.aclose()
|
|
132
|
+
self._exit_stack = None
|
|
133
|
+
self._session = None
|
|
134
|
+
|
|
135
|
+
async def get_tools(self) -> List[Tool]:
|
|
136
|
+
"""Fetches tools from the MCP server and converts them to Agentify format."""
|
|
137
|
+
if not self._session:
|
|
138
|
+
raise RuntimeError("MCPConnection is not active. Use 'async with ...'")
|
|
139
|
+
|
|
140
|
+
result = await self._session.list_tools()
|
|
141
|
+
return await convert_mcp_tools_to_agentify(self._session, result.tools)
|
|
@@ -17,8 +17,7 @@ class MemoryAddress:
|
|
|
17
17
|
user_id: Optional[str] = None
|
|
18
18
|
conversation_id: Optional[str] = None
|
|
19
19
|
agent_id: Optional[str] = None
|
|
20
|
-
# extras lets
|
|
21
|
-
extras: Tuple[Tuple[str, str], ...] = ()
|
|
20
|
+
extras: Tuple[Tuple[str, str], ...] = () # extras lets pass stable routing dimensions if needed.
|
|
22
21
|
|
|
23
22
|
def as_tuple(self) -> Tuple:
|
|
24
23
|
"""Stable, hashable representation for keys and indexing."""
|
|
@@ -94,6 +93,7 @@ class ConversationStore(Protocol):
|
|
|
94
93
|
def replace_messages(self, addr: MemoryAddress, messages: List[Message]) -> None: ...
|
|
95
94
|
def delete_conversation(self, addr: MemoryAddress) -> None: ...
|
|
96
95
|
def set_ttl(self, addr: MemoryAddress, seconds: int) -> None: ...
|
|
96
|
+
def list_conversations(self, limit: int = 100, offset: int = 0) -> List[MemoryAddress]: ...
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
class TokenCounter(Protocol):
|
|
@@ -69,7 +69,7 @@ class MemoryService:
|
|
|
69
69
|
|
|
70
70
|
color = role_colors.get(msg.role, Colors.RESET)
|
|
71
71
|
|
|
72
|
-
# Extract agent_id if available to show
|
|
72
|
+
# Extract agent_id if available to show who is speaking
|
|
73
73
|
agent_id = addr.agent_id if addr and addr.agent_id else "unknown"
|
|
74
74
|
agent_tag = f"[{agent_id}]" if agent_id else ""
|
|
75
75
|
|
|
@@ -124,3 +124,7 @@ class MemoryService:
|
|
|
124
124
|
def delete_history(self, addr: MemoryAddress) -> None:
|
|
125
125
|
"""Remove all messages for the given address."""
|
|
126
126
|
self.store.delete_conversation(addr)
|
|
127
|
+
|
|
128
|
+
def list_conversations(self, limit: int = 100, offset: int = 0) -> List[MemoryAddress]:
|
|
129
|
+
"""List active conversations from the underlying store."""
|
|
130
|
+
return self.store.list_conversations(limit=limit, offset=offset)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Memory storage backends."""
|
|
2
|
+
from .in_memory_store import InMemoryStore
|
|
3
|
+
from .redis_store import RedisStore
|
|
4
|
+
from .elastic_store import ElasticsearchStore
|
|
5
|
+
from .sqlite_store import SQLiteStore
|
|
6
|
+
|
|
7
|
+
__all__ = ["InMemoryStore", "RedisStore", "ElasticsearchStore", "SQLiteStore"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Optional, Any, Dict
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from elasticsearch import Elasticsearch
|
|
9
|
+
from elasticsearch.helpers import bulk
|
|
10
|
+
except ImportError:
|
|
11
|
+
Elasticsearch = None # type: ignore
|
|
12
|
+
bulk = None # type: ignore
|
|
13
|
+
|
|
14
|
+
from ..interfaces import ConversationStore, MemoryAddress, Message
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ElasticsearchStore(ConversationStore):
|
|
20
|
+
"""
|
|
21
|
+
Elasticsearch-backed store.
|
|
22
|
+
Stores messages as individual documents to enable advanced search.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str = "http://localhost:9200",
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
index_name: str = "agentify-memory",
|
|
30
|
+
verify_certs: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
if Elasticsearch is None:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"Elasticsearch is not installed. Please install agentify[elastic] or 'pip install elasticsearch'."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Connection setup
|
|
38
|
+
options = {}
|
|
39
|
+
if api_key:
|
|
40
|
+
options["api_key"] = api_key
|
|
41
|
+
|
|
42
|
+
self.client = Elasticsearch(
|
|
43
|
+
url,
|
|
44
|
+
verify_certs=verify_certs,
|
|
45
|
+
**options
|
|
46
|
+
)
|
|
47
|
+
self.index_name = index_name
|
|
48
|
+
self._ensure_index()
|
|
49
|
+
|
|
50
|
+
def _ensure_index(self) -> None:
|
|
51
|
+
"""Create index with optimal mappings if it doesn't exist."""
|
|
52
|
+
if self.client.indices.exists(index=self.index_name):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
mapping = {
|
|
56
|
+
"mappings": {
|
|
57
|
+
"properties": {
|
|
58
|
+
# Address fields (Exact match)
|
|
59
|
+
"api_version": {"type": "keyword"},
|
|
60
|
+
"tenant_id": {"type": "keyword"},
|
|
61
|
+
"user_id": {"type": "keyword"},
|
|
62
|
+
"conversation_id": {"type": "keyword"},
|
|
63
|
+
"agent_id": {"type": "keyword"},
|
|
64
|
+
|
|
65
|
+
# Message fields
|
|
66
|
+
"role": {"type": "keyword"},
|
|
67
|
+
"content": {"type": "text"}, # Full-text searchable
|
|
68
|
+
"name": {"type": "keyword"},
|
|
69
|
+
"id": {"type": "keyword"},
|
|
70
|
+
"ts": {"type": "date", "format": "epoch_second"},
|
|
71
|
+
|
|
72
|
+
# Structured Objects
|
|
73
|
+
"metadata": {"type": "object", "enabled": True},
|
|
74
|
+
"address_key": {"type": "keyword"}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try:
|
|
79
|
+
self.client.indices.create(index=self.index_name, body=mapping)
|
|
80
|
+
logger.info(f"Created Elasticsearch index: {self.index_name}")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error(f"Failed to create index {self.index_name}: {e}")
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def _addr_to_doc(self, addr: MemoryAddress, msg: Message) -> Dict[str, Any]:
|
|
86
|
+
"""Flatten address + message into a single document."""
|
|
87
|
+
base = msg.to_dict()
|
|
88
|
+
# Add filtering fields
|
|
89
|
+
base.update({
|
|
90
|
+
"api_version": addr.api_version,
|
|
91
|
+
"tenant_id": addr.tenant_id,
|
|
92
|
+
"user_id": addr.user_id,
|
|
93
|
+
"conversation_id": addr.conversation_id,
|
|
94
|
+
"agent_id": addr.agent_id,
|
|
95
|
+
"address_key": addr.key_str(),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return base
|
|
99
|
+
|
|
100
|
+
def _build_filter_query(self, addr: MemoryAddress) -> List[Dict[str, Any]]:
|
|
101
|
+
"""Construct bool/filter clauses for the exact address."""
|
|
102
|
+
must = []
|
|
103
|
+
if addr.api_version:
|
|
104
|
+
must.append({"term": {"api_version": addr.api_version}})
|
|
105
|
+
if addr.tenant_id:
|
|
106
|
+
must.append({"term": {"tenant_id": addr.tenant_id}})
|
|
107
|
+
if addr.user_id:
|
|
108
|
+
must.append({"term": {"user_id": addr.user_id}})
|
|
109
|
+
if addr.conversation_id:
|
|
110
|
+
must.append({"term": {"conversation_id": addr.conversation_id}})
|
|
111
|
+
if addr.agent_id:
|
|
112
|
+
must.append({"term": {"agent_id": addr.agent_id}})
|
|
113
|
+
return must
|
|
114
|
+
|
|
115
|
+
def append_message(self, addr: MemoryAddress, msg: Message) -> None:
|
|
116
|
+
doc = self._addr_to_doc(addr, msg)
|
|
117
|
+
self.client.index(index=self.index_name, id=msg.id, document=doc, refresh=True)
|
|
118
|
+
# refresh='true' makes it visible immediately (good for chat consistency, possibly slower for bulk)
|
|
119
|
+
|
|
120
|
+
def read_messages(self, addr: MemoryAddress, start: int = 0, end: int = -1) -> List[Message]:
|
|
121
|
+
must = self._build_filter_query(addr)
|
|
122
|
+
|
|
123
|
+
# We need to fetch enough messages to support the slice.
|
|
124
|
+
# Since 'end' can be -1 (all), we might need a large size or scroll.
|
|
125
|
+
# For chat history, usually 100-1000 is enough.
|
|
126
|
+
size = 1000 if end == -1 else (end + 20)
|
|
127
|
+
|
|
128
|
+
query = {
|
|
129
|
+
"query": {"bool": {"filter": must}},
|
|
130
|
+
"sort": [{"ts": {"order": "asc"}}],
|
|
131
|
+
"size": size,
|
|
132
|
+
"from": start
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
resp = self.client.search(index=self.index_name, body=query)
|
|
136
|
+
hits = resp["hits"]["hits"]
|
|
137
|
+
|
|
138
|
+
msgs = []
|
|
139
|
+
for h in hits:
|
|
140
|
+
src = h["_source"]
|
|
141
|
+
# Extract only message fields to reconstruct
|
|
142
|
+
m_kwargs = {
|
|
143
|
+
"role": src.get("role"),
|
|
144
|
+
"content": src.get("content"),
|
|
145
|
+
"name": src.get("name"),
|
|
146
|
+
"tool_call_id": src.get("tool_call_id"),
|
|
147
|
+
"metadata": src.get("metadata", {}),
|
|
148
|
+
"id": src.get("id"),
|
|
149
|
+
"ts": int(src.get("ts", 0))
|
|
150
|
+
}
|
|
151
|
+
msgs.append(Message(**m_kwargs))
|
|
152
|
+
|
|
153
|
+
# Slice locally if specific end requested
|
|
154
|
+
if end != -1:
|
|
155
|
+
limit = end - start + 1
|
|
156
|
+
msgs = msgs[:limit]
|
|
157
|
+
|
|
158
|
+
return msgs
|
|
159
|
+
|
|
160
|
+
def replace_messages(self, addr: MemoryAddress, messages: List[Message]) -> None:
|
|
161
|
+
# 1. Delete existing for this address
|
|
162
|
+
self.delete_conversation(addr)
|
|
163
|
+
|
|
164
|
+
if not messages:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# 2. Bulk insert new
|
|
168
|
+
actions = []
|
|
169
|
+
for msg in messages:
|
|
170
|
+
doc = self._addr_to_doc(addr, msg)
|
|
171
|
+
actions.append({
|
|
172
|
+
"_index": self.index_name,
|
|
173
|
+
"_id": msg.id,
|
|
174
|
+
"_source": doc
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if actions:
|
|
178
|
+
bulk(self.client, actions, refresh=True)
|
|
179
|
+
|
|
180
|
+
def delete_conversation(self, addr: MemoryAddress) -> None:
|
|
181
|
+
must = self._build_filter_query(addr)
|
|
182
|
+
query = {"query": {"bool": {"filter": must}}}
|
|
183
|
+
self.client.delete_by_query(index=self.index_name, body=query, refresh=True)
|
|
184
|
+
|
|
185
|
+
def set_ttl(self, addr: MemoryAddress, seconds: int) -> None:
|
|
186
|
+
# Implementing TTL in ES usually requires Index Lifecycle Management (ILM)
|
|
187
|
+
# or a separate cleanup job, as TTL is per-index or per-document requires setup.
|
|
188
|
+
# For simplicity, we log strictly: This is not natively supported per-conversation efficiently.
|
|
189
|
+
logger.warning("set_ttl is not fully supported in simple ElasticsearchStore yet.")
|
|
190
|
+
|
|
191
|
+
def list_conversations(self, limit: int = 100, offset: int = 0) -> List[MemoryAddress]:
|
|
192
|
+
"""
|
|
193
|
+
List unique conversation addresses using aggregations.
|
|
194
|
+
Uses a terms aggregation on 'address_key'.
|
|
195
|
+
"""
|
|
196
|
+
size = offset + limit
|
|
197
|
+
query = {
|
|
198
|
+
"size": 0,
|
|
199
|
+
"aggs": {
|
|
200
|
+
"unique_conversations": {
|
|
201
|
+
"terms": {
|
|
202
|
+
"field": "address_key",
|
|
203
|
+
"size": size
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
resp = self.client.search(index=self.index_name, body=query)
|
|
210
|
+
buckets = resp["aggregations"]["unique_conversations"]["buckets"]
|
|
211
|
+
|
|
212
|
+
# Apply offset/limit locally
|
|
213
|
+
buckets_slice = buckets[offset : offset + limit]
|
|
214
|
+
|
|
215
|
+
results = []
|
|
216
|
+
for b in buckets_slice:
|
|
217
|
+
k = b["key"]
|
|
218
|
+
core = k
|
|
219
|
+
prefix_check = "mem:"
|
|
220
|
+
if core.startswith(prefix_check):
|
|
221
|
+
core = core[len(prefix_check):]
|
|
222
|
+
|
|
223
|
+
parts = core.split(":")
|
|
224
|
+
kwargs = {}
|
|
225
|
+
extras = []
|
|
226
|
+
|
|
227
|
+
for part in parts:
|
|
228
|
+
if "=" not in part:
|
|
229
|
+
continue
|
|
230
|
+
key, val = part.split("=", 1)
|
|
231
|
+
if key == "v": kwargs["api_version"] = val
|
|
232
|
+
elif key == "t": kwargs["tenant_id"] = val
|
|
233
|
+
elif key == "u": kwargs["user_id"] = val
|
|
234
|
+
elif key == "c": kwargs["conversation_id"] = val
|
|
235
|
+
elif key == "a": kwargs["agent_id"] = val
|
|
236
|
+
else:
|
|
237
|
+
extras.append((key, val))
|
|
238
|
+
|
|
239
|
+
if extras:
|
|
240
|
+
kwargs["extras"] = tuple(extras)
|
|
241
|
+
|
|
242
|
+
results.append(MemoryAddress(**kwargs))
|
|
243
|
+
|
|
244
|
+
return results
|
|
@@ -27,3 +27,9 @@ class InMemoryStore(ConversationStore):
|
|
|
27
27
|
|
|
28
28
|
def set_ttl(self, addr: MemoryAddress, seconds: int) -> None:
|
|
29
29
|
self._ttl[addr] = seconds # not enforced in-memory
|
|
30
|
+
|
|
31
|
+
def list_conversations(self, limit: int = 100, offset: int = 0) -> List[MemoryAddress]:
|
|
32
|
+
keys = list(self._history.keys())
|
|
33
|
+
# Sort for stability
|
|
34
|
+
keys.sort(key=lambda k: k.key_str())
|
|
35
|
+
return keys[offset : offset + limit]
|