glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +265 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -3,39 +3,45 @@
3
3
 
4
4
  Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
6
7
  """
7
8
 
9
+ import asyncio
8
10
  import json
9
11
  import logging
10
12
  from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
13
+ from contextlib import asynccontextmanager
11
14
  from os import PathLike
12
15
  from pathlib import Path
13
16
  from typing import Any, BinaryIO
14
17
 
15
18
  import httpx
16
-
19
+ from glaip_sdk.agents import Agent
17
20
  from glaip_sdk.client._agent_payloads import (
18
21
  AgentCreateRequest,
19
22
  AgentListParams,
20
23
  AgentListResult,
21
24
  AgentUpdateRequest,
22
25
  )
26
+ from glaip_sdk.client.agent_runs import AgentRunsClient
23
27
  from glaip_sdk.client.base import BaseClient
24
28
  from glaip_sdk.client.mcps import MCPClient
25
29
  from glaip_sdk.client.run_rendering import (
26
30
  AgentRunRenderingManager,
27
31
  compute_timeout_seconds,
28
32
  )
33
+ from glaip_sdk.client.shared import build_shared_config
29
34
  from glaip_sdk.client.tools import ToolClient
30
35
  from glaip_sdk.config.constants import (
36
+ AGENT_CONFIG_FIELDS,
31
37
  DEFAULT_AGENT_FRAMEWORK,
32
38
  DEFAULT_AGENT_RUN_TIMEOUT,
33
39
  DEFAULT_AGENT_TYPE,
34
40
  DEFAULT_AGENT_VERSION,
35
41
  DEFAULT_MODEL,
36
42
  )
37
- from glaip_sdk.exceptions import NotFoundError
38
- from glaip_sdk.models import Agent
43
+ from glaip_sdk.exceptions import NotFoundError, ValidationError
44
+ from glaip_sdk.models import AgentResponse
39
45
  from glaip_sdk.payload_schemas.agent import list_server_only_fields
40
46
  from glaip_sdk.utils.agent_config import normalize_agent_config_for_import
41
47
  from glaip_sdk.utils.client_utils import (
@@ -67,6 +73,21 @@ _MERGED_SEQUENCE_FIELDS = ("tools", "agents", "mcps")
67
73
  _DEFAULT_METADATA_TYPE = "custom"
68
74
 
69
75
 
76
+ @asynccontextmanager
77
+ async def _async_timeout_guard(
78
+ timeout_seconds: float | None,
79
+ ) -> AsyncGenerator[None, None]:
80
+ """Apply an asyncio timeout when a custom timeout is provided."""
81
+ if timeout_seconds is None:
82
+ yield
83
+ return
84
+ try:
85
+ async with asyncio.timeout(timeout_seconds):
86
+ yield
87
+ except asyncio.TimeoutError as exc:
88
+ raise httpx.TimeoutException(f"Request timed out after {timeout_seconds}s") from exc
89
+
90
+
70
91
  def _normalise_sequence(value: Any) -> list[Any] | None:
71
92
  """Normalise optional sequence inputs to plain lists."""
72
93
  if value is None:
@@ -97,9 +118,7 @@ def _merge_override_maps(
97
118
  for key, value in source.items():
98
119
  if value is None:
99
120
  continue
100
- merged[key] = (
101
- _normalise_sequence(value) if key in _MERGED_SEQUENCE_FIELDS else value
102
- )
121
+ merged[key] = _normalise_sequence(value) if key in _MERGED_SEQUENCE_FIELDS else value
103
122
  return merged
104
123
 
105
124
 
@@ -134,9 +153,7 @@ def _prepare_agent_metadata(value: Any) -> dict[str, Any]:
134
153
  return prepared
135
154
 
136
155
 
137
- def _load_agent_file_payload(
138
- file_path: Path, *, model_override: str | None
139
- ) -> dict[str, Any]:
156
+ def _load_agent_file_payload(file_path: Path, *, model_override: str | None) -> dict[str, Any]:
140
157
  """Load agent configuration from disk and normalise legacy fields."""
141
158
  if not file_path.exists():
142
159
  raise FileNotFoundError(f"Agent configuration file not found: {file_path}")
@@ -168,9 +185,7 @@ def _prepare_import_payload(
168
185
  raw_definition = load_resource_from_file(file_path)
169
186
  original_refs = _extract_original_refs(raw_definition)
170
187
 
171
- base_payload = _load_agent_file_payload(
172
- file_path, model_override=overrides_dict.get("model")
173
- )
188
+ base_payload = _load_agent_file_payload(file_path, model_override=overrides_dict.get("model"))
174
189
 
175
190
  cli_args = _build_cli_args(overrides_dict)
176
191
 
@@ -199,19 +214,7 @@ def _extract_original_refs(raw_definition: dict) -> dict[str, list]:
199
214
 
200
215
  def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
201
216
  """Build CLI args from overrides, filtering out None values."""
202
- cli_args = {
203
- key: overrides_dict.get(key)
204
- for key in (
205
- "name",
206
- "instruction",
207
- "model",
208
- "tools",
209
- "agents",
210
- "mcps",
211
- "timeout",
212
- )
213
- if overrides_dict.get(key) is not None
214
- }
217
+ cli_args = {key: overrides_dict.get(key) for key in AGENT_CONFIG_FIELDS if overrides_dict.get(key) is not None}
215
218
 
216
219
  # Normalize sequence fields
217
220
  for field in _MERGED_SEQUENCE_FIELDS:
@@ -223,11 +226,7 @@ def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
223
226
 
224
227
  def _build_additional_args(overrides_dict: dict, cli_args: dict) -> dict[str, Any]:
225
228
  """Build additional args not already in CLI args."""
226
- return {
227
- key: value
228
- for key, value in overrides_dict.items()
229
- if value is not None and key not in cli_args
230
- }
229
+ return {key: value for key, value in overrides_dict.items() if value is not None and key not in cli_args}
231
230
 
232
231
 
233
232
  def _remove_model_fields_if_needed(merged: dict, overrides_dict: dict) -> None:
@@ -264,6 +263,7 @@ class AgentClient(BaseClient):
264
263
  self._renderer_manager = AgentRunRenderingManager(logger)
265
264
  self._tool_client: ToolClient | None = None
266
265
  self._mcp_client: MCPClient | None = None
266
+ self._runs_client: AgentRunsClient | None = None
267
267
 
268
268
  def list_agents(
269
269
  self,
@@ -278,9 +278,7 @@ class AgentClient(BaseClient):
278
278
  """
279
279
  if query is not None and kwargs:
280
280
  # Both query object and individual parameters provided
281
- raise ValueError(
282
- "Provide either `query` or individual filter arguments, not both."
283
- )
281
+ raise ValueError("Provide either `query` or individual filter arguments, not both.")
284
282
 
285
283
  if query is None:
286
284
  # Create query from individual parameters for backward compatibility
@@ -339,7 +337,19 @@ class AgentClient(BaseClient):
339
337
 
340
338
  def get_agent_by_id(self, agent_id: str) -> Agent:
341
339
  """Get agent by ID."""
342
- data = self._request("GET", f"/agents/{agent_id}")
340
+ try:
341
+ data = self._request("GET", f"/agents/{agent_id}")
342
+ except ValidationError as exc:
343
+ if exc.status_code == 422:
344
+ message = f"Agent '{agent_id}' not found"
345
+ raise NotFoundError(
346
+ message,
347
+ status_code=404,
348
+ error_type=exc.error_type,
349
+ payload=exc.payload,
350
+ request_id=exc.request_id,
351
+ ) from exc
352
+ raise
343
353
 
344
354
  if isinstance(data, str):
345
355
  # Some backends may respond with plain text for missing agents.
@@ -352,7 +362,8 @@ class AgentClient(BaseClient):
352
362
  status_code=404,
353
363
  )
354
364
 
