glaip-sdk 0.2.1__py3-none-any.whl → 0.3.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 (50) hide show
  1. glaip_sdk/_version.py +8 -0
  2. glaip_sdk/branding.py +13 -0
  3. glaip_sdk/cli/commands/agents.py +180 -39
  4. glaip_sdk/cli/commands/mcps.py +44 -18
  5. glaip_sdk/cli/commands/models.py +11 -5
  6. glaip_sdk/cli/commands/tools.py +35 -16
  7. glaip_sdk/cli/commands/transcripts.py +8 -0
  8. glaip_sdk/cli/constants.py +38 -0
  9. glaip_sdk/cli/context.py +8 -0
  10. glaip_sdk/cli/display.py +34 -19
  11. glaip_sdk/cli/main.py +14 -7
  12. glaip_sdk/cli/masking.py +8 -33
  13. glaip_sdk/cli/pager.py +9 -10
  14. glaip_sdk/cli/slash/agent_session.py +57 -20
  15. glaip_sdk/cli/slash/prompt.py +8 -0
  16. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  17. glaip_sdk/cli/slash/session.py +341 -46
  18. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  19. glaip_sdk/cli/slash/tui/remote_runs_app.py +632 -0
  20. glaip_sdk/cli/transcript/viewer.py +232 -32
  21. glaip_sdk/cli/update_notifier.py +2 -2
  22. glaip_sdk/cli/utils.py +266 -35
  23. glaip_sdk/cli/validators.py +5 -6
  24. glaip_sdk/client/__init__.py +2 -1
  25. glaip_sdk/client/_agent_payloads.py +30 -0
  26. glaip_sdk/client/agent_runs.py +147 -0
  27. glaip_sdk/client/agents.py +186 -22
  28. glaip_sdk/client/main.py +23 -6
  29. glaip_sdk/client/mcps.py +2 -4
  30. glaip_sdk/client/run_rendering.py +66 -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/rich_components.py +58 -2
  36. glaip_sdk/utils/client_utils.py +13 -0
  37. glaip_sdk/utils/export.py +143 -0
  38. glaip_sdk/utils/import_export.py +6 -9
  39. glaip_sdk/utils/rendering/__init__.py +122 -1
  40. glaip_sdk/utils/rendering/renderer/base.py +3 -7
  41. glaip_sdk/utils/rendering/renderer/debug.py +0 -1
  42. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  43. glaip_sdk/utils/rendering/steps.py +1 -0
  44. glaip_sdk/utils/resource_refs.py +26 -15
  45. glaip_sdk/utils/serialization.py +16 -0
  46. {glaip_sdk-0.2.1.dist-info → glaip_sdk-0.3.0.dist-info}/METADATA +24 -2
  47. glaip_sdk-0.3.0.dist-info/RECORD +94 -0
  48. glaip_sdk-0.2.1.dist-info/RECORD +0 -86
  49. {glaip_sdk-0.2.1.dist-info → glaip_sdk-0.3.0.dist-info}/WHEEL +0 -0
  50. {glaip_sdk-0.2.1.dist-info → glaip_sdk-0.3.0.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -12,15 +12,17 @@ import json
12
12
  import logging
13
13
  import os
14
14
  import sys
15
+ import asyncio
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
 
20
21
  import click
22
+ import yaml
21
23
  from rich.console import Console, Group
22
24
  from rich.markdown import Markdown
23
- from rich.pretty import Pretty
25
+ from rich.syntax import Syntax
24
26
 
25
27
  from glaip_sdk import _version as _version_module
26
28
  from glaip_sdk.branding import (
@@ -30,9 +32,195 @@ from glaip_sdk.branding import (
30
32
  SUCCESS_STYLE,
31
33
  WARNING_STYLE,
32
34
  )
33
- from glaip_sdk.cli.rich_helpers import markup_text
35
+ from glaip_sdk.cli import masking, pager
36
+ from glaip_sdk.cli.constants import LITERAL_STRING_THRESHOLD, TABLE_SORT_ENABLED
37
+ from glaip_sdk.cli.config import load_config
38
+ from glaip_sdk.cli.context import (
39
+ _get_view,
40
+ detect_export_format as _detect_export_format,
41
+ get_ctx_value,
42
+ )
43
+ from glaip_sdk.cli.io import export_resource_to_file_with_validation
44
+ from glaip_sdk.cli.rich_helpers import markup_text, print_markup
34
45
  from glaip_sdk.icons import ICON_AGENT
35
- from glaip_sdk.rich_components import AIPPanel
46
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
47
+ from glaip_sdk.utils import format_datetime, is_uuid
48
+ from glaip_sdk.utils.rendering.renderer import (
49
+ CapturingConsole,
50
+ RendererConfig,
51
+ RichStreamRenderer,
52
+ )
53
+
54
+
55
+ @contextmanager
56
+ def bind_slash_session_context(ctx: Any, session: Any) -> Any:
57
+ """Temporarily attach a slash session to the Click context.
58
+
59
+ Args:
60
+ ctx: Click context object.
61
+ session: SlashSession instance to bind.
62
+
63
+ Yields:
64
+ None - context manager for use in with statement.
65
+ """
66
+ ctx_obj = getattr(ctx, "obj", None)
67
+ has_context = isinstance(ctx_obj, dict)
68
+ previous_session = ctx_obj.get("_slash_session") if has_context else None
69
+ if has_context:
70
+ ctx_obj["_slash_session"] = session
71
+ try:
72
+ yield
73
+ finally:
74
+ if has_context:
75
+ if previous_session is None:
76
+ ctx_obj.pop("_slash_session", None)
77
+ else:
78
+ ctx_obj["_slash_session"] = previous_session
79
+
80
+
81
+ def restore_slash_session_context(ctx_obj: dict[str, Any], previous_session: Any | None) -> None:
82
+ """Restore slash session context after operation.
83
+
84
+ Args:
85
+ ctx_obj: Click context obj dictionary.
86
+ previous_session: Previous session to restore, or None to remove.
87
+ """
88
+ if previous_session is None:
89
+ ctx_obj.pop("_slash_session", None)
90
+ else:
91
+ ctx_obj["_slash_session"] = previous_session
92
+
93
+
94
+ def handle_best_effort_check(
95
+ check_func: Callable[[], None],
96
+ ) -> None:
97
+ """Handle best-effort duplicate/existence checks with proper exception handling.
98
+
99
+ Args:
100
+ check_func: Function that performs the check and raises ClickException if duplicate found.
101
+ """
102
+ try:
103
+ check_func()
104
+ except click.ClickException:
105
+ raise
106
+ except Exception:
107
+ # Non-fatal: best-effort duplicate check
108
+ pass
109
+
110
+
111
+ def prompt_export_choice_questionary(
112
+ default_path: Path,
113
+ default_display: str,
114
+ ) -> tuple[str, Path | None] | None:
115
+ """Prompt user for export destination using questionary with numeric shortcuts.
116
+
117
+ Args:
118
+ default_path: Default export path.
119
+ default_display: Formatted display string for default path.
120
+
121
+ Returns:
122
+ Tuple of (choice, path) or None if cancelled/unavailable.
123
+ Choice can be "default", "custom", or "cancel".
124
+ """
125
+ # Import here for optional dependency (questionary may not be installed)
126
+ try: # pragma: no cover - optional dependency
127
+ import questionary
128
+ from questionary import Choice
129
+ except Exception: # pragma: no cover - optional dependency
130
+ return None
131
+
132
+ if questionary is None or Choice is None:
133
+ return None
134
+
135
+ try:
136
+ question = questionary.select(
137
+ "Export transcript",
138
+ choices=[
139
+ Choice(
140
+ title=f"Save to default ({default_display})",
141
+ value=("default", default_path),
142
+ shortcut_key="1",
143
+ ),
144
+ Choice(
145
+ title="Choose a different path",
146
+ value=("custom", None),
147
+ shortcut_key="2",
148
+ ),
149
+ Choice(
150
+ title="Cancel",
151
+ value=("cancel", None),
152
+ shortcut_key="3",
153
+ ),
154
+ ],
155
+ use_shortcuts=True,
156
+ instruction="Press 1-3 (or arrows) then Enter.",
157
+ )
158
+ answer = questionary_safe_ask(question)
159
+ except Exception:
160
+ return None
161
+
162
+ if answer is None:
163
+ return ("cancel", None)
164
+ return answer
165
+
166
+
167
+ def questionary_safe_ask(question: Any, *, patch_stdout: bool = False) -> Any:
168
+ """Run `questionary.Question` safely even when an asyncio loop is active."""
169
+ ask_fn = getattr(question, "unsafe_ask", None)
170
+ if not callable(ask_fn):
171
+ raise RuntimeError("Questionary prompt is missing unsafe_ask()")
172
+
173
+ if not _asyncio_loop_running():
174
+ return ask_fn(patch_stdout=patch_stdout)
175
+
176
+ return _run_questionary_in_thread(question, patch_stdout=patch_stdout)
177
+
178
+
179
+ def _asyncio_loop_running() -> bool:
180
+ """Return True when an asyncio event loop is already running."""
181
+ try:
182
+ asyncio.get_running_loop()
183
+ except RuntimeError:
184
+ return False
185
+ return True
186
+
187
+
188
+ def _run_questionary_in_thread(question: Any, *, patch_stdout: bool = False) -> Any:
189
+ """Execute a questionary prompt in a background thread."""
190
+ if getattr(question, "should_skip_question", False):
191
+ return getattr(question, "default", None)
192
+
193
+ application = getattr(question, "application", None)
194
+ run_callable = getattr(application, "run", None) if application is not None else None
195
+ if callable(run_callable):
196
+ try:
197
+ if patch_stdout:
198
+ from prompt_toolkit.patch_stdout import patch_stdout as pt_patch_stdout
199
+
200
+ with pt_patch_stdout():
201
+ return run_callable(in_thread=True)
202
+ return run_callable(in_thread=True)
203
+ except TypeError:
204
+ pass
205
+
206
+ return question.unsafe_ask(patch_stdout=patch_stdout)
207
+
208
+
209
+ class _LiteralYamlDumper(yaml.SafeDumper):
210
+ """YAML dumper that emits literal scalars for multiline strings."""
211
+
212
+
213
+ def _literal_str_representer(dumper: yaml.Dumper, data: str) -> yaml.nodes.ScalarNode:
214
+ """Represent strings in YAML, using literal blocks for verbose values."""
215
+ needs_literal = "\n" in data or "\r" in data
216
+ if not needs_literal and LITERAL_STRING_THRESHOLD and len(data) >= LITERAL_STRING_THRESHOLD: # pragma: no cover
217
+ needs_literal = True
218
+
219
+ style = "|" if needs_literal else None
220
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
221
+
222
+
223
+ _LiteralYamlDumper.add_representer(str, _literal_str_representer)
36
224
 
37
225
  # Optional interactive deps (fuzzy palette)
38
226
  try:
@@ -56,22 +244,6 @@ except Exception: # pragma: no cover - optional dependency
56
244
 
57
245
  if TYPE_CHECKING: # pragma: no cover - import-only during type checking
58
246
  from glaip_sdk import Client
59
- from glaip_sdk.cli import masking, pager
60
- from glaip_sdk.cli.config import load_config
61
- from glaip_sdk.cli.context import (
62
- _get_view,
63
- get_ctx_value,
64
- )
65
- from glaip_sdk.cli.context import (
66
- detect_export_format as _detect_export_format,
67
- )
68
- from glaip_sdk.rich_components import AIPTable
69
- from glaip_sdk.utils import is_uuid
70
- from glaip_sdk.utils.rendering.renderer import (
71
- CapturingConsole,
72
- RendererConfig,
73
- RichStreamRenderer,
74
- )
75
247
 
76
248
  console = Console()
77
249
  pager.console = console
@@ -144,8 +316,6 @@ def format_datetime_fields(
144
316
  Returns:
145
317
  New dictionary with formatted datetime fields
146
318
  """
147
- from glaip_sdk.utils import format_datetime
148
-
149
319
  formatted = data.copy()
150
320
  for field in fields:
151
321
  if field in formatted:
@@ -208,10 +378,6 @@ def handle_resource_export(
208
378
  get_by_id_func: Function to fetch resource by ID
209
379
  console_override: Optional console override
210
380
  """
211
- from glaip_sdk.cli.display import handle_rich_output
212
- from glaip_sdk.cli.io import export_resource_to_file_with_validation
213
- from glaip_sdk.cli.rich_helpers import print_markup
214
-
215
381
  active_console = console_override or console
216
382
 
217
383
  # Auto-detect format from file extension
@@ -235,6 +401,8 @@ def handle_resource_export(
235
401
  ):
236
402
  export_resource_to_file_with_validation(full_resource, export_path, detected_format)
237
403
  except Exception:
404
+ from glaip_sdk.cli.display import handle_rich_output # noqa: E402 - avoid circular import
405
+
238
406
  handle_rich_output(
239
407
  ctx,
240
408
  markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
@@ -329,6 +497,28 @@ def sdk_version() -> str:
329
497
  return "0.0.0"
330
498
 
331
499
 
500
+ @contextmanager
501
+ def with_client_and_spinner(
502
+ ctx: Any,
503
+ spinner_message: str,
504
+ *,
505
+ console_override: Console | None = None,
506
+ ) -> Any:
507
+ """Context manager for commands that need client and spinner.
508
+
509
+ Args:
510
+ ctx: Click context.
511
+ spinner_message: Message to display in spinner.
512
+ console_override: Optional console override.
513
+
514
+ Yields:
515
+ Client instance.
516
+ """
517
+ client = get_client(ctx)
518
+ with spinner_context(ctx, spinner_message, console_override=console_override):
519
+ yield client
520
+
521
+
332
522
  def spinner_context(
333
523
  ctx: Any | None,
334
524
  message: str,
@@ -602,6 +792,7 @@ def _prompt_with_auto_select(
602
792
  valid_choices = set(choices)
603
793
 
604
794
  def _auto_select(_: Buffer) -> None:
795
+ """Auto-select text when a valid choice is entered."""
605
796
  text = buffer.text
606
797
  if not text or text not in valid_choices:
607
798
  return
@@ -635,9 +826,23 @@ class _FuzzyCompleter:
635
826
  """Fuzzy completer for prompt_toolkit."""
636
827
 
637
828
  def __init__(self, words: list[str]) -> None:
829
+ """Initialize fuzzy completer with word list.
830
+
831
+ Args:
832
+ words: List of words to complete from.
833
+ """
638
834
  self.words = words
639
835
 
640
836
  def get_completions(self, document: Any, _complete_event: Any) -> Any: # pragma: no cover
837
+ """Get fuzzy completions for the current word.
838
+
839
+ Args:
840
+ document: Document object from prompt_toolkit.
841
+ _complete_event: Completion event (unused).
842
+
843
+ Yields:
844
+ Completion objects matching the current word.
845
+ """
641
846
  word = document.get_word_before_cursor()
642
847
  if not word:
643
848
  return
@@ -769,7 +974,7 @@ def _fuzzy_score(search: str, target: str) -> int:
769
974
  return score
770
975
 
771
976
 
772
- # ----------------------------- Pretty outputs ---------------------------- #
977
+ # ----------------------- Structured renderer helpers -------------------- #
773
978
 
774
979
 
775
980
  def _coerce_result_payload(result: Any) -> Any:
@@ -811,6 +1016,37 @@ def _render_markdown_output(data: Any) -> None:
811
1016
  click.echo(str(data))
812
1017
 
813
1018
 
1019
+ def _format_yaml_text(data: Any) -> str:
1020
+ """Convert structured payloads to YAML for readability."""
1021
+ try:
1022
+ yaml_text = yaml.dump(
1023
+ data,
1024
+ sort_keys=False,
1025
+ default_flow_style=False,
1026
+ allow_unicode=True,
1027
+ Dumper=_LiteralYamlDumper,
1028
+ )
1029
+ except Exception: # pragma: no cover - defensive YAML fallback
1030
+ try:
1031
+ return str(data)
1032
+ except Exception: # pragma: no cover - defensive str fallback
1033
+ return repr(data)
1034
+
1035
+ yaml_text = yaml_text.rstrip()
1036
+ if yaml_text.endswith("..."): # pragma: no cover - defensive YAML cleanup
1037
+ yaml_text = yaml_text[:-3].rstrip()
1038
+ return yaml_text
1039
+
1040
+
1041
+ def _build_yaml_renderable(data: Any) -> Any:
1042
+ """Return a syntax-highlighted YAML renderable when possible."""
1043
+ yaml_text = _format_yaml_text(data) or "# No data"
1044
+ try:
1045
+ return Syntax(yaml_text, "yaml", word_wrap=False)
1046
+ except Exception: # pragma: no cover - defensive syntax highlighting fallback
1047
+ return yaml_text
1048
+
1049
+
814
1050
  def output_result(
815
1051
  ctx: Any,
816
1052
  result: Any,
@@ -843,7 +1079,7 @@ def output_result(
843
1079
  _render_markdown_output(data)
844
1080
  return
845
1081
 
846
- renderable = Pretty(data)
1082
+ renderable = _build_yaml_renderable(data)
847
1083
  if panel_title:
848
1084
  console.print(AIPPanel(renderable, title=panel_title))
849
1085
  else:
@@ -854,7 +1090,7 @@ def output_result(
854
1090
  # ----------------------------- List rendering ---------------------------- #
855
1091
 
856
1092
  # Threshold no longer used - fuzzy palette is always default for TTY
857
- # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
1093
+ # _PICK_THRESHOLD = 5
858
1094
 
859
1095
 
860
1096
  def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None) -> list[dict[str, Any]]:
@@ -902,12 +1138,7 @@ def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[
902
1138
 
903
1139
  def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
904
1140
  """Return True when rows should be name-sorted prior to rendering."""
905
- return (
906
- os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
907
- and rows
908
- and isinstance(rows[0], dict)
909
- and "name" in rows[0]
910
- )
1141
+ return TABLE_SORT_ENABLED and rows and isinstance(rows[0], dict) and "name" in rows[0]
911
1142
 
912
1143
 
913
1144
  def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
@@ -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"]
@@ -209,6 +209,11 @@ class AgentListParams:
209
209
  return params
210
210
 
211
211
  def _base_filter_params(self) -> dict[str, Any]:
212
+ """Build base filter parameters from non-None fields.
213
+
214
+ Returns:
215
+ Dictionary of filter parameters with non-None values.
216
+ """
212
217
  return {
213
218
  key: value
214
219
  for key, value in (
@@ -221,6 +226,11 @@ class AgentListParams:
221
226
  }
222
227
 
223
228
  def _apply_pagination_params(self, params: dict[str, Any]) -> None:
229
+ """Apply pagination parameters to the params dictionary.
230
+
231
+ Args:
232
+ params: Dictionary to update with pagination parameters.
233
+ """
224
234
  if self.limit is not None:
225
235
  if not 1 <= self.limit <= 100:
226
236
  raise ValueError("limit must be between 1 and 100 inclusive")
@@ -238,6 +248,11 @@ class AgentListParams:
238
248
  params["sync_langflow_agents"] = str(self.sync_langflow_agents).lower()
239
249
 
240
250
  def _apply_timestamp_filters(self, params: dict[str, Any]) -> None:
251
+ """Apply timestamp filter parameters to the params dictionary.
252
+
253
+ Args:
254
+ params: Dictionary to update with timestamp filter parameters.
255
+ """
241
256
  timestamp_filters = {
242
257
  "created_at_start": self.created_at_start,
243
258
  "created_at_end": self.created_at_end,
@@ -249,6 +264,11 @@ class AgentListParams:
249
264
  params[key] = value
250
265
 
251
266
  def _apply_metadata_filters(self, params: dict[str, Any]) -> None:
267
+ """Apply metadata filter parameters to the params dictionary.
268
+
269
+ Args:
270
+ params: Dictionary to update with metadata filter parameters.
271
+ """
252
272
  if not self.metadata:
253
273
  return
254
274
  for key, value in self.metadata.items():
@@ -269,12 +289,22 @@ class AgentListResult:
269
289
  message: str | None = None
270
290
 
271
291
  def __len__(self) -> int: # pragma: no cover - simple delegation
292
+ """Return the number of items in the result list."""
272
293
  return len(self.items)
273
294
 
274
295
  def __iter__(self): # pragma: no cover - simple delegation
296
+ """Return an iterator over the items in the result list."""
275
297
  return iter(self.items)
276
298
 
277
299
  def __getitem__(self, index: int) -> Any: # pragma: no cover - simple delegation
300
+ """Get an item from the result list by index.
301
+
302
+ Args:
303
+ index: Index of the item to retrieve.
304
+
305
+ Returns:
306
+ The item at the specified index.
307
+ """
278
308
  return self.items[index]
279
309
 
280
310
 
@@ -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)