glaip-sdk 0.0.20__py3-none-any.whl → 0.1.0__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.
@@ -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,
@@ -111,6 +129,7 @@ class StepManager:
111
129
  else:
112
130
  self.order.append(step_id)
113
131
  self.key_index[key] = step_id
132
+ self.state.retained_ids.add(step_id)
114
133
  self._prune_steps()
115
134
  self._last_running[(task_id, context_id, kind, name)] = step_id
116
135
  return st
@@ -131,26 +150,46 @@ class StepManager:
131
150
 
132
151
  def _remove_subtree(self, root_id: str) -> None:
133
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."""
134
158
  stack = [root_id]
135
- to_remove = []
159
+ collected: list[str] = []
136
160
  while stack:
137
161
  sid = stack.pop()
138
- to_remove.append(sid)
162
+ collected.append(sid)
139
163
  stack.extend(self.children.pop(sid, []))
164
+ return collected
140
165
 
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)
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)
151
188
 
152
189
  def _should_prune_steps(self, total: int) -> bool:
153
190
  """Check if steps should be pruned."""
191
+ if self.max_steps <= 0:
192
+ return False
154
193
  return total > self.max_steps
155
194
 
156
195
  def _get_oldest_step_id(self) -> str | None:
@@ -172,6 +211,38 @@ class StepManager:
172
211
  self._remove_subtree(sid)
173
212
  total -= subtree_size
174
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
+
175
246
  def get_child_count(self, step_id: str) -> int:
176
247
  """Get the number of child steps for a given step.
177
248
 
@@ -289,3 +360,860 @@ class StepManager:
289
360
  sid = stack.pop()
290
361
  yield sid
