glaip-sdk 0.2.2__py3-none-any.whl → 0.4.0__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 (71) hide show
  1. glaip_sdk/cli/auth.py +2 -1
  2. glaip_sdk/cli/commands/agents.py +51 -36
  3. glaip_sdk/cli/commands/configure.py +2 -1
  4. glaip_sdk/cli/commands/mcps.py +219 -62
  5. glaip_sdk/cli/commands/models.py +3 -5
  6. glaip_sdk/cli/commands/tools.py +27 -16
  7. glaip_sdk/cli/commands/transcripts.py +1 -1
  8. glaip_sdk/cli/constants.py +3 -0
  9. glaip_sdk/cli/display.py +1 -1
  10. glaip_sdk/cli/hints.py +58 -0
  11. glaip_sdk/cli/io.py +6 -3
  12. glaip_sdk/cli/main.py +3 -4
  13. glaip_sdk/cli/slash/agent_session.py +4 -13
  14. glaip_sdk/cli/slash/prompt.py +3 -0
  15. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  16. glaip_sdk/cli/slash/session.py +139 -48
  17. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  18. glaip_sdk/cli/slash/tui/remote_runs_app.py +632 -0
  19. glaip_sdk/cli/transcript/capture.py +1 -1
  20. glaip_sdk/cli/transcript/viewer.py +19 -678
  21. glaip_sdk/cli/update_notifier.py +2 -1
  22. glaip_sdk/cli/utils.py +228 -101
  23. glaip_sdk/cli/validators.py +5 -6
  24. glaip_sdk/client/__init__.py +2 -1
  25. glaip_sdk/client/agent_runs.py +147 -0
  26. glaip_sdk/client/agents.py +40 -22
  27. glaip_sdk/client/main.py +2 -6
  28. glaip_sdk/client/mcps.py +13 -5
  29. glaip_sdk/client/run_rendering.py +90 -111
  30. glaip_sdk/client/shared.py +21 -0
  31. glaip_sdk/client/tools.py +2 -3
  32. glaip_sdk/config/constants.py +11 -0
  33. glaip_sdk/models/__init__.py +56 -0
  34. glaip_sdk/models/agent_runs.py +117 -0
  35. glaip_sdk/models.py +8 -7
  36. glaip_sdk/rich_components.py +58 -2
  37. glaip_sdk/utils/client_utils.py +13 -0
  38. glaip_sdk/utils/display.py +23 -15
  39. glaip_sdk/utils/export.py +143 -0
  40. glaip_sdk/utils/import_export.py +6 -9
  41. glaip_sdk/utils/rendering/__init__.py +115 -1
  42. glaip_sdk/utils/rendering/formatting.py +5 -30
  43. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  44. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  45. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  46. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  47. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  48. glaip_sdk/utils/rendering/models.py +1 -0
  49. glaip_sdk/utils/rendering/renderer/__init__.py +10 -28
  50. glaip_sdk/utils/rendering/renderer/base.py +217 -1476
  51. glaip_sdk/utils/rendering/renderer/debug.py +24 -1
  52. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  53. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  54. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  55. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  56. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  57. glaip_sdk/utils/rendering/state.py +204 -0
  58. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  59. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -439
  60. glaip_sdk/utils/rendering/steps/format.py +176 -0
  61. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  62. glaip_sdk/utils/rendering/timing.py +36 -0
  63. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  64. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  65. glaip_sdk/utils/resource_refs.py +26 -15
  66. glaip_sdk/utils/validation.py +13 -21
  67. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/METADATA +24 -2
  68. glaip_sdk-0.4.0.dist-info/RECORD +110 -0
  69. glaip_sdk-0.2.2.dist-info/RECORD +0 -87
  70. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/WHEEL +0 -0
  71. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -26,7 +26,8 @@ from glaip_sdk.branding import (
26
26
  )
27
27
  from glaip_sdk.cli.commands.update import update_command
28
28
  from glaip_sdk.cli.constants import UPDATE_CHECK_ENABLED
