glaip-sdk 0.2.2__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 (38) hide show
  1. glaip_sdk/cli/commands/agents.py +50 -35
  2. glaip_sdk/cli/commands/mcps.py +28 -18
  3. glaip_sdk/cli/commands/models.py +3 -5
  4. glaip_sdk/cli/commands/tools.py +27 -16
  5. glaip_sdk/cli/constants.py +3 -0
  6. glaip_sdk/cli/main.py +1 -3
  7. glaip_sdk/cli/slash/agent_session.py +3 -13
  8. glaip_sdk/cli/slash/prompt.py +3 -0
  9. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  10. glaip_sdk/cli/slash/session.py +138 -47
  11. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  12. glaip_sdk/cli/slash/tui/remote_runs_app.py +632 -0
  13. glaip_sdk/cli/transcript/viewer.py +6 -32
  14. glaip_sdk/cli/utils.py +183 -9
  15. glaip_sdk/cli/validators.py +5 -6
  16. glaip_sdk/client/__init__.py +2 -1
  17. glaip_sdk/client/agent_runs.py +147 -0
  18. glaip_sdk/client/agents.py +42 -22
  19. glaip_sdk/client/main.py +18 -6
  20. glaip_sdk/client/mcps.py +2 -4
  21. glaip_sdk/client/tools.py +2 -3
  22. glaip_sdk/config/constants.py +11 -0
  23. glaip_sdk/models/__init__.py +56 -0
  24. glaip_sdk/models/agent_runs.py +117 -0
  25. glaip_sdk/rich_components.py +58 -2
  26. glaip_sdk/utils/client_utils.py +13 -0
  27. glaip_sdk/utils/export.py +143 -0
  28. glaip_sdk/utils/import_export.py +6 -9
  29. glaip_sdk/utils/rendering/__init__.py +122 -1
  30. glaip_sdk/utils/rendering/renderer/base.py +3 -7
  31. glaip_sdk/utils/rendering/renderer/debug.py +0 -1
  32. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  33. glaip_sdk/utils/rendering/steps.py +1 -0
  34. glaip_sdk/utils/resource_refs.py +26 -15
  35. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.3.0.dist-info}/METADATA +24 -2
  36. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.3.0.dist-info}/RECORD +38 -31
  37. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.3.0.dist-info}/WHEEL +0 -0
  38. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.3.0.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -12,8 +12,9 @@ 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
 
@@ -39,10 +40,11 @@ from glaip_sdk.cli.context import (
39
40
  detect_export_format as _detect_export_format,
40
41
  get_ctx_value,
41
42
  )
42
- from glaip_sdk.cli.rich_helpers import markup_text
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
43
45
  from glaip_sdk.icons import ICON_AGENT
44
46
  from glaip_sdk.rich_components import AIPPanel, AIPTable
45
- from glaip_sdk.utils import is_uuid
47
+ from glaip_sdk.utils import format_datetime, is_uuid
46
48
  from glaip_sdk.utils.rendering.renderer import (
47
49
  CapturingConsole,
48
50
  RendererConfig,
@@ -50,6 +52,160 @@ from glaip_sdk.utils.rendering.renderer import (
50
52
  )
51
53
 
52
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
+
53
209
  class _LiteralYamlDumper(yaml.SafeDumper):
54
210
  """YAML dumper that emits literal scalars for multiline strings."""
55
211
 
@@ -160,8 +316,6 @@ def format_datetime_fields(
160
316
  Returns:
161
317
  New dictionary with formatted datetime fields
162
318
  """
163
- from glaip_sdk.utils import format_datetime
164
-
165
319
  formatted = data.copy()
166
320
  for field in fields:
167
321
  if field in formatted:
@@ -224,10 +378,6 @@ def handle_resource_export(
224
378
  get_by_id_func: Function to fetch resource by ID
225
379
  console_override: Optional console override
226
380
  """
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
381
  active_console = console_override or console
232
382
 
233
383
  # Auto-detect format from file extension
@@ -251,6 +401,8 @@ def handle_resource_export(
251
401
  ):
252
402
  export_resource_to_file_with_validation(full_resource, export_path, detected_format)
253
403
  except Exception:
404
+ from glaip_sdk.cli.display import handle_rich_output # noqa: E402 - avoid circular import
405
+
254
406
  handle_rich_output(
255
407
  ctx,
256
408
  markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
@@ -345,6 +497,28 @@ def sdk_version() -> str:
345
497
  return "0.0.0"
346
498
 
347
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
+
348
522
  def spinner_context(
349
523
  ctx: Any | None,
350
524
  message: str,
@@ -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)
@@ -5,9 +5,11 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ import asyncio
8
9
  import json
9
10
  import logging
10
11
  from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
12
+ from contextlib import asynccontextmanager
11
13
  from os import PathLike
12
14
  from pathlib import Path
13
15
  from typing import Any, BinaryIO
@@ -20,6 +22,7 @@ from glaip_sdk.client._agent_payloads import (
20
22
  AgentListResult,
21
23
  AgentUpdateRequest,
22
24
  )
25
+ from glaip_sdk.client.agent_runs import AgentRunsClient
23
26
  from glaip_sdk.client.base import BaseClient
24
27
  from glaip_sdk.client.mcps import MCPClient
25
28
  from glaip_sdk.client.run_rendering import (
@@ -28,6 +31,7 @@ from glaip_sdk.client.run_rendering import (
28
31
  )
29
32
  from glaip_sdk.client.tools import ToolClient
30
33
  from glaip_sdk.config.constants import (
34
+ AGENT_CONFIG_FIELDS,
31
35
  DEFAULT_AGENT_FRAMEWORK,
32
36
  DEFAULT_AGENT_RUN_TIMEOUT,
33
37
  DEFAULT_AGENT_TYPE,
@@ -67,6 +71,19 @@ _MERGED_SEQUENCE_FIELDS = ("tools", "agents", "mcps")
67
71
  _DEFAULT_METADATA_TYPE = "custom"
68
72
 
69
73
 
74
+ @asynccontextmanager
75
+ async def _async_timeout_guard(timeout_seconds: float | None) -> AsyncGenerator[None, None]:
76
+ """Apply an asyncio timeout when a custom timeout is provided."""
77
+ if timeout_seconds is None:
78
+ yield
79
+ return
80
+ try:
81
+ async with asyncio.timeout(timeout_seconds):
82
+ yield
83
+ except asyncio.TimeoutError as exc:
84
+ raise httpx.TimeoutException(f"Request timed out after {timeout_seconds}s") from exc
85
+
86
+
70
87
  def _normalise_sequence(value: Any) -> list[Any] | None:
71
88
  """Normalise optional sequence inputs to plain lists."""
72
89
  if value is None:
@@ -193,19 +210,7 @@ def _extract_original_refs(raw_definition: dict) -> dict[str, list]:
193
210
 
194
211
  def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
195
212
  """Build CLI args from overrides, filtering out None values."""
196
- cli_args = {
197
- key: overrides_dict.get(key)
198
- for key in (
199
- "name",
200
- "instruction",
201
- "model",
202
- "tools",
203
- "agents",
204
- "mcps",
205
- "timeout",
206
- )
207
- if overrides_dict.get(key) is not None
208
- }
213
+ cli_args = {key: overrides_dict.get(key) for key in AGENT_CONFIG_FIELDS if overrides_dict.get(key) is not None}
209
214
 
210
215
  # Normalize sequence fields
211
216
  for field in _MERGED_SEQUENCE_FIELDS:
@@ -254,6 +259,7 @@ class AgentClient(BaseClient):
254
259
  self._renderer_manager = AgentRunRenderingManager(logger)
255
260
  self._tool_client: ToolClient | None = None
256
261
  self._mcp_client: MCPClient | None = None
262
+ self._runs_client: "AgentRunsClient | None" = None
257
263
 
258
264
  def list_agents(
259
265
  self,
@@ -1172,7 +1178,7 @@ class AgentClient(BaseClient):
1172
1178
  message: str,
1173
1179
  files: list[str | BinaryIO] | None = None,
1174
1180
  *,
1175
- timeout: float | None = None,
1181
+ request_timeout: float | None = None,
1176
1182
  **kwargs,
1177
1183
  ) -> AsyncGenerator[dict, None]:
1178
1184
  """Async run an agent with a message, yielding streaming JSON chunks.
@@ -1181,7 +1187,7 @@ class AgentClient(BaseClient):
1181
1187
  agent_id: ID of the agent to run
1182
1188
  message: Message to send to the agent
1183
1189
  files: Optional list of files to include
1184
- timeout: Request timeout in seconds
1190
+ request_timeout: Optional request timeout in seconds (defaults to client timeout)
1185
1191
  **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
1186
1192
 
1187
1193
  Yields:
@@ -1192,18 +1198,22 @@ class AgentClient(BaseClient):
1192
1198
  httpx.TimeoutException: When general timeout occurs
1193
1199
  Exception: For other unexpected errors
1194
1200
  """
1201
+ # Derive timeout values for request/control flow
1202
+ legacy_timeout = kwargs.get("timeout")
1203
+ http_timeout_override = request_timeout if request_timeout is not None else legacy_timeout
1204
+ http_timeout = http_timeout_override or self.timeout
1205
+
1195
1206
  # Prepare request data
1196
1207
  payload, data_payload, files_payload, headers = self._prepare_request_data(message, files, **kwargs)
1197
1208
 
1198
1209
  # Create async client configuration
1199
- async_client_config = self._create_async_client_config(timeout, headers)
1210
+ async_client_config = self._create_async_client_config(http_timeout_override, headers)
1200
1211
 
1201
1212
  # Get execution timeout for streaming control
1202
1213
  timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
1203
1214
  agent_name = kwargs.get("agent_name")
1204
1215
 
1205
- try:
1206
- # Create async client and stream response
1216
+ async def _chunk_stream() -> AsyncGenerator[dict, None]:
1207
1217
  async with httpx.AsyncClient(**async_client_config) as async_client:
1208
1218
  async for chunk in self._stream_agent_response(
1209
1219
  async_client,
@@ -1217,7 +1227,17 @@ class AgentClient(BaseClient):
1217
1227
  ):
1218
1228
  yield chunk
1219
1229
 
1220
- finally:
1221
- # Ensure cleanup - this is handled by the calling context
1222
- # but we keep this for safety in case of future changes
1223
- pass
1230
+ async with _async_timeout_guard(http_timeout):
1231
+ async for chunk in _chunk_stream():
1232
+ yield chunk
1233
+
1234
+ @property
1235
+ def runs(self) -> "AgentRunsClient":
1236
+ """Get the agent runs client."""
1237
+ if self._runs_client is None:
1238
+ # Import here to avoid circular dependency with client.main
1239
+ from glaip_sdk.client.main import _build_shared_config
1240
+
1241
+ shared_config = _build_shared_config(self)
1242
+ self._runs_client = AgentRunsClient(**shared_config)
1243
+ return self._runs_client
glaip_sdk/client/main.py CHANGED
@@ -14,6 +14,23 @@ from glaip_sdk.client.tools import ToolClient
14
14
  from glaip_sdk.models import MCP, Agent, Tool
15
15
 
16
16
 
17
+ def _build_shared_config(client: BaseClient) -> dict[str, Any]:
18
+ """Build shared configuration dictionary for sub-clients.
19
+
20
+ Args:
21
+ client: Base client instance.
22
+
23
+ Returns:
24
+ Dictionary with shared configuration.
25
+ """
26
+ return {
27
+ "parent_client": client,
28
+ "api_url": client.api_url,
29
+ "api_key": client.api_key,
30
+ "timeout": client._timeout,
31
+ }
32
+
33
+
17
34
  class Client(BaseClient):
18
35
  """Main client that composes all specialized clients and shares one HTTP session."""
19
36
 
@@ -25,12 +42,7 @@ class Client(BaseClient):
25
42
  """
26
43
  super().__init__(**kwargs)
27
44
  # Share the single httpx.Client + config with sub-clients
28
- shared_config = {
29
- "parent_client": self,
30
- "api_url": self.api_url,
31
- "api_key": self.api_key,
32
- "timeout": self._timeout,
33
- }
45
+ shared_config = _build_shared_config(self)
34
46
  self.agents = AgentClient(**shared_config)
35
47
  self.tools = ToolClient(**shared_config)
36
48
  self.mcps = MCPClient(**shared_config)
glaip_sdk/client/mcps.py CHANGED
@@ -14,7 +14,7 @@ from glaip_sdk.config.constants import (
14
14
  DEFAULT_MCP_TYPE,
15
15
  )
16
16
  from glaip_sdk.models import MCP
17
- from glaip_sdk.utils.client_utils import create_model_instances, find_by_name
17
+ from glaip_sdk.utils.client_utils import add_kwargs_to_payload, create_model_instances, find_by_name
18
18
 
19
19
  # API endpoints
20
20
  MCPS_ENDPOINT = "/mcps/"
@@ -147,9 +147,7 @@ class MCPClient(BaseClient):
147
147
 
148
148
  # Add any other kwargs (excluding already handled ones)
149
149
  excluded_keys = {"type"} # type is handled above
150
- for key, value in kwargs.items():
151
- if key not in excluded_keys:
152
- payload[key] = value
150
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
153
151
 
154
152
  return payload
155
153
 
glaip_sdk/client/tools.py CHANGED
@@ -18,6 +18,7 @@ from glaip_sdk.config.constants import (
18
18
  )
19
19
  from glaip_sdk.models import Tool
20
20
  from glaip_sdk.utils.client_utils import (
21
+ add_kwargs_to_payload,
21
22
  create_model_instances,
22
23
  find_by_name,
23
24
  )
@@ -200,9 +201,7 @@ class ToolClient(BaseClient):
200
201
 
201
202
  # Add any other kwargs (excluding already handled ones)
202
203
  excluded_keys = {"tags", "version"}
203
- for key, value in kwargs.items():
204
- if key not in excluded_keys:
205
- payload[key] = value
204
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
206
205
 
207
206
  return payload
208
207
 
@@ -39,3 +39,14 @@ DEFAULT_MCP_TRANSPORT = "stdio"
39
39
 
40
40
  # Default error messages
41
41
  DEFAULT_ERROR_MESSAGE = "Unknown error"
42
+
43
+ # Agent configuration fields used for CLI args and payload building
44
+ AGENT_CONFIG_FIELDS = (
45
+ "name",
46
+ "instruction",
47
+ "model",
48
+ "tools",
49
+ "agents",
50
+ "mcps",
51
+ "timeout",
52
+ )