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