agentify-core 0.1.0__py3-none-any.whl
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/__init__.py +32 -0
- agentify/core/__init__.py +13 -0
- agentify/core/agent.py +624 -0
- agentify/core/callbacks.py +73 -0
- agentify/core/config.py +30 -0
- agentify/core/tool.py +30 -0
- agentify/extensions/__init__.py +0 -0
- agentify/extensions/prompts/__init__.py +3 -0
- agentify/extensions/prompts/assistant.py +16 -0
- agentify/extensions/tools/__init__.py +9 -0
- agentify/extensions/tools/calculator.py +56 -0
- agentify/extensions/tools/time.py +21 -0
- agentify/extensions/tools/weather.py +51 -0
- agentify/llm/__init__.py +6 -0
- agentify/llm/client.py +133 -0
- agentify/memory/__init__.py +17 -0
- agentify/memory/interfaces.py +101 -0
- agentify/memory/policies.py +79 -0
- agentify/memory/service.py +114 -0
- agentify/memory/stores/__init__.py +6 -0
- agentify/memory/stores/in_memory_store.py +29 -0
- agentify/memory/stores/redis_store.py +51 -0
- agentify/multi_agent/__init__.py +9 -0
- agentify/multi_agent/hierarchical.py +85 -0
- agentify/multi_agent/pipeline.py +67 -0
- agentify/multi_agent/team.py +62 -0
- agentify/multi_agent/tool_wrapper.py +124 -0
- agentify_core-0.1.0.dist-info/METADATA +148 -0
- agentify_core-0.1.0.dist-info/RECORD +32 -0
- agentify_core-0.1.0.dist-info/WHEEL +5 -0
- agentify_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentify_core-0.1.0.dist-info/top_level.txt +1 -0
agentify/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Core exports
|
|
2
|
+
from agentify.core import (
|
|
3
|
+
BaseAgent,
|
|
4
|
+
Tool,
|
|
5
|
+
AgentConfig,
|
|
6
|
+
ImageConfig,
|
|
7
|
+
AgentCallbackHandler,
|
|
8
|
+
LoggingCallbackHandler,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# LLM exports
|
|
12
|
+
from agentify.llm import LLMClientFactory
|
|
13
|
+
|
|
14
|
+
# Memory exports
|
|
15
|
+
from agentify.memory.service import MemoryService
|
|
16
|
+
from agentify.memory.interfaces import MemoryAddress
|
|
17
|
+
from agentify.memory.policies import MemoryPolicy
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BaseAgent",
|
|
23
|
+
"Tool",
|
|
24
|
+
"AgentConfig",
|
|
25
|
+
"ImageConfig",
|
|
26
|
+
"AgentCallbackHandler",
|
|
27
|
+
"LoggingCallbackHandler",
|
|
28
|
+
"LLMClientFactory",
|
|
29
|
+
"MemoryService",
|
|
30
|
+
"MemoryAddress",
|
|
31
|
+
"MemoryPolicy",
|
|
32
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from agentify.core.agent import BaseAgent
|
|
2
|
+
from agentify.core.tool import Tool
|
|
3
|
+
from agentify.core.callbacks import AgentCallbackHandler, LoggingCallbackHandler
|
|
4
|
+
from agentify.core.config import AgentConfig, ImageConfig
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseAgent",
|
|
8
|
+
"Tool",
|
|
9
|
+
"AgentCallbackHandler",
|
|
10
|
+
"LoggingCallbackHandler",
|
|
11
|
+
"AgentConfig",
|
|
12
|
+
"ImageConfig",
|
|
13
|
+
]
|
agentify/core/agent.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import base64
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import Any, Dict, Generator, List, Optional, Union, Iterator
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from openai import RateLimitError
|
|
12
|
+
|
|
13
|
+
from agentify.core.tool import Tool
|
|
14
|
+
from agentify.llm.client import LLMClientFactory, LLMClientType
|
|
15
|
+
from agentify.memory.service import MemoryService
|
|
16
|
+
from agentify.memory.interfaces import MemoryAddress
|
|
17
|
+
from agentify.core.config import AgentConfig, ImageConfig
|
|
18
|
+
from agentify.core.callbacks import LoggingCallbackHandler
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseAgent:
|
|
24
|
+
"""Framework-agnostic AI Agent core class."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config: AgentConfig,
|
|
29
|
+
memory: MemoryService,
|
|
30
|
+
*,
|
|
31
|
+
memory_address: Optional[MemoryAddress] = None,
|
|
32
|
+
client_factory: Optional[LLMClientFactory] = None,
|
|
33
|
+
tools: Optional[List[Tool]] = None,
|
|
34
|
+
image_config: Optional[ImageConfig] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.config = config
|
|
37
|
+
self.memory = memory
|
|
38
|
+
self.memory_address = memory_address
|
|
39
|
+
self.image_config = image_config or ImageConfig()
|
|
40
|
+
|
|
41
|
+
# Decouple callbacks from config to avoid mutation of shared config
|
|
42
|
+
self.callbacks = list(self.config.callbacks) if self.config.callbacks else []
|
|
43
|
+
if not self.callbacks:
|
|
44
|
+
self.callbacks.append(LoggingCallbackHandler(logger))
|
|
45
|
+
|
|
46
|
+
self._tools: Dict[str, Tool] = {t.name: t for t in tools or []}
|
|
47
|
+
|
|
48
|
+
factory = client_factory or LLMClientFactory()
|
|
49
|
+
self.client: LLMClientType = factory.create_client(
|
|
50
|
+
provider=self.config.provider,
|
|
51
|
+
config_override=self.config.client_config_override,
|
|
52
|
+
timeout=self.config.timeout,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def tool_defs(self) -> List[Dict[str, Any]]:
|
|
57
|
+
"""Dynamically generate tool definitions for the LLM."""
|
|
58
|
+
return [
|
|
59
|
+
{"type": "function", "function": t.schema} for t in self._tools.values()
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def list_tools(self) -> List[str]:
|
|
64
|
+
"""Return the names of registered tools."""
|
|
65
|
+
return list(self._tools.keys())
|
|
66
|
+
|
|
67
|
+
def _encode_image_to_base64(self, image_path: str) -> str:
|
|
68
|
+
"""Opens an image, resizes it, compresses it, and returns it as base64."""
|
|
69
|
+
try:
|
|
70
|
+
with Image.open(image_path) as img_pil:
|
|
71
|
+
if img_pil.mode not in ("RGB", "L"):
|
|
72
|
+
img_pil = img_pil.convert("RGB")
|
|
73
|
+
|
|
74
|
+
max_side = self.image_config.max_side_px
|
|
75
|
+
img_pil.thumbnail((max_side, max_side))
|
|
76
|
+
|
|
77
|
+
buf = BytesIO()
|
|
78
|
+
img_pil.save(
|
|
79
|
+
buf,
|
|
80
|
+
format="JPEG",
|
|
81
|
+
quality=self.image_config.quality,
|
|
82
|
+
optimize=True,
|
|
83
|
+
)
|
|
84
|
+
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
85
|
+
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
logger.error(f"Image file not found: {image_path}")
|
|
88
|
+
raise
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error(f"Image processing error for {image_path}: {e}", exc_info=True)
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
def _build_user_content(
|
|
94
|
+
self,
|
|
95
|
+
user_input: str,
|
|
96
|
+
*,
|
|
97
|
+
image_path: Optional[str] = None,
|
|
98
|
+
image_detail_override: Optional[str] = None,
|
|
99
|
+
) -> Optional[Union[str, List[Dict[str, Any]]]]:
|
|
100
|
+
"""Build the `content` field of the user message supporting:
|
|
101
|
+
- text only
|
|
102
|
+
- image only
|
|
103
|
+
- image + text (OpenAI-like multimodal list)
|
|
104
|
+
"""
|
|
105
|
+
has_text = bool(user_input and user_input.strip())
|
|
106
|
+
has_image = bool(image_path)
|
|
107
|
+
|
|
108
|
+
if not has_text and not has_image:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
if not has_image:
|
|
112
|
+
return user_input
|
|
113
|
+
|
|
114
|
+
b64_image_data = self._encode_image_to_base64(image_path) # type: ignore[arg-type]
|
|
115
|
+
detail_level = image_detail_override or self.image_config.detail
|
|
116
|
+
|
|
117
|
+
parts: List[Dict[str, Any]] = [
|
|
118
|
+
{
|
|
119
|
+
"type": "image_url",
|
|
120
|
+
"image_url": {
|
|
121
|
+
"url": f"data:image/jpeg;base64,{b64_image_data}",
|
|
122
|
+
"detail": detail_level,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
if has_text:
|
|
127
|
+
parts.append({"type": "text", "text": user_input})
|
|
128
|
+
|
|
129
|
+
return parts
|
|
130
|
+
|
|
131
|
+
# Memory helpers
|
|
132
|
+
|
|
133
|
+
def _addr_or_raise(self, addr: Optional[MemoryAddress]) -> MemoryAddress:
|
|
134
|
+
"""Ensure we have a MemoryAddress to operate on."""
|
|
135
|
+
effective = addr or self.memory_address
|
|
136
|
+
if effective is None:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"MemoryAddress required: pass it in constructor (memory_address=...) "
|
|
139
|
+
"or in each call (addr=...)."
|
|
140
|
+
)
|
|
141
|
+
return effective
|
|
142
|
+
|
|
143
|
+
def _ensure_system_initialized(self, addr: MemoryAddress) -> None:
|
|
144
|
+
"""Ensure the system message is present exactly once at the beginning."""
|
|
145
|
+
history = self.memory.get_history(addr)
|
|
146
|
+
if not history or history[0].get("role") != "system":
|
|
147
|
+
self.memory.append_history(
|
|
148
|
+
addr, {"role": "system", "content": self.config.system_prompt}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get_history(self, addr: MemoryAddress) -> List[Dict[str, Any]]:
|
|
152
|
+
"""Return current conversation history for this address."""
|
|
153
|
+
return self.memory.get_history(addr)
|
|
154
|
+
|
|
155
|
+
def add(
|
|
156
|
+
self,
|
|
157
|
+
role: str,
|
|
158
|
+
content: Optional[Union[str, List[Dict[str, Any]]]] = None,
|
|
159
|
+
*,
|
|
160
|
+
addr: Optional[MemoryAddress] = None,
|
|
161
|
+
**kwargs: Any,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Append a message to memory at the provided address."""
|
|
164
|
+
a = self._addr_or_raise(addr)
|
|
165
|
+
msg: Dict[str, Any] = {"role": role}
|
|
166
|
+
if content is not None:
|
|
167
|
+
msg["content"] = content
|
|
168
|
+
msg.update(kwargs)
|
|
169
|
+
self.memory.append_history(a, msg)
|
|
170
|
+
|
|
171
|
+
def clear_memory(self, *, addr: Optional[MemoryAddress] = None) -> None:
|
|
172
|
+
"""Reset history for the provided address to the initial system prompt only."""
|
|
173
|
+
a = self._addr_or_raise(addr)
|
|
174
|
+
self.memory.reset_history(
|
|
175
|
+
a, {"role": "system", "content": self.config.system_prompt}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def save_history(self, path: str, *, addr: Optional[MemoryAddress] = None) -> None:
|
|
179
|
+
"""Persist current history to a local JSON file."""
|
|
180
|
+
a = self._addr_or_raise(addr)
|
|
181
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
182
|
+
json.dump(self.memory.get_history(a), f, ensure_ascii=False, indent=2)
|
|
183
|
+
|
|
184
|
+
def load_history(self, path: str, *, addr: Optional[MemoryAddress] = None) -> None:
|
|
185
|
+
"""Load a previously exported JSON history into this address."""
|
|
186
|
+
a = self._addr_or_raise(addr)
|
|
187
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
188
|
+
raw: List[Dict[str, Any]] = json.load(f)
|
|
189
|
+
|
|
190
|
+
if raw and raw[0].get("role") == "system":
|
|
191
|
+
messages = raw
|
|
192
|
+
else:
|
|
193
|
+
messages = [{"role": "system", "content": self.config.system_prompt}] + raw
|
|
194
|
+
|
|
195
|
+
self.memory.reset_history(a, messages[0])
|
|
196
|
+
for m in messages[1:]:
|
|
197
|
+
self.memory.append_history(a, m)
|
|
198
|
+
|
|
199
|
+
# Core Logic
|
|
200
|
+
|
|
201
|
+
def _get_llm_response(
|
|
202
|
+
self, *, addr: MemoryAddress
|
|
203
|
+
) -> Union[Any, Generator[Dict[str, Any], None, None]]:
|
|
204
|
+
"""Perform the LLM call with retries and error handling."""
|
|
205
|
+
tool_choice_param = "auto" if self._tools else None
|
|
206
|
+
common_params: Dict[str, Any] = {
|
|
207
|
+
"model": self.config.model_name,
|
|
208
|
+
"messages": self.memory.get_history(addr),
|
|
209
|
+
"temperature": self.config.temperature,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Only add tools if they exist
|
|
213
|
+
tools_payload = self.tool_defs
|
|
214
|
+
if tools_payload:
|
|
215
|
+
common_params["tools"] = tools_payload
|
|
216
|
+
common_params["tool_choice"] = tool_choice_param
|
|
217
|
+
|
|
218
|
+
for cb in self.callbacks:
|
|
219
|
+
cb.on_llm_start(self.config.model_name, common_params["messages"])
|
|
220
|
+
|
|
221
|
+
for attempt in range(self.config.max_retries):
|
|
222
|
+
try:
|
|
223
|
+
if self.config.stream:
|
|
224
|
+
return self.client.chat.completions.create(
|
|
225
|
+
**common_params, stream=True
|
|
226
|
+
)
|
|
227
|
+
response = self.client.chat.completions.create(
|
|
228
|
+
**common_params, stream=False
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for cb in self.callbacks:
|
|
232
|
+
cb.on_llm_end(response)
|
|
233
|
+
|
|
234
|
+
if response.choices and len(response.choices) > 0:
|
|
235
|
+
return response.choices[0].message
|
|
236
|
+
raise ValueError("API response did not contain valid 'choices'.")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
# Unify error handling
|
|
239
|
+
for cb in self.callbacks:
|
|
240
|
+
cb.on_error(e, f"_get_llm_response attempt {attempt + 1}")
|
|
241
|
+
|
|
242
|
+
if isinstance(e, RateLimitError):
|
|
243
|
+
if attempt == self.config.max_retries - 1:
|
|
244
|
+
logger.error("API Rate Limit reached after retries.")
|
|
245
|
+
raise
|
|
246
|
+
sleep_time = 2**attempt
|
|
247
|
+
logger.warning(f"Rate limit reached. Retrying in {sleep_time}s...")
|
|
248
|
+
time.sleep(sleep_time)
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
logger.error(
|
|
252
|
+
f"Error in _get_llm_response (attempt {attempt + 1}/{self.config.max_retries}): {e}",
|
|
253
|
+
exc_info=True,
|
|
254
|
+
)
|
|
255
|
+
if attempt == self.config.max_retries - 1:
|
|
256
|
+
raise
|
|
257
|
+
time.sleep(2**attempt)
|
|
258
|
+
|
|
259
|
+
msg = f"LLM completions ({self.client.__class__.__name__}) failed after {self.config.max_retries} retries."
|
|
260
|
+
logger.critical(msg)
|
|
261
|
+
raise RuntimeError(msg)
|
|
262
|
+
|
|
263
|
+
def _split_concatenated_json_objects(self, json_string: str) -> List[str]:
|
|
264
|
+
"""Attempt to split a string that may contain multiple concatenated JSON objects."""
|
|
265
|
+
objects_str: List[str] = []
|
|
266
|
+
decoder = json.JSONDecoder()
|
|
267
|
+
s = json_string.strip()
|
|
268
|
+
pos = 0
|
|
269
|
+
|
|
270
|
+
if not s:
|
|
271
|
+
return []
|
|
272
|
+
try:
|
|
273
|
+
json.loads(s)
|
|
274
|
+
return [s] # single valid JSON
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
while pos < len(s):
|
|
279
|
+
try:
|
|
280
|
+
_, consumed = decoder.raw_decode(s[pos:])
|
|
281
|
+
objects_str.append(s[pos : pos + consumed])
|
|
282
|
+
pos += consumed
|
|
283
|
+
while pos < len(s) and s[pos].isspace():
|
|
284
|
+
pos += 1
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
if not objects_str:
|
|
287
|
+
logger.warning(f"Could not decode JSON from: '{json_string}'")
|
|
288
|
+
return [json_string]
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"Agent '{self.config.name}': could not decode more JSON at pos {pos} "
|
|
291
|
+
f"of '{s}'. Parsed objects: {len(objects_str)}."
|
|
292
|
+
)
|
|
293
|
+
break
|
|
294
|
+
return objects_str if objects_str else [json_string]
|
|
295
|
+
|
|
296
|
+
def _parse_tool_arguments(self, tool_name: str, args_value: Any) -> Dict[str, Any]:
|
|
297
|
+
"""Safely parse tool arguments from various formats."""
|
|
298
|
+
if args_value is None:
|
|
299
|
+
args_str = "{}"
|
|
300
|
+
elif isinstance(args_value, str):
|
|
301
|
+
args_str = args_value
|
|
302
|
+
else:
|
|
303
|
+
args_str = json.dumps(args_value)
|
|
304
|
+
|
|
305
|
+
if not args_str.strip():
|
|
306
|
+
args_str = "{}"
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
return json.loads(args_str)
|
|
310
|
+
except json.JSONDecodeError as exc:
|
|
311
|
+
logger.warning(
|
|
312
|
+
f"Invalid JSON arguments for '{tool_name}': {exc}. Received: '{args_str}'"
|
|
313
|
+
)
|
|
314
|
+
raise ValueError(f"Invalid JSON arguments: {exc}")
|
|
315
|
+
|
|
316
|
+
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
317
|
+
"""Execute a single tool and return its output as a string."""
|
|
318
|
+
tool = self._tools.get(tool_name)
|
|
319
|
+
|
|
320
|
+
for cb in self.callbacks:
|
|
321
|
+
cb.on_tool_start(tool_name, arguments)
|
|
322
|
+
|
|
323
|
+
if not tool:
|
|
324
|
+
err_msg = json.dumps({"error": f"Tool '{tool_name}' is not registered."})
|
|
325
|
+
for cb in self.callbacks:
|
|
326
|
+
cb.on_tool_finish(tool_name, err_msg)
|
|
327
|
+
return err_msg
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
result = tool(**arguments)
|
|
331
|
+
result_str = str(result)
|
|
332
|
+
for cb in self.callbacks:
|
|
333
|
+
cb.on_tool_finish(tool_name, result_str)
|
|
334
|
+
return result_str
|
|
335
|
+
except Exception as e:
|
|
336
|
+
for cb in self.callbacks:
|
|
337
|
+
cb.on_error(e, f"Tool execution: {tool_name}")
|
|
338
|
+
logger.error(
|
|
339
|
+
f"Unexpected error executing tool '{tool_name}': {e}", exc_info=True
|
|
340
|
+
)
|
|
341
|
+
return json.dumps(
|
|
342
|
+
{"error": f"Unexpected error executing tool '{tool_name}': {e}"}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _process_stream_response(
|
|
346
|
+
self, response_stream: Iterator[Any]
|
|
347
|
+
) -> Generator[str, None, List[Dict[str, Any]]]:
|
|
348
|
+
"""
|
|
349
|
+
Process streaming response, yielding content and collecting tool calls.
|
|
350
|
+
Returns the assembled tool calls.
|
|
351
|
+
"""
|
|
352
|
+
tool_call_assembler: Dict[int, Dict[str, Any]] = {}
|
|
353
|
+
|
|
354
|
+
full_content = []
|
|
355
|
+
|
|
356
|
+
for chunk in response_stream:
|
|
357
|
+
if not chunk.choices:
|
|
358
|
+
continue
|
|
359
|
+
delta = chunk.choices[0].delta
|
|
360
|
+
|
|
361
|
+
if delta.content:
|
|
362
|
+
for cb in self.callbacks:
|
|
363
|
+
cb.on_llm_new_token(delta.content)
|
|
364
|
+
full_content.append(delta.content)
|
|
365
|
+
yield delta.content
|
|
366
|
+
|
|
367
|
+
if delta.tool_calls:
|
|
368
|
+
for tc_delta in delta.tool_calls:
|
|
369
|
+
idx = tc_delta.index
|
|
370
|
+
if idx not in tool_call_assembler:
|
|
371
|
+
tool_call_assembler[idx] = {
|
|
372
|
+
"id": None,
|
|
373
|
+
"type": "function",
|
|
374
|
+
"function": {"name": "", "arguments": ""},
|
|
375
|
+
}
|
|
376
|
+
call_data = tool_call_assembler[idx]
|
|
377
|
+
if tc_delta.id and not call_data["id"]:
|
|
378
|
+
call_data["id"] = tc_delta.id
|
|
379
|
+
if tc_delta.function:
|
|
380
|
+
if tc_delta.function.name:
|
|
381
|
+
call_data["function"]["name"] = tc_delta.function.name
|
|
382
|
+
if tc_delta.function.arguments:
|
|
383
|
+
call_data["function"]["arguments"] += (
|
|
384
|
+
tc_delta.function.arguments
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Call on_llm_end with the full accumulated content
|
|
388
|
+
full_response_text = "".join(full_content)
|
|
389
|
+
for cb in self.callbacks:
|
|
390
|
+
# Construct a minimal mock response object or just pass the text if the handler supports it.
|
|
391
|
+
# The protocol says 'response: Any'.
|
|
392
|
+
cb.on_llm_end(full_response_text)
|
|
393
|
+
|
|
394
|
+
assembled_tool_calls = []
|
|
395
|
+
for idx in sorted(tool_call_assembler.keys()):
|
|
396
|
+
call_data = tool_call_assembler[idx]
|
|
397
|
+
if not call_data.get("id"):
|
|
398
|
+
call_data["id"] = (
|
|
399
|
+
f"s_{self.config.provider[:3]}_tc_{idx}_{uuid.uuid4().hex[:6]}"
|
|
400
|
+
)
|
|
401
|
+
if call_data.get("function", {}).get("name"):
|
|
402
|
+
assembled_tool_calls.append(call_data)
|
|
403
|
+
|
|
404
|
+
return assembled_tool_calls
|
|
405
|
+
|
|
406
|
+
def _process_sync_response(
|
|
407
|
+
self, msg_object: Any
|
|
408
|
+
) -> tuple[Optional[str], List[Dict[str, Any]]]:
|
|
409
|
+
"""Process synchronous response, returning content and tool calls."""
|
|
410
|
+
content = getattr(msg_object, "content", None)
|
|
411
|
+
tool_calls = []
|
|
412
|
+
|
|
413
|
+
if getattr(msg_object, "tool_calls", None):
|
|
414
|
+
for i, tc in enumerate(msg_object.tool_calls):
|
|
415
|
+
tc_id = (
|
|
416
|
+
tc.id
|
|
417
|
+
or f"ns_{self.config.provider[:3]}_tc_{i}_{uuid.uuid4().hex[:6]}"
|
|
418
|
+
)
|
|
419
|
+
tool_calls.append(
|
|
420
|
+
{
|
|
421
|
+
"id": tc_id,
|
|
422
|
+
"type": "function",
|
|
423
|
+
"function": {
|
|
424
|
+
"name": tc.function.name,
|
|
425
|
+
"arguments": tc.function.arguments or "{}",
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return content, tool_calls
|
|
431
|
+
|
|
432
|
+
def _expand_tool_calls(
|
|
433
|
+
self, tool_calls: List[Dict[str, Any]]
|
|
434
|
+
) -> List[Dict[str, Any]]:
|
|
435
|
+
"""Handle cases where the model concatenates multiple JSON objects in one argument string."""
|
|
436
|
+
expanded_tool_calls: List[Dict[str, Any]] = []
|
|
437
|
+
|
|
438
|
+
for tc in tool_calls:
|
|
439
|
+
tool_name = tc.get("function", {}).get("name", "unknown_tool")
|
|
440
|
+
args_value = tc.get("function", {}).get("arguments")
|
|
441
|
+
original_id = tc.get("id", f"gen_id_{uuid.uuid4().hex[:4]}")
|
|
442
|
+
|
|
443
|
+
if args_value is None:
|
|
444
|
+
args_str = ""
|
|
445
|
+
elif isinstance(args_value, str):
|
|
446
|
+
args_str = args_value
|
|
447
|
+
else:
|
|
448
|
+
args_str = json.dumps(args_value)
|
|
449
|
+
|
|
450
|
+
if not args_str.strip():
|
|
451
|
+
tc["function"]["arguments"] = "{}"
|
|
452
|
+
expanded_tool_calls.append(tc)
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
split_args_json = self._split_concatenated_json_objects(args_str)
|
|
456
|
+
|
|
457
|
+
if len(split_args_json) > 1:
|
|
458
|
+
for i, single_arg_json in enumerate(split_args_json):
|
|
459
|
+
expanded_tool_calls.append(
|
|
460
|
+
{
|
|
461
|
+
"id": f"{original_id}_part_{i}",
|
|
462
|
+
"type": "function",
|
|
463
|
+
"function": {
|
|
464
|
+
"name": tool_name,
|
|
465
|
+
"arguments": single_arg_json,
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
elif len(split_args_json) == 1:
|
|
470
|
+
tc["function"]["arguments"] = split_args_json[0]
|
|
471
|
+
expanded_tool_calls.append(tc)
|
|
472
|
+
else:
|
|
473
|
+
# Fallback
|
|
474
|
+
expanded_tool_calls.append(tc)
|
|
475
|
+
|
|
476
|
+
return expanded_tool_calls
|
|
477
|
+
|
|
478
|
+
def _execute_agent_loop(
|
|
479
|
+
self,
|
|
480
|
+
user_input: str,
|
|
481
|
+
*,
|
|
482
|
+
addr: MemoryAddress,
|
|
483
|
+
image_path: Optional[str] = None,
|
|
484
|
+
image_detail_override: Optional[str] = None,
|
|
485
|
+
) -> Generator[str, None, None]:
|
|
486
|
+
"""Orchestrates the agent logic:
|
|
487
|
+
1. Prepare context (system + user message).
|
|
488
|
+
2. Loop:
|
|
489
|
+
- Get LLM response (stream or sync).
|
|
490
|
+
- Yield content if streaming.
|
|
491
|
+
- If tool calls: execute and append results.
|
|
492
|
+
- If no tool calls: break.
|
|
493
|
+
"""
|
|
494
|
+
self._ensure_system_initialized(addr)
|
|
495
|
+
|
|
496
|
+
for cb in self.callbacks:
|
|
497
|
+
cb.on_agent_start(self.config.name, user_input)
|
|
498
|
+
|
|
499
|
+
user_content = self._build_user_content(
|
|
500
|
+
user_input,
|
|
501
|
+
image_path=image_path,
|
|
502
|
+
image_detail_override=image_detail_override,
|
|
503
|
+
)
|
|
504
|
+
if user_content is not None:
|
|
505
|
+
self.add(role="user", content=user_content, addr=addr)
|
|
506
|
+
|
|
507
|
+
for _ in range(self.config.max_tool_iter):
|
|
508
|
+
response_or_stream = self._get_llm_response(addr=addr)
|
|
509
|
+
|
|
510
|
+
current_turn_content_parts: List[str] = []
|
|
511
|
+
assembled_tool_calls: List[Dict[str, Any]] = []
|
|
512
|
+
|
|
513
|
+
if self.config.stream:
|
|
514
|
+
gen = self._process_stream_response(response_or_stream) # type: ignore
|
|
515
|
+
try:
|
|
516
|
+
while True:
|
|
517
|
+
content_chunk = next(gen)
|
|
518
|
+
yield content_chunk
|
|
519
|
+
current_turn_content_parts.append(content_chunk)
|
|
520
|
+
except StopIteration as e:
|
|
521
|
+
assembled_tool_calls = e.value
|
|
522
|
+
else:
|
|
523
|
+
content, assembled_tool_calls = self._process_sync_response(
|
|
524
|
+
response_or_stream
|
|
525
|
+
)
|
|
526
|
+
if content:
|
|
527
|
+
yield content
|
|
528
|
+
current_turn_content_parts.append(content)
|
|
529
|
+
|
|
530
|
+
# Expand tool calls (fix for some models)
|
|
531
|
+
assembled_tool_calls = self._expand_tool_calls(assembled_tool_calls)
|
|
532
|
+
full_turn_content = "".join(current_turn_content_parts)
|
|
533
|
+
|
|
534
|
+
# If no tool calls, we are done
|
|
535
|
+
if not assembled_tool_calls:
|
|
536
|
+
self.add(role="assistant", content=full_turn_content, addr=addr)
|
|
537
|
+
for cb in self.callbacks:
|
|
538
|
+
cb.on_agent_finish(self.config.name, full_turn_content)
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
# Record assistant message with tool calls
|
|
542
|
+
assistant_msg: Dict[str, Any] = {"role": "assistant"}
|
|
543
|
+
if full_turn_content:
|
|
544
|
+
assistant_msg["content"] = full_turn_content
|
|
545
|
+
assistant_msg["tool_calls"] = assembled_tool_calls
|
|
546
|
+
self.add(addr=addr, **assistant_msg)
|
|
547
|
+
|
|
548
|
+
# Execute tools
|
|
549
|
+
for tc in assembled_tool_calls:
|
|
550
|
+
tool_name = tc["function"]["name"]
|
|
551
|
+
tool_call_id = tc["id"]
|
|
552
|
+
args_str = tc["function"]["arguments"]
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
args = self._parse_tool_arguments(tool_name, args_str)
|
|
556
|
+
result_content = self._execute_tool(tool_name, args)
|
|
557
|
+
except ValueError as e:
|
|
558
|
+
result_content = json.dumps({"error": str(e)})
|
|
559
|
+
|
|
560
|
+
self.add(
|
|
561
|
+
role="tool",
|
|
562
|
+
content=result_content,
|
|
563
|
+
tool_call_id=tool_call_id,
|
|
564
|
+
name=tool_name,
|
|
565
|
+
addr=addr,
|
|
566
|
+
)
|
|
567
|
+
else:
|
|
568
|
+
warn_msg = f"\n[WARNING] Agent '{self.config.name}' reached max iterations ({self.config.max_tool_iter}).\n"
|
|
569
|
+
logger.warning(warn_msg.strip())
|
|
570
|
+
# Notify finish even on max iterations
|
|
571
|
+
for cb in self.callbacks:
|
|
572
|
+
cb.on_agent_finish(self.config.name, warn_msg)
|
|
573
|
+
yield warn_msg
|
|
574
|
+
|
|
575
|
+
# Public entrypoint
|
|
576
|
+
|
|
577
|
+
def respond(
|
|
578
|
+
self,
|
|
579
|
+
user_input: str,
|
|
580
|
+
*,
|
|
581
|
+
addr: Optional[MemoryAddress] = None,
|
|
582
|
+
image_path: Optional[str] = None,
|
|
583
|
+
image_detail_override: Optional[str] = None,
|
|
584
|
+
) -> Union[str, Generator[str, None, None]]:
|
|
585
|
+
"""Main entrypoint to interact with the agent."""
|
|
586
|
+
a = self._addr_or_raise(addr)
|
|
587
|
+
response_generator = self._execute_agent_loop(
|
|
588
|
+
user_input,
|
|
589
|
+
addr=a,
|
|
590
|
+
image_path=image_path,
|
|
591
|
+
image_detail_override=image_detail_override,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
if self.config.stream:
|
|
595
|
+
return response_generator
|
|
596
|
+
|
|
597
|
+
parts: List[str] = list(response_generator)
|
|
598
|
+
return "".join(parts).strip()
|
|
599
|
+
|
|
600
|
+
# Tool registry management
|
|
601
|
+
|
|
602
|
+
def tool_exists(self, name: str) -> bool:
|
|
603
|
+
"""Check whether a tool is registered."""
|
|
604
|
+
return name in self._tools
|
|
605
|
+
|
|
606
|
+
def unregister_tool(self, name: str) -> bool:
|
|
607
|
+
"""Unregister a tool. Returns True if removed, False if missing."""
|
|
608
|
+
if name not in self._tools:
|
|
609
|
+
return False
|
|
610
|
+
self._tools.pop(name)
|
|
611
|
+
return True
|
|
612
|
+
|
|
613
|
+
def register_tool(self, tool: Tool) -> None:
|
|
614
|
+
"""Register (or replace) a tool."""
|
|
615
|
+
if tool.name in self._tools:
|
|
616
|
+
# Check if it's the same tool object
|
|
617
|
+
if self._tools[tool.name] == tool:
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
logger.debug(
|
|
621
|
+
f"Overwriting existing tool '{tool.name}' in agent '{self.config.name}'"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
self._tools[tool.name] = tool
|