291
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
+ # ------------------------------------------------------------------
389
+
390
+ def apply_event(self, event: dict[str, Any]) -> Step:
391
+ """Apply an SSE step event and return the updated step."""
392
+ cloned_events = self._split_multi_tool_event(event)
393
+ if cloned_events:
394
+ last_step: Step | None = None
395
+ for cloned in cloned_events:
396
+ last_step = self._apply_single_event(cloned)
397
+ if last_step:
398
+ return last_step
399
+ return self._apply_single_event(event)
400
+
401
+ def _split_multi_tool_event(self, event: dict[str, Any]) -> list[dict[str, Any]]:
402
+ """Split events that describe multiple tool calls into per-call clones."""
403
+ metadata = event.get("metadata") or {}
404
+ tool_info = metadata.get("tool_info") or {}
405
+ tool_calls = tool_info.get("tool_calls")
406
+ if not self._should_split_tool_calls(tool_calls):
407
+ return []
408
+ if self._all_delegate_calls(tool_calls):
409
+ return []
410
+
411
+ base_step_id = metadata.get("step_id") or "step"
412
+ clones: list[dict[str, Any]] = []
413
+ for index, call in enumerate(tool_calls):
414
+ clone = self._clone_tool_call(event, tool_info, call, base_step_id, index)
415
+ if clone is not None:
416
+ clones.append(clone)
417
+ return clones
418
+
419
+ @staticmethod
420
+ def _should_split_tool_calls(tool_calls: Any) -> bool:
421
+ """Return True when an event references more than one tool call."""
422
+ return isinstance(tool_calls, list) and len(tool_calls) > 1
423
+
424
+ def _all_delegate_calls(self, tool_calls: Any) -> bool:
425
+ """Return True when an event batch only contains delegate tools."""
426
+ if not isinstance(tool_calls, list) or not tool_calls:
427
+ return False
428
+ for call in tool_calls:
429
+ if not isinstance(call, dict):
430
+ return False
431
+ name = (call.get("name") or "").lower()
432
+ if not self._is_delegate_tool(name):
433
+ return False
434
+ return True
435
+
436
+ def _clone_tool_call(
437
+ self,
438
+ event: dict[str, Any],
439
+ tool_info: dict[str, Any],
440
+ call: Any,
441
+ base_step_id: str,
442
+ index: int,
443
+ ) -> dict[str, Any] | None:
444
+ """Create a per-call clone of a multi-tool event."""
445
+ if not isinstance(call, dict):
446
+ return None
447
+
448
+ cloned = deepcopy(event)
449
+ cloned_meta = cloned.setdefault("metadata", {})
450
+ cloned_tool_info = dict(tool_info)
451
+ cloned_tool_info["tool_calls"] = [dict(call)]
452
+ self._copy_tool_call_field(call, cloned_tool_info, "name")
453
+ self._copy_tool_call_field(call, cloned_tool_info, "args")
454
+ self._copy_tool_call_field(call, cloned_tool_info, "id")
455
+ cloned_meta["tool_info"] = cloned_tool_info
456
+ cloned_meta["step_id"] = self._derive_call_step_id(call, base_step_id, index)
457
+ return cloned
458
+
459
+ @staticmethod
460
+ def _copy_tool_call_field(
461
+ call: dict[str, Any], target: dict[str, Any], field: str
462
+ ) -> None:
463
+ """Copy a field from the tool call when it exists."""
464
+ value = call.get(field)
465
+ if value:
466
+ target[field] = value
467
+
468
+ @staticmethod
469
+ def _derive_call_step_id(
470
+ call: dict[str, Any], base_step_id: str, index: int
471
+ ) -> str:
472
+ """Determine the per-call step identifier."""
473
+ call_id = call.get("id")
474
+ if isinstance(call_id, str):
475
+ stripped = call_id.strip()
476
+ if stripped:
477
+ return stripped
478
+ return f"{base_step_id}#{index}"
479
+
480
+ def _apply_single_event(self, event: dict[str, Any]) -> Step:
481
+ metadata, step_id, tool_info, args = self._parse_event_payload(event)
482
+ tool_name = self._resolve_tool_name(tool_info, metadata, step_id)
483
+ kind = self._derive_step_kind(tool_name, metadata)
484
+ parent_hint = self._coerce_parent_id(metadata.get("previous_step_ids"))
485
+
486
+ step = self._get_or_create_step(
487
+ step_id=step_id,
488
+ kind=kind,
489
+ tool_name=tool_name,
490
+ event=event,
491
+ metadata=metadata,
492
+ args=args,
493
+ )
494
+ parent_id = self._determine_parent_id(step, metadata, parent_hint)
495
+ self._link_step(step, parent_id)
496
+
497
+ self.state.retained_ids.add(step.step_id)
498
+ step.display_label = self._compose_display_label(
499
+ step.kind, tool_name, args, metadata
500
+ )
501
+ self._flush_buffered_children(step.step_id)
502
+ self._apply_pending_branch_flags(step.step_id)
503
+
504
+ status = self._normalise_status(
505
+ metadata.get("status"), event.get("status"), event.get("task_state")
506
+ )
507
+ status = self._apply_failure_state(step, status, event)
508
+
509
+ server_time = self._coerce_server_time(metadata.get("time"))
510
+ self._update_server_timestamps(step, server_time, status)
511
+
512
+ self._apply_duration(
513
+ step=step,
514
+ status=status,
515
+ tool_info=tool_info,
516
+ args=args,
517
+ server_time=server_time,
518
+ )
519
+
520
+ self._update_scope_bindings(
521
+ step=step,
522
+ metadata=metadata,
523
+ tool_name=tool_name,
524
+ status=status,
525
+ )
526
+
527
+ step.status_icon = self._status_icon_for_step(step)
528
+ self._update_parallel_tracking(step)
529
+ self._update_running_index(step)
530
+ self._prune_steps()
531
+ return step
532
+
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):
538
+ raise ValueError("Step event missing metadata payload")
539
+
540
+ step_id = metadata.get("step_id")
541
+ if not isinstance(step_id, str) or not step_id:
542
+ raise ValueError("Step event missing step_id")
543
+
544
+ tool_info = metadata.get("tool_info") or {}
545
+ if not isinstance(tool_info, dict):
546
+ tool_info = {}
547
+
548
+ canonical_step_id = self._canonicalize_step_id(step_id, tool_info)
549
+ metadata["step_id"] = canonical_step_id
550
+ step_id = canonical_step_id
551
+
552
+ args = self._resolve_tool_args(tool_info)
553
+
554
+ return metadata, step_id, tool_info, args
555
+
556
+ def _resolve_tool_name(
557
+ self, tool_info: dict[str, Any], metadata: dict[str, Any], step_id: str
558
+ ) -> str:
559
+ name = tool_info.get("name")
560
+ if not name:
561
+ call = self._first_tool_call(tool_info)
562
+ if call:
563
+ name = call.get("name")
564
+ if isinstance(name, str) and name.strip():
565
+ return name
566
+ if name is not None:
567
+ return str(name)
568
+
569
+ agent_name = metadata.get("agent_name")
570
+ if isinstance(agent_name, str) and agent_name.strip():
571
+ return agent_name
572
+ return step_id
573
+
574
+ def _resolve_tool_args(self, tool_info: dict[str, Any]) -> dict[str, Any]:
575
+ args = tool_info.get("args")
576
+ if isinstance(args, dict):
577
+ return args
578
+ call = self._first_tool_call(tool_info)
579
+ if call:
580
+ call_args = call.get("args")
581
+ if isinstance(call_args, dict):
582
+ return call_args
583
+ return {}
584
+
585
+ def _first_tool_call(self, tool_info: dict[str, Any]) -> dict[str, Any] | None:
586
+ tool_calls = tool_info.get("tool_calls")
587
+ if isinstance(tool_calls, list) and tool_calls:
588
+ candidate = tool_calls[0]
589
+ if isinstance(candidate, dict):
590
+ return candidate
591
+ return None
592
+
593
+ def _get_or_create_step(
594
+ self,
595
+ step_id: str,
596
+ kind: str,
597
+ tool_name: str,
598
+ event: dict[str, Any],
599
+ metadata: dict[str, Any],
600
+ args: dict[str, Any],
601
+ ) -> Step:
602
+ existing = self.by_id.get(step_id)
603
+ 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
+ )
610
+
611
+ def _create_step_from_event(
612
+ self,
613
+ step_id: str,
614
+ kind: str,
615
+ tool_name: str,
616
+ event: dict[str, Any],
617
+ metadata: dict[str, Any],
618
+ args: dict[str, Any],
619
+ ) -> Step:
620
+ step = Step(
621
+ step_id=step_id,
622
+ kind=kind,
623
+ 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
+ ),
630
+ args=args or {},
631
+ )
632
+ self.by_id[step_id] = step
633
+ self.state.retained_ids.add(step_id)
634
+ return step
635
+
636
+ def _update_existing_step(
637
+ self,
638
+ step: Step,
639
+ kind: str,
640
+ tool_name: str,
641
+ event: dict[str, Any],
642
+ metadata: dict[str, Any],
643
+ args: dict[str, Any],
644
+ ) -> Step:
645
+ step.kind = step.kind or kind
646
+ step.name = tool_name or step.name
647
+ if args:
648
+ 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
+ )
655
+ return step
656
+
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
+ )
663
+ if not failure_reason:
664
+ step.status = status
665
+ return status
666
+
667
+ step.failure_reason = failure_reason
668
+ if status not in {"failed", "stopped"}:
669
+ status = "failed"
670
+ self._set_branch_warning(step.parent_id)
671
+ step.status = status
672
+ return status
673
+
674
+ def _apply_duration(
675
+ self,
676
+ step: Step,
677
+ status: str,
678
+ tool_info: dict[str, Any],
679
+ args: dict[str, Any],
680
+ server_time: float | None,
681
+ ) -> None:
682
+ duration_ms, duration_source = self._resolve_duration_from_event(
683
+ tool_info, args
684
+ )
685
+ if duration_ms is not None:
686
+ step.duration_ms = duration_ms
687
+ step.duration_source = duration_source
688
+ return
689
+
690
+ if status in {"finished", "failed", "stopped"} and step.duration_ms is None:
691
+ timeline_ms = self._calculate_server_duration(step, server_time)
692
+ if timeline_ms is not None:
693
+ step.duration_ms = timeline_ms
694
+ step.duration_source = "timeline"
695
+ return
696
+ try:
697
+ step.duration_ms = int((monotonic() - step.started_at) * 1000)
698
+ except Exception:
699
+ step.duration_ms = 0
700
+ step.duration_source = step.duration_source or "monotonic"
701
+
702
+ def _update_running_index(self, step: Step) -> None:
703
+ key = (step.task_id, step.context_id, step.kind, step.name)
704
+ if step.status == "finished":
705
+ if self._last_running.get(key) == step.step_id:
706
+ self._last_running.pop(key, None)
707
+ else:
708
+ self._last_running[key] = step.step_id
709
+
710
+ def _coalesce_metadata_value(
711
+ self,
712
+ key: str,
713
+ event: dict[str, Any],
714
+ metadata: dict[str, Any],
715
+ *,
716
+ fallback: Any = None,
717
+ ) -> Any:
718
+ if event.get(key) is not None:
719
+ return event[key]
720
+ if metadata.get(key) is not None:
721
+ return metadata[key]
722
+ return fallback
723
+
724
+ def _coerce_parent_id(self, parent_value: Any) -> str | None:
725
+ if isinstance(parent_value, list):
726
+ for candidate in parent_value:
727
+ if isinstance(candidate, str) and candidate.strip():
728
+ return self._canonical_parent_id(candidate)
729
+ elif isinstance(parent_value, str) and parent_value.strip():
730
+ return self._canonical_parent_id(parent_value)
731
+ return None
732
+
733
+ def _canonical_parent_id(self, value: str) -> str:
734
+ return self._step_aliases.get(value, value)
735
+
736
+ def _derive_step_kind(self, tool_name: str | None, metadata: dict[str, Any]) -> str:
737
+ metadata_kind = metadata.get("kind")
738
+ kind = self._clean_kind(metadata_kind)
739
+ tool = (tool_name or "").lower()
740
+
741
+ if self._is_thinking_step(kind, tool):
742
+ return "thinking"
743
+ if self._is_delegate_tool(tool):
744
+ return "delegate"
745
+ if kind == "agent_thinking_step" and tool:
746
+ return "tool"
747
+ if self._is_top_level_agent(tool_name, metadata, kind):
748
+ return "agent"
749
+ if kind == "agent_step" and tool.startswith("delegate"):
750
+ return "delegate"
751
+ if tool.startswith("agent_"):
752
+ return "agent"
753
+ return kind or "tool"
754
+
755
+ def _clean_kind(self, metadata_kind: Any) -> str:
756
+ return metadata_kind.lower() if isinstance(metadata_kind, str) else ""
757
+
758
+ def _is_thinking_step(self, kind: str, tool: str) -> bool:
759
+ if tool.startswith("agent_thinking"):
760
+ return True
761
+ return kind == "agent_thinking_step" and not tool
762
+
763
+ def _is_delegate_tool(self, tool: str) -> bool:
764
+ return tool.startswith(("delegate_to_", "delegate-", "delegate ", "delegate_"))
765
+
766
+ def _is_top_level_agent(
767
+ self, tool_name: str | None, metadata: dict[str, Any], kind: str
768
+ ) -> bool:
769
+ if kind != "agent_step":
770
+ return False
771
+ agent_name = metadata.get("agent_name")
772
+ if isinstance(agent_name, str) and agent_name and tool_name == agent_name:
773
+ return True
774
+ return self._looks_like_uuid(tool_name or "")
775
+
776
+ @staticmethod
777
+ def _looks_like_uuid(value: str) -> bool:
778
+ stripped = value.replace("-", "")
779
+ if len(stripped) not in {32, 36}:
780
+ return False
781
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
782
+
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
+ def _normalise_status(
843
+ self,
844
+ metadata_status: Any,
845
+ event_status: Any,
846
+ task_state: Any,
847
+ ) -> str:
848
+ for candidate in (metadata_status, event_status, task_state):
849
+ status = (candidate or "").lower() if isinstance(candidate, str) else ""
850
+ if status in {"running", "started", "pending", "working"}:
851
+ return "running"
852
+ if status in {"finished", "success", "succeeded", "completed"}:
853
+ return "finished"
854
+ if status in {"failed", "error"}:
855
+ return "failed"
856
+ if status in {"stopped", "cancelled", "canceled"}:
857
+ return "stopped"
858
+ return "running"
859
+
860
+ def _extract_failure_reason(
861
+ self,
862
+ status: str,
863
+ task_state: Any,
864
+ content: Any,
865
+ ) -> str | None:
866
+ failure_states = {"failed", "stopped", "error"}
867
+ task_state_str = (
868
+ (task_state or "").lower() if isinstance(task_state, str) else ""
869
+ )
870
+ if status in failure_states or task_state_str in failure_states:
871
+ if isinstance(content, str) and content.strip():
872
+ return content.strip()
873
+ if task_state_str:
874
+ return task_state_str
875
+ return None
876
+
877
+ def _resolve_duration_from_event(
878
+ self,
879
+ tool_info: dict[str, Any],
880
+ args: dict[str, Any],
881
+ ) -> tuple[int | None, str | None]:
882
+ exec_time = tool_info.get("execution_time")
883
+ if isinstance(exec_time, (int, float)):
884
+ return max(0, int(round(float(exec_time) * 1000))), "metadata"
885
+
886
+ duration_seconds = tool_info.get("duration_seconds")
887
+ if isinstance(duration_seconds, (int, float)):
888
+ return max(0, int(round(float(duration_seconds) * 1000))), "metadata"
889
+
890
+ wait_seconds = args.get("wait_seconds")
891
+ if isinstance(wait_seconds, (int, float)):
892
+ return max(0, int(round(float(wait_seconds) * 1000))), "argument"
893
+
894
+ return None, None
895
+
896
+ def _determine_parent_id(
897
+ self, step: Step, metadata: dict[str, Any], parent_hint: str | None
898
+ ) -> str | None:
899
+ scope_parent = self._lookup_scope_parent(metadata, step)
900
+ candidate = scope_parent or parent_hint
901
+ if candidate == step.step_id:
902
+ logger.debug(
903
+ "Step %s cannot parent itself; dropping parent hint", candidate
904
+ )
905
+ return None
906
+ return candidate
907
+
908
+ def _lookup_scope_parent(self, metadata: dict[str, Any], step: Step) -> str | None:
909
+ agent_name = metadata.get("agent_name")
910
+ if not isinstance(agent_name, str) or not agent_name.strip():
911
+ return None
912
+ stack = self._scope_anchors.get(agent_name.strip())
913
+ if not stack:
914
+ return None
915
+ anchor_id = stack[-1]
916
+ if anchor_id == step.step_id:
917
+ return None
918
+ return anchor_id
919
+
920
+ def _link_step(self, step: Step, parent_id: str | None) -> None:
921
+ """Attach a step to the resolved parent, buffering when necessary."""
922
+ parent_id = self._sanitize_parent_reference(step, parent_id)
923
+ if self._ensure_existing_link(step, parent_id):
924
+ return
925
+
926
+ self._detach_from_current_parent(step)
927
+ self._attach_to_parent(step, parent_id)
928
+
929
+ def _sanitize_parent_reference(
930
+ self, step: Step, parent_id: str | None
931
+ ) -> str | None:
932
+ """Guard against self-referential parent assignments."""
933
+ if parent_id != step.step_id:
934
+ return parent_id
935
+
936
+ logger.debug(
937
+ "Ignoring self-referential parent_id %s for step %s",
938
+ parent_id,
939
+ step.step_id,
940
+ )
941
+ return step.parent_id
942
+
943
+ def _ensure_existing_link(self, step: Step, parent_id: str | None) -> bool:
944
+ """Keep existing parent/child wiring in sync when the parent is unchanged."""
945
+ if parent_id != step.parent_id:
946
+ return False
947
+
948
+ if parent_id is None:
949
+ if step.step_id not in self.state.root_order:
950
+ self.state.link_root(step.step_id)
951
+ return True
952
+
953
+ if parent_id not in self.by_id:
954
+ return False
955
+
956
+ children = self.children.get(parent_id, [])
957
+ if step.step_id not in children:
958
+ self.state.link_child(parent_id, step.step_id)
959
+ return True
960
+
961
+ def _detach_from_current_parent(self, step: Step) -> None:
962
+ """Remove the step from its current parent/root collection."""
963
+ if step.parent_id:
964
+ self.state.unlink_child(step.parent_id, step.step_id)
965
+ return
966
+ self.state.unlink_root(step.step_id)
967
+
968
+ def _attach_to_parent(self, step: Step, parent_id: str | None) -> None:
969
+ """Attach the step to the requested parent, buffering when needed."""
970
+ if parent_id is None:
971
+ step.parent_id = None
972
+ self.state.link_root(step.step_id)
973
+ return
974
+
975
+ if parent_id not in self.by_id:
976
+ self.state.buffer_child(parent_id, step.step_id)
977
+ step.parent_id = None
978
+ return
979
+
980
+ step.parent_id = parent_id
981
+ self.state.link_child(parent_id, step.step_id)
982
+ self.state.unlink_root(step.step_id)
983
+
984
+ def _update_scope_bindings(
985
+ self,
986
+ *,
987
+ step: Step,
988
+ metadata: dict[str, Any],
989
+ tool_name: str,
990
+ status: str,
991
+ ) -> None:
992
+ agent_name = metadata.get("agent_name")
993
+ if step.kind == "agent" and isinstance(agent_name, str) and agent_name.strip():
994
+ self._register_scope_anchor(agent_name.strip(), step.step_id)
995
+ return
996
+
997
+ if step.kind == "delegate":
998
+ slug = self._derive_delegate_slug(tool_name)
999
+ if not slug:
1000
+ return
1001
+ # Ensure the delegate anchor exists even if the first event we see is already finished
1002
+ if status == "running" or step.step_id not in self._step_scope_map:
1003
+ self._register_scope_anchor(slug, step.step_id)
1004
+ elif status in {"finished", "failed", "stopped"}:
1005
+ self._release_scope_anchor(step.step_id)
1006
+ return
1007
+
1008
+ if status in {"finished", "failed", "stopped"}:
1009
+ self._release_scope_anchor(step.step_id)
1010
+
1011
+ def _register_scope_anchor(self, scope_key: str, step_id: str) -> None:
1012
+ scope = scope_key.strip()
1013
+ stack = self._scope_anchors.setdefault(scope, [])
1014
+ if step_id not in stack:
1015
+ stack.append(step_id)
1016
+ self._step_scope_map[step_id] = scope
1017
+
1018
+ def _release_scope_anchor(self, step_id: str) -> None:
1019
+ scope = self._step_scope_map.get(step_id)
1020
+ if not scope or scope == (self.root_agent_id or "").strip():
1021
+ return
1022
+ stack = self._scope_anchors.get(scope)
1023
+ if stack:
1024
+ if stack[-1] == step_id:
1025
+ stack.pop()
1026
+ elif step_id in stack:
1027
+ stack.remove(step_id)
1028
+ # Clean up if stack is now empty
1029
+ if len(stack) == 0:
1030
+ self._scope_anchors.pop(scope, None)
1031
+ self._step_scope_map.pop(step_id, None)
1032
+
1033
+ @staticmethod
1034
+ def _derive_delegate_slug(tool_name: str | None) -> str | None:
1035
+ if not isinstance(tool_name, str):
1036
+ return None
1037
+ slug = tool_name.strip()
1038
+ if slug.startswith("delegate_to_"):
1039
+ slug = slug.removeprefix("delegate_to_")
1040
+ elif slug.startswith("delegate_"):
1041
+ slug = slug.removeprefix("delegate_")
1042
+ elif slug.startswith("delegate-"):
1043
+ slug = slug.removeprefix("delegate-")
1044
+ slug = slug.replace("-", "_").strip()
1045
+ return slug or None
1046
+
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:
1059
+ if server_time is None:
1060
+ return
1061
+ if status == "running" and step.server_started_at is None:
1062
+ step.server_started_at = server_time
1063
+ elif status in {"finished", "failed", "stopped"}:
1064
+ step.server_finished_at = server_time
1065
+ if step.server_started_at is None:
1066
+ step.server_started_at = server_time
1067
+
1068
+ def _calculate_server_duration(
1069
+ self, step: Step, server_time: float | None
1070
+ ) -> int | None:
1071
+ start = step.server_started_at
1072
+ end = server_time if server_time is not None else step.server_finished_at
1073
+ if start is None or end is None:
1074
+ return None
1075
+ try:
1076
+ return max(0, int(round((float(end) - float(start)) * 1000)))
1077
+ except Exception:
1078
+ return None
1079
+
1080
+ def _flush_buffered_children(self, parent_id: str) -> None:
1081
+ for child_id in self.state.pop_buffered_children(parent_id):
1082
+ child = self.by_id.get(child_id)
1083
+ if not child:
1084
+ continue
1085
+ child.parent_id = parent_id
1086
+ self.state.link_child(parent_id, child_id)
1087
+ self.state.unlink_root(child_id)
1088
+
1089
+ def _apply_pending_branch_flags(self, step_id: str) -> None:
1090
+ if step_id not in self.state.pending_branch_failures:
1091
+ return
1092
+ step = self.by_id.get(step_id)
1093
+ if step:
1094
+ step.branch_failed = True
1095
+ step.status_icon = "warning"
1096
+ self.state.pending_branch_failures.discard(step_id)
1097
+
1098
+ def _set_branch_warning(self, parent_id: str | None) -> None:
1099
+ if not parent_id:
1100
+ return
1101
+ parent = self.by_id.get(parent_id)
1102
+ if parent:
1103
+ parent.branch_failed = True
1104
+ parent.status_icon = "warning"
1105
+ else:
1106
+ self.state.pending_branch_failures.add(parent_id)
1107
+
1108
+ def _update_parallel_tracking(self, step: Step) -> None:
1109
+ if step.kind != "tool":
1110
+ step.is_parallel = False
1111
+ return
1112
+
1113
+ key = (step.task_id, step.context_id)
1114
+ running = self.state.running_by_context.get(key)
1115
+
1116
+ if step.status == "running":
1117
+ if running is None:
1118
+ running = set()
1119
+ self.state.running_by_context[key] = running
1120
+ running.add(step.step_id)
1121
+ elif running:
1122
+ running.discard(step.step_id)
1123
+ step.is_parallel = False
1124
+
1125
+ if not running:
1126
+ self.state.running_by_context.pop(key, None)
1127
+ step.is_parallel = False
1128
+ return
1129
+
1130
+ is_parallel = len(running) > 1
1131
+ for sid in running:
1132
+ current = self.by_id.get(sid)
1133
+ if current:
1134
+ current.is_parallel = is_parallel
1135
+
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
+ def _canonicalize_step_id(self, step_id: str, tool_info: dict[str, Any]) -> str:
1146
+ alias = self._lookup_alias(step_id)
1147
+ if alias:
1148
+ return alias
1149
+
1150
+ candidate_ids = self._collect_instance_ids(tool_info)
1151
+ alias = self._find_existing_candidate_alias(candidate_ids)
1152
+ if alias:
1153
+ self._step_aliases[step_id] = alias
1154
+ return alias
1155
+
1156
+ return self._register_new_alias(step_id, candidate_ids)
1157
+
1158
+ def _lookup_alias(self, step_id: str) -> str | None:
1159
+ alias = self._step_aliases.get(step_id)
1160
+ return alias if alias else None
1161
+
1162
+ def _find_existing_candidate_alias(self, candidate_ids: list[str]) -> str | None:
1163
+ for candidate in candidate_ids:
1164
+ mapped = self._step_aliases.get(candidate)
1165
+ if mapped:
1166
+ return mapped
1167
+ return None
1168
+
1169
+ def _register_new_alias(self, step_id: str, candidate_ids: list[str]) -> str:
1170
+ if candidate_ids:
1171
+ canonical = step_id if len(candidate_ids) > 1 else candidate_ids[0]
1172
+ self._step_aliases[step_id] = canonical
1173
+ for candidate in candidate_ids:
1174
+ self._step_aliases.setdefault(candidate, canonical)
1175
+ return canonical
1176
+
1177
+ self._step_aliases.setdefault(step_id, step_id)
1178
+ return step_id
1179
+
1180
+ def _collect_instance_ids(self, tool_info: dict[str, Any]) -> list[str]:
1181
+ """Collect all potential identifiers for a tool invocation."""
1182
+ candidates: list[str] = []
1183
+ identifier = self._normalise_identifier(tool_info.get("id"))
1184
+ if identifier:
1185
+ candidates.append(identifier)
1186
+
1187
+ candidates.extend(self._extract_tool_call_ids(tool_info.get("tool_calls")))
1188
+ return self._deduplicate_candidates(candidates)
1189
+
1190
+ def _extract_tool_call_ids(self, tool_calls: Any) -> list[str]:
1191
+ """Extract unique IDs from tool_calls payloads."""
1192
+ if not isinstance(tool_calls, list):
1193
+ return []
1194
+ collected: list[str] = []
1195
+ for call in tool_calls:
1196
+ if not isinstance(call, dict):
1197
+ continue
1198
+ identifier = self._normalise_identifier(call.get("id"))
1199
+ if identifier:
1200
+ collected.append(identifier)
1201
+ return collected
1202
+
1203
+ @staticmethod
1204
+ def _normalise_identifier(value: Any) -> str | None:
1205
+ if isinstance(value, str):
1206
+ stripped = value.strip()
1207
+ return stripped or None
1208
+ return None
1209
+
1210
+ @staticmethod
1211
+ def _deduplicate_candidates(candidates: list[str]) -> list[str]:
1212
+ seen: set[str] = set()
1213
+ ordered: list[str] = []
1214
+ for candidate in candidates:
1215
+ if candidate in seen:
1216
+ continue
1217
+ seen.add(candidate)
1218
+ ordered.append(candidate)
1219
+ return ordered