355
- return Agent(**data)._set_client(self)
365
+ response = AgentResponse(**data)
366
+ return Agent.from_response(response, client=self)
356
367
 
357
368
  def find_agents(self, name: str | None = None) -> list[Agent]:
358
369
  """Find agents by name."""
@@ -366,15 +377,27 @@ class AgentClient(BaseClient):
366
377
  # Renderer delegation helpers
367
378
  # ------------------------------------------------------------------ #
368
379
  def _get_renderer_manager(self) -> AgentRunRenderingManager:
380
+ """Get or create the renderer manager instance.
381
+
382
+ Returns:
383
+ AgentRunRenderingManager instance.
384
+ """
369
385
  manager = getattr(self, "_renderer_manager", None)
370
386
  if manager is None:
371
387
  manager = AgentRunRenderingManager(logger)
372
388
  self._renderer_manager = manager
373
389
  return manager
374
390
 
375
- def _create_renderer(
376
- self, renderer: RichStreamRenderer | str | None, **kwargs: Any
377
- ) -> RichStreamRenderer:
391
+ def _create_renderer(self, renderer: RichStreamRenderer | str | None, **kwargs: Any) -> RichStreamRenderer:
392
+ """Create or return a renderer instance.
393
+
394
+ Args:
395
+ renderer: Renderer instance, string identifier, or None.
396
+ **kwargs: Additional keyword arguments (e.g., verbose).
397
+
398
+ Returns:
399
+ RichStreamRenderer instance.
400
+ """
378
401
  manager = self._get_renderer_manager()
379
402
  verbose = kwargs.get("verbose", False)
380
403
  if isinstance(renderer, RichStreamRenderer) or hasattr(renderer, "on_start"):
@@ -389,6 +412,18 @@ class AgentClient(BaseClient):
389
412
  agent_name: str | None,
390
413
  meta: dict[str, Any],
391
414
  ) -> tuple[str, dict[str, Any], float | None, float | None]:
415
+ """Process stream events from an HTTP response.
416
+
417
+ Args:
418
+ stream_response: HTTP response stream.
419
+ renderer: Renderer to use for displaying events.
420
+ timeout_seconds: Timeout in seconds.
421
+ agent_name: Optional agent name.
422
+ meta: Metadata dictionary.
423
+
424
+ Returns:
425
+ Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
426
+ """
392
427
  manager = self._get_renderer_manager()
393
428
  return manager.process_stream_events(
394
429
  stream_response,
@@ -406,6 +441,18 @@ class AgentClient(BaseClient):
406
441
  started_monotonic: float | None,
407
442
  finished_monotonic: float | None,
408
443
  ) -> str:
444
+ """Finalize the renderer and return the final response text.
445
+
446
+ Args:
447
+ renderer: Renderer to finalize.
448
+ final_text: Final text content.
449
+ stats_usage: Usage statistics dictionary.
450
+ started_monotonic: Start time (monotonic).
451
+ finished_monotonic: Finish time (monotonic).
452
+
453
+ Returns:
454
+ Final text string.
455
+ """
409
456
  manager = self._get_renderer_manager()
410
457
  return manager.finalize_renderer(
411
458
  renderer,
@@ -416,11 +463,21 @@ class AgentClient(BaseClient):
416
463
  )
417
464
 
418
465
  def _get_tool_client(self) -> ToolClient:
466
+ """Get or create the tool client instance.
467
+
468
+ Returns:
469
+ ToolClient instance.
470
+ """
419
471
  if self._tool_client is None:
420
472
  self._tool_client = ToolClient(parent_client=self)
421
473
  return self._tool_client
422
474
 
423
475
  def _get_mcp_client(self) -> MCPClient:
476
+ """Get or create the MCP client instance.
477
+
478
+ Returns:
479
+ MCPClient instance.
480
+ """
424
481
  if self._mcp_client is None:
425
482
  self._mcp_client = MCPClient(parent_client=self)
426
483
  return self._mcp_client
@@ -430,6 +487,15 @@ class AgentClient(BaseClient):
430
487
  entry: Any,
431
488
  fallback_iter: Iterator[Any] | None,
432
489
  ) -> tuple[str | None, str | None]:
490
+ """Normalize a reference entry to extract ID and name.
491
+
492
+ Args:
493
+ entry: Reference entry (string, dict, or other).
494
+ fallback_iter: Optional iterator for fallback values.
495
+
496
+ Returns:
497
+ Tuple of (entry_id, entry_name).
498
+ """
433
499
  entry_id: str | None = None
434
500
  entry_name: str | None = None
435
501
 
@@ -466,6 +532,19 @@ class AgentClient(BaseClient):
466
532
  label: str,
467
533
  plural_label: str | None = None,
468
534
  ) -> list[str] | None:
535
+ """Resolve a list of resource references to IDs.
536
+
537
+ Args:
538
+ items: List of resource references to resolve.
539
+ references: Optional list of reference objects for fallback.
540
+ fetch_by_id: Function to fetch resource by ID.
541
+ find_by_name: Function to find resources by name.
542
+ label: Singular label for error messages.
543
+ plural_label: Plural label for error messages.
544
+
545
+ Returns:
546
+ List of resolved resource IDs, or None if items is empty.
547
+ """
469
548
  if not items:
470
549
  return None
471
550
 
@@ -497,6 +576,22 @@ class AgentClient(BaseClient):
497
576
  singular: str,
498
577
  plural: str,
499
578
  ) -> str:
579
+ """Resolve a single resource reference to an ID.
580
+
581
+ Args:
582
+ entry: Resource reference to resolve.
583
+ fallback_iter: Optional iterator for fallback values.
584
+ fetch_by_id: Function to fetch resource by ID.
585
+ find_by_name: Function to find resources by name.
586
+ singular: Singular label for error messages.
587
+ plural: Plural label for error messages.
588
+
589
+ Returns:
590
+ Resolved resource ID string.
591
+
592
+ Raises:
593
+ ValueError: If the resource cannot be resolved.
594
+ """
500
595
  entry_id, entry_name = self._normalise_reference_entry(entry, fallback_iter)
501
596
 
502
597
  validated_id = self._validate_resource_id(fetch_by_id, entry_id)
@@ -506,15 +601,21 @@ class AgentClient(BaseClient):
506
601
  return entry_id
507
602
 
508
603
  if entry_name:
509
- resolved, success = self._resolve_resource_by_name(
510
- find_by_name, entry_name, singular, plural
511
- )
604
+ resolved, success = self._resolve_resource_by_name(find_by_name, entry_name, singular, plural)
512
605
  return resolved if success else entry_name
513
606
 
514
607
  raise ValueError(f"{singular} references must include a valid ID or name.")
515
608
 
516
609
  @staticmethod
517
610
  def _coerce_reference_value(entry: Any) -> str:
611
+ """Coerce a reference entry to a string value.
612
+
613
+ Args:
614
+ entry: Reference entry (dict, string, or other).
615
+
616
+ Returns:
617
+ String representation of the reference.
618
+ """
518
619
  if isinstance(entry, dict):
519
620
  if entry.get("id"):
520
621
  return str(entry["id"])
@@ -523,9 +624,16 @@ class AgentClient(BaseClient):
523
624
  return str(entry)
524
625
 
525
626
  @staticmethod
526
- def _validate_resource_id(
527
- fetch_by_id: Callable[[str], Any], candidate_id: str | None
528
- ) -> str | None:
627
+ def _validate_resource_id(fetch_by_id: Callable[[str], Any], candidate_id: str | None) -> str | None:
628
+ """Validate a resource ID by attempting to fetch it.
629
+
630
+ Args:
631
+ fetch_by_id: Function to fetch resource by ID.
632
+ candidate_id: Candidate ID to validate.
633
+
634
+ Returns:
635
+ Validated ID if found, None otherwise.
636
+ """
529
637
  if not candidate_id:
530
638
  return None
531
639
  try:
@@ -541,27 +649,33 @@ class AgentClient(BaseClient):
541
649
  singular: str,
542
650
  plural: str,
543
651
  ) -> tuple[str, bool]:
652
+ """Resolve a resource by name to an ID.
653
+
654
+ Args:
655
+ find_by_name: Function to find resources by name.
656
+ entry_name: Name of the resource to find.
657
+ singular: Singular label for error messages.
658
+ plural: Plural label for error messages.
659
+
660
+ Returns:
661
+ Tuple of (resolved_id, success).
662
+
663
+ Raises:
664
+ ValueError: If resource not found or multiple matches exist.
665
+ """
544
666
  try:
545
667
  matches = find_by_name(entry_name)
546
668
  except Exception:
547
669
  return entry_name, False
548
670
 
549
671
  if not matches:
550
- raise ValueError(
551
- f"{singular} '{entry_name}' not found in current workspace."
552
- )
672
+ raise ValueError(f"{singular} '{entry_name}' not found in current workspace.")
553
673
  if len(matches) > 1:
554
- exact = [
555
- m
556
- for m in matches
557
- if getattr(m, "name", "").lower() == entry_name.lower()
558
- ]
674
+ exact = [m for m in matches if getattr(m, "name", "").lower() == entry_name.lower()]
559
675
  if len(exact) == 1:
560
676
  matches = exact
561
677
  else:
562
- raise ValueError(
563
- f"Multiple {plural} named '{entry_name}'. Please disambiguate."
564
- )
678
+ raise ValueError(f"Multiple {plural} named '{entry_name}'. Please disambiguate.")
565
679
  return str(matches[0].id), True
566
680
 
567
681
  def _resolve_tool_ids(
@@ -569,6 +683,15 @@ class AgentClient(BaseClient):
569
683
  tools: list[Any] | None,
570
684
  references: list[Any] | None = None,
571
685
  ) -> list[str] | None:
686
+ """Resolve tool references to IDs.
687
+
688
+ Args:
689
+ tools: List of tool references to resolve.
690
+ references: Optional list of reference objects for fallback.
691
+
692
+ Returns:
693
+ List of resolved tool IDs, or None if tools is empty.
694
+ """
572
695
  tool_client = self._get_tool_client()
573
696
  return self._resolve_resource_ids(
574
697
  tools,
@@ -584,6 +707,15 @@ class AgentClient(BaseClient):
584
707
  agents: list[Any] | None,
585
708
  references: list[Any] | None = None,
586
709
  ) -> list[str] | None:
710
+ """Resolve agent references to IDs.
711
+
712
+ Args:
713
+ agents: List of agent references to resolve.
714
+ references: Optional list of reference objects for fallback.
715
+
716
+ Returns:
717
+ List of resolved agent IDs, or None if agents is empty.
718
+ """
587
719
  return self._resolve_resource_ids(
588
720
  agents,
589
721
  references,
@@ -598,6 +730,15 @@ class AgentClient(BaseClient):
598
730
  mcps: list[Any] | None,
599
731
  references: list[Any] | None = None,
600
732
  ) -> list[str] | None:
733
+ """Resolve MCP references to IDs.
734
+
735
+ Args:
736
+ mcps: List of MCP references to resolve.
737
+ references: Optional list of reference objects for fallback.
738
+
739
+ Returns:
740
+ List of resolved MCP IDs, or None if mcps is empty.
741
+ """
601
742
  mcp_client = self._get_mcp_client()
602
743
  return self._resolve_resource_ids(
603
744
  mcps,
@@ -610,9 +751,7 @@ class AgentClient(BaseClient):
610
751
 
611
752
  def _create_agent_from_payload(self, payload: Mapping[str, Any]) -> "Agent":
612
753
  """Create an agent using a fully prepared payload mapping."""
613
- known, extras = _split_known_and_extra(
614
- payload, AgentCreateRequest.__dataclass_fields__
615
- )
754
+ known, extras = _split_known_and_extra(payload, AgentCreateRequest.__dataclass_fields__)
616
755
 
617
756
  name = known.pop("name", None)
618
757
  instruction = known.pop("instruction", None)
@@ -691,7 +830,8 @@ class AgentClient(BaseClient):
691
830
  get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
692
831
  json=payload_dict,
693
832
  )
694
- return Agent(**full_agent_data)._set_client(self)
833
+ response = AgentResponse(**full_agent_data)
834
+ return Agent.from_response(response, client=self)
695
835
 
696
836
  def create_agent(
697
837
  self,
@@ -721,9 +861,7 @@ class AgentClient(BaseClient):
721
861
  overrides = _merge_override_maps(base_overrides, kwargs)
722
862
 
723
863
  if file is not None:
724
- payload = _prepare_import_payload(
725
- Path(file).expanduser(), overrides, drop_model_fields=True
726
- )
864
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
727
865
  if overrides.get("model") is None:
728
866
  payload.pop("model", None)
729
867
  else:
@@ -746,9 +884,7 @@ class AgentClient(BaseClient):
746
884
  payload: Mapping[str, Any],
747
885
  ) -> "Agent":
748
886
  """Update an agent using a prepared payload mapping."""
749
- known, extras = _split_known_and_extra(
750
- payload, AgentUpdateRequest.__dataclass_fields__
751
- )
887
+ known, extras = _split_known_and_extra(payload, AgentUpdateRequest.__dataclass_fields__)
752
888
  _normalise_sequence_fields(known)
753
889
 
754
890
  tool_refs = extras.pop("_tool_refs", None)
@@ -790,8 +926,9 @@ class AgentClient(BaseClient):
790
926
 
791
927
  payload_dict = request.to_payload(current_agent)
792
928
 
793
- response = self._request("PUT", f"/agents/{agent_id}", json=payload_dict)
794
- return Agent(**response)._set_client(self)
929
+ api_response = self._request("PUT", f"/agents/{agent_id}", json=payload_dict)
930
+ response = AgentResponse(**api_response)
931
+ return Agent.from_response(response, client=self)
795
932
 