29
- from glaip_sdk.cli.utils import command_hint, format_command_hint
29
+ from glaip_sdk.cli.hints import format_command_hint
30
+ from glaip_sdk.cli.utils import command_hint
30
31
  from glaip_sdk.rich_components import AIPPanel
31
32
 
32
33
  FetchLatestVersion = Callable[[], str | None]
glaip_sdk/cli/utils.py CHANGED
@@ -7,13 +7,14 @@ Authors:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
10
11
  import importlib
11
12
  import json
12
13
  import logging
13
14
  import os
14
15
  import sys
15
16
  from collections.abc import Callable, Iterable
16
- from contextlib import AbstractContextManager, nullcontext
17
+ from contextlib import AbstractContextManager, contextmanager, nullcontext
17
18
  from pathlib import Path
18
19
  from typing import TYPE_CHECKING, Any, cast
19
20
 
@@ -26,29 +27,206 @@ from rich.syntax import Syntax
26
27
  from glaip_sdk import _version as _version_module
27
28
  from glaip_sdk.branding import (
28
29
  ACCENT_STYLE,
29
- HINT_COMMAND_STYLE,
30
- HINT_DESCRIPTION_COLOR,
31
30
  SUCCESS_STYLE,
32
31
  WARNING_STYLE,
33
32
  )
34
33
  from glaip_sdk.cli import masking, pager
35
- from glaip_sdk.cli.constants import LITERAL_STRING_THRESHOLD, TABLE_SORT_ENABLED
36
34
  from glaip_sdk.cli.config import load_config
35
+ from glaip_sdk.cli.constants import LITERAL_STRING_THRESHOLD, TABLE_SORT_ENABLED
37
36
  from glaip_sdk.cli.context import (
38
37
  _get_view,
39
- detect_export_format as _detect_export_format,
40
38
  get_ctx_value,
41
39
  )
42
- from glaip_sdk.cli.rich_helpers import markup_text
40
+ from glaip_sdk.cli.context import (
41
+ detect_export_format as _detect_export_format,
42
+ )
43
+ from glaip_sdk.cli import display as cli_display
44
+ from glaip_sdk.cli.hints import command_hint
45
+ from glaip_sdk.cli.io import export_resource_to_file_with_validation
46
+ from glaip_sdk.cli.rich_helpers import markup_text, print_markup
43
47
  from glaip_sdk.icons import ICON_AGENT
44
48
  from glaip_sdk.rich_components import AIPPanel, AIPTable
45
- from glaip_sdk.utils import is_uuid
49
+ from glaip_sdk.utils import format_datetime, is_uuid
46
50
  from glaip_sdk.utils.rendering.renderer import (
47
51
  CapturingConsole,
48
- RendererConfig,
52
+ RendererFactoryOptions,
49
53
  RichStreamRenderer,
54
+ make_default_renderer,
55
+ make_verbose_renderer,
50
56
  )
51
57
 
58
+ questionary = None # type: ignore[assignment]
59
+
60
+
61
+ def _load_questionary_module() -> tuple[Any | None, Any | None]:
62
+ """Return the questionary module and Choice class if available."""
63
+ module = questionary
64
+ if module is not None:
65
+ return module, getattr(module, "Choice", None)
66
+
67
+ try: # pragma: no cover - optional dependency
68
+ module = __import__("questionary")
69
+ except ImportError:
70
+ return None, None
71
+
72
+ return module, getattr(module, "Choice", None)
73
+
74
+
75
+ def _make_questionary_choice(choice_cls: Any | None, **kwargs: Any) -> Any:
76
+ """Create a questionary Choice instance or lightweight fallback."""
77
+ if choice_cls is None:
78
+ return kwargs
79
+ return choice_cls(**kwargs)
80
+
81
+
82
+ @contextmanager
83
+ def bind_slash_session_context(ctx: Any, session: Any) -> Any:
84
+ """Temporarily attach a slash session to the Click context.
85
+
86
+ Args:
87
+ ctx: Click context object.
88
+ session: SlashSession instance to bind.
89
+
90
+ Yields:
91
+ None - context manager for use in with statement.
92
+ """
93
+ ctx_obj = getattr(ctx, "obj", None)
94
+ has_context = isinstance(ctx_obj, dict)
95
+ previous_session = ctx_obj.get("_slash_session") if has_context else None
96
+ if has_context:
97
+ ctx_obj["_slash_session"] = session
98
+ try:
99
+ yield
100
+ finally:
101
+ if has_context:
102
+ if previous_session is None:
103
+ ctx_obj.pop("_slash_session", None)
104
+ else:
105
+ ctx_obj["_slash_session"] = previous_session
106
+
107
+
108
+ def restore_slash_session_context(ctx_obj: dict[str, Any], previous_session: Any | None) -> None:
109
+ """Restore slash session context after operation.
110
+
111
+ Args:
112
+ ctx_obj: Click context obj dictionary.
113
+ previous_session: Previous session to restore, or None to remove.
114
+ """
115
+ if previous_session is None:
116
+ ctx_obj.pop("_slash_session", None)
117
+ else:
118
+ ctx_obj["_slash_session"] = previous_session
119
+
120
+
121
+ def handle_best_effort_check(
122
+ check_func: Callable[[], None],
123
+ ) -> None:
124
+ """Handle best-effort duplicate/existence checks with proper exception handling.
125
+
126
+ Args:
127
+ check_func: Function that performs the check and raises ClickException if duplicate found.
128
+ """
129
+ try:
130
+ check_func()
131
+ except click.ClickException:
132
+ raise
133
+ except Exception:
134
+ # Non-fatal: best-effort duplicate check
135
+ pass
136
+
137
+
138
+ def prompt_export_choice_questionary(
139
+ default_path: Path,
140
+ default_display: str,
141
+ ) -> tuple[str, Path | None] | None:
142
+ """Prompt user for export destination using questionary with numeric shortcuts.
143
+
144
+ Args:
145
+ default_path: Default export path.
146
+ default_display: Formatted display string for default path.
147
+
148
+ Returns:
149
+ Tuple of (choice, path) or None if cancelled/unavailable.
150
+ Choice can be "default", "custom", or "cancel".
151
+ """
152
+ questionary_module, choice_cls = _load_questionary_module()
153
+ if questionary_module is None or choice_cls is None:
154
+ return None
155
+
156
+ try:
157
+ question = questionary_module.select(
158
+ "Export transcript",
159
+ choices=[
160
+ _make_questionary_choice(
161
+ choice_cls,
162
+ title=f"Save to default ({default_display})",
163
+ value=("default", default_path),
164
+ shortcut_key="1",
165
+ ),
166
+ _make_questionary_choice(
167
+ choice_cls,
168
+ title="Choose a different path",
169
+ value=("custom", None),
170
+ shortcut_key="2",
171
+ ),
172
+ _make_questionary_choice(
173
+ choice_cls,
174
+ title="Cancel",
175
+ value=("cancel", None),
176
+ shortcut_key="3",
177
+ ),
178
+ ],
179
+ use_shortcuts=True,
180
+ instruction="Press 1-3 (or arrows) then Enter.",
181
+ )
182
+ answer = questionary_safe_ask(question)
183
+ except Exception:
184
+ return None
185
+
186
+ if answer is None:
187
+ return ("cancel", None)
188
+ return answer
189
+
190
+
191
+ def questionary_safe_ask(question: Any, *, patch_stdout: bool = False) -> Any:
192
+ """Run `questionary.Question` safely even when an asyncio loop is active."""
193
+ ask_fn = getattr(question, "unsafe_ask", None)
194
+ if not callable(ask_fn):
195
+ raise RuntimeError("Questionary prompt is missing unsafe_ask()")
196
+
197
+ if not _asyncio_loop_running():
198
+ return ask_fn(patch_stdout=patch_stdout)
199
+
200
+ return _run_questionary_in_thread(question, patch_stdout=patch_stdout)
201
+
202
+
203
+ def _asyncio_loop_running() -> bool:
204
+ """Return True when an asyncio event loop is already running."""
205
+ try:
206
+ asyncio.get_running_loop()
207
+ except RuntimeError:
208
+ return False
209
+ return True
210
+
211
+
212
+ def _run_questionary_in_thread(question: Any, *, patch_stdout: bool = False) -> Any:
213
+ """Execute a questionary prompt in a background thread."""
214
+ if getattr(question, "should_skip_question", False):
215
+ return getattr(question, "default", None)
216
+
217
+ application = getattr(question, "application", None)
218
+ run_callable = getattr(application, "run", None) if application is not None else None
219
+ if callable(run_callable):
220
+ try:
221
+ if patch_stdout and pt_patch_stdout is not None:
222
+ with pt_patch_stdout():
223
+ return run_callable(in_thread=True)
224
+ return run_callable(in_thread=True)
225
+ except TypeError:
226
+ pass
227
+
228
+ return question.unsafe_ask(patch_stdout=patch_stdout)
229
+
52
230
 
53
231
  class _LiteralYamlDumper(yaml.SafeDumper):
54
232
  """YAML dumper that emits literal scalars for multiline strings."""
@@ -70,6 +248,7 @@ _LiteralYamlDumper.add_representer(str, _literal_str_representer)
70
248
  try:
71
249
  from prompt_toolkit.buffer import Buffer
72
250
  from prompt_toolkit.completion import Completion
251
+ from prompt_toolkit.patch_stdout import patch_stdout as pt_patch_stdout
73
252
  from prompt_toolkit.selection import SelectionType
74
253
  from prompt_toolkit.shortcuts import PromptSession, prompt
75
254
 
@@ -79,13 +258,9 @@ except Exception: # pragma: no cover - optional dependency
79
258
  SelectionType = None # type: ignore[assignment]
80
259
  PromptSession = None # type: ignore[assignment]
81
260
  prompt = None # type: ignore[assignment]
261
+ pt_patch_stdout = None # type: ignore[assignment]
82
262
  _HAS_PTK = False
83
263
 
84
- try:
85
- import questionary
86
- except Exception: # pragma: no cover - optional dependency
87
- questionary = None
88
-
89
264
  if TYPE_CHECKING: # pragma: no cover - import-only during type checking
90
265
  from glaip_sdk import Client
91
266
 
@@ -160,8 +335,6 @@ def format_datetime_fields(
160
335
  Returns:
161
336
  New dictionary with formatted datetime fields
162
337
  """
163
- from glaip_sdk.utils import format_datetime
164
-
165
338
  formatted = data.copy()
166
339
  for field in fields:
167
340
  if field in formatted:
@@ -224,10 +397,6 @@ def handle_resource_export(
224
397
  get_by_id_func: Function to fetch resource by ID
225
398
  console_override: Optional console override
226
399
  """
227
- from glaip_sdk.cli.display import handle_rich_output
228
- from glaip_sdk.cli.io import export_resource_to_file_with_validation
229
- from glaip_sdk.cli.rich_helpers import print_markup
230
-
231
400
  active_console = console_override or console
232
401
 
233
402
  # Auto-detect format from file extension
@@ -251,7 +420,7 @@ def handle_resource_export(
251
420
  ):
252
421
  export_resource_to_file_with_validation(full_resource, export_path, detected_format)
253
422
  except Exception:
254
- handle_rich_output(
423
+ cli_display.handle_rich_output(
255
424
  ctx,
256
425
  markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
257
426
  )
@@ -264,73 +433,6 @@ def handle_resource_export(
264
433
  )
265
434
 
266
435
 
267
- def in_slash_mode(ctx: click.Context | None = None) -> bool:
268
- """Return True when running inside the slash command palette."""
269
- if ctx is None:
270
- try:
271
- ctx = click.get_current_context(silent=True)
272
- except RuntimeError:
273
- ctx = None
274
-
275
- if ctx is None:
276
- return False
277
-
278
- obj = getattr(ctx, "obj", None)
279
- if isinstance(obj, dict):
280
- return bool(obj.get("_slash_session"))
281
-
282
- return bool(getattr(obj, "_slash_session", False))
283
-
284
-
285
- def command_hint(
286
- cli_command: str | None,
287
- slash_command: str | None = None,
288
- *,
289
- ctx: click.Context | None = None,
290
- ) -> str | None:
291
- """Return the appropriate command string for the current mode.
292
-
293
- Args:
294
- cli_command: Command string without the ``aip`` prefix (e.g., ``"status"``).
295
- slash_command: Slash command counterpart (e.g., ``"status"`` or ``"/status"``).
296
- ctx: Optional Click context override.
297
-
298
- Returns:
299
- The formatted command string for the active mode, or ``None`` when no
300
- equivalent command exists in that mode.
301
- """
302
- if in_slash_mode(ctx):
303
- if not slash_command:
304
- return None
305
- return slash_command if slash_command.startswith("/") else f"/{slash_command}"
306
-
307
- if not cli_command:
308
- return None
309
- return f"aip {cli_command}"
310
-
311
-
312
- def format_command_hint(
313
- command: str | None,
314
- description: str | None = None,
315
- ) -> str | None:
316
- """Return a Rich markup string that highlights a command hint.
317
-
318
- Args:
319
- command: Command text to highlight (already formatted for the active mode).
320
- description: Optional short description to display alongside the command.
321
-
322
- Returns:
323
- Markup string suitable for Rich rendering, or ``None`` when ``command`` is falsy.
324
- """
325
- if not command:
326
- return None
327
-
328
- highlighted = f"[{HINT_COMMAND_STYLE}]{command}[/]"
329
- if description:
330
- highlighted += f" [{HINT_DESCRIPTION_COLOR}]{description}[/{HINT_DESCRIPTION_COLOR}]"
331
- return highlighted
332
-
333
-
334
436
  def sdk_version() -> str:
335
437
  """Return the current SDK version, warning if metadata is unavailable."""
336
438
  version = getattr(_version_module, "__version__", None)
@@ -345,6 +447,28 @@ def sdk_version() -> str:
345
447
  return "0.0.0"
346
448
 
347
449
 
450
+ @contextmanager
451
+ def with_client_and_spinner(
452
+ ctx: Any,
453
+ spinner_message: str,
454
+ *,
455
+ console_override: Console | None = None,
456
+ ) -> Any:
457
+ """Context manager for commands that need client and spinner.
458
+
459
+ Args:
460
+ ctx: Click context.
461
+ spinner_message: Message to display in spinner.
462
+ console_override: Optional console override.
463
+
464
+ Yields:
465
+ Client instance.
466
+ """
467
+ client = get_client(ctx)
468
+ with spinner_context(ctx, spinner_message, console_override=console_override):
469
+ yield client
470
+
471
+
348
472
  def spinner_context(
349
473
  ctx: Any | None,
350
474
  message: str,
@@ -1184,19 +1308,20 @@ def build_renderer(
1184
1308
 
1185
1309
  # Configure renderer based on verbose mode and explicit overrides
1186
1310
  live_enabled = bool(live) if live is not None else not verbose
1187
- renderer_cfg = RendererConfig(
1188
- live=live_enabled,
1189
- append_finished_snapshots=bool(snapshots)
1190
- if snapshots is not None
1191
- else RendererConfig.append_finished_snapshots,
1311
+ cfg_overrides = {
1312
+ "live": live_enabled,
1313
+ "append_finished_snapshots": bool(snapshots) if snapshots is not None else False,
1314
+ }
1315
+ renderer_console = (
1316
+ working_console.original_console if isinstance(working_console, CapturingConsole) else working_console
1192
1317
  )
1193
-
1194
- # Create the renderer instance
1195
- renderer = RichStreamRenderer(
1196
- working_console.original_console if isinstance(working_console, CapturingConsole) else working_console,
1197
- cfg=renderer_cfg,
1198
- verbose=verbose,
1318
+ factory = make_verbose_renderer if verbose else make_default_renderer
1319
+ factory_options = RendererFactoryOptions(
1320
+ console=renderer_console,
1321
+ cfg_overrides=cfg_overrides,
1322
+ verbose=verbose if factory is make_default_renderer else None,
1199
1323
  )
1324
+ renderer = factory_options.build(factory)
1200
1325
 
1201
1326
  # Link the renderer back to the slash session when running from the palette.
1202
1327
  _register_renderer_with_session(_ctx, renderer)
@@ -1366,17 +1491,19 @@ def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
1366
1491
 
1367
1492
  def _handle_questionary_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
1368
1493
  """Handle ambiguity using questionary interactive interface."""
1369
- if not (questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1494
+ questionary_module, choice_cls = _load_questionary_module()
1495
+ if not (questionary_module and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1370
1496
  raise click.ClickException("Interactive selection not available")
1371
1497
 
1372
1498
  # Escape special characters for questionary
1373
1499
  safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1374
1500
  safe_ref = ref.replace("{", "{{").replace("}", "}}")
1375
1501
 
1376
- picked_idx = questionary.select(
1502
+ picked_idx = questionary_module.select(
1377
1503
  f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
1378
1504
  choices=[
1379
- questionary.Choice(
1505
+ _make_questionary_choice(
1506
+ choice_cls,
1380
1507
  title=(
1381
1508
  f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — "
1382
1509
  f"{getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}"
@@ -13,6 +13,7 @@ from typing import Any
13
13
 
14
14
  import click
15
15
 
16
+ from glaip_sdk.cli.utils import handle_best_effort_check
16
17
  from glaip_sdk.utils.validation import (
17
18
  coerce_timeout,
18
19
  validate_agent_instruction,
@@ -226,14 +227,12 @@ def validate_name_uniqueness_cli(
226
227
  Raises:
227
228
  click.ClickException: If name is not unique
228
229
  """
229
- try:
230
+
231
+ def _check_duplicate() -> None:
230
232
  existing = finder_func(name=name)
231
233
  if existing:
232
234
  raise click.ClickException(
233
235
  f"A {resource_type.lower()} named '{name}' already exists. Please choose a unique name."
234
236
  )
235
- except click.ClickException:
236
- raise
237
- except Exception:
238
- # Non-fatal: best-effort duplicate check
239
- pass
237
+
238
+ handle_best_effort_check(_check_duplicate)
@@ -5,6 +5,7 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ from glaip_sdk.client.agent_runs import AgentRunsClient
8
9
  from glaip_sdk.client.main import Client
9
10
 
10
- __all__ = ["Client"]
11
+ __all__ = ["AgentRunsClient", "Client"]
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Agent runs client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from glaip_sdk.client.base import BaseClient
13
+ from glaip_sdk.exceptions import TimeoutError, ValidationError
14
+ from glaip_sdk.models.agent_runs import RunSummary, RunsPage, RunWithOutput
15
+
16
+
17
+ class AgentRunsClient(BaseClient):
18
+ """Client for agent run operations."""
19
+
20
+ def list_runs(
21
+ self,
22
+ agent_id: str,
23
+ *,
24
+ limit: int = 20,
25
+ page: int = 1,
26
+ ) -> RunsPage:
27
+ """List agent runs with pagination.
28
+
29
+ Args:
30
+ agent_id: UUID of the agent
31
+ limit: Number of runs per page (1-100, default 20)
32
+ page: Page number (1-based, default 1)
33
+
34
+ Returns:
35
+ RunsPage containing paginated run summaries
36
+
37
+ Raises:
38
+ ValidationError: If pagination parameters are invalid
39
+ NotFoundError: If agent is not found
40
+ AuthenticationError: If authentication fails
41
+ TimeoutError: If request times out (30s default)
42
+ """
43
+ self._validate_pagination_params(limit, page)
44
+ envelope = self._fetch_runs_envelope(agent_id, limit, page)
45
+ normalized_data = self._normalize_runs_payload(envelope.get("data"))
46
+ runs = [RunSummary(**item) for item in normalized_data]
47
+ return self._build_runs_page(envelope, runs, limit, page)
48
+
49
+ def _fetch_runs_envelope(self, agent_id: str, limit: int, page: int) -> dict[str, Any]:
50
+ params = {"limit": limit, "page": page}
51
+ try:
52
+ envelope = self._request_with_envelope(
53
+ "GET",
54
+ f"/agents/{agent_id}/runs",
55
+ params=params,
56
+ )
57
+ except httpx.TimeoutException as e:
58
+ raise TimeoutError(f"Request timed out after {self._timeout}s while fetching agent runs") from e
59
+
60
+ if isinstance(envelope, dict):
61
+ return envelope
62
+ return {"data": envelope}
63
+
64
+ @staticmethod
65
+ def _validate_pagination_params(limit: int, page: int) -> None:
66
+ if limit < 1 or limit > 100:
67
+ raise ValidationError("limit must be between 1 and 100")
68
+ if page < 1:
69
+ raise ValidationError("page must be >= 1")
70
+
71
+ @staticmethod
72
+ def _normalize_runs_payload(data_payload: Any) -> list[Any]:
73
+ if not data_payload:
74
+ return []
75
+ normalized_data: list[Any] = []
76
+ for item in data_payload:
77
+ normalized_data.append(AgentRunsClient._normalize_run_item(item))
78
+ return normalized_data
79
+
80
+ @staticmethod
81
+ def _normalize_run_item(item: Any) -> Any:
82
+ if isinstance(item, dict):
83
+ if item.get("config") is None:
84
+ item["config"] = {}
85
+ schedule_id = item.get("schedule_id")
86
+ if schedule_id == "None" or schedule_id == "":
87
+ item["schedule_id"] = None
88
+ return item
89
+
90
+ @staticmethod
91
+ def _build_runs_page(
92
+ envelope: dict[str, Any],
93
+ runs: list[RunSummary],
94
+ limit: int,
95
+ page: int,
96
+ ) -> RunsPage:
97
+ return RunsPage(
98
+ data=runs,
99
+ total=envelope.get("total", 0),
100
+ page=envelope.get("page", page),
101
+ limit=envelope.get("limit", limit),
102
+ has_next=envelope.get("has_next", False),
103
+ has_prev=envelope.get("has_prev", False),
104
+ )
105
+
106
+ def get_run(
107
+ self,
108
+ agent_id: str,
109
+ run_id: str,
110
+ ) -> RunWithOutput:
111
+ """Get detailed run information including SSE event stream.
112
+
113
+ Args:
114
+ agent_id: UUID of the agent
115
+ run_id: UUID of the run
116
+
117
+ Returns:
118
+ RunWithOutput containing complete run details and event stream
119
+
120
+ Raises:
121
+ NotFoundError: If run or agent is not found
122
+ AuthenticationError: If authentication fails
123
+ TimeoutError: If request times out (30s default)
124
+ """
125
+ try:
126
+ envelope = self._request_with_envelope(
127
+ "GET",
128
+ f"/agents/{agent_id}/runs/{run_id}",
129
+ )
130
+ except httpx.TimeoutException as e:
131
+ raise TimeoutError(f"Request timed out after {self._timeout}s while fetching run detail") from e
132
+
133
+ if not isinstance(envelope, dict):
134
+ envelope = {"data": envelope}
135
+
136
+ data = envelope.get("data") or {}
137
+ # Normalize config, output, and schedule_id fields
138
+ if data.get("config") is None:
139
+ data["config"] = {}
140
+ if data.get("output") is None:
141
+ data["output"] = []
142
+ # Normalize schedule_id: convert string "None" to None
143
+ schedule_id = data.get("schedule_id")
144
+ if schedule_id == "None" or schedule_id == "":
145
+ data["schedule_id"] = None
146
+
147
+ return RunWithOutput(**data)