glaip-sdk 0.7.6__py3-none-any.whl → 0.7.8__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.
@@ -523,6 +523,21 @@ class AccountStore:
523
523
 
524
524
  self._save_config(config)
525
525
 
526
+ def save_config_updates(self, config: dict[str, Any]) -> None:
527
+ """Save config updates, preserving all existing keys.
528
+
529
+ This method allows external code to update arbitrary config keys
530
+ (e.g., TUI preferences) while preserving the full config structure.
531
+
532
+ Args:
533
+ config: Complete configuration dictionary to save. This should
534
+ include all keys that should be preserved, not just updates.
535
+
536
+ Raises:
537
+ AccountStoreError: If config file cannot be written.
538
+ """
539
+ self._save_config(config)
540
+
526
541
 
527
542
  # Global instance for convenience
528
543
  _account_store = AccountStore()
glaip_sdk/cli/config.py CHANGED
@@ -47,6 +47,7 @@ _PRESERVE_KEYS = {
47
47
  "version",
48
48
  "active_account",
49
49
  "accounts",
50
+ "tui",
50
51
  }
51
52
 
52
53
 
@@ -3,6 +3,11 @@
3
3
  Provides a minimal interactive list with the same columns/order as the Rich
4
4
  fallback (name, API URL, masked key, status) and keyboard navigation.
5
5
 
6
+ Integrates with TUI foundation services:
7
+ - KeybindRegistry: Centralized keybind registration with scoped actions
8
+ - ClipboardAdapter: Cross-platform clipboard operations with OSC 52 support
9
+ - ToastBus: Non-blocking toast notifications for user feedback
10
+
6
11
  Authors:
7
12
  Raymond Christopher (raymond.christopher@gdplabs.id)
8
13
  """
@@ -23,10 +28,13 @@ from glaip_sdk.cli.slash.accounts_shared import (
23
28
  env_credentials_present,
24
29
  )
25
30
  from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
26
- from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
31
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
27
32
  from glaip_sdk.cli.slash.tui.context import TUIContext
33
+ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
28
34
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
35
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
29
36
  from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
37
+ from glaip_sdk.cli.slash.tui.toast import ToastBus
30
38
  from glaip_sdk.cli.validators import validate_api_key
31
39
  from glaip_sdk.utils.validation import validate_url
32
40
 
@@ -84,6 +92,30 @@ FORM_KEY_ID = "#form-key"
84
92
  # CSS file name
85
93
  CSS_FILE_NAME = "accounts.tcss"
86
94
 
95
+ KEYBIND_SCOPE = "accounts"
96
+ KEYBIND_CATEGORY = "Accounts"
97
+
98
+
99
+ @dataclass
100
+ class KeybindDef:
101
+ """Keybind definition with action, key, and description."""
102
+
103
+ action: str
104
+ key: str
105
+ description: str
106
+
107
+
108
+ KEYBIND_DEFINITIONS: tuple[KeybindDef, ...] = (
109
+ KeybindDef("switch_row", "enter", "Switch"),
110
+ KeybindDef("focus_filter", "/", "Filter"),
111
+ KeybindDef("add_account", "a", "Add"),
112
+ KeybindDef("edit_account", "e", "Edit"),
113
+ KeybindDef("delete_account", "d", "Delete"),
114
+ KeybindDef("copy_account", "c", "Copy"),
115
+ KeybindDef("clear_or_exit", "escape", "Close"),
116
+ KeybindDef("app_exit", "q", "Close"),
117
+ )
118
+
87
119
 
88
120
  @dataclass
89
121
  class AccountsTUICallbacks:
@@ -250,6 +282,27 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
250
282
  self._connection_tester = connection_tester
251
283
  self._validate_name = validate_name
252
284
 
285
+ def _get_api_url_suggestions(self, _value: str) -> list[str]:
286
+ """Get API URL suggestions from existing accounts.
287
+
288
+ Args:
289
+ _value: Current input value (unused, but required by Textual's suggestor API).
290
+
291
+ Returns:
292
+ List of unique API URLs from existing accounts.
293
+ """
294
+ try:
295
+ store = get_account_store()
296
+ accounts = store.list_accounts()
297
+ # Extract unique API URLs, excluding the current account's URL in edit mode
298
+ existing_url = self._existing.get("api_url", "")
299
+ urls = {account.get("api_url", "") for account in accounts.values() if account.get("api_url")}
300
+ if existing_url in urls:
301
+ urls.remove(existing_url)
302
+ return sorted(urls)
303
+ except Exception: # pragma: no cover - defensive
304
+ return []
305
+
253
306
  def compose(self) -> ComposeResult:
254
307
  """Render the form controls."""
255
308
  title = "Add account" if self._mode == "add" else "Edit account"
@@ -259,7 +312,12 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
259
312
  id="form-name",
260
313
  disabled=self._mode == "edit",
261
314
  )
262
- url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
315
+ url_input = Input(
316
+ value=self._existing.get("api_url", ""),
317
+ placeholder="https://api.example.com",
318
+ id="form-url",
319
+ suggestions=self._get_api_url_suggestions,
320
+ )
263
321
  key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
264
322
  test_checkbox = Checkbox(
265
323
  "Test connection before save",
@@ -427,8 +485,13 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
427
485
  self._env_lock = env_lock
428
486
  self._callbacks = callbacks
429
487
  self._ctx = ctx
488
+ self._keybinds: KeybindRegistry | None = None
489
+ self._toast_bus: ToastBus | None = None
490
+ self._toast_ready = False
491
+ self._clipboard: ClipboardAdapter | None = None
430
492
  self._filter_text: str = ""
431
493
  self._is_switching = False
494
+ self._initialize_context_services()
432
495
 
433
496
  def compose(self) -> ComposeResult:
434
497
  """Build the Textual layout."""
@@ -481,6 +544,52 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
481
544
  main = self.query_one(Vertical)
482
545
  main.styles.gap = 0
483
546
  self._update_filter_button_visibility()
547
+ self._prepare_toasts()
548
+ self._register_keybinds()
549
+
550
+ def _initialize_context_services(self) -> None:
551
+ if self._ctx:
552
+ if self._ctx.keybinds is None:
553
+ self._ctx.keybinds = KeybindRegistry()
554
+ if self._ctx.toasts is None:
555
+ self._ctx.toasts = ToastBus()
556
+ if self._ctx.clipboard is None:
557
+ self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
558
+ self._keybinds = self._ctx.keybinds
559
+ self._toast_bus = self._ctx.toasts
560
+ self._clipboard = self._ctx.clipboard
561
+ else:
562
+ # Fallback: create services independently when ctx is None
563
+ terminal = TerminalCapabilities(
564
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
565
+ )
566
+ self._clipboard = ClipboardAdapter(terminal=terminal)
567
+
568
+ def _prepare_toasts(self) -> None:
569
+ """Prepare toast system by marking ready and clearing any existing toasts."""
570
+ self._toast_ready = True
571
+ if self._toast_bus:
572
+ self._toast_bus.clear()
573
+
574
+ def _register_keybinds(self) -> None:
575
+ if not self._keybinds:
576
+ return
577
+ for keybind_def in KEYBIND_DEFINITIONS:
578
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
579
+ if self._keybinds.get(scoped_action):
580
+ continue
581
+ try:
582
+ self._keybinds.register(
583
+ action=scoped_action,
584
+ key=keybind_def.key,
585
+ description=keybind_def.description,
586
+ category=KEYBIND_CATEGORY,
587
+ )
588
+ except ValueError as e:
589
+ # Expected: duplicate registration (already registered by another component)
590
+ # Silently skip to allow multiple apps to register same keybinds
591
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
592
+ continue
484
593
 
485
594
  def _header_text(self) -> str:
486
595
  """Build header text with active account and host."""
@@ -643,6 +752,24 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
643
752
  """Hide the loading indicator."""
644
753
  hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
645
754
 
755
+ def _handle_switch_scheduling_error(self, exc: Exception) -> None:
756
+ """Handle errors when scheduling the switch task fails.
757
+
758
+ Args:
759
+ exc: The exception that occurred during task scheduling.
760
+ """
761
+ self._hide_loading()
762
+ self._is_switching = False
763
+ error_msg = f"Switch failed to start: {exc}"
764
+ if self._toast_ready and self._toast_bus:
765
+ self._toast_bus.show(message=error_msg, variant="error")
766
+ try:
767
+ self._set_status(error_msg, "red")
768
+ except Exception:
769
+ # App not mounted yet, status update not possible
770
+ logging.error(error_msg, exc_info=exc)
771
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
772
+
646
773
  def _clear_filter(self) -> None:
647
774
  """Clear the filter input and reset filter state."""
648
775
  filter_input = self.query_one(FILTER_INPUT_ID, Input)
@@ -665,7 +792,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
665
792
 
666
793
  if switched:
667
794
  self._active_account = name
668
- self._set_status(message or f"Switched to '{name}'.", "green")
795
+ status_msg = message or f"Switched to '{name}'."
796
+ if self._toast_ready and self._toast_bus:
797
+ self._toast_bus.show(message=status_msg, variant="success")
669
798
  self._update_header()
670
799
  self._reload_rows()
671
800
  else:
@@ -674,11 +803,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
674
803
  try:
675
804
  self.track_task(perform(), logger=logging.getLogger(__name__))
676
805
  except Exception as exc:
677
- # If scheduling the task fails, clear loading/switching state and surface the error.
678
- self._hide_loading()
679
- self._is_switching = False
680
- self._set_status(f"Switch failed to start: {exc}", "red")
681
- logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
806
+ self._handle_switch_scheduling_error(exc)
682
807
 
683
808
  def _update_header(self) -> None:
684
809
  """Refresh header text to reflect active/lock state."""
@@ -781,12 +906,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
781
906
  return
782
907
 
783
908
  text = f"Account: {name}\nURL: {account.get('api_url', '')}"
784
- adapter = ClipboardAdapter()
785
- result = adapter.copy(text)
909
+ adapter = self._clipboard or ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
910
+ # OSC 52 works by writing to stdout, no custom writer needed
911
+ try:
912
+ asyncio.get_running_loop()
913
+ except RuntimeError:
914
+ result = adapter.copy(text)
915
+ self._handle_copy_result(name, result)
916
+ return
917
+
918
+ async def perform() -> None:
919
+ result = await asyncio.to_thread(adapter.copy, text)
920
+ self._handle_copy_result(name, result)
921
+
922
+ self.track_task(perform(), logger=logging.getLogger(__name__))
786
923
 
924
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
925
+ """Update UI state after a copy attempt."""
787
926
  if result.success:
927
+ if self._toast_ready and self._toast_bus:
928
+ self._toast_bus.copy_success(label=name)
929
+ # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
788
930
  self._set_status(f"Copied '{name}' to clipboard.", "green")
789
931
  else:
932
+ if self._toast_ready and self._toast_bus:
933
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant="warning")
934
+ # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
790
935
  self._set_status(f"Copy failed: {result.message}", "red")
791
936
 
792
937
  def _check_env_lock_hotkey(self) -> bool:
@@ -12,13 +12,14 @@ from __future__ import annotations
12
12
 
13
13
  import os
14
14
  from dataclasses import dataclass
15
- from typing import TYPE_CHECKING
16
15
 
16
+ from glaip_sdk.cli.account_store import get_account_store
17
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
18
+ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
17
19
  from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
18
20
  from glaip_sdk.cli.slash.tui.theme import ThemeManager
19
-
20
- if TYPE_CHECKING:
21
- from glaip_sdk.cli.slash.tui.toast import ToastBus
21
+ from glaip_sdk.cli.slash.tui.toast import ToastBus
22
+ from glaip_sdk.cli.tui_settings import load_tui_settings
22
23
 
23
24
 
24
25
  @dataclass
@@ -37,23 +38,47 @@ class TUIContext:
37
38
  """
38
39
 
39
40
  terminal: TerminalCapabilities
40
- keybinds: object | None = None
41
+ keybinds: KeybindRegistry | None = None
41
42
  theme: ThemeManager | None = None
42
43
  toasts: ToastBus | None = None
43
- clipboard: object | None = None
44
+ clipboard: ClipboardAdapter | None = None
44
45
 
45
46
  @classmethod
46
47
  async def create(cls) -> TUIContext:
47
48
  """Create a TUIContext instance with detected terminal capabilities.
48
49
 
49
50
  This factory method detects terminal capabilities asynchronously and
50
- returns a populated TUIContext instance. Other components (keybinds,
51
- theme, toasts, clipboard) will be set incrementally as they are created.
51
+ returns a populated TUIContext instance with all services initialized
52
+ (keybinds, theme, toasts, clipboard).
52
53
 
53
54
  Returns:
54
- TUIContext instance with terminal capabilities detected.
55
+ TUIContext instance with all services initialized.
55
56
  """
56
57
  terminal = await TerminalCapabilities.detect()
57
- theme_name = os.getenv("AIP_TUI_THEME") or None
58
- theme = ThemeManager(terminal, theme=theme_name)
59
- return cls(terminal=terminal, theme=theme)
58
+ store = get_account_store()
59
+ settings = load_tui_settings(store=store)
60
+
61
+ # Handle env var override: normalize empty strings and "default" to None
62
+ # Empty string from os.getenv() is falsy, so strip() result becomes None in the or expression
63
+ env_theme = os.getenv("AIP_TUI_THEME")
64
+ env_theme = env_theme.strip() if env_theme else None
65
+ if env_theme and env_theme.lower() == "default":
66
+ env_theme = None
67
+
68
+ theme_name = env_theme or settings.theme_name
69
+ theme = ThemeManager(
70
+ terminal,
71
+ mode=settings.theme_mode,
72
+ theme=theme_name,
73
+ settings_store=store,
74
+ )
75
+ keybinds = KeybindRegistry()
76
+ toasts = ToastBus()
77
+ clipboard = ClipboardAdapter(terminal=terminal)
78
+ return cls(
79
+ terminal=terminal,
80
+ keybinds=keybinds,
81
+ theme=theme,
82
+ toasts=toasts,
83
+ clipboard=clipboard,
84
+ )
@@ -6,9 +6,11 @@ import logging
6
6
  from enum import Enum
7
7
  from typing import Literal
8
8
 
9
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError
9
10
  from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
10
11
  from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
11
12
  from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
13
+ from glaip_sdk.cli.tui_settings import persist_tui_theme
12
14
 
13
15
  logger = logging.getLogger(__name__)
14
16
 
@@ -30,11 +32,13 @@ class ThemeManager:
30
32
  *,
31
33
  mode: ThemeMode | str = ThemeMode.AUTO,
32
34
  theme: str | None = None,
35
+ settings_store: AccountStore | None = None,
33
36
  ) -> None:
34
37
  """Initialize the theme manager."""
35
38
  self._terminal = terminal
36
39
  self._mode = self._coerce_mode(mode)
37
- self._theme = theme
40
+ self._theme = self._normalize_theme_name(theme)
41
+ self._settings_store = settings_store
38
42
 
39
43
  @property
40
44
  def mode(self) -> ThemeMode:
@@ -70,10 +74,12 @@ class ThemeManager:
70
74
  def set_mode(self, mode: ThemeMode | str) -> None:
71
75
  """Set auto/light/dark mode."""
72
76
  self._mode = self._coerce_mode(mode)
77
+ self._persist_preferences()
73
78
 
74
79
  def set_theme(self, theme: str | None) -> None:
75
80
  """Set explicit theme name (or None to use the default)."""
76
- self._theme = theme
81
+ self._theme = self._normalize_theme_name(theme)
82
+ self._persist_preferences()
77
83
 
78
84
  def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
79
85
  """Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
@@ -84,3 +90,23 @@ class ThemeManager:
84
90
  except ValueError:
85
91
  logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
86
92
  return ThemeMode.AUTO
93
+
94
+ def _persist_preferences(self) -> None:
95
+ if self._settings_store is None:
96
+ return
97
+ try:
98
+ persist_tui_theme(mode=self._mode.value, name=self._theme, store=self._settings_store)
99
+ except (OSError, AccountStoreError) as exc:
100
+ # Log recoverable errors (permissions, I/O) as warnings
101
+ logger.warning(f"Failed to persist TUI theme preferences: {exc}")
102
+ except Exception as exc:
103
+ # Log unexpected errors at error level for debugging
104
+ logger.error(f"Unexpected error persisting TUI theme preferences: {exc}", exc_info=True)
105
+
106
+ def _normalize_theme_name(self, theme: str | None) -> str | None:
107
+ if not isinstance(theme, str):
108
+ return None
109
+ cleaned = theme.strip()
110
+ if not cleaned or cleaned.lower() == "default":
111
+ return None
112
+ return cleaned
@@ -0,0 +1,125 @@
1
+ """Typed loader/saver for TUI preferences stored in config.yaml.
2
+
3
+ This module implements the TUI preferences store as defined in
4
+ `specs/architecture/tui-preferences-store/spec.md`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, cast
11
+
12
+ from glaip_sdk.cli.account_store import AccountStore, get_account_store
13
+
14
+ ThemeModeValue = Literal["auto", "light", "dark"]
15
+
16
+ _DEFAULT_THEME_MODE: ThemeModeValue = "auto"
17
+ _DEFAULT_LEADER = "ctrl+x"
18
+ _DEFAULT_MOUSE_CAPTURE = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TUISettings:
23
+ """Resolved TUI preferences from config.yaml."""
24
+
25
+ theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
26
+ theme_name: str | None = None
27
+ leader: str = _DEFAULT_LEADER
28
+ keybind_overrides: dict[str, str] = field(default_factory=dict)
29
+ mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
30
+
31
+
32
+ def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
33
+ """Load TUI preferences from the CLI config file."""
34
+ store = store or get_account_store()
35
+ config = store.load_config()
36
+
37
+ tui = _as_dict(config.get("tui"))
38
+ theme = _as_dict(tui.get("theme"))
39
+ mode = _coerce_theme_mode(theme.get("mode"))
40
+ name = _normalize_theme_name(theme.get("name"))
41
+
42
+ keybinds = _as_dict(tui.get("keybinds"))
43
+ leader = keybinds.get("leader")
44
+ if not isinstance(leader, str) or not leader.strip():
45
+ leader = _DEFAULT_LEADER
46
+
47
+ overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
48
+
49
+ mouse_capture = tui.get("mouse_capture")
50
+ if not isinstance(mouse_capture, bool):
51
+ mouse_capture = _DEFAULT_MOUSE_CAPTURE
52
+
53
+ return TUISettings(
54
+ theme_mode=mode,
55
+ theme_name=name,
56
+ leader=leader,
57
+ keybind_overrides=overrides,
58
+ mouse_capture=mouse_capture,
59
+ )
60
+
61
+
62
+ def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
63
+ """Update TUI preferences, preserving unrelated config keys."""
64
+ store = store or get_account_store()
65
+ config = store.load_config()
66
+
67
+ existing = _as_dict(config.get("tui"))
68
+ config["tui"] = _merge_dict(existing, patch)
69
+
70
+ store.save_config_updates(config)
71
+
72
+
73
+ def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
74
+ """Persist theme preferences in the tui.theme namespace."""
75
+ update_tui_settings(
76
+ {"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
77
+ store=store,
78
+ )
79
+
80
+
81
+ def _as_dict(value: Any) -> dict[str, Any]:
82
+ if isinstance(value, dict):
83
+ return dict(value)
84
+ return {}
85
+
86
+
87
+ def _coerce_theme_mode(value: Any) -> ThemeModeValue:
88
+ if isinstance(value, str):
89
+ lowered = value.strip().lower()
90
+ if lowered in {"auto", "light", "dark"}:
91
+ return cast(ThemeModeValue, lowered)
92
+ return _DEFAULT_THEME_MODE
93
+
94
+
95
+ def _normalize_theme_name(value: Any) -> str | None:
96
+ if not isinstance(value, str):
97
+ return None
98
+ cleaned = value.strip()
99
+ if not cleaned or cleaned.lower() == "default":
100
+ return None
101
+ return cleaned
102
+
103
+
104
+ def _serialize_theme_name(name: str | None) -> str:
105
+ if isinstance(name, str):
106
+ cleaned = name.strip()
107
+ if cleaned:
108
+ return cleaned
109
+ return "default"
110
+
111
+
112
+ def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
113
+ if not isinstance(value, dict):
114
+ return {}
115
+ return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
116
+
117
+
118
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
119
+ merged = dict(base)
120
+ for key, value in patch.items():
121
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
122
+ merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
123
+ else:
124
+ merged[key] = value
125
+ return merged
@@ -1,6 +1,6 @@
1
1
  """Tool registry for glaip_sdk.
2
2
 
3
- This module provides the ToolRegistry that caches deployed tools
3
+ This module provides a ToolRegistry that caches deployed tools
4
4
  to avoid redundant API calls when deploying agents with tools.
5
5
 
6
6
  Authors:
@@ -92,7 +92,7 @@ class ToolRegistry(BaseRegistry["Tool"]):
92
92
  Raises:
93
93
  ValueError: If name cannot be extracted from the reference.
94
94
  """
95
- # Lazy import to avoid circular dependency
95
+ # Lazy import to avoid circular dependency: Tool -> ToolRegistry -> Tool
96
96
  from glaip_sdk.tools.base import Tool # noqa: PLC0415
97
97
 
98
98
  if isinstance(ref, str):
@@ -129,6 +129,34 @@ class ToolRegistry(BaseRegistry["Tool"]):
129
129
  if hasattr(tool, "id") and tool.id:
130
130
  self._cache[tool.id] = tool
131
131
 
132
+ def _resolve_native_platform_tool(self, name: str, tool_class: type | None = None) -> Tool:
133
+ """Find a native tool on the platform and cache it.
134
+
135
+ Args:
136
+ name: The tool name to look up.
137
+ tool_class: Optional local implementation to preserve.
138
+
139
+ Returns:
140
+ The resolved Tool object.
141
+
142
+ Raises:
143
+ ValueError: If the tool is not found on the platform.
144
+ """
145
+ from glaip_sdk.utils.discovery import find_tool # noqa: PLC0415
146
+
147
+ logger.info("Looking up native tool: %s", name)
148
+ tool = find_tool(name)
149
+ if tool:
150
+ # Preserve local implementation if provided
151
+ if tool_class:
152
+ tool.tool_class = tool_class
153
+ self._cache_tool(tool, name)
154
+ return tool
155
+
156
+ raise ValueError(
157
+ f"Native tool '{name}' not found on platform. Ensure the tool is deployed or check for name mismatches."
158
+ )
159
+
132
160
  def _resolve_tool_instance(self, ref: Any, name: str) -> Tool | None:
133
161
  """Resolve a ToolClass instance.
134
162
 
@@ -139,11 +167,9 @@ class ToolRegistry(BaseRegistry["Tool"]):
139
167
  Returns:
140
168
  The resolved tool, or None if not a ToolClass instance.
141
169
  """
142
- # Lazy imports to avoid circular dependency
170
+ # Lazy imports to avoid circular dependency: Tool -> ToolRegistry -> Tool
143
171
  from glaip_sdk.tools.base import Tool as ToolClass # noqa: PLC0415
144
172
  from glaip_sdk.tools.base import ToolType # noqa: PLC0415
145
- from glaip_sdk.utils.discovery import find_tool # noqa: PLC0415
146
- from glaip_sdk.utils.sync import update_or_create_tool # noqa: PLC0415
147
173
 
148
174
  # Use try/except to handle mocked Tool class in tests
149
175
  try:
@@ -162,19 +188,11 @@ class ToolRegistry(BaseRegistry["Tool"]):
162
188
 
163
189
  # Tool.from_native() - look up on platform
164
190
  if ref.tool_type == ToolType.NATIVE:
165
- logger.info("Looking up native tool: %s", name)
166
- tool = find_tool(name)
167
- if tool:
168
- self._cache[name] = tool
169
- return tool
170
- raise ValueError(f"Native tool not found on platform: {name}")
171
-
172
- # Tool.from_langchain() - upload the tool_class
191
+ return self._resolve_native_platform_tool(name, tool_class=getattr(ref, "tool_class", None))
192
+
193
+ # Tool.from_langchain() - resolve the inner tool_class (promoted or uploaded)
173
194
  if ref.tool_class is not None:
174
- logger.info("Uploading custom tool: %s", name)
175
- tool = update_or_create_tool(ref.tool_class)
176
- self._cache_tool(tool, name)
177
- return tool
195
+ return self._resolve_custom_tool(ref.tool_class, name)
178
196
 
179
197
  # Unresolvable Tool instance - neither native nor has tool_class
180
198
  raise ValueError(
@@ -193,8 +211,6 @@ class ToolRegistry(BaseRegistry["Tool"]):
193
211
  Returns:
194
212
  The resolved tool, or None if not a deployed tool.
195
213
  """
196
- from glaip_sdk.utils.discovery import find_tool # noqa: PLC0415
197
-
198
214
  # Already deployed tool (not a ToolClass, but has id/name)
199
215
  # This handles API response objects and backward compatibility
200
216
  if not (hasattr(ref, "id") and hasattr(ref, "name") and not isinstance(ref, type)):
@@ -207,32 +223,50 @@ class ToolRegistry(BaseRegistry["Tool"]):
207
223
  return ref
208
224
 
209
225
  # Tool without ID (backward compatibility) - look up on platform
210
- logger.info("Looking up native tool: %s", name)
211
- tool = find_tool(name)
212
- if tool:
213
- # Use _cache_tool to cache by both name and ID if available
214
- self._cache_tool(tool, name)
215
- return tool
216
- raise ValueError(f"Native tool not found on platform: {name}")
226
+ return self._resolve_native_platform_tool(name)
217
227
 
218
228
  def _resolve_custom_tool(self, ref: Any, name: str) -> Tool | None:
219
- """Resolve a custom tool class.
229
+ """Resolve a custom tool class, promoting aip_agents.tools classes to NATIVE.
230
+
231
+ This method handles two main paths:
232
+ 1. **Promotion**: If the tool class is from `aip_agents.tools`, it is automatically
233
+ promoted to a `NATIVE` tool type. It then performs a platform lookup to link it
234
+ with the deployed native tool while preserving the local `tool_class` for local execution.
235
+ 2. **Upload**: If it is a standard LangChain tool, it is uploaded to the platform
236
+ as a custom tool.
220
237
 
221
238
  Args:
222
- ref: The tool reference to resolve.
239
+ ref: The tool reference (usually a class) to resolve.
223
240
  name: The extracted tool name.
224
241
 
225
242
  Returns:
226
243
  The resolved tool, or None if not a custom tool.
227
244
  """
228
- from glaip_sdk.utils.sync import update_or_create_tool # noqa: PLC0415
245
+ # aip_agents tools are automatically promoted to NATIVE
246
+ if self._is_aip_agents_tool(ref):
247
+ from glaip_sdk.utils.tool_detection import get_tool_name # noqa: PLC0415
248
+
249
+ # Get name from class attribute or field
250
+ tool_name = get_tool_name(ref)
251
+ if tool_name is None:
252
+ raise ValueError(f"Tool class {ref.__name__} has no 'name' attribute")
253
+
254
+ return self._resolve_native_platform_tool(tool_name, tool_class=ref)
229
255
 
230
256
  if not self._is_custom_tool(ref):
231
257
  return None
232
258
 
259
+ # Regular custom tools - upload to platform
260
+ from glaip_sdk.utils.sync import update_or_create_tool # noqa: PLC0415
261
+
233
262
  logger.info("Uploading custom tool: %s", name)
234
263
  tool = update_or_create_tool(ref)
264
+
265
+ # Cache the resolved tool
235
266
  self._cache_tool(tool, name)
267
+ if hasattr(tool, "id") and tool.id:
268
+ self._cache[tool.id] = tool
269
+
236
270
  return tool
237
271
 
238
272
  def _resolve_dict_tool(self, ref: Any, name: str) -> Tool | None:
@@ -245,6 +279,7 @@ class ToolRegistry(BaseRegistry["Tool"]):
245
279
  Returns:
246
280
  The resolved tool, or None if not a dict.
247
281
  """
282
+ # Lazy imports to avoid circular dependency
248
283
  from glaip_sdk.tools.base import Tool as ToolClass # noqa: PLC0415
249
284
 
250
285
  if not isinstance(ref, dict):
@@ -268,18 +303,10 @@ class ToolRegistry(BaseRegistry["Tool"]):
268
303
  Returns:
269
304
  The resolved tool, or None if not a string.
270
305
  """
271
- from glaip_sdk.utils.discovery import find_tool # noqa: PLC0415
272
-
273
306
  if not isinstance(ref, str):
274
307
  return None
275
308
 
276
- logger.info("Looking up tool by name: %s", name)
277
- tool = find_tool(name)
278
- if tool:
279
- # Use _cache_tool to cache by both name and ID for consistency
280
- self._cache_tool(tool, name)
281
- return tool
282
- raise ValueError(f"Tool not found on platform: {name}")
309
+ return self._resolve_native_platform_tool(name)
283
310
 
284
311
  def _resolve_and_cache(self, ref: Any, name: str) -> Tool:
285
312
  """Resolve tool reference - upload if class, find if string/native.
@@ -292,7 +319,7 @@ class ToolRegistry(BaseRegistry["Tool"]):
292
319
  The resolved glaip_sdk.models.Tool object.
293
320
 
294
321
  Raises:
295
- ValueError: If the tool cannot be resolved.
322
+ ValueError: If tool cannot be resolved.
296
323
  """
297
324
  # Try each resolution strategy in order
298
325
  resolvers = [
@@ -310,6 +337,24 @@ class ToolRegistry(BaseRegistry["Tool"]):
310
337
 
311
338
  raise ValueError(f"Could not resolve tool reference: {ref}")
312
339
 
340
+ def _is_aip_agents_tool(self, ref: Any) -> bool:
341
+ """Check if reference is an aip-agents tool.
342
+
343
+ Args:
344
+ ref: The reference to check.
345
+
346
+ Returns:
347
+ True if ref is from aip_agents.tools package.
348
+ """
349
+ try:
350
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
351
+ is_aip_agents_tool,
352
+ )
353
+ except ImportError:
354
+ return False
355
+
356
+ return is_aip_agents_tool(ref)
357
+
313
358
  def _is_custom_tool(self, ref: Any) -> bool:
314
359
  """Check if reference is a custom tool class/instance.
315
360
 
@@ -323,10 +368,18 @@ class ToolRegistry(BaseRegistry["Tool"]):
323
368
  from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
324
369
  is_langchain_tool,
325
370
  )
371
+
372
+ is_tool = is_langchain_tool(ref)
326
373
  except ImportError:
374
+ is_tool = hasattr(ref, "args_schema") or hasattr(ref, "_run")
375
+ if is_tool:
376
+ logger.warning("tool_detection module missing; identifying tool via fallback attributes.")
377
+
378
+ # aip_agents tools are NOT custom - they're native
379
+ if is_tool and self._is_aip_agents_tool(ref):
327
380
  return False
328
381
 
329
- return is_langchain_tool(ref)
382
+ return is_tool
330
383
 
331
384
  def resolve(self, ref: Any) -> Tool:
332
385
  """Resolve a tool reference to a platform Tool object.
@@ -348,7 +401,6 @@ class ToolRegistry(BaseRegistry["Tool"]):
348
401
  # Use _cache_tool to cache by both name and ID for consistency
349
402
  self._cache_tool(ref, name)
350
403
  return ref
351
-
352
404
  # Tool without ID (e.g., from Tool.from_native()) - needs platform lookup
353
405
  # Fall through to normal resolution
354
406
 
@@ -9,9 +9,10 @@ Authors:
9
9
 
10
10
  from typing import Any
11
11
 
12
- from glaip_sdk.runner.tool_adapter.base_tool_adapter import BaseToolAdapter
13
12
  from gllm_core.utils import LoggerManager
14
13
 
14
+ from glaip_sdk.runner.tool_adapter.base_tool_adapter import BaseToolAdapter
15
+
15
16
  logger = LoggerManager().get_logger(__name__)
16
17
 
17
18
  # Constant for unknown tool name placeholder
@@ -73,8 +74,30 @@ class LangChainToolAdapter(BaseToolAdapter):
73
74
  if self._is_langchain_tool(tool_ref):
74
75
  return self._instantiate_langchain_tool(tool_ref)
75
76
 
76
- # 3. Platform tools (not supported)
77
+ # 3. Native tools with discovered class
77
78
  if self._is_platform_tool(tool_ref):
79
+ # Try to discover local implementation for native tool
80
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
81
+ find_aip_agents_tool_class,
82
+ get_tool_name,
83
+ )
84
+
85
+ # Get tool name from reference
86
+ tool_name = get_tool_name(tool_ref) if not isinstance(tool_ref, str) else tool_ref
87
+
88
+ if tool_name:
89
+ discovered_class = find_aip_agents_tool_class(tool_name)
90
+ if discovered_class:
91
+ logger.info("Instantiating native tool locally: %s", tool_name)
92
+ try:
93
+ return discovered_class()
94
+ except TypeError as exc:
95
+ raise ValueError(
96
+ f"Could not instantiate native tool '{tool_name}'. "
97
+ "Ensure it has a zero-argument constructor or adjust the instantiation logic."
98
+ ) from exc
99
+
100
+ # If no local class found, raise platform tool error
78
101
  raise ValueError(self._get_platform_tool_error(tool_ref))
79
102
 
80
103
  # 4. Unknown type
glaip_sdk/tools/base.py CHANGED
@@ -231,6 +231,9 @@ class Tool:
231
231
  Native tools are pre-existing tools on the GL AIP platform
232
232
  that don't require uploading (e.g., "time_tool", "web_search").
233
233
 
234
+ For local execution, automatically discovers the corresponding aip_agents.tools
235
+ class if available. If not found, tool can only be used after deployment.
236
+
234
237
  Args:
235
238
  name: The name of the native tool on the platform.
236
239
 
@@ -241,7 +244,14 @@ class Tool:
241
244
  >>> time_tool = Tool.from_native("time_tool")
242
245
  >>> web_search = Tool.from_native("web_search")
243
246
  """
244
- return cls(name=name, type=ToolType.NATIVE)
247
+ # Try to discover local implementation for native execution
248
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
249
+ find_aip_agents_tool_class,
250
+ )
251
+
252
+ tool_class = find_aip_agents_tool_class(name)
253
+
254
+ return cls(name=name, type=ToolType.NATIVE, tool_class=tool_class)
245
255
 
246
256
  @classmethod
247
257
  def from_langchain(cls, tool_class: type) -> Tool:
@@ -287,19 +297,11 @@ class Tool:
287
297
  Raises:
288
298
  ValueError: If name cannot be extracted or is not a valid string.
289
299
  """
290
- # Try model_fields first (Pydantic v2)
291
- if hasattr(tool_class, "model_fields"):
292
- name_field = tool_class.model_fields.get("name")
293
- if name_field and name_field.default:
294
- # Validate that default is actually a string
295
- if isinstance(name_field.default, str):
296
- return name_field.default
297
-
298
- # Try direct name attribute
299
- if hasattr(tool_class, "name"):
300
- name_attr = getattr(tool_class, "name")
301
- if isinstance(name_attr, str):
302
- return name_attr
300
+ from glaip_sdk.utils.tool_detection import get_tool_name # noqa: PLC0415
301
+
302
+ name = get_tool_name(tool_class)
303
+ if name:
304
+ return name
303
305
 
304
306
  # If we can't extract the name, raise an error
305
307
  raise ValueError(
@@ -385,12 +387,22 @@ class Tool:
385
387
  if not self._client:
386
388
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
387
389
 
390
+ # Handle both Client (has .tools) and ToolClient (direct methods)
391
+ # Priority: Check if client has a 'tools' attribute (Client instance)
392
+ # Otherwise, use client directly (ToolClient instance)
393
+ if hasattr(self._client, "tools") and self._client.tools is not None:
394
+ # Main Client instance - use the tools sub-client
395
+ tools_client = self._client.tools
396
+ else:
397
+ # ToolClient instance - use directly
398
+ tools_client = self._client
399
+
388
400
  # Check if file upload is requested
389
401
  if "file" in kwargs:
390
402
  file_path = kwargs.pop("file")
391
- response = self._client.tools.update_via_file(self._id, file_path, **kwargs)
403
+ response = tools_client.update_tool_via_file(self._id, file_path, **kwargs)
392
404
  else:
393
- response = self._client.tools.update(tool_id=self._id, **kwargs)
405
+ response = tools_client.update_tool(tool_id=self._id, **kwargs)
394
406
 
395
407
  # Update local properties from response
396
408
  if hasattr(response, "name") and response.name:
@@ -414,7 +426,17 @@ class Tool:
414
426
  if not self._client:
415
427
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
416
428
 
417
- self._client.tools.delete(tool_id=self._id)
429
+ # Handle both Client (has .tools) and ToolClient (direct methods)
430
+ # Priority: Check if client has a 'tools' attribute (Client instance)
431
+ # Otherwise, use client directly (ToolClient instance)
432
+ if hasattr(self._client, "tools") and self._client.tools is not None:
433
+ # Main Client instance - use the tools sub-client
434
+ tools_client = self._client.tools
435
+ else:
436
+ # ToolClient instance - use directly
437
+ tools_client = self._client
438
+
439
+ tools_client.delete_tool(self._id)
418
440
  self._id = None
419
441
  self._client = None
420
442
 
@@ -77,6 +77,7 @@ def __getattr__(name: str) -> type:
77
77
  "get_client": _client_module,
78
78
  "set_client": _client_module,
79
79
  "reset_client": _client_module,
80
+ "tool_detection": "glaip_sdk.utils.tool_detection",
80
81
  }
81
82
 
82
83
  if name in lazy_imports:
@@ -4,9 +4,140 @@ Authors:
4
4
  Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import ast
10
+ import importlib
11
+ import inspect
12
+ import pkgutil
13
+ from functools import lru_cache
8
14
  from typing import Any
9
15
 
16
+ # Constants for frequently used strings to avoid duplication (S1192)
17
+ _NAME = "name"
18
+ _AIP_AGENTS_TOOLS = "aip_agents.tools"
19
+ _BASE_TOOL = "BaseTool"
20
+
21
+ # Internal map to store all discovered tools in the session
22
+ _DISCOVERED_TOOLS: dict[str, type] | None = None
23
+
24
+
25
+ def _should_skip_module(module_name: str) -> bool:
26
+ """Check if module should be skipped during tool discovery."""
27
+ short_name = module_name.rsplit(".", 1)[-1]
28
+ return short_name.startswith("_") or "test" in short_name
29
+
30
+
31
+ def _get_pydantic_field_default(cls: type, attr_name: str, field_name: str) -> str | None:
32
+ """Extract default value from a Pydantic field."""
33
+ try:
34
+ fields = getattr(cls, attr_name, {})
35
+ field = fields.get(field_name)
36
+ # Broad exception handling needed because:
37
+ # - model_fields/__fields__ might be a descriptor that raises AttributeError
38
+ # - field.default might raise during access
39
+ # - Various Pydantic internals can raise unexpected exceptions
40
+ if field and hasattr(field, "default") and isinstance(field.default, str):
41
+ return field.default
42
+ except Exception: # pylint: disable=broad-except
43
+ pass
44
+ return None
45
+
46
+
47
+ def _get_name_from_pydantic_v2(cls: type) -> str | None:
48
+ """Extract name from Pydantic v2 model_fields."""
49
+ return _get_pydantic_field_default(cls, "model_fields", _NAME)
50
+
51
+
52
+ def _get_name_from_pydantic_v1(cls: type) -> str | None:
53
+ """Extract name from Pydantic v1 __fields__."""
54
+ return _get_pydantic_field_default(cls, "__fields__", _NAME)
55
+
56
+
57
+ def get_tool_name(ref: Any) -> str | None:
58
+ """Extract tool name from a tool class or instance.
59
+
60
+ Handles LangChain BaseTool (Pydantic v1/v2) and standard classes.
61
+
62
+ Args:
63
+ ref: Tool class or instance.
64
+
65
+ Returns:
66
+ The extracted tool name, or None if not found.
67
+ """
68
+ if ref is None:
69
+ return None
70
+
71
+ # 1. Try instance 'name' attribute
72
+ if not isinstance(ref, type):
73
+ try:
74
+ name = getattr(ref, _NAME, None)
75
+ if isinstance(name, str):
76
+ return name
77
+ except Exception: # pylint: disable=broad-except
78
+ pass
79
+
80
+ cls = ref if isinstance(ref, type) else type(ref)
81
+
82
+ # 2. Try class 'model_fields' (Pydantic v2)
83
+ # Check Pydantic v2 first for forward compatibility
84
+ name = _get_name_from_pydantic_v2(cls)
85
+ if name:
86
+ return name
87
+
88
+ # 3. Try class '__fields__' (Pydantic v1)
89
+ name = _get_name_from_pydantic_v1(cls)
90
+ if name:
91
+ return name
92
+
93
+ # 4. Try direct class attribute
94
+ if hasattr(cls, _NAME):
95
+ try:
96
+ name_attr = getattr(cls, _NAME)
97
+ if isinstance(name_attr, str):
98
+ return name_attr
99
+ except Exception: # pylint: disable=broad-except
100
+ pass
101
+
102
+ return None
103
+
104
+
105
+ def _check_langchain_standard(ref: Any) -> bool:
106
+ """Perform standard isinstance/issubclass check for LangChain tool."""
107
+ try:
108
+ from langchain_core.tools import BaseTool # noqa: PLC0415
109
+
110
+ # Check if BaseTool is actually a type to avoid TypeError in issubclass/isinstance
111
+ if isinstance(BaseTool, type):
112
+ if isinstance(ref, type) and issubclass(ref, BaseTool):
113
+ return True
114
+ if isinstance(ref, BaseTool):
115
+ return True
116
+ except (ImportError, TypeError):
117
+ pass
118
+ return False
119
+
120
+
121
+ def _check_langchain_fallback(ref: Any) -> bool:
122
+ """Perform name-based fallback check for LangChain tool (robust for mocks).
123
+
124
+ This fallback handles cases where:
125
+ - BaseTool is mocked in tests
126
+ - BaseTool is re-imported through internal modules (e.g., runner)
127
+ - isinstance/issubclass checks fail due to module reloading
128
+ """
129
+ try:
130
+ cls = ref if isinstance(ref, type) else getattr(ref, "__class__", None)
131
+ if cls and hasattr(cls, "__mro__"):
132
+ for c in cls.__mro__:
133
+ c_name = getattr(c, "__name__", None)
134
+ c_module = getattr(c, "__module__", "")
135
+ if c_name == _BASE_TOOL and ("langchain" in c_module or "runner" in c_module):
136
+ return True
137
+ except (AttributeError, TypeError):
138
+ pass
139
+ return False
140
+
10
141
 
11
142
  def is_langchain_tool(ref: Any) -> bool:
12
143
  """Check if ref is a LangChain BaseTool class or instance.
@@ -21,17 +152,133 @@ def is_langchain_tool(ref: Any) -> bool:
21
152
  Returns:
22
153
  True if ref is a LangChain BaseTool class or instance.
23
154
  """
155
+ if ref is None:
156
+ return False
157
+
158
+ # 1. Standard check (preferred)
159
+ if _check_langchain_standard(ref):
160
+ return True
161
+
162
+ # 2. Name-based check (robust fallback for mocks and re-imports)
163
+ return _check_langchain_fallback(ref)
164
+
165
+
166
+ def is_aip_agents_tool(ref: Any) -> bool:
167
+ """Check if ref is an aip-agents tool class or instance.
168
+
169
+ Args:
170
+ ref: Object to check.
171
+
172
+ Returns:
173
+ True if ref is from aip_agents.tools package.
174
+ """
24
175
  try:
25
- from langchain_core.tools import BaseTool # noqa: PLC0415
176
+ # Check class module
177
+ if isinstance(ref, type):
178
+ return ref.__module__.startswith(_AIP_AGENTS_TOOLS)
179
+
180
+ # Check instance class
181
+ if hasattr(ref, "__class__"):
182
+ return ref.__class__.__module__.startswith(_AIP_AGENTS_TOOLS)
183
+
184
+ return False
185
+ except (AttributeError, TypeError):
186
+ return False
187
+
188
+
189
+ def _get_discovered_classes_from_module(module: Any) -> list[type]:
190
+ """Extract BaseTool subclasses from a module."""
191
+ discovered_classes = []
192
+ for attr_name in dir(module):
193
+ if attr_name.startswith("_"):
194
+ continue
195
+
196
+ try:
197
+ attr = getattr(module, attr_name)
198
+ if inspect.isclass(attr) and is_langchain_tool(attr):
199
+ # Ensure it's not the BaseTool class itself
200
+ if getattr(attr, "__name__", None) != _BASE_TOOL:
201
+ discovered_classes.append(attr)
202
+ except Exception: # pylint: disable=broad-except
203
+ continue
204
+ return discovered_classes
205
+
206
+
207
+ def _import_and_map_module(module_name: str, tools_map: dict[str, type]) -> None:
208
+ """Import a single module and extract its tools."""
209
+ try:
210
+ module = importlib.import_module(module_name)
211
+ classes = _get_discovered_classes_from_module(module)
212
+ for tool_class in classes:
213
+ name = get_tool_name(tool_class)
214
+ if name:
215
+ tools_map[name] = tool_class
216
+ except Exception: # pylint: disable=broad-except
217
+ # Broad catch to skip broken modules during discovery
218
+ pass
219
+
220
+
221
+ def _walk_and_map_package(package: Any, tools_map: dict[str, type]) -> None:
222
+ """Walk through a package and map all tools found."""
223
+ try:
224
+ # Walk packages using the package's path and name
225
+ for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
226
+ if _should_skip_module(module_name):
227
+ continue # pragma: no cover
26
228
 
27
- if isinstance(ref, type) and issubclass(ref, BaseTool):
28
- return True
29
- if isinstance(ref, BaseTool):
30
- return True
31
- except ImportError:
229
+ _import_and_map_module(module_name, tools_map)
230
+ except Exception: # pylint: disable=broad-except
231
+ # Broad catch for walk_packages failure
32
232
  pass
33
233
 
34
- return False
234
+
235
+ def _get_all_aip_agents_tools() -> dict[str, type]:
236
+ """Discover and map all tools in aip_agents.tools (once per session)."""
237
+ global _DISCOVERED_TOOLS # pylint: disable=global-statement
238
+ if _DISCOVERED_TOOLS is None:
239
+ _DISCOVERED_TOOLS = {}
240
+ try:
241
+ package = importlib.import_module(_AIP_AGENTS_TOOLS)
242
+ if hasattr(package, "__path__"):
243
+ _walk_and_map_package(package, _DISCOVERED_TOOLS)
244
+ except (ImportError, AttributeError):
245
+ pass
246
+ return _DISCOVERED_TOOLS
247
+
248
+
249
+ @lru_cache(maxsize=128)
250
+ def find_aip_agents_tool_class(name: str) -> type | None:
251
+ """Find and return a native tool class by tool name.
252
+
253
+ Searches aip_agents.tools submodules for BaseTool subclasses
254
+ with matching 'name' attribute. Uses caching to improve performance.
255
+
256
+ Note:
257
+ Results are discovered once per session and cached. If tools are
258
+ dynamically added to the path after the first call, they may not
259
+ be discovered until the session restarts.
260
+
261
+ Args:
262
+ name (str): The tool name to search for (e.g., "google_serper").
263
+
264
+ Returns:
265
+ type|None: The discovered tool class, or None if not found.
266
+
267
+ Examples:
268
+ >>> find_aip_agents_tool_class("google_serper")
269
+ <class 'aip_agents.tools.web_search.serper_tool.GoogleSerperTool'>
270
+
271
+ >>> find_aip_agents_tool_class("nonexistent")
272
+ None
273
+ """
274
+ return _get_all_aip_agents_tools().get(name)
275
+
276
+
277
+ def clear_discovery_cache() -> None:
278
+ """Clear the tool discovery cache (internal use for testing)."""
279
+ global _DISCOVERED_TOOLS # pylint: disable=global-statement
280
+ _DISCOVERED_TOOLS = None
281
+ find_aip_agents_tool_class.cache_clear()
35
282
 
36
283
 
37
284
  def is_tool_plugin_decorator(decorator: ast.expr) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glaip-sdk
3
- Version: 0.7.6
3
+ Version: 0.7.8
4
4
  Summary: Python SDK and CLI for GL AIP (GDP Labs AI Agent Package) - Build, run, and manage AI agents
5
5
  Author-email: Raymond Christopher <raymond.christopher@gdplabs.id>
6
6
  License: MIT
@@ -7,10 +7,10 @@ glaip_sdk/rich_components.py,sha256=44Z0V1ZQleVh9gUDGwRR5mriiYFnVGOhm7fFxZYbP8c,
7
7
  glaip_sdk/agents/__init__.py,sha256=VfYov56edbWuySXFEbWJ_jLXgwnFzPk1KB-9-mfsUCc,776
8
8
  glaip_sdk/agents/base.py,sha256=GQnzCw2cqlrbxwdoWFfhBcBlEDgubY4tlD6gr1b3zps,44539
9
9
  glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
10
- glaip_sdk/cli/account_store.py,sha256=TK4iTV93Q1uD9mCY_2ZMT6EazHKU2jX0qhgWfEM4V-4,18459
10
+ glaip_sdk/cli/account_store.py,sha256=u_memecwEQssustZs2wYBrHbEmKUlDfmmL-zO1F3n3A,19034
11
11
  glaip_sdk/cli/agent_config.py,sha256=YAbFKrTNTRqNA6b0i0Q3pH-01rhHDRi5v8dxSFwGSwM,2401
12
12
  glaip_sdk/cli/auth.py,sha256=bqOHMGIOCg3KXssme3uJBBjEbK0rCEppQ6oq-gJ-hzA,24276
13
- glaip_sdk/cli/config.py,sha256=s0_xBB1e5YE4I_Wc4q-ayY3dwsBU1JrHAF-8ySlim7Y,3040
13
+ glaip_sdk/cli/config.py,sha256=hFKQSdqELQZLKBuFnhEirNLDyPJwEmpQuD8nHuWxokg,3051
14
14
  glaip_sdk/cli/constants.py,sha256=zqcVtzfj6huW97gbCmhkFqntge1H-c1vnkGqTazADgU,895
15
15
  glaip_sdk/cli/context.py,sha256=--Y5vc6lgoAV7cRoUAr9UxSQaLmkMg29FolA7EwoRqM,3803
16
16
  glaip_sdk/cli/display.py,sha256=ojgWdGeD5KUnGOmWNqqK4JP-1EaWHWX--DWze3BmIz0,12137
@@ -23,6 +23,7 @@ glaip_sdk/cli/mcp_validators.py,sha256=cwbz7p_p7_9xVuuF96OBQOdmEgo5UObU6iWWQ2X03
23
23
  glaip_sdk/cli/pager.py,sha256=TmiMDNpUMuZju7QJ6A_ITqIoEf8Dhv8U6mTXx2Fga1k,7935
24
24
  glaip_sdk/cli/resolution.py,sha256=AGvv7kllLcuvk_jdaArJqH3lId4IDEXpHceRZwy14xY,2448
25
25
  glaip_sdk/cli/rich_helpers.py,sha256=kO47N8e506rxrN6Oc9mbAWN3Qb536oQPWZy1s9A616g,819
26
+ glaip_sdk/cli/tui_settings.py,sha256=ey9bSlolEj3_SMmjVVV_PY0FkfH1psAFPOl03NlykvI,3867
26
27
  glaip_sdk/cli/update_notifier.py,sha256=0zpWxr4nSyz0tiLWyC7EEO2deAnVmsRcVlMV79G2QMI,18049
27
28
  glaip_sdk/cli/validators.py,sha256=k4J2ACYJPF6UmWJfENt9OHWdp4RNArVxR3hoeqauO88,5629
28
29
  glaip_sdk/cli/commands/__init__.py,sha256=6Z3ASXDut0lAbUX_umBFtxPzzFyqoiZfVeTahThFu1A,219
@@ -80,10 +81,10 @@ glaip_sdk/cli/slash/remote_runs_controller.py,sha256=a5X5rYgb9l6dHhvTewRUCj-hAo7
80
81
  glaip_sdk/cli/slash/session.py,sha256=Zn2hXND_Tfameh_PI8g4VIMd7GPWxwhtPNMN9p6cF7M,65081
81
82
  glaip_sdk/cli/slash/tui/__init__.py,sha256=oBUzeoslYwPKVlhqhgg4I7480b77vQNc9ec0NgdTC1s,977
82
83
  glaip_sdk/cli/slash/tui/accounts.tcss,sha256=BCjIuTetmVjydv6DCliY38Cze2LUEu7IY44sL5nIuLU,1194
83
- glaip_sdk/cli/slash/tui/accounts_app.py,sha256=6ihnAnzKD49eeXYW3dYWUAdUEyoXNFwoEoi3kS3WtXM,35999
84
+ glaip_sdk/cli/slash/tui/accounts_app.py,sha256=baxAN1kUs3xyOvk6VCrdwcdEL89D37FkLlFP8mXrKHE,42049
84
85
  glaip_sdk/cli/slash/tui/background_tasks.py,sha256=SAe1mV2vXB3mJcSGhelU950vf8Lifjhws9iomyIVFKw,2422
85
86
  glaip_sdk/cli/slash/tui/clipboard.py,sha256=HL_RWIdONyRmDtTYuDzxJTS_mRcLxuR37Ac9Ug5nh40,4730
86
- glaip_sdk/cli/slash/tui/context.py,sha256=03mo2kgvpyUcNBYz7G2Uyu7X3FJlSUzVoP5Rt9MCZZY,2141
87
+ glaip_sdk/cli/slash/tui/context.py,sha256=XgHsXPl8LDDwIueP_jhP6xqpx_zHOk0FF19rWob_z5Q,3128
87
88
  glaip_sdk/cli/slash/tui/keybind_registry.py,sha256=_rK05BxTxNudYc4iJ9gDxpgeUkjDAq8rarIT-9A-jyM,6739
88
89
  glaip_sdk/cli/slash/tui/loading.py,sha256=nW5pv_Tnl9FUOPR3Qf2O5gt1AGHSo3b5-Uofg34F6AE,1909
89
90
  glaip_sdk/cli/slash/tui/remote_runs_app.py,sha256=RCrI-c5ilKV6Iy1lz2Aok9xo2Ou02vqcXACMXTdodnE,24716
@@ -91,7 +92,7 @@ glaip_sdk/cli/slash/tui/terminal.py,sha256=iC31XChTL34gXY6vXdSIX3HmD36tuA9EYTPZ2
91
92
  glaip_sdk/cli/slash/tui/toast.py,sha256=LP_myZwgnrdowrRxGK24lMlx7iZt7iOwFhrbc4NW0DY,3493
92
93
  glaip_sdk/cli/slash/tui/theme/__init__.py,sha256=rtM2ik83YNCRcI1qh_Sf3rnxco2OvCNNT3NbHY6cLvw,432
93
94
  glaip_sdk/cli/slash/tui/theme/catalog.py,sha256=G52eU3h8YI9D8XUALVg1KVZ4Lq65VnZdgPS3F_P7XLE,2544
94
- glaip_sdk/cli/slash/tui/theme/manager.py,sha256=X600J_WIBM1CHgsQeMFGFPuaVAFCINFcBXFWmeD4B5Q,2707
95
+ glaip_sdk/cli/slash/tui/theme/manager.py,sha256=LBnxEMIwz-8cAlZGYk5tIoAJbOJyGYsmDlyuGJ-LlX4,3945
95
96
  glaip_sdk/cli/slash/tui/theme/tokens.py,sha256=ympMRny_d-gHtmnPR-lmNZ-C9SGBy2q-MH81l0L1h-Y,1423
96
97
  glaip_sdk/cli/transcript/__init__.py,sha256=yiYHyNtebMCu3BXu56Xm5RBC2tDc865q8UGPnoe6QRs,920
97
98
  glaip_sdk/cli/transcript/cache.py,sha256=Wi1uln6HP1U6F-MRTrfnxi9bn6XJTxwWXhREIRPoMqQ,17439
@@ -137,7 +138,7 @@ glaip_sdk/registry/__init__.py,sha256=mjvElYE-wwmbriGe-c6qy4on0ccEuWxW_EWWrSbptC
137
138
  glaip_sdk/registry/agent.py,sha256=F0axW4BIUODqnttIOzxnoS5AqQkLZ1i48FTeZNnYkhA,5203
138
139
  glaip_sdk/registry/base.py,sha256=0x2ZBhiERGUcf9mQeWlksSYs5TxDG6FxBYQToYZa5D4,4143
139
140
  glaip_sdk/registry/mcp.py,sha256=kNJmiijIbZL9Btx5o2tFtbaT-WG6O4Xf_nl3wz356Ow,7978
140
- glaip_sdk/registry/tool.py,sha256=QnbAlk09lYvEb9PEdCsvpg4CGxlLbvvFWBS8WkM1ZoM,12955
141
+ glaip_sdk/registry/tool.py,sha256=c0Ja4rFYMOKs_1yjDLDZxCId4IjQzprwXzX0iIL8Fio,14979
141
142
  glaip_sdk/runner/__init__.py,sha256=orJ3nLR9P-n1qMaAMWZ_xRS4368YnDpdltg-bX5BlUk,2210
142
143
  glaip_sdk/runner/base.py,sha256=KIjcSAyDCP9_mn2H4rXR5gu1FZlwD9pe0gkTBmr6Yi4,2663
143
144
  glaip_sdk/runner/deps.py,sha256=Du3hr2R5RHOYCRAv7RVmx661x-ayVXIeZ8JD7ODirTA,3884
@@ -149,12 +150,12 @@ glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py,sha256=b58GuadPz7q7aXoJyTY
149
150
  glaip_sdk/runner/mcp_adapter/mcp_config_builder.py,sha256=fQcRaueDuyUzXUSVn9N8QxfaYNIteEO_R_uibx_0Icw,3440
150
151
  glaip_sdk/runner/tool_adapter/__init__.py,sha256=scv8sSPxSWjlSNEace03R230YbmWgphLgqINKvDjWmM,480
151
152
  glaip_sdk/runner/tool_adapter/base_tool_adapter.py,sha256=nL--eicV0St5_0PZZSEhRurHDZHNwhGN2cKOUh0C5IY,1400
152
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py,sha256=goSSDOpubuplsKpfemlbesf_bZBdpDKSTqLILvApcjA,7438
153
+ glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py,sha256=SgfQM5NgKyYBs34juxv3TCEicJbKgFIVPPZa22tA9AU,8484
153
154
  glaip_sdk/schedules/__init__.py,sha256=Ty__lE8ta3a6O7EiEsSXliVOwA3EBLKxKRsjAJt2WUg,482
154
155
  glaip_sdk/schedules/base.py,sha256=ZRKWknoxQOYMhX8mjQ7S7oqpy6Wr0xdbzcgIrycsEQ8,9727
155
156
  glaip_sdk/tools/__init__.py,sha256=rhGzEqQFCzeMrxmikBuNrMz4PyYczwic28boDKVmoHs,585
156
- glaip_sdk/tools/base.py,sha256=hkz2NZFHW1PqsRiXh3kKTVLIjMPR274Uwml944vH5tg,16325
157
- glaip_sdk/utils/__init__.py,sha256=ntohV7cxlY2Yksi2nFuFm_Mg2XVJbBbSJVRej7Mi9YE,2770
157
+ glaip_sdk/tools/base.py,sha256=KRaWWX5cKAvEKtBr4iSOaKQlQ973A4pNOW2KVvA1aYs,17353
158
+ glaip_sdk/utils/__init__.py,sha256=5a1kNLtUriwd1qAT6RU083GOyABS7LMZQacDP4yS9S4,2830
158
159
  glaip_sdk/utils/agent_config.py,sha256=RhcHsSOVwOaSC2ggnPuHn36Aa0keGJhs8KGb2InvzRk,7262
159
160
  glaip_sdk/utils/bundler.py,sha256=fLumFj1MqqqGA1Mwn05v_cEKPALv3rIPEMvaURpxZ80,15171
160
161
  glaip_sdk/utils/client.py,sha256=otPUOIDvLCCsvFBNR8YMZFtRrORggmvvlFjl3YeeTqQ,3121
@@ -172,7 +173,7 @@ glaip_sdk/utils/run_renderer.py,sha256=d_VMI6LbvHPUUeRmGqh5wK_lHqDEIAcym2iqpbtDa
172
173
  glaip_sdk/utils/runtime_config.py,sha256=Gl9-CQ4lYZ39vRSgtdfcSU3CXshVDDuTOdSzjvsCgG0,14070
173
174
  glaip_sdk/utils/serialization.py,sha256=z-qpvWLSBrGK3wbUclcA1UIKLXJedTnMSwPdq-FF4lo,13308
174
175
  glaip_sdk/utils/sync.py,sha256=71egWp5qm_8tYpWZyGazvnP4NnyW16rcmzjGVicmQEE,6043
175
- glaip_sdk/utils/tool_detection.py,sha256=6dHp0naLnvY6jwy_38k4kyTgQnizgcsq9hpeLSjAmcc,1471
176
+ glaip_sdk/utils/tool_detection.py,sha256=B7xze014TZyqWI4JqLhkZrbtT5h32CjQEXRswtdcljI,9808
176
177
  glaip_sdk/utils/tool_storage_provider.py,sha256=lampwUeWu4Uy8nBG7C4ZT-M6AHoWZS0m67HdLx21VDg,5396
177
178
  glaip_sdk/utils/validation.py,sha256=hB_k3lvHdIFUiSwHStrC0Eqnhx0OG2UvwqASeem0HuQ,6859
178
179
  glaip_sdk/utils/a2a/__init__.py,sha256=_X8AvDOsHeppo5n7rP5TeisVxlAdkZDTFReBk_9lmxo,876
@@ -206,8 +207,8 @@ glaip_sdk/utils/rendering/steps/format.py,sha256=Chnq7OBaj8XMeBntSBxrX5zSmrYeGcO
206
207
  glaip_sdk/utils/rendering/steps/manager.py,sha256=BiBmTeQMQhjRMykgICXsXNYh1hGsss-fH9BIGVMWFi0,13194
207
208
  glaip_sdk/utils/rendering/viewer/__init__.py,sha256=XrxmE2cMAozqrzo1jtDFm8HqNtvDcYi2mAhXLXn5CjI,457
208
209
  glaip_sdk/utils/rendering/viewer/presenter.py,sha256=mlLMTjnyeyPVtsyrAbz1BJu9lFGQSlS-voZ-_Cuugv0,5725
209
- glaip_sdk-0.7.6.dist-info/METADATA,sha256=jGATytvWTrk-Fe3kv_N6mxfD0vkpmDCsO4odOnOzPPM,8365
210
- glaip_sdk-0.7.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
211
- glaip_sdk-0.7.6.dist-info/entry_points.txt,sha256=NkhO6FfgX9Zrjn63GuKphf-dLw7KNJvucAcXc7P3aMk,54
212
- glaip_sdk-0.7.6.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
213
- glaip_sdk-0.7.6.dist-info/RECORD,,
210
+ glaip_sdk-0.7.8.dist-info/METADATA,sha256=srMagYZpJce1Yhs0KJo0636l_He6YAFCS1HVtEY8k9Q,8365
211
+ glaip_sdk-0.7.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
212
+ glaip_sdk-0.7.8.dist-info/entry_points.txt,sha256=NkhO6FfgX9Zrjn63GuKphf-dLw7KNJvucAcXc7P3aMk,54
213
+ glaip_sdk-0.7.8.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
214
+ glaip_sdk-0.7.8.dist-info/RECORD,,