glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__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 (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +104 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +241 -121
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +291 -35
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +466 -89
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
@@ -7,9 +7,9 @@ Authors:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import io
11
10
  import json
12
11
  import logging
12
+ from collections.abc import AsyncIterable, Callable
13
13
  from time import monotonic
14
14
  from typing import Any
15
15
 
@@ -19,8 +19,18 @@ from rich.console import Console as _Console
19
19
  from glaip_sdk.config.constants import DEFAULT_AGENT_RUN_TIMEOUT
20
20
  from glaip_sdk.utils.client_utils import iter_sse_events
21
21
  from glaip_sdk.utils.rendering.models import RunStats
22
- from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
23
- from glaip_sdk.utils.rendering.renderer.config import RendererConfig
22
+ from glaip_sdk.utils.rendering.renderer import (
23
+ RendererFactoryOptions,
24
+ RichStreamRenderer,
25
+ make_default_renderer,
26
+ make_minimal_renderer,
27
+ make_silent_renderer,
28
+ make_verbose_renderer,
29
+ )
30
+ from glaip_sdk.utils.rendering.state import TranscriptBuffer
31
+
32
+ NO_AGENT_RESPONSE_FALLBACK = "No agent response received."
33
+ _FINAL_EVENT_TYPES = {"final_response", "error", "step_limit_exceeded"}
24
34
 
25
35
 
26
36
  def _coerce_to_string(value: Any) -> str:
@@ -36,41 +46,6 @@ def _has_visible_text(value: Any) -> bool:
36
46
  return isinstance(value, str) and bool(value.strip())
37
47
 
38
48
 
39
- def _update_state_transcript(state: Any, text_value: str) -> bool:
40
- """Inject transcript text into renderer state if possible."""
41
- if state is None:
42
- return False
43
-
44
- updated = False
45
-
46
- if hasattr(state, "final_text") and not _has_visible_text(getattr(state, "final_text", "")):
47
- try:
48
- state.final_text = text_value
49
- updated = True
50
- except Exception:
51
- pass
52
-
53
- buffer = getattr(state, "buffer", None)
54
- if isinstance(buffer, list) and not any(_has_visible_text(item) for item in buffer):
55
- buffer.append(text_value)
56
- updated = True
57
-
58
- return updated
59
-
60
-
61
- def _update_renderer_transcript(renderer: Any, text_value: str) -> None:
62
- """Populate the renderer (or its state) with the supplied text."""
63
- state = getattr(renderer, "state", None)
64
- if _update_state_transcript(state, text_value):
65
- return
66
-
67
- if hasattr(renderer, "final_text") and not _has_visible_text(getattr(renderer, "final_text", "")):
68
- try:
69
- renderer.final_text = text_value
70
- except Exception:
71
- pass
72
-
73
-
74
49
  class AgentRunRenderingManager:
75
50
  """Coordinate renderer creation and streaming event handling."""
76
51
 
@@ -81,6 +56,7 @@ class AgentRunRenderingManager:
81
56
  logger: Optional logger instance, creates default if None
82
57
  """
83
58
  self._logger = logger or logging.getLogger(__name__)
59
+ self._buffer_factory = TranscriptBuffer
84
60
 
85
61
  # --------------------------------------------------------------------- #
86
62
  # Renderer setup helpers
@@ -92,17 +68,38 @@ class AgentRunRenderingManager:
92
68
  verbose: bool = False,
93
69
  ) -> RichStreamRenderer:
94
70
  """Create an appropriate renderer based on the supplied spec."""
71
+ transcript_buffer = self._buffer_factory()
72
+ base_options = RendererFactoryOptions(console=_Console(), transcript_buffer=transcript_buffer)
95
73
  if isinstance(renderer_spec, RichStreamRenderer):
96
74
  return renderer_spec
97
75
 
98
76
  if isinstance(renderer_spec, str):
99
- if renderer_spec == "silent":
100
- return self._create_silent_renderer()
101
- if renderer_spec == "minimal":
102
- return self._create_minimal_renderer()
103
- return self._create_default_renderer(verbose)
77
+ lowered = renderer_spec.lower()
78
+ if lowered == "silent":
79
+ return self._attach_buffer(base_options.build(make_silent_renderer), transcript_buffer)
80
+ if lowered == "minimal":
81
+ return self._attach_buffer(base_options.build(make_minimal_renderer), transcript_buffer)
82
+ if lowered == "verbose":
83
+ return self._attach_buffer(base_options.build(make_verbose_renderer), transcript_buffer)
104
84
 
105
- return self._create_default_renderer(verbose)
85
+ if verbose:
86
+ return self._attach_buffer(base_options.build(make_verbose_renderer), transcript_buffer)
87
+
88
+ default_options = RendererFactoryOptions(
89
+ console=_Console(),
90
+ transcript_buffer=transcript_buffer,
91
+ verbose=verbose,
92
+ )
93
+ return self._attach_buffer(default_options.build(make_default_renderer), transcript_buffer)
94
+
95
+ @staticmethod
96
+ def _attach_buffer(renderer: RichStreamRenderer, buffer: TranscriptBuffer) -> RichStreamRenderer:
97
+ """Attach a captured transcript buffer to a renderer for later inspection."""
98
+ try:
99
+ renderer._captured_transcript_buffer = buffer # type: ignore[attr-defined]
100
+ except Exception:
101
+ pass
102
+ return renderer
106
103
 
107
104
  def build_initial_metadata(
108
105
  self,
@@ -123,47 +120,6 @@ class AgentRunRenderingManager:
123
120
  """Notify renderer that streaming is starting."""
124
121
  renderer.on_start(meta)
125
122
 
126
- def _create_silent_renderer(self) -> RichStreamRenderer:
127
- silent_config = RendererConfig(
128
- live=False,
129
- persist_live=False,
130
- render_thinking=False,
131
- )
132
- return RichStreamRenderer(
133
- console=_Console(file=io.StringIO(), force_terminal=False),
134
- cfg=silent_config,
135
- verbose=False,
136
- )
137
-
138
- def _create_minimal_renderer(self) -> RichStreamRenderer:
139
- minimal_config = RendererConfig(
140
- live=False,
141
- persist_live=False,
142
- render_thinking=False,
143
- )
144
- return RichStreamRenderer(
145
- console=_Console(),
146
- cfg=minimal_config,
147
- verbose=False,
148
- )
149
-
150
- def _create_verbose_renderer(self) -> RichStreamRenderer:
151
- verbose_config = RendererConfig(
152
- live=False,
153
- append_finished_snapshots=False,
154
- )
155
- return RichStreamRenderer(
156
- console=_Console(),
157
- cfg=verbose_config,
158
- verbose=True,
159
- )
160
-
161
- def _create_default_renderer(self, verbose: bool) -> RichStreamRenderer:
162
- if verbose:
163
- return self._create_verbose_renderer()
164
- default_config = RendererConfig()
165
- return RichStreamRenderer(console=_Console(), cfg=default_config)
166
-
167
123
  # --------------------------------------------------------------------- #
168
124
  # Streaming event handling
169
125
  # --------------------------------------------------------------------- #
@@ -201,6 +157,80 @@ class AgentRunRenderingManager:
201
157
 
202
158
  if controller and getattr(controller, "enabled", False):
203
159
  controller.poll(renderer)
160
+ parsed_event = self._parse_event(event)
161
+ if parsed_event and self._is_final_event(parsed_event):
162
+ break
163
+ finally:
164
+ if controller and getattr(controller, "enabled", False):
165
+ controller.on_stream_complete()
166
+
167
+ finished_monotonic = monotonic()
168
+ return final_text, stats_usage, started_monotonic, finished_monotonic
169
+
170
+ async def async_process_stream_events(
171
+ self,
172
+ event_stream: AsyncIterable[dict[str, Any]],
173
+ renderer: RichStreamRenderer,
174
+ meta: dict[str, Any],
175
+ *,
176
+ skip_final_render: bool = True,
177
+ ) -> tuple[str, dict[str, Any], float | None, float | None]:
178
+ """Process streaming events from an async event source.
179
+
180
+ This method provides unified stream processing for both remote (HTTP)
181
+ and local (LangGraph) agent execution, ensuring consistent behavior.
182
+
183
+ Args:
184
+ event_stream: Async iterable yielding SSE-like event dicts.
185
+ Each event should have a "data" key with JSON string, or be
186
+ a pre-parsed dict with "content", "metadata", etc.
187
+ renderer: Renderer to use for displaying events.
188
+ meta: Metadata dictionary for renderer context.
189
+ skip_final_render: If True, skip rendering final_response events
190
+ (they are rendered separately via finalize_renderer).
191
+
192
+ Returns:
193
+ Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
194
+ """
195
+ final_text = ""
196
+ stats_usage: dict[str, Any] = {}
197
+ started_monotonic: float | None = None
198
+ last_rendered_content: str | None = None
199
+
200
+ controller = getattr(renderer, "transcript_controller", None)
201
+ if controller and getattr(controller, "enabled", False):
202
+ controller.on_stream_start(renderer)
203
+
204
+ try:
205
+ async for event in event_stream:
206
+ if started_monotonic is None:
207
+ started_monotonic = monotonic()
208
+
209
+ # Parse event if needed (handles both raw SSE and pre-parsed dicts)
210
+ parsed_event = self._parse_event(event)
211
+ if parsed_event is None:
212
+ continue
213
+
214
+ # Process the event and update accumulators
215
+ final_text, stats_usage = self._handle_parsed_event(
216
+ parsed_event,
217
+ renderer,
218
+ final_text,
219
+ stats_usage,
220
+ meta,
221
+ skip_final_render=skip_final_render,
222
+ last_rendered_content=last_rendered_content,
223
+ )
224
+
225
+ # Track last rendered content to avoid duplicates
226
+ content_str = self._extract_content_string(parsed_event)
227
+ if content_str:
228
+ last_rendered_content = content_str
229
+
230
+ if controller and getattr(controller, "enabled", False):
231
+ controller.poll(renderer)
232
+ if parsed_event and self._is_final_event(parsed_event):
233
+ break
204
234
  finally:
205
235
  if controller and getattr(controller, "enabled", False):
206
236
  controller.on_stream_complete()
@@ -208,18 +238,256 @@ class AgentRunRenderingManager:
208
238
  finished_monotonic = monotonic()
209
239
  return final_text, stats_usage, started_monotonic, finished_monotonic
210
240
 
241
+ def _parse_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
242
+ """Parse an SSE event dict into a usable format.
243
+
244
+ Args:
245
+ event: Raw event dict, either with "data" key (SSE format) or
246
+ pre-parsed with "content", "metadata", etc.
247
+
248
+ Returns:
249
+ Parsed event dict, or None if parsing fails.
250
+ """
251
+ if "data" in event:
252
+ try:
253
+ return json.loads(event["data"])
254
+ except json.JSONDecodeError:
255
+ self._logger.debug("Non-JSON SSE fragment skipped")
256
+ return None
257
+ # Already parsed (e.g., from local runner)
258
+ return event if event else None
259
+
260
+ def _handle_parsed_event(
261
+ self,
262
+ ev: dict[str, Any],
263
+ renderer: RichStreamRenderer,
264
+ final_text: str,
265
+ stats_usage: dict[str, Any],
266
+ meta: dict[str, Any],
267
+ *,
268
+ skip_final_render: bool = True,
269
+ last_rendered_content: str | None = None,
270
+ ) -> tuple[str, dict[str, Any]]:
271
+ """Handle a parsed event and update accumulators.
272
+
273
+ Args:
274
+ ev: Parsed event dictionary.
275
+ renderer: Renderer instance.
276
+ final_text: Current accumulated final text.
277
+ stats_usage: Usage statistics dictionary.
278
+ meta: Metadata dictionary.
279
+ skip_final_render: If True, skip rendering final_response events.
280
+ last_rendered_content: Last rendered content to avoid duplicates.
281
+
282
+ Returns:
283
+ Tuple of (updated_final_text, updated_stats_usage).
284
+ """
285
+ kind = self._get_event_kind(ev)
286
+
287
+ # Dispatch to specialized handlers based on event kind
288
+ handler = self._get_event_handler(kind, ev)
289
+ if handler:
290
+ return handler(ev, renderer, final_text, stats_usage, meta, skip_final_render)
291
+
292
+ # Default: handle content events
293
+ return self._handle_content_event_async(ev, renderer, final_text, stats_usage, last_rendered_content)
294
+
295
+ def _get_event_handler(
296
+ self,
297
+ kind: str | None,
298
+ ev: dict[str, Any],
299
+ ) -> Callable[..., tuple[str, dict[str, Any]]] | None:
300
+ """Get the appropriate handler for an event kind.
301
+
302
+ Args:
303
+ kind: Event kind string.
304
+ ev: Event dictionary (for checking is_final flag).
305
+
306
+ Returns:
307
+ Handler function or None for default content handling.
308
+ """
309
+ if kind == "usage":
310
+ return self._handle_usage_event
311
+ if kind == "final_response" or ev.get("is_final"):
312
+ return self._handle_final_response_event
313
+ if kind == "run_info":
314
+ return self._handle_run_info_event_wrapper
315
+ if kind in ("artifact", "status_update"):
316
+ return self._handle_render_only_event
317
+ return None
318
+
319
+ def _handle_usage_event(
320
+ self,
321
+ ev: dict[str, Any],
322
+ _renderer: RichStreamRenderer,
323
+ final_text: str,
324
+ stats_usage: dict[str, Any],
325
+ _meta: dict[str, Any],
326
+ _skip_final_render: bool,
327
+ ) -> tuple[str, dict[str, Any]]:
328
+ """Handle usage events."""
329
+ stats_usage.update(ev.get("usage") or {})
330
+ return final_text, stats_usage
331
+
332
+ def _handle_final_response_event(
333
+ self,
334
+ ev: dict[str, Any],
335
+ renderer: RichStreamRenderer,
336
+ final_text: str,
337
+ stats_usage: dict[str, Any],
338
+ _meta: dict[str, Any],
339
+ skip_final_render: bool,
340
+ ) -> tuple[str, dict[str, Any]]:
341
+ """Handle final_response events."""
342
+ content = ev.get("content")
343
+ if content:
344
+ final_text = str(content)
345
+ if not skip_final_render:
346
+ renderer.on_event(ev)
347
+ return final_text, stats_usage
348
+
349
+ def _handle_run_info_event_wrapper(
350
+ self,
351
+ ev: dict[str, Any],
352
+ renderer: RichStreamRenderer,
353
+ final_text: str,
354
+ stats_usage: dict[str, Any],
355
+ meta: dict[str, Any],
356
+ _skip_final_render: bool,
357
+ ) -> tuple[str, dict[str, Any]]:
358
+ """Handle run_info events."""
359
+ self._handle_run_info_event(ev, meta, renderer)
360
+ return final_text, stats_usage
361
+
362
+ def _handle_render_only_event(
363
+ self,
364
+ ev: dict[str, Any],
365
+ renderer: RichStreamRenderer,
366
+ final_text: str,
367
+ stats_usage: dict[str, Any],
368
+ _meta: dict[str, Any],
369
+ _skip_final_render: bool,
370
+ ) -> tuple[str, dict[str, Any]]:
371
+ """Handle events that only need rendering (artifact, status_update)."""
372
+ renderer.on_event(ev)
373
+ return final_text, stats_usage
374
+
375
+ def _handle_content_event_async(
376
+ self,
377
+ ev: dict[str, Any],
378
+ renderer: RichStreamRenderer,
379
+ final_text: str,
380
+ stats_usage: dict[str, Any],
381
+ last_rendered_content: str | None,
382
+ ) -> tuple[str, dict[str, Any]]:
383
+ """Handle content events with deduplication."""
384
+ content = ev.get("content")
385
+ if content:
386
+ content_str = str(content)
387
+ if not content_str.startswith("Artifact received:"):
388
+ kind = self._get_event_kind(ev)
389
+ # Skip accumulating content for status updates and agent steps
390
+ if kind in ("agent_step", "status_update"):
391
+ renderer.on_event(ev)
392
+ return final_text, stats_usage
393
+
394
+ if self._is_token_event(ev):
395
+ renderer.on_event(ev)
396
+ final_text = f"{final_text}{content_str}"
397
+ else:
398
+ if content_str != last_rendered_content:
399
+ renderer.on_event(ev)
400
+ final_text = content_str
401
+ else:
402
+ renderer.on_event(ev)
403
+ return final_text, stats_usage
404
+
405
+ def _get_event_kind(self, ev: dict[str, Any]) -> str | None:
406
+ """Extract normalized event kind from parsed event.
407
+
408
+ Args:
409
+ ev: Parsed event dictionary.
410
+
411
+ Returns:
412
+ Event kind string or None.
413
+ """
414
+ metadata = ev.get("metadata") or {}
415
+ kind = metadata.get("kind")
416
+ if kind:
417
+ return str(kind)
418
+ event_type = ev.get("event_type")
419
+ return str(event_type) if event_type else None
420
+
421
+ def _is_token_event(self, ev: dict[str, Any]) -> bool:
422
+ """Return True when the event represents token streaming output.
423
+
424
+ Args:
425
+ ev: Parsed event dictionary.
426
+
427
+ Returns:
428
+ True when the event is a token chunk, otherwise False.
429
+ """
430
+ metadata = ev.get("metadata") or {}
431
+ kind = metadata.get("kind")
432
+ return str(kind).lower() == "token"
433
+
434
+ def _is_final_event(self, ev: dict[str, Any]) -> bool:
435
+ """Return True when the event marks stream termination.
436
+
437
+ Args:
438
+ ev: Parsed event dictionary.
439
+
440
+ Returns:
441
+ True when the event is terminal, otherwise False.
442
+ """
443
+ if ev.get("is_final") is True or ev.get("final") is True:
444
+ return True
445
+ kind = self._get_event_kind(ev)
446
+ return kind in _FINAL_EVENT_TYPES
447
+
448
+ def _extract_content_string(self, event: dict[str, Any]) -> str | None:
449
+ """Extract textual content from a parsed event.
450
+
451
+ Args:
452
+ event: Parsed event dictionary.
453
+
454
+ Returns:
455
+ Content string or None.
456
+ """
457
+ if not event:
458
+ return None
459
+ content = event.get("content")
460
+ if content:
461
+ return str(content)
462
+ return None
463
+
211
464
  def _capture_request_id(
212
465
  self,
213
466
  stream_response: httpx.Response,
214
467
  meta: dict[str, Any],
215
468
  renderer: RichStreamRenderer,
216
469
  ) -> None:
470
+ """Capture request ID from response headers and update metadata.
471
+
472
+ Args:
473
+ stream_response: HTTP response stream.
474
+ meta: Metadata dictionary to update.
475
+ renderer: Renderer instance.
476
+ """
217
477
  req_id = stream_response.headers.get("x-request-id") or stream_response.headers.get("x-run-id")
218
478
  if req_id:
219
479
  meta["run_id"] = req_id
220
480
  renderer.on_start(meta)
221
481
 
222
482
  def _maybe_start_timer(self, event: dict[str, Any]) -> float | None:
483
+ """Start timing if this is a content-bearing event.
484
+
485
+ Args:
486
+ event: Event dictionary.
487
+
488
+ Returns:
489
+ Monotonic time if timer should start, None otherwise.
490
+ """
223
491
  try:
224
492
  ev = json.loads(event["data"])
225
493
  except json.JSONDecodeError:
@@ -237,6 +505,18 @@ class AgentRunRenderingManager:
237
505
  stats_usage: dict[str, Any],
238
506
  meta: dict[str, Any],
239
507
  ) -> tuple[str, dict[str, Any]]:
508
+ """Process a single streaming event.
509
+
510
+ Args:
511
+ event: Event dictionary.
512
+ renderer: Renderer instance.
513
+ final_text: Accumulated text so far.
514
+ stats_usage: Usage statistics dictionary.
515
+ meta: Metadata dictionary.
516
+
517
+ Returns:
518
+ Tuple of (updated_final_text, updated_stats_usage).
519
+ """
240
520
  try:
241
521
  ev = json.loads(event["data"])
242
522
  except json.JSONDecodeError:
@@ -257,7 +537,9 @@ class AgentRunRenderingManager:
257
537
  if handled is not None:
258
538
  return handled
259
539
 
260
- if ev.get("content"):
540
+ # Only accumulate content for actual content events, not status updates or agent steps
541
+ # Status updates (agent_step) should be rendered but not accumulated in final_text
542
+ if ev.get("content") and kind not in ("agent_step", "status_update"):
261
543
  final_text = self._handle_content_event(ev, final_text)
262
544
 
263
545
  return final_text, stats_usage
@@ -292,8 +574,19 @@ class AgentRunRenderingManager:
292
574
  return None
293
575
 
294
576
  def _handle_content_event(self, ev: dict[str, Any], final_text: str) -> str:
577
+ """Handle a content event and update final text.
578
+
579
+ Args:
580
+ ev: Event dictionary.
581
+ final_text: Current accumulated text.
582
+
583
+ Returns:
584
+ Updated final text.
585
+ """
295
586
  content = ev.get("content", "")
296
587
  if not content.startswith("Artifact received:"):
588
+ if self._is_token_event(ev):
589
+ return f"{final_text}{content}"
297
590
  return content
298
591
  return final_text
299
592
 
@@ -303,6 +596,13 @@ class AgentRunRenderingManager:
303
596
  meta: dict[str, Any],
304
597
  renderer: RichStreamRenderer,
305
598
  ) -> None:
599
+ """Handle a run_info event and update metadata.
600
+
601
+ Args:
602
+ ev: Event dictionary.
603
+ meta: Metadata dictionary to update.
604
+ renderer: Renderer instance.
605
+ """
306
606
  if ev.get("model"):
307
607
  meta["model"] = ev["model"]
308
608
  renderer.on_start(meta)
@@ -316,7 +616,52 @@ class AgentRunRenderingManager:
316
616
  return
317
617
 
318
618
  text_value = _coerce_to_string(text)
319
- _update_renderer_transcript(renderer, text_value)
619
+ state = getattr(renderer, "state", None)
620
+ if state is None:
621
+ self._ensure_renderer_text(renderer, text_value)
622
+ return
623
+
624
+ self._ensure_state_final_text(state, text_value)
625
+ self._ensure_state_buffer(state, text_value)
626
+
627
+ def _ensure_renderer_text(self, renderer: RichStreamRenderer, text_value: str) -> None:
628
+ """Best-effort assignment for renderer.final_text."""
629
+ if not hasattr(renderer, "final_text"):
630
+ return
631
+ current_text = getattr(renderer, "final_text", "")
632
+ if _has_visible_text(current_text):
633
+ return
634
+ self._safe_set_attr(renderer, "final_text", text_value)
635
+
636
+ def _ensure_state_final_text(self, state: Any, text_value: str) -> None:
637
+ """Best-effort assignment for renderer.state.final_text."""
638
+ current_text = getattr(state, "final_text", "")
639
+ if _has_visible_text(current_text):
640
+ return
641
+ self._safe_set_attr(state, "final_text", text_value)
642
+
643
+ def _ensure_state_buffer(self, state: Any, text_value: str) -> None:
644
+ """Append fallback text to the state buffer when available."""
645
+ buffer = getattr(state, "buffer", None)
646
+ if not hasattr(buffer, "append"):
647
+ return
648
+ self._safe_append(buffer.append, text_value)
649
+
650
+ @staticmethod
651
+ def _safe_set_attr(target: Any, attr: str, value: str) -> None:
652
+ """Assign attribute while masking renderer-specific failures."""
653
+ try:
654
+ setattr(target, attr, value)
655
+ except Exception:
656
+ pass
657
+
658
+ @staticmethod
659
+ def _safe_append(appender: Callable[[str], Any], value: str) -> None:
660
+ """Invoke append-like functions without leaking renderer errors."""
661
+ try:
662
+ appender(value)
663
+ except Exception:
664
+ pass
320
665
 
321
666
  # --------------------------------------------------------------------- #
322
667
  # Finalisation helpers
@@ -343,7 +688,9 @@ class AgentRunRenderingManager:
343
688
  elif hasattr(renderer, "buffer"):
344
689
  buffer_values = renderer.buffer
345
690
 
346
- if buffer_values is not None:
691
+ if isinstance(buffer_values, TranscriptBuffer):
692
+ rendered_text = buffer_values.render()
693
+ elif buffer_values is not None:
347
694
  try:
348
695
  rendered_text = "".join(buffer_values)
349
696
  except TypeError:
@@ -354,7 +701,7 @@ class AgentRunRenderingManager:
354
701
  self._ensure_renderer_final_content(renderer, fallback_text)
355
702
 
356
703
  renderer.on_complete(st)
357
- return final_text or rendered_text or "No response content received."
704
+ return final_text or rendered_text or NO_AGENT_RESPONSE_FALLBACK
358
705
 
359
706
 
360
707
  def compute_timeout_seconds(kwargs: dict[str, Any]) -> float:
@@ -368,3 +715,33 @@ def compute_timeout_seconds(kwargs: dict[str, Any]) -> float:
368
715
  if not specified in kwargs.
369
716
  """
370
717
  return kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
718
+
719
+
720
+ def finalize_render_manager(
721
+ manager: AgentRunRenderingManager,
722
+ renderer: RichStreamRenderer,
723
+ final_text: str,
724
+ stats_usage: dict[str, Any],
725
+ started_monotonic: float | None,
726
+ finished_monotonic: float | None,
727
+ ) -> str:
728
+ """Helper to finalize renderer via manager and return final text.
729
+
730
+ Args:
731
+ manager: The rendering manager instance.
732
+ renderer: Renderer to finalize.
733
+ final_text: Final text content.
734
+ stats_usage: Usage statistics dictionary.
735
+ started_monotonic: Start time (monotonic).
736
+ finished_monotonic: Finish time (monotonic).
737
+
738
+ Returns:
739
+ Final text string.
740
+ """
741
+ return manager.finalize_renderer(
742
+ renderer,
743
+ final_text,
744
+ stats_usage,
745
+ started_monotonic,
746
+ finished_monotonic,
747
+ )
@@ -0,0 +1,21 @@
1
+ """Shared helpers for client configuration wiring.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from glaip_sdk.client.base import BaseClient
12
+
13
+
14
+ def build_shared_config(client: BaseClient) -> dict[str, Any]:
15
+ """Return the keyword arguments used to initialize sub-clients."""
16
+ return {
17
+ "parent_client": client,
18
+ "api_url": client.api_url,
19
+ "api_key": client.api_key,
20
+ "timeout": client._timeout,
21
+ }