glaip-sdk 0.0.7__py3-none-any.whl → 0.6.5b6__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 (161) hide show
  1. glaip_sdk/__init__.py +6 -3
  2. glaip_sdk/_version.py +12 -5
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1126 -0
  5. glaip_sdk/branding.py +79 -15
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +699 -0
  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 +503 -183
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +774 -137
  14. glaip_sdk/cli/commands/mcps.py +1124 -181
  15. glaip_sdk/cli/commands/models.py +25 -10
  16. glaip_sdk/cli/commands/tools.py +144 -92
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +61 -0
  19. glaip_sdk/cli/config.py +95 -0
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +150 -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 +143 -53
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +24 -18
  30. glaip_sdk/cli/main.py +420 -145
  31. glaip_sdk/cli/masking.py +136 -0
  32. glaip_sdk/cli/mcp_validators.py +287 -0
  33. glaip_sdk/cli/pager.py +266 -0
  34. glaip_sdk/cli/parsers/__init__.py +7 -0
  35. glaip_sdk/cli/parsers/json_input.py +177 -0
  36. glaip_sdk/cli/resolution.py +28 -21
  37. glaip_sdk/cli/rich_helpers.py +27 -0
  38. glaip_sdk/cli/slash/__init__.py +15 -0
  39. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +282 -0
  42. glaip_sdk/cli/slash/prompt.py +245 -0
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +1679 -0
  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 +872 -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 +31 -0
  52. glaip_sdk/cli/transcript/cache.py +536 -0
  53. glaip_sdk/cli/transcript/capture.py +329 -0
  54. glaip_sdk/cli/transcript/export.py +38 -0
  55. glaip_sdk/cli/transcript/history.py +815 -0
  56. glaip_sdk/cli/transcript/launcher.py +77 -0
  57. glaip_sdk/cli/transcript/viewer.py +372 -0
  58. glaip_sdk/cli/update_notifier.py +290 -0
  59. glaip_sdk/cli/utils.py +247 -1238
  60. glaip_sdk/cli/validators.py +16 -18
  61. glaip_sdk/client/__init__.py +2 -1
  62. glaip_sdk/client/_agent_payloads.py +520 -0
  63. glaip_sdk/client/agent_runs.py +147 -0
  64. glaip_sdk/client/agents.py +940 -574
  65. glaip_sdk/client/base.py +163 -48
  66. glaip_sdk/client/main.py +35 -12
  67. glaip_sdk/client/mcps.py +126 -18
  68. glaip_sdk/client/run_rendering.py +415 -0
  69. glaip_sdk/client/shared.py +21 -0
  70. glaip_sdk/client/tools.py +195 -37
  71. glaip_sdk/client/validators.py +20 -48
  72. glaip_sdk/config/constants.py +15 -5
  73. glaip_sdk/exceptions.py +16 -9
  74. glaip_sdk/icons.py +25 -0
  75. glaip_sdk/mcps/__init__.py +21 -0
  76. glaip_sdk/mcps/base.py +345 -0
  77. glaip_sdk/models/__init__.py +90 -0
  78. glaip_sdk/models/agent.py +47 -0
  79. glaip_sdk/models/agent_runs.py +116 -0
  80. glaip_sdk/models/common.py +42 -0
  81. glaip_sdk/models/mcp.py +33 -0
  82. glaip_sdk/models/tool.py +33 -0
  83. glaip_sdk/payload_schemas/__init__.py +7 -0
  84. glaip_sdk/payload_schemas/agent.py +85 -0
  85. glaip_sdk/registry/__init__.py +55 -0
  86. glaip_sdk/registry/agent.py +164 -0
  87. glaip_sdk/registry/base.py +139 -0
  88. glaip_sdk/registry/mcp.py +253 -0
  89. glaip_sdk/registry/tool.py +231 -0
  90. glaip_sdk/rich_components.py +98 -2
  91. glaip_sdk/runner/__init__.py +59 -0
  92. glaip_sdk/runner/base.py +84 -0
  93. glaip_sdk/runner/deps.py +115 -0
  94. glaip_sdk/runner/langgraph.py +597 -0
  95. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +158 -0
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  99. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +177 -0
  102. glaip_sdk/tools/__init__.py +22 -0
  103. glaip_sdk/tools/base.py +435 -0
  104. glaip_sdk/utils/__init__.py +59 -13
  105. glaip_sdk/utils/a2a/__init__.py +34 -0
  106. glaip_sdk/utils/a2a/event_processor.py +188 -0
  107. glaip_sdk/utils/agent_config.py +53 -40
  108. glaip_sdk/utils/bundler.py +267 -0
  109. glaip_sdk/utils/client.py +111 -0
  110. glaip_sdk/utils/client_utils.py +58 -26
  111. glaip_sdk/utils/datetime_helpers.py +58 -0
  112. glaip_sdk/utils/discovery.py +78 -0
  113. glaip_sdk/utils/display.py +65 -32
  114. glaip_sdk/utils/export.py +143 -0
  115. glaip_sdk/utils/general.py +1 -36
  116. glaip_sdk/utils/import_export.py +20 -25
  117. glaip_sdk/utils/import_resolver.py +492 -0
  118. glaip_sdk/utils/instructions.py +101 -0
  119. glaip_sdk/utils/rendering/__init__.py +115 -1
  120. glaip_sdk/utils/rendering/formatting.py +85 -43
  121. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  122. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +51 -19
  123. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  124. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  125. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  126. glaip_sdk/utils/rendering/models.py +39 -7
  127. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  128. glaip_sdk/utils/rendering/renderer/base.py +672 -759
  129. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  130. glaip_sdk/utils/rendering/renderer/debug.py +75 -22
  131. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  132. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  133. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  134. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  135. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  136. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  137. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  138. glaip_sdk/utils/rendering/state.py +204 -0
  139. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  140. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  141. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  142. glaip_sdk/utils/rendering/steps/format.py +176 -0
  143. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  144. glaip_sdk/utils/rendering/timing.py +36 -0
  145. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  146. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  147. glaip_sdk/utils/resource_refs.py +29 -26
  148. glaip_sdk/utils/runtime_config.py +422 -0
  149. glaip_sdk/utils/serialization.py +184 -51
  150. glaip_sdk/utils/sync.py +142 -0
  151. glaip_sdk/utils/tool_detection.py +33 -0
  152. glaip_sdk/utils/validation.py +21 -30
  153. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/METADATA +58 -12
  154. glaip_sdk-0.6.5b6.dist-info/RECORD +159 -0
  155. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/WHEEL +1 -1
  156. glaip_sdk/models.py +0 -250
  157. glaip_sdk/utils/rendering/renderer/progress.py +0 -118
  158. glaip_sdk/utils/rendering/steps.py +0 -232
  159. glaip_sdk/utils/rich_utils.py +0 -29
  160. glaip_sdk-0.0.7.dist-info/RECORD +0 -55
  161. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/entry_points.txt +0 -0
@@ -3,40 +3,60 @@
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
 
8
- import io
9
+ import asyncio
9
10
  import json
10
11
  import logging
11
- from collections.abc import AsyncGenerator
12
- from time import monotonic
12
+ from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
13
+ from contextlib import asynccontextmanager
14
+ from os import PathLike
15
+ from pathlib import Path
13
16
  from typing import Any, BinaryIO
14
17
 
15
18
  import httpx
16
- from rich.console import Console as _Console
17
-
19
+ from glaip_sdk.agents import Agent
20
+ from glaip_sdk.client._agent_payloads import (
21
+ AgentCreateRequest,
22
+ AgentListParams,
23
+ AgentListResult,
24
+ AgentUpdateRequest,
25
+ )
26
+ from glaip_sdk.client.agent_runs import AgentRunsClient
18
27
  from glaip_sdk.client.base import BaseClient
28
+ from glaip_sdk.client.mcps import MCPClient
29
+ from glaip_sdk.client.run_rendering import (
30
+ AgentRunRenderingManager,
31
+ compute_timeout_seconds,
32
+ )
33
+ from glaip_sdk.client.shared import build_shared_config
34
+ from glaip_sdk.client.tools import ToolClient
19
35
  from glaip_sdk.config.constants import (
36
+ AGENT_CONFIG_FIELDS,
20
37
  DEFAULT_AGENT_FRAMEWORK,
21
- DEFAULT_AGENT_PROVIDER,
22
38
  DEFAULT_AGENT_RUN_TIMEOUT,
23
39
  DEFAULT_AGENT_TYPE,
24
40
  DEFAULT_AGENT_VERSION,
25
41
  DEFAULT_MODEL,
26
42
  )
27
- from glaip_sdk.exceptions import NotFoundError
28
- from glaip_sdk.models import Agent
43
+ from glaip_sdk.exceptions import NotFoundError, ValidationError
44
+ from glaip_sdk.models import AgentResponse
45
+ from glaip_sdk.payload_schemas.agent import list_server_only_fields
46
+ from glaip_sdk.utils.agent_config import normalize_agent_config_for_import
29
47
  from glaip_sdk.utils.client_utils import (
30
48
  aiter_sse_events,
31
49
  create_model_instances,
32
- extract_ids,
33
50
  find_by_name,
34
- iter_sse_events,
35
51
  prepare_multipart_data,
36
52
  )
37
- from glaip_sdk.utils.rendering.models import RunStats
53
+ from glaip_sdk.utils.import_export import (
54
+ convert_export_to_import_format,
55
+ merge_import_with_cli_args,
56
+ )
38
57
  from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
39
- from glaip_sdk.utils.rendering.renderer.config import RendererConfig
58
+ from glaip_sdk.utils.resource_refs import is_uuid
59
+ from glaip_sdk.utils.serialization import load_resource_from_file
40
60
  from glaip_sdk.utils.validation import validate_agent_instruction
41
61
 
42
62
  # API endpoints
@@ -48,6 +68,181 @@ SSE_CONTENT_TYPE = "text/event-stream"
48
68
  # Set up module-level logger
49
69
  logger = logging.getLogger("glaip_sdk.agents")
50
70
 
71
+ _SERVER_ONLY_IMPORT_FIELDS = set(list_server_only_fields()) | {"success", "message"}
72
+ _MERGED_SEQUENCE_FIELDS = ("tools", "agents", "mcps")
73
+ _DEFAULT_METADATA_TYPE = "custom"
74
+
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
+
91
+ def _normalise_sequence(value: Any) -> list[Any] | None:
92
+ """Normalise optional sequence inputs to plain lists."""
93
+ if value is None:
94
+ return None
95
+ if isinstance(value, list):
96
+ return value
97
+ if isinstance(value, (tuple, set)):
98
+ return list(value)
99
+ return [value]
100
+
101
+
102
+ def _normalise_sequence_fields(mapping: dict[str, Any]) -> None:
103
+ """Normalise merged sequence fields in-place."""
104
+ for field in _MERGED_SEQUENCE_FIELDS:
105
+ if field in mapping:
106
+ normalised = _normalise_sequence(mapping[field])
107
+ if normalised is not None:
108
+ mapping[field] = normalised
109
+
110
+
111
+ def _merge_override_maps(
112
+ base_values: Mapping[str, Any],
113
+ extra_values: Mapping[str, Any],
114
+ ) -> dict[str, Any]:
115
+ """Merge override mappings while normalising sequence fields."""
116
+ merged: dict[str, Any] = {}
117
+ for source in (base_values, extra_values):
118
+ for key, value in source.items():
119
+ if value is None:
120
+ continue
121
+ merged[key] = _normalise_sequence(value) if key in _MERGED_SEQUENCE_FIELDS else value
122
+ return merged
123
+
124
+
125
+ def _split_known_and_extra(
126
+ payload: Mapping[str, Any],
127
+ known_fields: Mapping[str, Any],
128
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
129
+ """Split payload mapping into known request fields and extras."""
130
+ known: dict[str, Any] = {}
131
+ extras: dict[str, Any] = {}
132
+ for key, value in payload.items():
133
+ if value is None:
134
+ continue
135
+ if key in known_fields:
136
+ known[key] = value
137
+ else:
138
+ extras[key] = value
139
+ return known, extras
140
+
141
+
142
+ def _prepare_agent_metadata(value: Any) -> dict[str, Any]:
143
+ """Ensure agent metadata contains ``type: custom`` by default."""
144
+ if value is None:
145
+ return {"type": _DEFAULT_METADATA_TYPE}
146
+ if not isinstance(value, Mapping):
147
+ return {"type": _DEFAULT_METADATA_TYPE}
148
+
149
+ prepared = dict(value)
150
+ metadata_type = prepared.get("type")
151
+ if not metadata_type:
152
+ prepared["type"] = _DEFAULT_METADATA_TYPE
153
+ return prepared
154
+
155
+
156
+ def _load_agent_file_payload(file_path: Path, *, model_override: str | None) -> dict[str, Any]:
157
+ """Load agent configuration from disk and normalise legacy fields."""
158
+ if not file_path.exists():
159
+ raise FileNotFoundError(f"Agent configuration file not found: {file_path}")
160
+ if not file_path.is_file():
161
+ raise ValueError(f"Agent configuration path must point to a file: {file_path}")
162
+
163
+ raw_data = load_resource_from_file(file_path)
164
+ if not isinstance(raw_data, Mapping):
165
+ raise ValueError("Agent configuration file must contain a mapping/object.")
166
+
167
+ payload = convert_export_to_import_format(dict(raw_data))
168
+ payload = normalize_agent_config_for_import(payload, model_override)
169
+
170
+ for field in _SERVER_ONLY_IMPORT_FIELDS:
171
+ payload.pop(field, None)
172
+
173
+ return payload
174
+
175
+
176
+ def _prepare_import_payload(
177
+ file_path: Path,
178
+ overrides: Mapping[str, Any],
179
+ *,
180
+ drop_model_fields: bool = False,
181
+ ) -> dict[str, Any]:
182
+ """Prepare merged payload from file contents and explicit overrides."""
183
+ overrides_dict = dict(overrides)
184
+
185
+ raw_definition = load_resource_from_file(file_path)
186
+ original_refs = _extract_original_refs(raw_definition)
187
+
188
+ base_payload = _load_agent_file_payload(file_path, model_override=overrides_dict.get("model"))
189
+
190
+ cli_args = _build_cli_args(overrides_dict)
191
+
192
+ merged = merge_import_with_cli_args(base_payload, cli_args)
193
+
194
+ additional = _build_additional_args(overrides_dict, cli_args)
195
+ merged.update(additional)
196
+
197
+ if drop_model_fields:
198
+ _remove_model_fields_if_needed(merged, overrides_dict)
199
+
200
+ _set_default_refs(merged, original_refs)
201
+
202
+ _normalise_sequence_fields(merged)
203
+ return merged
204
+
205
+
206
+ def _extract_original_refs(raw_definition: dict) -> dict[str, list]:
207
+ """Extract original tool/agent/mcp references from raw definition."""
208
+ return {
209
+ "tools": list(raw_definition.get("tools") or []),
210
+ "agents": list(raw_definition.get("agents") or []),
211
+ "mcps": list(raw_definition.get("mcps") or []),
212
+ }
213
+
214
+
215
+ def _build_cli_args(overrides_dict: dict) -> dict[str, Any]:
216
+ """Build CLI args from overrides, filtering out None values."""
217
+ cli_args = {key: overrides_dict.get(key) for key in AGENT_CONFIG_FIELDS if overrides_dict.get(key) is not None}
218
+
219
+ # Normalize sequence fields
220
+ for field in _MERGED_SEQUENCE_FIELDS:
221
+ if field in cli_args:
222
+ cli_args[field] = tuple(_normalise_sequence(cli_args[field]) or [])
223
+
224
+ return cli_args
225
+
226
+
227
+ def _build_additional_args(overrides_dict: dict, cli_args: dict) -> dict[str, Any]:
228
+ """Build additional args not already in CLI args."""
229
+ return {key: value for key, value in overrides_dict.items() if value is not None and key not in cli_args}
230
+
231
+
232
+ def _remove_model_fields_if_needed(merged: dict, overrides_dict: dict) -> None:
233
+ """Remove model fields if not explicitly overridden."""
234
+ if overrides_dict.get("language_model_id") is None:
235
+ merged.pop("language_model_id", None)
236
+ if overrides_dict.get("provider") is None:
237
+ merged.pop("provider", None)
238
+
239
+
240
+ def _set_default_refs(merged: dict, original_refs: dict) -> None:
241
+ """Set default references if not already present."""
242
+ merged.setdefault("_tool_refs", original_refs["tools"])
243
+ merged.setdefault("_agent_refs", original_refs["agents"])
244
+ merged.setdefault("_mcp_refs", original_refs["mcps"])
245
+
51
246
 
52
247
  class AgentClient(BaseClient):
53
248
  """Client for agent operations."""
@@ -65,44 +260,52 @@ class AgentClient(BaseClient):
65
260
  **kwargs: Additional arguments for standalone initialization
66
261
  """
67
262
  super().__init__(parent_client=parent_client, **kwargs)
263
+ self._renderer_manager = AgentRunRenderingManager(logger)
264
+ self._tool_client: ToolClient | None = None
265
+ self._mcp_client: MCPClient | None = None
266
+ self._runs_client: AgentRunsClient | None = None
68
267
 
69
268
  def list_agents(
70
269
  self,
71
- agent_type: str | None = None,
72
- framework: str | None = None,
73
- name: str | None = None,
74
- version: str | None = None,
75
- sync_langflow_agents: bool = False,
76
- ) -> list[Agent]:
77
- """List agents with optional filtering.
270
+ query: AgentListParams | None = None,
271
+ **kwargs: Any,
272
+ ) -> AgentListResult:
273
+ """List agents with optional filtering and pagination support.
78
274
 
79
275
  Args:
80
- agent_type: Filter by agent type (config, code, a2a)
81
- framework: Filter by framework (langchain, langgraph, google_adk)
82
- name: Filter by partial name match (case-insensitive)
83
- version: Filter by exact version match
84
- sync_langflow_agents: Sync with LangFlow server before listing (only applies when agent_type=langflow)
85
-
86
- Returns:
87
- List of agents matching the filters
276
+ query: Query parameters for filtering agents. If None, uses kwargs to create query.
277
+ **kwargs: Individual filter parameters for backward compatibility.
88
278
  """
