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 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