glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__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 (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  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 +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -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 +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  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/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  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 +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  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 +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
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:
@@ -256,6 +266,9 @@ class Tool:
256
266
  Returns:
257
267
  A Tool reference that will be uploaded during Agent.deploy().
258
268
 
269
+ Raises:
270
+ ValueError: If the tool class has no valid string 'name' attribute or field.
271
+
259
272
  Example:
260
273
  >>> from langchain_core.tools import BaseTool
261
274
  >>>
@@ -267,7 +280,34 @@ class Tool:
267
280
  >>>
268
281
  >>> greeting_tool = Tool.from_langchain(GreetingTool)
269
282
  """
270
- return cls(tool_class=tool_class, type=ToolType.CUSTOM)
283
+ # Extract name from tool_class to populate the name attribute
284
+ tool_name = cls._extract_tool_name(tool_class)
285
+ return cls(name=tool_name, tool_class=tool_class, type=ToolType.CUSTOM)
286
+
287
+ @staticmethod
288
+ def _extract_tool_name(tool_class: type) -> str:
289
+ """Extract tool name from a LangChain tool class.
290
+
291
+ Args:
292
+ tool_class: A LangChain BaseTool subclass.
293
+
294
+ Returns:
295
+ The extracted tool name.
296
+
297
+ Raises:
298
+ ValueError: If name cannot be extracted or is not a valid string.
299
+ """
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
305
+
306
+ # If we can't extract the name, raise an error
307
+ raise ValueError(
308
+ f"Cannot extract name from tool class {tool_class.__name__}. "
309
+ f"Ensure the tool class has a 'name' attribute or field with a valid string value."
310
+ )
271
311
 
272
312
  def get_import_path(self) -> str | None:
273
313
  """Get the import path for custom tools.
@@ -292,15 +332,8 @@ class Tool:
292
332
  return self.name
293
333
 
294
334
  if self.tool_class is not None:
295
- # LangChain BaseTool - get name from model_fields
296
- if hasattr(self.tool_class, "model_fields"):
297
- name_field = self.tool_class.model_fields.get("name")
298
- if name_field and name_field.default:
299
- return name_field.default
300
-
301
- # Direct name attribute
302
- if hasattr(self.tool_class, "name"):
303
- return self.tool_class.name
335
+ # Reuse extraction logic for consistency
336
+ return self._extract_tool_name(self.tool_class)
304
337
 
305
338
  raise ValueError(f"Cannot determine name for tool: {self}")
306
339
 
@@ -354,12 +387,22 @@ class Tool:
354
387
  if not self._client:
355
388
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
356
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
+
357
400
  # Check if file upload is requested
358
401
  if "file" in kwargs:
359
402
  file_path = kwargs.pop("file")
360
- 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)
361
404
  else:
362
- response = self._client.tools.update(tool_id=self._id, **kwargs)
405
+ response = tools_client.update_tool(tool_id=self._id, **kwargs)
363
406
 
364
407
  # Update local properties from response
365
408
  if hasattr(response, "name") and response.name:
@@ -383,7 +426,17 @@ class Tool:
383
426
  if not self._client:
384
427
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
385
428
 
386
- 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)
387
440
  self._id = None
388
441
  self._client = None
389
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:
@@ -14,6 +14,7 @@ import inspect
14
14
  from pathlib import Path
15
15
 
16
16
  from glaip_sdk.utils.import_resolver import ImportResolver
17
+ from glaip_sdk.utils.tool_detection import is_tool_plugin_decorator
17
18
 
18
19
 
19
20
  class ToolBundler:
@@ -50,9 +51,14 @@ class ToolBundler:
50
51
  self.tool_dir = self.tool_file.parent
51
52
  self._import_resolver = ImportResolver(self.tool_dir)
52
53
 
53
- def bundle(self) -> str:
54
+ def bundle(self, add_tool_plugin_decorator: bool = True) -> str:
54
55
  """Bundle tool source code with inlined local imports.
55
56
 
57
+ Args:
58
+ add_tool_plugin_decorator: If True, add @tool_plugin decorator to BaseTool classes.
59
+ Set to False for newer servers (0.1.85+) where decorator is optional.
60
+ Defaults to True for backward compatibility with older servers.
61
+
56
62
  Returns:
57
63
  Bundled source code with all local dependencies inlined.
58
64
  """
@@ -62,6 +68,16 @@ class ToolBundler:
62
68
  tree = ast.parse(full_source)
63
69
  local_imports, external_imports = self._import_resolver.categorize_imports(tree)
64
70
 
71
+ # NOTE: The @tool_plugin decorator is REQUIRED by older servers (< 0.1.85) for remote execution.
72
+ # Newer servers (0.1.85+) make the decorator optional.
73
+ # The server validates uploaded tool code and will reject tools without the decorator
74
+ # with error: "No classes found with @tool_plugin decorator".
75
+ # See: docs/resources/reference/schemas/tools.md - "Plugin Requirements"
76
+ # TESTED: Commenting out this decorator addition causes HTTP 400 ValidationError from older servers.
77
+ # We try without decorator first (for new servers), then retry with decorator if validation fails.
78
+ if add_tool_plugin_decorator:
79
+ self._add_tool_plugin_decorator(tree)
80
+
65
81
  # Extract main code nodes (excluding imports, docstrings, glaip_sdk.Tool subclasses)
66
82
  main_code_nodes = self._extract_main_code_nodes(tree)
67
83
 
@@ -71,6 +87,13 @@ class ToolBundler:
71
87
  # Merge all external imports
72
88
  all_external_imports = external_imports + inlined_external_imports
73
89
 
90
+ # NOTE: The gllm_plugin.tools import is REQUIRED when decorator is added.
91
+ # Without this import, the decorator will cause a NameError when the server executes the code.
92
+ # TESTED: Commenting out this import causes NameError when server tries to use the decorator.
93
+ # This import is added automatically during bundling so source files can remain clean.
94
+ if add_tool_plugin_decorator:
95
+ self._ensure_tool_plugin_import(all_external_imports)
96
+
74
97
  # Build bundled code
75
98
  bundled_code = ["# Bundled tool with inlined local imports\n"]
76
99
  bundled_code.extend(self._import_resolver.format_external_imports(all_external_imports))
@@ -109,6 +132,103 @@ class ToolBundler:
109
132
  main_code_nodes.append(ast.unparse(node))
110
133
  return main_code_nodes
111
134
 
135
+ @staticmethod
136
+ def _add_tool_plugin_decorator(tree: ast.AST) -> None:
137
+ """Add @tool_plugin decorator to BaseTool classes that don't have it.
138
+
139
+ This allows tools to be clean (without decorator) for local use,
140
+ while the decorator is automatically added during bundling for remote execution.
141
+
142
+ Args:
143
+ tree: AST tree to modify in-place.
144
+ """
145
+ for node in ast.walk(tree):
146
+ if not isinstance(node, ast.ClassDef):
147
+ continue
148
+
149
+ if not ToolBundler._inherits_from_base_tool(node):
150
+ continue
151
+
152
+ if ToolBundler._has_tool_plugin_decorator(node):
153
+ continue
154
+
155
+ decorator_call = ToolBundler._create_tool_plugin_decorator()
156
+ node.decorator_list.insert(0, decorator_call)
157
+
158
+ @staticmethod
159
+ def _inherits_from_base_tool(class_node: ast.ClassDef) -> bool:
160
+ """Check if a class inherits from BaseTool.
161
+
162
+ Args:
163
+ class_node: AST ClassDef node to check.
164
+
165
+ Returns:
166
+ True if class inherits from BaseTool.
167
+ """
168
+ for base in class_node.bases:
169
+ if isinstance(base, ast.Name) and base.id == "BaseTool":
170
+ return True
171
+ if isinstance(base, ast.Attribute) and base.attr == "BaseTool":
172
+ # Handle nested attributes like langchain_core.tools.BaseTool
173
+ # Check if the value chain leads to langchain_core
174
+ value = base.value
175
+ while isinstance(value, ast.Attribute):
176
+ value = value.value
177
+ if isinstance(value, ast.Name) and value.id == "langchain_core":
178
+ return True
179
+ return False
180
+
181
+ @staticmethod
182
+ def _has_tool_plugin_decorator(class_node: ast.ClassDef) -> bool:
183
+ """Check if a class already has the @tool_plugin decorator.
184
+
185
+ Args:
186
+ class_node: AST ClassDef node to check.
187
+
188
+ Returns:
189
+ True if decorator already exists.
190
+ """
191
+ for decorator in class_node.decorator_list:
192
+ if is_tool_plugin_decorator(decorator):
193
+ return True
194
+ return False
195
+
196
+ @staticmethod
197
+ def _create_tool_plugin_decorator() -> ast.Call:
198
+ """Create a @tool_plugin decorator AST node.
199
+
200
+ Returns:
201
+ AST Call node representing @tool_plugin(version="1.0.0").
202
+ """
203
+ return ast.Call(
204
+ func=ast.Name(id="tool_plugin", ctx=ast.Load()),
205
+ args=[],
206
+ keywords=[ast.keyword(arg="version", value=ast.Constant(value="1.0.0"))],
207
+ )
208
+
209
+ @staticmethod
210
+ def _ensure_tool_plugin_import(external_imports: list) -> None:
211
+ """Ensure gllm_plugin.tools import is present in external imports.
212
+
213
+ Args:
214
+ external_imports: List of external import nodes (modified in-place).
215
+ """
216
+ # Check if import already exists
217
+ for import_node in external_imports:
218
+ if isinstance(import_node, ast.ImportFrom) and import_node.module == "gllm_plugin.tools":
219
+ # Check if tool_plugin is in the names
220
+ for alias in import_node.names:
221
+ if alias.name == "tool_plugin":
222
+ return # Import already present
223
+
224
+ # Add the import
225
+ import_node = ast.ImportFrom(
226
+ module="gllm_plugin.tools",
227
+ names=[ast.alias(name="tool_plugin")],
228
+ level=0,
229
+ )
230
+ external_imports.append(import_node)
231
+
112
232
  @staticmethod
113
233
  def _is_sdk_tool_subclass(node: ast.ClassDef) -> bool:
114
234
  """Check if AST class definition inherits from Tool.
@@ -135,7 +255,7 @@ class ToolBundler:
135
255
  return False
136
256
 
137
257
  @classmethod
138
- def bundle_from_source(cls, file_path: Path) -> tuple[str, str, str]:
258
+ def bundle_from_source(cls, file_path: Path, add_tool_plugin_decorator: bool = True) -> tuple[str, str, str]:
139
259
  """Extract tool info directly from source file without importing.
140
260
 
141
261
  This is used as a fallback when the tool class cannot be imported
@@ -143,6 +263,9 @@ class ToolBundler:
143
263
 
144
264
  Args:
145
265
  file_path: Path to the tool source file.
266
+ add_tool_plugin_decorator: If True, add @tool_plugin decorator to BaseTool classes.
267
+ Set to False for newer servers (0.1.85+) where decorator is optional.
268
+ Defaults to True for backward compatibility with older servers.
146
269
 
147
270
  Returns:
148
271
  Tuple of (name, description, bundled_source_code).
@@ -160,6 +283,12 @@ class ToolBundler:
160
283
  tool_dir = file_path.parent
161
284
  import_resolver = ImportResolver(tool_dir)
162
285
 
286
+ # NOTE: The @tool_plugin decorator is REQUIRED by older servers (< 0.1.85) for remote execution.
287
+ # Newer servers (0.1.85+) make the decorator optional.
288
+ # See bundle() method for detailed explanation.
289
+ if add_tool_plugin_decorator:
290
+ cls._add_tool_plugin_decorator(tree)
291
+
163
292
  # Find tool name and description from class definitions
164
293
  tool_name, tool_description = cls._extract_tool_metadata(tree, file_path.stem)
165
294
 
@@ -180,6 +309,13 @@ class ToolBundler:
180
309
 
181
310
  # Build bundled code
182
311
  all_external_imports = external_imports + inlined_external_imports
312
+
313
+ # NOTE: The gllm_plugin.tools import is REQUIRED when decorator is added.
314
+ # See bundle() method for detailed explanation.
315
+ # TESTED: Commenting out this import causes NameError when server tries to use the decorator.
316
+ if add_tool_plugin_decorator:
317
+ cls._ensure_tool_plugin_import(all_external_imports)
318
+
183
319
  bundled_code = ["# Bundled tool with inlined local imports\n"]
184
320
  bundled_code.extend(import_resolver.format_external_imports(all_external_imports))
185
321
 
@@ -13,6 +13,8 @@ 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
+
16
18
 
17
19
  class ImportResolver:
18
20
  """Resolves and categorizes Python imports for tool bundling.
@@ -215,11 +217,49 @@ class ImportResolver:
215
217
  True if import should be skipped.
216
218
  """
217
219
  if isinstance(node, ast.ImportFrom):
218
- return node.module and any(node.module.startswith(m) for m in self.EXCLUDED_MODULES)
220
+ return self._should_skip_import_from(node)
219
221
  if isinstance(node, ast.Import):
220
- return any(alias.name.startswith(m) for m in self.EXCLUDED_MODULES for alias in node.names)
222
+ return self._should_skip_regular_import(node)
221
223
  return False
222
224
 
225
+ def _should_skip_import_from(self, node: ast.ImportFrom) -> bool:
226
+ """Check if ImportFrom node should be skipped.
227
+
228
+ Args:
229
+ node: ImportFrom node to check.
230
+
231
+ Returns:
232
+ True if import should be skipped.
233
+ """
234
+ if not node.module:
235
+ return False
236
+ return self._is_module_excluded(node.module)
237
+
238
+ def _should_skip_regular_import(self, node: ast.Import) -> bool:
239
+ """Check if Import node should be skipped.
240
+
241
+ Args:
242
+ node: Import node to check.
243
+
244
+ Returns:
245
+ True if any alias should be skipped.
246
+ """
247
+ return any(self._is_module_excluded(alias.name) for alias in node.names)
248
+
249
+ def _is_module_excluded(self, module_name: str) -> bool:
250
+ """Check if a module name should be excluded.
251
+
252
+ Args:
253
+ module_name: Module name to check.
254
+
255
+ Returns:
256
+ True if module is excluded.
257
+ """
258
+ # Exact match for glaip_sdk or match excluded submodules with boundary
259
+ if module_name == "glaip_sdk":
260
+ return True
261
+ return any(module_name == m or module_name.startswith(m + ".") for m in self.EXCLUDED_MODULES)
262
+
223
263
  @staticmethod
224
264
  def _build_import_strings(future_imports: list, regular_imports: list) -> list[str]:
225
265
  """Build formatted import strings from import nodes.
@@ -444,15 +484,7 @@ class ImportResolver:
444
484
  Returns:
445
485
  True if decorator is @tool_plugin.
446
486
  """
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
487
+ return is_tool_plugin_decorator(decorator)
456
488
 
457
489
  @staticmethod
458
490
  def _filter_bases(bases: list) -> list:
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ import sys
11
12
  from datetime import datetime, timezone
12
13
  from time import monotonic
13
14
  from typing import Any
@@ -349,6 +350,9 @@ class RichStreamRenderer(TranscriptModeMixin):
349
350
  self._handle_status_event(ev)
350
351
  elif kind == "content":
351
352
  self._handle_content_event(content)
353
+ elif kind == "token":
354
+ # Token events should stream content incrementally with immediate console output
355
+ self._handle_token_event(content)
352
356
  elif kind == "final_response":
353
357
  self._handle_final_response_event(content, metadata)
354
358
  elif kind in {"agent_step", "agent_thinking_step"}:
@@ -368,6 +372,31 @@ class RichStreamRenderer(TranscriptModeMixin):
368
372
  self.state.append_transcript_text(content)
369
373
  self._ensure_live()
370
374
 
375
+ def _handle_token_event(self, content: str) -> None:
376
+ """Handle token streaming events - print immediately for real-time streaming."""
377
+ if content:
378
+ self.state.append_transcript_text(content)
379
+ # Print token content directly to stdout for immediate visibility when not verbose
380
+ # This bypasses Rich's Live display which has refresh rate limitations
381
+ if not self.verbose:
382
+ try:
383
+ # Mark that we're streaming tokens directly to prevent Live display from starting
384
+ self._streaming_tokens_directly = True
385
+ # Stop Live display if active to prevent it from intercepting stdout
386
+ # and causing each token to appear on a new line
387
+ if self.live is not None:
388
+ self._stop_live_display()
389
+ # Write directly to stdout - tokens will stream on the same line
390
+ # since we're bypassing Rich's console which adds newlines
391
+ sys.stdout.write(content)
392
+ sys.stdout.flush()
393
+ except Exception:
394
+ # Fallback to live display if direct write fails
395
+ self._ensure_live()
396
+ else:
397
+ # In verbose mode, use normal live display (debug panels handle the output)
398
+ self._ensure_live()
399
+
371
400
  def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
372
401
  """Handle final response events."""
373
402
  if content:
@@ -521,6 +550,18 @@ class RichStreamRenderer(TranscriptModeMixin):
521
550
  if getattr(self, "_transcript_mode_enabled", False):
522
551
  return
523
552
 
553
+ # When verbose=False and tokens were streamed directly, skip final panel
554
+ # The user's script will print the final result, avoiding duplication
555
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
556
+ # Add a newline after streaming tokens for clean separation
557
+ try:
558
+ sys.stdout.write("\n")
559
+ sys.stdout.flush()
560
+ except Exception:
561
+ pass
562
+ self.state.printed_final_output = True
563
+ return
564
+
524
565
  if self.verbose:
525
566
  panel = build_final_panel(
526
567
  self.state,
@@ -597,6 +638,19 @@ class RichStreamRenderer(TranscriptModeMixin):
597
638
 
598
639
  def _finalize_display(self) -> None:
599
640
  """Finalize live display and render final output."""
641
+ # When verbose=False and tokens were streamed directly, skip live display updates
642
+ # to avoid showing duplicate final result
643
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
644
+ # Just add a newline after streaming tokens for clean separation
645
+ try:
646
+ sys.stdout.write("\n")
647
+ sys.stdout.flush()
648
+ except Exception:
649
+ pass
650
+ self._stop_live_display()
651
+ self.state.printed_final_output = True
652
+ return
653
+
600
654
  # Final refresh
601
655
  self._ensure_live()
602
656
 
@@ -629,6 +683,10 @@ class RichStreamRenderer(TranscriptModeMixin):
629
683
  """Ensure live display is updated."""
630
684
  if getattr(self, "_transcript_mode_enabled", False):
631
685
  return
686
+ # When verbose=False, don't start Live display if we're streaming tokens directly
687
+ # This prevents Live from intercepting stdout and causing tokens to appear on separate lines
688
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
689
+ return
632
690
  if not self._ensure_live_stack():
633
691
  return
634
692
 
@@ -315,7 +315,7 @@ def normalize_runtime_config_keys(
315
315
 
316
316
 
317
317
  def _get_name_from_class(cls: type) -> str:
318
- """Extract name from a class, handling Pydantic models.
318
+ """Extract name from a class, handling Pydantic models and @property descriptors.
319
319
 
320
320
  Args:
321
321
  cls: The class to extract name from.
@@ -323,9 +323,10 @@ def _get_name_from_class(cls: type) -> str:
323
323
  Returns:
324
324
  The resolved name string.
325
325
  """
326
- # Try class-level name attribute first
326
+ # Try class-level name attribute first, but guard against @property descriptors
327
+ # When a class has @property name, getattr returns the property object, not a string
327
328
  class_name = getattr(cls, "name", None)
328
- if class_name:
329
+ if isinstance(class_name, str) and class_name:
329
330
  return class_name
330
331
 
331
332
  # For Pydantic models, check model_fields for default value
@@ -355,24 +356,26 @@ def get_name_from_key(key: object) -> str | None:
355
356
  Raises:
356
357
  ValueError: If the key cannot be resolved to a valid name.
357
358
  """
358
- # Instance with name attribute
359
- if hasattr(key, "name"):
360
- name = getattr(key, "name", None)
361
- if name:
362
- return name
363
- raise ValueError(f"Unable to resolve config key: {key!r}")
364
-
365
- # Class type (not instance)
359
+ # Class type (not instance) - must check BEFORE hasattr("name")
360
+ # because classes with @property name will have hasattr return True
361
+ # but getattr returns the property descriptor, not a string
366
362
  if isinstance(key, type):
367
363
  return _get_name_from_class(key)
368
364
 
369
- # String key
365
+ # String key - check early to avoid attribute access
370
366
  if isinstance(key, str):
371
367
  if is_uuid(key):
372
368
  logger.warning("UUID '%s' not supported in local mode, skipping", key)
373
369
  return None
374
370
  return key
375
371
 
372
+ # Instance with name attribute
373
+ if hasattr(key, "name"):
374
+ name = getattr(key, "name", None)
375
+ # Guard against @property that returns non-string (e.g., descriptor)
376
+ if isinstance(name, str) and name:
377
+ return name
378
+
376
379
  raise ValueError(f"Unable to resolve config key: {key!r}")
377
380
 
378
381
 
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: