glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__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 (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -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 +265 -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.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -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,385 +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(
108
- task_id=task_id, context_id=context_id, kind=kind, name=name
109
- )
110
- if existing:
111
- if args and existing.args != args:
112
- existing.args = args
113
- return existing
114
- slot = self._alloc_slot(task_id, context_id, kind, name)
115
- key = self._key(task_id, context_id, kind, name, slot)
116
- step_id = self._make_id(task_id, context_id, kind, name, slot)
117
- st = Step(
118
- step_id=step_id,
119
- kind=kind,
120
- name=name,
121
- parent_id=parent_id,
122
- task_id=task_id,
123
- context_id=context_id,
124
- args=args or {},
125
- )
126
- self.by_id[step_id] = st
127
- if parent_id:
128
- self.children.setdefault(parent_id, []).append(step_id)
129
- else:
130
- self.order.append(step_id)
131
- self.key_index[key] = step_id
132
- self.state.retained_ids.add(step_id)
133
- self._prune_steps()
134
- self._last_running[(task_id, context_id, kind, name)] = step_id
135
- return st
136
-
137
- def _calculate_total_steps(self) -> int:
138
- """Calculate total number of steps."""
139
- return len(self.order) + sum(len(v) for v in self.children.values())
140
-
141
- def _get_subtree_size(self, root_id: str) -> int:
142
- """Get the size of a subtree (including root)."""
143
- subtree = [root_id]
144
- stack = list(self.children.get(root_id, []))
145
- while stack:
146
- x = stack.pop()
147
- subtree.append(x)
148
- stack.extend(self.children.get(x, []))
149
- return len(subtree)
150
-
151
- def _remove_subtree(self, root_id: str) -> None:
152
- """Remove a complete subtree from all data structures."""
153
- for step_id in self._collect_subtree_ids(root_id):
154
- self._purge_step_references(step_id)
155
-
156
- def _collect_subtree_ids(self, root_id: str) -> list[str]:
157
- """Return a flat list of step ids contained within a subtree."""
158
- stack = [root_id]
159
- collected: list[str] = []
160
- while stack:
161
- sid = stack.pop()
162
- collected.append(sid)
163
- stack.extend(self.children.pop(sid, []))
164
- return collected
165
-
166
- def _purge_step_references(self, step_id: str) -> None:
167
- """Remove a single step id from all indexes and helper structures."""
168
- st = self.by_id.pop(step_id, None)
169
- if st:
170
- key = (st.task_id, st.context_id, st.kind, st.name)
171
- self._last_running.pop(key, None)
172
- self.state.retained_ids.discard(step_id)
173
- self.state.discard_running(step_id)
174
- self._remove_parent_links(step_id)
175
- if step_id in self.order:
176
- self.order.remove(step_id)
177
- self.state.buffered_children.pop(step_id, None)
178
- self.state.pending_branch_failures.discard(step_id)
179
-
180
- def _remove_parent_links(self, child_id: str) -> None:
181
- """Detach a child id from any parent lists."""
182
- for parent, kids in self.children.copy().items():
183
- if child_id not in kids:
184
- continue
185
- kids.remove(child_id)
186
- if not kids:
187
- self.children.pop(parent, None)
188
-
189
- def _should_prune_steps(self, total: int) -> bool:
190
- """Check if steps should be pruned."""
191
- if self.max_steps <= 0:
192
- return False
193
- return total > self.max_steps
194
-
195
- def _get_oldest_step_id(self) -> str | None:
196
- """Get the oldest step ID for pruning."""
197
- return self.order[0] if self.order else None
198
-
199
- def _prune_steps(self) -> None:
200
- """Prune steps when total exceeds maximum."""
201
- total = self._calculate_total_steps()
202
- if not self._should_prune_steps(total):
203
- return
204
-
205
- while self._should_prune_steps(total) and self.order:
206
- sid = self._get_oldest_step_id()
207
- if not sid:
208
- break
209
-
210
- subtree_size = self._get_subtree_size(sid)
211
- self._remove_subtree(sid)
212
- total -= subtree_size
213
-
214
- def remove_step(self, step_id: str) -> None:
215
- """Remove a single step from the tree and cached indexes."""
216
- step = self.by_id.pop(step_id, None)
217
- if not step:
218
- return
219
-
220
- if step.parent_id:
221
- self.state.unlink_child(step.parent_id, step_id)
222
- else:
223
- self.state.unlink_root(step_id)
224
-
225
- self.children.pop(step_id, None)
226
- self.state.buffered_children.pop(step_id, None)
227
- self.state.retained_ids.discard(step_id)
228
- self.state.pending_branch_failures.discard(step_id)
229
- self.state.discard_running(step_id)
230
-
231
- self.key_index = {
232
- key: sid for key, sid in self.key_index.items() if sid != step_id
233
- }
234
- for key, last_sid in self._last_running.copy().items():
235
- if last_sid == step_id:
236
- self._last_running.pop(key, None)
237
-
238
- aliases = [
239
- alias
240
- for alias, target in self._step_aliases.items()
241
- if alias == step_id or target == step_id
242
- ]
243
- for alias in aliases:
244
- self._step_aliases.pop(alias, None)
245
-
246
- def get_child_count(self, step_id: str) -> int:
247
- """Get the number of child steps for a given step.
248
-
249
- Args:
250
- step_id: The parent step ID
251
23
 
252
- Returns:
253
- Number of child steps
254
- """
255
- return len(self.children.get(step_id, []))
24
+ class StepEventMixin:
25
+ """Mixin providing SSE event processing capabilities for StepManager.
256
26
 
257
- def find_running(
258
- self,
259
- *,
260
- task_id: str | None,
261
- context_id: str | None,
262
- kind: str,
263
- name: str,
264
- ) -> Step | None:
265
- """Find a currently running step with the given parameters.
266
-
267
- Args:
268
- task_id: Task identifier
269
- context_id: Context identifier
270
- kind: Step kind (tool, delegate, agent)
271
- name: Step name
272
-
273
- Returns:
274
- The running Step if found, None otherwise
275
- """
276
- key = (task_id, context_id, kind, name)
277
- step_id = self._last_running.get(key)
278
- if step_id:
279
- st = self.by_id.get(step_id)
280
- if st and st.status != "finished":
281
- return st
282
- for sid in reversed(list(self._iter_all_steps())):
283
- st = self.by_id.get(sid)
284
- if (
285
- st
286
- and (st.task_id, st.context_id, st.kind, st.name)
287
- == (
288
- task_id,
289
- context_id,
290
- kind,
291
- name,
292
- )
293
- and st.status != "finished"
294
- ):
295
- return st
296
- return None
297
-
298
- def finish(
299
- self,
300
- *,
301
- task_id: str | None,
302
- context_id: str | None,
303
- kind: str,
304
- name: str,
305
- output: object | None = None,
306
- duration_raw: float | None = None,
307
- ) -> Step:
308
- """Finish a step with the given parameters.
309
-
310
- Args:
311
- task_id: Task identifier
312
- context_id: Context identifier
313
- kind: Step kind (tool, delegate, agent)
314
- name: Step name
315
- output: Step output data
316
- duration_raw: Raw duration in seconds
317
-
318
- Returns:
319
- The finished Step instance
320
-
321
- Raises:
322
- RuntimeError: If no matching step is found
323
- """
324
- st = self.find_running(
325
- task_id=task_id, context_id=context_id, kind=kind, name=name
326
- )
327
- if not st:
328
- # Try to find any existing step with matching parameters, even if not running
329
- for sid in reversed(list(self._iter_all_steps())):
330
- st_check = self.by_id.get(sid)
331
- if (
332
- st_check
333
- and st_check.task_id == task_id
334
- and st_check.context_id == context_id
335
- and st_check.kind == kind
336
- and st_check.name == name
337
- ):
338
- st = st_check
339
- break
340
-
341
- # If still no step found, create a new one
342
- if not st:
343
- st = self.start_or_get(
344
- task_id=task_id, context_id=context_id, kind=kind, name=name
345
- )
346
-
347
- if output:
348
- st.output = output
349
- st.finish(duration_raw)
350
- key = (task_id, context_id, kind, name)
351
- if self._last_running.get(key) == st.step_id:
352
- self._last_running.pop(key, None)
353
- return st
354
-
355
- def _iter_all_steps(self) -> Iterator[str]:
356
- for root in self.order:
357
- yield root
358
- stack = list(self.children.get(root, []))
359
- while stack:
360
- sid = stack.pop()
361
- yield sid
362
- stack.extend(self.children.get(sid, []))
363
-
364
- def iter_tree(self) -> Iterator[tuple[str, tuple[bool, ...]]]:
365
- """Expose depth-first traversal info for rendering."""
366
- yield from self.state.iter_visible_tree()
367
-
368
- @property
369
- def order(self) -> list[str]:
370
- """Root step ordering accessor backed by StepTreeState."""
371
- return self.state.root_order
372
-
373
- @order.setter
374
- def order(self, value: list[str]) -> None:
375
- self.state.root_order = list(value)
376
-
377
- @property
378
- def children(self) -> dict[str, list[str]]:
379
- """Child mapping accessor backed by StepTreeState."""
380
- return self.state.child_map
381
-
382
- @children.setter
383
- def children(self, value: dict[str, list[str]]) -> None:
384
- self.state.child_map = value
385
-
386
- # ------------------------------------------------------------------
387
- # SSE-aware helpers
388
- # ------------------------------------------------------------------
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
+ """
389
31
 
390
32
  def apply_event(self, event: dict[str, Any]) -> Step:
391
33
  """Apply an SSE step event and return the updated step."""
@@ -457,18 +99,14 @@ class StepManager:
457
99
  return cloned
458
100
 
459
101
  @staticmethod
460
- def _copy_tool_call_field(
461
- call: dict[str, Any], target: dict[str, Any], field: str
462
- ) -> None:
102
+ def _copy_tool_call_field(call: dict[str, Any], target: dict[str, Any], field: str) -> None:
463
103
  """Copy a field from the tool call when it exists."""
464
104
  value = call.get(field)
465
105
  if value:
466
106
  target[field] = value
467
107
 
468
108
  @staticmethod
469
- def _derive_call_step_id(
470
- call: dict[str, Any], base_step_id: str, index: int
471
- ) -> str:
109
+ def _derive_call_step_id(call: dict[str, Any], base_step_id: str, index: int) -> str:
472
110
  """Determine the per-call step identifier."""
473
111
  call_id = call.get("id")
474
112
  if isinstance(call_id, str):
@@ -479,6 +117,7 @@ class StepManager:
479
117
 
480
118
  def _apply_single_event(self, event: dict[str, Any]) -> Step:
481
119
  metadata, step_id, tool_info, args = self._parse_event_payload(event)
120
+ metadata_failed = bool(metadata.pop(COERCION_FAILED_KEY, False))
482
121
  tool_name = self._resolve_tool_name(tool_info, metadata, step_id)
483
122
  kind = self._derive_step_kind(tool_name, metadata)
484
123
  parent_hint = self._coerce_parent_id(metadata.get("previous_step_ids"))
@@ -495,18 +134,17 @@ class StepManager:
495
134
  self._link_step(step, parent_id)
496
135
 
497
136
  self.state.retained_ids.add(step.step_id)
498
- step.display_label = self._compose_display_label(
499
- step.kind, tool_name, args, metadata
500
- )
137
+ if metadata_failed:
138
+ step.metadata = {}
139
+ else:
140
+ step.metadata = dict(metadata)
501
141
  self._flush_buffered_children(step.step_id)
502
142
  self._apply_pending_branch_flags(step.step_id)
503
143
 
504
- status = self._normalise_status(
505
- metadata.get("status"), event.get("status"), event.get("task_state")
506
- )
144
+ status = self._normalise_status(metadata.get("status"), event.get("status"), event.get("task_state"))
507
145
  status = self._apply_failure_state(step, status, event)
508
146
 
509
- server_time = self._coerce_server_time(metadata.get("time"))
147
+ server_time = coerce_server_time(metadata.get("time"))
510
148
  self._update_server_timestamps(step, server_time, status)
511
149
 
512
150
  self._apply_duration(
@@ -524,18 +162,17 @@ class StepManager:
524
162
  status=status,
525
163
  )
526
164
 
527
- step.status_icon = self._status_icon_for_step(step)
528
165
  self._update_parallel_tracking(step)
529
166
  self._update_running_index(step)
530
167
  self._prune_steps()
531
168
  return step
532
169
 
533
- def _parse_event_payload(
534
- self, event: dict[str, Any]
535
- ) -> tuple[dict[str, Any], str, dict[str, Any], dict[str, Any]]:
536
- metadata = event.get("metadata") or {}
537
- if not isinstance(metadata, dict):
170
+ def _parse_event_payload(self, event: dict[str, Any]) -> tuple[dict[str, Any], str, dict[str, Any], dict[str, Any]]:
171
+ metadata_raw = event.get("metadata") or {}
172
+ metadata, metadata_reliable = self._coerce_event_metadata(metadata_raw)
173
+ if not metadata:
538
174
  raise ValueError("Step event missing metadata payload")
175
+ metadata[COERCION_FAILED_KEY] = not metadata_reliable
539
176
 
540
177
  step_id = metadata.get("step_id")
541
178
  if not isinstance(step_id, str) or not step_id:
@@ -553,9 +190,40 @@ class StepManager:
553
190
 
554
191
  return metadata, step_id, tool_info, args
555
192
 
556
- def _resolve_tool_name(
557
- self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str
558
- ) -> str:
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
+
226
+ def _resolve_tool_name(self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str) -> str:
559
227
  name = tool_info.get("name")
560
228
  if not name:
561
229
  call = self._first_tool_call(tool_info)
@@ -601,12 +269,8 @@ class StepManager:
601
269
  ) -> Step:
602
270
  existing = self.by_id.get(step_id)
603
271
  if existing:
604
- return self._update_existing_step(
605
- existing, kind, tool_name, event, metadata, args
606
- )
607
- return self._create_step_from_event(
608
- step_id, kind, tool_name, event, metadata, args
609
- )
272
+ return self._update_existing_step(existing, kind, tool_name, event, metadata, args)
273
+ return self._create_step_from_event(step_id, kind, tool_name, event, metadata, args)
610
274
 
611
275
  def _create_step_from_event(
612
276
  self,
@@ -621,12 +285,8 @@ class StepManager:
621
285
  step_id=step_id,
622
286
  kind=kind,
623
287
  name=tool_name or step_id,
624
- task_id=self._coalesce_metadata_value(
625
- "task_id", event, metadata, fallback=None
626
- ),
627
- context_id=self._coalesce_metadata_value(
628
- "context_id", event, metadata, fallback=None
629
- ),
288
+ task_id=self._coalesce_metadata_value("task_id", event, metadata, fallback=None),
289
+ context_id=self._coalesce_metadata_value("context_id", event, metadata, fallback=None),
630
290
  args=args or {},
631
291
  )
632
292
  self.by_id[step_id] = step
@@ -646,20 +306,12 @@ class StepManager:
646
306
  step.name = tool_name or step.name
647
307
  if args:
648
308
  step.args = args
649
- step.task_id = self._coalesce_metadata_value(
650
- "task_id", event, metadata, fallback=step.task_id
651
- )
652
- step.context_id = self._coalesce_metadata_value(
653
- "context_id", event, metadata, fallback=step.context_id
654
- )
309
+ step.task_id = self._coalesce_metadata_value("task_id", event, metadata, fallback=step.task_id)
310
+ step.context_id = self._coalesce_metadata_value("context_id", event, metadata, fallback=step.context_id)
655
311
  return step
656
312
 
657
- def _apply_failure_state(
658
- self, step: Step, status: str, event: dict[str, Any]
659
- ) -> str:
660
- failure_reason = self._extract_failure_reason(
661
- status, event.get("task_state"), event.get("content")
662
- )
313
+ def _apply_failure_state(self, step: Step, status: str, event: dict[str, Any]) -> str:
314
+ failure_reason = self._extract_failure_reason(status, event.get("task_state"), event.get("content"))
663
315
  if not failure_reason:
664
316
  step.status = status
665
317
  return status
@@ -679,9 +331,7 @@ class StepManager:
679
331
  args: dict[str, Any],
680
332
  server_time: float | None,
681
333
  ) -> None:
682
- duration_ms, duration_source = self._resolve_duration_from_event(
683
- tool_info, args
684
- )
334
+ duration_ms, duration_source = self._resolve_duration_from_event(tool_info, args)
685
335
  if duration_ms is not None:
686
336
  step.duration_ms = duration_ms
687
337
  step.duration_source = duration_source
@@ -750,6 +400,8 @@ class StepManager:
750
400
  return "delegate"
751
401
  if tool.startswith("agent_"):
752
402
  return "agent"
403
+ if kind == "agent_step":
404
+ return "tool" if tool else "agent_step"
753
405
  return kind or "tool"
754
406
 
755
407
  def _clean_kind(self, metadata_kind: Any) -> str:
@@ -763,9 +415,7 @@ class StepManager:
763
415
  def _is_delegate_tool(self, tool: str) -> bool:
764
416
  return tool.startswith(("delegate_to_", "delegate-", "delegate ", "delegate_"))
765
417
 
766
- def _is_top_level_agent(
767
- self, tool_name: str | None, metadata: dict[str, Any], kind: str
768
- ) -> bool:
418
+ def _is_top_level_agent(self, tool_name: str | None, metadata: dict[str, Any], kind: str) -> bool:
769
419
  if kind != "agent_step":
770
420
  return False
771
421
  agent_name = metadata.get("agent_name")
@@ -780,65 +430,6 @@ class StepManager:
780
430
  return False
781
431
  return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
782
432
 
783
- def _step_icon_for_kind(self, step_kind: str) -> str:
784
- if step_kind == "agent":
785
- return ICON_AGENT_STEP
786
- if step_kind == "delegate":
787
- return ICON_DELEGATE
788
- if step_kind == "thinking":
789
- return "💭"
790
- return ICON_TOOL_STEP
791
-
792
- def _humanize_tool_name(self, raw_name: str | None) -> str:
793
- if not raw_name:
794
- return UNKNOWN_STEP_DETAIL
795
- name = raw_name
796
- if name.startswith("delegate_to_"):
797
- name = name.removeprefix("delegate_to_")
798
- elif name.startswith("delegate_"):
799
- name = name.removeprefix("delegate_")
800
- cleaned = name.replace("_", " ").replace("-", " ").strip()
801
- if not cleaned:
802
- return UNKNOWN_STEP_DETAIL
803
- return cleaned[:1].upper() + cleaned[1:]
804
-
805
- def _compose_display_label(
806
- self,
807
- step_kind: str,
808
- tool_name: str | None,
809
- args: dict[str, Any],
810
- metadata: dict[str, Any],
811
- ) -> str:
812
- icon = self._step_icon_for_kind(step_kind)
813
- body = self._resolve_label_body(step_kind, tool_name, metadata)
814
- label = f"{icon} {body}".strip()
815
- if isinstance(args, dict) and args:
816
- label = f"{label} —"
817
- return label or UNKNOWN_STEP_DETAIL
818
-
819
- def _resolve_label_body(
820
- self,
821
- step_kind: str,
822
- tool_name: str | None,
823
- metadata: dict[str, Any],
824
- ) -> str:
825
- if step_kind == "thinking":
826
- thinking_text = metadata.get("thinking_and_activity_info")
827
- if isinstance(thinking_text, str) and thinking_text.strip():
828
- return thinking_text.strip()
829
- return "Thinking…"
830
-
831
- if step_kind == "delegate":
832
- return self._humanize_tool_name(tool_name)
833
-
834
- if step_kind == "agent":
835
- agent_name = metadata.get("agent_name")
836
- if isinstance(agent_name, str) and agent_name.strip():
837
- return agent_name.strip()
838
-
839
- friendly = self._humanize_tool_name(tool_name)
840
- return friendly
841
-
842
433
  def _normalise_status(
843
434
  self,
844
435
  metadata_status: Any,
@@ -864,9 +455,7 @@ class StepManager:
864
455
  content: Any,
865
456
  ) -> str | None:
866
457
  failure_states = {"failed", "stopped", "error"}
867
- task_state_str = (
868
- (task_state or "").lower() if isinstance(task_state, str) else ""
869
- )
458
+ task_state_str = (task_state or "").lower() if isinstance(task_state, str) else ""
870
459
  if status in failure_states or task_state_str in failure_states:
871
460
  if isinstance(content, str) and content.strip():
872
461
  return content.strip()
@@ -893,15 +482,11 @@ class StepManager:
893
482
 
894
483
  return None, None
895
484
 
896
- def _determine_parent_id(
897
- self, step: Step, metadata: dict[str, Any], parent_hint: str | None
898
- ) -> str | None:
485
+ def _determine_parent_id(self, step: Step, metadata: dict[str, Any], parent_hint: str | None) -> str | None:
899
486
  scope_parent = self._lookup_scope_parent(metadata, step)
900
487
  candidate = scope_parent or parent_hint
901
488
  if candidate == step.step_id:
902
- logger.debug(
903
- "Step %s cannot parent itself; dropping parent hint", candidate
904
- )
489
+ logger.debug("Step %s cannot parent itself; dropping parent hint", candidate)
905
490
  return None
906
491
  return candidate
907
492
 
@@ -926,9 +511,7 @@ class StepManager:
926
511
  self._detach_from_current_parent(step)
927
512
  self._attach_to_parent(step, parent_id)
928
513
 
929
- def _sanitize_parent_reference(
930
- self, step: Step, parent_id: str | None
931
- ) -> str | None:
514
+ def _sanitize_parent_reference(self, step: Step, parent_id: str | None) -> str | None:
932
515
  """Guard against self-referential parent assignments."""
933
516
  if parent_id != step.step_id:
934
517
  return parent_id
@@ -1044,18 +627,7 @@ class StepManager:
1044
627
  slug = slug.replace("-", "_").strip()
1045
628
  return slug or None
1046
629
 
1047
- @staticmethod
1048
- def _coerce_server_time(value: Any) -> float | None:
1049
- if isinstance(value, (int, float)):
1050
- return float(value)
1051
- try:
1052
- return float(value)
1053
- except (TypeError, ValueError):
1054
- return None
1055
-
1056
- def _update_server_timestamps(
1057
- self, step: Step, server_time: float | None, status: str
1058
- ) -> None:
630
+ def _update_server_timestamps(self, step: Step, server_time: float | None, status: str) -> None:
1059
631
  if server_time is None:
1060
632
  return
1061
633
  if status == "running" and step.server_started_at is None:
@@ -1065,9 +637,7 @@ class StepManager:
1065
637
  if step.server_started_at is None:
1066
638
  step.server_started_at = server_time
1067
639
 
1068
- def _calculate_server_duration(
1069
- self, step: Step, server_time: float | None
1070
- ) -> int | None:
640
+ def _calculate_server_duration(self, step: Step, server_time: float | None) -> int | None:
1071
641
  start = step.server_started_at
1072
642
  end = server_time if server_time is not None else step.server_finished_at
1073
643
  if start is None or end is None:
@@ -1092,7 +662,6 @@ class StepManager:
1092
662
  step = self.by_id.get(step_id)
1093
663
  if step:
1094
664
  step.branch_failed = True
1095
- step.status_icon = "warning"
1096
665
  self.state.pending_branch_failures.discard(step_id)
1097
666
 
1098
667
  def _set_branch_warning(self, parent_id: str | None) -> None:
@@ -1101,7 +670,6 @@ class StepManager:
1101
670
  parent = self.by_id.get(parent_id)
1102
671
  if parent:
1103
672
  parent.branch_failed = True
1104
- parent.status_icon = "warning"
1105
673
  else:
1106
674
  self.state.pending_branch_failures.add(parent_id)
1107
675
 
@@ -1133,15 +701,6 @@ class StepManager:
1133
701
  if current:
1134
702
  current.is_parallel = is_parallel
1135
703
 
1136
- def _status_icon_for_step(self, step: Step) -> str:
1137
- if step.status == "finished":
1138
- return "warning" if step.branch_failed else "success"
1139
- if step.status == "failed":
1140
- return "failed"
1141
- if step.status == "stopped":
1142
- return "warning"
1143
- return "spinner"
1144
-
1145
704
  def _canonicalize_step_id(self, step_id: str, tool_info: dict[str, Any]) -> str:
1146
705
  alias = self._lookup_alias(step_id)
1147
706
  if alias: