glaip-sdk 0.0.7__py3-none-any.whl → 0.6.5b6__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 (161) hide show
  1. glaip_sdk/__init__.py +6 -3
  2. glaip_sdk/_version.py +12 -5
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1126 -0
  5. glaip_sdk/branding.py +79 -15
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +699 -0
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +503 -183
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +774 -137
  14. glaip_sdk/cli/commands/mcps.py +1124 -181
  15. glaip_sdk/cli/commands/models.py +25 -10
  16. glaip_sdk/cli/commands/tools.py +144 -92
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +61 -0
  19. glaip_sdk/cli/config.py +95 -0
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +150 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +143 -53
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +24 -18
  30. glaip_sdk/cli/main.py +420 -145
  31. glaip_sdk/cli/masking.py +136 -0
  32. glaip_sdk/cli/mcp_validators.py +287 -0
  33. glaip_sdk/cli/pager.py +266 -0
  34. glaip_sdk/cli/parsers/__init__.py +7 -0
  35. glaip_sdk/cli/parsers/json_input.py +177 -0
  36. glaip_sdk/cli/resolution.py +28 -21
  37. glaip_sdk/cli/rich_helpers.py +27 -0
  38. glaip_sdk/cli/slash/__init__.py +15 -0
  39. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +282 -0
  42. glaip_sdk/cli/slash/prompt.py +245 -0
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +1679 -0
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +872 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +31 -0
  52. glaip_sdk/cli/transcript/cache.py +536 -0
  53. glaip_sdk/cli/transcript/capture.py +329 -0
  54. glaip_sdk/cli/transcript/export.py +38 -0
  55. glaip_sdk/cli/transcript/history.py +815 -0
  56. glaip_sdk/cli/transcript/launcher.py +77 -0
  57. glaip_sdk/cli/transcript/viewer.py +372 -0
  58. glaip_sdk/cli/update_notifier.py +290 -0
  59. glaip_sdk/cli/utils.py +247 -1238
  60. glaip_sdk/cli/validators.py +16 -18
  61. glaip_sdk/client/__init__.py +2 -1
  62. glaip_sdk/client/_agent_payloads.py +520 -0
  63. glaip_sdk/client/agent_runs.py +147 -0
  64. glaip_sdk/client/agents.py +940 -574
  65. glaip_sdk/client/base.py +163 -48
  66. glaip_sdk/client/main.py +35 -12
  67. glaip_sdk/client/mcps.py +126 -18
  68. glaip_sdk/client/run_rendering.py +415 -0
  69. glaip_sdk/client/shared.py +21 -0
  70. glaip_sdk/client/tools.py +195 -37
  71. glaip_sdk/client/validators.py +20 -48
  72. glaip_sdk/config/constants.py +15 -5
  73. glaip_sdk/exceptions.py +16 -9
  74. glaip_sdk/icons.py +25 -0
  75. glaip_sdk/mcps/__init__.py +21 -0
  76. glaip_sdk/mcps/base.py +345 -0
  77. glaip_sdk/models/__init__.py +90 -0
  78. glaip_sdk/models/agent.py +47 -0
  79. glaip_sdk/models/agent_runs.py +116 -0
  80. glaip_sdk/models/common.py +42 -0
  81. glaip_sdk/models/mcp.py +33 -0
  82. glaip_sdk/models/tool.py +33 -0
  83. glaip_sdk/payload_schemas/__init__.py +7 -0
  84. glaip_sdk/payload_schemas/agent.py +85 -0
  85. glaip_sdk/registry/__init__.py +55 -0
  86. glaip_sdk/registry/agent.py +164 -0
  87. glaip_sdk/registry/base.py +139 -0
  88. glaip_sdk/registry/mcp.py +253 -0
  89. glaip_sdk/registry/tool.py +231 -0
  90. glaip_sdk/rich_components.py +98 -2
  91. glaip_sdk/runner/__init__.py +59 -0
  92. glaip_sdk/runner/base.py +84 -0
  93. glaip_sdk/runner/deps.py +115 -0
  94. glaip_sdk/runner/langgraph.py +597 -0
  95. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +158 -0
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  99. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +177 -0
  102. glaip_sdk/tools/__init__.py +22 -0
  103. glaip_sdk/tools/base.py +435 -0
  104. glaip_sdk/utils/__init__.py +59 -13
  105. glaip_sdk/utils/a2a/__init__.py +34 -0
  106. glaip_sdk/utils/a2a/event_processor.py +188 -0
  107. glaip_sdk/utils/agent_config.py +53 -40
  108. glaip_sdk/utils/bundler.py +267 -0
  109. glaip_sdk/utils/client.py +111 -0
  110. glaip_sdk/utils/client_utils.py +58 -26
  111. glaip_sdk/utils/datetime_helpers.py +58 -0
  112. glaip_sdk/utils/discovery.py +78 -0
  113. glaip_sdk/utils/display.py +65 -32
  114. glaip_sdk/utils/export.py +143 -0
  115. glaip_sdk/utils/general.py +1 -36
  116. glaip_sdk/utils/import_export.py +20 -25
  117. glaip_sdk/utils/import_resolver.py +492 -0
  118. glaip_sdk/utils/instructions.py +101 -0
  119. glaip_sdk/utils/rendering/__init__.py +115 -1
  120. glaip_sdk/utils/rendering/formatting.py +85 -43
  121. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  122. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +51 -19
  123. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  124. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  125. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  126. glaip_sdk/utils/rendering/models.py +39 -7
  127. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  128. glaip_sdk/utils/rendering/renderer/base.py +672 -759
  129. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  130. glaip_sdk/utils/rendering/renderer/debug.py +75 -22
  131. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  132. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  133. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  134. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  135. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  136. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  137. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  138. glaip_sdk/utils/rendering/state.py +204 -0
  139. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  140. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  141. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  142. glaip_sdk/utils/rendering/steps/format.py +176 -0
  143. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  144. glaip_sdk/utils/rendering/timing.py +36 -0
  145. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  146. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  147. glaip_sdk/utils/resource_refs.py +29 -26
  148. glaip_sdk/utils/runtime_config.py +422 -0
  149. glaip_sdk/utils/serialization.py +184 -51
  150. glaip_sdk/utils/sync.py +142 -0
  151. glaip_sdk/utils/tool_detection.py +33 -0
  152. glaip_sdk/utils/validation.py +21 -30
  153. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/METADATA +58 -12
  154. glaip_sdk-0.6.5b6.dist-info/RECORD +159 -0
  155. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/WHEEL +1 -1
  156. glaip_sdk/models.py +0 -250
  157. glaip_sdk/utils/rendering/renderer/progress.py +0 -118
  158. glaip_sdk/utils/rendering/steps.py +0 -232
  159. glaip_sdk/utils/rich_utils.py +0 -29
  160. glaip_sdk-0.0.7.dist-info/RECORD +0 -55
  161. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -134,25 +124,51 @@ def normalize_agent_config_for_import(
134
124
  if not isinstance(agent_config, dict):
135
125
  return normalized_data
136
126
 
137
- # Priority 1: CLI --model flag (highest priority)
127
+ # Apply normalization based on priority order
138
128
  if cli_model:
139
- # When CLI model is specified, set it and don't extract from agent_config
140
- normalized_data["model"] = cli_model
141
- return normalized_data
129
+ return _apply_cli_model_override(normalized_data, cli_model)
142
130
 
143
- # Priority 2: language_model_id already exists - clean up agent_config
144
131
  if normalized_data.get("language_model_id"):
145
- # If language_model_id exists, we should still clean up any conflicting
146
- # LM settings from agent_config to prevent backend validation errors
147
- if isinstance(agent_config, dict):
148
- # Remove LM identity keys from agent_config since language_model_id takes precedence
149
- lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
150
- for key in lm_keys_to_remove:
151
- agent_config.pop(key, None)
152
- normalized_data["agent_config"] = agent_config
132
+ return _cleanup_existing_language_model(normalized_data, agent_config)
133
+
134
+ return _extract_lm_from_agent_config(normalized_data, agent_config)
135
+
136
+
137
+ def _apply_cli_model_override(normalized_data: dict, cli_model: str) -> dict:
138
+ """Apply CLI model override (highest priority)."""
139
+ normalized_data["model"] = cli_model
140
+ return normalized_data
141
+
142
+
143
+ def _cleanup_existing_language_model(normalized_data: dict, agent_config: dict) -> dict:
144
+ """Clean up agent_config when language_model_id already exists."""
145
+ # Remove LM identity keys from agent_config since language_model_id takes precedence
146
+ lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
147
+ for key in lm_keys_to_remove:
148
+ agent_config.pop(key, None)
149
+ normalized_data["agent_config"] = agent_config
150
+ return normalized_data
151
+
152
+
153
+ def _extract_lm_from_agent_config(normalized_data: dict, agent_config: dict) -> dict:
154
+ """Extract LM settings from agent_config (lowest priority)."""
155
+ extracted_lm = _extract_lm_settings(agent_config)
156
+
157
+ if not extracted_lm:
153
158
  return normalized_data
154
159
 
155
- # Priority 3: Extract LM settings from agent_config
160
+ # Add extracted LM settings to top level
161
+ normalized_data.update(extracted_lm)
162
+
163
+ # Create sanitized agent_config (remove extracted LM settings but keep memory)
164
+ sanitized_config = _sanitize_agent_config(agent_config)
165
+ normalized_data["agent_config"] = sanitized_config
166
+
167
+ return normalized_data
168
+
169
+
170
+ def _extract_lm_settings(agent_config: dict) -> dict[str, Any]:
171
+ """Extract LM settings from agent_config."""
156
172
  extracted_lm = {}
157
173
 
158
174
  # Extract lm_name if present
@@ -163,19 +179,16 @@ def normalize_agent_config_for_import(
163
179
  if "lm_provider" in agent_config:
164
180
  extracted_lm["lm_provider"] = agent_config["lm_provider"]
165
181
 
166
- # If we extracted LM settings, update the normalized data
167
- if extracted_lm:
168
- # Add extracted LM settings to top level
169
- normalized_data.update(extracted_lm)
182
+ return extracted_lm
170
183
 
171
- # Create sanitized agent_config (remove extracted LM settings but keep memory)
172
- sanitized_config = agent_config.copy()
173
184
 
174
- # Remove LM identity keys but preserve memory and other settings
175
- lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
176
- for key in lm_keys_to_remove:
177
- sanitized_config.pop(key, None)
185
+ def _sanitize_agent_config(agent_config: dict) -> dict:
186
+ """Create sanitized agent_config by removing LM identity keys."""
187
+ sanitized_config = agent_config.copy()
178
188
 
179
- normalized_data["agent_config"] = sanitized_config
189
+ # Remove LM identity keys but preserve memory and other settings
190
+ lm_keys_to_remove = {"lm_provider", "lm_name", "lm_base_url"}
191
+ for key in lm_keys_to_remove:
192
+ sanitized_config.pop(key, None)
180
193
 
181
- return normalized_data
194
+ return sanitized_config
@@ -0,0 +1,267 @@
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
+
18
+
19
+ class ToolBundler:
20
+ """Bundles tool source code with inlined local imports.
21
+
22
+ This class handles the complex process of taking a tool class and
23
+ producing a single, self-contained source file with all local
24
+ dependencies inlined.
25
+
26
+ Attributes:
27
+ tool_class: The tool class to bundle.
28
+ tool_file: Path to the file containing the tool class.
29
+ tool_dir: Directory containing the tool file.
30
+
31
+ Example:
32
+ >>> bundler = ToolBundler(MyToolClass)
33
+ >>> bundled_source = bundler.bundle()
34
+ """
35
+
36
+ def __init__(self, tool_class: type) -> None:
37
+ """Initialize the ToolBundler.
38
+
39
+ Args:
40
+ tool_class: The tool class or decorated function to bundle.
41
+ """
42
+ # If it's a gllm_core Tool, get the underlying function
43
+ if hasattr(tool_class, "__wrapped__"):
44
+ actual_func = tool_class.__wrapped__
45
+ else:
46
+ actual_func = tool_class
47
+
48
+ self.tool_class = tool_class
49
+ self.tool_file = Path(inspect.getfile(actual_func))
50
+ self.tool_dir = self.tool_file.parent
51
+ self._import_resolver = ImportResolver(self.tool_dir)
52
+
53
+ def bundle(self) -> str:
54
+ """Bundle tool source code with inlined local imports.
55
+
56
+ Returns:
57
+ Bundled source code with all local dependencies inlined.
58
+ """
59
+ with open(self.tool_file, encoding="utf-8") as f:
60
+ full_source = f.read()
61
+
62
+ tree = ast.parse(full_source)
63
+ local_imports, external_imports = self._import_resolver.categorize_imports(tree)
64
+
65
+ # Extract main code nodes (excluding imports, docstrings, glaip_sdk.Tool subclasses)
66
+ main_code_nodes = self._extract_main_code_nodes(tree)
67
+
68
+ # Inline local imports and collect their external imports
69
+ inlined_code, inlined_external_imports = self._import_resolver.inline_local_imports(local_imports)
70
+
71
+ # Merge all external imports
72
+ all_external_imports = external_imports + inlined_external_imports
73
+
74
+ # Build bundled code
75
+ bundled_code = ["# Bundled tool with inlined local imports\n"]
76
+ bundled_code.extend(self._import_resolver.format_external_imports(all_external_imports))
77
+
78
+ # Add inlined dependencies FIRST (before main tool code)
79
+ bundled_code.extend(inlined_code)
80
+
81
+ # Then add main tool code
82
+ bundled_code.append("# Main tool code\n")
83
+ for node_code in main_code_nodes:
84
+ bundled_code.append(node_code + "\n")
85
+ bundled_code.append("\n")
86
+
87
+ return "".join(bundled_code)
88
+
89
+ def _extract_main_code_nodes(self, tree: ast.AST) -> list[str]:
90
+ """Extract main code nodes from AST, excluding imports and Tool subclasses.
91
+
92
+ Args:
93
+ tree: AST tree of the source file.
94
+
95
+ Returns:
96
+ List of unparsed code node strings.
97
+ """
98
+ main_code_nodes = []
99
+ for node in tree.body:
100
+ # Skip imports
101
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
102
+ continue
103
+ # Skip module docstrings
104
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
105
+ continue
106
+ # Skip glaip_sdk.Tool subclasses
107
+ if isinstance(node, ast.ClassDef) and self._is_sdk_tool_subclass(node):
108
+ continue
109
+ main_code_nodes.append(ast.unparse(node))
110
+ return main_code_nodes
111
+
112
+ @staticmethod
113
+ def _is_sdk_tool_subclass(node: ast.ClassDef) -> bool:
114
+ """Check if AST class definition inherits from Tool.
115
+
116
+ These classes are only needed locally for upload configuration
117
+ and should be excluded from bundled code.
118
+
119
+ Args:
120
+ node: AST ClassDef node to check.
121
+
122
+ Returns:
123
+ True if class inherits from Tool.
124
+ """
125
+ for base in node.bases:
126
+ if isinstance(base, ast.Name) and base.id == "Tool":
127
+ return True
128
+ if (
129
+ isinstance(base, ast.Attribute)
130
+ and base.attr == "Tool"
131
+ and isinstance(base.value, ast.Name)
132
+ and base.value.id in ("glaip_sdk",)
133
+ ):
134
+ return True
135
+ return False
136
+
137
+ @classmethod
138
+ def bundle_from_source(cls, file_path: Path) -> tuple[str, str, str]:
139
+ """Extract tool info directly from source file without importing.
140
+
141
+ This is used as a fallback when the tool class cannot be imported
142
+ due to missing dependencies.
143
+
144
+ Args:
145
+ file_path: Path to the tool source file.
146
+
147
+ Returns:
148
+ Tuple of (name, description, bundled_source_code).
149
+
150
+ Raises:
151
+ FileNotFoundError: If the source file doesn't exist.
152
+ """
153
+ if not file_path.exists():
154
+ raise FileNotFoundError(f"Tool source file not found: {file_path}")
155
+
156
+ with open(file_path, encoding="utf-8") as f:
157
+ source_code = f.read()
158
+
159
+ tree = ast.parse(source_code)
160
+ tool_dir = file_path.parent
161
+ import_resolver = ImportResolver(tool_dir)
162
+
163
+ # Find tool name and description from class definitions
164
+ tool_name, tool_description = cls._extract_tool_metadata(tree, file_path.stem)
165
+
166
+ # Categorize imports
167
+ local_imports, external_imports = import_resolver.categorize_imports(tree)
168
+
169
+ # Extract main code nodes
170
+ main_code_nodes = []
171
+ for node in tree.body:
172
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
173
+ continue
174
+ if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
175
+ continue
176
+ main_code_nodes.append(ast.unparse(node))
177
+
178
+ # Inline local imports
179
+ inlined_code, inlined_external_imports = import_resolver.inline_local_imports(local_imports)
180
+
181
+ # Build bundled code
182
+ all_external_imports = external_imports + inlined_external_imports
183
+ bundled_code = ["# Bundled tool with inlined local imports\n"]
184
+ bundled_code.extend(import_resolver.format_external_imports(all_external_imports))
185
+
186
+ # Add main tool code
187
+ bundled_code.append("# Main tool code\n")
188
+ for node_code in main_code_nodes:
189
+ bundled_code.append(node_code + "\n")
190
+ bundled_code.append("\n")
191
+
192
+ # Then add inlined dependencies
193
+ bundled_code.extend(inlined_code)
194
+
195
+ bundled_source = "".join(bundled_code)
196
+
197
+ return tool_name, tool_description, bundled_source
198
+
199
+ @staticmethod
200
+ def _extract_tool_metadata(tree: ast.AST, fallback_name: str) -> tuple[str, str]:
201
+ """Extract tool name and description from AST.
202
+
203
+ Args:
204
+ tree: AST tree of the source file.
205
+ fallback_name: Name to use if not found in source.
206
+
207
+ Returns:
208
+ Tuple of (tool_name, tool_description).
209
+ """
210
+ tool_name, tool_description = ToolBundler._find_class_attributes(tree)
211
+
212
+ if not tool_name:
213
+ # Convert class name to snake_case as fallback
214
+ tool_name = "".join(["_" + c.lower() if c.isupper() else c for c in fallback_name]).lstrip("_")
215
+
216
+ if not tool_description:
217
+ tool_description = f"Tool: {fallback_name}"
218
+
219
+ return tool_name, tool_description
220
+
221
+ @staticmethod
222
+ def _find_class_attributes(tree: ast.AST) -> tuple[str | None, str | None]:
223
+ """Find name and description attributes in class definitions.
224
+
225
+ Args:
226
+ tree: AST tree to search.
227
+
228
+ Returns:
229
+ Tuple of (name, description) if found.
230
+ """
231
+ for node in ast.walk(tree):
232
+ if not isinstance(node, ast.ClassDef):
233
+ continue
234
+ name, description = ToolBundler._extract_class_name_description(node)
235
+ if name or description:
236
+ return name, description
237
+ return None, None
238
+
239
+ @staticmethod
240
+ def _extract_class_name_description(
241
+ class_node: ast.ClassDef,
242
+ ) -> tuple[str | None, str | None]:
243
+ """Extract name and description from a single class definition.
244
+
245
+ Args:
246
+ class_node: AST ClassDef node.
247
+
248
+ Returns:
249
+ Tuple of (name, description) if found.
250
+ """
251
+ name = None
252
+ description = None
253
+
254
+ for item in class_node.body:
255
+ if not isinstance(item, ast.AnnAssign):
256
+ continue
257
+ if not isinstance(item.target, ast.Name):
258
+ continue
259
+ if not isinstance(item.value, ast.Constant):
260
+ continue
261
+
262
+ if item.target.id == "name":
263
+ name = item.value.value
264
+ elif item.target.id == "description":
265
+ description = item.value.value
266
+
267
+ return name, description
@@ -0,0 +1,111 @@
1
+ """Client singleton management for GLAIP SDK.
2
+
3
+ This module provides a singleton pattern for the GLAIP SDK client instance
4
+ used by the agents runtime. Uses a class-based singleton pattern consistent
5
+ with the registry implementations.
6
+
7
+ Thread Safety:
8
+ The singleton is created lazily on first access. In Python, the GIL ensures
9
+ that class attribute assignment is atomic, making this pattern safe for
10
+ multi-threaded access. For multiprocessing, each process gets its own
11
+ client instance (no shared state across processes).
12
+
13
+ Authors:
14
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dotenv import load_dotenv
20
+ from glaip_sdk.client import Client
21
+
22
+
23
+ class _ClientSingleton:
24
+ """Singleton holder for GLAIP SDK Client.
25
+
26
+ This class follows the same pattern as registry singletons
27
+ (_ToolRegistrySingleton, _MCPRegistrySingleton, _AgentRegistrySingleton).
28
+ """
29
+
30
+ _instance: Client | None = None
31
+
32
+ @classmethod
33
+ def get_instance(cls) -> Client:
34
+ """Get or create the singleton client instance.
35
+
36
+ Returns:
37
+ The singleton client instance.
38
+
39
+ Example:
40
+ >>> from glaip_sdk.utils.client import get_client
41
+ >>> client = get_client()
42
+ >>> agents = client.list_agents()
43
+ """
44
+ if cls._instance is None:
45
+ load_dotenv()
46
+ cls._instance = Client()
47
+ return cls._instance
48
+
49
+ @classmethod
50
+ def set_instance(cls, client: Client) -> None:
51
+ """Set the singleton client instance.
52
+
53
+ Useful for testing or when you need to configure the client manually.
54
+
55
+ Args:
56
+ client: The client instance to use.
57
+
58
+ Example:
59
+ >>> from glaip_sdk import Client
60
+ >>> from glaip_sdk.utils.client import set_client
61
+ >>> client = Client(api_key="my-key")
62
+ >>> set_client(client)
63
+ """
64
+ cls._instance = client
65
+
66
+ @classmethod
67
+ def reset(cls) -> None:
68
+ """Reset the singleton client instance.
69
+
70
+ Useful for testing to ensure a fresh client is created.
71
+ """
72
+ cls._instance = None
73
+
74
+
75
+ def get_client() -> Client:
76
+ """Get or create singleton client instance.
77
+
78
+ Returns:
79
+ The singleton client instance.
80
+
81
+ Example:
82
+ >>> from glaip_sdk.utils.client import get_client
83
+ >>> client = get_client()
84
+ >>> agents = client.list_agents()
85
+ """
86
+ return _ClientSingleton.get_instance()
87
+
88
+
89
+ def set_client(client: Client) -> None:
90
+ """Set the singleton client instance.
91
+
92
+ Useful for testing or when you need to configure the client manually.
93
+
94
+ Args:
95
+ client: The client instance to use.
96
+
97
+ Example:
98
+ >>> from glaip_sdk import Client
99
+ >>> from glaip_sdk.utils.client import set_client
100
+ >>> client = Client(api_key="my-key")
101
+ >>> set_client(client)
102
+ """
103
+ _ClientSingleton.set_instance(client)
104
+
105
+
106
+ def reset_client() -> None:
107
+ """Reset the singleton client instance.
108
+
109
+ Useful for testing to ensure a fresh client is created.
110
+ """
111
+ _ClientSingleton.reset()