glaip-sdk 0.0.20__py3-none-any.whl → 0.1.3__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 (66) hide show
  1. glaip_sdk/_version.py +1 -3
  2. glaip_sdk/branding.py +2 -6
  3. glaip_sdk/cli/agent_config.py +2 -6
  4. glaip_sdk/cli/auth.py +11 -30
  5. glaip_sdk/cli/commands/agents.py +64 -107
  6. glaip_sdk/cli/commands/configure.py +12 -36
  7. glaip_sdk/cli/commands/mcps.py +25 -63
  8. glaip_sdk/cli/commands/models.py +2 -4
  9. glaip_sdk/cli/commands/tools.py +22 -35
  10. glaip_sdk/cli/commands/update.py +3 -8
  11. glaip_sdk/cli/config.py +1 -3
  12. glaip_sdk/cli/display.py +10 -13
  13. glaip_sdk/cli/io.py +8 -14
  14. glaip_sdk/cli/main.py +10 -30
  15. glaip_sdk/cli/mcp_validators.py +5 -15
  16. glaip_sdk/cli/pager.py +3 -9
  17. glaip_sdk/cli/parsers/json_input.py +11 -22
  18. glaip_sdk/cli/resolution.py +3 -9
  19. glaip_sdk/cli/rich_helpers.py +1 -3
  20. glaip_sdk/cli/slash/agent_session.py +5 -10
  21. glaip_sdk/cli/slash/prompt.py +3 -10
  22. glaip_sdk/cli/slash/session.py +46 -98
  23. glaip_sdk/cli/transcript/cache.py +6 -19
  24. glaip_sdk/cli/transcript/capture.py +45 -20
  25. glaip_sdk/cli/transcript/launcher.py +1 -3
  26. glaip_sdk/cli/transcript/viewer.py +224 -47
  27. glaip_sdk/cli/update_notifier.py +165 -21
  28. glaip_sdk/cli/utils.py +33 -91
  29. glaip_sdk/cli/validators.py +11 -12
  30. glaip_sdk/client/_agent_payloads.py +10 -30
  31. glaip_sdk/client/agents.py +33 -63
  32. glaip_sdk/client/base.py +77 -35
  33. glaip_sdk/client/mcps.py +1 -3
  34. glaip_sdk/client/run_rendering.py +121 -26
  35. glaip_sdk/client/tools.py +8 -24
  36. glaip_sdk/client/validators.py +20 -48
  37. glaip_sdk/exceptions.py +1 -3
  38. glaip_sdk/icons.py +9 -3
  39. glaip_sdk/models.py +14 -33
  40. glaip_sdk/payload_schemas/agent.py +1 -3
  41. glaip_sdk/utils/agent_config.py +4 -14
  42. glaip_sdk/utils/client_utils.py +7 -21
  43. glaip_sdk/utils/display.py +2 -6
  44. glaip_sdk/utils/general.py +1 -3
  45. glaip_sdk/utils/import_export.py +3 -9
  46. glaip_sdk/utils/rendering/formatting.py +52 -12
  47. glaip_sdk/utils/rendering/models.py +17 -8
  48. glaip_sdk/utils/rendering/renderer/__init__.py +1 -5
  49. glaip_sdk/utils/rendering/renderer/base.py +1181 -328
  50. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  51. glaip_sdk/utils/rendering/renderer/debug.py +4 -14
  52. glaip_sdk/utils/rendering/renderer/panels.py +1 -3
  53. glaip_sdk/utils/rendering/renderer/progress.py +3 -11
  54. glaip_sdk/utils/rendering/renderer/stream.py +9 -42
  55. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  56. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  57. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  58. glaip_sdk/utils/rendering/steps.py +899 -25
  59. glaip_sdk/utils/resource_refs.py +4 -13
  60. glaip_sdk/utils/serialization.py +14 -46
  61. glaip_sdk/utils/validation.py +4 -4
  62. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/METADATA +12 -1
  63. glaip_sdk-0.1.3.dist-info/RECORD +83 -0
  64. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  65. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/WHEEL +0 -0
  66. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/entry_points.txt +0 -0
@@ -6,9 +6,18 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import logging
9
10
  from collections.abc import Iterator
11
+ from copy import deepcopy
12
+ from time import monotonic
13
+ from typing import Any
10
14
 
15
+ from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
11
16
  from glaip_sdk.utils.rendering.models import Step
17
+ from glaip_sdk.utils.rendering.step_tree_state import StepTreeState
18
+
19
+ logger = logging.getLogger(__name__)
20
+ UNKNOWN_STEP_DETAIL = "Unknown step detail"
12
21
 
13
22
 
14
23
  class StepManager:
@@ -24,13 +33,22 @@ class StepManager:
24
33
  Args:
25
34
  max_steps: Maximum number of steps to retain before pruning
26
35
  """
27
- self.by_id: dict[str, Step] = {}
28
- self.order: list[str] = []
29
- self.children: dict[str, list[str]] = {}
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
30
39
  self.key_index: dict[tuple, str] = {}
31
40
  self.slot_counter: dict[tuple, int] = {}
32
- self.max_steps = max_steps
41
+ self.max_steps = normalised_max
33
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()
34
52
 
35
53
  def _alloc_slot(
36
54
  self,
@@ -86,9 +104,7 @@ class StepManager:
86
104
  Returns:
87
105
  The Step instance (new or existing)
88
106
  """
89
- existing = self.find_running(
90
- task_id=task_id, context_id=context_id, kind=kind, name=name
91
- )
107
+ existing = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
92
108
  if existing:
93
109
  if args and existing.args != args:
94
110
  existing.args = args
@@ -111,6 +127,7 @@ class StepManager:
111
127
  else:
112
128
  self.order.append(step_id)
113
129
  self.key_index[key] = step_id
130
+ self.state.retained_ids.add(step_id)
114
131
  self._prune_steps()
115
132
  self._last_running[(task_id, context_id, kind, name)] = step_id
116
133
  return st
@@ -131,26 +148,46 @@ class StepManager:
131
148
 
132
149
  def _remove_subtree(self, root_id: str) -> None:
133
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."""
134
156
  stack = [root_id]
135
- to_remove = []
157
+ collected: list[str] = []
136
158
  while stack:
137
159
  sid = stack.pop()
138
- to_remove.append(sid)
160
+ collected.append(sid)
139
161
  stack.extend(self.children.pop(sid, []))
162
+ return collected
140
163
 
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)
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)
151
186
 
152
187
  def _should_prune_steps(self, total: int) -> bool:
153
188
  """Check if steps should be pruned."""
189
+ if self.max_steps <= 0:
190
+ return False
154
191
  return total > self.max_steps
155
192
 
156
193
  def _get_oldest_step_id(self) -> str | None:
@@ -172,6 +209,32 @@ class StepManager:
172
209
  self._remove_subtree(sid)
173
210
  total -= subtree_size
174
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
+
175
238
  def get_child_count(self, step_id: str) -> int:
176
239
  """Get the number of child steps for a given step.
177
240
 
@@ -250,9 +313,7 @@ class StepManager:
250
313
  Raises:
251
314
  RuntimeError: If no matching step is found
252
315
  """
253
- st = self.find_running(
254
- task_id=task_id, context_id=context_id, kind=kind, name=name
255
- )
316
+ st = self.find_running(task_id=task_id, context_id=context_id, kind=kind, name=name)
256
317
  if not st:
257
318
  # Try to find any existing step with matching parameters, even if not running
258
319
  for sid in reversed(list(self._iter_all_steps())):
@@ -269,9 +330,7 @@ class StepManager:
269
330
 
270
331
  # If still no step found, create a new one
271
332
  if not st:
272
- st = self.start_or_get(
273
- task_id=task_id, context_id=context_id, kind=kind, name=name
274
- )
333
+ st = self.start_or_get(task_id=task_id, context_id=context_id, kind=kind, name=name)
275
334
 
276
335
  if output:
277
336
  st.output = output
@@ -289,3 +348,818 @@ class StepManager:
289
348
  sid = stack.pop()
290
349
  yield sid
291
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
+ # ------------------------------------------------------------------
377
+
378
+ def apply_event(self, event: dict[str, Any]) -> Step:
379
+ """Apply an SSE step event and return the updated step."""
380
+ cloned_events = self._split_multi_tool_event(event)
381
+ if cloned_events:
382
+ last_step: Step | None = None
383
+ for cloned in cloned_events:
384
+ last_step = self._apply_single_event(cloned)
385
+ if last_step:
386
+ return last_step
387
+ return self._apply_single_event(event)
388
+
389
+ def _split_multi_tool_event(self, event: dict[str, Any]) -> list[dict[str, Any]]:
390
+ """Split events that describe multiple tool calls into per-call clones."""
391
+ metadata = event.get("metadata") or {}
392
+ tool_info = metadata.get("tool_info") or {}
393
+ tool_calls = tool_info.get("tool_calls")
394
+ if not self._should_split_tool_calls(tool_calls):
395
+ return []
396
+ if self._all_delegate_calls(tool_calls):
397
+ return []
398
+
399
+ base_step_id = metadata.get("step_id") or "step"
400
+ clones: list[dict[str, Any]] = []
401
+ for index, call in enumerate(tool_calls):
402
+ clone = self._clone_tool_call(event, tool_info, call, base_step_id, index)
403
+ if clone is not None:
404
+ clones.append(clone)
405
+ return clones
406
+
407
+ @staticmethod
408
+ def _should_split_tool_calls(tool_calls: Any) -> bool:
409
+ """Return True when an event references more than one tool call."""
410
+ return isinstance(tool_calls, list) and len(tool_calls) > 1
411
+
412
+ def _all_delegate_calls(self, tool_calls: Any) -> bool:
413
+ """Return True when an event batch only contains delegate tools."""
414
+ if not isinstance(tool_calls, list) or not tool_calls:
415
+ return False
416
+ for call in tool_calls:
417
+ if not isinstance(call, dict):
418
+ return False
419
+ name = (call.get("name") or "").lower()
420
+ if not self._is_delegate_tool(name):
421
+ return False
422
+ return True
423
+
424
+ def _clone_tool_call(
425
+ self,
426
+ event: dict[str, Any],
427
+ tool_info: dict[str, Any],
428
+ call: Any,
429
+ base_step_id: str,
430
+ index: int,
431
+ ) -> dict[str, Any] | None:
432
+ """Create a per-call clone of a multi-tool event."""
433
+ if not isinstance(call, dict):
434
+ return None
435
+
436
+ cloned = deepcopy(event)
437
+ cloned_meta = cloned.setdefault("metadata", {})
438
+ cloned_tool_info = dict(tool_info)
439
+ cloned_tool_info["tool_calls"] = [dict(call)]
440
+ self._copy_tool_call_field(call, cloned_tool_info, "name")
441
+ self._copy_tool_call_field(call, cloned_tool_info, "args")
442
+ self._copy_tool_call_field(call, cloned_tool_info, "id")
443
+ cloned_meta["tool_info"] = cloned_tool_info
444
+ cloned_meta["step_id"] = self._derive_call_step_id(call, base_step_id, index)
445
+ return cloned
446
+
447
+ @staticmethod
448
+ def _copy_tool_call_field(call: dict[str, Any], target: dict[str, Any], field: str) -> None:
449
+ """Copy a field from the tool call when it exists."""
450
+ value = call.get(field)
451
+ if value:
452
+ target[field] = value
453
+
454
+ @staticmethod
455
+ def _derive_call_step_id(call: dict[str, Any], base_step_id: str, index: int) -> str:
456
+ """Determine the per-call step identifier."""
457
+ call_id = call.get("id")
458
+ if isinstance(call_id, str):
459
+ stripped = call_id.strip()
460
+ if stripped:
461
+ return stripped
462
+ return f"{base_step_id}#{index}"
463
+
464
+ def _apply_single_event(self, event: dict[str, Any]) -> Step:
465
+ metadata, step_id, tool_info, args = self._parse_event_payload(event)
466
+ tool_name = self._resolve_tool_name(tool_info, metadata, step_id)
467
+ kind = self._derive_step_kind(tool_name, metadata)
468
+ parent_hint = self._coerce_parent_id(metadata.get("previous_step_ids"))
469
+
470
+ step = self._get_or_create_step(
471
+ step_id=step_id,
472
+ kind=kind,
473
+ tool_name=tool_name,
474
+ event=event,
475
+ metadata=metadata,
476
+ args=args,
477
+ )
478
+ parent_id = self._determine_parent_id(step, metadata, parent_hint)
479
+ self._link_step(step, parent_id)
480
+
481
+ self.state.retained_ids.add(step.step_id)
482
+ step.display_label = self._compose_display_label(step.kind, tool_name, args, metadata)
483
+ self._flush_buffered_children(step.step_id)
484
+ self._apply_pending_branch_flags(step.step_id)
485
+
486
+ status = self._normalise_status(metadata.get("status"), event.get("status"), event.get("task_state"))
487
+ status = self._apply_failure_state(step, status, event)
488
+
489
+ server_time = self._coerce_server_time(metadata.get("time"))
490
+ self._update_server_timestamps(step, server_time, status)
491
+
492
+ self._apply_duration(
493
+ step=step,
494
+ status=status,
495
+ tool_info=tool_info,
496
+ args=args,
497
+ server_time=server_time,
498
+ )
499
+
500
+ self._update_scope_bindings(
501
+ step=step,
502
+ metadata=metadata,
503
+ tool_name=tool_name,
504
+ status=status,
505
+ )
506
+
507
+ step.status_icon = self._status_icon_for_step(step)
508
+ self._update_parallel_tracking(step)
509
+ self._update_running_index(step)
510
+ self._prune_steps()
511
+ return step
512
+
513
+ 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):
516
+ raise ValueError("Step event missing metadata payload")
517
+
518
+ step_id = metadata.get("step_id")
519
+ if not isinstance(step_id, str) or not step_id:
520
+ raise ValueError("Step event missing step_id")
521
+
522
+ tool_info = metadata.get("tool_info") or {}
523
+ if not isinstance(tool_info, dict):
524
+ tool_info = {}
525
+
526
+ canonical_step_id = self._canonicalize_step_id(step_id, tool_info)
527
+ metadata["step_id"] = canonical_step_id
528
+ step_id = canonical_step_id
529
+
530
+ args = self._resolve_tool_args(tool_info)
531
+
532
+ return metadata, step_id, tool_info, args
533
+
534
+ def _resolve_tool_name(self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str) -> str:
535
+ name = tool_info.get("name")
536
+ if not name:
537
+ call = self._first_tool_call(tool_info)
538
+ if call:
539
+ name = call.get("name")
540
+ if isinstance(name, str) and name.strip():
541
+ return name
542
+ if name is not None:
543
+ return str(name)
544
+
545
+ agent_name = metadata.get("agent_name")
546
+ if isinstance(agent_name, str) and agent_name.strip():
547
+ return agent_name
548
+ return step_id
549
+
550
+ def _resolve_tool_args(self, tool_info: dict[str, Any]) -> dict[str, Any]:
551
+ args = tool_info.get("args")
552
+ if isinstance(args, dict):
553
+ return args
554
+ call = self._first_tool_call(tool_info)
555
+ if call:
556
+ call_args = call.get("args")
557
+ if isinstance(call_args, dict):
558
+ return call_args
559
+ return {}
560
+
561
+ def _first_tool_call(self, tool_info: dict[str, Any]) -> dict[str, Any] | None:
562
+ tool_calls = tool_info.get("tool_calls")
563
+ if isinstance(tool_calls, list) and tool_calls:
564
+ candidate = tool_calls[0]
565
+ if isinstance(candidate, dict):
566
+ return candidate
567
+ return None
568
+
569
+ def _get_or_create_step(
570
+ self,
571
+ step_id: str,
572
+ kind: str,
573
+ tool_name: str,
574
+ event: dict[str, Any],
575
+ metadata: dict[str, Any],
576
+ args: dict[str, Any],
577
+ ) -> Step:
578
+ existing = self.by_id.get(step_id)
579
+ if existing:
580
+ return self._update_existing_step(existing, kind, tool_name, event, metadata, args)
581
+ return self._create_step_from_event(step_id, kind, tool_name, event, metadata, args)
582
+
583
+ def _create_step_from_event(
584
+ self,
585
+ step_id: str,
586
+ kind: str,
587
+ tool_name: str,
588
+ event: dict[str, Any],
589
+ metadata: dict[str, Any],
590
+ args: dict[str, Any],
591
+ ) -> Step:
592
+ step = Step(
593
+ step_id=step_id,
594
+ kind=kind,
595
+ name=tool_name or step_id,
596
+ task_id=self._coalesce_metadata_value("task_id", event, metadata, fallback=None),
597
+ context_id=self._coalesce_metadata_value("context_id", event, metadata, fallback=None),
598
+ args=args or {},
599
+ )
600
+ self.by_id[step_id] = step
601
+ self.state.retained_ids.add(step_id)
602
+ return step
603
+
604
+ def _update_existing_step(
605
+ self,
606
+ step: Step,
607
+ kind: str,
608
+ tool_name: str,
609
+ event: dict[str, Any],
610
+ metadata: dict[str, Any],
611
+ args: dict[str, Any],
612
+ ) -> Step:
613
+ step.kind = step.kind or kind
614
+ step.name = tool_name or step.name
615
+ if args:
616
+ step.args = args
617
+ step.task_id = self._coalesce_metadata_value("task_id", event, metadata, fallback=step.task_id)
618
+ step.context_id = self._coalesce_metadata_value("context_id", event, metadata, fallback=step.context_id)
619
+ return step
620
+
621
+ def _apply_failure_state(self, step: Step, status: str, event: dict[str, Any]) -> str:
622
+ failure_reason = self._extract_failure_reason(status, event.get("task_state"), event.get("content"))
623
+ if not failure_reason:
624
+ step.status = status
625
+ return status
626
+
627
+ step.failure_reason = failure_reason
628
+ if status not in {"failed", "stopped"}:
629
+ status = "failed"
630
+ self._set_branch_warning(step.parent_id)
631
+ step.status = status
632
+ return status
633
+
634
+ def _apply_duration(
635
+ self,
636
+ step: Step,
637
+ status: str,
638
+ tool_info: dict[str, Any],
639
+ args: dict[str, Any],
640
+ server_time: float | None,
641
+ ) -> None:
642
+ duration_ms, duration_source = self._resolve_duration_from_event(tool_info, args)
643
+ if duration_ms is not None:
644
+ step.duration_ms = duration_ms
645
+ step.duration_source = duration_source
646
+ return
647
+
648
+ if status in {"finished", "failed", "stopped"} and step.duration_ms is None:
649
+ timeline_ms = self._calculate_server_duration(step, server_time)
650
+ if timeline_ms is not None:
651
+ step.duration_ms = timeline_ms
652
+ step.duration_source = "timeline"
653
+ return
654
+ try:
655
+ step.duration_ms = int((monotonic() - step.started_at) * 1000)
656
+ except Exception:
657
+ step.duration_ms = 0
658
+ step.duration_source = step.duration_source or "monotonic"
659
+
660
+ def _update_running_index(self, step: Step) -> None:
661
+ key = (step.task_id, step.context_id, step.kind, step.name)
662
+ if step.status == "finished":
663
+ if self._last_running.get(key) == step.step_id:
664
+ self._last_running.pop(key, None)
665
+ else:
666
+ self._last_running[key] = step.step_id
667
+
668
+ def _coalesce_metadata_value(
669
+ self,
670
+ key: str,
671
+ event: dict[str, Any],
672
+ metadata: dict[str, Any],
673
+ *,
674
+ fallback: Any = None,
675
+ ) -> Any:
676
+ if event.get(key) is not None:
677
+ return event[key]
678
+ if metadata.get(key) is not None:
679
+ return metadata[key]
680
+ return fallback
681
+
682
+ def _coerce_parent_id(self, parent_value: Any) -> str | None:
683
+ if isinstance(parent_value, list):
684
+ for candidate in parent_value:
685
+ if isinstance(candidate, str) and candidate.strip():
686
+ return self._canonical_parent_id(candidate)
687
+ elif isinstance(parent_value, str) and parent_value.strip():
688
+ return self._canonical_parent_id(parent_value)
689
+ return None
690
+
691
+ def _canonical_parent_id(self, value: str) -> str:
692
+ return self._step_aliases.get(value, value)
693
+
694
+ def _derive_step_kind(self, tool_name: str | None, metadata: dict[str, Any]) -> str:
695
+ metadata_kind = metadata.get("kind")
696
+ kind = self._clean_kind(metadata_kind)
697
+ tool = (tool_name or "").lower()
698
+
699
+ if self._is_thinking_step(kind, tool):
700
+ return "thinking"
701
+ if self._is_delegate_tool(tool):
702
+ return "delegate"
703
+ if kind == "agent_thinking_step" and tool:
704
+ return "tool"
705
+ if self._is_top_level_agent(tool_name, metadata, kind):
706
+ return "agent"
707
+ if kind == "agent_step" and tool.startswith("delegate"):
708
+ return "delegate"
709
+ if tool.startswith("agent_"):
710
+ return "agent"
711
+ if kind == "agent_step":
712
+ return "tool" if tool else "agent_step"
713
+ return kind or "tool"
714
+
715
+ def _clean_kind(self, metadata_kind: Any) -> str:
716
+ return metadata_kind.lower() if isinstance(metadata_kind, str) else ""
717
+
718
+ def _is_thinking_step(self, kind: str, tool: str) -> bool:
719
+ if tool.startswith("agent_thinking"):
720
+ return True
721
+ return kind == "agent_thinking_step" and not tool
722
+
723
+ def _is_delegate_tool(self, tool: str) -> bool:
724
+ return tool.startswith(("delegate_to_", "delegate-", "delegate ", "delegate_"))
725
+
726
+ def _is_top_level_agent(self, tool_name: str | None, metadata: dict[str, Any], kind: str) -> bool:
727
+ if kind != "agent_step":
728
+ return False
729
+ agent_name = metadata.get("agent_name")
730
+ if isinstance(agent_name, str) and agent_name and tool_name == agent_name:
731
+ return True
732
+ return self._looks_like_uuid(tool_name or "")
733
+
734
+ @staticmethod
735
+ def _looks_like_uuid(value: str) -> bool:
736
+ stripped = value.replace("-", "")
737
+ if len(stripped) not in {32, 36}:
738
+ return False
739
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
740
+
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
+ def _normalise_status(
801
+ self,
802
+ metadata_status: Any,
803
+ event_status: Any,
804
+ task_state: Any,
805
+ ) -> str:
806
+ for candidate in (metadata_status, event_status, task_state):
807
+ status = (candidate or "").lower() if isinstance(candidate, str) else ""
808
+ if status in {"running", "started", "pending", "working"}:
809
+ return "running"
810
+ if status in {"finished", "success", "succeeded", "completed"}:
811
+ return "finished"
812
+ if status in {"failed", "error"}:
813
+ return "failed"
814
+ if status in {"stopped", "cancelled", "canceled"}:
815
+ return "stopped"
816
+ return "running"
817
+
818
+ def _extract_failure_reason(
819
+ self,
820
+ status: str,
821
+ task_state: Any,
822
+ content: Any,
823
+ ) -> str | None:
824
+ failure_states = {"failed", "stopped", "error"}
825
+ task_state_str = (task_state or "").lower() if isinstance(task_state, str) else ""
826
+ if status in failure_states or task_state_str in failure_states:
827
+ if isinstance(content, str) and content.strip():
828
+ return content.strip()
829
+ if task_state_str:
830
+ return task_state_str
831
+ return None
832
+
833
+ def _resolve_duration_from_event(
834
+ self,
835
+ tool_info: dict[str, Any],
836
+ args: dict[str, Any],
837
+ ) -> tuple[int | None, str | None]:
838
+ exec_time = tool_info.get("execution_time")
839
+ if isinstance(exec_time, (int, float)):
840
+ return max(0, int(round(float(exec_time) * 1000))), "metadata"
841
+
842
+ duration_seconds = tool_info.get("duration_seconds")
843
+ if isinstance(duration_seconds, (int, float)):
844
+ return max(0, int(round(float(duration_seconds) * 1000))), "metadata"
845
+
846
+ wait_seconds = args.get("wait_seconds")
847
+ if isinstance(wait_seconds, (int, float)):
848
+ return max(0, int(round(float(wait_seconds) * 1000))), "argument"
849
+
850
+ return None, None
851
+
852
+ def _determine_parent_id(self, step: Step, metadata: dict[str, Any], parent_hint: str | None) -> str | None:
853
+ scope_parent = self._lookup_scope_parent(metadata, step)
854
+ candidate = scope_parent or parent_hint
855
+ if candidate == step.step_id:
856
+ logger.debug("Step %s cannot parent itself; dropping parent hint", candidate)
857
+ return None
858
+ return candidate
859
+
860
+ def _lookup_scope_parent(self, metadata: dict[str, Any], step: Step) -> str | None:
861
+ agent_name = metadata.get("agent_name")
862
+ if not isinstance(agent_name, str) or not agent_name.strip():
863
+ return None
864
+ stack = self._scope_anchors.get(agent_name.strip())
865
+ if not stack:
866
+ return None
867
+ anchor_id = stack[-1]
868
+ if anchor_id == step.step_id:
869
+ return None
870
+ return anchor_id
871
+
872
+ def _link_step(self, step: Step, parent_id: str | None) -> None:
873
+ """Attach a step to the resolved parent, buffering when necessary."""
874
+ parent_id = self._sanitize_parent_reference(step, parent_id)
875
+ if self._ensure_existing_link(step, parent_id):
876
+ return
877
+
878
+ self._detach_from_current_parent(step)
879
+ self._attach_to_parent(step, parent_id)
880
+
881
+ def _sanitize_parent_reference(self, step: Step, parent_id: str | None) -> str | None:
882
+ """Guard against self-referential parent assignments."""
883
+ if parent_id != step.step_id:
884
+ return parent_id
885
+
886
+ logger.debug(
887
+ "Ignoring self-referential parent_id %s for step %s",
888
+ parent_id,
889
+ step.step_id,
890
+ )
891
+ return step.parent_id
892
+
893
+ def _ensure_existing_link(self, step: Step, parent_id: str | None) -> bool:
894
+ """Keep existing parent/child wiring in sync when the parent is unchanged."""
895
+ if parent_id != step.parent_id:
896
+ return False
897
+
898
+ if parent_id is None:
899
+ if step.step_id not in self.state.root_order:
900
+ self.state.link_root(step.step_id)
901
+ return True
902
+
903
+ if parent_id not in self.by_id:
904
+ return False
905
+
906
+ children = self.children.get(parent_id, [])
907
+ if step.step_id not in children:
908
+ self.state.link_child(parent_id, step.step_id)
909
+ return True
910
+
911
+ def _detach_from_current_parent(self, step: Step) -> None:
912
+ """Remove the step from its current parent/root collection."""
913
+ if step.parent_id:
914
+ self.state.unlink_child(step.parent_id, step.step_id)
915
+ return
916
+ self.state.unlink_root(step.step_id)
917
+
918
+ def _attach_to_parent(self, step: Step, parent_id: str | None) -> None:
919
+ """Attach the step to the requested parent, buffering when needed."""
920
+ if parent_id is None:
921
+ step.parent_id = None
922
+ self.state.link_root(step.step_id)
923
+ return
924
+
925
+ if parent_id not in self.by_id:
926
+ self.state.buffer_child(parent_id, step.step_id)
927
+ step.parent_id = None
928
+ return
929
+
930
+ step.parent_id = parent_id
931
+ self.state.link_child(parent_id, step.step_id)
932
+ self.state.unlink_root(step.step_id)
933
+
934
+ def _update_scope_bindings(
935
+ self,
936
+ *,
937
+ step: Step,
938
+ metadata: dict[str, Any],
939
+ tool_name: str,
940
+ status: str,
941
+ ) -> None:
942
+ agent_name = metadata.get("agent_name")
943
+ if step.kind == "agent" and isinstance(agent_name, str) and agent_name.strip():
944
+ self._register_scope_anchor(agent_name.strip(), step.step_id)
945
+ return
946
+
947
+ if step.kind == "delegate":
948
+ slug = self._derive_delegate_slug(tool_name)
949
+ if not slug:
950
+ return
951
+ # Ensure the delegate anchor exists even if the first event we see is already finished
952
+ if status == "running" or step.step_id not in self._step_scope_map:
953
+ self._register_scope_anchor(slug, step.step_id)
954
+ elif status in {"finished", "failed", "stopped"}:
955
+ self._release_scope_anchor(step.step_id)
956
+ return
957
+
958
+ if status in {"finished", "failed", "stopped"}:
959
+ self._release_scope_anchor(step.step_id)
960
+
961
+ def _register_scope_anchor(self, scope_key: str, step_id: str) -> None:
962
+ scope = scope_key.strip()
963
+ stack = self._scope_anchors.setdefault(scope, [])
964
+ if step_id not in stack:
965
+ stack.append(step_id)
966
+ self._step_scope_map[step_id] = scope
967
+
968
+ def _release_scope_anchor(self, step_id: str) -> None:
969
+ scope = self._step_scope_map.get(step_id)
970
+ if not scope or scope == (self.root_agent_id or "").strip():
971
+ return
972
+ stack = self._scope_anchors.get(scope)
973
+ if stack:
974
+ if stack[-1] == step_id:
975
+ stack.pop()
976
+ elif step_id in stack:
977
+ stack.remove(step_id)
978
+ # Clean up if stack is now empty
979
+ if len(stack) == 0:
980
+ self._scope_anchors.pop(scope, None)
981
+ self._step_scope_map.pop(step_id, None)
982
+
983
+ @staticmethod
984
+ def _derive_delegate_slug(tool_name: str | None) -> str | None:
985
+ if not isinstance(tool_name, str):
986
+ return None
987
+ slug = tool_name.strip()
988
+ if slug.startswith("delegate_to_"):
989
+ slug = slug.removeprefix("delegate_to_")
990
+ elif slug.startswith("delegate_"):
991
+ slug = slug.removeprefix("delegate_")
992
+ elif slug.startswith("delegate-"):
993
+ slug = slug.removeprefix("delegate-")
994
+ slug = slug.replace("-", "_").strip()
995
+ return slug or None
996
+
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
+ def _update_server_timestamps(self, step: Step, server_time: float | None, status: str) -> None:
1007
+ if server_time is None:
1008
+ return
1009
+ if status == "running" and step.server_started_at is None:
1010
+ step.server_started_at = server_time
1011
+ elif status in {"finished", "failed", "stopped"}:
1012
+ step.server_finished_at = server_time
1013
+ if step.server_started_at is None:
1014
+ step.server_started_at = server_time
1015
+
1016
+ def _calculate_server_duration(self, step: Step, server_time: float | None) -> int | None:
1017
+ start = step.server_started_at
1018
+ end = server_time if server_time is not None else step.server_finished_at
1019
+ if start is None or end is None:
1020
+ return None
1021
+ try:
1022
+ return max(0, int(round((float(end) - float(start)) * 1000)))
1023
+ except Exception:
1024
+ return None
1025
+
1026
+ def _flush_buffered_children(self, parent_id: str) -> None:
1027
+ for child_id in self.state.pop_buffered_children(parent_id):
1028
+ child = self.by_id.get(child_id)
1029
+ if not child:
1030
+ continue
1031
+ child.parent_id = parent_id
1032
+ self.state.link_child(parent_id, child_id)
1033
+ self.state.unlink_root(child_id)
1034
+
1035
+ def _apply_pending_branch_flags(self, step_id: str) -> None:
1036
+ if step_id not in self.state.pending_branch_failures:
1037
+ return
1038
+ step = self.by_id.get(step_id)
1039
+ if step:
1040
+ step.branch_failed = True
1041
+ step.status_icon = "warning"
1042
+ self.state.pending_branch_failures.discard(step_id)
1043
+
1044
+ def _set_branch_warning(self, parent_id: str | None) -> None:
1045
+ if not parent_id:
1046
+ return
1047
+ parent = self.by_id.get(parent_id)
1048
+ if parent:
1049
+ parent.branch_failed = True
1050
+ parent.status_icon = "warning"
1051
+ else:
1052
+ self.state.pending_branch_failures.add(parent_id)
1053
+
1054
+ def _update_parallel_tracking(self, step: Step) -> None:
1055
+ if step.kind != "tool":
1056
+ step.is_parallel = False
1057
+ return
1058
+
1059
+ key = (step.task_id, step.context_id)
1060
+ running = self.state.running_by_context.get(key)
1061
+
1062
+ if step.status == "running":
1063
+ if running is None:
1064
+ running = set()
1065
+ self.state.running_by_context[key] = running
1066
+ running.add(step.step_id)
1067
+ elif running:
1068
+ running.discard(step.step_id)
1069
+ step.is_parallel = False
1070
+
1071
+ if not running:
1072
+ self.state.running_by_context.pop(key, None)
1073
+ step.is_parallel = False
1074
+ return
1075
+
1076
+ is_parallel = len(running) > 1
1077
+ for sid in running:
1078
+ current = self.by_id.get(sid)
1079
+ if current:
1080
+ current.is_parallel = is_parallel
1081
+
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
+ def _canonicalize_step_id(self, step_id: str, tool_info: dict[str, Any]) -> str:
1092
+ alias = self._lookup_alias(step_id)
1093
+ if alias:
1094
+ return alias
1095
+
1096
+ candidate_ids = self._collect_instance_ids(tool_info)
1097
+ alias = self._find_existing_candidate_alias(candidate_ids)
1098
+ if alias:
1099
+ self._step_aliases[step_id] = alias
1100
+ return alias
1101
+
1102
+ return self._register_new_alias(step_id, candidate_ids)
1103
+
1104
+ def _lookup_alias(self, step_id: str) -> str | None:
1105
+ alias = self._step_aliases.get(step_id)
1106
+ return alias if alias else None
1107
+
1108
+ def _find_existing_candidate_alias(self, candidate_ids: list[str]) -> str | None:
1109
+ for candidate in candidate_ids:
1110
+ mapped = self._step_aliases.get(candidate)
1111
+ if mapped:
1112
+ return mapped
1113
+ return None
1114
+
1115
+ def _register_new_alias(self, step_id: str, candidate_ids: list[str]) -> str:
1116
+ if candidate_ids:
1117
+ canonical = step_id if len(candidate_ids) > 1 else candidate_ids[0]
1118
+ self._step_aliases[step_id] = canonical
1119
+ for candidate in candidate_ids:
1120
+ self._step_aliases.setdefault(candidate, canonical)
1121
+ return canonical
1122
+
1123
+ self._step_aliases.setdefault(step_id, step_id)
1124
+ return step_id
1125
+
1126
+ def _collect_instance_ids(self, tool_info: dict[str, Any]) -> list[str]:
1127
+ """Collect all potential identifiers for a tool invocation."""
1128
+ candidates: list[str] = []
1129
+ identifier = self._normalise_identifier(tool_info.get("id"))
1130
+ if identifier:
1131
+ candidates.append(identifier)
1132
+
1133
+ candidates.extend(self._extract_tool_call_ids(tool_info.get("tool_calls")))
1134
+ return self._deduplicate_candidates(candidates)
1135
+
1136
+ def _extract_tool_call_ids(self, tool_calls: Any) -> list[str]:
1137
+ """Extract unique IDs from tool_calls payloads."""
1138
+ if not isinstance(tool_calls, list):
1139
+ return []
1140
+ collected: list[str] = []
1141
+ for call in tool_calls:
1142
+ if not isinstance(call, dict):
1143
+ continue
1144
+ identifier = self._normalise_identifier(call.get("id"))
1145
+ if identifier:
1146
+ collected.append(identifier)
1147
+ return collected
1148
+
1149
+ @staticmethod
1150
+ def _normalise_identifier(value: Any) -> str | None:
1151
+ if isinstance(value, str):
1152
+ stripped = value.strip()
1153
+ return stripped or None
1154
+ return None
1155
+
1156
+ @staticmethod
1157
+ def _deduplicate_candidates(candidates: list[str]) -> list[str]:
1158
+ seen: set[str] = set()
1159
+ ordered: list[str] = []
1160
+ for candidate in candidates:
1161
+ if candidate in seen:
1162
+ continue
1163
+ seen.add(candidate)
1164
+ ordered.append(candidate)
1165
+ return ordered