glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +104 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +241 -121
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +291 -35
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +466 -89
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
@@ -1,4 +1,4 @@
1
- """Rendering utilities.
1
+ """SSE event processing mixin for StepManager.
2
2
 
3
3
  Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
@@ -7,373 +7,27 @@ Authors:
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
- from collections.abc import Iterator
10
+ from collections.abc import Mapping
11
11
  from copy import deepcopy
12
12
  from time import monotonic
13
13
  from typing import Any
14
14
 
15
- from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
16
15
  from glaip_sdk.utils.rendering.models import Step
17
- from glaip_sdk.utils.rendering.step_tree_state import StepTreeState
16
+ from glaip_sdk.utils.rendering.timing import coerce_server_time
18
17
 
19
18
  logger = logging.getLogger(__name__)
20
- UNKNOWN_STEP_DETAIL = "Unknown step detail"
21
19
 
22
20
 
23
- class StepManager:
24
- """Manages the lifecycle and organization of execution steps.
21
+ COERCION_FAILED_KEY = "_meta_coercion_failed_"
25
22
 
26
- Tracks step creation, parent-child relationships, and execution state
27
- with automatic pruning of old steps when limits are reached.
28
- """
29
-
30
- def __init__(self, max_steps: int = 200) -> None:
31
- """Initialize the step manager.
32
-
33
- Args:
34
- max_steps: Maximum number of steps to retain before pruning
35
- """
36
- normalised_max = int(max_steps) if isinstance(max_steps, (int, float)) else 0
37
- self.state = StepTreeState(max_steps=normalised_max)
38
- self.by_id: dict[str, Step] = self.state.step_index
39
- self.key_index: dict[tuple, str] = {}
40
- self.slot_counter: dict[tuple, int] = {}
41
- self.max_steps = normalised_max
42
- self._last_running: dict[tuple, str] = {}
43
- self._step_aliases: dict[str, str] = {}
44
- self.root_agent_id: str | None = None
45
- self._scope_anchors: dict[str, list[str]] = {}
46
- self._step_scope_map: dict[str, str] = {}
47
-
48
- def set_root_agent(self, agent_id: str | None) -> None:
49
- """Record the root agent identifier for scope-aware parenting."""
50
- if isinstance(agent_id, str) and agent_id.strip():
51
- self.root_agent_id = agent_id.strip()
52
-
53
- def _alloc_slot(
54
- self,
55
- task_id: str | None,
56
- context_id: str | None,
57
- kind: str,
58
- name: str,
59
- ) -> int:
60
- k = (task_id, context_id, kind, name)
61
- self.slot_counter[k] = self.slot_counter.get(k, 0) + 1
62
- return self.slot_counter[k]
63
-
64
- def _key(
65
- self,
66
- task_id: str | None,
67
- context_id: str | None,
68
- kind: str,
69
- name: str,
70
- slot: int,
71
- ) -> tuple[str | None, str | None, str, str, int]:
72
- return (task_id, context_id, kind, name, slot)
73
-
74
- def _make_id(
75
- self,
76
- task_id: str | None,
77
- context_id: str | None,
78
- kind: str,
79
- name: str,
80
- slot: int,
81
- ) -> str:
82
- return f"{task_id or 't'}::{context_id or 'c'}::{kind}::{name}::{slot}"
83
-
84
- def start_or_get(
85
- self,
86
- *,
87
- task_id: str | None,
88
- context_id: str | None,
89
- kind: str,
90
- name: str,
91
- parent_id: str | None = None,
92
- args: dict[str, object] | None = None,
93
- ) -> Step:
94
- """Start a new step or return existing running step with same parameters.
95
-
96
- Args:
97
- task_id: Task identifier
98
- context_id: Context identifier
99
- kind: Step kind (tool, delegate, agent)
100
- name: Step name
101
- parent_id: Parent step ID if this is a child step
102
- args: Step arguments
103
-
104
- Returns:
105
- The Step instance (new or existing)
106
- """
107
- existing = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
108
- if existing:
109
- if args and existing.args != args:
110
- existing.args = args
111
- return existing
112
- slot = self._alloc_slot(task_id, context_id, kind, name)
113
- key = self._key(task_id, context_id, kind, name, slot)
114
- step_id = self._make_id(task_id, context_id, kind, name, slot)
115
- st = Step(
116
- step_id=step_id,
117
- kind=kind,
118
- name=name,
119
- parent_id=parent_id,
120
- task_id=task_id,
121
- context_id=context_id,
122
- args=args or {},
123
- )
124
- self.by_id[step_id] = st
125
- if parent_id:
126
- self.children.setdefault(parent_id, []).append(step_id)
127
- else:
128
- self.order.append(step_id)
129
- self.key_index[key] = step_id
130
- self.state.retained_ids.add(step_id)
131
- self._prune_steps()
132
- self._last_running[(task_id, context_id, kind, name)] = step_id
133
- return st
134
-
135
- def _calculate_total_steps(self) -> int:
136
- """Calculate total number of steps."""
137
- return len(self.order) + sum(len(v) for v in self.children.values())
138
-
139
- def _get_subtree_size(self, root_id: str) -> int:
140
- """Get the size of a subtree (including root)."""
141
- subtree = [root_id]
142
- stack = list(self.children.get(root_id, []))
143
- while stack:
144
- x = stack.pop()
145
- subtree.append(x)
146
- stack.extend(self.children.get(x, []))
147
- return len(subtree)
148
-
149
- def _remove_subtree(self, root_id: str) -> None:
150
- """Remove a complete subtree from all data structures."""
151
- for step_id in self._collect_subtree_ids(root_id):
152
- self._purge_step_references(step_id)
153
-
154
- def _collect_subtree_ids(self, root_id: str) -> list[str]:
155
- """Return a flat list of step ids contained within a subtree."""
156
- stack = [root_id]
157
- collected: list[str] = []
158
- while stack:
159
- sid = stack.pop()
160
- collected.append(sid)
161
- stack.extend(self.children.pop(sid, []))
162
- return collected
163
-
164
- def _purge_step_references(self, step_id: str) -> None:
165
- """Remove a single step id from all indexes and helper structures."""
166
- st = self.by_id.pop(step_id, None)
167
- if st:
168
- key = (st.task_id, st.context_id, st.kind, st.name)
169
- self._last_running.pop(key, None)
170
- self.state.retained_ids.discard(step_id)
171
- self.state.discard_running(step_id)
172
- self._remove_parent_links(step_id)
173
- if step_id in self.order:
174
- self.order.remove(step_id)
175
- self.state.buffered_children.pop(step_id, None)
176
- self.state.pending_branch_failures.discard(step_id)
177
-
178
- def _remove_parent_links(self, child_id: str) -> None:
179
- """Detach a child id from any parent lists."""
180
- for parent, kids in self.children.copy().items():
181
- if child_id not in kids:
182
- continue
183
- kids.remove(child_id)
184
- if not kids:
185
- self.children.pop(parent, None)
186
-
187
- def _should_prune_steps(self, total: int) -> bool:
188
- """Check if steps should be pruned."""
189
- if self.max_steps <= 0:
190
- return False
191
- return total > self.max_steps
192
23
 
