glaip-sdk 0.5.5__py3-none-any.whl → 0.6.1__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 (49) hide show
  1. glaip_sdk/__init__.py +4 -1
  2. glaip_sdk/agents/__init__.py +27 -0
  3. glaip_sdk/agents/base.py +996 -0
  4. glaip_sdk/cli/commands/common_config.py +36 -0
  5. glaip_sdk/cli/commands/tools.py +2 -5
  6. glaip_sdk/cli/config.py +13 -2
  7. glaip_sdk/cli/main.py +20 -0
  8. glaip_sdk/cli/slash/accounts_controller.py +217 -0
  9. glaip_sdk/cli/slash/accounts_shared.py +19 -0
  10. glaip_sdk/cli/slash/session.py +57 -7
  11. glaip_sdk/cli/slash/tui/accounts.tcss +54 -0
  12. glaip_sdk/cli/slash/tui/accounts_app.py +379 -0
  13. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  14. glaip_sdk/cli/slash/tui/loading.py +58 -0
  15. glaip_sdk/cli/slash/tui/remote_runs_app.py +9 -12
  16. glaip_sdk/client/_agent_payloads.py +10 -9
  17. glaip_sdk/client/agents.py +70 -8
  18. glaip_sdk/client/base.py +1 -0
  19. glaip_sdk/client/main.py +12 -4
  20. glaip_sdk/client/mcps.py +112 -10
  21. glaip_sdk/client/tools.py +151 -7
  22. glaip_sdk/mcps/__init__.py +21 -0
  23. glaip_sdk/mcps/base.py +345 -0
  24. glaip_sdk/models/__init__.py +65 -31
  25. glaip_sdk/models/agent.py +47 -0
  26. glaip_sdk/models/agent_runs.py +0 -1
  27. glaip_sdk/models/common.py +42 -0
  28. glaip_sdk/models/mcp.py +33 -0
  29. glaip_sdk/models/tool.py +33 -0
  30. glaip_sdk/registry/__init__.py +55 -0
  31. glaip_sdk/registry/agent.py +164 -0
  32. glaip_sdk/registry/base.py +139 -0
  33. glaip_sdk/registry/mcp.py +251 -0
  34. glaip_sdk/registry/tool.py +238 -0
  35. glaip_sdk/tools/__init__.py +22 -0
  36. glaip_sdk/tools/base.py +435 -0
  37. glaip_sdk/utils/__init__.py +50 -9
  38. glaip_sdk/utils/bundler.py +267 -0
  39. glaip_sdk/utils/client.py +111 -0
  40. glaip_sdk/utils/client_utils.py +26 -7
  41. glaip_sdk/utils/discovery.py +78 -0
  42. glaip_sdk/utils/import_resolver.py +492 -0
  43. glaip_sdk/utils/instructions.py +101 -0
  44. glaip_sdk/utils/sync.py +142 -0
  45. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/METADATA +5 -3
  46. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/RECORD +48 -22
  47. glaip_sdk/models.py +0 -241
  48. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/WHEEL +0 -0
  49. {glaip_sdk-0.5.5.dist-info → glaip_sdk-0.6.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,379 @@
1
+ """Textual UI for the /accounts command.
2
+
3
+ Provides a minimal interactive list with the same columns/order as the Rich
4
+ fallback (name, API URL, masked key, status) and keyboard navigation.
5
+
6
+ Authors:
7
+ Raymond Christopher (raymond.christopher@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+
17
+ from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
18
+ from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
19
+ from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
20
+
21
+ try: # pragma: no cover - optional dependency
22
+ from textual import events
23
+ from textual.app import App, ComposeResult
24
+ from textual.binding import Binding
25
+ from textual.containers import Container, Horizontal, Vertical
26
+ from textual.widgets import DataTable, Footer, Header, Input, LoadingIndicator, Static
27
+ except Exception: # pragma: no cover - optional dependency
28
+ events = None # type: ignore[assignment]
29
+ App = None # type: ignore[assignment]
30
+ ComposeResult = None # type: ignore[assignment]
31
+ Binding = None # type: ignore[assignment]
32
+ Container = None # type: ignore[assignment]
33
+ Horizontal = None # type: ignore[assignment]
34
+ Vertical = None # type: ignore[assignment]
35
+ DataTable = None # type: ignore[assignment]
36
+ Footer = None # type: ignore[assignment]
37
+ Header = None # type: ignore[assignment]
38
+ Input = None # type: ignore[assignment]
39
+ LoadingIndicator = None # type: ignore[assignment]
40
+ Static = None # type: ignore[assignment]
41
+
42
+ TEXTUAL_SUPPORTED = App is not None and DataTable is not None
43
+
44
+ # Widget IDs for Textual UI
45
+ ACCOUNTS_TABLE_ID = "#accounts-table"
46
+ FILTER_INPUT_ID = "#filter-input"
47
+ STATUS_ID = "#status"
48
+ ACCOUNTS_LOADING_ID = "#accounts-loading"
49
+
50
+
51
+ @dataclass
52
+ class AccountsTUICallbacks:
53
+ """Callbacks invoked by the Textual UI."""
54
+
55
+ switch_account: Callable[[str], tuple[bool, str]]
56
+
57
+
58
+ def run_accounts_textual(
59
+ rows: list[dict[str, str | bool]],
60
+ *,
61
+ active_account: str | None,
62
+ env_lock: bool,
63
+ callbacks: AccountsTUICallbacks,
64
+ ) -> None:
65
+ """Launch the Textual accounts browser if dependencies are available."""
66
+ if not TEXTUAL_SUPPORTED:
67
+ return
68
+ app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
69
+ app.run()
70
+
71
+
72
+ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover - interactive
73
+ """Textual application for browsing accounts."""
74
+
75
+ CSS_PATH = "accounts.tcss"
76
+ BINDINGS = [
77
+ Binding("enter", "switch_row", "Switch", show=True),
78
+ Binding("return", "switch_row", "Switch", show=False),
79
+ Binding("/", "focus_filter", "Filter", show=True),
80
+ # Esc clears filter when focused/non-empty; otherwise exits
81
+ Binding("escape", "clear_or_exit", "Close", priority=True),
82
+ Binding("q", "app_exit", "Close", priority=True),
83
+ ]
84
+
85
+ def __init__(
86
+ self,
87
+ rows: list[dict[str, str | bool]],
88
+ active_account: str | None,
89
+ env_lock: bool,
90
+ callbacks: AccountsTUICallbacks,
91
+ ) -> None:
92
+ """Initialize the Textual accounts app.
93
+
94
+ Args:
95
+ rows: Account data rows to display.
96
+ active_account: Name of the currently active account.
97
+ env_lock: Whether environment credentials are locking account switching.
98
+ callbacks: Callbacks for account switching operations.
99
+ """
100
+ super().__init__()
101
+ self._all_rows = rows
102
+ self._active_account = active_account
103
+ self._env_lock = env_lock
104
+ self._callbacks = callbacks
105
+ self._filter_text: str = ""
106
+ self._is_switching = False
107
+
108
+ def compose(self) -> ComposeResult:
109
+ """Build the Textual layout."""
110
+ header_text = self._header_text()
111
+ yield Static(header_text, id="header-info")
112
+ if self._env_lock:
113
+ yield Static(
114
+ "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled.",
115
+ id="env-lock",
116
+ )
117
+ filter_bar = Container(
118
+ Static("Filter (/):", id="filter-label"),
119
+ Input(placeholder="Type to filter by name or host", id="filter-input"),
120
+ id="filter-container",
121
+ )
122
+ filter_bar.styles.padding = (0, 0)
123
+ main = Vertical(
124
+ filter_bar,
125
+ DataTable(id=ACCOUNTS_TABLE_ID.lstrip("#")),
126
+ )
127
+ # Avoid large gaps; keep main content filling available space
128
+ main.styles.height = "1fr"
129
+ main.styles.padding = (0, 0)
130
+ yield main
131
+ yield Horizontal(
132
+ LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
133
+ Static("", id=STATUS_ID.lstrip("#")),
134
+ id="status-bar",
135
+ )
136
+ yield Footer()
137
+
138
+ def on_mount(self) -> None:
139
+ """Configure table columns and load rows."""
140
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
141
+ table.add_column("Name", width=20)
142
+ table.add_column("API URL", width=40)
143
+ table.add_column("Key (masked)", width=20)
144
+ table.add_column("Status", width=14)
145
+ table.cursor_type = "row"
146
+ table.zebra_stripes = True
147
+ table.styles.height = "1fr" # Fill available space below the filter
148
+ table.styles.margin = 0
149
+ self._reload_rows()
150
+ table.focus()
151
+ # Keep the filter tight to the table
152
+ main = self.query_one(Vertical)
153
+ main.styles.gap = 0
154
+
155
+ def _header_text(self) -> str:
156
+ """Build header text with active account and host."""
157
+ host = self._get_active_host() or "Not configured"
158
+ lock_icon = " [yellow]🔒[/]" if self._env_lock else ""
159
+ active = self._active_account or "None"
160
+ return f"[green]Active:[/] [bold]{active}[/] ([cyan]{host}[/]){lock_icon}"
161
+
162
+ def _get_active_host(self) -> str | None:
163
+ """Return the API host for the active account (shortened)."""
164
+ return self._get_host_for_name(self._active_account)
165
+
166
+ def _get_host_for_name(self, name: str | None) -> str | None:
167
+ """Return shortened API URL for a given account name."""
168
+ if not name:
169
+ return None
170
+ for row in self._all_rows:
171
+ if row.get("name") == name:
172
+ url = str(row.get("api_url", ""))
173
+ return url if len(url) <= 40 else f"{url[:37]}..."
174
+ return None
175
+
176
+ def action_focus_filter(self) -> None:
177
+ """Focus the filter input and clear previous text."""
178
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
179
+ filter_input.value = self._filter_text
180
+ filter_input.focus()
181
+
182
+ def action_switch_row(self) -> None:
183
+ """Switch to the currently selected account."""
184
+ if self._env_lock:
185
+ self._set_status("Switching disabled: env credentials in use.", "yellow")
186
+ return
187
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
188
+ if table.cursor_row is None:
189
+ self._set_status("No account selected.", "yellow")
190
+ return
191
+ try:
192
+ row_key = table.get_row_at(table.cursor_row)[0]
193
+ except Exception:
194
+ self._set_status("Unable to read selected row.", "red")
195
+ return
196
+ name = str(row_key)
197
+ if self._is_switching:
198
+ self._set_status("Already switching...", "yellow")
199
+ return
200
+ self._is_switching = True
201
+ host = self._get_host_for_name(name)
202
+ if host:
203
+ self._show_loading(f"Connecting to '{name}' ({host})...")
204
+ else:
205
+ self._show_loading(f"Connecting to '{name}'...")
206
+ self._queue_switch(name)
207
+
208
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
209
+ """Handle mouse click selection by triggering switch."""
210
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
211
+ try:
212
+ # Move cursor to clicked row then switch
213
+ table.cursor_coordinate = (event.cursor_row, 0)
214
+ except Exception:
215
+ return
216
+ self.action_switch_row()
217
+
218
+ def on_input_submitted(self, event: Input.Submitted) -> None:
219
+ """Apply filter when user presses Enter inside filter input."""
220
+ self._filter_text = (event.value or "").strip()
221
+ self._reload_rows()
222
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
223
+ table.focus()
224
+
225
+ def on_input_changed(self, event: Input.Changed) -> None:
226
+ """Apply filter live as the user types."""
227
+ self._filter_text = (event.value or "").strip()
228
+ self._reload_rows()
229
+
230
+ def on_key(self, event: events.Key) -> None: # type: ignore[override]
231
+ """Let users start typing to filter without pressing '/' first."""
232
+ if not getattr(event, "is_printable", False):
233
+ return
234
+ if not event.character:
235
+ return
236
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
237
+ if filter_input.has_focus:
238
+ return
239
+ filter_input.focus()
240
+ filter_input.value = (filter_input.value or "") + event.character
241
+ filter_input.cursor_position = len(filter_input.value)
242
+ self._filter_text = filter_input.value.strip()
243
+ self._reload_rows()
244
+ event.stop()
245
+
246
+ def _reload_rows(self) -> None:
247
+ """Refresh table rows based on current filter/active state."""
248
+ # Work on a copy to avoid mutating the backing rows list
249
+ rows_copy = [dict(row) for row in self._all_rows]
250
+ for row in rows_copy:
251
+ row["active"] = row.get("name") == self._active_account
252
+
253
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
254
+ table.clear()
255
+ filtered = self._filtered_rows(rows_copy)
256
+ for row in filtered:
257
+ row_for_status = dict(row)
258
+ row_for_status["active"] = row_for_status.get("name") == self._active_account
259
+ # Use markup to align status colors with Rich fallback (green active badge).
260
+ status = build_account_status_string(row_for_status, use_markup=True)
261
+ # pylint: disable=duplicate-code
262
+ # Reuses shared status builder; columns mirror accounts_controller Rich table.
263
+ table.add_row(
264
+ str(row.get("name", "")),
265
+ str(row.get("api_url", "")),
266
+ str(row.get("masked_key", "")),
267
+ status,
268
+ )
269
+ # Move cursor to active or first row
270
+ cursor_idx = 0
271
+ for idx, row in enumerate(filtered):
272
+ if row.get("name") == self._active_account:
273
+ cursor_idx = idx
274
+ break
275
+ if filtered:
276
+ table.cursor_coordinate = (cursor_idx, 0)
277
+ else:
278
+ self._set_status("No accounts match the current filter.", "yellow")
279
+ return
280
+
281
+ # Update status to reflect filter state
282
+ if self._filter_text:
283
+ self._set_status(f"Filtered: {self._filter_text}", "cyan")
284
+ else:
285
+ self._set_status("", "white")
286
+
287
+ def _filtered_rows(self, rows: list[dict[str, str | bool]] | None = None) -> list[dict[str, str | bool]]:
288
+ """Return rows filtered by name or API URL substring."""
289
+ base_rows = rows if rows is not None else [dict(row) for row in self._all_rows]
290
+ if not self._filter_text:
291
+ return list(base_rows)
292
+ needle = self._filter_text.lower()
293
+ filtered = [
294
+ row
295
+ for row in base_rows
296
+ if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
297
+ ]
298
+
299
+ # Sort so name matches surface first, then URL matches, then alphabetically
300
+ def score(row: dict[str, str | bool]) -> tuple[int, str]:
301
+ name = str(row.get("name", "")).lower()
302
+ url = str(row.get("api_url", "")).lower()
303
+ name_hit = needle in name
304
+ url_hit = needle in url
305
+ # Extract nested conditional into clear statement
306
+ if name_hit:
307
+ priority = 0
308
+ elif url_hit:
309
+ priority = 1
310
+ else:
311
+ priority = 2
312
+ return (priority, name)
313
+
314
+ return sorted(filtered, key=score)
315
+
316
+ def _set_status(self, message: str, style: str) -> None:
317
+ """Update status line with message."""
318
+ status = self.query_one(STATUS_ID, Static)
319
+ status.update(f"[{style}]{message}[/]")
320
+
321
+ def _show_loading(self, message: str | None = None) -> None:
322
+ """Show the loading indicator and optional status message."""
323
+ show_loading_indicator(self, ACCOUNTS_LOADING_ID, message=message, set_status=self._set_status)
324
+
325
+ def _hide_loading(self) -> None:
326
+ """Hide the loading indicator."""
327
+ hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
328
+
329
+ def _queue_switch(self, name: str) -> None:
330
+ """Run switch in background to keep UI responsive."""
331
+
332
+ async def perform() -> None:
333
+ try:
334
+ switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
335
+ except Exception as exc: # pragma: no cover - defensive
336
+ self._set_status(f"Switch failed: {exc}", "red")
337
+ return
338
+ finally:
339
+ self._hide_loading()
340
+ self._is_switching = False
341
+
342
+ if switched:
343
+ self._active_account = name
344
+ self._set_status(message or f"Switched to '{name}'.", "green")
345
+ self._update_header()
346
+ self._reload_rows()
347
+ else:
348
+ self._set_status(message or "Switch failed; kept previous account.", "yellow")
349
+
350
+ try:
351
+ self.track_task(perform(), logger=logging.getLogger(__name__))
352
+ except Exception as exc:
353
+ # If scheduling the task fails, clear loading/switching state and surface the error.
354
+ self._hide_loading()
355
+ self._is_switching = False
356
+ self._set_status(f"Switch failed to start: {exc}", "red")
357
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
358
+
359
+ def _update_header(self) -> None:
360
+ """Refresh header text to reflect active/lock state."""
361
+ header = self.query_one("#header-info", Static)
362
+ header.update(self._header_text())
363
+
364
+ def action_clear_or_exit(self) -> None:
365
+ """Clear filter when focused/non-empty; otherwise exit.
366
+
367
+ UX note: helps users reset the list without leaving the TUI.
368
+ """
369
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
370
+ # Extract nested conditional into clear statement
371
+ should_clear = filter_input.has_focus and (filter_input.value or self._filter_text)
372
+ if should_clear:
373
+ filter_input.value = ""
374
+ self._filter_text = ""
375
+ self._reload_rows()
376
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
377
+ table.focus()
378
+ return
379
+ self.exit()
@@ -0,0 +1,72 @@
1
+ """Shared mixin for tracking background asyncio tasks in Textual apps.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from collections.abc import Callable, Coroutine
12
+ from typing import Any
13
+
14
+
15
+ class BackgroundTaskMixin:
16
+ """Mixin that tracks background tasks and cleans them up on unmount."""
17
+
18
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
19
+ """Initialize task tracking set for derived Textual apps."""
20
+ super().__init__(*args, **kwargs)
21
+ self._pending_tasks: set[asyncio.Task[Any]] = set()
22
+
23
+ def track_task(
24
+ self,
25
+ coro: Coroutine[Any, Any, Any],
26
+ *,
27
+ on_error: Callable[[Exception], None] | None = None,
28
+ logger: logging.Logger | None = None,
29
+ ) -> asyncio.Task[Any]:
30
+ """Create and track a background task with optional error handling."""
31
+ task = asyncio.create_task(coro)
32
+ self._pending_tasks.add(task)
33
+
34
+ def _cleanup(finished: asyncio.Task[Any]) -> None:
35
+ self._pending_tasks.discard(finished)
36
+ if finished.cancelled():
37
+ return
38
+ try:
39
+ exc = finished.exception()
40
+ except Exception:
41
+ return
42
+ if exc:
43
+ if on_error:
44
+ on_error(exc)
45
+ elif logger:
46
+ logger.debug("Background task failed", exc_info=exc)
47
+
48
+ task.add_done_callback(_cleanup)
49
+ return task
50
+
51
+ def on_unmount(self) -> None: # pragma: no cover - UI lifecycle hook
52
+ """Ensure background tasks are cleaned up on exit."""
53
+ pending = [task for task in self._pending_tasks if not task.done()]
54
+ for task in pending:
55
+ try:
56
+ task.cancel()
57
+ except Exception:
58
+ continue
59
+ if pending:
60
+ try:
61
+ loop = asyncio.get_running_loop()
62
+ except RuntimeError:
63
+ loop = None
64
+ if loop and loop.is_running():
65
+ try:
66
+ loop.create_task(asyncio.gather(*pending, return_exceptions=True))
67
+ except Exception:
68
+ pass
69
+ self._pending_tasks.clear()
70
+ parent_on_unmount = getattr(super(), "on_unmount", None)
71
+ if callable(parent_on_unmount):
72
+ parent_on_unmount() # type: ignore[misc]
@@ -0,0 +1,58 @@
1
+ """Shared helpers for toggling Textual loading indicators.
2
+
3
+ Note: uses Textual's built-in LoadingIndicator as the MVP; upgrade to the
4
+ PulseIndicator from cli-textual-animated-indicators.md when shipped.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING
11
+
12
+ try: # pragma: no cover - optional dependency
13
+ from textual.widgets import LoadingIndicator
14
+ except Exception: # pragma: no cover - optional dependency
15
+ LoadingIndicator = None # type: ignore[assignment]
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - type checking aid
18
+ from textual.widgets import LoadingIndicator as _LoadingIndicatorType
19
+
20
+ LoadingIndicator: type[_LoadingIndicatorType] | None
21
+
22
+
23
+ def _set_indicator_display(app: object, selector: str, visible: bool) -> None:
24
+ """Safely toggle a LoadingIndicator's display property."""
25
+ if LoadingIndicator is None:
26
+ return
27
+ try:
28
+ indicator = app.query_one(selector, LoadingIndicator) # type: ignore[arg-type]
29
+ indicator.display = visible
30
+ except Exception:
31
+ # Ignore lookup/rendering errors to keep UI resilient
32
+ return
33
+
34
+
35
+ def show_loading_indicator(
36
+ app: object,
37
+ selector: str,
38
+ *,
39
+ message: str | None = None,
40
+ set_status: Callable[..., None] | None = None,
41
+ status_style: str = "cyan",
42
+ ) -> None:
43
+ """Show a loading indicator and optionally set a status message."""
44
+ _set_indicator_display(app, selector, True)
45
+ if message and set_status:
46
+ try:
47
+ set_status(message, status_style)
48
+ except TypeError:
49
+ # Fallback for setters that accept only a single arg or kwargs
50
+ try:
51
+ set_status(message)
52
+ except Exception:
53
+ return
54
+
55
+
56
+ def hide_loading_indicator(app: object, selector: str) -> None:
57
+ """Hide a loading indicator."""
58
+ _set_indicator_display(app, selector, False)
@@ -24,6 +24,8 @@ from textual.reactive import ReactiveError
24
24
  from textual.screen import ModalScreen
25
25
  from textual.widgets import DataTable, Footer, Header, LoadingIndicator, RichLog, Static
26
26
 
27
+ from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
28
+
27
29
  logger = logging.getLogger(__name__)
28
30
 
29
31
  RUNS_TABLE_ID = "runs"
@@ -601,23 +603,18 @@ class RemoteRunsTextualApp(App[None]):
601
603
  footer_message: bool = True,
602
604
  ) -> None:
603
605
  """Display the loading indicator with an optional status message."""
604
- try:
605
- indicator = self.query_one(RUNS_LOADING_SELECTOR, LoadingIndicator)
606
- indicator.display = True
607
- except (AttributeError, RuntimeError) as e:
608
- logger.debug("Cannot show loading indicator: %s", type(e).__name__)
606
+ show_loading_indicator(
607
+ self,
608
+ RUNS_LOADING_SELECTOR,
609
+ message=message if footer_message else None,
610
+ set_status=self._update_status if footer_message else None,
611
+ )
609
612
  self._set_table_loading(table_spinner)
610
613
  self._table_spinner_active = table_spinner
611
- if message and footer_message:
612
- self._update_status(message)
613
614
 
614
615
  def _hide_loading(self) -> None:
615
616
  """Hide the loading indicator."""
616
- try:
617
- indicator = self.query_one(RUNS_LOADING_SELECTOR, LoadingIndicator)
618
- indicator.display = False
619
- except (AttributeError, RuntimeError) as e:
620
- logger.debug("Cannot hide loading indicator: %s", type(e).__name__)
617
+ hide_loading_indicator(self, RUNS_LOADING_SELECTOR)
621
618
  if self._table_spinner_active:
622
619
  self._set_table_loading(False)
623
620
  self._table_spinner_active = False
@@ -15,10 +15,7 @@ from glaip_sdk.config.constants import (
15
15
  DEFAULT_AGENT_VERSION,
16
16
  DEFAULT_MODEL,
17
17
  )
18
- from glaip_sdk.payload_schemas.agent import (
19
- AgentImportOperation,
20
- get_import_field_plan,
21
- )
18
+ from glaip_sdk.payload_schemas.agent import AgentImportOperation, get_import_field_plan
22
19
  from glaip_sdk.utils.client_utils import extract_ids
23
20
 
24
21
  _LM_CONFLICT_KEYS = {
@@ -437,12 +434,16 @@ __all__ = [
437
434
 
438
435
  def _build_base_update_payload(request: AgentUpdateRequest, current_agent: Any) -> dict[str, Any]:
439
436
  """Populate immutable agent update fields using request data or existing agent defaults."""
437
+ # Support both "agent_type" (runtime class) and "type" (API response) attributes
438
+ current_type = getattr(current_agent, "agent_type", None) or getattr(current_agent, "type", None)
440
439
  return {
441
- "name": request.name.strip() if request.name is not None else getattr(current_agent, "name", None),
442
- "instruction": request.instruction.strip()
443
- if request.instruction is not None
444
- else getattr(current_agent, "instruction", None),
445
- "type": request.agent_type or getattr(current_agent, "type", None) or DEFAULT_AGENT_TYPE,
440
+ "name": (request.name.strip() if request.name is not None else getattr(current_agent, "name", None)),
441
+ "instruction": (
442
+ request.instruction.strip()
443
+ if request.instruction is not None
444
+ else getattr(current_agent, "instruction", None)
445
+ ),
446
+ "type": request.agent_type or current_type or DEFAULT_AGENT_TYPE,
446
447
  "framework": request.framework or getattr(current_agent, "framework", None) or DEFAULT_AGENT_FRAMEWORK,
447
448
  "version": request.version or getattr(current_agent, "version", None) or DEFAULT_AGENT_VERSION,
448
449
  }