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.
- {agentify_core-0.4.0/agentify_core.egg-info → agentify_core-0.4.1}/PKG-INFO +4 -7
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/__init__.py +3 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/agent.py +157 -28
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/config.py +1 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/sync_bridge.py +0 -7
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/filesystem.py +1 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/planning.py +1 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/shell_safe.py +15 -42
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/llm/client.py +105 -67
- agentify_core-0.4.1/agentify/mcp/adapter.py +76 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/mcp/client.py +4 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/async_service.py +5 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/service.py +7 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/elastic_store.py +28 -9
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/sqlite_store.py +1 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/hierarchical.py +4 -3
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/pipeline.py +9 -5
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/tool_wrapper.py +28 -8
- {agentify_core-0.4.0 → agentify_core-0.4.1/agentify_core.egg-info}/PKG-INFO +4 -7
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/SOURCES.txt +2 -2
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/requires.txt +1 -4
- {agentify_core-0.4.0 → agentify_core-0.4.1}/pyproject.toml +17 -11
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_async_memory_service.py +42 -1
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_mcp.py +4 -4
- agentify_core-0.4.1/tests/test_spawn_agent_tool.py +86 -0
- agentify_core-0.4.1/tests/test_tool_hooks.py +79 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_verify_hooks.py +8 -8
- agentify_core-0.4.0/MANIFEST.in +0 -10
- agentify_core-0.4.0/agentify/mcp/adapter.py +0 -46
- agentify_core-0.4.0/requirements.txt +0 -27
- {agentify_core-0.4.0 → agentify_core-0.4.1}/LICENSE +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/README.md +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/README_PYPI.md +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/callbacks.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/multimodal.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/runnable.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/core/tool.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/prompts/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/prompts/assistant.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/calculator.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/time.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/extensions/tools/weather.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/llm/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/mcp/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/interfaces.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/policies.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/in_memory_store.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/memory/stores/redis_store.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/multi_agent/team.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/utils/__init__.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify/utils/style.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/dependency_links.txt +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/agentify_core.egg-info/top_level.txt +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/setup.cfg +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_filesystem_tools.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_memory_address.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_memory_logging.py +0 -0
- {agentify_core-0.4.0 → agentify_core-0.4.1}/tests/test_planning_tool.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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")
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
|
@@ -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
|
|
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,
|
|
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
|
|
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":
|
|
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
|
-
"
|
|
26
|
-
"
|
|
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
|
|
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
|
|
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)),
|
|
61
|
+
timeout = min(max(1, int(timeout)), 1800)
|
|
89
62
|
|
|
90
63
|
try:
|
|
91
64
|
completed = subprocess.run(
|