193
- def _get_oldest_step_id(self) -> str | None:
194
- """Get the oldest step ID for pruning."""
195
- return self.order[0] if self.order else None
24
+ class StepEventMixin:
25
+ """Mixin providing SSE event processing capabilities for StepManager.
196
26
 
197
- def _prune_steps(self) -> None:
198
- """Prune steps when total exceeds maximum."""
199
- total = self._calculate_total_steps()
200
- if not self._should_prune_steps(total):
201
- return
202
-
203
- while self._should_prune_steps(total) and self.order:
204
- sid = self._get_oldest_step_id()
205
- if not sid:
206
- break
207
-
208
- subtree_size = self._get_subtree_size(sid)
209
- self._remove_subtree(sid)
210
- total -= subtree_size
211
-
212
- def remove_step(self, step_id: str) -> None:
213
- """Remove a single step from the tree and cached indexes."""
214
- step = self.by_id.pop(step_id, None)
215
- if not step:
216
- return
217
-
218
- if step.parent_id:
219
- self.state.unlink_child(step.parent_id, step_id)
220
- else:
221
- self.state.unlink_root(step_id)
222
-
223
- self.children.pop(step_id, None)
224
- self.state.buffered_children.pop(step_id, None)
225
- self.state.retained_ids.discard(step_id)
226
- self.state.pending_branch_failures.discard(step_id)
227
- self.state.discard_running(step_id)
228
-
229
- self.key_index = {key: sid for key, sid in self.key_index.items() if sid != step_id}
230
- for key, last_sid in self._last_running.copy().items():
231
- if last_sid == step_id:
232
- self._last_running.pop(key, None)
233
-
234
- aliases = [alias for alias, target in self._step_aliases.items() if alias == step_id or target == step_id]
235
- for alias in aliases:
236
- self._step_aliases.pop(alias, None)
237
-
238
- def get_child_count(self, step_id: str) -> int:
239
- """Get the number of child steps for a given step.
240
-
241
- Args:
242
- step_id: The parent step ID
243
-
244
- Returns:
245
- Number of child steps
246
- """
247
- return len(self.children.get(step_id, []))
248
-
249
- def find_running(
250
- self,
251
- *,
252
- task_id: str | None,
253
- context_id: str | None,
254
- kind: str,
255
- name: str,
256
- ) -> Step | None:
257
- """Find a currently running step with the given parameters.
258
-
259
- Args:
260
- task_id: Task identifier
261
- context_id: Context identifier
262
- kind: Step kind (tool, delegate, agent)
263
- name: Step name
264
-
265
- Returns:
266
- The running Step if found, None otherwise
267
- """
268
- key = (task_id, context_id, kind, name)
269
- step_id = self._last_running.get(key)
270
- if step_id:
271
- st = self.by_id.get(step_id)
272
- if st and st.status != "finished":
273
- return st
274
- for sid in reversed(list(self._iter_all_steps())):
275
- st = self.by_id.get(sid)
276
- if (
277
- st
278
- and (st.task_id, st.context_id, st.kind, st.name)
279
- == (
280
- task_id,
281
- context_id,
282
- kind,
283
- name,
284
- )
285
- and st.status != "finished"
286
- ):
287
- return st
288
- return None
289
-
290
- def finish(
291
- self,
292
- *,
293
- task_id: str | None,
294
- context_id: str | None,
295
- kind: str,
296
- name: str,
297
- output: object | None = None,
298
- duration_raw: float | None = None,
299
- ) -> Step:
300
- """Finish a step with the given parameters.
301
-
302
- Args:
303
- task_id: Task identifier
304
- context_id: Context identifier
305
- kind: Step kind (tool, delegate, agent)
306
- name: Step name
307
- output: Step output data
308
- duration_raw: Raw duration in seconds
309
-
310
- Returns:
311
- The finished Step instance
312
-
313
- Raises:
314
- RuntimeError: If no matching step is found
315
- """
316
- st = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
317
- if not st:
318
- # Try to find any existing step with matching parameters, even if not running
319
- for sid in reversed(list(self._iter_all_steps())):
320
- st_check = self.by_id.get(sid)
321
- if (
322
- st_check
323
- and st_check.task_id == task_id
324
- and st_check.context_id == context_id
325
- and st_check.kind == kind
326
- and st_check.name == name
327
- ):
328
- st = st_check
329
- break
330
-
331
- # If still no step found, create a new one
332
- if not st:
333
- st = self.start_or_get(task_id=task_id, context_id=context_id, kind=kind, name=name)
334
-
335
- if output:
336
- st.output = output
337
- st.finish(duration_raw)
338
- key = (task_id, context_id, kind, name)
339
- if self._last_running.get(key) == st.step_id:
340
- self._last_running.pop(key, None)
341
- return st
342
-
343
- def _iter_all_steps(self) -> Iterator[str]:
344
- for root in self.order:
345
- yield root
346
- stack = list(self.children.get(root, []))
347
- while stack:
348
- sid = stack.pop()
349
- yield sid
350
- stack.extend(self.children.get(sid, []))
351
-
352
- def iter_tree(self) -> Iterator[tuple[str, tuple[bool, ...]]]:
353
- """Expose depth-first traversal info for rendering."""
354
- yield from self.state.iter_visible_tree()
355
-
356
- @property
357
- def order(self) -> list[str]:
358
- """Root step ordering accessor backed by StepTreeState."""
359
- return self.state.root_order
360
-
361
- @order.setter
362
- def order(self, value: list[str]) -> None:
363
- self.state.root_order = list(value)
364
-
365
- @property
366
- def children(self) -> dict[str, list[str]]:
367
- """Child mapping accessor backed by StepTreeState."""
368
- return self.state.child_map
369
-
370
- @children.setter
371
- def children(self, value: dict[str, list[str]]) -> None:
372
- self.state.child_map = value
373
-
374
- # ------------------------------------------------------------------
375
- # SSE-aware helpers
376
- # ------------------------------------------------------------------
27
+ This mixin adds methods to process server-sent events (SSE) and update
28
+ step state accordingly. It handles event parsing, step creation/updates,
29
+ parent-child relationships, and duration tracking.
30
+ """
377
31
 
378
32
  def apply_event(self, event: dict[str, Any]) -> Step:
379
33
  """Apply an SSE step event and return the updated step."""
@@ -463,6 +117,7 @@ class StepManager:
463
117
 
464
118
  def _apply_single_event(self, event: dict[str, Any]) -> Step:
465
119
  metadata, step_id, tool_info, args = self._parse_event_payload(event)
120
+ metadata_failed = bool(metadata.pop(COERCION_FAILED_KEY, False))
466
121
  tool_name = self._resolve_tool_name(tool_info, metadata, step_id)
467
122
  kind = self._derive_step_kind(tool_name, metadata)
468
123
  parent_hint = self._coerce_parent_id(metadata.get("previous_step_ids"))
@@ -479,14 +134,17 @@ class StepManager:
479
134
  self._link_step(step, parent_id)
480
135
 
481
136
  self.state.retained_ids.add(step.step_id)
482
- step.display_label = self._compose_display_label(step.kind, tool_name, args, metadata)
137
+ if metadata_failed:
138
+ step.metadata = {}
139
+ else:
140
+ step.metadata = dict(metadata)
483
141
  self._flush_buffered_children(step.step_id)
484
142
  self._apply_pending_branch_flags(step.step_id)
485
143
 
486
144
  status = self._normalise_status(metadata.get("status"), event.get("status"), event.get("task_state"))
487
145
  status = self._apply_failure_state(step, status, event)
488
146
 
489
- server_time = self._coerce_server_time(metadata.get("time"))
147
+ server_time = coerce_server_time(metadata.get("time"))
490
148
  self._update_server_timestamps(step, server_time, status)
491
149
 
492
150
  self._apply_duration(
@@ -504,16 +162,17 @@ class StepManager:
504
162
  status=status,
505
163
  )
506
164
 
507
- step.status_icon = self._status_icon_for_step(step)
508
165
  self._update_parallel_tracking(step)
509
166
  self._update_running_index(step)
510
167
  self._prune_steps()
511
168
  return step
512
169
 
513
170
  def _parse_event_payload(self, event: dict[str, Any]) -> tuple[dict[str, Any], str, dict[str, Any], dict[str, Any]]:
514
- metadata = event.get("metadata") or {}
515
- if not isinstance(metadata, dict):
171
+ metadata_raw = event.get("metadata") or {}
172
+ metadata, metadata_reliable = self._coerce_event_metadata(metadata_raw)
173
+ if not metadata:
516
174
  raise ValueError("Step event missing metadata payload")
175
+ metadata[COERCION_FAILED_KEY] = not metadata_reliable
517
176
 
518
177
  step_id = metadata.get("step_id")
519
178
  if not isinstance(step_id, str) or not step_id:
@@ -531,6 +190,39 @@ class StepManager:
531
190
 
532
191
  return metadata, step_id, tool_info, args
533
192
 
193
+ def _coerce_event_metadata(self, metadata: Any) -> tuple[dict[str, Any], bool]:
194
+ """Return a dict copy of event metadata with graceful fallbacks."""
195
+ if isinstance(metadata, dict):
196
+ return metadata, True
197
+ if isinstance(metadata, Mapping):
198
+ try:
199
+ return dict(metadata), True
200
+ except Exception:
201
+ logger.debug("Failed to coerce mapping metadata; falling back to key-by-key copy", exc_info=True)
202
+ return self._copy_mapping_fields(metadata), False
203
+ # All other payloads are treated as empty
204
+ return {}, False
205
+
206
+ @staticmethod
207
+ def _copy_mapping_fields(metadata: Mapping[str, Any]) -> dict[str, Any]:
208
+ """Copy known fields from a mapping without iteration."""
209
+ copied: dict[str, Any] = {}
210
+ for key in (
211
+ "step_id",
212
+ "tool_info",
213
+ "status",
214
+ "kind",
215
+ "previous_step_ids",
216
+ "time",
217
+ "agent_name",
218
+ "task_id",
219
+ "context_id",
220
+ ):
221
+ value = metadata.get(key) # type: ignore[attr-defined]
222
+ if value is not None:
223
+ copied[key] = value
224
+ return copied
225
+
534
226
  def _resolve_tool_name(self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str) -> str:
535
227
  name = tool_info.get("name")
536
228
  if not name:
@@ -738,65 +430,6 @@ class StepManager:
738
430
  return False
