agentify-core 0.3.0__tar.gz → 0.3.1__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.3.0/agentify_core.egg-info → agentify_core-0.3.1}/PKG-INFO +4 -2
- {agentify_core-0.3.0 → agentify_core-0.3.1}/README.md +6 -3
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/__init__.py +1 -1
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/agent.py +66 -7
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/callbacks.py +32 -4
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/config.py +1 -0
- agentify_core-0.3.1/agentify/core/runnable.py +20 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/tool.py +2 -2
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/filesystem.py +37 -12
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/llm/client.py +1 -1
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/interfaces.py +21 -7
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/service.py +26 -11
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/elastic_store.py +14 -12
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/redis_store.py +9 -6
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/sqlite_store.py +9 -6
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/hierarchical.py +7 -2
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/pipeline.py +32 -35
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/team.py +20 -11
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/tool_wrapper.py +6 -3
- {agentify_core-0.3.0 → agentify_core-0.3.1/agentify_core.egg-info}/PKG-INFO +4 -2
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/SOURCES.txt +3 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/requires.txt +1 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/pyproject.toml +4 -2
- {agentify_core-0.3.0 → agentify_core-0.3.1}/requirements.txt +1 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_filesystem_tools.py +15 -1
- agentify_core-0.3.1/tests/test_memory_address.py +26 -0
- agentify_core-0.3.1/tests/test_memory_logging.py +58 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_planning_tool.py +1 -1
- {agentify_core-0.3.0 → agentify_core-0.3.1}/LICENSE +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/MANIFEST.in +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/README_PYPI.md +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/prompts/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/prompts/assistant.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/calculator.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/planning.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/time.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/weather.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/llm/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/adapter.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/client.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/policies.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/in_memory_store.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/utils/__init__.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/utils/style.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/dependency_links.txt +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/top_level.txt +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/setup.cfg +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_mcp.py +0 -0
- {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_verify_hooks.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentify-core
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Framework-agnostic AI agent library for building single and multi-agent systems
|
|
5
|
-
Author
|
|
5
|
+
Author: Fabian M
|
|
6
|
+
Author-email: fabianmp_98@hotmail.com
|
|
6
7
|
License: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/fa8i/Agentify
|
|
8
9
|
Project-URL: Repository, https://github.com/fa8i/Agentify
|
|
@@ -24,6 +25,7 @@ License-File: LICENSE
|
|
|
24
25
|
Requires-Dist: openai
|
|
25
26
|
Requires-Dist: python-dotenv
|
|
26
27
|
Requires-Dist: Pillow
|
|
28
|
+
Requires-Dist: jsonschema>=4.0.0
|
|
27
29
|
Provides-Extra: redis
|
|
28
30
|
Requires-Dist: redis>=4.0.0; extra == "redis"
|
|
29
31
|
Provides-Extra: elastic
|
|
@@ -33,9 +33,11 @@ For optional features:
|
|
|
33
33
|
pip install agentify-core[all] # Installs all optional dependencies
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
### Quick Start
|
|
38
37
|
```python
|
|
38
|
+
# Note: Agentify does not auto-load .env. Load it manually if needed.
|
|
39
|
+
# from dotenv import load_dotenv; load_dotenv()
|
|
40
|
+
|
|
39
41
|
from agentify import BaseAgent, AgentConfig, MemoryService, MemoryAddress, tool
|
|
40
42
|
from agentify.memory.stores import InMemoryStore
|
|
41
43
|
|
|
@@ -58,7 +60,8 @@ agent = BaseAgent(
|
|
|
58
60
|
provider="provider",
|
|
59
61
|
model_name="model",
|
|
60
62
|
reasoning_effort="high", # optional param:"low", "medium", "high"
|
|
61
|
-
model_kwargs={"max_completion_tokens": 5000} # Pass model-specific params
|
|
63
|
+
model_kwargs={"max_completion_tokens": 5000}, # Pass model-specific params
|
|
64
|
+
verbose=True, # Controls logging (True by default)
|
|
62
65
|
),
|
|
63
66
|
memory=memory,
|
|
64
67
|
memory_address=addr,
|
|
@@ -9,8 +9,11 @@ from io import BytesIO
|
|
|
9
9
|
import inspect
|
|
10
10
|
from typing import Any, Dict, Generator, List, Optional, Union, Iterator, Callable, AsyncGenerator
|
|
11
11
|
|
|
12
|
+
from agentify.core.runnable import Runnable
|
|
13
|
+
|
|
12
14
|
from PIL import Image
|
|
13
15
|
from openai import RateLimitError
|
|
16
|
+
from jsonschema import validate, ValidationError
|
|
14
17
|
|
|
15
18
|
from agentify.core.tool import Tool
|
|
16
19
|
from agentify.llm.client import LLMClientFactory, LLMClientType, AsyncLLMClientType
|
|
@@ -22,12 +25,15 @@ from agentify.core.callbacks import LoggingCallbackHandler
|
|
|
22
25
|
logger = logging.getLogger(__name__)
|
|
23
26
|
|
|
24
27
|
|
|
25
|
-
class BaseAgent:
|
|
28
|
+
class BaseAgent(Runnable):
|
|
26
29
|
"""Core AI Agent class based on chat completions interface.
|
|
27
30
|
|
|
28
31
|
BaseAgent is the primary abstraction for building AI agents in Agentify. It provides
|
|
29
32
|
a unified interface for interacting with various LLM providers (OpenAI, Azure, DeepSeek,
|
|
30
33
|
Gemini, etc.) that implement the OpenAI SDK-compatible chat completions format.
|
|
34
|
+
|
|
35
|
+
It implements the Runnable protocol, making it composable within pipelines and teams.
|
|
36
|
+
|
|
31
37
|
|
|
32
38
|
The agent orchestrates the interaction between users, LLMs, and registered tools, managing
|
|
33
39
|
conversation history, tool execution, and model responses. It supports both synchronous
|
|
@@ -100,7 +106,7 @@ class BaseAgent:
|
|
|
100
106
|
|
|
101
107
|
# Decouple callbacks from config to avoid mutation of shared config
|
|
102
108
|
self.callbacks = list(self.config.callbacks) if self.config.callbacks else []
|
|
103
|
-
if not self.callbacks:
|
|
109
|
+
if not self.callbacks and self.config.verbose:
|
|
104
110
|
self.callbacks.append(LoggingCallbackHandler(logger))
|
|
105
111
|
|
|
106
112
|
self._tools: Dict[str, Tool] = {t.name: t for t in tools or []}
|
|
@@ -411,6 +417,38 @@ class BaseAgent:
|
|
|
411
417
|
)
|
|
412
418
|
raise ValueError(f"Invalid JSON arguments: {exc}")
|
|
413
419
|
|
|
420
|
+
def _validate_tool_arguments(self, tool: Tool, arguments: Dict[str, Any]) -> None:
|
|
421
|
+
"""Validate tool arguments against the tool's JSON schema."""
|
|
422
|
+
if not isinstance(arguments, dict):
|
|
423
|
+
raise ValueError(f"Tool '{tool.name}' arguments must be a JSON object.")
|
|
424
|
+
|
|
425
|
+
params_schema = tool.schema.get("parameters") or {"type": "object"}
|
|
426
|
+
if "type" not in params_schema:
|
|
427
|
+
params_schema = {"type": "object", **params_schema}
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
validate(instance=arguments, schema=params_schema)
|
|
431
|
+
except ValidationError as exc:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"Tool '{tool.name}' arguments failed schema validation: {exc.message}"
|
|
434
|
+
) from exc
|
|
435
|
+
|
|
436
|
+
def _serialize_tool_result(self, result: Any) -> str:
|
|
437
|
+
"""Normalize tool results to a JSON string when possible."""
|
|
438
|
+
if isinstance(result, bytes):
|
|
439
|
+
try:
|
|
440
|
+
return result.decode("utf-8")
|
|
441
|
+
except UnicodeDecodeError:
|
|
442
|
+
return base64.b64encode(result).decode("utf-8")
|
|
443
|
+
|
|
444
|
+
if isinstance(result, (dict, list)):
|
|
445
|
+
try:
|
|
446
|
+
return json.dumps(result, ensure_ascii=False)
|
|
447
|
+
except TypeError:
|
|
448
|
+
return json.dumps({"result": str(result)}, ensure_ascii=False)
|
|
449
|
+
|
|
450
|
+
return str(result)
|
|
451
|
+
|
|
414
452
|
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
415
453
|
"""Execute a single tool and return its output as a string."""
|
|
416
454
|
tool = self._tools.get(tool_name)
|
|
@@ -425,8 +463,9 @@ class BaseAgent:
|
|
|
425
463
|
return err_msg
|
|
426
464
|
|
|
427
465
|
try:
|
|
466
|
+
self._validate_tool_arguments(tool, arguments)
|
|
428
467
|
result = tool(**arguments)
|
|
429
|
-
result_str =
|
|
468
|
+
result_str = self._serialize_tool_result(result)
|
|
430
469
|
for cb in self.callbacks:
|
|
431
470
|
cb.on_tool_finish(tool_name, result_str)
|
|
432
471
|
return result_str
|
|
@@ -656,7 +695,7 @@ class BaseAgent:
|
|
|
656
695
|
assembled_tool_calls = self._expand_tool_calls(assembled_tool_calls)
|
|
657
696
|
full_turn_content = "".join(current_turn_content_parts)
|
|
658
697
|
|
|
659
|
-
#
|
|
698
|
+
# Exit if no tool calls are present
|
|
660
699
|
if not assembled_tool_calls:
|
|
661
700
|
# Add reasoning to metadata if present
|
|
662
701
|
msg_kwargs = {}
|
|
@@ -721,6 +760,7 @@ class BaseAgent:
|
|
|
721
760
|
addr: Optional[MemoryAddress] = None,
|
|
722
761
|
image_path: Optional[str] = None,
|
|
723
762
|
image_detail_override: Optional[str] = None,
|
|
763
|
+
**kwargs: Any,
|
|
724
764
|
) -> Union[str, Generator[str, None, None]]:
|
|
725
765
|
"""Main entrypoint to interact with the agent.
|
|
726
766
|
|
|
@@ -729,10 +769,15 @@ class BaseAgent:
|
|
|
729
769
|
addr: The memory address for the conversation.
|
|
730
770
|
image_path: Optional path to an image file.
|
|
731
771
|
image_detail_override: Optional detail level for image processing.
|
|
772
|
+
**kwargs: Additional arguments for compatibility.
|
|
732
773
|
|
|
733
774
|
Returns:
|
|
734
775
|
The agent's response as a string or a generator if streaming is enabled.
|
|
735
776
|
"""
|
|
777
|
+
# If addr is not provided, try to get it from kwargs (Protocol compatibility)
|
|
778
|
+
if addr is None and "memory_address" in kwargs:
|
|
779
|
+
addr = kwargs["memory_address"]
|
|
780
|
+
|
|
736
781
|
a = self._addr_or_raise(addr)
|
|
737
782
|
response_generator = self._execute_agent_loop(
|
|
738
783
|
user_input,
|
|
@@ -852,6 +897,7 @@ class BaseAgent:
|
|
|
852
897
|
return err_msg
|
|
853
898
|
|
|
854
899
|
try:
|
|
900
|
+
self._validate_tool_arguments(tool, arguments)
|
|
855
901
|
# Check for async_func attribute (used by AgentTool, FlowTool, SpawnAgentTool)
|
|
856
902
|
if hasattr(tool, "async_func") and asyncio.iscoroutinefunction(tool.async_func):
|
|
857
903
|
result = await tool.async_func(**arguments)
|
|
@@ -863,7 +909,7 @@ class BaseAgent:
|
|
|
863
909
|
result = await asyncio.get_event_loop().run_in_executor(
|
|
864
910
|
None, lambda: tool(**arguments)
|
|
865
911
|
)
|
|
866
|
-
result_str =
|
|
912
|
+
result_str = self._serialize_tool_result(result)
|
|
867
913
|
for cb in self.callbacks:
|
|
868
914
|
cb.on_tool_finish(tool_name, result_str)
|
|
869
915
|
return result_str
|
|
@@ -1004,7 +1050,7 @@ class BaseAgent:
|
|
|
1004
1050
|
assembled_tool_calls = self._expand_tool_calls(assembled_tool_calls)
|
|
1005
1051
|
full_turn_content = "".join(current_turn_content_parts)
|
|
1006
1052
|
|
|
1007
|
-
#
|
|
1053
|
+
# Exit if no tool calls are present
|
|
1008
1054
|
if not assembled_tool_calls:
|
|
1009
1055
|
msg_kwargs = {}
|
|
1010
1056
|
if full_reasoning_content:
|
|
@@ -1032,7 +1078,13 @@ class BaseAgent:
|
|
|
1032
1078
|
args_str = tc["function"]["arguments"]
|
|
1033
1079
|
try:
|
|
1034
1080
|
args = self._parse_tool_arguments(tool_name, args_str)
|
|
1035
|
-
|
|
1081
|
+
# Add timeout to prevent indefinite hangs
|
|
1082
|
+
result_content = await asyncio.wait_for(
|
|
1083
|
+
self._aexecute_tool(tool_name, args),
|
|
1084
|
+
timeout=60.0 # Default 60s timeout for tools
|
|
1085
|
+
)
|
|
1086
|
+
except asyncio.TimeoutError:
|
|
1087
|
+
result_content = json.dumps({"error": f"Tool '{tool_name}' execution timed out after 60 seconds."})
|
|
1036
1088
|
except ValueError as e:
|
|
1037
1089
|
result_content = json.dumps({"error": str(e)})
|
|
1038
1090
|
return tool_call_id, tool_name, result_content
|
|
@@ -1071,6 +1123,7 @@ class BaseAgent:
|
|
|
1071
1123
|
addr: Optional[MemoryAddress] = None,
|
|
1072
1124
|
image_path: Optional[str] = None,
|
|
1073
1125
|
image_detail_override: Optional[str] = None,
|
|
1126
|
+
**kwargs: Any,
|
|
1074
1127
|
) -> Union[str, AsyncGenerator[str, None]]:
|
|
1075
1128
|
"""Async entrypoint to interact with the agent.
|
|
1076
1129
|
|
|
@@ -1083,11 +1136,17 @@ class BaseAgent:
|
|
|
1083
1136
|
addr: The memory address for the conversation.
|
|
1084
1137
|
image_path: Optional path to an image file.
|
|
1085
1138
|
image_detail_override: Optional detail level for image processing.
|
|
1139
|
+
**kwargs: Additional arguments for compatibility.
|
|
1086
1140
|
|
|
1087
1141
|
Returns:
|
|
1088
1142
|
The agent's response as a string or an async generator if streaming is enabled.
|
|
1089
1143
|
"""
|
|
1144
|
+
# If addr is not provided, try to get it from kwargs (Protocol compatibility)
|
|
1145
|
+
if addr is None and "memory_address" in kwargs:
|
|
1146
|
+
addr = kwargs["memory_address"]
|
|
1147
|
+
|
|
1090
1148
|
a = self._addr_or_raise(addr)
|
|
1149
|
+
|
|
1091
1150
|
response_generator = self._aexecute_agent_loop(
|
|
1092
1151
|
user_input,
|
|
1093
1152
|
addr=a,
|
|
@@ -50,20 +50,48 @@ class LoggingCallbackHandler(AgentCallbackHandler):
|
|
|
50
50
|
|
|
51
51
|
def __init__(self, logger_instance: Optional[logging.Logger] = None):
|
|
52
52
|
self.logger = logger_instance or logger
|
|
53
|
+
self.redact_keys = {"password", "api_key", "token", "secret", "key", "authorization"}
|
|
54
|
+
|
|
55
|
+
# Auto-configure handler if none exists
|
|
56
|
+
if not self.logger.handlers:
|
|
57
|
+
handler = logging.StreamHandler()
|
|
58
|
+
handler.setLevel(logging.INFO)
|
|
59
|
+
self.logger.addHandler(handler)
|
|
60
|
+
self.logger.setLevel(logging.INFO)
|
|
61
|
+
|
|
62
|
+
def _mask_secrets(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
63
|
+
"""Recursively mask sensitive keys in a dictionary."""
|
|
64
|
+
masked = {}
|
|
65
|
+
for k, v in data.items():
|
|
66
|
+
if k.lower() in self.redact_keys:
|
|
67
|
+
masked[k] = "******"
|
|
68
|
+
elif isinstance(v, dict):
|
|
69
|
+
masked[k] = self._mask_secrets(v)
|
|
70
|
+
else:
|
|
71
|
+
masked[k] = v
|
|
72
|
+
return masked
|
|
53
73
|
|
|
54
74
|
def on_agent_start(self, agent_name: str, user_input: str) -> None:
|
|
55
|
-
self.logger.info(
|
|
75
|
+
self.logger.info(
|
|
76
|
+
f"{Colors.BLUE}[VERBOSE] Agent '{agent_name}' started.{Colors.RESET} Input: {user_input[:100]}..."
|
|
77
|
+
)
|
|
56
78
|
|
|
57
79
|
def on_agent_finish(self, agent_name: str, response: str) -> None:
|
|
58
80
|
self.logger.info(
|
|
59
|
-
f"Agent '{agent_name}' finished. Response: {response[:100]}..."
|
|
81
|
+
f"{Colors.BLUE}[VERBOSE] Agent '{agent_name}' finished.{Colors.RESET} Response: {response[:100]}..."
|
|
60
82
|
)
|
|
61
83
|
|
|
62
84
|
def on_tool_start(self, tool_name: str, args: Dict[str, Any]) -> None:
|
|
63
|
-
|
|
85
|
+
# Redact sensitive values
|
|
86
|
+
safe_args = self._mask_secrets(args)
|
|
87
|
+
self.logger.info(
|
|
88
|
+
f"{Colors.CYAN}[VERBOSE] Tool '{tool_name}' started.{Colors.RESET} Args: {safe_args}"
|
|
89
|
+
)
|
|
64
90
|
|
|
65
91
|
def on_tool_finish(self, tool_name: str, output: str) -> None:
|
|
66
|
-
self.logger.info(
|
|
92
|
+
self.logger.info(
|
|
93
|
+
f"{Colors.CYAN}[VERBOSE] Tool '{tool_name}' finished.{Colors.RESET} Output: {output[:100]}..."
|
|
94
|
+
)
|
|
67
95
|
|
|
68
96
|
def on_llm_start(self, model_name: str, messages: List[Dict[str, Any]]) -> None:
|
|
69
97
|
self.logger.debug(f"LLM '{model_name}' started. Messages: {len(messages)}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Protocol, Any, Dict, Optional, Generator, AsyncGenerator, Union
|
|
2
|
+
|
|
3
|
+
class Runnable(Protocol):
|
|
4
|
+
"""Standard interface for any chainable unit/agent in Agentify."""
|
|
5
|
+
|
|
6
|
+
def run(
|
|
7
|
+
self,
|
|
8
|
+
user_input: str,
|
|
9
|
+
**kwargs: Any
|
|
10
|
+
) -> Union[str, Generator[str, None, None]]:
|
|
11
|
+
"""Synchronous execution."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
async def arun(
|
|
15
|
+
self,
|
|
16
|
+
user_input: str,
|
|
17
|
+
**kwargs: Any
|
|
18
|
+
) -> Union[str, AsyncGenerator[str, None]]:
|
|
19
|
+
"""Asynchronous execution."""
|
|
20
|
+
...
|
|
@@ -19,10 +19,10 @@ class Tool:
|
|
|
19
19
|
def name(self) -> str:
|
|
20
20
|
return self.schema["name"]
|
|
21
21
|
|
|
22
|
-
def __call__(self, **kwargs: Any) -> str:
|
|
22
|
+
def __call__(self, *args: Any, **kwargs: Any) -> str:
|
|
23
23
|
"""Executes the function and returns JSON or string; captures generic errors."""
|
|
24
24
|
try:
|
|
25
|
-
result = self.func(**kwargs)
|
|
25
|
+
result = self.func(*args, **kwargs)
|
|
26
26
|
except Exception as exc: # noqa: BLE001
|
|
27
27
|
return json.dumps({"error": str(exc)}, ensure_ascii=False)
|
|
28
28
|
|
|
@@ -2,24 +2,32 @@ import os
|
|
|
2
2
|
from typing import Any, Dict, List, Optional
|
|
3
3
|
from agentify.core.tool import Tool
|
|
4
4
|
|
|
5
|
+
DEFAULT_MAX_READ_BYTES = 1024 * 1024
|
|
6
|
+
HARD_MAX_READ_BYTES = 5 * 1024 * 1024
|
|
7
|
+
|
|
5
8
|
class BaseFilesystemTool(Tool):
|
|
6
9
|
"""Base class for filesystem tools with sandbox security."""
|
|
7
10
|
|
|
8
11
|
def __init__(self, schema: Dict[str, Any], func: Any, sandbox_dir: Optional[str] = None):
|
|
9
12
|
super().__init__(schema, func)
|
|
10
|
-
#
|
|
11
|
-
# but for this agent library, let's default to CWD but allow override.
|
|
13
|
+
# Default to current working directory if no sandbox is provided.
|
|
12
14
|
self.sandbox_dir = os.path.abspath(sandbox_dir or os.getcwd())
|
|
13
15
|
|
|
14
16
|
def _validate_path(self, file_path: str) -> str:
|
|
15
17
|
"""Ensure path is within sandbox."""
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
+
# Resolve user path relative to sandbox.
|
|
19
|
+
# Note: os.path.join discards sandbox_dir if file_path is absolute.
|
|
20
|
+
full_path = os.path.join(self.sandbox_dir, file_path)
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
# Resolve symlinks and .. components
|
|
23
|
+
real_path = os.path.realpath(full_path)
|
|
24
|
+
real_sandbox = os.path.realpath(self.sandbox_dir)
|
|
25
|
+
|
|
26
|
+
# Check if the resolved path starts with the resolved sandbox path
|
|
27
|
+
if os.path.commonpath([real_sandbox, real_path]) != real_sandbox:
|
|
28
|
+
raise ValueError(f"Access denied: Path '{file_path}' resolves to '{real_path}', which is outside sandbox '{real_sandbox}'")
|
|
21
29
|
|
|
22
|
-
return
|
|
30
|
+
return real_path
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
class ListDirTool(BaseFilesystemTool):
|
|
@@ -70,21 +78,38 @@ class ReadFileTool(BaseFilesystemTool):
|
|
|
70
78
|
"file_path": {
|
|
71
79
|
"type": "string",
|
|
72
80
|
"description": "Path to the file to read.",
|
|
73
|
-
}
|
|
81
|
+
},
|
|
82
|
+
"max_bytes": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"description": "Maximum bytes to read from the file (hard-capped).",
|
|
85
|
+
},
|
|
74
86
|
},
|
|
75
87
|
"required": ["file_path"],
|
|
76
88
|
},
|
|
77
89
|
}
|
|
78
90
|
super().__init__(schema, self._read_file, sandbox_dir)
|
|
79
91
|
|
|
80
|
-
def _read_file(self, file_path: str) -> str:
|
|
92
|
+
def _read_file(self, file_path: str, max_bytes: Optional[int] = None) -> str:
|
|
81
93
|
try:
|
|
82
94
|
target_path = self._validate_path(file_path)
|
|
83
95
|
if not os.path.exists(target_path):
|
|
84
96
|
return f"Error: File '{file_path}' does not exist."
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
97
|
+
|
|
98
|
+
if max_bytes is not None and max_bytes <= 0:
|
|
99
|
+
return "Error: 'max_bytes' must be a positive integer."
|
|
100
|
+
|
|
101
|
+
read_limit = min(max_bytes or DEFAULT_MAX_READ_BYTES, HARD_MAX_READ_BYTES)
|
|
102
|
+
|
|
103
|
+
with open(target_path, "rb") as f:
|
|
104
|
+
content = f.read(read_limit + 1)
|
|
105
|
+
|
|
106
|
+
truncated = len(content) > read_limit
|
|
107
|
+
text = content[:read_limit].decode("utf-8", errors="replace")
|
|
108
|
+
|
|
109
|
+
if truncated:
|
|
110
|
+
return f"{text}\n[Truncated to {read_limit} bytes]"
|
|
111
|
+
|
|
112
|
+
return text
|
|
88
113
|
except Exception as e:
|
|
89
114
|
return f"Error reading file: {str(e)}"
|
|
90
115
|
|
|
@@ -3,7 +3,7 @@ from dotenv import load_dotenv
|
|
|
3
3
|
from typing import Union, Dict, Any, Optional, Callable
|
|
4
4
|
from openai import OpenAI, AzureOpenAI, AsyncOpenAI, AsyncAzureOpenAI
|
|
5
5
|
|
|
6
|
-
load_dotenv()
|
|
6
|
+
# load_dotenv() # Removed to avoid side effects on import
|
|
7
7
|
|
|
8
8
|
LLMClientType = Union[OpenAI, AzureOpenAI]
|
|
9
9
|
AsyncLLMClientType = Union[AsyncOpenAI, AsyncAzureOpenAI]
|
|
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
|
|
|
3
3
|
from typing import Any, Dict, List, Optional, Protocol, Tuple
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
import uuid
|
|
6
|
+
from urllib.parse import quote
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
@dataclass(frozen=True, slots=True)
|
|
@@ -30,15 +31,23 @@ class MemoryAddress:
|
|
|
30
31
|
self.extras,
|
|
31
32
|
)
|
|
32
33
|
|
|
34
|
+
def _encode_part(self, value: Optional[str]) -> Optional[str]:
|
|
35
|
+
if value is None:
|
|
36
|
+
return None
|
|
37
|
+
return quote(value, safe="")
|
|
38
|
+
|
|
39
|
+
def _encode_key(self, key: str) -> str:
|
|
40
|
+
return quote(key, safe="")
|
|
41
|
+
|
|
33
42
|
def key_str(self, prefix: str = "mem") -> str:
|
|
34
43
|
"""Human-readable key for key-value backends (e.g., Redis)."""
|
|
35
44
|
parts = [
|
|
36
|
-
("v", self.api_version),
|
|
37
|
-
("t", self.tenant_id),
|
|
38
|
-
("u", self.user_id),
|
|
39
|
-
("c", self.conversation_id),
|
|
40
|
-
("a", self.agent_id),
|
|
41
|
-
] +
|
|
45
|
+
("v", self._encode_part(self.api_version)),
|
|
46
|
+
("t", self._encode_part(self.tenant_id)),
|
|
47
|
+
("u", self._encode_part(self.user_id)),
|
|
48
|
+
("c", self._encode_part(self.conversation_id)),
|
|
49
|
+
("a", self._encode_part(self.agent_id)),
|
|
50
|
+
] + [(self._encode_key(k), self._encode_part(v)) for k, v in self.extras]
|
|
42
51
|
joined = ":".join(f"{k}={v}" for k, v in parts if v)
|
|
43
52
|
return f"{prefix}:{joined}" if joined else prefix
|
|
44
53
|
|
|
@@ -67,7 +76,12 @@ class Message:
|
|
|
67
76
|
d["name"] = self.name
|
|
68
77
|
if self.tool_call_id is not None:
|
|
69
78
|
d["tool_call_id"] = self.tool_call_id
|
|
70
|
-
|
|
79
|
+
|
|
80
|
+
# Merge metadata but do NOT overwrite reserved keys
|
|
81
|
+
reserved = {"role", "content", "name", "tool_call_id"}
|
|
82
|
+
for k, v in self.metadata.items():
|
|
83
|
+
if k not in reserved:
|
|
84
|
+
d[k] = v
|
|
71
85
|
return d
|
|
72
86
|
|
|
73
87
|
def to_dict(self) -> Dict[str, Any]:
|
|
@@ -27,12 +27,13 @@ class MemoryService:
|
|
|
27
27
|
store: ConversationStore,
|
|
28
28
|
policy: Optional[MemoryPolicy] = None,
|
|
29
29
|
log_enabled: bool = True,
|
|
30
|
-
max_log_length: Optional[int] =
|
|
30
|
+
max_log_length: Optional[int] = 5000,
|
|
31
31
|
) -> None:
|
|
32
32
|
self.store = store
|
|
33
33
|
self.policy = policy or MemoryPolicy(store)
|
|
34
34
|
self.log_enabled = log_enabled
|
|
35
|
-
self.max_log_length = max_log_length
|
|
35
|
+
self.max_log_length = max_log_length # max length preview of the log
|
|
36
|
+
self.redact_keys = {"password", "api_key", "token", "secret", "key", "authorization"}
|
|
36
37
|
|
|
37
38
|
def _normalize_message(self, message: Dict[str, Any]) -> Message:
|
|
38
39
|
"""Accept OpenAI-shaped dicts; move unknown keys (e.g., 'tool_calls') into metadata.
|
|
@@ -53,6 +54,26 @@ class MemoryService:
|
|
|
53
54
|
base["metadata"] = meta
|
|
54
55
|
return Message(**base)
|
|
55
56
|
|
|
57
|
+
def _mask_secrets(self, value: Any) -> Any:
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
masked: Dict[str, Any] = {}
|
|
60
|
+
for k, v in value.items():
|
|
61
|
+
if k.lower() in self.redact_keys:
|
|
62
|
+
masked[k] = "******"
|
|
63
|
+
else:
|
|
64
|
+
masked[k] = self._mask_secrets(v)
|
|
65
|
+
return masked
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [self._mask_secrets(item) for item in value]
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def _preview_content(self, content: Any) -> Any:
|
|
71
|
+
redacted = self._mask_secrets(content)
|
|
72
|
+
if isinstance(redacted, str) and self.max_log_length is not None:
|
|
73
|
+
if len(redacted) > self.max_log_length:
|
|
74
|
+
return redacted[: self.max_log_length] + "..."
|
|
75
|
+
return redacted
|
|
76
|
+
|
|
56
77
|
def append_history(self, addr: MemoryAddress, message: Dict[str, Any]) -> None:
|
|
57
78
|
"""Append a dict message (OpenAI-ish) to the given address, normalizing extras."""
|
|
58
79
|
msg = self._normalize_message(message)
|
|
@@ -85,20 +106,14 @@ class MemoryService:
|
|
|
85
106
|
f"{Colors.GRAY}{agent_tag}{Colors.RESET}{Colors.GRAY}[Reasoning]{Colors.RESET} {Colors.GRAY}{reasoning_preview}{Colors.RESET}"
|
|
86
107
|
)
|
|
87
108
|
|
|
88
|
-
|
|
89
|
-
content_preview = msg.content
|
|
90
|
-
else:
|
|
91
|
-
content_preview = (
|
|
92
|
-
(msg.content[: self.max_log_length] + "...")
|
|
93
|
-
if msg.content and len(msg.content) > self.max_log_length
|
|
94
|
-
else msg.content
|
|
95
|
-
)
|
|
109
|
+
content_preview = self._preview_content(msg.content)
|
|
96
110
|
|
|
97
111
|
tool_info = ""
|
|
98
112
|
if msg.metadata and "tool_calls" in msg.metadata:
|
|
113
|
+
tool_calls = self._mask_secrets(msg.metadata["tool_calls"])
|
|
99
114
|
tool_names = [
|
|
100
115
|
tc.get("function", {}).get("name", "unknown")
|
|
101
|
-
for tc in
|
|
116
|
+
for tc in tool_calls
|
|
102
117
|
]
|
|
103
118
|
tool_info = (
|
|
104
119
|
f"{Colors.MAGENTA} | tools: {', '.join(tool_names)}{Colors.RESET}"
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import logging
|
|
4
4
|
from typing import List, Optional, Any, Dict
|
|
5
5
|
from datetime import datetime
|
|
6
|
+
from urllib.parse import unquote
|
|
6
7
|
|
|
7
8
|
try:
|
|
8
9
|
from elasticsearch import Elasticsearch
|
|
@@ -120,9 +121,8 @@ class ElasticsearchStore(ConversationStore):
|
|
|
120
121
|
def read_messages(self, addr: MemoryAddress, start: int = 0, end: int = -1) -> List[Message]:
|
|
121
122
|
must = self._build_filter_query(addr)
|
|
122
123
|
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
# For chat history, usually 100-1000 is enough.
|
|
124
|
+
# Fetch enough messages to support the slice.
|
|
125
|
+
# If 'end' is -1 (all), a large size or scroll might be required.
|
|
126
126
|
size = 1000 if end == -1 else (end + 20)
|
|
127
127
|
|
|
128
128
|
query = {
|
|
@@ -183,9 +183,9 @@ class ElasticsearchStore(ConversationStore):
|
|
|
183
183
|
self.client.delete_by_query(index=self.index_name, body=query, refresh=True)
|
|
184
184
|
|
|
185
185
|
def set_ttl(self, addr: MemoryAddress, seconds: int) -> None:
|
|
186
|
-
# Implementing TTL in ES
|
|
187
|
-
# or a separate cleanup job
|
|
188
|
-
#
|
|
186
|
+
# Implementing TTL in ES typically requires Index Lifecycle Management (ILM)
|
|
187
|
+
# or a separate cleanup job.
|
|
188
|
+
# Log warning as this is not natively supported per-conversation efficiently.
|
|
189
189
|
logger.warning("set_ttl is not fully supported in simple ElasticsearchStore yet.")
|
|
190
190
|
|
|
191
191
|
def list_conversations(self, limit: int = 100, offset: int = 0) -> List[MemoryAddress]:
|
|
@@ -228,13 +228,15 @@ class ElasticsearchStore(ConversationStore):
|
|
|
228
228
|
if "=" not in part:
|
|
229
229
|
continue
|
|
230
230
|
key, val = part.split("=", 1)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
elif
|
|
235
|
-
elif
|
|
231
|
+
decoded_key = unquote(key)
|
|
232
|
+
decoded_val = unquote(val)
|
|
233
|
+
if decoded_key == "v": kwargs["api_version"] = decoded_val
|
|
234
|
+
elif decoded_key == "t": kwargs["tenant_id"] = decoded_val
|
|
235
|
+
elif decoded_key == "u": kwargs["user_id"] = decoded_val
|
|
236
|
+
elif decoded_key == "c": kwargs["conversation_id"] = decoded_val
|
|
237
|
+
elif decoded_key == "a": kwargs["agent_id"] = decoded_val
|
|
236
238
|
else:
|
|
237
|
-
extras.append((
|
|
239
|
+
extras.append((decoded_key, decoded_val))
|
|
238
240
|
|
|
239
241
|
if extras:
|
|
240
242
|
kwargs["extras"] = tuple(extras)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import json
|
|
3
3
|
from typing import List
|
|
4
|
+
from urllib.parse import unquote
|
|
4
5
|
|
|
5
6
|
try:
|
|
6
7
|
import redis
|
|
@@ -80,13 +81,15 @@ class RedisStore(ConversationStore):
|
|
|
80
81
|
if "=" not in part:
|
|
81
82
|
continue
|
|
82
83
|
key, val = part.split("=", 1)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
elif
|
|
87
|
-
elif
|
|
84
|
+
decoded_key = unquote(key)
|
|
85
|
+
decoded_val = unquote(val)
|
|
86
|
+
if decoded_key == "v": kwargs["api_version"] = decoded_val
|
|
87
|
+
elif decoded_key == "t": kwargs["tenant_id"] = decoded_val
|
|
88
|
+
elif decoded_key == "u": kwargs["user_id"] = decoded_val
|
|
89
|
+
elif decoded_key == "c": kwargs["conversation_id"] = decoded_val
|
|
90
|
+
elif decoded_key == "a": kwargs["agent_id"] = decoded_val
|
|
88
91
|
else:
|
|
89
|
-
extras.append((
|
|
92
|
+
extras.append((decoded_key, decoded_val))
|
|
90
93
|
|
|
91
94
|
if extras:
|
|
92
95
|
kwargs["extras"] = tuple(extras)
|
|
@@ -3,6 +3,7 @@ import sqlite3
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
from typing import List, Tuple, Any
|
|
6
|
+
from urllib.parse import unquote
|
|
6
7
|
from ..interfaces import ConversationStore, MemoryAddress, Message
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
@@ -178,13 +179,15 @@ class SQLiteStore(ConversationStore):
|
|
|
178
179
|
if "=" not in part:
|
|
179
180
|
continue
|
|
180
181
|
k, v = part.split("=", 1)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
elif
|
|
185
|
-
elif
|
|
182
|
+
decoded_key = unquote(k)
|
|
183
|
+
decoded_val = unquote(v)
|
|
184
|
+
if decoded_key == "v": kwargs["api_version"] = decoded_val
|
|
185
|
+
elif decoded_key == "t": kwargs["tenant_id"] = decoded_val
|
|
186
|
+
elif decoded_key == "u": kwargs["user_id"] = decoded_val
|
|
187
|
+
elif decoded_key == "c": kwargs["conversation_id"] = decoded_val
|
|
188
|
+
elif decoded_key == "a": kwargs["agent_id"] = decoded_val
|
|
186
189
|
else:
|
|
187
|
-
extras.append((
|
|
190
|
+
extras.append((decoded_key, decoded_val))
|
|
188
191
|
|
|
189
192
|
if extras:
|
|
190
193
|
kwargs["extras"] = tuple(extras)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
from typing import Dict, List, Union, Generator, AsyncGenerator
|
|
1
|
+
from typing import Dict, List, Union, Generator, AsyncGenerator, Any, Optional
|
|
2
|
+
from agentify.core.runnable import Runnable
|
|
2
3
|
from agentify.core.agent import BaseAgent
|
|
3
4
|
from agentify.memory.interfaces import MemoryAddress
|
|
4
5
|
from agentify.multi_agent.tool_wrapper import AgentTool, FlowTool, Flow
|
|
5
6
|
|
|
6
7
|
|
|
7
|
-
class HierarchicalTeam:
|
|
8
|
+
class HierarchicalTeam(Runnable):
|
|
8
9
|
"""Orchestrates a hierarchy of agents (Tree structure).
|
|
9
10
|
|
|
10
11
|
- Root agent is the entry point.
|
|
@@ -30,6 +31,8 @@ class HierarchicalTeam:
|
|
|
30
31
|
user_input: str,
|
|
31
32
|
session_id: str = "default_session",
|
|
32
33
|
user_id: str = "default_user",
|
|
34
|
+
context: Optional[Dict[str, Any]] = None,
|
|
35
|
+
**kwargs: Any,
|
|
33
36
|
) -> Union[str, Generator[str, None, None]]:
|
|
34
37
|
"""Run the hierarchical flow."""
|
|
35
38
|
|
|
@@ -51,6 +54,8 @@ class HierarchicalTeam:
|
|
|
51
54
|
user_input: str,
|
|
52
55
|
session_id: str = "default_session",
|
|
53
56
|
user_id: str = "default_user",
|
|
57
|
+
context: Optional[Dict[str, Any]] = None,
|
|
58
|
+
**kwargs: Any,
|
|
54
59
|
) -> Union[str, AsyncGenerator[str, None]]:
|
|
55
60
|
"""Async version of run(). Uses root agent's arun() for async execution."""
|
|
56
61
|
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
from typing import List, Union, Generator, Any, AsyncGenerator
|
|
2
|
-
|
|
3
|
-
from agentify.core.agent import BaseAgent
|
|
1
|
+
from typing import List, Union, Generator, Any, AsyncGenerator, Optional, Dict
|
|
2
|
+
from agentify.core.runnable import Runnable
|
|
4
3
|
from agentify.memory.interfaces import MemoryAddress
|
|
5
|
-
from agentify.multi_agent.team import Team
|
|
6
4
|
|
|
7
5
|
# Type alias for what can be a step in the pipeline
|
|
8
|
-
PipelineStep =
|
|
6
|
+
PipelineStep = Runnable
|
|
9
7
|
|
|
10
8
|
|
|
11
|
-
class SequentialPipeline:
|
|
9
|
+
class SequentialPipeline(Runnable):
|
|
12
10
|
"""Executes a sequence of agents/teams/pipelines in order.
|
|
13
11
|
|
|
14
12
|
The output of step N becomes the input of step N+1.
|
|
@@ -24,6 +22,8 @@ class SequentialPipeline:
|
|
|
24
22
|
user_input: str,
|
|
25
23
|
session_id: str = "default_session",
|
|
26
24
|
user_id: str = "default_user",
|
|
25
|
+
context: Optional[Dict[str, Any]] = None,
|
|
26
|
+
**kwargs: Any,
|
|
27
27
|
) -> Union[str, Generator[str, None, None]]:
|
|
28
28
|
"""Run the pipeline sequentially."""
|
|
29
29
|
|
|
@@ -40,21 +40,22 @@ class SequentialPipeline:
|
|
|
40
40
|
|
|
41
41
|
response: Union[str, Generator[str, None, None]]
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# Pass session info for unified Runnable execution.
|
|
44
|
+
# Support legacy agents by constructing a MemoryAddress if applicable.
|
|
45
|
+
run_kwargs = {
|
|
46
|
+
"session_id": session_id,
|
|
47
|
+
"user_id": user_id,
|
|
48
|
+
"context": context
|
|
49
|
+
}
|
|
50
|
+
# Add explicit memory address for BaseAgents (legacy support within Runnable)
|
|
51
|
+
if hasattr(step, "config") and hasattr(step, "run"):
|
|
52
|
+
run_kwargs["memory_address"] = MemoryAddress(
|
|
45
53
|
user_id=user_id, conversation_id=session_id, agent_id=step_name
|
|
46
54
|
)
|
|
47
|
-
|
|
55
|
+
|
|
56
|
+
run_kwargs.update(kwargs) # Pass through any other kwargs
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
# Team, SequentialPipeline, HierarchicalTeam
|
|
51
|
-
response = step.run(
|
|
52
|
-
user_input=current_input, session_id=session_id, user_id=user_id
|
|
53
|
-
)
|
|
54
|
-
else:
|
|
55
|
-
raise ValueError(
|
|
56
|
-
f"Step {i} ({type(step)}) is not a valid agent or flow."
|
|
57
|
-
)
|
|
58
|
+
response = step.run(user_input=current_input, **run_kwargs)
|
|
58
59
|
|
|
59
60
|
# If not last step, consume output to pass to next step
|
|
60
61
|
if not is_last_step:
|
|
@@ -72,6 +73,8 @@ class SequentialPipeline:
|
|
|
72
73
|
user_input: str,
|
|
73
74
|
session_id: str = "default_session",
|
|
74
75
|
user_id: str = "default_user",
|
|
76
|
+
context: Optional[Dict[str, Any]] = None,
|
|
77
|
+
**kwargs: Any,
|
|
75
78
|
) -> Union[str, AsyncGenerator[str, None]]:
|
|
76
79
|
"""Async version of run(). Sequentially awaits each step."""
|
|
77
80
|
|
|
@@ -86,26 +89,20 @@ class SequentialPipeline:
|
|
|
86
89
|
|
|
87
90
|
response: Union[str, AsyncGenerator[str, None]]
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
# Async execution
|
|
93
|
+
run_kwargs = {
|
|
94
|
+
"session_id": session_id,
|
|
95
|
+
"user_id": user_id,
|
|
96
|
+
"context": context
|
|
97
|
+
}
|
|
98
|
+
if hasattr(step, "config") and hasattr(step, "arun"):
|
|
99
|
+
run_kwargs["memory_address"] = MemoryAddress(
|
|
91
100
|
user_id=user_id, conversation_id=session_id, agent_id=step_name
|
|
92
101
|
)
|
|
93
|
-
|
|
102
|
+
run_kwargs.update(kwargs)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
response = await step.arun(
|
|
98
|
-
user_input=current_input, session_id=session_id, user_id=user_id
|
|
99
|
-
)
|
|
100
|
-
elif hasattr(step, "run"):
|
|
101
|
-
# Fallback to sync run for flows without arun
|
|
102
|
-
response = step.run(
|
|
103
|
-
user_input=current_input, session_id=session_id, user_id=user_id
|
|
104
|
-
)
|
|
105
|
-
else:
|
|
106
|
-
raise ValueError(
|
|
107
|
-
f"Step {i} ({type(step)}) is not a valid agent or flow."
|
|
108
|
-
)
|
|
104
|
+
# Polymorphic call
|
|
105
|
+
response = await step.arun(user_input=current_input, **run_kwargs)
|
|
109
106
|
|
|
110
107
|
# If not last step, consume output to pass to next step
|
|
111
108
|
if not is_last_step:
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
from typing import List, Optional, Union, Generator, AsyncGenerator
|
|
1
|
+
from typing import List, Optional, Union, Generator, AsyncGenerator, Any, Dict
|
|
2
|
+
import copy
|
|
3
|
+
from agentify.core.runnable import Runnable
|
|
2
4
|
from agentify.core.agent import BaseAgent
|
|
3
5
|
from agentify.memory.interfaces import MemoryAddress
|
|
4
6
|
from agentify.multi_agent.tool_wrapper import AgentTool
|
|
5
7
|
|
|
6
8
|
|
|
7
|
-
class Team:
|
|
9
|
+
class Team(Runnable):
|
|
8
10
|
"""Orchestrates a group of agents.
|
|
9
11
|
|
|
10
12
|
The Team class:
|
|
@@ -33,6 +35,7 @@ class Team:
|
|
|
33
35
|
user_input: str,
|
|
34
36
|
session_id: str = "default_session",
|
|
35
37
|
user_id: str = "default_user",
|
|
38
|
+
**kwargs: Any,
|
|
36
39
|
) -> Union[str, Generator[str, None, None]]:
|
|
37
40
|
"""Run the team workflow.
|
|
38
41
|
|
|
@@ -48,6 +51,11 @@ class Team:
|
|
|
48
51
|
agent_id=self.supervisor.config.name,
|
|
49
52
|
)
|
|
50
53
|
|
|
54
|
+
# Clone supervisor to avoid tool pollution across runs
|
|
55
|
+
# Use shallow copy + manual tools dict copy to avoid deepcopying net clients
|
|
56
|
+
supervisor = copy.copy(self.supervisor)
|
|
57
|
+
supervisor._tools = self.supervisor._tools.copy()
|
|
58
|
+
|
|
51
59
|
# 2. Register Workers as Tools (Dynamic Registration)
|
|
52
60
|
# Wrap each worker in an AgentTool, bound to the supervisor's address context
|
|
53
61
|
worker_tools = []
|
|
@@ -55,22 +63,20 @@ class Team:
|
|
|
55
63
|
tool_wrapper = AgentTool(agent=worker, parent_addr=supervisor_addr)
|
|
56
64
|
worker_tools.append(tool_wrapper)
|
|
57
65
|
|
|
58
|
-
# Register with supervisor
|
|
59
|
-
|
|
66
|
+
# Register with CLONED supervisor
|
|
67
|
+
supervisor.register_tool(tool_wrapper)
|
|
60
68
|
|
|
61
69
|
# 3. Run Supervisor
|
|
62
|
-
return
|
|
70
|
+
return supervisor.run(user_input=user_input, addr=supervisor_addr)
|
|
63
71
|
|
|
64
72
|
async def arun(
|
|
65
73
|
self,
|
|
66
74
|
user_input: str,
|
|
67
75
|
session_id: str = "default_session",
|
|
68
76
|
user_id: str = "default_user",
|
|
77
|
+
**kwargs: Any,
|
|
69
78
|
) -> Union[str, AsyncGenerator[str, None]]:
|
|
70
|
-
"""Async version of run().
|
|
71
|
-
|
|
72
|
-
Uses the supervisor's arun() for async execution with parallel tool calls.
|
|
73
|
-
"""
|
|
79
|
+
"""Async version of run()."""
|
|
74
80
|
# 1. Setup Supervisor Address
|
|
75
81
|
supervisor_addr = MemoryAddress(
|
|
76
82
|
user_id=user_id,
|
|
@@ -78,11 +84,14 @@ class Team:
|
|
|
78
84
|
agent_id=self.supervisor.config.name,
|
|
79
85
|
)
|
|
80
86
|
|
|
87
|
+
supervisor = copy.copy(self.supervisor)
|
|
88
|
+
supervisor._tools = self.supervisor._tools.copy()
|
|
89
|
+
|
|
81
90
|
# 2. Register Workers as Tools
|
|
82
91
|
for worker in self.workers:
|
|
83
92
|
tool_wrapper = AgentTool(agent=worker, parent_addr=supervisor_addr)
|
|
84
|
-
|
|
93
|
+
supervisor.register_tool(tool_wrapper)
|
|
85
94
|
|
|
86
95
|
# 3. Run Supervisor asynchronously
|
|
87
|
-
return await
|
|
96
|
+
return await supervisor.arun(user_input=user_input, addr=supervisor_addr)
|
|
88
97
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from typing import Any, Dict, Optional
|
|
2
2
|
import asyncio
|
|
3
|
+
import hashlib
|
|
3
4
|
from agentify.core.agent import BaseAgent
|
|
4
5
|
from agentify.core.tool import Tool
|
|
5
6
|
from agentify.memory.interfaces import MemoryAddress
|
|
@@ -245,10 +246,11 @@ class SpawnAgentTool(Tool):
|
|
|
245
246
|
if system_prompt:
|
|
246
247
|
new_config.system_prompt = system_prompt
|
|
247
248
|
|
|
248
|
-
# Create a unique address for this interaction
|
|
249
|
+
# Create a unique address for this interaction using hash of instructions
|
|
250
|
+
instr_hash = hashlib.sha256(instructions.encode("utf-8")).hexdigest()[:16]
|
|
249
251
|
child_addr = MemoryAddress(
|
|
250
252
|
user_id=self.parent_addr.user_id,
|
|
251
|
-
conversation_id=f"{self.parent_addr.conversation_id}_{role_name}_{
|
|
253
|
+
conversation_id=f"{self.parent_addr.conversation_id}_{role_name}_{instr_hash}",
|
|
252
254
|
agent_id=new_config.name,
|
|
253
255
|
)
|
|
254
256
|
|
|
@@ -288,9 +290,10 @@ class SpawnAgentTool(Tool):
|
|
|
288
290
|
if system_prompt:
|
|
289
291
|
new_config.system_prompt = system_prompt
|
|
290
292
|
|
|
293
|
+
instr_hash = hashlib.sha256(instructions.encode("utf-8")).hexdigest()[:16]
|
|
291
294
|
child_addr = MemoryAddress(
|
|
292
295
|
user_id=self.parent_addr.user_id,
|
|
293
|
-
conversation_id=f"{self.parent_addr.conversation_id}_{role_name}_{
|
|
296
|
+
conversation_id=f"{self.parent_addr.conversation_id}_{role_name}_{instr_hash}",
|
|
294
297
|
agent_id=new_config.name,
|
|
295
298
|
)
|
|
296
299
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentify-core
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Framework-agnostic AI agent library for building single and multi-agent systems
|
|
5
|
-
Author
|
|
5
|
+
Author: Fabian M
|
|
6
|
+
Author-email: fabianmp_98@hotmail.com
|
|
6
7
|
License: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/fa8i/Agentify
|
|
8
9
|
Project-URL: Repository, https://github.com/fa8i/Agentify
|
|
@@ -24,6 +25,7 @@ License-File: LICENSE
|
|
|
24
25
|
Requires-Dist: openai
|
|
25
26
|
Requires-Dist: python-dotenv
|
|
26
27
|
Requires-Dist: Pillow
|
|
28
|
+
Requires-Dist: jsonschema>=4.0.0
|
|
27
29
|
Provides-Extra: redis
|
|
28
30
|
Requires-Dist: redis>=4.0.0; extra == "redis"
|
|
29
31
|
Provides-Extra: elastic
|
|
@@ -9,6 +9,7 @@ agentify/core/__init__.py
|
|
|
9
9
|
agentify/core/agent.py
|
|
10
10
|
agentify/core/callbacks.py
|
|
11
11
|
agentify/core/config.py
|
|
12
|
+
agentify/core/runnable.py
|
|
12
13
|
agentify/core/tool.py
|
|
13
14
|
agentify/extensions/__init__.py
|
|
14
15
|
agentify/extensions/prompts/__init__.py
|
|
@@ -47,5 +48,7 @@ agentify_core.egg-info/requires.txt
|
|
|
47
48
|
agentify_core.egg-info/top_level.txt
|
|
48
49
|
tests/test_filesystem_tools.py
|
|
49
50
|
tests/test_mcp.py
|
|
51
|
+
tests/test_memory_address.py
|
|
52
|
+
tests/test_memory_logging.py
|
|
50
53
|
tests/test_planning_tool.py
|
|
51
54
|
tests/test_verify_hooks.py
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentify-core"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Framework-agnostic AI agent library for building single and multi-agent systems"
|
|
9
9
|
readme = "README_PYPI.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = {text = "MIT"}
|
|
12
12
|
authors = [
|
|
13
|
-
{name = "Fabian M",
|
|
13
|
+
{name = "Fabian M"},
|
|
14
|
+
{email = "fabianmp_98@hotmail.com"}
|
|
14
15
|
]
|
|
15
16
|
keywords = ["agentify", "agentify-core","agent", "multi-agent", "ai", "llm", "openai", "framework"]
|
|
16
17
|
classifiers = [
|
|
@@ -31,6 +32,7 @@ dependencies = [
|
|
|
31
32
|
"openai",
|
|
32
33
|
"python-dotenv",
|
|
33
34
|
"Pillow",
|
|
35
|
+
"jsonschema>=4.0.0",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
# Dependencias opcionales
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import tempfile
|
|
3
3
|
import pytest
|
|
4
|
-
from agentify.tools.filesystem import ListDirTool, ReadFileTool, WriteFileTool
|
|
4
|
+
from agentify.extensions.tools.filesystem import ListDirTool, ReadFileTool, WriteFileTool
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class TestFilesystemTools:
|
|
@@ -48,6 +48,19 @@ class TestFilesystemTools:
|
|
|
48
48
|
with open(os.path.join(tmpdir, "output.txt"), "r") as f:
|
|
49
49
|
assert f.read() == "Test content"
|
|
50
50
|
|
|
51
|
+
def test_read_file_tool_truncation(self):
|
|
52
|
+
"""Test ReadFileTool enforces max_bytes limit."""
|
|
53
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
54
|
+
test_file = os.path.join(tmpdir, "big.txt")
|
|
55
|
+
with open(test_file, "w") as f:
|
|
56
|
+
f.write("A" * 50)
|
|
57
|
+
|
|
58
|
+
tool = ReadFileTool(sandbox_dir=tmpdir)
|
|
59
|
+
result = tool._read_file("big.txt", max_bytes=10)
|
|
60
|
+
|
|
61
|
+
assert result.startswith("A" * 10)
|
|
62
|
+
assert "[Truncated to 10 bytes]" in result
|
|
63
|
+
|
|
51
64
|
def test_sandbox_security(self):
|
|
52
65
|
"""Test that sandbox prevents access outside its boundaries."""
|
|
53
66
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -58,5 +71,6 @@ class TestFilesystemTools:
|
|
|
58
71
|
assert "Access denied" in result or "Error" in result
|
|
59
72
|
|
|
60
73
|
|
|
74
|
+
|
|
61
75
|
if __name__ == "__main__":
|
|
62
76
|
pytest.main([__file__, "-v"])
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from agentify.memory.interfaces import MemoryAddress, Message
|
|
4
|
+
from agentify.memory.stores.sqlite_store import SQLiteStore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_memory_address_encoding_roundtrip(tmp_path):
|
|
8
|
+
db_path = tmp_path / "memory.db"
|
|
9
|
+
store = SQLiteStore(str(db_path))
|
|
10
|
+
|
|
11
|
+
addr = MemoryAddress(
|
|
12
|
+
user_id="user:1",
|
|
13
|
+
conversation_id="conv=1",
|
|
14
|
+
agent_id="agent/name",
|
|
15
|
+
extras=(("chan:1", "a:b"),),
|
|
16
|
+
)
|
|
17
|
+
store.append_message(addr, Message(role="user", content="hi"))
|
|
18
|
+
|
|
19
|
+
conversations = store.list_conversations()
|
|
20
|
+
assert len(conversations) == 1
|
|
21
|
+
|
|
22
|
+
recovered = conversations[0]
|
|
23
|
+
assert recovered.user_id == addr.user_id
|
|
24
|
+
assert recovered.conversation_id == addr.conversation_id
|
|
25
|
+
assert recovered.agent_id == addr.agent_id
|
|
26
|
+
assert recovered.extras == addr.extras
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from agentify.memory.interfaces import MemoryAddress, Message
|
|
4
|
+
from agentify.memory.service import MemoryService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InMemoryStore:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.data = {}
|
|
10
|
+
|
|
11
|
+
def append_message(self, addr: MemoryAddress, msg: Message) -> None:
|
|
12
|
+
key = addr.key_str()
|
|
13
|
+
self.data.setdefault(key, []).append(msg)
|
|
14
|
+
|
|
15
|
+
def read_messages(self, addr: MemoryAddress, start: int = 0, end: int = -1):
|
|
16
|
+
key = addr.key_str()
|
|
17
|
+
msgs = self.data.get(key, [])
|
|
18
|
+
return msgs[start:] if end == -1 else msgs[start:end]
|
|
19
|
+
|
|
20
|
+
def replace_messages(self, addr: MemoryAddress, messages):
|
|
21
|
+
self.data[addr.key_str()] = list(messages)
|
|
22
|
+
|
|
23
|
+
def delete_conversation(self, addr: MemoryAddress) -> None:
|
|
24
|
+
self.data.pop(addr.key_str(), None)
|
|
25
|
+
|
|
26
|
+
def set_ttl(self, addr: MemoryAddress, seconds: int) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def list_conversations(self, limit: int = 100, offset: int = 0):
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_memory_service_redacts_logs(caplog):
|
|
34
|
+
store = InMemoryStore()
|
|
35
|
+
service = MemoryService(store, log_enabled=True, max_log_length=5000)
|
|
36
|
+
addr = MemoryAddress(conversation_id="conv:test")
|
|
37
|
+
|
|
38
|
+
message = {
|
|
39
|
+
"role": "user",
|
|
40
|
+
"content": {"api_key": "secret-value", "note": "ok"},
|
|
41
|
+
"metadata": {
|
|
42
|
+
"tool_calls": [
|
|
43
|
+
{
|
|
44
|
+
"function": {
|
|
45
|
+
"name": "do_thing",
|
|
46
|
+
"arguments": {"token": "token123"},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
with caplog.at_level(logging.INFO):
|
|
54
|
+
service.append_history(addr, message)
|
|
55
|
+
|
|
56
|
+
assert "secret-value" not in caplog.text
|
|
57
|
+
assert "token123" not in caplog.text
|
|
58
|
+
assert "******" in caplog.text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|