796
933
  def update_agent(
797
934
  self,
@@ -818,9 +955,7 @@ class AgentClient(BaseClient):
818
955
  overrides = _merge_override_maps(base_overrides, kwargs)
819
956
 
820
957
  if file is not None:
821
- payload = _prepare_import_payload(
822
- Path(file).expanduser(), overrides, drop_model_fields=True
823
- )
958
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
824
959
  else:
825
960
  payload = overrides
826
961
 
@@ -840,6 +975,62 @@ class AgentClient(BaseClient):
840
975
  """Delete an agent."""
841
976
  self._request("DELETE", f"/agents/{agent_id}")
842
977
 
978
+ def upsert_agent(self, identifier: str | Agent, **kwargs) -> Agent:
979
+ """Create or update an agent by instance, ID, or name.
980
+
981
+ Args:
982
+ identifier: Agent instance, ID (UUID string), or name
983
+ **kwargs: Agent configuration (instruction, description, tools, etc.)
984
+
985
+ Returns:
986
+ The created or updated agent.
987
+
988
+ Example:
989
+ >>> # By name (creates if not exists)
990
+ >>> agent = client.agents.upsert_agent(
991
+ ... "hello_agent",
992
+ ... instruction="You are a helpful assistant.",
993
+ ... description="A friendly agent",
994
+ ... )
995
+ >>> # By instance
996
+ >>> agent = client.agents.upsert_agent(existing_agent, description="Updated")
997
+ >>> # By ID
998
+ >>> agent = client.agents.upsert_agent("uuid-here", description="Updated")
999
+ """
1000
+ # Handle Agent instance
1001
+ if isinstance(identifier, Agent):
1002
+ if identifier.id:
1003
+ logger.info("Updating agent by instance: %s", identifier.name)
1004
+ return self.update_agent(identifier.id, name=identifier.name, **kwargs)
1005
+ identifier = identifier.name
1006
+
1007
+ # Handle string (ID or name)
1008
+ if isinstance(identifier, str):
1009
+ # Check if it's a UUID
1010
+ if is_uuid(identifier):
1011
+ logger.info("Updating agent by ID: %s", identifier)
1012
+ return self.update_agent(identifier, **kwargs)
1013
+
1014
+ # It's a name - find or create
1015
+ return self._upsert_agent_by_name(identifier, **kwargs)
1016
+
1017
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
1018
+
1019
+ def _upsert_agent_by_name(self, name: str, **kwargs) -> Agent:
1020
+ """Find agent by name and update, or create if not found."""
1021
+ existing = self.find_agents(name)
1022
+
1023
+ if len(existing) == 1:
1024
+ logger.info("Updating existing agent: %s", name)
1025
+ return self.update_agent(existing[0].id, name=name, **kwargs)
1026
+
1027
+ if len(existing) > 1:
1028
+ raise ValueError(f"Multiple agents found with name '{name}'")
1029
+
1030
+ # Create new agent
1031
+ logger.info("Creating new agent: %s", name)
1032
+ return self.create_agent(name=name, **kwargs)
1033
+
843
1034
  def _prepare_sync_request_data(
844
1035
  self,
845
1036
  message: str,
@@ -882,9 +1073,7 @@ class AgentClient(BaseClient):
882
1073
  payload["tty"] = True
883
1074
  return payload, None, None, headers, None
884
1075
 
885
- def _get_timeout_values(
886
- self, timeout: float | None, **kwargs: Any
887
- ) -> tuple[float, float]:
1076
+ def _get_timeout_values(self, timeout: float | None, **kwargs: Any) -> tuple[float, float]:
888
1077
  """Get request timeout and execution timeout values.
889
1078
 
890
1079
  Args:
@@ -906,9 +1095,32 @@ class AgentClient(BaseClient):
906
1095
  tty: bool = False,
907
1096
  *,
908
1097
  renderer: RichStreamRenderer | str | None = "auto",
1098
+ runtime_config: dict[str, Any] | None = None,
909
1099
  **kwargs,
910
1100
  ) -> str:
911
- """Run an agent with a message, streaming via a renderer."""
1101
+ """Run an agent with a message, streaming via a renderer.
1102
+
1103
+ Args:
1104
+ agent_id: The ID of the agent to run.
1105
+ message: The message to send to the agent.
1106
+ files: Optional list of files to include with the request.
1107
+ tty: Whether to enable TTY mode.
1108
+ renderer: Renderer for streaming output.
1109
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
1110
+ Keys should be platform IDs. Example:
1111
+ {
1112
+ "tool_configs": {"tool-id": {"param": "value"}},
1113
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
1114
+ "agent_config": {"planning": True},
1115
+ }
1116
+ **kwargs: Additional arguments to pass to the run API.
1117
+
1118
+ Returns:
1119
+ The agent's response as a string.
1120
+ """
1121
+ # Include runtime_config in kwargs only when caller hasn't already provided it
1122
+ if runtime_config is not None and "runtime_config" not in kwargs:
1123
+ kwargs["runtime_config"] = runtime_config
912
1124
  (
913
1125
  payload,
914
1126
  data_payload,
@@ -1009,9 +1221,7 @@ class AgentClient(BaseClient):
1009
1221
  headers = {"Accept": SSE_CONTENT_TYPE}
1010
1222
  return payload, None, None, headers
1011
1223
 
1012
- def _create_async_client_config(
1013
- self, timeout: float | None, headers: dict | None
1014
- ) -> dict:
1224
+ def _create_async_client_config(self, timeout: float | None, headers: dict | None) -> dict:
1015
1225
  """Create async client configuration with proper headers and timeout."""
1016
1226
  config = self._build_async_client(timeout or self.timeout)
1017
1227
  if headers:
@@ -1040,9 +1250,7 @@ class AgentClient(BaseClient):
1040
1250
  ) as stream_response:
1041
1251
  stream_response.raise_for_status()
1042
1252
 
1043
- async for event in aiter_sse_events(
1044
- stream_response, timeout_seconds, agent_name
1045
- ):
1253
+ async for event in aiter_sse_events(stream_response, timeout_seconds, agent_name):
1046
1254
  try:
1047
1255
  chunk = json.loads(event["data"])
1048
1256
  yield chunk
@@ -1056,7 +1264,8 @@ class AgentClient(BaseClient):
1056
1264
  message: str,
1057
1265
  files: list[str | BinaryIO] | None = None,
1058
1266
  *,
1059
- timeout: float | None = None,
1267
+ request_timeout: float | None = None,
1268
+ runtime_config: dict[str, Any] | None = None,
1060
1269
  **kwargs,
1061
1270
  ) -> AsyncGenerator[dict, None]:
1062
1271
  """Async run an agent with a message, yielding streaming JSON chunks.
@@ -1065,7 +1274,14 @@ class AgentClient(BaseClient):
1065
1274
  agent_id: ID of the agent to run
1066
1275
  message: Message to send to the agent
1067
1276
  files: Optional list of files to include
1068
- timeout: Request timeout in seconds
1277
+ request_timeout: Optional request timeout in seconds (defaults to client timeout)
1278
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
1279
+ Keys should be platform IDs. Example:
1280
+ {
1281
+ "tool_configs": {"tool-id": {"param": "value"}},
1282
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
1283
+ "agent_config": {"planning": True},
1284
+ }
1069
1285
  **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
1070
1286
 
1071
1287
  Yields:
@@ -1076,20 +1292,25 @@ class AgentClient(BaseClient):
1076
1292
  httpx.TimeoutException: When general timeout occurs
1077
1293
  Exception: For other unexpected errors
1078
1294
  """
1295
+ # Include runtime_config in kwargs only when caller hasn't already provided it
1296
+ if runtime_config is not None and "runtime_config" not in kwargs:
1297
+ kwargs["runtime_config"] = runtime_config
1298
+ # Derive timeout values for request/control flow
1299
+ legacy_timeout = kwargs.get("timeout")
1300
+ http_timeout_override = request_timeout if request_timeout is not None else legacy_timeout
1301
+ http_timeout = http_timeout_override or self.timeout
1302
+
1079
1303
  # Prepare request data
1080
- payload, data_payload, files_payload, headers = self._prepare_request_data(
1081
- message, files, **kwargs
1082
- )
1304
+ payload, data_payload, files_payload, headers = self._prepare_request_data(message, files, **kwargs)
1083
1305
 
1084
1306
  # Create async client configuration
1085
- async_client_config = self._create_async_client_config(timeout, headers)
1307
+ async_client_config = self._create_async_client_config(http_timeout_override, headers)
1086
1308
 
1087
1309
  # Get execution timeout for streaming control
1088
1310
  timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
1089
1311
  agent_name = kwargs.get("agent_name")
1090
1312
 
1091
- try:
1092
- # Create async client and stream response
1313
+ async def _chunk_stream() -> AsyncGenerator[dict, None]:
1093
1314
  async with httpx.AsyncClient(**async_client_config) as async_client:
1094
1315
  async for chunk in self._stream_agent_response(
1095
1316
  async_client,
@@ -1103,7 +1324,14 @@ class AgentClient(BaseClient):
1103
1324
  ):
1104
1325
  yield chunk
1105
1326
 
1106
- finally:
1107
- # Ensure cleanup - this is handled by the calling context
1108
- # but we keep this for safety in case of future changes
1109
- pass
1327
+ async with _async_timeout_guard(http_timeout):
1328
+ async for chunk in _chunk_stream():
1329
+ yield chunk
1330
+
1331
+ @property
1332
+ def runs(self) -> "AgentRunsClient":
1333
+ """Get the agent runs client."""
1334
+ if self._runs_client is None:
1335
+ shared_config = build_shared_config(self)
1336
+ self._runs_client = AgentRunsClient(**shared_config)
1337
+ return self._runs_client