glaip-sdk 0.0.0b99__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 (207) hide show
  1. glaip_sdk/__init__.py +52 -0
  2. glaip_sdk/_version.py +81 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1227 -0
  5. glaip_sdk/branding.py +211 -0
  6. glaip_sdk/cli/__init__.py +9 -0
  7. glaip_sdk/cli/account_store.py +540 -0
  8. glaip_sdk/cli/agent_config.py +78 -0
  9. glaip_sdk/cli/auth.py +705 -0
  10. glaip_sdk/cli/commands/__init__.py +5 -0
  11. glaip_sdk/cli/commands/accounts.py +746 -0
  12. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  13. glaip_sdk/cli/commands/agents/_common.py +561 -0
  14. glaip_sdk/cli/commands/agents/create.py +151 -0
  15. glaip_sdk/cli/commands/agents/delete.py +64 -0
  16. glaip_sdk/cli/commands/agents/get.py +89 -0
  17. glaip_sdk/cli/commands/agents/list.py +129 -0
  18. glaip_sdk/cli/commands/agents/run.py +264 -0
  19. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  20. glaip_sdk/cli/commands/agents/update.py +112 -0
  21. glaip_sdk/cli/commands/common_config.py +104 -0
  22. glaip_sdk/cli/commands/configure.py +895 -0
  23. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  24. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  25. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  26. glaip_sdk/cli/commands/mcps/create.py +152 -0
  27. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  28. glaip_sdk/cli/commands/mcps/get.py +212 -0
  29. glaip_sdk/cli/commands/mcps/list.py +69 -0
  30. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  31. glaip_sdk/cli/commands/mcps/update.py +190 -0
  32. glaip_sdk/cli/commands/models.py +67 -0
  33. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  34. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  35. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  36. glaip_sdk/cli/commands/tools/_common.py +80 -0
  37. glaip_sdk/cli/commands/tools/create.py +228 -0
  38. glaip_sdk/cli/commands/tools/delete.py +61 -0
  39. glaip_sdk/cli/commands/tools/get.py +103 -0
  40. glaip_sdk/cli/commands/tools/list.py +69 -0
  41. glaip_sdk/cli/commands/tools/script.py +49 -0
  42. glaip_sdk/cli/commands/tools/update.py +102 -0
  43. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  44. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  45. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  46. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  47. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  48. glaip_sdk/cli/commands/update.py +192 -0
  49. glaip_sdk/cli/config.py +95 -0
  50. glaip_sdk/cli/constants.py +38 -0
  51. glaip_sdk/cli/context.py +150 -0
  52. glaip_sdk/cli/core/__init__.py +79 -0
  53. glaip_sdk/cli/core/context.py +124 -0
  54. glaip_sdk/cli/core/output.py +851 -0
  55. glaip_sdk/cli/core/prompting.py +649 -0
  56. glaip_sdk/cli/core/rendering.py +187 -0
  57. glaip_sdk/cli/display.py +355 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +112 -0
  60. glaip_sdk/cli/main.py +686 -0
  61. glaip_sdk/cli/masking.py +136 -0
  62. glaip_sdk/cli/mcp_validators.py +287 -0
  63. glaip_sdk/cli/pager.py +266 -0
  64. glaip_sdk/cli/parsers/__init__.py +7 -0
  65. glaip_sdk/cli/parsers/json_input.py +177 -0
  66. glaip_sdk/cli/resolution.py +68 -0
  67. glaip_sdk/cli/rich_helpers.py +27 -0
  68. glaip_sdk/cli/slash/__init__.py +15 -0
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +285 -0
  72. glaip_sdk/cli/slash/prompt.py +256 -0
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +1724 -0
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +31 -0
  91. glaip_sdk/cli/transcript/cache.py +536 -0
  92. glaip_sdk/cli/transcript/capture.py +329 -0
  93. glaip_sdk/cli/transcript/export.py +38 -0
  94. glaip_sdk/cli/transcript/history.py +815 -0
  95. glaip_sdk/cli/transcript/launcher.py +77 -0
  96. glaip_sdk/cli/transcript/viewer.py +374 -0
  97. glaip_sdk/cli/update_notifier.py +369 -0
  98. glaip_sdk/cli/validators.py +238 -0
  99. glaip_sdk/client/__init__.py +12 -0
  100. glaip_sdk/client/_schedule_payloads.py +89 -0
  101. glaip_sdk/client/agent_runs.py +147 -0
  102. glaip_sdk/client/agents.py +1353 -0
  103. glaip_sdk/client/base.py +502 -0
  104. glaip_sdk/client/main.py +253 -0
  105. glaip_sdk/client/mcps.py +401 -0
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/payloads/agent/requests.py +495 -0
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +747 -0
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +690 -0
  113. glaip_sdk/client/validators.py +198 -0
  114. glaip_sdk/config/constants.py +52 -0
  115. glaip_sdk/exceptions.py +113 -0
  116. glaip_sdk/hitl/__init__.py +15 -0
  117. glaip_sdk/hitl/local.py +151 -0
  118. glaip_sdk/icons.py +25 -0
  119. glaip_sdk/mcps/__init__.py +21 -0
  120. glaip_sdk/mcps/base.py +345 -0
  121. glaip_sdk/models/__init__.py +107 -0
  122. glaip_sdk/models/agent.py +47 -0
  123. glaip_sdk/models/agent_runs.py +117 -0
  124. glaip_sdk/models/common.py +42 -0
  125. glaip_sdk/models/mcp.py +33 -0
  126. glaip_sdk/models/schedule.py +224 -0
  127. glaip_sdk/models/tool.py +33 -0
  128. glaip_sdk/payload_schemas/__init__.py +7 -0
  129. glaip_sdk/payload_schemas/agent.py +85 -0
  130. glaip_sdk/registry/__init__.py +55 -0
  131. glaip_sdk/registry/agent.py +164 -0
  132. glaip_sdk/registry/base.py +139 -0
  133. glaip_sdk/registry/mcp.py +253 -0
  134. glaip_sdk/registry/tool.py +393 -0
  135. glaip_sdk/rich_components.py +125 -0
  136. glaip_sdk/runner/__init__.py +59 -0
  137. glaip_sdk/runner/base.py +84 -0
  138. glaip_sdk/runner/deps.py +112 -0
  139. glaip_sdk/runner/langgraph.py +870 -0
  140. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  141. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  142. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  143. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  144. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  145. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  146. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  147. glaip_sdk/schedules/__init__.py +22 -0
  148. glaip_sdk/schedules/base.py +291 -0
  149. glaip_sdk/tools/__init__.py +22 -0
  150. glaip_sdk/tools/base.py +466 -0
  151. glaip_sdk/utils/__init__.py +86 -0
  152. glaip_sdk/utils/a2a/__init__.py +34 -0
  153. glaip_sdk/utils/a2a/event_processor.py +188 -0
  154. glaip_sdk/utils/agent_config.py +194 -0
  155. glaip_sdk/utils/bundler.py +267 -0
  156. glaip_sdk/utils/client.py +111 -0
  157. glaip_sdk/utils/client_utils.py +486 -0
  158. glaip_sdk/utils/datetime_helpers.py +58 -0
  159. glaip_sdk/utils/discovery.py +78 -0
  160. glaip_sdk/utils/display.py +135 -0
  161. glaip_sdk/utils/export.py +143 -0
  162. glaip_sdk/utils/general.py +61 -0
  163. glaip_sdk/utils/import_export.py +168 -0
  164. glaip_sdk/utils/import_resolver.py +530 -0
  165. glaip_sdk/utils/instructions.py +101 -0
  166. glaip_sdk/utils/rendering/__init__.py +115 -0
  167. glaip_sdk/utils/rendering/formatting.py +264 -0
  168. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  169. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  170. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  171. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  172. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  173. glaip_sdk/utils/rendering/models.py +85 -0
  174. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  175. glaip_sdk/utils/rendering/renderer/base.py +1082 -0
  176. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  177. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  178. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  179. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  180. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  181. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  182. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  183. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  184. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  185. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  186. glaip_sdk/utils/rendering/state.py +204 -0
  187. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  188. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  189. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  190. glaip_sdk/utils/rendering/steps/format.py +176 -0
  191. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  192. glaip_sdk/utils/rendering/timing.py +36 -0
  193. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  194. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  195. glaip_sdk/utils/resource_refs.py +195 -0
  196. glaip_sdk/utils/run_renderer.py +41 -0
  197. glaip_sdk/utils/runtime_config.py +425 -0
  198. glaip_sdk/utils/serialization.py +424 -0
  199. glaip_sdk/utils/sync.py +142 -0
  200. glaip_sdk/utils/tool_detection.py +33 -0
  201. glaip_sdk/utils/tool_storage_provider.py +140 -0
  202. glaip_sdk/utils/validation.py +264 -0
  203. glaip_sdk-0.0.0b99.dist-info/METADATA +239 -0
  204. glaip_sdk-0.0.0b99.dist-info/RECORD +207 -0
  205. glaip_sdk-0.0.0b99.dist-info/WHEEL +5 -0
  206. glaip_sdk-0.0.0b99.dist-info/entry_points.txt +2 -0
  207. glaip_sdk-0.0.0b99.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1353 @@
1
+ #!/usr/bin/env python3
2
+ """Agent client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
13
+ from contextlib import asynccontextmanager
14
+ from os import PathLike
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, BinaryIO
17
+
18
+ if TYPE_CHECKING:
19
+ from glaip_sdk.client.schedules import ScheduleClient
20
+
21
+ import httpx
22
+ from glaip_sdk.agents import Agent
23
+ from glaip_sdk.client.payloads.agent import (
24
+ AgentCreateRequest,
25
+ AgentListParams,
26
+ AgentListResult,
27
+ AgentUpdateRequest,
28
+ )
29
+ from glaip_sdk.client.agent_runs import AgentRunsClient
30
+ from glaip_sdk.client.base import BaseClient
31
+ from glaip_sdk.client.mcps import MCPClient
32
+ from glaip_sdk.client.run_rendering import (
33
+ AgentRunRenderingManager,
34
+ compute_timeout_seconds,
35
+ )
36
+ from glaip_sdk.client.shared import build_shared_config
37
+ from glaip_sdk.client.tools import ToolClient
38
+ from glaip_sdk.config.constants import (
39
+ AGENT_CONFIG_FIELDS,
40
+ DEFAULT_AGENT_FRAMEWORK,
41
+ DEFAULT_AGENT_RUN_TIMEOUT,
42
+ DEFAULT_AGENT_TYPE,
43
+ DEFAULT_AGENT_VERSION,
44
+ DEFAULT_MODEL,
45
+ )
46
+ from glaip_sdk.exceptions import NotFoundError, ValidationError
47
+ from glaip_sdk.models import AgentResponse
48
+ from glaip_sdk.payload_schemas.agent import list_server_only_fields
49
+ from glaip_sdk.utils.agent_config import normalize_agent_config_for_import
50
+ from glaip_sdk.utils.client_utils import (
51
+ aiter_sse_events,
52
+ create_model_instances,
53
+ find_by_name,
54
+ prepare_multipart_data,
55
+ )
56
+ from glaip_sdk.utils.import_export import (
57
+ convert_export_to_import_format,
58
+ merge_import_with_cli_args,
59
+ )
60
+ from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
61
+ from glaip_sdk.utils.resource_refs import is_uuid
62
+ from glaip_sdk.utils.serialization import load_resource_from_file
63
+ from glaip_sdk.utils.validation import validate_agent_instruction
64
+
65
+ # API endpoints
66
+ AGENTS_ENDPOINT = "/agents/"
67
+
68
+ # SSE content type
69
+ SSE_CONTENT_TYPE = "text/event-stream"
70
+
71
+ # Set up module-level logger
72
+ logger = logging.getLogger("glaip_sdk.agents")
73
+
74
+ _SERVER_ONLY_IMPORT_FIELDS = set(list_server_only_fields()) | {"success", "message"}
75
+ _MERGED_SEQUENCE_FIELDS = ("tools", "agents", "mcps")
76
+ _DEFAULT_METADATA_TYPE = "custom"
77
+
78
+
79
+ @asynccontextmanager
80
+ async def _async_timeout_guard(
81
+ timeout_seconds: float | None,
82
+ ) -> AsyncGenerator[None, None]:
83
+ """Apply an asyncio timeout when a custom timeout is provided."""
84
+ if timeout_seconds is None:
85
+ yield
86
+ return
87
+ try:
88
+ async with asyncio.timeout(timeout_seconds):
89
+ yield
90
+ except asyncio.TimeoutError as exc:
91
+ raise httpx.TimeoutException(f"Request timed out after {timeout_seconds}s") from exc
92
+
93
+
94
+ def _normalise_sequence(value: Any) -> list[Any] | None:
95
+ """Normalise optional sequence inputs to plain lists."""
96
+ if value is None:
97
+ return None
98
+ if isinstance(value, list):
99
+ return value
100
+ if isinstance(value, (tuple, set)):
101
+ return list(value)
102
+ return [value]
103
+
104
+
105
+ def _normalise_sequence_fields(mapping: dict[str, Any]) -> None:
106
+ """Normalise merged sequence fields in-place."""
107
+ for field in _MERGED_SEQUENCE_FIELDS:
108
+ if field in mapping:
109
+ normalised = _normalise_sequence(mapping[field])
110
+ if normalised is not None:
111
+ mapping[field] = normalised
112
+
113
+
114
+ def _merge_override_maps(
115
+ base_values: Mapping[str, Any],
116
+ extra_values: Mapping[str, Any],
117
+ ) -> dict[str, Any]:
118
+ """Merge override mappings while normalising sequence fields."""
119
+ merged: dict[str, Any] = {}
120
+ for source in (base_values, extra_values):
121
+ for key, value in source.items():
122
+ if value is None:
123
+ continue
124
+ merged[key] = _normalise_sequence(value) if key in _MERGED_SEQUENCE_FIELDS else value
125
+ return merged
126
+
127
+
128
+ def _split_known_and_extra(
129
+ payload: Mapping[str, Any],
130
+ known_fields: Mapping[str, Any],
131
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
132
+ """Split payload mapping into known request fields and extras."""
133
+ known: dict[str, Any] = {}
134
+ extras: dict[str, Any] = {}
135
+ for key, value in payload.items():
136
+ if value is None:
137
+ continue
138
+ if key in known_fields:
139
+ known[key] = value
140
+ else:
141
+ extras[key] = value
142
+ return known, extras
143
+
144
+
145
+ def _prepare_agent_metadata(value: Any) -> dict[str, Any]:
146
+ """Ensure agent metadata contains ``type: custom`` by default."""
147
+ if value is None:
148
+ return {"type": _DEFAULT_METADATA_TYPE}
149
+ if not isinstance(value, Mapping):
150
+ return {"type": _DEFAULT_METADATA_TYPE}
151
+
152
+ prepared = dict(value)
153
+ metadata_type = prepared.get("type")
154
+ if not metadata_type:
155
+ prepared["type"] = _DEFAULT_METADATA_TYPE
156
+ return prepared
157
+
158
+
159
+ def _load_agent_file_payload(file_path: Path, *, model_override: str | None) -> dict[str, Any]:
160
+ """Load agent configuration from disk and normalise legacy fields."""
161
+ if not file_path.exists():
162
+ raise FileNotFoundError(f"Agent configuration file not found: {file_path}")
163
+ if not file_path.is_file():
164
+ raise ValueError(f"Agent configuration path must point to a file: {file_path}")
165
+
166
+ raw_data = load_resource_from_file(file_path)
167
+ if not isinstance(raw_data, Mapping):
168
+ raise ValueError("Agent configuration file must contain a mapping/object.")
169
+
170
+ payload = convert_export_to_import_format(dict(raw_data))
171
+ payload = normalize_agent_config_for_import(payload, model_override)
172
+
173
+ for field in _SERVER_ONLY_IMPORT_FIELDS:
174
+ payload.pop(field, None)
175
+
176
+ return payload
177
+
178
+
179
+ def _prepare_import_payload(
180
+ file_path: Path,
181
+ overrides: Mapping[str, Any],
182
+ *,
183
+ drop_model_fields: bool = False,
184
+ ) -> dict[str, Any]:
185
+ """Prepare merged payload from file contents and explicit overrides."""
186
+ overrides_dict = dict(overrides)
187
+
188
+ raw_definition = load_resource_from_file(file_path)
189
+ original_refs = _extract_original_refs(raw_definition)
190
+
191
+ base_payload = _load_agent_file_payload(file_path, model_override=overrides_dict.get("model"))
192
+
193
+ cli_args = _build_cli_args(overrides_dict)
194
+
195
+ merged = merge_import_with_cli_args(base_payload, cli_args)
196
+
197
+ additional = _build_additional_args(overrides_dict, cli_args)
198
+ merged.update(additional)
199
+
200
+ if drop_model_fields:
201
+ _remove_model_fields_if_needed(merged, overrides_dict)
202
+
203
+ _set_default_refs(merged, original_refs)
204
+
205
+ _normalise_sequence_fields(merged)
206
+ return merged
207
+
208
+
209
+ def _extract_original_refs(raw_definition: dict) -> dict[str, list]:
210
+ """Extract original tool/agent/mcp references from raw definition."""
211
+ return {
212
+ "tools": list(raw_definition.get("tools") or []),
213
+ "agents": list(raw_definition.get("agents") or []),
214
+ "mcps": list(raw_definition.get("mcps") or []),
215
+ }
216
+
217
+
218
+ def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
219
+ """Build CLI args from overrides, filtering out None values."""
220
+ cli_args = {key: overrides_dict.get(key) for key in AGENT_CONFIG_FIELDS if overrides_dict.get(key) is not None}
221
+
222
+ # Normalize sequence fields
223
+ for field in _MERGED_SEQUENCE_FIELDS:
224
+ if field in cli_args:
225
+ cli_args[field] = tuple(_normalise_sequence(cli_args[field]) or [])
226
+
227
+ return cli_args
228
+
229
+
230
+ def _build_additional_args(overrides_dict: dict, cli_args: dict) -> dict[str, Any]:
231
+ """Build additional args not already in CLI args."""
232
+ return {key: value for key, value in overrides_dict.items() if value is not None and key not in cli_args}
233
+
234
+
235
+ def _remove_model_fields_if_needed(merged: dict, overrides_dict: dict) -> None:
236
+ """Remove model fields if not explicitly overridden."""
237
+ if overrides_dict.get("language_model_id") is None:
238
+ merged.pop("language_model_id", None)
239
+ if overrides_dict.get("provider") is None:
240
+ merged.pop("provider", None)
241
+
242
+
243
+ def _set_default_refs(merged: dict, original_refs: dict) -> None:
244
+ """Set default references if not already present."""
245
+ merged.setdefault("_tool_refs", original_refs["tools"])
246
+ merged.setdefault("_agent_refs", original_refs["agents"])
247
+ merged.setdefault("_mcp_refs", original_refs["mcps"])
248
+
249
+
250
+ class AgentClient(BaseClient):
251
+ """Client for agent operations."""
252
+
253
+ def __init__(
254
+ self,
255
+ *,
256
+ parent_client: BaseClient | None = None,
257
+ **kwargs: Any,
258
+ ) -> None:
259
+ """Initialize the agent client.
260
+
261
+ Args:
262
+ parent_client: Parent client to adopt session/config from
263
+ **kwargs: Additional arguments for standalone initialization
264
+ """
265
+ super().__init__(parent_client=parent_client, **kwargs)
266
+ self._renderer_manager = AgentRunRenderingManager(logger)
267
+ self._tool_client: ToolClient | None = None
268
+ self._mcp_client: MCPClient | None = None
269
+ self._runs_client: AgentRunsClient | None = None
270
+ self._schedule_client: ScheduleClient | None = None
271
+
272
+ def list_agents(
273
+ self,
274
+ query: AgentListParams | None = None,
275
+ **kwargs: Any,
276
+ ) -> AgentListResult:
277
+ """List agents with optional filtering and pagination support.
278
+
279
+ Args:
280
+ query: Query parameters for filtering agents. If None, uses kwargs to create query.
281
+ **kwargs: Individual filter parameters for backward compatibility.
282
+ """
283
+ if query is not None and kwargs:
284
+ # Both query object and individual parameters provided
285
+ raise ValueError("Provide either `query` or individual filter arguments, not both.")
286
+
287
+ if query is None:
288
+ # Create query from individual parameters for backward compatibility
289
+ query = AgentListParams(**kwargs)
290
+
291
+ params = query.to_query_params()
292
+ envelope = self._request_with_envelope(
293
+ "GET",
294
+ AGENTS_ENDPOINT,
295
+ params=params if params else None,
296
+ )
297
+
298
+ if not isinstance(envelope, dict):
299
+ envelope = {"data": envelope}
300
+
301
+ data_payload = envelope.get("data") or []
302
+ items = create_model_instances(data_payload, Agent, self)
303
+
304
+ return AgentListResult(
305
+ items=items,
306
+ total=envelope.get("total"),
307
+ page=envelope.get("page"),
308
+ limit=envelope.get("limit"),
309
+ has_next=envelope.get("has_next"),
310
+ has_prev=envelope.get("has_prev"),
311
+ message=envelope.get("message"),
312
+ )
313
+
314
+ def sync_langflow_agents(
315
+ self,
316
+ base_url: str | None = None,
317
+ api_key: str | None = None,
318
+ ) -> dict[str, Any]:
319
+ """Sync LangFlow agents by fetching flows from the LangFlow server.
320
+
321
+ This method synchronizes agents with LangFlow flows. It fetches all flows
322
+ from the configured LangFlow server and creates/updates corresponding agents.
323
+
324
+ Args:
325
+ base_url: Custom LangFlow server base URL. If not provided, uses LANGFLOW_BASE_URL env var.
326
+ api_key: Custom LangFlow API key. If not provided, uses LANGFLOW_API_KEY env var.
327
+
328
+ Returns:
329
+ Response containing sync results and statistics
330
+
331
+ Raises:
332
+ ValueError: If LangFlow server configuration is missing
333
+ """
334
+ payload = {}
335
+ if base_url is not None:
336
+ payload["base_url"] = base_url
337
+ if api_key is not None:
338
+ payload["api_key"] = api_key
339
+
340
+ return self._request("POST", "/agents/langflow/sync", json=payload)
341
+
342
+ def get_agent_by_id(self, agent_id: str) -> Agent:
343
+ """Get agent by ID."""
344
+ try:
345
+ data = self._request("GET", f"/agents/{agent_id}")
346
+ except ValidationError as exc:
347
+ if exc.status_code == 422:
348
+ message = f"Agent '{agent_id}' not found"
349
+ raise NotFoundError(
350
+ message,
351
+ status_code=404,
352
+ error_type=exc.error_type,
353
+ payload=exc.payload,
354
+ request_id=exc.request_id,
355
+ ) from exc
356
+ raise
357
+
358
+ if isinstance(data, str):
359
+ # Some backends may respond with plain text for missing agents.
360
+ message = data.strip() or f"Agent '{agent_id}' not found"
361
+ raise NotFoundError(message, status_code=404)
362
+
363
+ if not isinstance(data, dict):
364
+ raise NotFoundError(
365
+ f"Agent '{agent_id}' not found (unexpected response type)",
366
+ status_code=404,
367
+ )
368
+
369
+ response = AgentResponse(**data)
370
+ return Agent.from_response(response, client=self)
371
+
372
+ def find_agents(self, name: str | None = None) -> list[Agent]:
373
+ """Find agents by name."""
374
+ result = self.list_agents(name=name)
375
+ agents = list(result)
376
+ if name is None:
377
+ return agents
378
+ return find_by_name(agents, name, case_sensitive=False)
379
+
380
+ # ------------------------------------------------------------------ #
381
+ # Renderer delegation helpers
382
+ # ------------------------------------------------------------------ #
383
+ def _get_renderer_manager(self) -> AgentRunRenderingManager:
384
+ """Get or create the renderer manager instance.
385
+
386
+ Returns:
387
+ AgentRunRenderingManager instance.
388
+ """
389
+ manager = getattr(self, "_renderer_manager", None)
390
+ if manager is None:
391
+ manager = AgentRunRenderingManager(logger)
392
+ self._renderer_manager = manager
393
+ return manager
394
+
395
+ def _create_renderer(self, renderer: RichStreamRenderer | str | None, **kwargs: Any) -> RichStreamRenderer:
396
+ """Create or return a renderer instance.
397
+
398
+ Args:
399
+ renderer: Renderer instance, string identifier, or None.
400
+ **kwargs: Additional keyword arguments (e.g., verbose).
401
+
402
+ Returns:
403
+ RichStreamRenderer instance.
404
+ """
405
+ manager = self._get_renderer_manager()
406
+ verbose = kwargs.get("verbose", False)
407
+ if isinstance(renderer, RichStreamRenderer) or hasattr(renderer, "on_start"):
408
+ return renderer # type: ignore[return-value]
409
+ return manager.create_renderer(renderer, verbose=verbose)
410
+
411
+ def _process_stream_events(
412
+ self,
413
+ stream_response: httpx.Response,
414
+ renderer: RichStreamRenderer,
415
+ timeout_seconds: float,
416
+ agent_name: str | None,
417
+ meta: dict[str, Any],
418
+ ) -> tuple[str, dict[str, Any], float | None, float | None]:
419
+ """Process stream events from an HTTP response.
420
+
421
+ Args:
422
+ stream_response: HTTP response stream.
423
+ renderer: Renderer to use for displaying events.
424
+ timeout_seconds: Timeout in seconds.
425
+ agent_name: Optional agent name.
426
+ meta: Metadata dictionary.
427
+
428
+ Returns:
429
+ Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
430
+ """
431
+ manager = self._get_renderer_manager()
432
+ return manager.process_stream_events(
433
+ stream_response,
434
+ renderer,
435
+ timeout_seconds,
436
+ agent_name,
437
+ meta,
438
+ )
439
+
440
+ def _finalize_renderer(
441
+ self,
442
+ renderer: RichStreamRenderer,
443
+ final_text: str,
444
+ stats_usage: dict[str, Any],
445
+ started_monotonic: float | None,
446
+ finished_monotonic: float | None,
447
+ ) -> str:
448
+ """Finalize the renderer and return the final response text.
449
+
450
+ Args:
451
+ renderer: Renderer to finalize.
452
+ final_text: Final text content.
453
+ stats_usage: Usage statistics dictionary.
454
+ started_monotonic: Start time (monotonic).
455
+ finished_monotonic: Finish time (monotonic).
456
+
457
+ Returns:
458
+ Final text string.
459
+ """
460
+ from glaip_sdk.client.run_rendering import finalize_render_manager # noqa: PLC0415
461
+
462
+ manager = self._get_renderer_manager()
463
+ return finalize_render_manager(
464
+ manager, renderer, final_text, stats_usage, started_monotonic, finished_monotonic
465
+ )
466
+
467
+ def _get_tool_client(self) -> ToolClient:
468
+ """Get or create the tool client instance.
469
+
470
+ Returns:
471
+ ToolClient instance.
472
+ """
473
+ if self._tool_client is None:
474
+ self._tool_client = ToolClient(parent_client=self)
475
+ return self._tool_client
476
+
477
+ def _get_mcp_client(self) -> MCPClient:
478
+ """Get or create the MCP client instance.
479
+
480
+ Returns:
481
+ MCPClient instance.
482
+ """
483
+ if self._mcp_client is None:
484
+ self._mcp_client = MCPClient(parent_client=self)
485
+ return self._mcp_client
486
+
487
+ @property
488
+ def schedules(self) -> "ScheduleClient":
489
+ """Get or create the schedule client instance.
490
+
491
+ Returns:
492
+ ScheduleClient instance.
493
+ """
494
+ if self._schedule_client is None:
495
+ # Import here to avoid circular import
496
+ from glaip_sdk.client.schedules import ScheduleClient # noqa: PLC0415
497
+
498
+ self._schedule_client = ScheduleClient(parent_client=self)
499
+ return self._schedule_client
500
+
501
+ def _normalise_reference_entry(
502
+ self,
503
+ entry: Any,
504
+ fallback_iter: Iterator[Any] | None,
505
+ ) -> tuple[str | None, str | None]:
506
+ """Normalize a reference entry to extract ID and name.
507
+
508
+ Args:
509
+ entry: Reference entry (string, dict, or other).
510
+ fallback_iter: Optional iterator for fallback values.
511
+
512
+ Returns:
513
+ Tuple of (entry_id, entry_name).
514
+ """
515
+ entry_id: str | None = None
516
+ entry_name: str | None = None
517
+
518
+ if isinstance(entry, str):
519
+ if is_uuid(entry):
520
+ entry_id = entry
521
+ else:
522
+ entry_name = entry
523
+ elif isinstance(entry, dict):
524
+ entry_id = entry.get("id")
525
+ entry_name = entry.get("name")
526
+ else:
527
+ entry_name = str(entry)
528
+
529
+ if entry_name or fallback_iter is None:
530
+ return entry_id, entry_name
531
+
532
+ try:
533
+ ref = next(fallback_iter)
534
+ except StopIteration:
535
+ ref = None
536
+ if isinstance(ref, dict):
537
+ entry_name = ref.get("name") or entry_name
538
+
539
+ return entry_id, entry_name
540
+
541
+ def _resolve_resource_ids(
542
+ self,
543
+ items: list[Any] | None,
544
+ references: list[Any] | None,
545
+ *,
546
+ fetch_by_id: Callable[[str], Any],
547
+ find_by_name: Callable[[str], list[Any]],
548
+ label: str,
549
+ plural_label: str | None = None,
550
+ ) -> list[str] | None:
551
+ """Resolve a list of resource references to IDs.
552
+
553
+ Args:
554
+ items: List of resource references to resolve.
555
+ references: Optional list of reference objects for fallback.
556
+ fetch_by_id: Function to fetch resource by ID.
557
+ find_by_name: Function to find resources by name.
558
+ label: Singular label for error messages.
559
+ plural_label: Plural label for error messages.
560
+
561
+ Returns:
562
+ List of resolved resource IDs, or None if items is empty.
563
+ """
564
+ if not items:
565
+ return None
566
+
567
+ if references is None:
568
+ return [self._coerce_reference_value(entry) for entry in items]
569
+
570
+ singular = label
571
+ plural = plural_label or f"{label}s"
572
+ fallback_iter = iter(references or [])
573
+
574
+ return [
575
+ self._resolve_single_resource(
576
+ entry,
577
+ fallback_iter,
578
+ fetch_by_id,
579
+ find_by_name,
580
+ singular,
581
+ plural,
582
+ )
583
+ for entry in items
584
+ ]
585
+
586
+ def _resolve_single_resource(
587
+ self,
588
+ entry: Any,
589
+ fallback_iter: Iterator[Any] | None,
590
+ fetch_by_id: Callable[[str], Any],
591
+ find_by_name: Callable[[str], list[Any]],
592
+ singular: str,
593
+ plural: str,
594
+ ) -> str:
595
+ """Resolve a single resource reference to an ID.
596
+
597
+ Args:
598
+ entry: Resource reference to resolve.
599
+ fallback_iter: Optional iterator for fallback values.
600
+ fetch_by_id: Function to fetch resource by ID.
601
+ find_by_name: Function to find resources by name.
602
+ singular: Singular label for error messages.
603
+ plural: Plural label for error messages.
604
+
605
+ Returns:
606
+ Resolved resource ID string.
607
+
608
+ Raises:
609
+ ValueError: If the resource cannot be resolved.
610
+ """
611
+ entry_id, entry_name = self._normalise_reference_entry(entry, fallback_iter)
612
+
613
+ validated_id = self._validate_resource_id(fetch_by_id, entry_id)
614
+ if validated_id:
615
+ return validated_id
616
+ if entry_id and entry_name is None:
617
+ return entry_id
618
+
619
+ if entry_name:
620
+ resolved, success = self._resolve_resource_by_name(find_by_name, entry_name, singular, plural)
621
+ return resolved if success else entry_name
622
+
623
+ raise ValueError(f"{singular} references must include a valid ID or name.")
624
+
625
+ @staticmethod
626
+ def _coerce_reference_value(entry: Any) -> str:
627
+ """Coerce a reference entry to a string value.
628
+
629
+ Args:
630
+ entry: Reference entry (dict, string, or other).
631
+
632
+ Returns:
633
+ String representation of the reference.
634
+ """
635
+ if isinstance(entry, dict):
636
+ if entry.get("id"):
637
+ return str(entry["id"])
638
+ if entry.get("name"):
639
+ return str(entry["name"])
640
+ return str(entry)
641
+
642
+ @staticmethod
643
+ def _validate_resource_id(fetch_by_id: Callable[[str], Any], candidate_id: str | None) -> str | None:
644
+ """Validate a resource ID by attempting to fetch it.
645
+
646
+ Args:
647
+ fetch_by_id: Function to fetch resource by ID.
648
+ candidate_id: Candidate ID to validate.
649
+
650
+ Returns:
651
+ Validated ID if found, None otherwise.
652
+ """
653
+ if not candidate_id:
654
+ return None
655
+ try:
656
+ fetch_by_id(candidate_id)
657
+ except Exception:
658
+ return None
659
+ return candidate_id
660
+
661
+ @staticmethod
662
+ def _resolve_resource_by_name(
663
+ find_by_name: Callable[[str], list[Any]],
664
+ entry_name: str,
665
+ singular: str,
666
+ plural: str,
667
+ ) -> tuple[str, bool]:
668
+ """Resolve a resource by name to an ID.
669
+
670
+ Args:
671
+ find_by_name: Function to find resources by name.
672
+ entry_name: Name of the resource to find.
673
+ singular: Singular label for error messages.
674
+ plural: Plural label for error messages.
675
+
676
+ Returns:
677
+ Tuple of (resolved_id, success).
678
+
679
+ Raises:
680
+ ValueError: If resource not found or multiple matches exist.
681
+ """
682
+ try:
683
+ matches = find_by_name(entry_name)
684
+ except Exception:
685
+ return entry_name, False
686
+
687
+ if not matches:
688
+ raise ValueError(f"{singular} '{entry_name}' not found in current workspace.")
689
+ if len(matches) > 1:
690
+ exact = [m for m in matches if getattr(m, "name", "").lower() == entry_name.lower()]
691
+ if len(exact) == 1:
692
+ matches = exact
693
+ else:
694
+ raise ValueError(f"Multiple {plural} named '{entry_name}'. Please disambiguate.")
695
+ return str(matches[0].id), True
696
+
697
+ def _resolve_tool_ids(
698
+ self,
699
+ tools: list[Any] | None,
700
+ references: list[Any] | None = None,
701
+ ) -> list[str] | None:
702
+ """Resolve tool references to IDs.
703
+
704
+ Args:
705
+ tools: List of tool references to resolve.
706
+ references: Optional list of reference objects for fallback.
707
+
708
+ Returns:
709
+ List of resolved tool IDs, or None if tools is empty.
710
+ """
711
+ tool_client = self._get_tool_client()
712
+ return self._resolve_resource_ids(
713
+ tools,
714
+ references,
715
+ fetch_by_id=tool_client.get_tool_by_id,
716
+ find_by_name=tool_client.find_tools,
717
+ label="Tool",
718
+ plural_label="tools",
719
+ )
720
+
721
+ def _resolve_agent_ids(
722
+ self,
723
+ agents: list[Any] | None,
724
+ references: list[Any] | None = None,
725
+ ) -> list[str] | None:
726
+ """Resolve agent references to IDs.
727
+
728
+ Args:
729
+ agents: List of agent references to resolve.
730
+ references: Optional list of reference objects for fallback.
731
+
732
+ Returns:
733
+ List of resolved agent IDs, or None if agents is empty.
734
+ """
735
+ return self._resolve_resource_ids(
736
+ agents,
737
+ references,
738
+ fetch_by_id=self.get_agent_by_id,
739
+ find_by_name=self.find_agents,
740
+ label="Agent",
741
+ plural_label="agents",
742
+ )
743
+
744
+ def _resolve_mcp_ids(
745
+ self,
746
+ mcps: list[Any] | None,
747
+ references: list[Any] | None = None,
748
+ ) -> list[str] | None:
749
+ """Resolve MCP references to IDs.
750
+
751
+ Args:
752
+ mcps: List of MCP references to resolve.
753
+ references: Optional list of reference objects for fallback.
754
+
755
+ Returns:
756
+ List of resolved MCP IDs, or None if mcps is empty.
757
+ """
758
+ mcp_client = self._get_mcp_client()
759
+ return self._resolve_resource_ids(
760
+ mcps,
761
+ references,
762
+ fetch_by_id=mcp_client.get_mcp_by_id,
763
+ find_by_name=mcp_client.find_mcps,
764
+ label="MCP",
765
+ plural_label="MCPs",
766
+ )
767
+
768
+ def _create_agent_from_payload(self, payload: Mapping[str, Any]) -> "Agent":
769
+ """Create an agent using a fully prepared payload mapping."""
770
+ known, extras = _split_known_and_extra(payload, AgentCreateRequest.__dataclass_fields__)
771
+
772
+ name = known.pop("name", None)
773
+ instruction = known.pop("instruction", None)
774
+ if not name or not str(name).strip():
775
+ raise ValueError("Agent name cannot be empty or whitespace")
776
+ if not instruction or not str(instruction).strip():
777
+ raise ValueError("Agent instruction cannot be empty or whitespace")
778
+
779
+ validated_instruction = validate_agent_instruction(str(instruction))
780
+ _normalise_sequence_fields(known)
781
+
782
+ resolved_model = known.pop("model", None) or DEFAULT_MODEL
783
+ tool_refs = extras.pop("_tool_refs", None)
784
+ agent_refs = extras.pop("_agent_refs", None)
785
+ mcp_refs = extras.pop("_mcp_refs", None)
786
+
787
+ tools_raw = known.pop("tools", None)
788
+ agents_raw = known.pop("agents", None)
789
+ mcps_raw = known.pop("mcps", None)
790
+
791
+ resolved_tools = self._resolve_tool_ids(tools_raw, tool_refs)
792
+ resolved_agents = self._resolve_agent_ids(agents_raw, agent_refs)
793
+ resolved_mcps = self._resolve_mcp_ids(mcps_raw, mcp_refs)
794
+
795
+ language_model_id = known.pop("language_model_id", None)
796
+ provider = known.pop("provider", None)
797
+ model_name = known.pop("model_name", None)
798
+
799
+ agent_type_value = known.pop("agent_type", None)
800
+ fallback_type_value = known.pop("type", None)
801
+ if agent_type_value is None:
802
+ agent_type_value = fallback_type_value or DEFAULT_AGENT_TYPE
803
+
804
+ framework_value = known.pop("framework", None) or DEFAULT_AGENT_FRAMEWORK
805
+ version_value = known.pop("version", None) or DEFAULT_AGENT_VERSION
806
+ account_id = known.pop("account_id", None)
807
+ description = known.pop("description", None)
808
+ metadata = _prepare_agent_metadata(known.pop("metadata", None))
809
+ tool_configs = known.pop("tool_configs", None)
810
+ agent_config = known.pop("agent_config", None)
811
+ timeout_value = known.pop("timeout", None)
812
+ a2a_profile = known.pop("a2a_profile", None)
813
+
814
+ final_extras = {**known, **extras}
815
+ final_extras.setdefault("model", resolved_model)
816
+
817
+ request = AgentCreateRequest(
818
+ name=str(name).strip(),
819
+ instruction=validated_instruction,
820
+ model=resolved_model,
821
+ language_model_id=language_model_id,
822
+ provider=provider,
823
+ model_name=model_name,
824
+ agent_type=agent_type_value,
825
+ framework=framework_value,
826
+ version=version_value,
827
+ account_id=account_id,
828
+ description=description,
829
+ metadata=metadata,
830
+ tools=resolved_tools,
831
+ agents=resolved_agents,
832
+ mcps=resolved_mcps,
833
+ tool_configs=tool_configs,
834
+ agent_config=agent_config,
835
+ timeout=timeout_value or DEFAULT_AGENT_RUN_TIMEOUT,
836
+ a2a_profile=a2a_profile,
837
+ extras=final_extras,
838
+ )
839
+
840
+ payload_dict = request.to_payload()
841
+ payload_dict.setdefault("model", resolved_model)
842
+
843
+ full_agent_data = self._post_then_fetch(
844
+ id_key="id",
845
+ post_endpoint=AGENTS_ENDPOINT,
846
+ get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
847
+ json=payload_dict,
848
+ )
849
+ response = AgentResponse(**full_agent_data)
850
+ return Agent.from_response(response, client=self)
851
+
852
+ def create_agent(
853
+ self,
854
+ name: str | None = None,
855
+ instruction: str | None = None,
856
+ model: str | None = None,
857
+ tools: list[str | Any] | None = None,
858
+ agents: list[str | Any] | None = None,
859
+ timeout: int | None = None,
860
+ *,
861
+ file: str | PathLike[str] | None = None,
862
+ mcps: list[str | Any] | None = None,
863
+ tool_configs: Mapping[str, Any] | None = None,
864
+ **kwargs: Any,
865
+ ) -> "Agent":
866
+ """Create a new agent, optionally loading configuration from a file."""
867
+ base_overrides = {
868
+ "name": name,
869
+ "instruction": instruction,
870
+ "model": model,
871
+ "tools": tools,
872
+ "agents": agents,
873
+ "timeout": timeout,
874
+ "mcps": mcps,
875
+ "tool_configs": tool_configs,
876
+ }
877
+ overrides = _merge_override_maps(base_overrides, kwargs)
878
+
879
+ if file is not None:
880
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
881
+ if overrides.get("model") is None:
882
+ payload.pop("model", None)
883
+ else:
884
+ payload = overrides
885
+
886
+ return self._create_agent_from_payload(payload)
887
+
888
+ def create_agent_from_file( # pragma: no cover - thin compatibility wrapper
889
+ self,
890
+ file_path: str | PathLike[str],
891
+ **overrides: Any,
892
+ ) -> "Agent":
893
+ """Backward-compatible helper to create an agent from a configuration file."""
894
+ return self.create_agent(file=file_path, **overrides)
895
+
896
+ def _update_agent_from_payload(
897
+ self,
898
+ agent_id: str,
899
+ current_agent: Agent,
900
+ payload: Mapping[str, Any],
901
+ ) -> "Agent":
902
+ """Update an agent using a prepared payload mapping."""
903
+ known, extras = _split_known_and_extra(payload, AgentUpdateRequest.__dataclass_fields__)
904
+ _normalise_sequence_fields(known)
905
+
906
+ tool_refs = extras.pop("_tool_refs", None)
907
+ agent_refs = extras.pop("_agent_refs", None)
908
+ mcp_refs = extras.pop("_mcp_refs", None)
909
+
910
+ tools_value = known.pop("tools", None)
911
+ agents_value = known.pop("agents", None)
912
+ mcps_value = known.pop("mcps", None)
913
+
914
+ if tools_value is not None:
915
+ tools_value = self._resolve_tool_ids(tools_value, tool_refs)
916
+ if agents_value is not None:
917
+ agents_value = self._resolve_agent_ids(agents_value, agent_refs)
918
+ if mcps_value is not None:
919
+ mcps_value = self._resolve_mcp_ids(mcps_value, mcp_refs) # pragma: no cover
920
+
921
+ request = AgentUpdateRequest(
922
+ name=known.pop("name", None),
923
+ instruction=known.pop("instruction", None),
924
+ description=known.pop("description", None),
925
+ model=known.pop("model", None),
926
+ language_model_id=known.pop("language_model_id", None),
927
+ provider=known.pop("provider", None),
928
+ model_name=known.pop("model_name", None),
929
+ agent_type=known.pop("agent_type", known.pop("type", None)),
930
+ framework=known.pop("framework", None),
931
+ version=known.pop("version", None),
932
+ account_id=known.pop("account_id", None),
933
+ metadata=known.pop("metadata", None),
934
+ tools=tools_value,
935
+ tool_configs=known.pop("tool_configs", None),
936
+ agents=agents_value,
937
+ mcps=mcps_value,
938
+ agent_config=known.pop("agent_config", None),
939
+ a2a_profile=known.pop("a2a_profile", None),
940
+ extras={**known, **extras},
941
+ )
942
+
943
+ payload_dict = request.to_payload(current_agent)
944
+
945
+ api_response = self._request("PUT", f"/agents/{agent_id}", json=payload_dict)
946
+ response = AgentResponse(**api_response)
947
+ return Agent.from_response(response, client=self)
948
+
949
+ def update_agent(
950
+ self,
951
+ agent_id: str,
952
+ name: str | None = None,
953
+ instruction: str | None = None,
954
+ model: str | None = None,
955
+ *,
956
+ file: str | PathLike[str] | None = None,
957
+ tools: list[str | Any] | None = None,
958
+ agents: list[str | Any] | None = None,
959
+ mcps: list[str | Any] | None = None,
960
+ **kwargs: Any,
961
+ ) -> "Agent":
962
+ """Update an existing agent."""
963
+ base_overrides = {
964
+ "name": name,
965
+ "instruction": instruction,
966
+ "model": model,
967
+ "tools": tools,
968
+ "agents": agents,
969
+ "mcps": mcps,
970
+ }
971
+ overrides = _merge_override_maps(base_overrides, kwargs)
972
+
973
+ if file is not None:
974
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
975
+ else:
976
+ payload = overrides
977
+
978
+ current_agent = self.get_agent_by_id(agent_id)
979
+ return self._update_agent_from_payload(agent_id, current_agent, payload)
980
+
981
+ def update_agent_from_file( # pragma: no cover - thin compatibility wrapper
982
+ self,
983
+ agent_id: str,
984
+ file_path: str | PathLike[str],
985
+ **overrides: Any,
986
+ ) -> "Agent":
987
+ """Backward-compatible helper to update an agent from a configuration file."""
988
+ return self.update_agent(agent_id, file=file_path, **overrides)
989
+
990
+ def delete_agent(self, agent_id: str) -> None:
991
+ """Delete an agent."""
992
+ self._request("DELETE", f"/agents/{agent_id}")
993
+
994
+ def upsert_agent(self, identifier: str | Agent, **kwargs) -> Agent:
995
+ """Create or update an agent by instance, ID, or name.
996
+
997
+ Args:
998
+ identifier: Agent instance, ID (UUID string), or name
999
+ **kwargs: Agent configuration (instruction, description, tools, etc.)
1000
+
1001
+ Returns:
1002
+ The created or updated agent.
1003
+
1004
+ Example:
1005
+ >>> # By name (creates if not exists)
1006
+ >>> agent = client.agents.upsert_agent(
1007
+ ... "hello_agent",
1008
+ ... instruction="You are a helpful assistant.",
1009
+ ... description="A friendly agent",
1010
+ ... )
1011
+ >>> # By instance
1012
+ >>> agent = client.agents.upsert_agent(existing_agent, description="Updated")
1013
+ >>> # By ID
1014
+ >>> agent = client.agents.upsert_agent("uuid-here", description="Updated")
1015
+ """
1016
+ # Handle Agent instance
1017
+ if isinstance(identifier, Agent):
1018
+ if identifier.id:
1019
+ logger.info("Updating agent by instance: %s", identifier.name)
1020
+ return self.update_agent(identifier.id, name=identifier.name, **kwargs)
1021
+ identifier = identifier.name
1022
+
1023
+ # Handle string (ID or name)
1024
+ if isinstance(identifier, str):
1025
+ # Check if it's a UUID
1026
+ if is_uuid(identifier):
1027
+ logger.info("Updating agent by ID: %s", identifier)
1028
+ return self.update_agent(identifier, **kwargs)
1029
+
1030
+ # It's a name - find or create
1031
+ return self._upsert_agent_by_name(identifier, **kwargs)
1032
+
1033
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
1034
+
1035
+ def _upsert_agent_by_name(self, name: str, **kwargs) -> Agent:
1036
+ """Find agent by name and update, or create if not found."""
1037
+ existing = self.find_agents(name)
1038
+
1039
+ if len(existing) == 1:
1040
+ logger.info("Updating existing agent: %s", name)
1041
+ return self.update_agent(existing[0].id, name=name, **kwargs)
1042
+
1043
+ if len(existing) > 1:
1044
+ raise ValueError(f"Multiple agents found with name '{name}'")
1045
+
1046
+ # Create new agent
1047
+ logger.info("Creating new agent: %s", name)
1048
+ return self.create_agent(name=name, **kwargs)
1049
+
1050
+ def _prepare_sync_request_data(
1051
+ self,
1052
+ message: str,
1053
+ files: list[str | BinaryIO] | None,
1054
+ tty: bool,
1055
+ **kwargs: Any,
1056
+ ) -> tuple[dict | None, dict | None, list | None, dict, Any | None]:
1057
+ """Prepare request data for synchronous agent runs with renderer support.
1058
+
1059
+ Args:
1060
+ message: Message to send
1061
+ files: Optional files to include
1062
+ tty: Whether to enable TTY mode
1063
+ **kwargs: Additional request parameters
1064
+
1065
+ Returns:
1066
+ Tuple of (payload, data_payload, files_payload, headers, multipart_data)
1067
+ """
1068
+ headers = {"Accept": SSE_CONTENT_TYPE}
1069
+
1070
+ if files:
1071
+ # Handle multipart data for file uploads
1072
+ multipart_data = prepare_multipart_data(message, files)
1073
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
1074
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
1075
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
1076
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
1077
+
1078
+ return (
1079
+ None,
1080
+ multipart_data.data,
1081
+ multipart_data.files,
1082
+ headers,
1083
+ multipart_data,
1084
+ )
1085
+ else:
1086
+ # Simple JSON payload for text-only requests
1087
+ payload = {"input": message, "stream": True, **kwargs}
1088
+ if tty:
1089
+ payload["tty"] = True
1090
+ return payload, None, None, headers, None
1091
+
1092
+ def _get_timeout_values(self, timeout: float | None, **kwargs: Any) -> tuple[float, float]:
1093
+ """Get request timeout and execution timeout values.
1094
+
1095
+ Args:
1096
+ timeout: Request timeout (overrides instance timeout)
1097
+ **kwargs: Additional parameters including execution timeout
1098
+
1099
+ Returns:
1100
+ Tuple of (request_timeout, execution_timeout)
1101
+ """
1102
+ request_timeout = timeout or self.timeout
1103
+ execution_timeout = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
1104
+ return request_timeout, execution_timeout
1105
+
1106
+ def run_agent(
1107
+ self,
1108
+ agent_id: str,
1109
+ message: str,
1110
+ files: list[str | BinaryIO] | None = None,
1111
+ tty: bool = False,
1112
+ *,
1113
+ renderer: RichStreamRenderer | str | None = "auto",
1114
+ runtime_config: dict[str, Any] | None = None,
1115
+ **kwargs,
1116
+ ) -> str:
1117
+ """Run an agent with a message, streaming via a renderer.
1118
+
1119
+ Args:
1120
+ agent_id: The ID of the agent to run.
1121
+ message: The message to send to the agent.
1122
+ files: Optional list of files to include with the request.
1123
+ tty: Whether to enable TTY mode.
1124
+ renderer: Renderer for streaming output.
1125
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
1126
+ Keys should be platform IDs. Example:
1127
+ {
1128
+ "tool_configs": {"tool-id": {"param": "value"}},
1129
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
1130
+ "agent_config": {"planning": True},
1131
+ }
1132
+ **kwargs: Additional arguments to pass to the run API.
1133
+
1134
+ Returns:
1135
+ The agent's response as a string.
1136
+ """
1137
+ # Include runtime_config in kwargs only when caller hasn't already provided it
1138
+ if runtime_config is not None and "runtime_config" not in kwargs:
1139
+ kwargs["runtime_config"] = runtime_config
1140
+ (
1141
+ payload,
1142
+ data_payload,
1143
+ files_payload,
1144
+ headers,
1145
+ multipart_data,
1146
+ ) = self._prepare_sync_request_data(message, files, tty, **kwargs)
1147
+
1148
+ render_manager = self._get_renderer_manager()
1149
+ verbose = kwargs.get("verbose", False)
1150
+ r = self._create_renderer(renderer, verbose=verbose)
1151
+ meta = render_manager.build_initial_metadata(agent_id, message, kwargs)
1152
+ render_manager.start_renderer(r, meta)
1153
+
1154
+ final_text = ""
1155
+ stats_usage: dict[str, Any] = {}
1156
+ started_monotonic: float | None = None
1157
+ finished_monotonic: float | None = None
1158
+
1159
+ timeout_seconds = compute_timeout_seconds(kwargs)
1160
+
1161
+ try:
1162
+ response = self.http_client.stream(
1163
+ "POST",
1164
+ f"/agents/{agent_id}/run",
1165
+ json=payload,
1166
+ data=data_payload,
1167
+ files=files_payload,
1168
+ headers=headers,
1169
+ timeout=timeout_seconds,
1170
+ )
1171
+
1172
+ with response as stream_response:
1173
+ stream_response.raise_for_status()
1174
+
1175
+ agent_name = kwargs.get("agent_name")
1176
+
1177
+ (
1178
+ final_text,
1179
+ stats_usage,
1180
+ started_monotonic,
1181
+ finished_monotonic,
1182
+ ) = self._process_stream_events(
1183
+ stream_response,
1184
+ r,
1185
+ timeout_seconds,
1186
+ agent_name,
1187
+ meta,
1188
+ )
1189
+
1190
+ except KeyboardInterrupt:
1191
+ try:
1192
+ r.close()
1193
+ finally:
1194
+ raise
1195
+ except Exception:
1196
+ try:
1197
+ r.close()
1198
+ finally:
1199
+ raise
1200
+ finally:
1201
+ if multipart_data:
1202
+ multipart_data.close()
1203
+
1204
+ return self._finalize_renderer(
1205
+ r,
1206
+ final_text,
1207
+ stats_usage,
1208
+ started_monotonic,
1209
+ finished_monotonic,
1210
+ )
1211
+
1212
+ def _prepare_request_data(
1213
+ self,
1214
+ message: str,
1215
+ files: list[str | BinaryIO] | None,
1216
+ **kwargs,
1217
+ ) -> tuple[dict | None, dict | None, dict | None, dict | None]:
1218
+ """Prepare request data for async agent runs.
1219
+
1220
+ Returns:
1221
+ Tuple of (payload, data_payload, files_payload, headers)
1222
+ """
1223
+ if files:
1224
+ # Handle multipart data for file uploads
1225
+ multipart_data = prepare_multipart_data(message, files)
1226
+ # Inject optional multipart extras expected by backend
1227
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
1228
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
1229
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
1230
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
1231
+
1232
+ headers = {"Accept": SSE_CONTENT_TYPE}
1233
+ return None, multipart_data.data, multipart_data.files, headers
1234
+ else:
1235
+ # Simple JSON payload for text-only requests
1236
+ payload = {"input": message, "stream": True, **kwargs}
1237
+ headers = {"Accept": SSE_CONTENT_TYPE}
1238
+ return payload, None, None, headers
1239
+
1240
+ def _create_async_client_config(self, timeout: float | None, headers: dict | None) -> dict:
1241
+ """Create async client configuration with proper headers and timeout."""
1242
+ config = self._build_async_client(timeout or self.timeout)
1243
+ if headers:
1244
+ config["headers"] = {**config["headers"], **headers}
1245
+ return config
1246
+
1247
+ async def _stream_agent_response(
1248
+ self,
1249
+ async_client: httpx.AsyncClient,
1250
+ agent_id: str,
1251
+ payload: dict | None,
1252
+ data_payload: dict | None,
1253
+ files_payload: dict | None,
1254
+ headers: dict | None,
1255
+ timeout_seconds: float,
1256
+ agent_name: str | None,
1257
+ ) -> AsyncGenerator[dict, None]:
1258
+ """Stream the agent response and yield parsed JSON chunks."""
1259
+ async with async_client.stream(
1260
+ "POST",
1261
+ f"/agents/{agent_id}/run",
1262
+ json=payload,
1263
+ data=data_payload,
1264
+ files=files_payload,
1265
+ headers=headers,
1266
+ ) as stream_response:
1267
+ stream_response.raise_for_status()
1268
+
1269
+ async for event in aiter_sse_events(stream_response, timeout_seconds, agent_name):
1270
+ try:
1271
+ chunk = json.loads(event["data"])
1272
+ yield chunk
1273
+ except json.JSONDecodeError:
1274
+ logger.debug("Non-JSON SSE fragment skipped")
1275
+ continue
1276
+
1277
+ async def arun_agent(
1278
+ self,
1279
+ agent_id: str,
1280
+ message: str,
1281
+ files: list[str | BinaryIO] | None = None,
1282
+ *,
1283
+ request_timeout: float | None = None,
1284
+ runtime_config: dict[str, Any] | None = None,
1285
+ **kwargs,
1286
+ ) -> AsyncGenerator[dict, None]:
1287
+ """Async run an agent with a message, yielding streaming JSON chunks.
1288
+
1289
+ Args:
1290
+ agent_id: ID of the agent to run
1291
+ message: Message to send to the agent
1292
+ files: Optional list of files to include
1293
+ request_timeout: Optional request timeout in seconds (defaults to client timeout)
1294
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
1295
+ Keys should be platform IDs. Example:
1296
+ {
1297
+ "tool_configs": {"tool-id": {"param": "value"}},
1298
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
1299
+ "agent_config": {"planning": True},
1300
+ }
1301
+ **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
1302
+
1303
+ Yields:
1304
+ Dictionary containing parsed JSON chunks from the streaming response
1305
+
1306
+ Raises:
1307
+ AgentTimeoutError: When agent execution times out
1308
+ httpx.TimeoutException: When general timeout occurs
1309
+ Exception: For other unexpected errors
1310
+ """
1311
+ # Include runtime_config in kwargs only when caller hasn't already provided it
1312
+ if runtime_config is not None and "runtime_config" not in kwargs:
1313
+ kwargs["runtime_config"] = runtime_config
1314
+ # Derive timeout values for request/control flow
1315
+ legacy_timeout = kwargs.get("timeout")
1316
+ http_timeout_override = request_timeout if request_timeout is not None else legacy_timeout
1317
+ http_timeout = http_timeout_override or self.timeout
1318
+
1319
+ # Prepare request data
1320
+ payload, data_payload, files_payload, headers = self._prepare_request_data(message, files, **kwargs)
1321
+
1322
+ # Create async client configuration
1323
+ async_client_config = self._create_async_client_config(http_timeout_override, headers)
1324
+
1325
+ # Get execution timeout for streaming control
1326
+ timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
1327
+ agent_name = kwargs.get("agent_name")
1328
+
1329
+ async def _chunk_stream() -> AsyncGenerator[dict, None]:
1330
+ async with httpx.AsyncClient(**async_client_config) as async_client:
1331
+ async for chunk in self._stream_agent_response(
1332
+ async_client,
1333
+ agent_id,
1334
+ payload,
1335
+ data_payload,
1336
+ files_payload,
1337
+ headers,
1338
+ timeout_seconds,
1339
+ agent_name,
1340
+ ):
1341
+ yield chunk
1342
+
1343
+ async with _async_timeout_guard(http_timeout):
1344
+ async for chunk in _chunk_stream():
1345
+ yield chunk
1346
+
1347
+ @property
1348
+ def runs(self) -> "AgentRunsClient":
1349
+ """Get the agent runs client."""
1350
+ if self._runs_client is None:
1351
+ shared_config = build_shared_config(self)
1352
+ self._runs_client = AgentRunsClient(**shared_config)
1353
+ return self._runs_client