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.
- glaip_sdk/cli/commands/agents.py +19 -0
- glaip_sdk/cli/commands/mcps.py +1 -2
- glaip_sdk/cli/slash/session.py +0 -3
- glaip_sdk/cli/transcript/viewer.py +176 -6
- glaip_sdk/cli/utils.py +0 -1
- glaip_sdk/client/run_rendering.py +125 -20
- glaip_sdk/icons.py +9 -3
- glaip_sdk/utils/rendering/formatting.py +50 -7
- glaip_sdk/utils/rendering/models.py +15 -2
- glaip_sdk/utils/rendering/renderer/__init__.py +0 -2
- glaip_sdk/utils/rendering/renderer/base.py +1131 -218
- glaip_sdk/utils/rendering/renderer/config.py +3 -5
- glaip_sdk/utils/rendering/renderer/stream.py +3 -3
- glaip_sdk/utils/rendering/renderer/toggle.py +184 -0
- glaip_sdk/utils/rendering/step_tree_state.py +102 -0
- glaip_sdk/utils/rendering/steps.py +944 -16
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.0.dist-info}/METADATA +12 -1
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.0.dist-info}/RECORD +20 -18
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.0.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.0.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
|
-
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
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 =
|
|
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
|
-
|
|
159
|
+
collected: list[str] = []
|
|
136
160
|
while stack:
|
|
137
161
|
sid = stack.pop()
|
|
138
|
-
|
|
162
|
+
collected.append(sid)
|
|
139
163
|
stack.extend(self.children.pop(sid, []))
|
|
164
|
+
return collected
|
|
140
165
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|