glaip-sdk 0.6.19__py3-none-any.whl → 0.7.27__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.
Files changed (135) hide show
  1. glaip_sdk/agents/base.py +283 -30
  2. glaip_sdk/agents/component.py +233 -0
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  8. glaip_sdk/cli/commands/agents/_common.py +562 -0
  9. glaip_sdk/cli/commands/agents/create.py +155 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +1 -1
  17. glaip_sdk/cli/commands/configure.py +1 -2
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/entrypoint.py +20 -0
  46. glaip_sdk/cli/main.py +112 -35
  47. glaip_sdk/cli/pager.py +3 -3
  48. glaip_sdk/cli/resolution.py +2 -1
  49. glaip_sdk/cli/slash/accounts_controller.py +3 -1
  50. glaip_sdk/cli/slash/agent_session.py +1 -1
  51. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  52. glaip_sdk/cli/slash/session.py +343 -20
  53. glaip_sdk/cli/slash/tui/__init__.py +29 -1
  54. glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
  55. glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
  56. glaip_sdk/cli/slash/tui/clipboard.py +316 -0
  57. glaip_sdk/cli/slash/tui/context.py +92 -0
  58. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  59. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  60. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  61. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  62. glaip_sdk/cli/slash/tui/loading.py +43 -21
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +388 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +1 -1
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +293 -17
  78. glaip_sdk/client/base.py +25 -0
  79. glaip_sdk/client/hitl.py +136 -0
  80. glaip_sdk/client/main.py +7 -5
  81. glaip_sdk/client/mcps.py +44 -13
  82. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  83. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
  84. glaip_sdk/client/payloads/agent/responses.py +43 -0
  85. glaip_sdk/client/run_rendering.py +109 -30
  86. glaip_sdk/client/schedules.py +439 -0
  87. glaip_sdk/client/tools.py +52 -23
  88. glaip_sdk/config/constants.py +22 -2
  89. glaip_sdk/guardrails/__init__.py +80 -0
  90. glaip_sdk/guardrails/serializer.py +91 -0
  91. glaip_sdk/hitl/__init__.py +35 -2
  92. glaip_sdk/hitl/base.py +64 -0
  93. glaip_sdk/hitl/callback.py +43 -0
  94. glaip_sdk/hitl/local.py +1 -31
  95. glaip_sdk/hitl/remote.py +523 -0
  96. glaip_sdk/models/__init__.py +47 -1
  97. glaip_sdk/models/_provider_mappings.py +101 -0
  98. glaip_sdk/models/_validation.py +97 -0
  99. glaip_sdk/models/agent.py +2 -1
  100. glaip_sdk/models/agent_runs.py +2 -1
  101. glaip_sdk/models/constants.py +141 -0
  102. glaip_sdk/models/model.py +170 -0
  103. glaip_sdk/models/schedule.py +224 -0
  104. glaip_sdk/payload_schemas/agent.py +1 -0
  105. glaip_sdk/payload_schemas/guardrails.py +34 -0
  106. glaip_sdk/ptc.py +145 -0
  107. glaip_sdk/registry/tool.py +270 -57
  108. glaip_sdk/runner/__init__.py +20 -3
  109. glaip_sdk/runner/deps.py +4 -1
  110. glaip_sdk/runner/langgraph.py +251 -27
  111. glaip_sdk/runner/logging_config.py +77 -0
  112. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
  113. glaip_sdk/runner/ptc_adapter.py +98 -0
  114. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
  115. glaip_sdk/schedules/__init__.py +22 -0
  116. glaip_sdk/schedules/base.py +291 -0
  117. glaip_sdk/tools/base.py +67 -14
  118. glaip_sdk/utils/__init__.py +1 -0
  119. glaip_sdk/utils/agent_config.py +8 -2
  120. glaip_sdk/utils/bundler.py +138 -2
  121. glaip_sdk/utils/import_resolver.py +427 -49
  122. glaip_sdk/utils/runtime_config.py +3 -2
  123. glaip_sdk/utils/sync.py +31 -11
  124. glaip_sdk/utils/tool_detection.py +274 -6
  125. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
  126. glaip_sdk-0.7.27.dist-info/RECORD +227 -0
  127. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
  128. glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
  129. glaip_sdk/cli/commands/agents.py +0 -1509
  130. glaip_sdk/cli/commands/mcps.py +0 -1356
  131. glaip_sdk/cli/commands/tools.py +0 -576
  132. glaip_sdk/cli/utils.py +0 -263
  133. glaip_sdk-0.6.19.dist-info/RECORD +0 -163
  134. glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
  135. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,10 @@ import ast
