agentify-core 0.4.0__tar.gz → 0.4.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 (65) hide show
  1. {agentify_core-0.4.0/agentify_core.egg-info → agentify_core-0.4.1}/PKG-INFO +4 -7
  2. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/__init__.py +3 -1
  3. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/agent.py +157 -28
  4. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/config.py +1 -1
  5. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/sync_bridge.py +0 -7
  6. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/filesystem.py +1 -1
  7. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/planning.py +1 -1
  8. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/shell_safe.py +15 -42
  9. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/llm/client.py +105 -67
  10. agentify_core-0.4.1/agentify/mcp/adapter.py +76 -0
  11. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/mcp/client.py +4 -0
  12. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/async_service.py +5 -1
  13. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/service.py +7 -1
  14. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/elastic_store.py +28 -9
  15. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/sqlite_store.py +1 -1
  16. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/hierarchical.py +4 -3
  17. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/pipeline.py +9 -5
  18. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/tool_wrapper.py +28 -8
  19. {agentify_core-0.4.0 → agentify_core-0.4.1/agentify_core.egg-info}/PKG-INFO +4 -7
  20. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/SOURCES.txt +2 -2
  21. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/requires.txt +1 -4
  22. {agentify_core-0.4.0 → agentify_core-0.4.1}/pyproject.toml +17 -11
  23. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_async_memory_service.py +42 -1
  24. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_mcp.py +4 -4
  25. agentify_core-0.4.1/tests/test_spawn_agent_tool.py +86 -0
  26. agentify_core-0.4.1/tests/test_tool_hooks.py +79 -0
  27. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_verify_hooks.py +8 -8
  28. agentify_core-0.4.0/MANIFEST.in +0 -10
  29. agentify_core-0.4.0/agentify/mcp/adapter.py +0 -46
  30. agentify_core-0.4.0/requirements.txt +0 -27
  31. {agentify_core-0.4.0 → agentify_core-0.4.1}/LICENSE +0 -0
  32. {agentify_core-0.4.0 → agentify_core-0.4.1}/README.md +0 -0
  33. {agentify_core-0.4.0 → agentify_core-0.4.1}/README_PYPI.md +0 -0
  34. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/__init__.py +0 -0
  35. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/callbacks.py +0 -0
  36. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/multimodal.py +0 -0
  37. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/runnable.py +0 -0
  38. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/tool.py +0 -0
  39. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/__init__.py +0 -0
  40. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/prompts/__init__.py +0 -0
  41. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/prompts/assistant.py +0 -0
  42. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/__init__.py +0 -0
  43. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/calculator.py +0 -0
  44. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/time.py +0 -0
  45. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/weather.py +0 -0
  46. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/llm/__init__.py +0 -0
  47. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/mcp/__init__.py +0 -0
  48. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/__init__.py +0 -0
  49. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/interfaces.py +0 -0
  50. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/policies.py +0 -0
  51. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/__init__.py +0 -0
  52. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/in_memory_store.py +0 -0
  53. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/redis_store.py +0 -0
  54. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/__init__.py +0 -0
  55. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/team.py +0 -0
  56. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/utils/__init__.py +0 -0
  57. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/utils/style.py +0 -0
  58. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/dependency_links.txt +0 -0
  59. {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/top_level.txt +0 -0
  60. {agentify_core-0.4.0 → agentify_core-0.4.1}/setup.cfg +0 -0
  61. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_filesystem_tools.py +0 -0
  62. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_memory_address.py +0 -0
  63. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_memory_logging.py +0 -0
  64. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_planning_tool.py +0 -0
  65. {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_sync_bridge.py +0 -0
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentify-core
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Framework-agnostic AI agent library for building single and multi-agent systems
5
- Author: Fabian M
6
- Author-email: fabianmp_98@hotmail.com
5
+ Author-email: Fabian M <fabianmp_98@hotmail.com>
7
6
  License: MIT
8
7
  Project-URL: Homepage, https://github.com/fa8i/Agentify
9
8
  Project-URL: Repository, https://github.com/fa8i/Agentify
10
9
  Project-URL: Bug Tracker, https://github.com/fa8i/Agentify/issues
11
- Keywords: agentify,agentify-core,agent,multi-agent,ai,llm,openai,framework
10
+ Keywords: agentify,agentify-core,agent,multi-agent,ai,llm,openai,framework,mcp
12
11
  Classifier: Development Status :: 3 - Alpha
13
12
  Classifier: Intended Audience :: Developers
14
13
  Classifier: License :: OSI Approved :: MIT License
@@ -26,14 +25,13 @@ Requires-Dist: openai
26
25
  Requires-Dist: python-dotenv
27
26
  Requires-Dist: Pillow
28
27
  Requires-Dist: jsonschema>=4.0.0
28
+ Requires-Dist: mcp
29
29
  Provides-Extra: redis
30
30
  Requires-Dist: redis>=4.0.0; extra == "redis"
31
31
  Provides-Extra: elastic
32
32
  Requires-Dist: elasticsearch>=8.0.0; extra == "elastic"
33
33
  Provides-Extra: tools
34
34
  Requires-Dist: requests>=2.25.0; extra == "tools"
35
- Provides-Extra: mcp
36
- Requires-Dist: mcp; extra == "mcp"
37
35
  Provides-Extra: ui
38
36
  Requires-Dist: gradio==5.49.1; extra == "ui"
39
37
  Provides-Extra: all
@@ -41,7 +39,6 @@ Requires-Dist: redis>=4.0.0; extra == "all"
41
39
  Requires-Dist: elasticsearch>=8.0.0; extra == "all"
42
40
  Requires-Dist: requests>=2.25.0; extra == "all"
43
41
  Requires-Dist: gradio==5.49.1; extra == "all"
44
- Requires-Dist: mcp; extra == "all"
45
42
  Provides-Extra: dev
46
43
  Requires-Dist: pytest>=7.0.0; extra == "dev"
47
44
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -8,10 +8,11 @@ from agentify.llm.client import LLMClientFactory
8
8
 
9
9
  # Memory exports
10
10
  from agentify.memory.service import MemoryService
11
+ from agentify.memory.async_service import AsyncMemoryService
11
12
  from agentify.memory.interfaces import MemoryAddress
12
13
  from agentify.memory.policies import MemoryPolicy
13
14
 
14
- __version__ = "0.4.0"
15
+ __version__ = "0.3.1"
15
16
 
16
17
  __all__ = [
17
18
  "BaseAgent",
@@ -20,6 +21,7 @@ __all__ = [
20
21
  "tool",
21
22
  "LLMClientFactory",
22
23
  "MemoryService",
24
+ "AsyncMemoryService",
23
25
  "MemoryAddress",
24
26
  "MemoryPolicy",
25
27
  "ImageConfig",
@@ -1,4 +1,3 @@
1
- from __future__ import annotations
2
1
  import asyncio
3
2
  import contextvars
4
3
  import json
@@ -10,7 +9,11 @@ from typing import Any, Dict, Generator, List, Optional, Union, Callable, AsyncG
10
9
 
11
10
  from agentify.core.runnable import Runnable
12
11
 
13
- from openai import RateLimitError
12
+ try:
13
+ from openai import RateLimitError
14
+ except ModuleNotFoundError: # pragma: no cover - optional provider dependency
15
+ class RateLimitError(Exception):
16
+ pass
14
17
  from jsonschema import validate, ValidationError
15
18
 
16
19
  from agentify.core.tool import Tool
@@ -44,6 +47,8 @@ class BaseAgent(Runnable):
44
47
  image_config: Optional[ImageConfig] = None,
45
48
  pre_hooks: Optional[List[Callable]] = None,
46
49
  post_hooks: Optional[List[Callable]] = None,
50
+ tool_pre_hooks: Optional[List[Callable]] = None,
51
+ tool_post_hooks: Optional[List[Callable]] = None,
47
52
  ) -> None:
48
53
  self.config = config
49
54
  self.memory = memory
@@ -51,6 +56,8 @@ class BaseAgent(Runnable):
51
56
  self.image_config = image_config or ImageConfig()
52
57
  self.pre_hooks = pre_hooks or []
53
58
  self.post_hooks = post_hooks or []
59
+ self.tool_pre_hooks = tool_pre_hooks or []
60
+ self.tool_post_hooks = tool_post_hooks or []
54
61
 
55
62
  # Decouple callbacks from config to avoid mutation of shared config
56
63
  self.callbacks = list(self.config.callbacks) if self.config.callbacks else []
@@ -60,24 +67,17 @@ class BaseAgent(Runnable):
60
67
  self._tools: Dict[str, Tool] = {t.name: t for t in tools or []}
61
68
 
62
69
  self._factory = client_factory or LLMClientFactory()
63
- self._client: Optional[LLMClientType] = None
70
+ self.client: LLMClientType = self._factory.create_client(
71
+ provider=self.config.provider,
72
+ config_override=self.config.client_config_override,
73
+ timeout=self.config.timeout,
74
+ )
64
75
  # Async client is created lazily on first arun() call
65
76
  self._async_client: Optional[AsyncLLMClientType] = None
66
77
 
67
78
  # Auto-wrap memory for async operations (transparent to user)
68
79
  self._async_memory = AsyncMemoryService.from_sync(memory)
69
80
 
70
- @property
71
- def client(self) -> LLMClientType:
72
- """Lazily create and return the sync client."""
73
- if self._client is None:
74
- self._client = self._factory.create_client(
75
- provider=self.config.provider,
76
- config_override=self.config.client_config_override,
77
- timeout=self.config.timeout,
78
- )
79
- return self._client
80
-
81
81
  @property
82
82
  def tool_defs(self) -> List[Dict[str, Any]]:
83
83
  """Dynamically generate tool definitions for the LLM."""
@@ -147,12 +147,58 @@ class BaseAgent(Runnable):
147
147
  # Async memory helpers (for async loop)
148
148
 
149
149
  async def _aensure_system_initialized(self, addr: MemoryAddress) -> None:
150
- """Async version: ensure system message is present (non-blocking)."""
150
+ """Async version: ensure system message is present and correct (non-blocking)."""
151
151
  history = await self._async_memory.get_history(addr)
152
- if not history or history[0].get("role") != "system":
152
+
153
+ if not history:
153
154
  await self._async_memory.append_history(
154
155
  addr, {"role": "system", "content": self.config.system_prompt}
155
156
  )
157
+ return
158
+
159
+ is_first_correct = (
160
+ history[0].get("role") == "system"
161
+ and history[0].get("content") == self.config.system_prompt
162
+ )
163
+ has_duplicates = sum(
164
+ 1 for m in history
165
+ if m.get("role") == "system" and m.get("content") == self.config.system_prompt
166
+ ) > 1
167
+
168
+ if not is_first_correct or has_duplicates:
169
+ new_history = [{"role": "system", "content": self.config.system_prompt}]
170
+ for m in history:
171
+ if m.get("role") == "system" and m.get("content") == self.config.system_prompt:
172
+ continue
173
+ if m is history[0] and m.get("role") == "system":
174
+ continue
175
+ new_history.append(m)
176
+
177
+ await self._async_memory.replace_history(addr, new_history)
178
+
179
+ async def _arollback_last_tool_turn(self, addr: MemoryAddress, tool_message_count: int) -> None:
180
+ """Rollback assistant/tool messages from the last tool iteration.
181
+
182
+ This is used when execution is interrupted (for example permission challenge)
183
+ so the next retry does not inherit partial tool-call state.
184
+ """
185
+ if tool_message_count <= 0:
186
+ return
187
+ history = await self._async_memory.get_history(addr)
188
+ remove_count = 1 + tool_message_count # assistant tool_call message + tool messages
189
+ if len(history) <= 1 or len(history) <= remove_count:
190
+ return
191
+
192
+ kept = history[:-remove_count]
193
+ if not kept:
194
+ return
195
+
196
+ first = kept[0]
197
+ if first.get("role") != "system":
198
+ first = {"role": "system", "content": self.config.system_prompt}
199
+ kept = [first] + kept
200
+
201
+ await self._async_memory.replace_history(addr, kept)
156
202
 
157
203
  async def _aadd(
158
204
  self,
@@ -445,6 +491,7 @@ class BaseAgent(Runnable):
445
491
  addr=a,
446
492
  image_path=image_path,
447
493
  image_detail_override=image_detail_override,
494
+ input_role=str(kwargs.get("input_role", "user")),
448
495
  )
449
496
 
450
497
  if self.config.stream:
@@ -581,6 +628,14 @@ class BaseAgent(Runnable):
581
628
 
582
629
  try:
583
630
  self._validate_tool_arguments(tool, arguments)
631
+ for hook in self.tool_pre_hooks:
632
+ self._execute_hook(
633
+ hook,
634
+ agent=self,
635
+ tool=tool,
636
+ tool_name=tool_name,
637
+ arguments=arguments,
638
+ )
584
639
  # Check for async_func attribute (used by AgentTool, FlowTool, SpawnAgentTool)
585
640
  if hasattr(tool, "async_func") and asyncio.iscoroutinefunction(tool.async_func):
586
641
  result = await tool.async_func(**arguments)
@@ -594,10 +649,28 @@ class BaseAgent(Runnable):
594
649
  None, lambda: current_ctx.run(tool, **arguments)
595
650
  )
596
651
  result_str = self._serialize_tool_result(result)
652
+ for hook in self.tool_post_hooks:
653
+ self._execute_hook(
654
+ hook,
655
+ agent=self,
656
+ tool=tool,
657
+ tool_name=tool_name,
658
+ arguments=arguments,
659
+ result=result_str,
660
+ )
597
661
  for cb in self.callbacks:
598
662
  cb.on_tool_finish(tool_name, result_str)
599
663
  return result_str
600
664
  except Exception as e:
665
+ for hook in self.tool_post_hooks:
666
+ self._execute_hook(
667
+ hook,
668
+ agent=self,
669
+ tool=tool,
670
+ tool_name=tool_name,
671
+ arguments=arguments,
672
+ error=e,
673
+ )
601
674
  for cb in self.callbacks:
602
675
  cb.on_error(e, f"Tool execution: {tool_name}")
603
676
  logger.error(
@@ -617,6 +690,7 @@ class BaseAgent(Runnable):
617
690
  tool_call_assembler: Dict[int, Dict[str, Any]] = {}
618
691
  full_content = []
619
692
  full_reasoning = []
693
+ has_reasoning_attr = False
620
694
 
621
695
  async for chunk in response_stream:
622
696
  if not chunk.choices:
@@ -630,10 +704,12 @@ class BaseAgent(Runnable):
630
704
  yield delta.content
631
705
 
632
706
  # Handle reasoning content if present
633
- if hasattr(delta, "reasoning_content") and delta.reasoning_content:
634
- for cb in self.callbacks:
635
- cb.on_reasoning_step(delta.reasoning_content)
636
- full_reasoning.append(delta.reasoning_content)
707
+ if hasattr(delta, "reasoning_content"):
708
+ has_reasoning_attr = True
709
+ if delta.reasoning_content:
710
+ for cb in self.callbacks:
711
+ cb.on_reasoning_step(delta.reasoning_content)
712
+ full_reasoning.append(delta.reasoning_content)
637
713
 
638
714
  if delta.tool_calls:
639
715
  for tc_delta in delta.tool_calls:
@@ -671,7 +747,7 @@ class BaseAgent(Runnable):
671
747
  if call_data.get("function", {}).get("name"):
672
748
  self._last_stream_tool_calls.append(call_data)
673
749
 
674
- self._last_stream_reasoning = "".join(full_reasoning)
750
+ self._last_stream_reasoning = "".join(full_reasoning) if has_reasoning_attr else None
675
751
 
676
752
  async def _aexecute_agent_loop(
677
753
  self,
@@ -680,6 +756,7 @@ class BaseAgent(Runnable):
680
756
  addr: MemoryAddress,
681
757
  image_path: Optional[str] = None,
682
758
  image_detail_override: Optional[str] = None,
759
+ input_role: str = "user",
683
760
  ) -> AsyncGenerator[str, None]:
684
761
  """Async version of the agent loop with parallel tool execution."""
685
762
  await self._aensure_system_initialized(addr)
@@ -696,13 +773,15 @@ class BaseAgent(Runnable):
696
773
  image_detail_override=image_detail_override,
697
774
  )
698
775
  if user_content is not None:
699
- await self._aadd(role="user", content=user_content, addr=addr)
776
+ await self._aadd(role=input_role, content=user_content, addr=addr)
700
777
 
701
778
  accumulated_response: List[str] = []
702
779
 
703
780
  iteration_count = 0
781
+ reached_max_iter = False
704
782
  while True:
705
783
  if self.config.max_tool_iter is not None and iteration_count >= self.config.max_tool_iter:
784
+ reached_max_iter = True
706
785
  break
707
786
  iteration_count += 1
708
787
 
@@ -737,8 +816,16 @@ class BaseAgent(Runnable):
737
816
  # Exit if no tool calls are present
738
817
  if not assembled_tool_calls:
739
818
  msg_kwargs = {}
740
- if full_reasoning_content:
819
+ if full_reasoning_content is not None:
741
820
  msg_kwargs["metadata"] = {"reasoning_content": full_reasoning_content}
821
+ if self.config.stream:
822
+ yield "\x1eagentify_event:" + json.dumps(
823
+ {
824
+ "type": "reasoning_full",
825
+ "text": full_reasoning_content,
826
+ },
827
+ ensure_ascii=False,
828
+ ) + "\x1e"
742
829
 
743
830
  await self._aadd(role="assistant", content=full_turn_content, addr=addr, **msg_kwargs)
744
831
  for cb in self.callbacks:
@@ -750,13 +837,33 @@ class BaseAgent(Runnable):
750
837
  if full_turn_content:
751
838
  assistant_msg["content"] = full_turn_content
752
839
  assistant_msg["tool_calls"] = assembled_tool_calls
753
- if full_reasoning_content:
840
+ if full_reasoning_content is not None:
754
841
  assistant_msg["metadata"] = {"reasoning_content": full_reasoning_content}
842
+
843
+ tool_names = [
844
+ str(tc.get("function", {}).get("name", "")).strip()
845
+ for tc in assembled_tool_calls
846
+ if str(tc.get("function", {}).get("name", "")).strip()
847
+ ]
848
+ for cb in self.callbacks:
849
+ cb_func = getattr(cb, "on_assistant_tool_intent", None)
850
+ if callable(cb_func):
851
+ cb_func(self.config.name, full_turn_content, tool_names)
852
+ if self.config.stream and tool_names:
853
+ yield "\x1eagentify_event:" + json.dumps(
854
+ {
855
+ "type": "assistant_with_tools",
856
+ "content": full_turn_content or "",
857
+ "tools": ", ".join(tool_names),
858
+ "reasoning": full_reasoning_content or "",
859
+ },
860
+ ensure_ascii=False,
861
+ ) + "\x1e"
755
862
 
756
863
  await self._aadd(addr=addr, **assistant_msg)
757
864
 
758
865
  # Execute tools IN PARALLEL using asyncio.gather
759
- async def execute_single_tool(tc: Dict[str, Any]) -> tuple[str, str, str]:
866
+ async def execute_single_tool(tc: Dict[str, Any]) -> tuple[str, str, str, Optional[BaseException]]:
760
867
  tool_name = tc["function"]["name"]
761
868
  tool_call_id = tc["id"]
762
869
  args_str = tc["function"]["arguments"]
@@ -776,16 +883,33 @@ class BaseAgent(Runnable):
776
883
  )
777
884
  }
778
885
  )
886
+ return tool_call_id, tool_name, result_content, None
779
887
  except ValueError as e:
780
888
  result_content = json.dumps({"error": str(e)})
781
- return tool_call_id, tool_name, result_content
889
+ return tool_call_id, tool_name, result_content, None
890
+ except BaseException as e:
891
+ scope = getattr(e, "scope", None)
892
+ reason = getattr(e, "reason", str(e))
893
+ if isinstance(scope, str) and scope:
894
+ result_content = json.dumps(
895
+ {
896
+ "ok": False,
897
+ "error": reason,
898
+ "scope": scope,
899
+ "interrupted": True,
900
+ }
901
+ )
902
+ return tool_call_id, tool_name, result_content, e
903
+ raise
904
+ return tool_call_id, tool_name, result_content, None
782
905
 
783
906
  tool_results = await asyncio.gather(
784
907
  *[execute_single_tool(tc) for tc in assembled_tool_calls]
785
908
  )
786
909
 
787
910
  # Add tool results to memory
788
- for tool_call_id, tool_name, result_content in tool_results:
911
+ interrupt_signal: Optional[BaseException] = None
912
+ for tool_call_id, tool_name, result_content, signal in tool_results:
789
913
  await self._aadd(
790
914
  role="tool",
791
915
  content=result_content,
@@ -793,7 +917,12 @@ class BaseAgent(Runnable):
793
917
  name=tool_name,
794
918
  addr=addr,
795
919
  )
796
- else:
920
+ if interrupt_signal is None and signal is not None:
921
+ interrupt_signal = signal
922
+ if interrupt_signal is not None:
923
+ await self._arollback_last_tool_turn(addr, len(tool_results))
924
+ raise interrupt_signal
925
+ if reached_max_iter:
797
926
  warn_msg = f"\n[WARNING] Agent '{self.config.name}' reached max iterations ({self.config.max_tool_iter}).\n"
798
927
  logger.warning(warn_msg.strip())
799
928
  for cb in self.callbacks:
@@ -23,7 +23,7 @@ class AgentConfig:
23
23
  stream: bool = False
24
24
  max_retries: int = 3
25
25
  verbose: bool = True
26
- max_tool_iter: Optional[int] = 10
26
+ max_tool_iter: Optional[int] = 25
27
27
  delegation_recovery_enabled: bool = True
28
28
  delegation_recovery_mode: str = "retry_isolated"
29
29
  delegation_max_retries: int = 1
@@ -12,13 +12,6 @@ T = TypeVar("T")
12
12
 
13
13
  def has_running_loop() -> bool:
14
14
  """Return True when an event loop is currently running in this thread."""
15
- try:
16
- loop = asyncio.get_event_loop()
17
- if loop.is_running():
18
- return True
19
- except RuntimeError:
20
- pass
21
-
22
15
  try:
23
16
  asyncio.get_running_loop()
24
17
  return True
@@ -1,5 +1,5 @@
1
1
  import os
2
- from typing import Any, Dict, List, Optional
2
+ from typing import Any, Dict, Optional
3
3
  from agentify.core.tool import Tool
4
4
 
5
5
  DEFAULT_MAX_READ_BYTES = 1024 * 1024
@@ -61,7 +61,7 @@ class TodoTool(Tool):
61
61
  # Re-assign IDs to keep them sequential
62
62
  for i, t in enumerate(self._todos):
63
63
  t["id"] = i
64
- return f"Task removed. Remaining tasks re-indexed."
64
+ return "Task removed. Remaining tasks re-indexed."
65
65
  return f"Error: Invalid task_id {task_id}"
66
66
 
67
67
  elif action == "list":
@@ -1,6 +1,6 @@
1
1
  import shlex
2
2
  import subprocess
3
- from typing import Any, Dict, List, Optional
3
+ from typing import Any, Dict, Optional
4
4
 
5
5
  from agentify.core.tool import Tool
6
6
 
@@ -9,26 +9,32 @@ MAX_OUTPUT_CHARS = 8000
9
9
 
10
10
 
11
11
  class ShellSafeTool(Tool):
12
- """Execute a restricted set of local shell commands safely."""
12
+ """Execute local shell commands with guardrails."""
13
13
 
14
14
  def __init__(self, *, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS):
15
15
  self.timeout_seconds = max(1, timeout_seconds)
16
16
  schema = {
17
17
  "name": "run_safe_command",
18
- "description": "Run an allowlisted shell command safely.",
18
+ "description": (
19
+ "Execute a shell command in the local environment using argv parsing (no shell=True). "
20
+ "Use this when the user asks to run terminal commands. "
21
+ "Return stdout/stderr/return_code so the assistant can explain the real result. "
22
+ "Avoid global pip/npm installs unless the user explicitly requests them; "
23
+ "prefer dedicated integration install tools when available."
24
+ ),
19
25
  "parameters": {
20
26
  "type": "object",
21
27
  "properties": {
22
28
  "command": {
23
29
  "type": "string",
24
30
  "description": (
25
- "Command to run. Allowed: pwd, ls [path], whoami, date, "
26
- "uname -a, git status"
31
+ "Exact command string to execute. The runtime prompts the user before execution. "
32
+ "Examples: pwd, cp -r src dst, rsync -av src/ dst/."
27
33
  ),
28
34
  },
29
35
  "timeout_seconds": {
30
36
  "type": "integer",
31
- "description": "Optional timeout override (max 60).",
37
+ "description": "Optional timeout override (max 1800).",
32
38
  },
33
39
  },
34
40
  "required": ["command"],
@@ -42,50 +48,17 @@ class ShellSafeTool(Tool):
42
48
  return {"text": text, "truncated": False}
43
49
  return {"text": text[:MAX_OUTPUT_CHARS], "truncated": True}
44
50
 
45
- @staticmethod
46
- def _is_allowed(tokens: List[str]) -> bool:
47
- if not tokens:
48
- return False
49
-
50
- cmd = tokens[0]
51
- if cmd in {"pwd", "whoami", "date"}:
52
- return len(tokens) == 1
53
-
54
- if cmd == "uname":
55
- return tokens == ["uname", "-a"]
56
-
57
- if cmd == "ls":
58
- # allow: ls OR ls <path>
59
- return len(tokens) in {1, 2}
60
-
61
- if cmd == "git":
62
- return tokens == ["git", "status"]
63
-
64
- return False
65
-
66
51
  def _run_safe_command(self, command: str, timeout_seconds: Optional[int] = None) -> Dict[str, Any]:
67
52
  try:
68
53
  tokens = shlex.split(command)
69
54
  except ValueError as exc:
70
55
  return {"ok": False, "error": f"Invalid command syntax: {exc}"}
71
56
 
72
- if not self._is_allowed(tokens):
73
- return {
74
- "ok": False,
75
- "error": "Command not allowed by safety policy.",
76
- "allowed_examples": [
77
- "pwd",
78
- "ls",
79
- "ls .",
80
- "whoami",
81
- "date",
82
- "uname -a",
83
- "git status",
84
- ],
85
- }
57
+ if not tokens:
58
+ return {"ok": False, "error": "Empty command."}
86
59
 
87
60
  timeout = timeout_seconds if timeout_seconds is not None else self.timeout_seconds
88
- timeout = min(max(1, int(timeout)), 60)
61
+ timeout = min(max(1, int(timeout)), 1800)
89
62
 
90
63
  try:
91
64
  completed = subprocess.run(