glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) 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 +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -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/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -13,16 +13,12 @@ from dataclasses import dataclass
13
13
  class RendererConfig:
14
14
  """Configuration for the RichStreamRenderer."""
15
15
 
16
- # Style and layout
17
- theme: str = "dark" # dark|light
18
- style: str = "pretty" # pretty|debug|minimal
19
-
20
16
  # Performance
21
- think_threshold: float = 0.7
22
17
  refresh_debounce: float = 0.25
23
18
  render_thinking: bool = True
24
19
  live: bool = True
25
20
  persist_live: bool = True
21
+ summary_display_window: int = 20
26
22
 
27
23
  # Scrollback/append options
28
24
  summary_max_steps: int = 0
@@ -7,31 +7,14 @@ Authors:
7
7
  import json
8
8
  from datetime import datetime, timezone
9
9
  from typing import Any
10
+ from collections.abc import Callable, Iterable
10
11
 
11
12
  from rich.console import Console
12
13
  from rich.markdown import Markdown
13
14
 
14
15
  from glaip_sdk.branding import PRIMARY, SUCCESS, WARNING
15
16
  from glaip_sdk.rich_components import AIPPanel
16
-
17
-
18
- def _coerce_datetime(value: Any) -> datetime | None:
19
- """Attempt to coerce an arbitrary value to an aware datetime."""
20
- if value is None:
21
- return None
22
-
23
- if isinstance(value, datetime):
24
- return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
25
-
26
- if isinstance(value, str):
27
- try:
28
- normalised = value.replace("Z", "+00:00")
29
- dt = datetime.fromisoformat(normalised)
30
- except ValueError:
31
- return None
32
- return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
33
-
34
- return None
17
+ from glaip_sdk.utils.datetime_helpers import coerce_datetime
35
18
 
36
19
 
37
20
  def _parse_event_timestamp(event: dict[str, Any], received_ts: datetime | None = None) -> datetime | None:
@@ -40,7 +23,7 @@ def _parse_event_timestamp(event: dict[str, Any], received_ts: datetime | None =
40
23
  return received_ts if received_ts.tzinfo else received_ts.replace(tzinfo=timezone.utc)
41
24
 
42
25
  ts_value = event.get("timestamp") or (event.get("metadata") or {}).get("timestamp")
43
- return _coerce_datetime(ts_value)
26
+ return coerce_datetime(ts_value)
44
27
 
45
28
 
46
29
  def _format_timestamp_for_display(dt: datetime) -> str:
@@ -170,3 +153,26 @@ def render_debug_event(
170
153
  except Exception as e:
171
154
  # Debug helpers must not break streaming
172
155
  print(f"Debug error: {e}") # Fallback debug output
156
+
157
+
158
+ def render_debug_event_stream(
159
+ events: Iterable[dict[str, Any]],
160
+ console: Console,
161
+ *,
162
+ resolve_timestamp: Callable[[dict[str, Any]], datetime | None],
163
+ ) -> None:
164
+ """Render a sequence of SSE events with baseline-aware timestamps."""
165
+ baseline: datetime | None = None
166
+ for event in events:
167
+ try:
168
+ received_ts = resolve_timestamp(event)
169
+ if baseline is None and received_ts is not None:
170
+ baseline = received_ts
171
+ render_debug_event(
172
+ event,
173
+ console,
174
+ received_ts=received_ts,
175
+ baseline_ts=baseline,
176
+ )
177
+ except Exception as exc: # pragma: no cover - debug stream resilience
178
+ console.print(f"[red]Debug stream error: {exc}[/red]")
@@ -0,0 +1,138 @@
1
+ """Renderer factory helpers for CLI, SDK, and slash sessions.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ from dataclasses import dataclass, is_dataclass, replace
11
+ from inspect import signature
12
+ from typing import Any
13
+ from collections.abc import Callable
14
+
15
+ from rich.console import Console
16
+
17
+ from glaip_sdk.utils.rendering.renderer.base import RichStreamRenderer
18
+ from glaip_sdk.utils.rendering.renderer.config import RendererConfig
19
+ from glaip_sdk.utils.rendering.state import TranscriptBuffer
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RendererFactoryOptions:
24
+ """Shared options for renderer factories."""
25
+
26
+ console: Console | None = None
27
+ cfg_overrides: dict[str, Any] | None = None
28
+ verbose: bool | None = None
29
+ transcript_buffer: TranscriptBuffer | None = None
30
+ callbacks: dict[str, Any] | None = None
31
+
32
+ def build(self, factory: Callable[..., RichStreamRenderer]) -> RichStreamRenderer:
33
+ """Instantiate a renderer using the provided factory and stored options."""
34
+ params = signature(factory).parameters
35
+ kwargs: dict[str, Any] = {}
36
+ if self.console is not None and "console" in params:
37
+ kwargs["console"] = self.console
38
+ if self.cfg_overrides is not None and "cfg_overrides" in params:
39
+ kwargs["cfg_overrides"] = self.cfg_overrides
40
+ if self.verbose is not None and "verbose" in params:
41
+ kwargs["verbose"] = self.verbose
42
+ if self.transcript_buffer is not None and "transcript_buffer" in params:
43
+ kwargs["transcript_buffer"] = self.transcript_buffer
44
+ if self.callbacks is not None and "callbacks" in params:
45
+ kwargs["callbacks"] = self.callbacks
46
+ return factory(**kwargs)
47
+
48
+
49
+ def _build_config(base: RendererConfig, overrides: dict[str, Any] | None = None) -> RendererConfig:
50
+ cfg = replace(base) if is_dataclass(base) else base
51
+ if overrides:
52
+ for key, value in overrides.items():
53
+ if hasattr(cfg, key):
54
+ setattr(cfg, key, value)
55
+ return cfg
56
+
57
+
58
+ def make_default_renderer(
59
+ *,
60
+ console: Console | None = None,
61
+ cfg_overrides: dict[str, Any] | None = None,
62
+ verbose: bool = False,
63
+ transcript_buffer: TranscriptBuffer | None = None,
64
+ callbacks: dict[str, Any] | None = None,
65
+ ) -> RichStreamRenderer:
66
+ """Create the default renderer used by SDK and CLI flows."""
67
+ cfg = _build_config(RendererConfig(), cfg_overrides)
68
+ return RichStreamRenderer(
69
+ console=console or Console(),
70
+ cfg=cfg,
71
+ verbose=verbose,
72
+ transcript_buffer=transcript_buffer,
73
+ callbacks=callbacks,
74
+ )
75
+
76
+
77
+ def make_verbose_renderer(
78
+ *,
79
+ console: Console | None = None,
80
+ cfg_overrides: dict[str, Any] | None = None,
81
+ transcript_buffer: TranscriptBuffer | None = None,
82
+ callbacks: dict[str, Any] | None = None,
83
+ ) -> RichStreamRenderer:
84
+ """Create a verbose renderer with snapshot appending disabled."""
85
+ verbose_cfg = RendererConfig(live=True, append_finished_snapshots=False)
86
+ cfg = _build_config(verbose_cfg, cfg_overrides)
87
+ return RichStreamRenderer(
88
+ console=console or Console(),
89
+ cfg=cfg,
90
+ verbose=True,
91
+ transcript_buffer=transcript_buffer,
92
+ callbacks=callbacks,
93
+ )
94
+
95
+
96
+ def make_minimal_renderer(
97
+ *,
98
+ console: Console | None = None,
99
+ cfg_overrides: dict[str, Any] | None = None,
100
+ transcript_buffer: TranscriptBuffer | None = None,
101
+ callbacks: dict[str, Any] | None = None,
102
+ ) -> RichStreamRenderer:
103
+ """Create a renderer that prints only essential output."""
104
+ minimal_cfg = RendererConfig(live=False, persist_live=False, render_thinking=False)
105
+ cfg = _build_config(minimal_cfg, cfg_overrides)
106
+ return RichStreamRenderer(
107
+ console=console or Console(),
108
+ cfg=cfg,
109
+ verbose=False,
110
+ transcript_buffer=transcript_buffer,
111
+ callbacks=callbacks,
112
+ )
113
+
114
+
115
+ def make_silent_renderer(
116
+ *,
117
+ console: Console | None = None,
118
+ cfg_overrides: dict[str, Any] | None = None,
119
+ transcript_buffer: TranscriptBuffer | None = None,
120
+ callbacks: dict[str, Any] | None = None,
121
+ ) -> RichStreamRenderer:
122
+ """Create a renderer that suppresses terminal output for background flows."""
123
+ cfg = _build_config(
124
+ RendererConfig(
125
+ live=False,
126
+ persist_live=False,
127
+ render_thinking=False,
128
+ ),
129
+ cfg_overrides,
130
+ )
131
+ silent_console = console or Console(file=io.StringIO(), force_terminal=False)
132
+ return RichStreamRenderer(
133
+ console=silent_console,
134
+ cfg=cfg,
135
+ verbose=False,
136
+ transcript_buffer=transcript_buffer,
137
+ callbacks=callbacks,
138
+ )
@@ -129,21 +129,13 @@ class StreamProcessor:
129
129
  metadata = event.get("metadata", {})
130
130
 
131
131
  # Try primary extraction method
132
- (
133
- tool_name,
134
- tool_args,
135
- tool_out,
136
- tool_calls_info,
137
- ) = self._extract_metadata_tool_calls(metadata)
132
+ tool_calls_result = self._extract_metadata_tool_calls(metadata)
133
+ tool_name, tool_args, tool_out, tool_calls_info = tool_calls_result
138
134
 
139
135
  # Fallback to nested metadata.tool_info (newer schema)
140
136
  if not tool_calls_info:
141
- (
142
- tool_name,
143
- tool_args,
144
- tool_out,
145
- tool_calls_info,
146
- ) = self._extract_tool_calls_from_metadata(metadata)
137
+ fallback_result = self._extract_tool_calls_from_metadata(metadata)
138
+ tool_name, tool_args, tool_out, tool_calls_info = fallback_result
147
139
 
148
140
  return tool_name, tool_args, tool_out, tool_calls_info
149
141
 
@@ -156,27 +148,6 @@ class StreamProcessor:
156
148
  if context_id:
157
149
  self.last_event_time_by_ctx[context_id] = monotonic()
158
150
 
159
- def should_insert_thinking_gap(self, task_id: str | None, context_id: str | None, think_threshold: float) -> bool:
160
- """Determine if a thinking gap should be inserted.
161
-
162
- Args:
163
- task_id: Task identifier
164
- context_id: Context identifier
165
- think_threshold: Threshold for thinking gap
166
-
167
- Returns:
168
- True if thinking gap should be inserted
169
- """
170
- if not task_id or not context_id:
171
- return False
172
-
173
- last_time = self.last_event_time_by_ctx.get(context_id)
174
- if last_time is None:
175
- return True
176
-
177
- elapsed = monotonic() - last_time
178
- return elapsed >= think_threshold
179
-
180
151
  def track_tools_and_agents(
181
152
  self,
182
153
  tool_name: str | None,
@@ -0,0 +1,79 @@
1
+ """Helpers for clamping the steps summary view to a rolling window.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+
11
+ from rich.text import Text
12
+
13
+ Node = tuple[str, tuple[bool, ...]]
14
+ LabelFn = Callable[[str], str]
15
+ ParentFn = Callable[[str], str | None]
16
+
17
+
18
+ def clamp_step_nodes(
19
+ nodes: list[Node],
20
+ *,
21
+ window: int,
22
+ get_label: LabelFn,
23
+ get_parent: ParentFn,
24
+ ) -> tuple[list[Node], Text | None, Text | None]:
25
+ """Return a windowed slice of nodes plus optional header/footer notices."""
26
+ if window <= 0 or len(nodes) <= window:
27
+ return nodes, None, None
28
+
29
+ start_index = len(nodes) - window
30
+ first_visible_step_id = nodes[start_index][0]
31
+ header = _build_header(first_visible_step_id, window, len(nodes), get_label, get_parent)
32
+ footer = _build_footer(len(nodes) - window)
33
+ return nodes[start_index:], header, footer
34
+
35
+
36
+ def _build_header(
37
+ step_id: str,
38
+ window: int,
39
+ total: int,
40
+ get_label: LabelFn,
41
+ get_parent: ParentFn,
42
+ ) -> Text:
43
+ """Construct the leading notice for a truncated window."""
44
+ parts = [f"… (latest {window} of {total} steps shown"]
45
+ path = _collect_path_labels(step_id, get_label, get_parent)
46
+ if path:
47
+ parts.append("; continuing with ")
48
+ parts.append(" / ".join(path))
49
+ parts.append(")")
50
+ return Text("".join(parts), style="dim")
51
+
52
+
53
+ def _build_footer(hidden_count: int) -> Text:
54
+ """Construct the footer notice indicating hidden steps."""
55
+ noun = "step" if hidden_count == 1 else "steps"
56
+ message = f"{hidden_count} earlier {noun} hidden. Press Ctrl+T to inspect the full transcript."
57
+ return Text(message, style="dim")
58
+
59
+
60
+ def _collect_path_labels(
61
+ step_id: str,
62
+ get_label: LabelFn,
63
+ get_parent: ParentFn,
64
+ ) -> list[str]:
65
+ """Collect labels for the ancestry of the provided step."""
66
+ labels: list[str] = []
67
+ seen: set[str] = set()
68
+ current = step_id
69
+ while current and current not in seen:
70
+ seen.add(current)
71
+ label = get_label(current)
72
+ if label:
73
+ labels.append(label)
74
+ parent = get_parent(current)
75
+ if not parent:
76
+ break
77
+ current = parent
78
+ labels.reverse()
79
+ return labels
@@ -0,0 +1,273 @@
1
+ """Thinking scope controller used by the renderer.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from time import monotonic
10
+ from typing import Any
11
+
12
+ from glaip_sdk.utils.rendering.formatting import is_step_finished
13
+ from glaip_sdk.utils.rendering.models import Step
14
+ from glaip_sdk.utils.rendering.state import ThinkingScopeState
15
+ from glaip_sdk.utils.rendering.steps import StepManager
16
+ from glaip_sdk.utils.rendering.timing import calculate_timeline_duration, coerce_server_time
17
+
18
+ FINISHED_STATUS_HINTS = {
19
+ "finished",
20
+ "success",
21
+ "succeeded",
22
+ "completed",
23
+ "failed",
24
+ "stopped",
25
+ "error",
26
+ }
27
+
28
+
29
+ class ThinkingScopeController:
30
+ """Encapsulates deterministic thinking bookkeeping for the renderer."""
31
+
32
+ def __init__(self, steps: StepManager, *, step_server_start_times: dict[str, float]) -> None:
33
+ """Initialize the thinking scope controller.
34
+
35
+ Args:
36
+ steps: Step manager instance for tracking steps
37
+ step_server_start_times: Dictionary mapping step IDs to server start times
38
+ """
39
+ self._steps = steps
40
+ self._step_server_start_times = step_server_start_times
41
+ self._scopes: dict[str, ThinkingScopeState] = {}
42
+
43
+ def update_timeline(self, step: Step | None, payload: dict[str, Any], *, enabled: bool) -> None:
44
+ """Update thinking spans for a streamed step event."""
45
+ if not enabled or not step:
46
+ return
47
+
48
+ now_monotonic = monotonic()
49
+ server_time = coerce_server_time(payload.get("time"))
50
+ status_hint = (payload.get("status") or "").lower()
51
+
52
+ if self._is_scope_anchor(step):
53
+ self._update_anchor_thinking(
54
+ step=step,
55
+ server_time=server_time,
56
+ status_hint=status_hint,
57
+ now_monotonic=now_monotonic,
58
+ )
59
+ return
60
+
61
+ self._update_child_thinking(
62
+ step=step,
63
+ server_time=server_time,
64
+ status_hint=status_hint,
65
+ now_monotonic=now_monotonic,
66
+ )
67
+
68
+ def close_active_scopes(self, server_time: float | None) -> None:
69
+ """Finish any in-flight thinking nodes during finalization."""
70
+ now = monotonic()
71
+ for scope in self._scopes.values():
72
+ if not scope.active_thinking_id:
73
+ continue
74
+ self._finish_scope_thinking(scope, server_time, now)
75
+
76
+ # ------------------------------------------------------------------
77
+ # Internal helpers mirroring the previous renderer implementation.
78
+ # ------------------------------------------------------------------
79
+ def _update_anchor_thinking(
80
+ self,
81
+ *,
82
+ step: Step,
83
+ server_time: float | None,
84
+ status_hint: str,
85
+ now_monotonic: float,
86
+ ) -> None:
87
+ scope = self._get_or_create_scope(step)
88
+ if scope.anchor_started_at is None and server_time is not None:
89
+ scope.anchor_started_at = server_time
90
+
91
+ if not scope.closed and scope.active_thinking_id is None:
92
+ self._start_scope_thinking(
93
+ scope,
94
+ start_server_time=scope.anchor_started_at or server_time,
95
+ start_monotonic=now_monotonic,
96
+ )
97
+
98
+ is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
99
+ if is_anchor_finished:
100
+ scope.anchor_finished_at = server_time or scope.anchor_finished_at
101
+ self._finish_scope_thinking(scope, server_time, now_monotonic)
102
+ scope.closed = True
103
+
104
+ parent_anchor_id = self._resolve_anchor_id(step)
105
+ if parent_anchor_id:
106
+ self._cascade_anchor_update(
107
+ parent_anchor_id=parent_anchor_id,
108
+ child_step=step,
109
+ server_time=server_time,
110
+ now_monotonic=now_monotonic,
111
+ is_finished=is_anchor_finished,
112
+ )
113
+
114
+ def _cascade_anchor_update(
115
+ self,
116
+ *,
117
+ parent_anchor_id: str,
118
+ child_step: Step,
119
+ server_time: float | None,
120
+ now_monotonic: float,
121
+ is_finished: bool,
122
+ ) -> None:
123
+ parent_scope = self._scopes.get(parent_anchor_id)
124
+ if not parent_scope or parent_scope.closed:
125
+ return
126
+ if is_finished:
127
+ self._mark_child_finished(parent_scope, child_step.step_id, server_time, now_monotonic)
128
+ else:
129
+ self._mark_child_running(parent_scope, child_step, server_time, now_monotonic)
130
+
131
+ def _update_child_thinking(
132
+ self,
133
+ *,
134
+ step: Step,
135
+ server_time: float | None,
136
+ status_hint: str,
137
+ now_monotonic: float,
138
+ ) -> None:
139
+ anchor_id = self._resolve_anchor_id(step)
140
+ if not anchor_id:
141
+ return
142
+
143
+ scope = self._scopes.get(anchor_id)
144
+ if not scope or scope.closed or step.kind == "thinking":
145
+ return
146
+
147
+ is_finish_event = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
148
+ if is_finish_event:
149
+ self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
150
+ else:
151
+ self._mark_child_running(scope, step, server_time, now_monotonic)
152
+
153
+ def _resolve_anchor_id(self, step: Step) -> str | None:
154
+ parent_id = step.parent_id
155
+ while parent_id:
156
+ parent = self._steps.by_id.get(parent_id)
157
+ if not parent:
158
+ return None
159
+ if self._is_scope_anchor(parent):
160
+ return parent.step_id
161
+ parent_id = parent.parent_id
162
+ return None
163
+
164
+ def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
165
+ scope = self._scopes.get(step.step_id)
166
+ if scope:
167
+ if scope.task_id is None:
168
+ scope.task_id = step.task_id
169
+ if scope.context_id is None:
170
+ scope.context_id = step.context_id
171
+ return scope
172
+ scope = ThinkingScopeState(
173
+ anchor_id=step.step_id,
174
+ task_id=step.task_id,
175
+ context_id=step.context_id,
176
+ )
177
+ self._scopes[step.step_id] = scope
178
+ return scope
179
+
180
+ def _is_scope_anchor(self, step: Step) -> bool:
181
+ if step.kind in {"agent", "delegate"}:
182
+ return True
183
+ name = (step.name or "").lower()
184
+ return name.startswith(("delegate_to_", "delegate_", "delegate "))
185
+
186
+ def _start_scope_thinking(
187
+ self,
188
+ scope: ThinkingScopeState,
189
+ *,
190
+ start_server_time: float | None,
191
+ start_monotonic: float,
192
+ ) -> None:
193
+ if scope.closed or scope.active_thinking_id or not scope.anchor_id:
194
+ return
195
+ step = self._steps.start_or_get(
196
+ task_id=scope.task_id,
197
+ context_id=scope.context_id,
198
+ kind="thinking",
199
+ name=f"agent_thinking_step::{scope.anchor_id}",
200
+ parent_id=scope.anchor_id,
201
+ args={"reason": "deterministic_timeline"},
202
+ )
203
+ step.display_label = "💭 Thinking…"
204
+ scope.active_thinking_id = step.step_id
205
+ scope.idle_started_at = start_server_time
206
+ scope.idle_started_monotonic = start_monotonic
207
+
208
+ def _finish_scope_thinking(
209
+ self,
210
+ scope: ThinkingScopeState,
211
+ end_server_time: float | None,
212
+ end_monotonic: float,
213
+ ) -> None:
214
+ if not scope.active_thinking_id:
215
+ return
216
+ thinking_step = self._steps.by_id.get(scope.active_thinking_id)
217
+ if not thinking_step:
218
+ scope.active_thinking_id = None
219
+ scope.idle_started_at = None
220
+ scope.idle_started_monotonic = None
221
+ return
222
+
223
+ duration = calculate_timeline_duration(
224
+ scope.idle_started_at,
225
+ end_server_time,
226
+ scope.idle_started_monotonic,
227
+ end_monotonic,
228
+ )
229
+ thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
230
+ if duration is not None:
231
+ thinking_step.finish(duration, source="timeline")
232
+ else:
233
+ thinking_step.finish(None, source="timeline")
234
+ scope.active_thinking_id = None
235
+ scope.idle_started_at = None
236
+ scope.idle_started_monotonic = None
237
+
238
+ def _mark_child_running(
239
+ self,
240
+ scope: ThinkingScopeState,
241
+ step: Step,
242
+ server_time: float | None,
243
+ now_monotonic: float,
244
+ ) -> None:
245
+ if step.step_id in scope.running_children:
246
+ return
247
+ scope.running_children.add(step.step_id)
248
+ if not scope.active_thinking_id:
249
+ return
250
+
251
+ start_server = self._step_server_start_times.get(step.step_id)
252
+ if start_server is None:
253
+ start_server = server_time
254
+ self._finish_scope_thinking(scope, start_server, now_monotonic)
255
+
256
+ def _mark_child_finished(
257
+ self,
258
+ scope: ThinkingScopeState,
259
+ step_id: str,
260
+ server_time: float | None,
261
+ now_monotonic: float,
262
+ ) -> None:
263
+ scope.running_children.discard(step_id)
264
+ if scope.active_thinking_id or scope.closed or scope.running_children:
265
+ return
266
+ self._start_scope_thinking(
267
+ scope,
268
+ start_server_time=server_time,
269
+ start_monotonic=now_monotonic,
270
+ )
271
+
272
+
273
+ __all__ = ["ThinkingScopeController", "FINISHED_STATUS_HINTS"]