13
13
  import importlib
14
14
  from pathlib import Path
15
15
 
16
+ from glaip_sdk.utils.tool_detection import is_tool_plugin_decorator
17
+
18
+ INIT_FILE = "__init__.py"
19
+
16
20
 
17
21
  class ImportResolver:
18
22
  """Resolves and categorizes Python imports for tool bundling.
@@ -44,6 +48,36 @@ class ImportResolver:
44
48
  """
45
49
  self.tool_dir = tool_dir
46
50
  self._processed_modules: set[str] = set()
51
+ self._package_root: Path = self._find_package_root(tool_dir)
52
+
53
+ def _find_package_root(self, start_path: Path) -> Path:
54
+ """Find package root using standard project markers.
55
+
56
+ Searches upward from start_path for common Python project markers:
57
+ - pyproject.toml (modern Python standard)
58
+ - setup.py (legacy setuptools)
59
+ - .git (version control root)
60
+
61
+ Falls back to start_path.parent if no markers found.
62
+
63
+ Args:
64
+ start_path: Starting directory to search from.
65
+
66
+ Returns:
67
+ Path to the identified package root.
68
+ """
69
+ # Check current directory and all parents
70
+ for parent in [start_path, *start_path.parents]:
71
+ if (parent / "pyproject.toml").exists():
72
+ return parent
73
+ if (parent / "setup.py").exists():
74
+ return parent
75
+ if (parent / ".git").exists():
76
+ return parent
77
+
78
+ # Fallback: return parent of start_path
79
+ # This maintains backward compatibility with original behavior
80
+ return start_path.parent
47
81
 
48
82
  def categorize_imports(self, tree: ast.AST) -> tuple[list, list]:
49
83
  """Categorize imports into local and external.
@@ -61,7 +95,7 @@ class ImportResolver:
61
95
  for node in ast.walk(tree):
62
96
  if isinstance(node, ast.ImportFrom):
63
97
  if self.is_local_import(node):
64
- module_file = self.resolve_module_path(node.module)
98
+ module_file = self._resolve_import_path(node)
65
99
  local_imports.append((node.module, module_file, node))
66
100
  else:
67
101
  external_imports.append(node)
@@ -70,6 +104,25 @@ class ImportResolver:
70
104
 
71
105
  return local_imports, external_imports
72
106
 
107
+ def _resolve_import_path(self, node: ast.ImportFrom) -> Path:
108
+ """Resolve import node to file path.
109
+
110
+ Handles both absolute and relative imports.
111
+
112
+ Args:
113
+ node: ImportFrom AST node.
114
+
115
+ Returns:
116
+ Path to the module file.
117
+ """
118
+ if node.level > 0:
119
+ return self.resolve_relative_import_path(node)
120
+
121
+ if not node.module:
122
+ return self.tool_dir / INIT_FILE
123
+
124
+ return self.resolve_module_path(node.module)
125
+
73
126
  def is_local_import(self, node: ast.ImportFrom) -> bool:
74
127
  """Check if import is local to the tool directory.
75
128
 
@@ -79,16 +132,83 @@ class ImportResolver:
79
132
  Returns:
80
133
  True if import is local.
