glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__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 (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,188 @@
1
+ """A2A event stream processing utilities.
2
+
3
+ This module provides helpers for processing the A2AEvent stream emitted by
4
+ agent execution backends (e.g., `arun_a2a_stream()`).
5
+
6
+ The MVP implementation focuses on extracting final response text;
7
+ full A2AConnector-equivalent normalization is deferred to follow-up PRs.
8
+
9
+ Authors:
10
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from gllm_core.utils import LoggerManager
19
+
20
+ logger = LoggerManager().get_logger(__name__)
21
+
22
+ # A2A event type constants (matching aip_agents.schema.a2a.A2AStreamEventType)
23
+ EVENT_TYPE_FINAL_RESPONSE = "final_response"
24
+ EVENT_TYPE_STATUS_UPDATE = "status_update"
25
+ EVENT_TYPE_TOOL_CALL = "tool_call"
26
+ EVENT_TYPE_TOOL_RESULT = "tool_result"
27
+ EVENT_TYPE_ERROR = "error"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class A2AEventStreamProcessor:
32
+ """Processor for `arun_a2a_stream()` event dictionaries.
33
+
34
+ The SDK uses lightweight dictionaries to represent A2A stream events.
35
+ This helper centralizes event-type normalization and MVP final-text extraction.
36
+
37
+ Example:
38
+ >>> processor = A2AEventStreamProcessor()
39
+ >>> events = [{"event_type": "final_response", "content": "Hello!", "is_final": True}]
40
+ >>> result = processor.extract_final_response(events)
41
+ >>> print(result)
42
+ Hello!
43
+ """
44
+
45
+ def extract_final_response(self, events: list[dict[str, Any]]) -> str:
46
+ """Extract the final response text from a list of A2AEvents.
47
+
48
+ Scans the event list for the final_response event and returns its content.
49
+ If no final_response is found, raises a RuntimeError.
50
+
51
+ Args:
52
+ events: List of A2AEvent dictionaries from arun_a2a_stream().
53
+
54
+ Returns:
55
+ The content string from the final_response event.
56
+
57
+ Raises:
58
+ RuntimeError: If no final_response event is found in the stream.
59
+ """
60
+ for event in reversed(events):
61
+ if self._is_final_response_event(event):
62
+ content = event.get("content", "")
63
+ logger.debug("Extracted final response: %d characters", len(str(content)))
64
+ return str(content)
65
+
66
+ # Fallback: check for events with is_final=True
67
+ for event in reversed(events):
68
+ if event.get("is_final", False):
69
+ content = event.get("content", "")
70
+ if content:
71
+ logger.debug("Extracted final from is_final flag: %d chars", len(str(content)))
72
+ return str(content)
73
+
74
+ raise RuntimeError(
75
+ "No final response received from the agent. The agent execution completed without producing a final answer."
76
+ )
77
+
78
+ def get_event_type(self, event: dict[str, Any]) -> str:
79
+ """Get the normalized event type string from an A2AEvent.
80
+
81
+ Args:
82
+ event: An A2AEvent dictionary.
83
+
84
+ Returns:
85
+ The event type as a lowercase string.
86
+ """
87
+ event_type = event.get("event_type", "unknown")
88
+ if isinstance(event_type, str):
89
+ return event_type.lower()
90
+ # Handle enum types (A2AStreamEventType)
91
+ return getattr(event_type, "value", str(event_type)).lower()
92
+
93
+ def is_tool_event(self, event: dict[str, Any]) -> bool:
94
+ """Check if an event is a tool-related event.
95
+
96
+ Args:
97
+ event: An A2AEvent dictionary.
98
+
99
+ Returns:
100
+ True if this is a tool_call or tool_result event.
101
+ """
102
+ event_type = self.get_event_type(event)
103
+ return event_type in (EVENT_TYPE_TOOL_CALL, EVENT_TYPE_TOOL_RESULT)
104
+
105
+ def is_error_event(self, event: dict[str, Any]) -> bool:
106
+ """Check if an event is an error event.
107
+
108
+ Args:
109
+ event: An A2AEvent dictionary.
110
+
111
+ Returns:
112
+ True if this is an error event.
113
+ """
114
+ return self.get_event_type(event) == EVENT_TYPE_ERROR
115
+
116
+ def _is_final_response_event(self, event: dict[str, Any]) -> bool:
117
+ """Check if an event is a final_response event.
118
+
119
+ Args:
120
+ event: An A2AEvent dictionary.
121
+
122
+ Returns:
123
+ True if this is a final_response event, False otherwise.
124
+ """
125
+ return self.get_event_type(event) == EVENT_TYPE_FINAL_RESPONSE
126
+
127
+
128
+ # Default processor instance for convenience functions
129
+ _DEFAULT_PROCESSOR = A2AEventStreamProcessor()
130
+
131
+
132
+ def extract_final_response(events: list[dict[str, Any]]) -> str:
133
+ """Extract the final response text from a list of A2AEvents.
134
+
135
+ Convenience function that uses the default A2AEventStreamProcessor.
136
+
137
+ Args:
138
+ events: List of A2AEvent dictionaries from arun_a2a_stream().
139
+
140
+ Returns:
141
+ The content string from the final_response event.
142
+
143
+ Raises:
144
+ RuntimeError: If no final_response event is found in the stream.
145
+ """
146
+ return _DEFAULT_PROCESSOR.extract_final_response(events)
147
+
148
+
149
+ def get_event_type(event: dict[str, Any]) -> str:
150
+ """Get the normalized event type string from an A2AEvent.
151
+
152
+ Convenience function that uses the default A2AEventStreamProcessor.
153
+
154
+ Args:
155
+ event: An A2AEvent dictionary.
156
+
157
+ Returns:
158
+ The event type as a lowercase string.
159
+ """
160
+ return _DEFAULT_PROCESSOR.get_event_type(event)
161
+
162
+
163
+ def is_tool_event(event: dict[str, Any]) -> bool:
164
+ """Check if an event is a tool-related event.
165
+
166
+ Convenience function that uses the default A2AEventStreamProcessor.
167
+
168
+ Args:
169
+ event: An A2AEvent dictionary.
170
+
171
+ Returns:
172
+ True if this is a tool_call or tool_result event.
173
+ """
174
+ return _DEFAULT_PROCESSOR.is_tool_event(event)
175
+
176
+
177
+ def is_error_event(event: dict[str, Any]) -> bool:
178
+ """Check if an event is an error event.
179
+
180
+ Convenience function that uses the default A2AEventStreamProcessor.
181
+
182
+ Args:
183
+ event: An A2AEvent dictionary.
184
+
185
+ Returns:
186
+ True if this is an error event.
187
+ """
188
+ return _DEFAULT_PROCESSOR.is_error_event(event)
@@ -54,17 +54,11 @@ def sanitize_agent_config(
54
54
  cfg = agent_config or {}
55
55
 
56
56
  if strip_lm_identity and isinstance(cfg, dict):
57
- cfg = {
58
- k: v
59
- for k, v in cfg.items()
60
- if k not in {"lm_provider", "lm_name", "lm_base_url"}
61
- }
57
+ cfg = {k: v for k, v in cfg.items() if k not in {"lm_provider", "lm_name", "lm_base_url"}}
62
58
  return cfg
63
59
 
64
60
 
65
- def resolve_language_model_selection(
66
- merged_data: dict[str, Any], cli_model: str | None
67
- ) -> tuple[dict[str, Any], bool]:
61
+ def resolve_language_model_selection(merged_data: dict[str, Any], cli_model: str | None) -> tuple[dict[str, Any], bool]:
68
62
  """Resolve language model selection from merged data and CLI args.
69
63
 
70
64
  Implements the LM selection priority:
@@ -98,17 +92,13 @@ def resolve_language_model_selection(
98
92
  # Priority 3: Legacy lm_name from agent_config
99
93
  agent_config = merged_data.get("agent_config") or {}
100
94
  if isinstance(agent_config, dict) and agent_config.get("lm_name"):
101
- return {
102
- "model": agent_config["lm_name"]
103
- }, True # Strip LM identity when extracting from agent_config
95
+ return {"model": agent_config["lm_name"]}, True # Strip LM identity when extracting from agent_config
104
96
 
105
97
  # No LM selection found
106
98
  return {}, False
107
99
 
108
100
 
109
- def normalize_agent_config_for_import(
110
- agent_data: dict[str, Any], cli_model: str | None = None
111
- ) -> dict[str, Any]:
101
+ def normalize_agent_config_for_import(agent_data: dict[str, Any], cli_model: str | None = None) -> dict[str, Any]:
112
102
  """Automatically normalize agent configuration by extracting LM settings from agent_config.
113
103
 
114
104
  This function addresses the common issue where exported agent configurations contain
@@ -0,0 +1,403 @@
1
+ """Tool source code bundling with import inlining.
2
+
3
+ This module provides the ToolBundler class for bundling Python tool source
4
+ code with all local dependencies inlined.
5
+
6
+ Authors:
7
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import inspect
14
+ from pathlib import Path
15
+
16
+ from glaip_sdk.utils.import_resolver import ImportResolver
17
+ from glaip_sdk.utils.tool_detection import is_tool_plugin_decorator
18
+
19
+
20
+ class ToolBundler:
21
+ """Bundles tool source code with inlined local imports.
22
+
23
+ This class handles the complex process of taking a tool class and
24
+ producing a single, self-contained source file with all local
25
+ dependencies inlined.
26
+
27
+ Attributes:
28
+ tool_class: The tool class to bundle.
29
+ tool_file: Path to the file containing the tool class.
30
+ tool_dir: Directory containing the tool file.
31
+
32
+ Example:
33
+ >>> bundler = ToolBundler(MyToolClass)
34
+ >>> bundled_source = bundler.bundle()
35
+ """
36
+
37
+ def __init__(self, tool_class: type) -> None:
38
+ """Initialize the ToolBundler.
39
+
40
+ Args:
41
+ tool_class: The tool class or decorated function to bundle.
42
+ """
43
+ # If it's a gllm_core Tool, get the underlying function
44
+ if hasattr(tool_class, "__wrapped__"):
45
+ actual_func = tool_class.__wrapped__
46
+ else:
47
+ actual_func = tool_class
48
+
49
+ self.tool_class = tool_class
50
+ self.tool_file = Path(inspect.getfile(actual_func))
51
+ self.tool_dir = self.tool_file.parent
52
+ self._import_resolver = ImportResolver(self.tool_dir)
53
+
54
+ def bundle(self, add_tool_plugin_decorator: bool = True) -> str:
55
+ """Bundle tool source code with inlined local imports.
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
+
62
+ Returns:
63
+ Bundled source code with all local dependencies inlined.
64
+ """
65
+ with open(self.tool_file, encoding="utf-8") as f:
66
+ full_source = f.read()
67
+
68
+ tree = ast.parse(full_source)
69
+ local_imports, external_imports = self._import_resolver.categorize_imports(tree)
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
+
81
+ # Extract main code nodes (excluding imports, docstrings, glaip_sdk.Tool subclasses)
82
+ main_code_nodes = self._extract_main_code_nodes(tree)
83
+
84
+ # Inline local imports and collect their external imports
85
+ inlined_code, inlined_external_imports = self._import_resolver.inline_local_imports(local_imports)
86
+
87
+ # Merge all external imports
88
+ all_external_imports = external_imports + inlined_external_imports
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
+
97
+ # Build bundled code
98
+ bundled_code = ["# Bundled tool with inlined local imports\n"]
99
+ bundled_code.extend(self._import_resolver.format_external_imports(all_external_imports))
100
+
101
+ # Add inlined dependencies FIRST (before main tool code)
102
+ bundled_code.extend(inlined_code)
103
+
104
+ # Then add main tool code
105
+ bundled_code.append("# Main tool code\n")
106
+ for node_code in main_code_nodes:
107
+ bundled_code.append(node_code + "\n")
108
+ bundled_code.append("\n")
109
+
110
+ return "".join(bundled_code)
111
+
112
+ def _extract_main_code_nodes(self, tree: ast.AST) -> list[str]:
113
+ """Extract main code nodes from AST, excluding imports and Tool subclasses.
114
+
115
+ Args:
116
+ tree: AST tree of the source file.
117
+
118
+ Returns:
119
+ List of unparsed code node strings.
120
+ """
121
+ main_code_nodes = []
122
+ for node in tree.body:
123
+ # Skip imports
124
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
125
+ continue
126
+ # Skip module docstrings
127
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
128
+ continue
129
+ # Skip glaip_sdk.Tool subclasses
130
+ if isinstance(node, ast.ClassDef) and self._is_sdk_tool_subclass(node):
131
+ continue
132
+ main_code_nodes.append(ast.unparse(node))
133
+ return main_code_nodes
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
+
232
+ @staticmethod
233
+ def _is_sdk_tool_subclass(node: ast.ClassDef) -> bool:
234
+ """Check if AST class definition inherits from Tool.
235
+
236
+ These classes are only needed locally for upload configuration
237
+ and should be excluded from bundled code.
238
+
239
+ Args:
240
+ node: AST ClassDef node to check.
241
+
242
+ Returns:
243
+ True if class inherits from Tool.
244
+ """
245
+ for base in node.bases:
246
+ if isinstance(base, ast.Name) and base.id == "Tool":
247
+ return True
248
+ if (
249
+ isinstance(base, ast.Attribute)
250
+ and base.attr == "Tool"
251
+ and isinstance(base.value, ast.Name)
252
+ and base.value.id in ("glaip_sdk",)
253
+ ):
254
+ return True
255
+ return False
256
+
257
+ @classmethod
258
+ def bundle_from_source(cls, file_path: Path, add_tool_plugin_decorator: bool = True) -> tuple[str, str, str]:
259
+ """Extract tool info directly from source file without importing.
260
+
261
+ This is used as a fallback when the tool class cannot be imported
262
+ due to missing dependencies.
263
+
264
+ Args:
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.
269
+
270
+ Returns:
271
+ Tuple of (name, description, bundled_source_code).
272
+
273
+ Raises:
274
+ FileNotFoundError: If the source file doesn't exist.
275
+ """
276
+ if not file_path.exists():
277
+ raise FileNotFoundError(f"Tool source file not found: {file_path}")
278
+
279
+ with open(file_path, encoding="utf-8") as f:
280
+ source_code = f.read()
281
+
282
+ tree = ast.parse(source_code)
283
+ tool_dir = file_path.parent
284
+ import_resolver = ImportResolver(tool_dir)
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
+
292
+ # Find tool name and description from class definitions
293
+ tool_name, tool_description = cls._extract_tool_metadata(tree, file_path.stem)
294
+
295
+ # Categorize imports
296
+ local_imports, external_imports = import_resolver.categorize_imports(tree)
297
+
298
+ # Extract main code nodes
299
+ main_code_nodes = []
300
+ for node in tree.body:
301
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
302
+ continue
303
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
304
+ continue
305
+ main_code_nodes.append(ast.unparse(node))
306
+
307
+ # Inline local imports
308
+ inlined_code, inlined_external_imports = import_resolver.inline_local_imports(local_imports)
309
+
310
+ # Build bundled code
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
+
319
+ bundled_code = ["# Bundled tool with inlined local imports\n"]
320
+ bundled_code.extend(import_resolver.format_external_imports(all_external_imports))
321
+
322
+ # Add main tool code
323
+ bundled_code.append("# Main tool code\n")
324
+ for node_code in main_code_nodes:
325
+ bundled_code.append(node_code + "\n")
326
+ bundled_code.append("\n")
327
+
328
+ # Then add inlined dependencies
329
+ bundled_code.extend(inlined_code)
330
+
331
+ bundled_source = "".join(bundled_code)
332
+
333
+ return tool_name, tool_description, bundled_source
334
+
335
+ @staticmethod
336
+ def _extract_tool_metadata(tree: ast.AST, fallback_name: str) -> tuple[str, str]:
337
+ """Extract tool name and description from AST.
338
+
339
+ Args:
340
+ tree: AST tree of the source file.
341
+ fallback_name: Name to use if not found in source.
342
+
343
+ Returns:
344
+ Tuple of (tool_name, tool_description).
345
+ """
346
+ tool_name, tool_description = ToolBundler._find_class_attributes(tree)
347
+
348
+ if not tool_name:
349
+ # Convert class name to snake_case as fallback
350
+ tool_name = "".join(["_" + c.lower() if c.isupper() else c for c in fallback_name]).lstrip("_")
351
+
352
+ if not tool_description:
353
+ tool_description = f"Tool: {fallback_name}"
354
+
355
+ return tool_name, tool_description
356
+
357
+ @staticmethod
358
+ def _find_class_attributes(tree: ast.AST) -> tuple[str | None, str | None]:
359
+ """Find name and description attributes in class definitions.
360
+
361
+ Args:
362
+ tree: AST tree to search.
363
+
364
+ Returns:
365
+ Tuple of (name, description) if found.
366
+ """
367
+ for node in ast.walk(tree):
368
+ if not isinstance(node, ast.ClassDef):
369
+ continue
370
+ name, description = ToolBundler._extract_class_name_description(node)
371
+ if name or description:
372
+ return name, description
373
+ return None, None
374
+
375
+ @staticmethod
376
+ def _extract_class_name_description(
377
+ class_node: ast.ClassDef,
378
+ ) -> tuple[str | None, str | None]:
379
+ """Extract name and description from a single class definition.
380
+
381
+ Args:
382
+ class_node: AST ClassDef node.
383
+
384
+ Returns:
385
+ Tuple of (name, description) if found.
386
+ """
387
+ name = None
388
+ description = None
389
+
390
+ for item in class_node.body:
391
+ if not isinstance(item, ast.AnnAssign):
392
+ continue
393
+ if not isinstance(item.target, ast.Name):
394
+ continue
395
+ if not isinstance(item.value, ast.Constant):
396
+ continue
397
+
398
+ if item.target.id == "name":
399
+ name = item.value.value
400
+ elif item.target.id == "description":
401
+ description = item.value.value
402
+
403
+ return name, description