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
@@ -0,0 +1,176 @@
1
+ """Presentation helpers for rendering steps.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
13
+ from glaip_sdk.utils.rendering.formatting import glyph_for_status, normalise_display_label, pretty_args
14
+ from glaip_sdk.utils.rendering.models import Step
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - typing helpers only
17
+ from glaip_sdk.utils.rendering.layout.transcript import TranscriptGlyphs
18
+
19
+ UNKNOWN_STEP_DETAIL = "Unknown step detail"
20
+ STATUS_ICON_STYLES = {
21
+ "success": "green",
22
+ "failed": "red",
23
+ "warning": "yellow",
24
+ }
25
+ CONNECTOR_VERTICAL = "│ "
26
+ CONNECTOR_EMPTY = " "
27
+ CONNECTOR_BRANCH = "├─ "
28
+ CONNECTOR_LAST = "└─ "
29
+ ROOT_MARKER = ""
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class StepPresentation:
34
+ """Lightweight view model for formatted steps."""
35
+
36
+ step_id: str
37
+ title: str
38
+ glyph: str | None
39
+ status_style: str | None
40
+ args_text: str | None = None
41
+ failure_reason: str | None = None
42
+ duration_ms: int | None = None
43
+ status_text: str | None = None
44
+
45
+
46
+ def humanize_tool_name(raw_name: str | None) -> str:
47
+ """Return a user-facing name for a tool or agent identifier."""
48
+ if not raw_name:
49
+ return UNKNOWN_STEP_DETAIL
50
+ name = raw_name
51
+ if name.startswith("delegate_to_"):
52
+ name = name.removeprefix("delegate_to_")
53
+ elif name.startswith("delegate_"):
54
+ name = name.removeprefix("delegate_")
55
+ cleaned = name.replace("_", " ").replace("-", " ").strip()
56
+ if not cleaned:
57
+ return UNKNOWN_STEP_DETAIL
58
+ lowered = cleaned.lower()
59
+ return lowered[0].upper() + lowered[1:] if lowered else UNKNOWN_STEP_DETAIL
60
+
61
+
62
+ def step_icon_for_kind(step_kind: str) -> str:
63
+ """Return the icon prefix for a step kind."""
64
+ if step_kind == "agent":
65
+ return ICON_AGENT_STEP
66
+ if step_kind == "delegate":
67
+ return ICON_DELEGATE
68
+ if step_kind == "thinking":
69
+ return "💭"
70
+ return ICON_TOOL_STEP
71
+
72
+
73
+ def resolve_label_body(step_kind: str, tool_name: str | None, metadata: dict[str, Any]) -> str:
74
+ """Resolve the textual body for a step label."""
75
+ if step_kind == "thinking":
76
+ thinking_text = metadata.get("thinking_and_activity_info")
77
+ if isinstance(thinking_text, str) and thinking_text.strip():
78
+ return thinking_text.strip()
79
+ return "Thinking…"
80
+
81
+ if step_kind == "delegate":
82
+ return humanize_tool_name(tool_name)
83
+
84
+ if step_kind == "agent":
85
+ agent_name = metadata.get("agent_name")
86
+ if isinstance(agent_name, str) and agent_name.strip():
87
+ return agent_name.strip()
88
+
89
+ return humanize_tool_name(tool_name)
90
+
91
+
92
+ def compose_display_label(
93
+ step_kind: str,
94
+ tool_name: str | None,
95
+ args: dict[str, Any],
96
+ metadata: dict[str, Any],
97
+ ) -> str:
98
+ """Compose the display label for a step using tool metadata."""
99
+ icon = step_icon_for_kind(step_kind)
100
+ body = resolve_label_body(step_kind, tool_name, metadata)
101
+ label = f"{icon} {body}".strip()
102
+ if isinstance(args, dict) and args:
103
+ label = f"{label} —"
104
+ return label or UNKNOWN_STEP_DETAIL
105
+
106
+
107
+ def status_icon_for_step(step: Step) -> str:
108
+ """Return the canonical status icon key for a step."""
109
+ if step.status == "failed":
110
+ return "failed"
111
+ if step.branch_failed:
112
+ return "warning"
113
+ if step.status == "finished":
114
+ return "success"
115
+ if step.status == "stopped":
116
+ return "warning"
117
+ return "spinner"
118
+
119
+
120
+ def format_step_label(step: Step) -> str:
121
+ """Return the normalized display label for a step."""
122
+ label = normalise_display_label(getattr(step, "display_label", None))
123
+ if label and label != UNKNOWN_STEP_DETAIL:
124
+ return label
125
+ metadata = getattr(step, "metadata", {}) or {}
126
+ computed = compose_display_label(step.kind, getattr(step, "name", None), getattr(step, "args", {}), metadata)
127
+ return normalise_display_label(computed)
128
+
129
+
130
+ def format_tool_args(step: Step, max_len: int = 160) -> str | None:
131
+ """Return a pretty-printed args summary for a step."""
132
+ if not step.args:
133
+ return None
134
+ try:
135
+ return pretty_args(step.args, max_len=max_len)
136
+ except Exception:
137
+ return None
138
+
139
+
140
+ def format_step(
141
+ step: Step,
142
+ *,
143
+ glyphs: TranscriptGlyphs | None = None,
144
+ label: str | None = None,
145
+ ) -> StepPresentation:
146
+ """Return a StepPresentation for downstream transcript rendering."""
147
+ del glyphs # Reserved for future glyph customisation hooks
148
+ if label:
149
+ resolved_label = normalise_display_label(label)
150
+ else:
151
+ resolved_label = format_step_label(step)
152
+ glyph_key = status_icon_for_step(step)
153
+ glyph = glyph_for_status(glyph_key)
154
+ style = STATUS_ICON_STYLES.get(glyph_key)
155
+ failure_reason = (step.failure_reason or "").strip() or None
156
+ return StepPresentation(
157
+ step_id=step.step_id,
158
+ title=resolved_label,
159
+ glyph=glyph,
160
+ status_style=style,
161
+ args_text=format_tool_args(step),
162
+ failure_reason=failure_reason,
163
+ duration_ms=getattr(step, "duration_ms", None),
164
+ )
165
+
166
+
167
+ def build_connector_prefix(branch_state: tuple[bool, ...]) -> str:
168
+ """Build connector prefix for a tree line based on ancestry state."""
169
+ if not branch_state:
170
+ return ROOT_MARKER
171
+
172
+ parts: list[str] = []
173
+ for ancestor_is_last in branch_state[:-1]:
174
+ parts.append(CONNECTOR_EMPTY if ancestor_is_last else CONNECTOR_VERTICAL)
175
+ parts.append(CONNECTOR_LAST if branch_state[-1] else CONNECTOR_BRANCH)
176
+ return "".join(parts)
@@ -9,9 +9,15 @@ from __future__ import annotations
9
9
  from collections.abc import Iterator
10
10
 
11
11
  from glaip_sdk.utils.rendering.models import Step
12
+ from glaip_sdk.utils.rendering.step_tree_state import StepTreeState
13
+ from glaip_sdk.utils.rendering.steps.event_processor import StepEventMixin
12
14
 
13
15
 
14
- class StepManager:
16
+ class StepManagerError(Exception):
17
+ """Raised when invalid operations are attempted on the step tree."""
18
+
19
+
20
+ class StepManager(StepEventMixin):
15
21
  """Manages the lifecycle and organization of execution steps.
16
22
 
17
23
  Tracks step creation, parent-child relationships, and execution state
@@ -24,13 +30,22 @@ class StepManager:
24
30
  Args:
25
31
  max_steps: Maximum number of steps to retain before pruning
26
32
  """
27
- self.by_id: dict[str, Step] = {}
28
- self.order: list[str] = []
29
- self.children: dict[str, list[str]] = {}
33
+ normalised_max = int(max_steps) if isinstance(max_steps, (int, float)) else 0
34
+ self.state = StepTreeState(max_steps=normalised_max)
35
+ self.by_id: dict[str, Step] = self.state.step_index
30
36
  self.key_index: dict[tuple, str] = {}
31
37
  self.slot_counter: dict[tuple, int] = {}
32
- self.max_steps = max_steps
38
+ self.max_steps = normalised_max
33
39
  self._last_running: dict[tuple, str] = {}
40
+ self._step_aliases: dict[str, str] = {}
41
+ self.root_agent_id: str | None = None
42
+ self._scope_anchors: dict[str, list[str]] = {}
43
+ self._step_scope_map: dict[str, str] = {}
44
+
45
+ def set_root_agent(self, agent_id: str | None) -> None:
46
+ """Record the root agent identifier for scope-aware parenting."""
47
+ if isinstance(agent_id, str) and agent_id.strip():
48
+ self.root_agent_id = agent_id.strip()
34
49
 
35
50
  def _alloc_slot(
36
51
  self,
@@ -86,9 +101,7 @@ class StepManager:
86
101
  Returns:
87
102
  The Step instance (new or existing)
88
103
  """
89
- existing = self.find_running(
90
- task_id=task_id, context_id=context_id, kind=kind, name=name
91
- )
104
+ existing = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
92
105
  if existing:
93
106
  if args and existing.args != args:
94
107
  existing.args = args
@@ -111,6 +124,7 @@ class StepManager:
111
124
  else:
112
125
  self.order.append(step_id)
113
126
  self.key_index[key] = step_id
127
+ self.state.retained_ids.add(step_id)
114
128
  self._prune_steps()
115
129
  self._last_running[(task_id, context_id, kind, name)] = step_id
116
130
  return st
@@ -131,26 +145,46 @@ class StepManager:
131
145
 
132
146
  def _remove_subtree(self, root_id: str) -> None:
133
147
  """Remove a complete subtree from all data structures."""
148
+ for step_id in self._collect_subtree_ids(root_id):
149
+ self._purge_step_references(step_id)
150
+
151
+ def _collect_subtree_ids(self, root_id: str) -> list[str]:
152
+ """Return a flat list of step ids contained within a subtree."""
134
153
  stack = [root_id]
135
- to_remove = []
154
+ collected: list[str] = []
136
155
  while stack:
137
156
  sid = stack.pop()
138
- to_remove.append(sid)
157
+ collected.append(sid)
139
158
  stack.extend(self.children.pop(sid, []))
159
+ return collected
140
160
 
141
- for sid in to_remove:
142
- st = self.by_id.pop(sid, None)
143
- if st:
144
- key = (st.task_id, st.context_id, st.kind, st.name)
145
- self._last_running.pop(key, None)
146
- for _parent, kids in list(self.children.items()):
147
- if sid in kids:
148
- kids.remove(sid)
149
- if sid in self.order:
150
- self.order.remove(sid)
161
+ def _purge_step_references(self, step_id: str) -> None:
162
+ """Remove a single step id from all indexes and helper structures."""
163
+ st = self.by_id.pop(step_id, None)
164
+ if st:
165
+ key = (st.task_id, st.context_id, st.kind, st.name)
166
+ self._last_running.pop(key, None)
167
+ self.state.retained_ids.discard(step_id)
168
+ self.state.discard_running(step_id)
169
+ self._remove_parent_links(step_id)
170
+ if step_id in self.order:
171
+ self.order.remove(step_id)
172
+ self.state.buffered_children.pop(step_id, None)
173
+ self.state.pending_branch_failures.discard(step_id)
174
+
175
+ def _remove_parent_links(self, child_id: str) -> None:
176
+ """Detach a child id from any parent lists."""
177
+ for parent, kids in self.children.copy().items():
178
+ if child_id not in kids:
179
+ continue
180
+ kids.remove(child_id)
181
+ if not kids:
182
+ self.children.pop(parent, None)
151
183
 
152
184
  def _should_prune_steps(self, total: int) -> bool:
153
185
  """Check if steps should be pruned."""
186
+ if self.max_steps <= 0:
187
+ return False
154
188
  return total > self.max_steps
155
189
 
156
190
  def _get_oldest_step_id(self) -> str | None:
@@ -172,6 +206,32 @@ class StepManager:
172
206
  self._remove_subtree(sid)
173
207
  total -= subtree_size
174
208
 