81
134
  """
135
+ # Handle relative imports (level > 0)
136
+ if node.level > 0:
137
+ return self._is_local_relative_import(node)
138
+
139
+ # Handle absolute imports with no module name
82
140
  if not node.module:
83
141
  return False
84
142
 
85
- # Handle package imports
143
+ # Handle dotted package imports
86
144
  if "." in node.module:
87
145
  return self._is_local_package_import(node.module)
88
146
 
147
+ # Handle simple module imports
89
148
  potential_file = self.tool_dir / f"{node.module}.py"
90
149
  return potential_file.exists()
91
150
 
151
+ def _is_local_relative_import(self, node: ast.ImportFrom) -> bool:
152
+ """Check if a relative import is local.
153
+
154
+ Args:
155
+ node: ImportFrom node with node.level > 0.
156
+
157
+ Returns:
158
+ True if the relative import resolves to a local file.
159
+ """
160
+ if node.level == 0:
161
+ return False
162
+
163
+ base_dir = self._calculate_relative_base_dir(node.level)
164
+ if not base_dir:
165
+ return False
166
+
167
+ if node.module is None:
168
+ return self._is_init_file_present(base_dir)
169
+
170
+ return self._check_module_exists(base_dir, node.module)
171
+
172
+ def _calculate_relative_base_dir(self, level: int) -> Path | None:
173
+ """Calculate base directory for relative import.
174
+
175
+ Args:
176
+ level: Relative import level (1=current, 2=parent, etc.).
177
+
178
+ Returns:
179
+ Base directory path or None if exceeds filesystem root.
180
+ """
181
+ base_dir = self.tool_dir
182
+ for _ in range(level - 1):
183
+ if base_dir == base_dir.parent:
184
+ return None
185
+ base_dir = base_dir.parent
186
+ return base_dir
187
+
188
+ def _is_init_file_present(self, base_dir: Path) -> bool:
189
+ """Check if __init__.py exists in directory.
190
+
191
+ Args:
192
+ base_dir: Directory to check.
193
+
194
+ Returns:
195
+ True if __init__.py exists.
196
+ """
197
+ return (base_dir / INIT_FILE).exists()
198
+
199
+ def _check_module_exists(self, base_dir: Path, module: str) -> bool:
200
+ """Check if module exists in base directory.
201
+
202
+ Args:
203
+ base_dir: Base directory to search from.
204
+ module: Module name (dotted or simple).
205
+
206
+ Returns:
207
+ True if module file or package exists.
208
+ """
209
+ parts = module.split(".")
210
+ return self._module_parts_exist(base_dir, parts)
211
+
92
212
  def _is_local_package_import(self, module: str) -> bool:
93
213
  """Check if a dotted module path is local.
94
214
 
@@ -100,29 +220,141 @@ class ImportResolver:
100
220
  """
101
221
  parts = module.split(".")
102
222
 
103
- # Case 1: First part matches current directory name
104
- if parts[0] == self.tool_dir.name:
105
- remaining_parts = parts[1:]
106
- if len(remaining_parts) == 1:
107
- module_path = self.tool_dir / f"{remaining_parts[0]}.py"
108
- if module_path.exists():
109
- return True
110
- elif len(remaining_parts) > 1:
111
- module_path = self.tool_dir / "/".join(remaining_parts[:-1]) / f"{remaining_parts[-1]}.py"
112
- if module_path.exists():
113
- return True
114
-
115
- # Case 2: First part is a subdirectory of tool_dir
223
+ if self._check_module_in_tool_dir(parts):
224
+ return True
225
+
226
+ if self._check_module_in_subdirectory(parts):
227
+ return True
228
+
229
+ if self._check_module_in_ancestor_packages(parts):
230
+ return True
231
+
232
+ return False
233
+
234
+ def _check_module_in_tool_dir(self, parts: list[str]) -> bool:
235
+ """Check if module is in the current tool directory (Case 1).
236
+
237
+ Args:
238
+ parts: Module path split into components.
239
+
240
+ Returns:
241
+ True if module is found in tool_dir.
242
+ """
243
+ if parts[0] != self.tool_dir.name:
244
+ return False
245
+
246
+ remaining_parts = parts[1:]
247
+ if not remaining_parts:
248
+ return False
249
+
250
+ return self._module_parts_exist(self.tool_dir, remaining_parts)
251
+
252
+ def _check_module_in_subdirectory(self, parts: list[str]) -> bool:
253
+ """Check if module is in a subdirectory of tool_dir (Case 2).
254
+
255
+ Args:
256
+ parts: Module path split into components.
257
+
258
+ Returns:
259
+ True if module is found in a subdirectory.
260
+ """
116
261
  package_dir = self.tool_dir / parts[0]
117
- if package_dir.is_dir():
118
- module_path = self.tool_dir / "/".join(parts[:-1]) / f"{parts[-1]}.py"
119
- if module_path.exists():
120
- return True
121
- module_path = self.tool_dir / "/".join(parts) / "__init__.py"
122
- return module_path.exists()
262
+ if not package_dir.is_dir():
263
+ return False
264
+
265
+ return self._module_parts_exist(self.tool_dir, parts)
266
+
267
+ def _check_module_in_ancestor_packages(self, parts: list[str]) -> bool:
268
+ """Check if module is in the package root.
269
+
270
+ Uses the discovered package root (via project markers) to resolve
271
+ imports from the package root. Also falls back to directory name
272
+ matching for backward compatibility.
273
+
274
+ Args:
275
+ parts: Module path split into components.
276
+
277
+ Returns:
278
+ True if module is found in the package root.
279
+ """
280
+ # Strategy 1: Check if package root contains the module
281
+ if self._check_module_in_package_root(parts):
282
+ return True
283
+
284
+ # Strategy 2: Fallback to directory name matching (backward compatibility)
285
+ return self._check_module_by_directory_name(parts)
286
+
287
+ def _check_module_in_package_root(self, parts: list[str]) -> bool:
288
+ """Check if module exists in the discovered package root.
289
+
290
+ Args:
291
+ parts: Module path split into components.
292
+
293
+ Returns:
294
+ True if module exists in package root.
295
+ """
296
+ # Resolve module from package root
297
+ module_path = self._resolve_from_package_root_path(self._package_root, parts)
298
+ return module_path.exists()
299
+
300
+ def _check_module_by_directory_name(self, parts: list[str]) -> bool:
301
+ """Check module by matching directory names (legacy fallback).
302
+
303
+ Args:
304
+ parts: Module path split into components.
305
+
306
+ Returns:
307
+ True if module found by directory name matching.
308
+ """
309
+ current = self.tool_dir
310
+
311
+ while current != current.parent:
312
+ if parts[0] == current.name:
313
+ return self._resolve_from_package_root(current, parts)
314
+ current = current.parent
123
315
 
124
316
  return False
125
317
 
318
+ def _resolve_from_package_root(self, package_root: Path, parts: list[str]) -> bool:
319
+ """Resolve module from a found package root directory.
320
+
321
+ Args:
322
+ package_root: The identified package root directory.
323
+ parts: Module path split into components.
324
+
325
+ Returns:
326
+ True if module exists in the package root.
327
+ """
328
+ remaining_parts = parts[1:]
329
+
330
+ if not remaining_parts:
331
+ return (package_root / INIT_FILE).exists()
332
+
333
+ return self._module_parts_exist(package_root, remaining_parts)
334
+
335
+ def _module_parts_exist(self, base_dir: Path, parts: list[str]) -> bool:
336
+ """Check if module parts exist as a file or package.
337
+
338
+ Args:
339
+ base_dir: Base directory to search from.
340
+ parts: Remaining module path components.
341
+
342
+ Returns:
343
+ True if module file or package exists.
344
+ """
345
+ if len(parts) == 1:
346
+ module_file = base_dir / f"{parts[0]}.py"
347
+ if module_file.exists():
348
+ return True
349
+
350
+ if len(parts) > 1:
351
+ module_file = base_dir / "/".join(parts[:-1]) / f"{parts[-1]}.py"
352
+ if module_file.exists():
353
+ return True
354
+
355
+ init_file = base_dir / "/".join(parts) / INIT_FILE
356
+ return init_file.exists()
357
+
126
358
  def resolve_module_path(self, module_name: str) -> Path:
127
359
  """Resolve module name to file path.
128
360
 
@@ -136,6 +368,40 @@ class ImportResolver:
136
368
  return self._resolve_dotted_module_path(module_name)
137
369
  return self.tool_dir / f"{module_name}.py"
138
370
 
371
+ def resolve_relative_import_path(self, node: ast.ImportFrom) -> Path:
372
+ """Resolve relative import to file path.
373
+
374
+ Args:
375
+ node: ImportFrom node with node.level > 0.
376
+
377
+ Returns:
378
+ Path to the module file.
379
+
380
+ Raises:
381
+ ValueError: If relative import level exceeds directory depth.
382
+ """
383
+ base_dir = self._calculate_relative_base_dir(node.level)
384
+ if base_dir is None:
385
+ raise ValueError(f"Invalid relative import: level {node.level} exceeds directory depth")
386
+
387
+ if node.module is None:
388
+ return base_dir / INIT_FILE
389
+
390
+ return self._resolve_dotted_module_path_from_base(base_dir, node.module)
391
+
392
+ def _resolve_dotted_module_path_from_base(self, base_dir: Path, module_name: str) -> Path:
393
+ """Resolve dotted module path from a specific base directory.
394
+
395
+ Args:
396
+ base_dir: Base directory to resolve from.
397
+ module_name: Dotted module path (e.g., 'package.module').
398
+
399
+ Returns:
400
+ Path to the module file.
401
+ """
402
+ parts = module_name.split(".")
403
+ return self._build_module_path(base_dir, parts)
404
+
139
405
  def _resolve_dotted_module_path(self, module_name: str) -> Path:
140
406
  """Resolve a dotted module path to a file path.
141
407
 
@@ -147,25 +413,107 @@ class ImportResolver:
147
413
  """
148
414
  parts = module_name.split(".")
149
415
 
150
- # Case 1: First part matches current directory name
151
- if parts[0] == self.tool_dir.name:
152
- remaining_parts = parts[1:]
153
- if len(remaining_parts) == 1:
154
- module_path = self.tool_dir / f"{remaining_parts[0]}.py"
155
- if module_path.exists():
156
- return module_path
157
- elif len(remaining_parts) > 1:
158
- module_path = self.tool_dir / "/".join(remaining_parts[:-1]) / f"{remaining_parts[-1]}.py"
159
- if module_path.exists():
160
- return module_path
161
-
162
- # Case 2: Standard package/module.py
163
- module_path = self.tool_dir / "/".join(parts[:-1]) / f"{parts[-1]}.py"
416
+ module_path = self._resolve_from_tool_dir(parts)
417
+ if module_path and module_path.exists():
418
+ return module_path
419
+
420
+ module_path = self._resolve_from_subdir(parts)
164
421
  if module_path.exists():
165
422
  return module_path
166
423
 
167
- # Try package/__init__.py
168
- return self.tool_dir / "/".join(parts) / "__init__.py"
424
+ module_path = self._resolve_from_ancestor_packages(parts)
425
+ if module_path and module_path.exists():
426
+ return module_path
427
+
428
+ return self.tool_dir / "/".join(parts) / INIT_FILE
429
+
430
+ def _resolve_from_tool_dir(self, parts: list[str]) -> Path | None:
431
+ """Resolve module from current tool directory.
432
+
433
+ Args:
434
+ parts: Module path split into components.
435
+
436
+ Returns:
437
+ Path to potential module file, or None if not applicable.
438
+ """
439
+ if parts[0] != self.tool_dir.name:
440
+ return None
441
+
442
+ remaining_parts = parts[1:]
443
+ if not remaining_parts:
444
+ return self.tool_dir / INIT_FILE
445
+
446
+ return self._build_module_path(self.tool_dir, remaining_parts)
447
+
448
+ def _resolve_from_subdir(self, parts: list[str]) -> Path:
449
+ """Resolve module from a subdirectory.
450
+
451
+ Args:
452
+ parts: Module path split into components.
453
+
454
+ Returns:
455
+ Path to potential module file.
456
+ """
457
+ return self._build_module_path(self.tool_dir, parts)
458
+
459
+ def _resolve_from_ancestor_packages(self, parts: list[str]) -> Path | None:
460
+ """Resolve module from an ancestor directory (Package Root Traversal).
461
+
462
+ Traverses up the directory tree looking for a matching package root.
463
+
464
+ Args:
465
+ parts: Module path split into components.
466
+
467
+ Returns:
468
+ Path to module if found in ancestor, None otherwise.
469
+ """
470
+ current = self.tool_dir
471
+
472
+ while current != current.parent:
473
+ if parts[0] == current.name:
474
+ return self._resolve_from_package_root_path(current, parts)
475
+ current = current.parent
476
+
477
+ return None
478
+
479
+ def _resolve_from_package_root_path(self, package_root: Path, parts: list[str]) -> Path:
480
+ """Build module path from a found package root.
481
+
482
+ Args:
483
+ package_root: The identified package root directory.
484
+ parts: Module path split into components.
485
+
486
+ Returns:
487
+ Path to potential module file.
488
+ """
489
+ remaining_parts = parts[1:]
490
+
491
+ if not remaining_parts:
492
+ return package_root / INIT_FILE
493
+
494
+ return self._build_module_path(package_root, remaining_parts)
495
+
496
+ def _build_module_path(self, base_dir: Path, parts: list[str]) -> Path:
497
+ """Build potential module file path from parts.
498
+
499
+ Args:
500
+ base_dir: Base directory to search from.
501
+ parts: Module path components.
502
+
503
+ Returns:
504
+ Path to potential module file.
505
+ """
506
+ if len(parts) == 1:
507
+ module_path = base_dir / f"{parts[0]}.py"
508
+ if module_path.exists():
509
+ return module_path
510
+
511
+ if len(parts) > 1:
512
+ module_path = base_dir / "/".join(parts[:-1]) / f"{parts[-1]}.py"
513
+ if module_path.exists():
514
+ return module_path
515
+
516
+ return base_dir / "/".join(parts) / INIT_FILE
169
517
 
170
518
  def format_external_imports(self, external_imports: list) -> list[str]:
171
519
  """Format external imports as code strings.
@@ -215,11 +563,49 @@ class ImportResolver:
215
563
  True if import should be skipped.
216
564
  """
217
565
  if isinstance(node, ast.ImportFrom):
218
- return node.module and any(node.module.startswith(m) for m in self.EXCLUDED_MODULES)
566
+ return self._should_skip_import_from(node)
219
567
  if isinstance(node, ast.Import):
220
- return any(alias.name.startswith(m) for m in self.EXCLUDED_MODULES for alias in node.names)
568
+ return self._should_skip_regular_import(node)
221
569
  return False
222
570
 
571
+ def _should_skip_import_from(self, node: ast.ImportFrom) -> bool:
572
+ """Check if ImportFrom node should be skipped.
573
+
574
+ Args:
575
+ node: ImportFrom node to check.
576
+
577
+ Returns:
578
+ True if import should be skipped.
579
+ """
580
+ if not node.module:
581
+ return False
582
+ return self._is_module_excluded(node.module)
583
+
584
+ def _should_skip_regular_import(self, node: ast.Import) -> bool:
585
+ """Check if Import node should be skipped.
586
+
587
+ Args:
588
+ node: Import node to check.
589
+
590
+ Returns:
591
+ True if any alias should be skipped.
592
+ """
593
+ return any(self._is_module_excluded(alias.name) for alias in node.names)
594
+
595
+ def _is_module_excluded(self, module_name: str) -> bool:
596
+ """Check if a module name should be excluded.
597
+
598
+ Args:
599
+ module_name: Module name to check.
600
+
601
+ Returns:
602
+ True if module is excluded.
603
+ """
604
+ # Exact match for glaip_sdk or match excluded submodules with boundary
605
+ if module_name == "glaip_sdk":
606
+ return True
607
+ return any(module_name == m or module_name.startswith(m + ".") for m in self.EXCLUDED_MODULES)
608
+
223
609
  @staticmethod
224
610
  def _build_import_strings(future_imports: list, regular_imports: list) -> list[str]:
225
611
  """Build formatted import strings from import nodes.
@@ -381,7 +767,7 @@ class ImportResolver:
381
767
  The node if external, None if local.
382
768
  """
383
769
  if isinstance(node, ast.ImportFrom):
384
- if node.module and node.module.startswith("."):
770
+ if node.level > 0:
385
771
  return None
386
772
  temp_resolver = ImportResolver(tool_dir)
387
773
  if temp_resolver.is_local_import(node):
@@ -444,15 +830,7 @@ class ImportResolver:
444
830
  Returns:
445
831
  True if decorator is @tool_plugin.
446
832
  """
447
- if isinstance(decorator, ast.Name) and decorator.id == "tool_plugin":
448
- return True
449
- if (
450
- isinstance(decorator, ast.Call)
451
- and isinstance(decorator.func, ast.Name)
452
- and decorator.func.id == "tool_plugin"
453
- ):
454
- return True
455
- return False
833
+ return is_tool_plugin_decorator(decorator)
456
834
 
457
835
  @staticmethod
458
836
  def _filter_bases(bases: list) -> list:
@@ -10,6 +10,7 @@ Authors:
10
10
  from __future__ import annotations
11
11
 
12
12
  from typing import TYPE_CHECKING
13
+ from collections.abc import Mapping
13
14
 
14
15
  from glaip_sdk.utils.resource_refs import is_uuid
15
16
  from gllm_core.utils import LoggerManager
@@ -379,14 +380,14 @@ def get_name_from_key(key: object) -> str | None:
379
380
  raise ValueError(f"Unable to resolve config key: {key!r}")
380
381
 
381
382
 
382
- def normalize_local_config_keys(config: dict[object, object]) -> dict[str, object]:
383
+ def normalize_local_config_keys(config: Mapping[object, object]) -> dict[str, object]:
383
384
  """Normalize all keys in a config dict to names for local mode.
384
385
 
385
386
  Converts instance/class/string keys to string names without using
386
387
  registry. UUID keys are skipped with a warning.
387
388
 
388
389
  Args:
389
- config: Dict with instance/class/string keys and any values.
390
+ config: Dict/Mapping with instance/class/string keys and any values.
390
391
 
391
392
  Returns:
392
393
  Dict with string name keys only. UUID keys are omitted.
glaip_sdk/utils/sync.py CHANGED
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  from typing import TYPE_CHECKING, Any
17
17
 
18
+ from glaip_sdk.exceptions import ValidationError
18
19
  from glaip_sdk.utils.bundler import ToolBundler
19
20
  from glaip_sdk.utils.import_resolver import load_class
20
21
  from gllm_core.utils import LoggerManager
@@ -94,19 +95,38 @@ def update_or_create_tool(tool_ref: Any) -> Tool:
94
95
  tool_name = _extract_tool_name(tool_class)
95
96
  tool_description = _extract_tool_description(tool_class)
96
97
 
97
- # Bundle source code
98
+ # Bundle source code - try without decorator first (for newer servers 0.1.85+)
99
+ # If validation fails, retry with decorator for older servers (< 0.1.85)
98
100
  bundler = ToolBundler(tool_class)
99
- bundled_source = bundler.bundle()
100
101
 
101
- logger.info("Tool info: name='%s', description='%s...'", tool_name, tool_description[:50])
102
- logger.info("Bundled source code: %d characters", len(bundled_source))
103
-
104
- # Use client's upsert method
105
- return client.tools.upsert_tool(
106
- tool_name,
107
- code=bundled_source,
108
- description=tool_description,
109
- )
102
+ try:
103
+ # Try without decorator first (for newer servers where it's optional)
104
+ bundled_source = bundler.bundle(add_tool_plugin_decorator=False)
105
+ logger.info("Tool info: name='%s', description='%s...'", tool_name, tool_description[:50])
106
+ logger.info("Bundled source code (without decorator): %d characters", len(bundled_source))
107
+
108
+ # Attempt upload without decorator
109
+ return client.tools.upsert_tool(
110
+ tool_name,
111
+ code=bundled_source,
112
+ description=tool_description,
113
+ )
114
+ except ValidationError as e:
115
+ # Check if error is about missing @tool_plugin decorator
116
+ error_message = str(e).lower()
117
+ if "@tool_plugin decorator" in error_message or "no classes found" in error_message:
118
+ # Retry with decorator for older servers (< 0.1.85)
119
+ logger.info("Server requires @tool_plugin decorator, retrying with decorator added")
120
+ bundled_source = bundler.bundle(add_tool_plugin_decorator=True)
121
+ logger.info("Bundled source code (with decorator): %d characters", len(bundled_source))
122
+
123
+ return client.tools.upsert_tool(
124
+ tool_name,
125
+ code=bundled_source,
126
+ description=tool_description,
127
+ )
128
+ # Re-raise if it's a different validation error
129
+ raise
110
130
 
111
131
 
112
132
  def update_or_create_agent(agent_config: dict[str, Any]) -> Agent: