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.
Files changed (56) hide show
  1. {agentify_core-0.3.0/agentify_core.egg-info → agentify_core-0.3.1}/PKG-INFO +4 -2
  2. {agentify_core-0.3.0 → agentify_core-0.3.1}/README.md +6 -3
  3. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/__init__.py +1 -1
  4. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/agent.py +66 -7
  5. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/callbacks.py +32 -4
  6. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/config.py +1 -0
  7. agentify_core-0.3.1/agentify/core/runnable.py +20 -0
  8. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/tool.py +2 -2
  9. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/filesystem.py +37 -12
  10. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/llm/client.py +1 -1
  11. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/interfaces.py +21 -7
  12. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/service.py +26 -11
  13. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/elastic_store.py +14 -12
  14. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/redis_store.py +9 -6
  15. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/sqlite_store.py +9 -6
  16. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/hierarchical.py +7 -2
  17. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/pipeline.py +32 -35
  18. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/team.py +20 -11
  19. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/tool_wrapper.py +6 -3
  20. {agentify_core-0.3.0 → agentify_core-0.3.1/agentify_core.egg-info}/PKG-INFO +4 -2
  21. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/SOURCES.txt +3 -0
  22. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/requires.txt +1 -0
  23. {agentify_core-0.3.0 → agentify_core-0.3.1}/pyproject.toml +4 -2
  24. {agentify_core-0.3.0 → agentify_core-0.3.1}/requirements.txt +1 -0
  25. {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_filesystem_tools.py +15 -1
  26. agentify_core-0.3.1/tests/test_memory_address.py +26 -0
  27. agentify_core-0.3.1/tests/test_memory_logging.py +58 -0
  28. {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_planning_tool.py +1 -1
  29. {agentify_core-0.3.0 → agentify_core-0.3.1}/LICENSE +0 -0
  30. {agentify_core-0.3.0 → agentify_core-0.3.1}/MANIFEST.in +0 -0
  31. {agentify_core-0.3.0 → agentify_core-0.3.1}/README_PYPI.md +0 -0
  32. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/core/__init__.py +0 -0
  33. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/__init__.py +0 -0
  34. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/prompts/__init__.py +0 -0
  35. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/prompts/assistant.py +0 -0
  36. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/__init__.py +0 -0
  37. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/calculator.py +0 -0
  38. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/planning.py +0 -0
  39. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/time.py +0 -0
  40. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/extensions/tools/weather.py +0 -0
  41. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/llm/__init__.py +0 -0
  42. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/__init__.py +0 -0
  43. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/adapter.py +0 -0
  44. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/mcp/client.py +0 -0
  45. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/__init__.py +0 -0
  46. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/policies.py +0 -0
  47. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/__init__.py +0 -0
  48. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/memory/stores/in_memory_store.py +0 -0
  49. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/multi_agent/__init__.py +0 -0
  50. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/utils/__init__.py +0 -0
  51. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify/utils/style.py +0 -0
  52. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/dependency_links.txt +0 -0
  53. {agentify_core-0.3.0 → agentify_core-0.3.1}/agentify_core.egg-info/top_level.txt +0 -0
  54. {agentify_core-0.3.0 → agentify_core-0.3.1}/setup.cfg +0 -0
  55. {agentify_core-0.3.0 → agentify_core-0.3.1}/tests/test_mcp.py +0 -0
  56. {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.0
3
+ Version: 0.3.1
4
4
  Summary: Framework-agnostic AI agent library for building single and multi-agent systems
5
- Author-email: Fabian M <fabianmp_98@hotmail.com>
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
- ## Quick Start
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,
@@ -11,7 +11,7 @@ from agentify.memory.service import MemoryService
11
11
  from agentify.memory.interfaces import MemoryAddress
12
12
  from agentify.memory.policies import MemoryPolicy
13
13
 
14
- __version__ = "0.3.0"
14
+ __version__ = "0.3.1"
15
15
 
16
16
  __all__ = [
17
17
  "BaseAgent",
@@ -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 = str(result)
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
- # If no tool calls, we are done
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 = str(result)
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
- # If no tool calls, we are done
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
- result_content = await self._aexecute_tool(tool_name, args)
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(f"Agent '{agent_name}' started. Input: {user_input[:100]}...")
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
- self.logger.info(f"Tool '{tool_name}' started. Args: {args}")
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(f"Tool '{tool_name}' finished. Output: {output[:100]}...")
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)}")
@@ -21,6 +21,7 @@ class AgentConfig:
21
21
  timeout: int = 60
22
22
  stream: bool = False
23
23
  max_retries: int = 3
24
+ verbose: bool = True
24
25
  max_tool_iter: Optional[int] = 10
25
26
  reasoning_effort: Optional[str] = None
26
27
  model_kwargs: Optional[Dict[str, Any]] = None
@@ -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
- # If no sandbox provided, default to current working directory or a safe temp dir could be better
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
- # Handle absolute paths by checking if they start with sandbox
17
- abs_path = os.path.abspath(os.path.join(self.sandbox_dir, file_path))
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
- if not abs_path.startswith(self.sandbox_dir):
20
- raise ValueError(f"Access denied: Path '{file_path}' is outside sandbox directory '{self.sandbox_dir}'")
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 abs_path
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
- with open(target_path, "r", encoding="utf-8") as f:
87
- return f.read()
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
- ] + list(self.extras)
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
- d.update(self.metadata) # carry extra fields (e.g., tool_calls)
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] = None,
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 # max length preview of the log
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
- if self.max_log_length is None:
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 msg.metadata["tool_calls"]
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
- # 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.
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 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.
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
- 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
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((key, val))
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
- if key == "v": kwargs["api_version"] = val
84
- elif key == "t": kwargs["tenant_id"] = val
85
- elif key == "u": kwargs["user_id"] = val
86
- elif key == "c": kwargs["conversation_id"] = val
87
- elif key == "a": kwargs["agent_id"] = val
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((key, val))
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
- if k == "v": kwargs["api_version"] = v
182
- elif k == "t": kwargs["tenant_id"] = v
183
- elif k == "u": kwargs["user_id"] = v
184
- elif k == "c": kwargs["conversation_id"] = v
185
- elif k == "a": kwargs["agent_id"] = v
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((k, v))
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 = Union[BaseAgent, Team, "SequentialPipeline", Any]
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
- if isinstance(step, BaseAgent):
44
- step_addr = MemoryAddress(
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
- response = step.run(user_input=current_input, addr=step_addr)
55
+
56
+ run_kwargs.update(kwargs) # Pass through any other kwargs
48
57
 
49
- elif hasattr(step, "run"):
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
- if isinstance(step, BaseAgent):
90
- step_addr = MemoryAddress(
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
- response = await step.arun(user_input=current_input, addr=step_addr)
102
+ run_kwargs.update(kwargs)
94
103
 
95
- elif hasattr(step, "arun"):
96
- # Team, SequentialPipeline, HierarchicalTeam with async support
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
- self.supervisor.register_tool(tool_wrapper)
66
+ # Register with CLONED supervisor
67
+ supervisor.register_tool(tool_wrapper)
60
68
 
61
69
  # 3. Run Supervisor
62
- return self.supervisor.run(user_input=user_input, addr=supervisor_addr)
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
- self.supervisor.register_tool(tool_wrapper)
93
+ supervisor.register_tool(tool_wrapper)
85
94
 
86
95
  # 3. Run Supervisor asynchronously
87
- return await self.supervisor.arun(user_input=user_input, addr=supervisor_addr)
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}_{instructions[:10]}",
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}_{instructions[:10]}",
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.0
3
+ Version: 0.3.1
4
4
  Summary: Framework-agnostic AI agent library for building single and multi-agent systems
5
- Author-email: Fabian M <fabianmp_98@hotmail.com>
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
@@ -1,6 +1,7 @@
1
1
  openai
2
2
  python-dotenv
3
3
  Pillow
4
+ jsonschema>=4.0.0
4
5
 
5
6
  [all]
6
7
  redis>=4.0.0
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentify-core"
7
- version = "0.3.0"
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", email = "fabianmp_98@hotmail.com"}
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
@@ -2,6 +2,7 @@
2
2
  openai
3
3
  python-dotenv
4
4
  Pillow
5
+ jsonschema>=4.0.0
5
6
 
6
7
  # --- Optional Extensions ---
7
8
  # Redis Storage
@@ -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
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from agentify.tools.planning import TodoTool
2
+ from agentify.extensions.tools.planning import TodoTool
3
3
 
4
4
 
5
5
  class TestPlanningTool:
File without changes
File without changes
File without changes