209
+ def remove_step(self, step_id: str) -> None:
210
+ """Remove a single step from the tree and cached indexes."""
211
+ step = self.by_id.pop(step_id, None)
212
+ if not step:
213
+ return
214
+
215
+ if step.parent_id:
216
+ self.state.unlink_child(step.parent_id, step_id)
217
+ else:
218
+ self.state.unlink_root(step_id)
219
+
220
+ self.children.pop(step_id, None)
221
+ self.state.buffered_children.pop(step_id, None)
222
+ self.state.retained_ids.discard(step_id)
223
+ self.state.pending_branch_failures.discard(step_id)
224
+ self.state.discard_running(step_id)
225
+
226
+ self.key_index = {key: sid for key, sid in self.key_index.items() if sid != step_id}
227
+ for key, last_sid in self._last_running.copy().items():
228
+ if last_sid == step_id:
229
+ self._last_running.pop(key, None)
230
+
231
+ aliases = [alias for alias, target in self._step_aliases.items() if alias == step_id or target == step_id]
232
+ for alias in aliases:
233
+ self._step_aliases.pop(alias, None)
234
+
175
235
  def get_child_count(self, step_id: str) -> int:
176
236
  """Get the number of child steps for a given step.
177
237
 
@@ -224,6 +284,24 @@ class StepManager:
224
284
  return st
225
285
  return None
226
286
 
287
+ def list_ordered(self) -> list[Step]:
288
+ """Return a depth-first list of steps currently tracked."""
289
+ ordered: list[Step] = []
290
+ for step_id, _branch_state in self.iter_tree():
291
+ step = self.by_id.get(step_id)
292
+ if step:
293
+ ordered.append(step)
294
+ return ordered
295
+
296
+ def prune_for_summary(self, limit: int) -> list[tuple[str, tuple[bool, ...]]]:
297
+ """Return the most recent ``limit`` nodes for compact summaries."""
298
+ if limit < 0:
299
+ raise StepManagerError("limit must be non-negative")
300
+ nodes = list(self.iter_tree())
301
+ if limit == 0 or len(nodes) <= limit:
302
+ return nodes
303
+ return nodes[-limit:]
304
+
227
305
  def finish(
228
306
  self,
229
307
  *,
@@ -250,9 +328,7 @@ class StepManager:
250
328
  Raises:
251
329
  RuntimeError: If no matching step is found
252
330
  """
253
- st = self.find_running(
254
- task_id=task_id, context_id=context_id, kind=kind, name=name
255
- )
331
+ st = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
256
332
  if not st:
257
333
  # Try to find any existing step with matching parameters, even if not running
258
334
  for sid in reversed(list(self._iter_all_steps())):
@@ -269,9 +345,7 @@ class StepManager:
269
345
 
270
346
  # If still no step found, create a new one
271
347
  if not st:
272
- st = self.start_or_get(
273
- task_id=task_id, context_id=context_id, kind=kind, name=name
274
- )
348
+ st = self.start_or_get(task_id=task_id, context_id=context_id, kind=kind, name=name)
275
349
 
276
350
  if output:
277
351
  st.output = output
@@ -289,3 +363,25 @@ class StepManager:
289
363
  sid = stack.pop()
290
364
  yield sid
291
365
  stack.extend(self.children.get(sid, []))
366
+
367
+ def iter_tree(self) -> Iterator[tuple[str, tuple[bool, ...]]]:
368
+ """Expose depth-first traversal info for rendering."""
369
+ yield from self.state.iter_visible_tree()
370
+
371
+ @property
372
+ def order(self) -> list[str]:
373
+ """Root step ordering accessor backed by StepTreeState."""
374
+ return self.state.root_order
375
+
376
+ @order.setter
377
+ def order(self, value: list[str]) -> None:
378
+ self.state.root_order = list(value)
379
+
380
+ @property
381
+ def children(self) -> dict[str, list[str]]:
382
+ """Child mapping accessor backed by StepTreeState."""
383
+ return self.state.child_map
384
+
385
+ @children.setter
386
+ def children(self, value: dict[str, list[str]]) -> None:
387
+ self.state.child_map = value
@@ -0,0 +1,36 @@
1
+ """Shared timing helpers for renderer components.
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
+
12
+ def coerce_server_time(value: Any) -> float | None:
13
+ """Convert a raw SSE/server time payload into a float."""
14
+ if isinstance(value, (int, float)):
15
+ return float(value)
16
+ try:
17
+ return float(value)
18
+ except (TypeError, ValueError):
19
+ return None
20
+
21
+
22
+ def calculate_timeline_duration(
23
+ start_server: float | None,
24
+ end_server: float | None,
25
+ start_monotonic: float | None,
26
+ end_monotonic: float | None,
27
+ ) -> float | None:
28
+ """Return best-effort elapsed time using server or monotonic clocks."""
29
+ if start_server is not None and end_server is not None:
30
+ return max(0.0, float(end_server) - float(start_server))
31
+ if start_monotonic is not None and end_monotonic is not None:
32
+ try:
33
+ return max(0.0, float(end_monotonic) - float(start_monotonic))
34
+ except Exception:
35
+ return None
36
+ return None
@@ -0,0 +1,21 @@
1
+ """Shared transcript viewer exports.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from glaip_sdk.utils.rendering.viewer.presenter import (
8
+ ViewerContext,
9
+ prepare_viewer_snapshot,
10
+ render_post_run_view,
11
+ render_transcript_events,
12
+ render_transcript_view,
13
+ )
14
+
15
+ __all__ = [
16
+ "ViewerContext",
17
+ "prepare_viewer_snapshot",
18
+ "render_post_run_view",
19
+ "render_transcript_events",
20
+ "render_transcript_view",
21
+ ]
@@ -0,0 +1,184 @@
1
+ """Shared presenter utilities for CLI/offline transcript viewing.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+
14
+ from glaip_sdk.utils.rendering.layout.transcript import (
15
+ DEFAULT_TRANSCRIPT_THEME,
16
+ TranscriptGlyphs,
17
+ TranscriptSnapshot,
18
+ build_transcript_snapshot,
19
+ build_transcript_view,
20
+ )
21
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event_stream
22
+ from glaip_sdk.utils.rendering.state import RendererState, coerce_received_at
23
+ from glaip_sdk.utils.rendering.steps import StepManager
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ViewerContext:
28
+ """Runtime context passed to transcript presenters."""
29
+
30
+ manifest_entry: dict[str, Any]
31
+ events: list[dict[str, Any]]
32
+ default_output: str
33
+ final_output: str
34
+ stream_started_at: float | None
35
+ meta: dict[str, Any]
36
+
37
+
38
+ def render_post_run_view(
39
+ console: Console,
40
+ ctx: ViewerContext,
41
+ *,
42
+ glyphs: TranscriptGlyphs | None = None,
43
+ theme: str = DEFAULT_TRANSCRIPT_THEME,
44
+ ) -> TranscriptSnapshot:
45
+ """Render the default summary view and return the snapshot used."""
46
+ snapshot, _state = prepare_viewer_snapshot(
47
+ ctx,
48
+ glyphs=glyphs,
49
+ theme=theme,
50
+ )
51
+ render_transcript_view(console, snapshot, theme=theme)
52
+ return snapshot
53
+
54
+
55
+ def render_transcript_view(
56
+ console: Console,
57
+ snapshot: TranscriptSnapshot,
58
+ *,
59
+ theme: str = DEFAULT_TRANSCRIPT_THEME,
60
+ ) -> None:
61
+ """Render the transcript summary using a prepared snapshot."""
62
+ header, body = build_transcript_view(snapshot, theme=theme)
63
+ _print_renderables(console, header + body)
64
+
65
+
66
+ def render_transcript_events(console: Console, events: list[dict[str, Any]]) -> None:
67
+ """Pretty-print transcript events using shared debug presenter."""
68
+ if not events:
69
+ console.print("[dim]No SSE events were captured for this run.[/dim]")
70
+ console.print()
71
+ return
72
+
73
+ console.print("[bold]Transcript Events[/bold]")
74
+ console.print("[dim]────────────────────────────────────────────────────────[/dim]")
75
+
76
+ render_debug_event_stream(
77
+ events,
78
+ console,
79
+ resolve_timestamp=lambda event: coerce_received_at(event.get("received_at")),
80
+ )
81
+ console.print()
82
+
83
+
84
+ def prepare_viewer_snapshot(
85
+ ctx: ViewerContext,
86
+ *,
87
+ glyphs: TranscriptGlyphs | None,
88
+ theme: str,
89
+ ) -> tuple[TranscriptSnapshot, RendererState]:
90
+ """Build a transcript snapshot plus renderer state for reusable viewing."""
91
+ state = _build_renderer_state(ctx)
92
+ manager = _build_steps_from_events(ctx.events)
93
+ query = _extract_query_from_manifest(ctx)
94
+ merged_meta = _merge_meta(ctx)
95
+ snapshot = build_transcript_snapshot(
96
+ state,
97
+ manager,
98
+ glyphs=glyphs,
99
+ query_text=query,
100
+ meta=merged_meta,
101
+ theme=theme,
102
+ )
103
+ return snapshot, state
104
+
105
+
106
+ def _build_renderer_state(ctx: ViewerContext) -> RendererState:
107
+ state = RendererState()
108
+ state.meta = dict(ctx.meta or {})
109
+
110
+ final_text = (ctx.final_output or "").strip()
111
+ default_text = (ctx.default_output or "").strip()
112
+ if final_text:
113
+ state.final_text = final_text
114
+ elif default_text:
115
+ state.final_text = default_text
116
+ state.buffer.append(default_text)
117
+
118
+ duration = _extract_final_duration(ctx.events)
119
+ if duration:
120
+ state.final_duration_text = duration # pragma: no cover - exercised indirectly via end-to-end tests
121
+ state.events = list(ctx.events or [])
122
+ return state
123
+
124
+
125
+ def _build_steps_from_events(events: list[dict[str, Any]]) -> StepManager:
126
+ manager = StepManager()
127
+ for event in events or []:
128
+ payload = _coerce_step_event(event)
129
+ if not payload:
130
+ continue
131
+ try:
132
+ manager.apply_event(payload)
133
+ except ValueError:
134
+ continue
135
+ return manager
136
+
137
+
138
+ def _coerce_step_event(event: dict[str, Any]) -> dict[str, Any] | None:
139
+ metadata = event.get("metadata")
140
+ if not isinstance(metadata, dict):
141
+ return None
142
+ if not isinstance(metadata.get("step_id"), str):
143
+ return None
144
+ return {
145
+ "metadata": metadata,
146
+ "status": event.get("status"),
147
+ "task_state": event.get("task_state"),
148
+ "content": event.get("content"),
149
+ "task_id": event.get("task_id"),
150
+ "context_id": event.get("context_id"),
151
+ }
152
+
153
+
154
+ def _extract_final_duration(events: list[dict[str, Any]]) -> str | None:
155
+ for event in events or []:
156
+ metadata = event.get("metadata") or {}
157
+ if metadata.get("kind") != "final_response":
158
+ continue
159
+ time_value = metadata.get("time")
160
+ if isinstance(time_value, (int, float)):
161
+ return f"{float(time_value):.2f}s"
162
+ return None
163
+
164
+
165
+ def _extract_query_from_manifest(ctx: ViewerContext) -> str | None:
166
+ query = ctx.manifest_entry.get("input_message") or ctx.meta.get("input_message") or ctx.meta.get("query")
167
+ if isinstance(query, str) and query.strip():
168
+ return query.strip()
169
+ return None
170
+
171
+
172
+ def _merge_meta(ctx: ViewerContext) -> dict[str, Any]:
173
+ merged = dict(ctx.meta or {})
174
+ manifest = ctx.manifest_entry or {}
175
+ for key in ("agent_name", "agent_id", "model", "run_id", "input_message"):
176
+ if key in manifest and manifest[key] and key not in merged:
177
+ merged[key] = manifest[key]
178
+ return merged
179
+
180
+
181
+ def _print_renderables(console: Console, renderables: list[Any]) -> None:
182
+ for renderable in renderables:
183
+ console.print(renderable)
184
+ console.print()