glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__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 (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -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 +265 -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.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -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 +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  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 +876 -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 +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -7,9 +7,9 @@ Authors:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import io
11
10
  import json
12
11
  import logging
12
+ from collections.abc import Callable
13
13
  from time import monotonic
14
14
  from typing import Any
15
15
 
@@ -19,8 +19,17 @@ from rich.console import Console as _Console
19
19
  from glaip_sdk.config.constants import DEFAULT_AGENT_RUN_TIMEOUT
20
20
  from glaip_sdk.utils.client_utils import iter_sse_events
21
21
  from glaip_sdk.utils.rendering.models import RunStats
22
- from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
23
- from glaip_sdk.utils.rendering.renderer.config import RendererConfig
22
+ from glaip_sdk.utils.rendering.renderer import (
23
+ RendererFactoryOptions,
24
+ RichStreamRenderer,
25
+ make_default_renderer,
26
+ make_minimal_renderer,
27
+ make_silent_renderer,
28
+ make_verbose_renderer,
29
+ )
30
+ from glaip_sdk.utils.rendering.state import TranscriptBuffer
31
+
32
+ NO_AGENT_RESPONSE_FALLBACK = "No agent response received."
24
33
 
25
34
 
26
35
  def _coerce_to_string(value: Any) -> str:
@@ -36,45 +45,6 @@ def _has_visible_text(value: Any) -> bool:
36
45
  return isinstance(value, str) and bool(value.strip())
37
46
 
38
47
 
39
- def _update_state_transcript(state: Any, text_value: str) -> bool:
40
- """Inject transcript text into renderer state if possible."""
41
- if state is None:
42
- return False
43
-
44
- updated = False
45
-
46
- if hasattr(state, "final_text") and not _has_visible_text(
47
- getattr(state, "final_text", "")
48
- ):
49
- try:
50
- state.final_text = text_value
51
- updated = True
52
- except Exception:
53
- pass
54
-
55
- buffer = getattr(state, "buffer", None)
56
- if isinstance(buffer, list) and not any(_has_visible_text(item) for item in buffer):
57
- buffer.append(text_value)
58
- updated = True
59
-
60
- return updated
61
-
62
-
63
- def _update_renderer_transcript(renderer: Any, text_value: str) -> None:
64
- """Populate the renderer (or its state) with the supplied text."""
65
- state = getattr(renderer, "state", None)
66
- if _update_state_transcript(state, text_value):
67
- return
68
-
69
- if hasattr(renderer, "final_text") and not _has_visible_text(
70
- getattr(renderer, "final_text", "")
71
- ):
72
- try:
73
- setattr(renderer, "final_text", text_value)
74
- except Exception:
75
- pass
76
-
77
-
78
48
  class AgentRunRenderingManager:
79
49
  """Coordinate renderer creation and streaming event handling."""
80
50
 
@@ -85,6 +55,7 @@ class AgentRunRenderingManager:
85
55
  logger: Optional logger instance, creates default if None
86
56
  """
87
57
  self._logger = logger or logging.getLogger(__name__)
58
+ self._buffer_factory = TranscriptBuffer
88
59
 
89
60
  # --------------------------------------------------------------------- #
90
61
  # Renderer setup helpers
@@ -96,17 +67,38 @@ class AgentRunRenderingManager:
96
67
  verbose: bool = False,
97
68
  ) -> RichStreamRenderer:
98
69
  """Create an appropriate renderer based on the supplied spec."""
70
+ transcript_buffer = self._buffer_factory()
71
+ base_options = RendererFactoryOptions(console=_Console(), transcript_buffer=transcript_buffer)
99
72
  if isinstance(renderer_spec, RichStreamRenderer):
100
73
  return renderer_spec
101
74
 
102
75
  if isinstance(renderer_spec, str):
103
- if renderer_spec == "silent":
104
- return self._create_silent_renderer()
105
- if renderer_spec == "minimal":
106
- return self._create_minimal_renderer()
107
- return self._create_default_renderer(verbose)
76
+ lowered = renderer_spec.lower()
77
+ if lowered == "silent":
78
+ return self._attach_buffer(base_options.build(make_silent_renderer), transcript_buffer)
79
+ if lowered == "minimal":
80
+ return self._attach_buffer(base_options.build(make_minimal_renderer), transcript_buffer)
81
+ if lowered == "verbose":
82
+ return self._attach_buffer(base_options.build(make_verbose_renderer), transcript_buffer)
83
+
84
+ if verbose:
85
+ return self._attach_buffer(base_options.build(make_verbose_renderer), transcript_buffer)
86
+
87
+ default_options = RendererFactoryOptions(
88
+ console=_Console(),
89
+ transcript_buffer=transcript_buffer,
90
+ verbose=verbose,
91
+ )
92
+ return self._attach_buffer(default_options.build(make_default_renderer), transcript_buffer)
108
93
 
109
- return self._create_default_renderer(verbose)
94
+ @staticmethod
95
+ def _attach_buffer(renderer: RichStreamRenderer, buffer: TranscriptBuffer) -> RichStreamRenderer:
96
+ """Attach a captured transcript buffer to a renderer for later inspection."""
97
+ try:
98
+ renderer._captured_transcript_buffer = buffer # type: ignore[attr-defined]
99
+ except Exception:
100
+ pass
101
+ return renderer
110
102
 
111
103
  def build_initial_metadata(
112
104
  self,
@@ -127,49 +119,6 @@ class AgentRunRenderingManager:
127
119
  """Notify renderer that streaming is starting."""
128
120
  renderer.on_start(meta)
129
121
 
130
- def _create_silent_renderer(self) -> RichStreamRenderer:
131
- silent_config = RendererConfig(
132
- live=False,
133
- persist_live=False,
134
- render_thinking=False,
135
- )
136
- return RichStreamRenderer(
137
- console=_Console(file=io.StringIO(), force_terminal=False),
138
- cfg=silent_config,
139
- verbose=False,
140
- )
141
-
142
- def _create_minimal_renderer(self) -> RichStreamRenderer:
143
- minimal_config = RendererConfig(
144
- live=False,
145
- persist_live=False,
146
- render_thinking=False,
147
- )
148
- return RichStreamRenderer(
149
- console=_Console(),
150
- cfg=minimal_config,
151
- verbose=False,
152
- )
153
-
154
- def _create_verbose_renderer(self) -> RichStreamRenderer:
155
- verbose_config = RendererConfig(
156
- theme="dark",
157
- style="debug",
158
- live=False,
159
- append_finished_snapshots=False,
160
- )
161
- return RichStreamRenderer(
162
- console=_Console(),
163
- cfg=verbose_config,
164
- verbose=True,
165
- )
166
-
167
- def _create_default_renderer(self, verbose: bool) -> RichStreamRenderer:
168
- if verbose:
169
- return self._create_verbose_renderer()
170
- default_config = RendererConfig()
171
- return RichStreamRenderer(console=_Console(), cfg=default_config)
172
-
173
122
  # --------------------------------------------------------------------- #
174
123
  # Streaming event handling
175
124
  # --------------------------------------------------------------------- #
@@ -220,14 +169,27 @@ class AgentRunRenderingManager:
220
169
  meta: dict[str, Any],
221
170
  renderer: RichStreamRenderer,
222
171
  ) -> None:
223
- req_id = stream_response.headers.get(
224
- "x-request-id"
225
- ) or stream_response.headers.get("x-run-id")
172
+ """Capture request ID from response headers and update metadata.
173
+
174
+ Args:
175
+ stream_response: HTTP response stream.
176
+ meta: Metadata dictionary to update.
177
+ renderer: Renderer instance.
178
+ """
179
+ req_id = stream_response.headers.get("x-request-id") or stream_response.headers.get("x-run-id")
226
180
  if req_id:
227
181
  meta["run_id"] = req_id
228
182
  renderer.on_start(meta)
229
183
 
230
184
  def _maybe_start_timer(self, event: dict[str, Any]) -> float | None:
185
+ """Start timing if this is a content-bearing event.
186
+
187
+ Args:
188
+ event: Event dictionary.
189
+
190
+ Returns:
191
+ Monotonic time if timer should start, None otherwise.
192
+ """
231
193
  try:
232
194
  ev = json.loads(event["data"])
233
195
  except json.JSONDecodeError:
@@ -245,6 +207,18 @@ class AgentRunRenderingManager:
245
207
  stats_usage: dict[str, Any],
246
208
  meta: dict[str, Any],
247
209
  ) -> tuple[str, dict[str, Any]]:
210
+ """Process a single streaming event.
211
+
212
+ Args:
213
+ event: Event dictionary.
214
+ renderer: Renderer instance.
215
+ final_text: Accumulated text so far.
216
+ stats_usage: Usage statistics dictionary.
217
+ meta: Metadata dictionary.
218
+
219
+ Returns:
220
+ Tuple of (updated_final_text, updated_stats_usage).
221
+ """
248
222
  try:
249
223
  ev = json.loads(event["data"])
250
224
  except json.JSONDecodeError:
@@ -300,6 +274,15 @@ class AgentRunRenderingManager:
300
274
  return None
301
275
 
302
276
  def _handle_content_event(self, ev: dict[str, Any], final_text: str) -> str:
277
+ """Handle a content event and update final text.
278
+
279
+ Args:
280
+ ev: Event dictionary.
281
+ final_text: Current accumulated text.
282
+
283
+ Returns:
284
+ Updated final text.
285
+ """
303
286
  content = ev.get("content", "")
304
287
  if not content.startswith("Artifact received:"):
305
288
  return content
@@ -311,6 +294,13 @@ class AgentRunRenderingManager:
311
294
  meta: dict[str, Any],
312
295
  renderer: RichStreamRenderer,
313
296
  ) -> None:
297
+ """Handle a run_info event and update metadata.
298
+
299
+ Args:
300
+ ev: Event dictionary.
301
+ meta: Metadata dictionary to update.
302
+ renderer: Renderer instance.
303
+ """
314
304
  if ev.get("model"):
315
305
  meta["model"] = ev["model"]
316
306
  renderer.on_start(meta)
@@ -318,15 +308,58 @@ class AgentRunRenderingManager:
318
308
  meta["run_id"] = ev["run_id"]
319
309
  renderer.on_start(meta)
320
310
 
321
- def _ensure_renderer_final_content(
322
- self, renderer: RichStreamRenderer, text: str
323
- ) -> None:
311
+ def _ensure_renderer_final_content(self, renderer: RichStreamRenderer, text: str) -> None:
324
312
  """Populate renderer state with final output when the stream omits it."""
325
313
  if not text:
326
314
  return
327
315
 
328
316
  text_value = _coerce_to_string(text)
329
- _update_renderer_transcript(renderer, text_value)
317
+ state = getattr(renderer, "state", None)
318
+ if state is None:
319
+ self._ensure_renderer_text(renderer, text_value)
320
+ return
321
+
322
+ self._ensure_state_final_text(state, text_value)
323
+ self._ensure_state_buffer(state, text_value)
324
+
325
+ def _ensure_renderer_text(self, renderer: RichStreamRenderer, text_value: str) -> None:
326
+ """Best-effort assignment for renderer.final_text."""
327
+ if not hasattr(renderer, "final_text"):
328
+ return
329
+ current_text = getattr(renderer, "final_text", "")
330
+ if _has_visible_text(current_text):
331
+ return
332
+ self._safe_set_attr(renderer, "final_text", text_value)
333
+
334
+ def _ensure_state_final_text(self, state: Any, text_value: str) -> None:
335
+ """Best-effort assignment for renderer.state.final_text."""
336
+ current_text = getattr(state, "final_text", "")
337
+ if _has_visible_text(current_text):
338
+ return
339
+ self._safe_set_attr(state, "final_text", text_value)
340
+
341
+ def _ensure_state_buffer(self, state: Any, text_value: str) -> None:
342
+ """Append fallback text to the state buffer when available."""
343
+ buffer = getattr(state, "buffer", None)
344
+ if not hasattr(buffer, "append"):
345
+ return
346
+ self._safe_append(buffer.append, text_value)
347
+
348
+ @staticmethod
349
+ def _safe_set_attr(target: Any, attr: str, value: str) -> None:
350
+ """Assign attribute while masking renderer-specific failures."""
351
+ try:
352
+ setattr(target, attr, value)
353
+ except Exception:
354
+ pass
355
+
356
+ @staticmethod
357
+ def _safe_append(appender: Callable[[str], Any], value: str) -> None:
358
+ """Invoke append-like functions without leaking renderer errors."""
359
+ try:
360
+ appender(value)
361
+ except Exception:
362
+ pass
330
363
 
331
364
  # --------------------------------------------------------------------- #
332
365
  # Finalisation helpers
@@ -351,9 +384,11 @@ class AgentRunRenderingManager:
351
384
  if hasattr(renderer, "state") and hasattr(renderer.state, "buffer"):
352
385
  buffer_values = renderer.state.buffer
353
386
  elif hasattr(renderer, "buffer"):
354
- buffer_values = getattr(renderer, "buffer")
387
+ buffer_values = renderer.buffer
355
388
 
356
- if buffer_values is not None:
389
+ if isinstance(buffer_values, TranscriptBuffer):
390
+ rendered_text = buffer_values.render()
391
+ elif buffer_values is not None:
357
392
  try:
358
393
  rendered_text = "".join(buffer_values)
359
394
  except TypeError:
@@ -364,7 +399,7 @@ class AgentRunRenderingManager:
364
399
  self._ensure_renderer_final_content(renderer, fallback_text)
365
400
 
366
401
  renderer.on_complete(st)
367
- return final_text or rendered_text or "No response content received."
402
+ return final_text or rendered_text or NO_AGENT_RESPONSE_FALLBACK
368
403
 
369
404
 
370
405
  def compute_timeout_seconds(kwargs: dict[str, Any]) -> float:
@@ -0,0 +1,21 @@
1
+ """Shared helpers for client configuration wiring.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from glaip_sdk.client.base import BaseClient
12
+
13
+
14
+ def build_shared_config(client: BaseClient) -> dict[str, Any]:
15
+ """Return the keyword arguments used to initialize sub-clients."""
16
+ return {
17
+ "parent_client": client,
18
+ "api_url": client.api_url,
19
+ "api_key": client.api_key,
20
+ "timeout": client._timeout,
21
+ }
glaip_sdk/client/tools.py CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
8
9
  import logging
@@ -16,11 +17,14 @@ from glaip_sdk.config.constants import (
16
17
  DEFAULT_TOOL_TYPE,
17
18
  DEFAULT_TOOL_VERSION,
18
19
  )
19
- from glaip_sdk.models import Tool
20
+ from glaip_sdk.models import ToolResponse
21
+ from glaip_sdk.tools import Tool
20
22
  from glaip_sdk.utils.client_utils import (
23
+ add_kwargs_to_payload,
21
24
  create_model_instances,
22
25
  find_by_name,
23
26
  )
27
+ from glaip_sdk.utils.resource_refs import is_uuid
24
28
 
25
29
  # API endpoints
26
30
  TOOLS_ENDPOINT = "/tools/"
@@ -58,11 +62,11 @@ class ToolClient(BaseClient):
58
62
  def get_tool_by_id(self, tool_id: str) -> Tool:
59
63
  """Get tool by ID."""
60
64
  data = self._request("GET", f"{TOOLS_ENDPOINT}{tool_id}")
61
- return Tool(**data)._set_client(self)
65
+ response = ToolResponse(**data)
66
+ return Tool.from_response(response, client=self)
62
67
 
63
68
  def find_tools(self, name: str | None = None) -> list[Tool]:
64
69
  """Find tools by name."""
65
- # Backend doesn't support name query parameter, so we fetch all and filter client-side
66
70
  data = self._request("GET", TOOLS_ENDPOINT)
67
71
  tools = create_model_instances(data, Tool, self)
68
72
  return find_by_name(tools, name, case_sensitive=False)
@@ -96,9 +100,7 @@ class ToolClient(BaseClient):
96
100
  """
97
101
  return os.path.splitext(os.path.basename(file_path))[0]
98
102
 
99
- def _prepare_upload_data(
100
- self, name: str, framework: str, description: str | None = None, **kwargs
101
- ) -> dict:
103
+ def _prepare_upload_data(self, name: str, framework: str, description: str | None = None, **kwargs) -> dict:
102
104
  """Prepare upload data dictionary.
103
105
 
104
106
  Args:
@@ -113,6 +115,7 @@ class ToolClient(BaseClient):
113
115
  data = {
114
116
  "name": name,
115
117
  "framework": framework,
118
+ "type": kwargs.pop("tool_type", DEFAULT_TOOL_TYPE), # Default to custom
116
119
  }
117
120
 
118
121
  if description:
@@ -154,7 +157,8 @@ class ToolClient(BaseClient):
154
157
  data=upload_data,
155
158
  )
156
159
 
157
- return Tool(**response)._set_client(self)
160
+ tool_response = ToolResponse(**response)
161
+ return Tool.from_response(tool_response, client=self)
158
162
 
159
163
  def _build_create_payload(
160
164
  self,
@@ -202,9 +206,7 @@ class ToolClient(BaseClient):
202
206
 
203
207
  # Add any other kwargs (excluding already handled ones)
204
208
  excluded_keys = {"tags", "version"}
205
- for key, value in kwargs.items():
206
- if key not in excluded_keys:
207
- payload[key] = value
209
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
208
210
 
209
211
  return payload
210
212
 
@@ -217,29 +219,21 @@ class ToolClient(BaseClient):
217
219
  elif hasattr(current_tool, "description") and current_tool.description:
218
220
  update_data["description"] = current_tool.description
219
221
 
220
- def _handle_tags_update(
221
- self, update_data: dict[str, Any], kwargs: dict[str, Any], current_tool: Tool
222
- ) -> None:
222
+ def _handle_tags_update(self, update_data: dict[str, Any], kwargs: dict[str, Any], current_tool: Tool) -> None:
223
223
  """Handle tags field in update payload."""
224
224
  if kwargs.get("tags"):
225
225
  if isinstance(kwargs["tags"], list):
226
- update_data["tags"] = ",".join(
227
- str(tag).strip() for tag in kwargs["tags"]
228
- )
226
+ update_data["tags"] = ",".join(str(tag).strip() for tag in kwargs["tags"])
229
227
  else:
230
228
  update_data["tags"] = str(kwargs["tags"])
231
229
  elif hasattr(current_tool, "tags") and current_tool.tags:
232
230
  # Preserve existing tags if present
233
231
  if isinstance(current_tool.tags, list):
234
- update_data["tags"] = ",".join(
235
- str(tag).strip() for tag in current_tool.tags
236
- )
232
+ update_data["tags"] = ",".join(str(tag).strip() for tag in current_tool.tags)
237
233
  else:
238
234
  update_data["tags"] = str(current_tool.tags)
239
235
 
240
- def _handle_additional_kwargs(
241
- self, update_data: dict[str, Any], kwargs: dict[str, Any]
242
- ) -> None:
236
+ def _handle_additional_kwargs(self, update_data: dict[str, Any], kwargs: dict[str, Any]) -> None:
243
237
  """Handle additional kwargs in update payload."""
244
238
  excluded_keys = {
245
239
  "tags",
@@ -286,16 +280,15 @@ class ToolClient(BaseClient):
286
280
  or getattr(current_tool, "type", None)
287
281
  or DEFAULT_TOOL_TYPE
288
282
  )
283
+ # Convert enum to string value for API payload
284
+ if hasattr(current_type, "value"):
285
+ current_type = current_type.value
289
286
 
290
287
  update_data = {
291
288
  "name": name if name is not None else current_tool.name,
292
289
  "type": current_type,
293
- "framework": kwargs.get(
294
- "framework", getattr(current_tool, "framework", DEFAULT_TOOL_FRAMEWORK)
295
- ),
296
- "version": kwargs.get(
297
- "version", getattr(current_tool, "version", DEFAULT_TOOL_VERSION)
298
- ),
290
+ "framework": kwargs.get("framework", getattr(current_tool, "framework", DEFAULT_TOOL_FRAMEWORK)),
291
+ "version": kwargs.get("version", getattr(current_tool, "version", DEFAULT_TOOL_VERSION)),
299
292
  }
300
293
 
301
294
  # Handle description update
@@ -355,9 +348,7 @@ class ToolClient(BaseClient):
355
348
 
356
349
  try:
357
350
  # Prepare upload data
358
- upload_data = self._prepare_upload_data(
359
- name=name, framework=framework, description=description, **kwargs
360
- )
351
+ upload_data = self._prepare_upload_data(name=name, framework=framework, description=description, **kwargs)
361
352
 
362
353
  # Upload file
363
354
  return self._upload_tool_file(temp_file_path, upload_data)
@@ -451,12 +442,149 @@ class ToolClient(BaseClient):
451
442
  def update_tool(self, tool_id: str, **kwargs) -> Tool:
452
443
  """Update an existing tool."""
453
444
  data = self._request("PUT", f"{TOOLS_ENDPOINT}{tool_id}", json=kwargs)
454
- return Tool(**data)._set_client(self)
445
+ response = ToolResponse(**data)
446
+ return Tool.from_response(response, client=self)
455
447
 
456
448
  def delete_tool(self, tool_id: str) -> None:
457
449
  """Delete a tool."""
458
450
  self._request("DELETE", f"{TOOLS_ENDPOINT}{tool_id}")
459
451
 
452
+ def upsert_tool(
453
+ self,
454
+ identifier: str | Tool,
455
+ code: str | None = None,
456
+ description: str | None = None,
457
+ framework: str = "langchain",
458
+ **kwargs,
459
+ ) -> Tool:
460
+ """Create or update a tool by instance, ID, or name.
461
+
462
+ Args:
463
+ identifier: Tool instance, ID (UUID string), or name
464
+ code: Python code containing the tool plugin (required for create)
465
+ description: Tool description
466
+ framework: Tool framework (defaults to "langchain")
467
+ **kwargs: Additional parameters (tags, version, etc.)
468
+
469
+ Returns:
470
+ The created or updated tool.
471
+
472
+ Example:
473
+ >>> # By name with code (creates if not exists)
474
+ >>> tool = client.tools.upsert_tool(
475
+ ... "greeting",
476
+ ... code=bundled_source,
477
+ ... description="A greeting tool",
478
+ ... )
479
+ >>> # By instance
480
+ >>> tool = client.tools.upsert_tool(existing_tool, code=new_code)
481
+ >>> # By ID
482
+ >>> tool = client.tools.upsert_tool("uuid-here", code=new_code)
483
+ """
484
+ # Handle Tool instance
485
+ if isinstance(identifier, Tool):
486
+ if identifier.id:
487
+ logger.info("Updating tool by instance: %s", identifier.name)
488
+ return self._do_tool_upsert_update(
489
+ identifier.id,
490
+ identifier.name,
491
+ code,
492
+ description,
493
+ framework,
494
+ **kwargs,
495
+ )
496
+ identifier = identifier.name
497
+
498
+ # Handle string (ID or name)
499
+ if isinstance(identifier, str):
500
+ if is_uuid(identifier):
501
+ logger.info("Updating tool by ID: %s", identifier)
502
+ existing = self.get_tool_by_id(identifier)
503
+ return self._do_tool_upsert_update(identifier, existing.name, code, description, framework, **kwargs)
504
+
505
+ # It's a name - find or create
506
+ return self._upsert_tool_by_name(identifier, code, description, framework, **kwargs)
507
+
508
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
509
+
510
+ def _do_tool_upsert_update(
511
+ self,
512
+ tool_id: str,
513
+ name: str | None,
514
+ code: str | None,
515
+ description: str | None,
516
+ framework: str,
517
+ **kwargs,
518
+ ) -> Tool:
519
+ """Perform the update part of tool upsert."""
520
+ if code:
521
+ # Update via file upload
522
+ with tempfile.NamedTemporaryFile(
523
+ mode="w",
524
+ suffix=".py",
525
+ prefix=f"{name or 'tool'}_",
526
+ delete=False,
527
+ encoding="utf-8",
528
+ ) as temp_file:
529
+ temp_file.write(code)
530
+ temp_file_path = temp_file.name
531
+
532
+ try:
533
+ return self.update_tool_via_file(
534
+ tool_id,
535
+ temp_file_path,
536
+ name=name,
537
+ description=description,
538
+ framework=framework,
539
+ **kwargs,
540
+ )
541
+ finally:
542
+ try:
543
+ os.unlink(temp_file_path)
544
+ except OSError:
545
+ pass
546
+ else:
547
+ # Metadata-only update
548
+ update_kwargs = {"framework": framework, **kwargs}
549
+ if name:
550
+ update_kwargs["name"] = name
551
+ if description:
552
+ update_kwargs["description"] = description
553
+ return self.update_tool(tool_id, **update_kwargs)
554
+
555
+ def _upsert_tool_by_name(
556
+ self,
557
+ name: str,
558
+ code: str | None,
559
+ description: str | None,
560
+ framework: str,
561
+ **kwargs,
562
+ ) -> Tool:
563
+ """Find tool by name and update, or create if not found."""
564
+ existing = self.find_tools(name)
565
+ name_lower = name.lower()
566
+ exact_matches = [tool for tool in existing if tool.name and tool.name.lower() == name_lower]
567
+
568
+ if len(exact_matches) == 1:
569
+ logger.info("Updating existing tool: %s", name)
570
+ return self._do_tool_upsert_update(exact_matches[0].id, name, code, description, framework, **kwargs)
571
+
572
+ if len(exact_matches) > 1:
573
+ raise ValueError(f"Multiple tools found with name '{name}'")
574
+
575
+ # Create new tool - code is required
576
+ if not code:
577
+ raise ValueError(f"Tool '{name}' not found and no code provided for creation")
578
+
579
+ logger.info("Creating new tool: %s", name)
580
+ return self.create_tool_from_code(
581
+ name=name,
582
+ code=code,
583
+ framework=framework,
584
+ description=description,
585
+ **kwargs,
586
+ )
587
+
460
588
  def get_tool_script(self, tool_id: str) -> str:
461
589
  """Get the tool script content.
462
590
 
@@ -525,8 +653,9 @@ class ToolClient(BaseClient):
525
653
  data=update_payload,
526
654
  )
527
655
 
528
- return Tool(**response)._set_client(self)
656
+ tool_response = ToolResponse(**response)
657
+ return Tool.from_response(tool_response, client=self)
529
658
 
530
659
  except Exception as e:
531
- logger.error(f"Failed to update tool {tool_id} via file: {e}")
660
+ logger.error("Failed to update tool %s via file: %s", tool_id, e)
532
661
  raise