89
- params = {}
90
- if agent_type is not None:
91
- params["agent_type"] = agent_type
92
- if framework is not None:
93
- params["framework"] = framework
94
- if name is not None:
95
- params["name"] = name
96
- if version is not None:
97
- params["version"] = version
98
- if sync_langflow_agents:
99
- params["sync_langflow_agents"] = "true"
100
-
101
- if params:
102
- data = self._request("GET", AGENTS_ENDPOINT, params=params)
103
- else:
104
- data = self._request("GET", AGENTS_ENDPOINT)
105
- return create_model_instances(data, Agent, self)
279
+ if query is not None and kwargs:
280
+ # Both query object and individual parameters provided
281
+ raise ValueError("Provide either `query` or individual filter arguments, not both.")
282
+
283
+ if query is None:
284
+ # Create query from individual parameters for backward compatibility
285
+ query = AgentListParams(**kwargs)
286
+
287
+ params = query.to_query_params()
288
+ envelope = self._request_with_envelope(
289
+ "GET",
290
+ AGENTS_ENDPOINT,
291
+ params=params if params else None,
292
+ )
293
+
294
+ if not isinstance(envelope, dict):
295
+ envelope = {"data": envelope}
296
+
297
+ data_payload = envelope.get("data") or []
298
+ items = create_model_instances(data_payload, Agent, self)
299
+
300
+ return AgentListResult(
301
+ items=items,
302
+ total=envelope.get("total"),
303
+ page=envelope.get("page"),
304
+ limit=envelope.get("limit"),
305
+ has_next=envelope.get("has_next"),
306
+ has_prev=envelope.get("has_prev"),
307
+ message=envelope.get("message"),
308
+ )
106
309
 
107
310
  def sync_langflow_agents(
108
311
  self,
@@ -134,7 +337,19 @@ class AgentClient(BaseClient):
134
337
 
135
338
  def get_agent_by_id(self, agent_id: str) -> Agent:
136
339
  """Get agent by ID."""
137
- 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
138
353
 
139
354
  if isinstance(data, str):
140
355
  # Some backends may respond with plain text for missing agents.
@@ -147,333 +362,573 @@ class AgentClient(BaseClient):
147
362
  status_code=404,
148
363
  )
149
364
 
150
- return Agent(**data)._set_client(self)
365
+ response = AgentResponse(**data)
366
+ return Agent.from_response(response, client=self)
151
367
 
152
368
  def find_agents(self, name: str | None = None) -> list[Agent]:
153
369
  """Find agents by name."""
154
- params = {}
155
- if name:
156
- params["name"] = name
157
-
158
- data = self._request("GET", AGENTS_ENDPOINT, params=params)
159
- agents = create_model_instances(data, Agent, self)
370
+ result = self.list_agents(name=name)
371
+ agents = list(result)
160
372
  if name is None:
161
373
  return agents
162
374
  return find_by_name(agents, name, case_sensitive=False)
163
375
 
164
- def _build_create_payload(
165
- self,
166
- name: str,
167
- instruction: str,
168
- model: str = DEFAULT_MODEL,
169
- tools: list[str | Any] | None = None,
170
- agents: list[str | Any] | None = None,
171
- timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
172
- **kwargs: Any,
173
- ) -> dict[str, Any]:
174
- """Build payload for agent creation with proper LM selection and metadata handling.
376
+ # ------------------------------------------------------------------ #
377
+ # Renderer delegation helpers
378
+ # ------------------------------------------------------------------ #
379
+ def _get_renderer_manager(self) -> AgentRunRenderingManager:
380
+ """Get or create the renderer manager instance.
381
+
382
+ Returns:
383
+ AgentRunRenderingManager instance.
384
+ """
385
+ manager = getattr(self, "_renderer_manager", None)
386
+ if manager is None:
387
+ manager = AgentRunRenderingManager(logger)
388
+ self._renderer_manager = manager
389
+ return manager
175
390
 
176
- CENTRALIZED PAYLOAD BUILDING LOGIC:
177
- - LM exclusivity: Uses language_model_id if provided, otherwise provider/model_name
178
- - Always includes required backend metadata
179
- - Preserves mem0 keys in agent_config
180
- - Handles tool/agent ID extraction from objects
391
+ def _create_renderer(self, renderer: RichStreamRenderer | str | None, **kwargs: Any) -> RichStreamRenderer:
392
+ """Create or return a renderer instance.
181
393
 
182
394
  Args:
183
- name: Agent name
184
- instruction: Agent instruction
185
- model: Language model name (used when language_model_id not provided)
186
- tools: List of tools to attach
187
- agents: List of sub-agents to attach
188
- timeout: Agent execution timeout
189
- **kwargs: Additional parameters (language_model_id, agent_config, etc.)
395
+ renderer: Renderer instance, string identifier, or None.
396
+ **kwargs: Additional keyword arguments (e.g., verbose).
190
397
 
191
398
  Returns:
192
- Complete payload dictionary for agent creation
399
+ RichStreamRenderer instance.
193
400
  """
194
- # Prepare the creation payload with required fields
195
- payload: dict[str, Any] = {
196
- "name": name.strip(),
197
- "instruction": instruction.strip(),
198
- "type": DEFAULT_AGENT_TYPE,
199
- "framework": DEFAULT_AGENT_FRAMEWORK,
200
- "version": DEFAULT_AGENT_VERSION,
201
- }
401
+ manager = self._get_renderer_manager()
402
+ verbose = kwargs.get("verbose", False)
403
+ if isinstance(renderer, RichStreamRenderer) or hasattr(renderer, "on_start"):
404
+ return renderer # type: ignore[return-value]
405
+ return manager.create_renderer(renderer, verbose=verbose)
202
406
 
203
- # Language model selection with exclusivity:
204
- # Priority: language_model_id (if provided) > provider/model_name (fallback)
205
- if kwargs.get("language_model_id"):
206
- # Use language_model_id - defer to kwargs update below
207
- pass
208
- else:
209
- # Use provider/model_name fallback
210
- payload["provider"] = DEFAULT_AGENT_PROVIDER
211
- payload["model_name"] = model or DEFAULT_MODEL
407
+ def _process_stream_events(
408
+ self,
409
+ stream_response: httpx.Response,
410
+ renderer: RichStreamRenderer,
411
+ timeout_seconds: float,
412
+ agent_name: str | None,
413
+ meta: dict[str, Any],
414
+ ) -> tuple[str, dict[str, Any], float | None, float | None]:
415
+ """Process stream events from an HTTP response.
212
416
 
213
- # Include execution timeout if provided
214
- if timeout is not None:
215
- payload["timeout"] = str(timeout)
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.
216
423
 
217
- # Ensure minimum required metadata for visibility
218
- if "metadata" not in kwargs:
219
- kwargs["metadata"] = {}
220
- if "type" not in kwargs["metadata"]:
221
- kwargs["metadata"]["type"] = "custom"
424
+ Returns:
425
+ Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
426
+ """
427
+ manager = self._get_renderer_manager()
428
+ return manager.process_stream_events(
429
+ stream_response,
430
+ renderer,
431
+ timeout_seconds,
432
+ agent_name,
433
+ meta,
434
+ )
222
435
 
223
- # Extract IDs from tool and agent objects
224
- tool_ids = extract_ids(tools)
225
- agent_ids = extract_ids(agents)
436
+ def _finalize_renderer(
437
+ self,
438
+ renderer: RichStreamRenderer,
439
+ final_text: str,
440
+ stats_usage: dict[str, Any],
441
+ started_monotonic: float | None,
442
+ finished_monotonic: float | None,
443
+ ) -> str:
444
+ """Finalize the renderer and return the final response text.
226
445
 
227
- # Add tools and agents if provided
228
- if tool_ids:
229
- payload["tools"] = tool_ids
230
- if agent_ids:
231
- payload["agents"] = agent_ids
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).
232
452
 
233
- # Add any additional kwargs (including language_model_id, agent_config, etc.)
234
- payload.update(kwargs)
453
+ Returns:
454
+ Final text string.
455
+ """
456
+ manager = self._get_renderer_manager()
457
+ return manager.finalize_renderer(
458
+ renderer,
459
+ final_text,
460
+ stats_usage,
461
+ started_monotonic,
462
+ finished_monotonic,
463
+ )
235
464
 
236
- return payload
465
+ def _get_tool_client(self) -> ToolClient:
466
+ """Get or create the tool client instance.
237
467
 
238
- def _build_basic_update_payload(
239
- self, current_agent: "Agent", name: str | None, instruction: str | None
240
- ) -> dict[str, Any]:
241
- """Build the basic update payload with required fields."""
242
- return {
243
- "name": name if name is not None else current_agent.name,
244
- "instruction": instruction
245
- if instruction is not None
246
- else current_agent.instruction,
247
- "type": DEFAULT_AGENT_TYPE, # Required by backend
248
- "framework": DEFAULT_AGENT_FRAMEWORK, # Required by backend
249
- "version": DEFAULT_AGENT_VERSION, # Required by backend
250
- }
468
+ Returns:
469
+ ToolClient instance.
470
+ """
471
+ if self._tool_client is None:
472
+ self._tool_client = ToolClient(parent_client=self)
473
+ return self._tool_client
251
474
 
252
- def _handle_language_model_selection(
253
- self,
254
- update_data: dict[str, Any],
255
- current_agent: "Agent",
256
- model: str | None,
257
- language_model_id: str | None,
258
- ) -> None:
259
- """Handle language model selection with proper priority and fallbacks."""
260
- if language_model_id:
261
- # Use language_model_id if provided
262
- update_data["language_model_id"] = language_model_id
263
- elif model is not None:
264
- # Use explicit model parameter
265
- update_data["provider"] = DEFAULT_AGENT_PROVIDER
266
- update_data["model_name"] = model
267
- else:
268
- # Use current agent config or fallbacks
269
- self._set_language_model_from_current_agent(update_data, current_agent)
475
+ def _get_mcp_client(self) -> MCPClient:
476
+ """Get or create the MCP client instance.
270
477
 
271
- def _set_language_model_from_current_agent(
272
- self, update_data: dict[str, Any], current_agent: "Agent"
273
- ) -> None:
274
- """Set language model from current agent config or use defaults."""
275
- if hasattr(current_agent, "agent_config") and current_agent.agent_config:
276
- agent_config = current_agent.agent_config
277
- if "lm_provider" in agent_config:
278
- update_data["provider"] = agent_config["lm_provider"]
279
- if "lm_name" in agent_config:
280
- update_data["model_name"] = agent_config["lm_name"]
281
- else:
282
- # Default fallback values
283
- update_data["provider"] = DEFAULT_AGENT_PROVIDER
284
- update_data["model_name"] = DEFAULT_MODEL
478
+ Returns:
479
+ MCPClient instance.
480
+ """
481
+ if self._mcp_client is None:
482
+ self._mcp_client = MCPClient(parent_client=self)
483
+ return self._mcp_client
285
484
 
286
- def _handle_tools_and_agents(
485
+ def _normalise_reference_entry(
287
486
  self,
288
- update_data: dict[str, Any],
289
- current_agent: "Agent",
290
- tools: list | None,
291
- agents: list | None,
292
- ) -> None:
293
- """Handle tools and agents with proper ID extraction."""
294
- # Handle tools
295
- if tools is not None:
296
- tool_ids = extract_ids(tools)
297
- update_data["tools"] = tool_ids if tool_ids else []
298
- else:
299
- update_data["tools"] = self._extract_current_tool_ids(current_agent)
487
+ entry: Any,
488
+ fallback_iter: Iterator[Any] | None,
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.
300
495
 
301
- # Handle agents
302
- if agents is not None:
303
- agent_ids = extract_ids(agents)
304
- update_data["agents"] = agent_ids if agent_ids else []
496
+ Returns:
497
+ Tuple of (entry_id, entry_name).
498
+ """
499
+ entry_id: str | None = None
500
+ entry_name: str | None = None
501
+
502
+ if isinstance(entry, str):
503
+ if is_uuid(entry):
504
+ entry_id = entry
505
+ else:
506
+ entry_name = entry
507
+ elif isinstance(entry, dict):
508
+ entry_id = entry.get("id")
509
+ entry_name = entry.get("name")
305
510
  else:
306
- update_data["agents"] = self._extract_current_agent_ids(current_agent)
307
-
308
- def _extract_current_tool_ids(self, current_agent: "Agent") -> list[str]:
309
- """Extract tool IDs from current agent."""
310
- if current_agent.tools:
311
- return [
312
- tool["id"] if isinstance(tool, dict) else tool
313
- for tool in current_agent.tools
314
- ]
315
- return []
316
-
317
- def _extract_current_agent_ids(self, current_agent: "Agent") -> list[str]:
318
- """Extract agent IDs from current agent."""
319
- if current_agent.agents:
320
- return [
321
- agent["id"] if isinstance(agent, dict) else agent
322
- for agent in current_agent.agents
323
- ]
324
- return []
325
-
326
- def _handle_agent_config(
511
+ entry_name = str(entry)
512
+
513
+ if entry_name or fallback_iter is None:
514
+ return entry_id, entry_name
515
+
516
+ try:
517
+ ref = next(fallback_iter)
518
+ except StopIteration:
519
+ ref = None
520
+ if isinstance(ref, dict):
521
+ entry_name = ref.get("name") or entry_name
522
+
523
+ return entry_id, entry_name
524
+
525
+ def _resolve_resource_ids(
327
526
  self,
328
- update_data: dict[str, Any],
329
- current_agent: "Agent",
330
- agent_config: dict | None,
331
- ) -> None:
332
- """Handle agent_config with proper merging and cleanup."""
333
- if agent_config is not None:
334
- # Use provided agent_config, merging with current if needed
335
- update_data["agent_config"] = self._merge_agent_configs(
336
- current_agent, agent_config
527
+ items: list[Any] | None,
528
+ references: list[Any] | None,
529
+ *,
530
+ fetch_by_id: Callable[[str], Any],
531
+ find_by_name: Callable[[str], list[Any]],
532
+ label: str,
533
+ plural_label: str | None = None,
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
+ """
548
+ if not items:
549
+ return None
550
+
551
+ if references is None:
552
+ return [self._coerce_reference_value(entry) for entry in items]
553
+
554
+ singular = label
555
+ plural = plural_label or f"{label}s"
556
+ fallback_iter = iter(references or [])
557
+
558
+ return [
559
+ self._resolve_single_resource(
560
+ entry,
561
+ fallback_iter,
562
+ fetch_by_id,
563
+ find_by_name,
564
+ singular,
565
+ plural,
337
566
  )
338
- elif hasattr(current_agent, "agent_config") and current_agent.agent_config:
339
- # Preserve existing agent_config
340
- update_data["agent_config"] = current_agent.agent_config.copy()
341
- else:
342
- # Default agent_config
343
- update_data["agent_config"] = {
344
- "lm_provider": DEFAULT_AGENT_PROVIDER,
345
- "lm_name": DEFAULT_MODEL,
346
- "lm_hyperparameters": {"temperature": 0.0},
347
- }
348
-
349
- # Clean LM keys from agent_config to prevent conflicts
350
- self._clean_agent_config_lm_keys(update_data)
351
-
352
- def _merge_agent_configs(self, current_agent: "Agent", new_config: dict) -> dict:
353
- """Merge current agent config with new config."""
354
- if hasattr(current_agent, "agent_config") and current_agent.agent_config:
355
- merged_config = current_agent.agent_config.copy()
356
- merged_config.update(new_config)
357
- return merged_config
358
- return new_config
359
-
360
- def _clean_agent_config_lm_keys(self, update_data: dict[str, Any]) -> None:
361
- """Remove LM keys from agent_config to prevent conflicts."""
362
- if "agent_config" in update_data and isinstance(
363
- update_data["agent_config"], dict
364
- ):
365
- agent_config = update_data["agent_config"]
366
- lm_keys_to_remove = {
367
- "lm_provider",
368
- "lm_name",
369
- "lm_base_url",
370
- "lm_hyperparameters",
371
- }
372
- for key in lm_keys_to_remove:
373
- agent_config.pop(key, None)
374
-
375
- def _finalize_update_payload(
567
+ for entry in items
568
+ ]
569
+
570
+ def _resolve_single_resource(
376
571
  self,
377
- update_data: dict[str, Any],
378
- current_agent: "Agent",
379
- **kwargs: Any,
380
- ) -> dict[str, Any]:
381
- """Finalize the update payload with metadata and additional kwargs."""
382
- # Handle metadata preservation
383
- if hasattr(current_agent, "metadata") and current_agent.metadata:
384
- update_data["metadata"] = current_agent.metadata.copy()
572
+ entry: Any,
573
+ fallback_iter: Iterator[Any] | None,
574
+ fetch_by_id: Callable[[str], Any],
575
+ find_by_name: Callable[[str], list[Any]],
576
+ singular: str,
577
+ plural: str,
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
+ """
595
+ entry_id, entry_name = self._normalise_reference_entry(entry, fallback_iter)
596
+
597
+ validated_id = self._validate_resource_id(fetch_by_id, entry_id)
598
+ if validated_id:
599
+ return validated_id
600
+ if entry_id and entry_name is None:
601
+ return entry_id
602
+
603
+ if entry_name:
604
+ resolved, success = self._resolve_resource_by_name(find_by_name, entry_name, singular, plural)
605
+ return resolved if success else entry_name
606
+
607
+ raise ValueError(f"{singular} references must include a valid ID or name.")
385
608
 
386
- # Add any other kwargs (excluding already handled ones)
387
- excluded_keys = {"tools", "agents", "agent_config", "language_model_id"}
388
- for key, value in kwargs.items():
389
- if key not in excluded_keys:
390
- update_data[key] = value
609
+ @staticmethod
610
+ def _coerce_reference_value(entry: Any) -> str:
611
+ """Coerce a reference entry to a string value.
391
612
 
392
- return update_data
613
+ Args:
614
+ entry: Reference entry (dict, string, or other).
615
+
616
+ Returns:
617
+ String representation of the reference.
618
+ """
619
+ if isinstance(entry, dict):
620
+ if entry.get("id"):
621
+ return str(entry["id"])
622
+ if entry.get("name"):
623
+ return str(entry["name"])
624
+ return str(entry)
625
+
626
+ @staticmethod
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
+ """
637
+ if not candidate_id:
638
+ return None
639
+ try:
640
+ fetch_by_id(candidate_id)
641
+ except Exception:
642
+ return None
643
+ return candidate_id
644
+
645
+ @staticmethod
646
+ def _resolve_resource_by_name(
647
+ find_by_name: Callable[[str], list[Any]],
648
+ entry_name: str,
649
+ singular: str,
650
+ plural: str,
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
+ """
666
+ try:
667
+ matches = find_by_name(entry_name)
668
+ except Exception:
669
+ return entry_name, False
670
+
671
+ if not matches:
672
+ raise ValueError(f"{singular} '{entry_name}' not found in current workspace.")
673
+ if len(matches) > 1:
674
+ exact = [m for m in matches if getattr(m, "name", "").lower() == entry_name.lower()]
675
+ if len(exact) == 1:
676
+ matches = exact
677
+ else:
678
+ raise ValueError(f"Multiple {plural} named '{entry_name}'. Please disambiguate.")
679
+ return str(matches[0].id), True
393
680
 
394
- def _build_update_payload(
681
+ def _resolve_tool_ids(
395
682
  self,
396
- current_agent: "Agent",
397
- name: str | None = None,
398
- instruction: str | None = None,
399
- model: str | None = None,
400
- **kwargs: Any,
401
- ) -> dict[str, Any]:
402
- """Build payload for agent update with proper LM selection and current state preservation.
683
+ tools: list[Any] | None,
684
+ references: list[Any] | None = None,
685
+ ) -> list[str] | None:
686
+ """Resolve tool references to IDs.
403
687
 
404
688
  Args:
405
- current_agent: Current agent object to update
406
- name: New agent name (None to keep current)
407
- instruction: New instruction (None to keep current)
408
- model: New language model name (None to use current or fallback)
409
- **kwargs: Additional parameters including language_model_id, agent_config, etc.
689
+ tools: List of tool references to resolve.
690
+ references: Optional list of reference objects for fallback.
410
691
 
411
692
  Returns:
412
- Complete payload dictionary for agent update
693
+ List of resolved tool IDs, or None if tools is empty.
694
+ """
695
+ tool_client = self._get_tool_client()
696
+ return self._resolve_resource_ids(
697
+ tools,
698
+ references,
699
+ fetch_by_id=tool_client.get_tool_by_id,
700
+ find_by_name=tool_client.find_tools,
701
+ label="Tool",
702
+ plural_label="tools",
703
+ )
704
+
705
+ def _resolve_agent_ids(
706
+ self,
707
+ agents: list[Any] | None,
708
+ references: list[Any] | None = None,
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.
413
715
 
414
- Notes:
415
- - LM exclusivity: Uses language_model_id if provided, otherwise provider/model_name
416
- - Preserves current values as defaults when new values not provided
417
- - Handles tools/agents updates with proper ID extraction
716
+ Returns:
717
+ List of resolved agent IDs, or None if agents is empty.
418
718
  """
419
- # Build basic payload
420
- update_data = self._build_basic_update_payload(current_agent, name, instruction)
719
+ return self._resolve_resource_ids(
720
+ agents,
721
+ references,
722
+ fetch_by_id=self.get_agent_by_id,
723
+ find_by_name=self.find_agents,
724
+ label="Agent",
725
+ plural_label="agents",
726
+ )
727
+
728
+ def _resolve_mcp_ids(
729
+ self,
730
+ mcps: list[Any] | None,
731
+ references: list[Any] | None = None,
732
+ ) -> list[str] | None:
733
+ """Resolve MCP references to IDs.
421
734
 
422
- # Handle language model selection
423
- language_model_id = kwargs.get("language_model_id")
424
- self._handle_language_model_selection(
425
- update_data, current_agent, model, language_model_id
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
+ """
742
+ mcp_client = self._get_mcp_client()
743
+ return self._resolve_resource_ids(
744
+ mcps,
745
+ references,
746
+ fetch_by_id=mcp_client.get_mcp_by_id,
747
+ find_by_name=mcp_client.find_mcps,
748
+ label="MCP",
749
+ plural_label="MCPs",
426
750
  )
427
751
 
428
- # Handle tools and agents
429
- tools = kwargs.get("tools")
430
- agents = kwargs.get("agents")
431
- self._handle_tools_and_agents(update_data, current_agent, tools, agents)
752
+ def _create_agent_from_payload(self, payload: Mapping[str, Any]) -> "Agent":
753
+ """Create an agent using a fully prepared payload mapping."""
754
+ known, extras = _split_known_and_extra(payload, AgentCreateRequest.__dataclass_fields__)
432
755
 
433
- # Handle agent config
434
- agent_config = kwargs.get("agent_config")
435
- self._handle_agent_config(update_data, current_agent, agent_config)
756
+ name = known.pop("name", None)
757
+ instruction = known.pop("instruction", None)
758
+ if not name or not str(name).strip():
759
+ raise ValueError("Agent name cannot be empty or whitespace")
760
+ if not instruction or not str(instruction).strip():
761
+ raise ValueError("Agent instruction cannot be empty or whitespace")
762
+
763
+ validated_instruction = validate_agent_instruction(str(instruction))
764
+ _normalise_sequence_fields(known)
765
+
766
+ resolved_model = known.pop("model", None) or DEFAULT_MODEL
767
+ tool_refs = extras.pop("_tool_refs", None)
768
+ agent_refs = extras.pop("_agent_refs", None)
769
+ mcp_refs = extras.pop("_mcp_refs", None)
770
+
771
+ tools_raw = known.pop("tools", None)
772
+ agents_raw = known.pop("agents", None)
773
+ mcps_raw = known.pop("mcps", None)
774
+
775
+ resolved_tools = self._resolve_tool_ids(tools_raw, tool_refs)
776
+ resolved_agents = self._resolve_agent_ids(agents_raw, agent_refs)
777
+ resolved_mcps = self._resolve_mcp_ids(mcps_raw, mcp_refs)
778
+
779
+ language_model_id = known.pop("language_model_id", None)
780
+ provider = known.pop("provider", None)
781
+ model_name = known.pop("model_name", None)
782
+
783
+ agent_type_value = known.pop("agent_type", None)
784
+ fallback_type_value = known.pop("type", None)
785
+ if agent_type_value is None:
786
+ agent_type_value = fallback_type_value or DEFAULT_AGENT_TYPE
787
+
788
+ framework_value = known.pop("framework", None) or DEFAULT_AGENT_FRAMEWORK
789
+ version_value = known.pop("version", None) or DEFAULT_AGENT_VERSION
790
+ account_id = known.pop("account_id", None)
791
+ description = known.pop("description", None)
792
+ metadata = _prepare_agent_metadata(known.pop("metadata", None))
793
+ tool_configs = known.pop("tool_configs", None)
794
+ agent_config = known.pop("agent_config", None)
795
+ timeout_value = known.pop("timeout", None)
796
+ a2a_profile = known.pop("a2a_profile", None)
797
+
798
+ final_extras = {**known, **extras}
799
+ final_extras.setdefault("model", resolved_model)
800
+
801
+ request = AgentCreateRequest(
802
+ name=str(name).strip(),
803
+ instruction=validated_instruction,
804
+ model=resolved_model,
805
+ language_model_id=language_model_id,
806
+ provider=provider,
807
+ model_name=model_name,
808
+ agent_type=agent_type_value,
809
+ framework=framework_value,
810
+ version=version_value,
811
+ account_id=account_id,
812
+ description=description,
813
+ metadata=metadata,
814
+ tools=resolved_tools,
815
+ agents=resolved_agents,
816
+ mcps=resolved_mcps,
817
+ tool_configs=tool_configs,
818
+ agent_config=agent_config,
819
+ timeout=timeout_value or DEFAULT_AGENT_RUN_TIMEOUT,
820
+ a2a_profile=a2a_profile,
821
+ extras=final_extras,
822
+ )
823
+
824
+ payload_dict = request.to_payload()
825
+ payload_dict.setdefault("model", resolved_model)
436
826
 
437
- # Finalize payload
438
- return self._finalize_update_payload(update_data, current_agent, **kwargs)
827
+ full_agent_data = self._post_then_fetch(
828
+ id_key="id",
829
+ post_endpoint=AGENTS_ENDPOINT,
830
+ get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
831
+ json=payload_dict,
832
+ )
833
+ response = AgentResponse(**full_agent_data)
834
+ return Agent.from_response(response, client=self)
439
835
 
440
836
  def create_agent(
441
837
  self,
442
- name: str,
443
- instruction: str,
444
- model: str = DEFAULT_MODEL,
838
+ name: str | None = None,
839
+ instruction: str | None = None,
840
+ model: str | None = None,
445
841
  tools: list[str | Any] | None = None,
446
842
  agents: list[str | Any] | None = None,
447
- timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
843
+ timeout: int | None = None,
844
+ *,
845
+ file: str | PathLike[str] | None = None,
846
+ mcps: list[str | Any] | None = None,
847
+ tool_configs: Mapping[str, Any] | None = None,
448
848
  **kwargs: Any,
449
849
  ) -> "Agent":
450
- """Create a new agent."""
451
- # Client-side validation
452
- if not name or not name.strip():
453
- raise ValueError("Agent name cannot be empty or whitespace")
850
+ """Create a new agent, optionally loading configuration from a file."""
851
+ base_overrides = {
852
+ "name": name,
853
+ "instruction": instruction,
854
+ "model": model,
855
+ "tools": tools,
856
+ "agents": agents,
857
+ "timeout": timeout,
858
+ "mcps": mcps,
859
+ "tool_configs": tool_configs,
860
+ }
861
+ overrides = _merge_override_maps(base_overrides, kwargs)
454
862
 
455
- # Validate instruction using centralized validation
456
- instruction = validate_agent_instruction(instruction)
457
-
458
- # Build payload using centralized builder
459
- payload = self._build_create_payload(
460
- name=name,
461
- instruction=instruction,
462
- model=model,
463
- tools=tools,
464
- agents=agents,
465
- timeout=timeout,
466
- **kwargs,
467
- )
863
+ if file is not None:
864
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
865
+ if overrides.get("model") is None:
866
+ payload.pop("model", None)
867
+ else:
868
+ payload = overrides
468
869
 
469
- # Create the agent and fetch full details
470
- full_agent_data = self._post_then_fetch(
471
- id_key="id",
472
- post_endpoint=AGENTS_ENDPOINT,
473
- get_endpoint_fmt=f"{AGENTS_ENDPOINT}{{id}}",
474
- json=payload,
870
+ return self._create_agent_from_payload(payload)
871
+
872
+ def create_agent_from_file( # pragma: no cover - thin compatibility wrapper
873
+ self,
874
+ file_path: str | PathLike[str],
875
+ **overrides: Any,
876
+ ) -> "Agent":
877
+ """Backward-compatible helper to create an agent from a configuration file."""
878
+ return self.create_agent(file=file_path, **overrides)
879
+
880
+ def _update_agent_from_payload(
881
+ self,
882
+ agent_id: str,
883
+ current_agent: Agent,
884
+ payload: Mapping[str, Any],
885
+ ) -> "Agent":
886
+ """Update an agent using a prepared payload mapping."""
887
+ known, extras = _split_known_and_extra(payload, AgentUpdateRequest.__dataclass_fields__)
888
+ _normalise_sequence_fields(known)
889
+
890
+ tool_refs = extras.pop("_tool_refs", None)
891
+ agent_refs = extras.pop("_agent_refs", None)
892
+ mcp_refs = extras.pop("_mcp_refs", None)
893
+
894
+ tools_value = known.pop("tools", None)
895
+ agents_value = known.pop("agents", None)
896
+ mcps_value = known.pop("mcps", None)
897
+
898
+ if tools_value is not None:
899
+ tools_value = self._resolve_tool_ids(tools_value, tool_refs)
900
+ if agents_value is not None:
901
+ agents_value = self._resolve_agent_ids(agents_value, agent_refs)
902
+ if mcps_value is not None:
903
+ mcps_value = self._resolve_mcp_ids(mcps_value, mcp_refs) # pragma: no cover
904
+
905
+ request = AgentUpdateRequest(
906
+ name=known.pop("name", None),
907
+ instruction=known.pop("instruction", None),
908
+ description=known.pop("description", None),
909
+ model=known.pop("model", None),
910
+ language_model_id=known.pop("language_model_id", None),
911
+ provider=known.pop("provider", None),
912
+ model_name=known.pop("model_name", None),
913
+ agent_type=known.pop("agent_type", known.pop("type", None)),
914
+ framework=known.pop("framework", None),
915
+ version=known.pop("version", None),
916
+ account_id=known.pop("account_id", None),
917
+ metadata=known.pop("metadata", None),
918
+ tools=tools_value,
919
+ tool_configs=known.pop("tool_configs", None),
920
+ agents=agents_value,
921
+ mcps=mcps_value,
922
+ agent_config=known.pop("agent_config", None),
923
+ a2a_profile=known.pop("a2a_profile", None),
924
+ extras={**known, **extras},
475
925
  )
476
- return Agent(**full_agent_data)._set_client(self)
926
+
927
+ payload_dict = request.to_payload(current_agent)
928
+
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)
477
932
 
478
933
  def update_agent(
479
934
  self,
@@ -481,29 +936,101 @@ class AgentClient(BaseClient):
481
936
  name: str | None = None,
482
937
  instruction: str | None = None,
483
938
  model: str | None = None,
939
+ *,
940
+ file: str | PathLike[str] | None = None,
941
+ tools: list[str | Any] | None = None,
942
+ agents: list[str | Any] | None = None,
943
+ mcps: list[str | Any] | None = None,
484
944
  **kwargs: Any,
485
945
  ) -> "Agent":
486
946
  """Update an existing agent."""
487
- # First, get the current agent data
488
- current_agent = self.get_agent_by_id(agent_id)
947
+ base_overrides = {
948
+ "name": name,
949
+ "instruction": instruction,
950
+ "model": model,
951
+ "tools": tools,
952
+ "agents": agents,
953
+ "mcps": mcps,
954
+ }
955
+ overrides = _merge_override_maps(base_overrides, kwargs)
489
956
 
490
- # Build payload using centralized builder
491
- update_data = self._build_update_payload(
492
- current_agent=current_agent,
493
- name=name,
494
- instruction=instruction,
495
- model=model,
496
- **kwargs,
497
- )
957
+ if file is not None:
958
+ payload = _prepare_import_payload(Path(file).expanduser(), overrides, drop_model_fields=True)
959
+ else:
960
+ payload = overrides
498
961
 
499
- # Send the complete payload
500
- data = self._request("PUT", f"/agents/{agent_id}", json=update_data)
501
- return Agent(**data)._set_client(self)
962
+ current_agent = self.get_agent_by_id(agent_id)
963
+ return self._update_agent_from_payload(agent_id, current_agent, payload)
964
+
965
+ def update_agent_from_file( # pragma: no cover - thin compatibility wrapper
966
+ self,
967
+ agent_id: str,
968
+ file_path: str | PathLike[str],
969
+ **overrides: Any,
970
+ ) -> "Agent":
971
+ """Backward-compatible helper to update an agent from a configuration file."""
972
+ return self.update_agent(agent_id, file=file_path, **overrides)
502
973
 
503
974
  def delete_agent(self, agent_id: str) -> None:
504
975
  """Delete an agent."""
505
976
  self._request("DELETE", f"/agents/{agent_id}")
506
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
+
507
1034
  def _prepare_sync_request_data(
508
1035
  self,
509
1036
  message: str,
@@ -546,9 +1073,7 @@ class AgentClient(BaseClient):
546
1073
  payload["tty"] = True
547
1074
  return payload, None, None, headers, None
548
1075
 
549
- def _get_timeout_values(
550
- self, timeout: float | None, **kwargs: Any
551
- ) -> tuple[float, float]:
1076
+ def _get_timeout_values(self, timeout: float | None, **kwargs: Any) -> tuple[float, float]:
552
1077
  """Get request timeout and execution timeout values.
553
1078
 
554
1079
  Args:
@@ -562,197 +1087,6 @@ class AgentClient(BaseClient):
562
1087
  execution_timeout = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
563
1088
  return request_timeout, execution_timeout
564
1089
 
565
- def _create_renderer(
566
- self, renderer: RichStreamRenderer | None, **kwargs: Any
567
- ) -> RichStreamRenderer:
568
- """Create appropriate renderer based on configuration."""
569
- if isinstance(renderer, RichStreamRenderer):
570
- return renderer
571
-
572
- verbose = kwargs.get("verbose", False)
573
-
574
- if isinstance(renderer, str):
575
- if renderer == "silent":
576
- return self._create_silent_renderer()
577
- elif renderer == "minimal":
578
- return self._create_minimal_renderer()
579
- else:
580
- return self._create_default_renderer(verbose)
581
- elif verbose:
582
- return self._create_verbose_renderer()
583
- else:
584
- return self._create_default_renderer(verbose)
585
-
586
- def _create_silent_renderer(self) -> RichStreamRenderer:
587
- """Create a silent renderer that suppresses all output."""
588
- silent_config = RendererConfig(
589
- live=False,
590
- persist_live=False,
591
- show_delegate_tool_panels=False,
592
- render_thinking=False,
593
- )
594
- return RichStreamRenderer(
595
- console=_Console(file=io.StringIO(), force_terminal=False),
596
- cfg=silent_config,
597
- verbose=False,
598
- )
599
-
600
- def _create_minimal_renderer(self) -> RichStreamRenderer:
601
- """Create a minimal renderer with basic output."""
602
- minimal_config = RendererConfig(
603
- live=False,
604
- persist_live=False,
605
- show_delegate_tool_panels=False,
606
- render_thinking=False,
607
- )
608
- return RichStreamRenderer(
609
- console=_Console(),
610
- cfg=minimal_config,
611
- verbose=False,
612
- )
613
-
614
- def _create_verbose_renderer(self) -> RichStreamRenderer:
615
- """Create a verbose renderer for detailed output."""
616
- verbose_config = RendererConfig(
617
- theme="dark",
618
- style="debug",
619
- live=False,
620
- show_delegate_tool_panels=True,
621
- append_finished_snapshots=False,
622
- )
623
- return RichStreamRenderer(
624
- console=_Console(),
625
- cfg=verbose_config,
626
- verbose=True,
627
- )
628
-
629
- def _create_default_renderer(self, verbose: bool) -> RichStreamRenderer:
630
- """Create the default renderer."""
631
- if verbose:
632
- return self._create_verbose_renderer()
633
- else:
634
- default_config = RendererConfig(show_delegate_tool_panels=True)
635
- return RichStreamRenderer(console=_Console(), cfg=default_config)
636
-
637
- def _initialize_stream_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]:
638
- """Initialize stream metadata."""
639
- return {
640
- "agent_name": kwargs.get("agent_name", ""),
641
- "model": kwargs.get("model"),
642
- "run_id": None,
643
- "input_message": "", # Will be set from kwargs if available
644
- }
645
-
646
- def _capture_request_id(
647
- self,
648
- stream_response: httpx.Response,
649
- meta: dict[str, Any],
650
- renderer: RichStreamRenderer,
651
- ) -> None:
652
- """Capture request ID from response headers."""
653
- req_id = stream_response.headers.get(
654
- "x-request-id"
655
- ) or stream_response.headers.get("x-run-id")
656
- if req_id:
657
- meta["run_id"] = req_id
658
- renderer.on_start(meta)
659
-
660
- def _should_start_timer(self, ev: dict[str, Any]) -> bool:
661
- """Check if timer should be started for this event."""
662
- return "content" in ev or "status" in ev or ev.get("metadata")
663
-
664
- def _handle_content_event(self, ev: dict[str, Any], final_text: str) -> str:
665
- """Handle content events."""
666
- content = ev.get("content", "")
667
- if not content.startswith("Artifact received:"):
668
- return content
669
- return final_text
670
-
671
- def _handle_usage_event(
672
- self, ev: dict[str, Any], stats_usage: dict[str, Any]
673
- ) -> None:
674
- """Handle usage events."""
675
- stats_usage.update(ev.get("usage") or {})
676
-
677
- def _handle_run_info_event(
678
- self, ev: dict[str, Any], meta: dict[str, Any], renderer: RichStreamRenderer
679
- ) -> None:
680
- """Handle run info events."""
681
- if ev.get("model"):
682
- meta["model"] = ev["model"]
683
- renderer.on_start(meta)
684
- if ev.get("run_id"):
685
- meta["run_id"] = ev["run_id"]
686
- renderer.on_start(meta)
687
-
688
- def _process_single_event(
689
- self,
690
- event: dict[str, Any],
691
- renderer: RichStreamRenderer,
692
- final_text: str,
693
- stats_usage: dict[str, Any],
694
- meta: dict[str, Any],
695
- ) -> tuple[str, dict[str, Any]]:
696
- """Process a single streaming event."""
697
- try:
698
- ev = json.loads(event["data"])
699
- except json.JSONDecodeError:
700
- logger.debug("Non-JSON SSE fragment skipped")
701
- return final_text, stats_usage
702
-
703
- kind = (ev.get("metadata") or {}).get("kind")
704
- renderer.on_event(ev)
705
-
706
- # Skip artifacts from content accumulation
707
- if kind == "artifact":
708
- return final_text, stats_usage
709
-
710
- # Handle different event types
711
- if kind == "final_response" and ev.get("content"):
712
- final_text = ev.get("content", "")
713
- elif ev.get("content"):
714
- final_text = self._handle_content_event(ev, final_text)
715
- elif kind == "usage":
716
- self._handle_usage_event(ev, stats_usage)
717
- elif kind == "run_info":
718
- self._handle_run_info_event(ev, meta, renderer)
719
-
720
- return final_text, stats_usage
721
-
722
- def _process_stream_events(
723
- self,
724
- stream_response: httpx.Response,
725
- renderer: RichStreamRenderer,
726
- timeout_seconds: float,
727
- agent_name: str | None,
728
- kwargs: dict[str, Any],
729
- ) -> tuple[str, dict[str, Any], float | None, float | None]:
730
- """Process streaming events and accumulate response."""
731
- final_text = ""
732
- stats_usage = {}
733
- started_monotonic = None
734
- finished_monotonic = None
735
-
736
- meta = self._initialize_stream_metadata(kwargs)
737
- self._capture_request_id(stream_response, meta, renderer)
738
-
739
- for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
740
- # Start timer at first meaningful event
741
- if started_monotonic is None:
742
- try:
743
- ev = json.loads(event["data"])
744
- if self._should_start_timer(ev):
745
- started_monotonic = monotonic()
746
- except json.JSONDecodeError:
747
- pass
748
-
749
- final_text, stats_usage = self._process_single_event(
750
- event, renderer, final_text, stats_usage, meta
751
- )
752
-
753
- finished_monotonic = monotonic()
754
- return final_text, stats_usage, started_monotonic, finished_monotonic
755
-
756
1090
  def run_agent(
757
1091
  self,
758
1092
  agent_id: str,
@@ -761,10 +1095,32 @@ class AgentClient(BaseClient):
761
1095
  tty: bool = False,
762
1096
  *,
763
1097
  renderer: RichStreamRenderer | str | None = "auto",
1098
+ runtime_config: dict[str, Any] | None = None,
764
1099
  **kwargs,
765
1100
  ) -> str:
766
- """Run an agent with a message, streaming via a renderer."""
767
- # Prepare request payload and headers
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
768
1124
  (
769
1125
  payload,
770
1126
  data_payload,
@@ -773,20 +1129,20 @@ class AgentClient(BaseClient):
773
1129
  multipart_data,
774
1130
  ) = self._prepare_sync_request_data(message, files, tty, **kwargs)
775
1131
 
776
- # Create renderer
777
- r = self._create_renderer(renderer, **kwargs)
1132
+ render_manager = self._get_renderer_manager()
1133
+ verbose = kwargs.get("verbose", False)
1134
+ r = self._create_renderer(renderer, verbose=verbose)
1135
+ meta = render_manager.build_initial_metadata(agent_id, message, kwargs)
1136
+ render_manager.start_renderer(r, meta)
778
1137
 
779
- # Initialize renderer
780
- meta = {
781
- "agent_name": kwargs.get("agent_name", agent_id),
782
- "model": kwargs.get("model"),
783
- "run_id": None,
784
- "input_message": message,
785
- }
786
- r.on_start(meta)
1138
+ final_text = ""
1139
+ stats_usage: dict[str, Any] = {}
1140
+ started_monotonic: float | None = None
1141
+ finished_monotonic: float | None = None
1142
+
1143
+ timeout_seconds = compute_timeout_seconds(kwargs)
787
1144
 
788
1145
  try:
789
- # Make streaming request
790
1146
  response = self.http_client.stream(
791
1147
  "POST",
792
1148
  f"/agents/{agent_id}/run",
@@ -794,13 +1150,12 @@ class AgentClient(BaseClient):
794
1150
  data=data_payload,
795
1151
  files=files_payload,
796
1152
  headers=headers,
1153
+ timeout=timeout_seconds,
797
1154
  )
798
1155
 
799
1156
  with response as stream_response:
800
1157
  stream_response.raise_for_status()
801
1158
 
802
- # Process streaming events
803
- timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
804
1159
  agent_name = kwargs.get("agent_name")
805
1160
 
806
1161
  (
@@ -809,7 +1164,11 @@ class AgentClient(BaseClient):
809
1164
  started_monotonic,
810
1165
  finished_monotonic,
811
1166
  ) = self._process_stream_events(
812
- stream_response, r, timeout_seconds, agent_name, kwargs
1167
+ stream_response,
1168
+ r,
1169
+ timeout_seconds,
1170
+ agent_name,
1171
+ meta,
813
1172
  )
814
1173
 
815
1174
  except KeyboardInterrupt:
@@ -823,25 +1182,16 @@ class AgentClient(BaseClient):
823
1182
  finally:
824
1183
  raise
825
1184
  finally:
826
- # Ensure cleanup
827
1185
  if multipart_data:
828
1186
  multipart_data.close()
829
1187
 
830
- # Finalize and return result
831
- st = RunStats()
832
- st.started_at = started_monotonic or st.started_at
833
- st.finished_at = finished_monotonic or st.started_at
834
- st.usage = stats_usage
835
-
836
- # Get final content
837
- if hasattr(r, "state") and hasattr(r.state, "buffer"):
838
- rendered_text = "".join(r.state.buffer)
839
- else:
840
- rendered_text = ""
841
-
842
- final_payload = final_text or rendered_text or "No response content received."
843
- r.on_complete(st)
844
- return final_payload
1188
+ return self._finalize_renderer(
1189
+ r,
1190
+ final_text,
1191
+ stats_usage,
1192
+ started_monotonic,
1193
+ finished_monotonic,
1194
+ )
845
1195
 
846
1196
  def _prepare_request_data(
847
1197
  self,
@@ -871,9 +1221,7 @@ class AgentClient(BaseClient):
871
1221
  headers = {"Accept": SSE_CONTENT_TYPE}
872
1222
  return payload, None, None, headers
873
1223
 
874
- def _create_async_client_config(
875
- self, timeout: float | None, headers: dict | None
876
- ) -> dict:
1224
+ def _create_async_client_config(self, timeout: float | None, headers: dict | None) -> dict:
877
1225
  """Create async client configuration with proper headers and timeout."""
878
1226
  config = self._build_async_client(timeout or self.timeout)
879
1227
  if headers:
@@ -902,9 +1250,7 @@ class AgentClient(BaseClient):
902
1250
  ) as stream_response:
903
1251
  stream_response.raise_for_status()
904
1252
 
905
- async for event in aiter_sse_events(
906
- stream_response, timeout_seconds, agent_name
907
- ):
1253
+ async for event in aiter_sse_events(stream_response, timeout_seconds, agent_name):
908
1254
  try:
909
1255
  chunk = json.loads(event["data"])
910
1256
  yield chunk
@@ -918,7 +1264,8 @@ class AgentClient(BaseClient):
918
1264
  message: str,
919
1265
  files: list[str | BinaryIO] | None = None,
920
1266
  *,
921
- timeout: float | None = None,
1267
+ request_timeout: float | None = None,
1268
+ runtime_config: dict[str, Any] | None = None,
922
1269
  **kwargs,
923
1270
  ) -> AsyncGenerator[dict, None]:
924
1271
  """Async run an agent with a message, yielding streaming JSON chunks.
@@ -927,7 +1274,14 @@ class AgentClient(BaseClient):
927
1274
  agent_id: ID of the agent to run
928
1275
  message: Message to send to the agent
929
1276
  files: Optional list of files to include
930
- 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
+ }
931
1285
  **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
932
1286
 
933
1287
  Yields:
@@ -938,20 +1292,25 @@ class AgentClient(BaseClient):
938
1292
  httpx.TimeoutException: When general timeout occurs
939
1293
  Exception: For other unexpected errors
940
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
+
941
1303
  # Prepare request data
942
- payload, data_payload, files_payload, headers = self._prepare_request_data(
943
- message, files, **kwargs
944
- )
1304
+ payload, data_payload, files_payload, headers = self._prepare_request_data(message, files, **kwargs)
945
1305
 
946
1306
  # Create async client configuration
947
- async_client_config = self._create_async_client_config(timeout, headers)
1307
+ async_client_config = self._create_async_client_config(http_timeout_override, headers)
948
1308
 
949
1309
  # Get execution timeout for streaming control
950
1310
  timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
951
1311
  agent_name = kwargs.get("agent_name")
952
1312
 
953
- try:
954
- # Create async client and stream response
1313
+ async def _chunk_stream() -> AsyncGenerator[dict, None]:
955
1314
  async with httpx.AsyncClient(**async_client_config) as async_client:
956
1315
  async for chunk in self._stream_agent_response(
957
1316
  async_client,
@@ -965,7 +1324,14 @@ class AgentClient(BaseClient):
965
1324
  ):
966
1325
  yield chunk
967
1326
 
968
- finally:
969
- # Ensure cleanup - this is handled by the calling context
970
- # but we keep this for safety in case of future changes
971
- 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