739
431
  return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
740
432
 
741
- def _step_icon_for_kind(self, step_kind: str) -> str:
742
- if step_kind == "agent":
743
- return ICON_AGENT_STEP
744
- if step_kind == "delegate":
745
- return ICON_DELEGATE
746
- if step_kind == "thinking":
747
- return "💭"
748
- return ICON_TOOL_STEP
749
-
750
- def _humanize_tool_name(self, raw_name: str | None) -> str:
751
- if not raw_name:
752
- return UNKNOWN_STEP_DETAIL
753
- name = raw_name
754
- if name.startswith("delegate_to_"):
755
- name = name.removeprefix("delegate_to_")
756
- elif name.startswith("delegate_"):
757
- name = name.removeprefix("delegate_")
758
- cleaned = name.replace("_", " ").replace("-", " ").strip()
759
- if not cleaned:
760
- return UNKNOWN_STEP_DETAIL
761
- return cleaned[:1].upper() + cleaned[1:]
762
-
763
- def _compose_display_label(
764
- self,
765
- step_kind: str,
766
- tool_name: str | None,
767
- args: dict[str, Any],
768
- metadata: dict[str, Any],
769
- ) -> str:
770
- icon = self._step_icon_for_kind(step_kind)
771
- body = self._resolve_label_body(step_kind, tool_name, metadata)
772
- label = f"{icon} {body}".strip()
773
- if isinstance(args, dict) and args:
774
- label = f"{label} —"
775
- return label or UNKNOWN_STEP_DETAIL
776
-
777
- def _resolve_label_body(
778
- self,
779
- step_kind: str,
780
- tool_name: str | None,
781
- metadata: dict[str, Any],
782
- ) -> str:
783
- if step_kind == "thinking":
784
- thinking_text = metadata.get("thinking_and_activity_info")
785
- if isinstance(thinking_text, str) and thinking_text.strip():
786
- return thinking_text.strip()
787
- return "Thinking…"
788
-
789
- if step_kind == "delegate":
790
- return self._humanize_tool_name(tool_name)
791
-
792
- if step_kind == "agent":
793
- agent_name = metadata.get("agent_name")
794
- if isinstance(agent_name, str) and agent_name.strip():
795
- return agent_name.strip()
796
-
797
- friendly = self._humanize_tool_name(tool_name)
798
- return friendly
799
-
800
433
  def _normalise_status(
801
434
  self,
802
435
  metadata_status: Any,
@@ -994,15 +627,6 @@ class StepManager:
994
627
  slug = slug.replace("-", "_").strip()
995
628
  return slug or None
996
629
 
997
- @staticmethod
998
- def _coerce_server_time(value: Any) -> float | None:
999
- if isinstance(value, (int, float)):
1000
- return float(value)
1001
- try:
1002
- return float(value)
1003
- except (TypeError, ValueError):
1004
- return None
1005
-
1006
630
  def _update_server_timestamps(self, step: Step, server_time: float | None, status: str) -> None:
1007
631
  if server_time is None:
1008
632
  return
@@ -1038,7 +662,6 @@ class StepManager:
1038
662
  step = self.by_id.get(step_id)
1039
663
  if step:
1040
664
  step.branch_failed = True
1041
- step.status_icon = "warning"
1042
665
  self.state.pending_branch_failures.discard(step_id)
1043
666
 
1044
667
  def _set_branch_warning(self, parent_id: str | None) -> None:
@@ -1047,7 +670,6 @@ class StepManager:
1047
670
  parent = self.by_id.get(parent_id)
1048
671
  if parent:
1049
672
  parent.branch_failed = True
1050
- parent.status_icon = "warning"
1051
673
  else:
1052
674
  self.state.pending_branch_failures.add(parent_id)
1053
675
 
@@ -1079,15 +701,6 @@ class StepManager:
1079
701
  if current:
1080
702
  current.is_parallel = is_parallel
1081
703
 
1082
- def _status_icon_for_step(self, step: Step) -> str:
1083
- if step.status == "finished":
1084
- return "warning" if step.branch_failed else "success"
1085
- if step.status == "failed":
1086
- return "failed"
1087
- if step.status == "stopped":
1088
- return "warning"
1089
- return "spinner"
1090
-
1091
704
  def _canonicalize_step_id(self, step_id: str, tool_info: dict[str, Any]) -> str:
1092
705
  alias = self._lookup_alias(step_id)
1093
706
  if alias: