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.
@@ -83,6 +83,7 @@ from glaip_sdk.icons import ICON_AGENT
83
83
  from glaip_sdk.utils import format_datetime, is_uuid
84
84
  from glaip_sdk.utils.agent_config import normalize_agent_config_for_import
85
85
  from glaip_sdk.utils.import_export import convert_export_to_import_format
86
+ from glaip_sdk.utils.rendering.renderer.toggle import TranscriptToggleController
86
87
  from glaip_sdk.utils.validation import coerce_timeout
87
88
 
88
89
  console = Console()
@@ -598,6 +599,23 @@ def _setup_run_renderer(ctx: Any, save: str | None, verbose: bool) -> Any:
598
599
  )
599
600
 
600
601
 
602
+ def _maybe_attach_transcript_toggle(ctx: Any, renderer: Any) -> None:
603
+ """Attach transcript toggle controller when interactive TTY is available."""
604
+ if renderer is None:
605
+ return
606
+
607
+ console_obj = getattr(renderer, "console", None)
608
+ if console_obj is None or not getattr(console_obj, "is_terminal", False):
609
+ return
610
+
611
+ tty_enabled = bool(get_ctx_value(ctx, "tty", True))
612
+ if not tty_enabled:
613
+ return
614
+
615
+ controller = TranscriptToggleController(enabled=True)
616
+ renderer.transcript_controller = controller
617
+
618
+
601
619
  def _prepare_run_kwargs(
602
620
  agent: Any,
603
621
  final_input_text: str,
@@ -733,6 +751,7 @@ def run(
733
751
 
734
752
  parsed_chat_history = _parse_chat_history(chat_history)
735
753
  renderer, working_console = _setup_run_renderer(ctx, save, verbose)
754
+ _maybe_attach_transcript_toggle(ctx, renderer)
736
755
 
737
756
  try:
738
757
  client.timeout = float(timeout)
@@ -1126,8 +1126,7 @@ def update(
1126
1126
  )
1127
1127
  if not import_file and not cli_flags_provided:
1128
1128
  raise click.ClickException(
1129
- "No update fields specified. Use --import file or at least one of: "
1130
- "--name, --transport, --description, --config, --auth"
1129
+ "No update fields specified. Use --import or one of: --name, --transport, --description, --config, --auth"
1131
1130
  )
1132
1131
 
1133
1132
  # Resolve MCP using helper function
@@ -958,14 +958,11 @@ class SlashSession:
958
958
  keybar = AIPGrid(expand=True)
959
959
  keybar.add_column(justify="left", ratio=1)
960
960
  keybar.add_column(justify="left", ratio=1)
961
- keybar.add_column(justify="left", ratio=1)
962
- keybar.add_column(justify="left", ratio=1)
963
961
 
964
962
  keybar.add_row(
965
963
  format_command_hint("/help", "Show commands") or "",
966
964
  format_command_hint("/details", "Agent config") or "",
967
965
  format_command_hint("/exit", "Back") or "",
968
- "[bold]Alt+Enter[/bold] [dim]Line break[/dim]",
969
966
  )
970
967
 
971
968
  return keybar
@@ -27,12 +27,18 @@ except Exception: # pragma: no cover - optional dependency
27
27
  from glaip_sdk.cli.transcript.cache import suggest_filename
28
28
  from glaip_sdk.icons import ICON_DELEGATE, ICON_TOOL_STEP
29
29
  from glaip_sdk.rich_components import AIPPanel
30
+ from glaip_sdk.utils.rendering.formatting import (
31
+ build_connector_prefix,
32
+ glyph_for_status,
33
+ normalise_display_label,
34
+ )
30
35
  from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
31
36
  from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
32
37
  from glaip_sdk.utils.rendering.renderer.progress import (
33
38
  format_elapsed_time,
34
39
  is_delegation_tool,
35
40
  )
41
+ from glaip_sdk.utils.rendering.steps import StepManager
36
42
 
37
43
  EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
38
44
 
@@ -372,12 +378,13 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
372
378
  self.console.print()
373
379
 
374
380
  def _render_steps_summary(self) -> None:
375
- panel_content = self._format_steps_summary(self._build_step_summary())
376
- panel = AIPPanel(
377
- Text(panel_content, style="dim"),
378
- title="Steps",
379
- border_style="blue",
380
- )
381
+ tree_text = self._build_tree_summary_text()
382
+ if tree_text is not None:
383
+ body = tree_text
384
+ else:
385
+ panel_content = self._format_steps_summary(self._build_step_summary())
386
+ body = Text(panel_content, style="dim")
387
+ panel = AIPPanel(body, title="Steps", border_style="blue")
381
388
  self.console.print(panel)
382
389
  self.console.print()
383
390
 
@@ -460,6 +467,169 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
460
467
 
461
468
  return [steps[name] for name in order]
462
469
 
470
+ def _build_tree_summary_text(self) -> Text | None:
471
+ """Render hierarchical tree from captured SSE events when available."""
472
+ manager = StepManager()
473
+ processed = False
474
+
475
+ for event in self.ctx.events:
476
+ payload = self._coerce_step_event(event)
477
+ if not payload:
478
+ continue
479
+ try:
480
+ manager.apply_event(payload)
481
+ processed = True
482
+ except ValueError:
483
+ continue
484
+
485
+ if not processed or not manager.order:
486
+ return None
487
+
488
+ lines: list[str] = []
489
+ roots = manager.order
490
+ total_roots = len(roots)
491
+ for index, root_id in enumerate(roots):
492
+ self._render_tree_branch(
493
+ manager=manager,
494
+ step_id=root_id,
495
+ ancestor_state=(),
496
+ is_last=index == total_roots - 1,
497
+ lines=lines,
498
+ )
499
+
500
+ if not lines:
501
+ return None
502
+
503
+ return Text("\n".join(lines), style="dim")
504
+
505
+ def _render_tree_branch(
506
+ self,
507
+ *,
508
+ manager: StepManager,
509
+ step_id: str,
510
+ ancestor_state: tuple[bool, ...],
511
+ is_last: bool,
512
+ lines: list[str],
513
+ ) -> None:
514
+ step = manager.by_id.get(step_id)
515
+ if not step:
516
+ return
517
+
518
+ suppress = self._should_hide_step(step)
519
+ children = manager.children.get(step_id, [])
520
+
521
+ if not suppress:
522
+ branch_state = ancestor_state
523
+ if branch_state:
524
+ branch_state = branch_state + (is_last,)
525
+ lines.append(self._format_tree_line(step, branch_state))
526
+ next_ancestor_state = ancestor_state + (is_last,)
527
+ else:
528
+ next_ancestor_state = ancestor_state
529
+
530
+ if not children:
531
+ return
532
+
533
+ total_children = len(children)
534
+ for idx, child_id in enumerate(children):
535
+ self._render_tree_branch(
536
+ manager=manager,
537
+ step_id=child_id,
538
+ ancestor_state=next_ancestor_state if not suppress else ancestor_state,
539
+ is_last=idx == total_children - 1,
540
+ lines=lines,
541
+ )
542
+
543
+ def _should_hide_step(self, step: Any) -> bool:
544
+ if getattr(step, "parent_id", None) is not None:
545
+ return False
546
+ if getattr(step, "kind", None) == "thinking":
547
+ return True
548
+ if getattr(step, "kind", None) == "agent":
549
+ return True
550
+ name = getattr(step, "name", "") or ""
551
+ return self._looks_like_uuid(name)
552
+
553
+ def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
554
+ metadata = event.get("metadata")
555
+ if not isinstance(metadata, dict):
556
+ return None
557
+ if not isinstance(metadata.get("step_id"), str):
558
+ return None
559
+ return {
560
+ "metadata": metadata,
561
+ "status": event.get("status"),
562
+ "task_state": event.get("task_state"),
563
+ "content": event.get("content"),
564
+ "task_id": event.get("task_id"),
565
+ "context_id": event.get("context_id"),
566
+ }
567
+
568
+ def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
569
+ prefix = build_connector_prefix(branch_state)
570
+ raw_label = normalise_display_label(getattr(step, "display_label", None))
571
+ title, summary = self._split_label(raw_label)
572
+ line = f"{prefix}{title}"
573
+
574
+ if summary:
575
+ line += f" — {self._truncate_summary(summary)}"
576
+
577
+ badge = self._format_duration_badge(step)
578
+ if badge:
579
+ line += f" {badge}"
580
+
581
+ glyph = glyph_for_status(getattr(step, "status_icon", None))
582
+ failure_reason = getattr(step, "failure_reason", None)
583
+ if glyph and glyph != "spinner":
584
+ if failure_reason and glyph == "✗":
585
+ line += f" {glyph} {failure_reason}"
586
+ else:
587
+ line += f" {glyph}"
588
+ elif failure_reason:
589
+ line += f" ✗ {failure_reason}"
590
+
591
+ return line
592
+
593
+ @staticmethod
594
+ def _format_duration_badge(step: Any) -> str | None:
595
+ duration_ms = getattr(step, "duration_ms", None)
596
+ if duration_ms is None:
597
+ return None
598
+ try:
599
+ duration_ms = int(duration_ms)
600
+ except Exception:
601
+ return None
602
+
603
+ if duration_ms <= 0:
604
+ payload = "<1ms"
605
+ elif duration_ms >= 1000:
606
+ payload = f"{duration_ms / 1000:.2f}s"
607
+ else:
608
+ payload = f"{duration_ms}ms"
609
+
610
+ return f"[{payload}]"
611
+
612
+ @staticmethod
613
+ def _split_label(label: str) -> tuple[str, str | None]:
614
+ if " — " in label:
615
+ title, summary = label.split(" — ", 1)
616
+ return title.strip(), summary.strip()
617
+ return label.strip(), None
618
+
619
+ @staticmethod
620
+ def _truncate_summary(summary: str, limit: int = 48) -> str:
621
+ summary = summary.strip()
622
+ if len(summary) <= limit:
623
+ return summary
624
+ return summary[: limit - 1].rstrip() + "…"
625
+
626
+ @staticmethod
627
+ def _looks_like_uuid(value: str) -> bool:
628
+ stripped = value.replace("-", "")
629
+ if len(stripped) not in {32, 36}:
630
+ return False
631
+ return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
632
+
463
633
  @staticmethod
464
634
  def _format_duration_from_ms(value: Any) -> str | None:
465
635
  try:
glaip_sdk/cli/utils.py CHANGED
@@ -982,7 +982,6 @@ def build_renderer(
982
982
  theme=theme,
983
983
  style=style,
984
984
  live=live_enabled,
985
- show_delegate_tool_panels=False,
986
985
  append_finished_snapshots=bool(snapshots)
987
986
  if snapshots is not None
988
987
  else RendererConfig.append_finished_snapshots,
@@ -23,6 +23,58 @@ from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
23
23
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
24
24
 
25
25
 
26
+ def _coerce_to_string(value: Any) -> str:
27
+ """Return a best-effort string representation for transcripts."""
28
+ try:
29
+ return str(value)
30
+ except Exception:
31
+ return f"{value}"
32
+
33
+
34
+ def _has_visible_text(value: Any) -> bool:
35
+ """Return True when the value is a non-empty string."""
36
+ return isinstance(value, str) and bool(value.strip())
37
+
38
+
39
+ def _update_state_transcript(state: Any, text_value: str) -> bool:
40
+ """Inject transcript text into renderer state if possible."""
41
+ if state is None:
42
+ return False
43
+
44
+ updated = False
45
+
46
+ if hasattr(state, "final_text") and not _has_visible_text(
47
+ getattr(state, "final_text", "")
48
+ ):
49
+ try:
50
+ state.final_text = text_value
51
+ updated = True
52
+ except Exception:
53
+ pass
54
+
55
+ buffer = getattr(state, "buffer", None)
56
+ if isinstance(buffer, list) and not any(_has_visible_text(item) for item in buffer):
57
+ buffer.append(text_value)
58
+ updated = True
59
+
60
+ return updated
61
+
62
+
63
+ def _update_renderer_transcript(renderer: Any, text_value: str) -> None:
64
+ """Populate the renderer (or its state) with the supplied text."""
65
+ state = getattr(renderer, "state", None)
66
+ if _update_state_transcript(state, text_value):
67
+ return
68
+
69
+ if hasattr(renderer, "final_text") and not _has_visible_text(
70
+ getattr(renderer, "final_text", "")
71
+ ):
72
+ try:
73
+ setattr(renderer, "final_text", text_value)
74
+ except Exception:
75
+ pass
76
+
77
+
26
78
  class AgentRunRenderingManager:
27
79
  """Coordinate renderer creation and streaming event handling."""
28
80
 
@@ -79,7 +131,6 @@ class AgentRunRenderingManager:
79
131
  silent_config = RendererConfig(
80
132
  live=False,
81
133
  persist_live=False,
82
- show_delegate_tool_panels=False,
83
134
  render_thinking=False,
84
135
  )
85
136
  return RichStreamRenderer(
@@ -92,7 +143,6 @@ class AgentRunRenderingManager:
92
143
  minimal_config = RendererConfig(
93
144
  live=False,
94
145
  persist_live=False,
95
- show_delegate_tool_panels=False,
96
146
  render_thinking=False,
97
147
  )
98
148
  return RichStreamRenderer(
@@ -106,7 +156,6 @@ class AgentRunRenderingManager:
106
156
  theme="dark",
107
157
  style="debug",
108
158
  live=False,
109
- show_delegate_tool_panels=False,
110
159
  append_finished_snapshots=False,
111
160
  )
112
161
  return RichStreamRenderer(
@@ -139,17 +188,28 @@ class AgentRunRenderingManager:
139
188
 
140
189
  self._capture_request_id(stream_response, meta, renderer)
141
190
 
142
- for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
143
- if started_monotonic is None:
144
- started_monotonic = self._maybe_start_timer(event)
191
+ controller = getattr(renderer, "transcript_controller", None)
192
+ if controller and getattr(controller, "enabled", False):
193
+ controller.on_stream_start(renderer)
145
194
 
146
- final_text, stats_usage = self._process_single_event(
147
- event,
148
- renderer,
149
- final_text,
150
- stats_usage,
151
- meta,
152
- )
195
+ try:
196
+ for event in iter_sse_events(stream_response, timeout_seconds, agent_name):
197
+ if started_monotonic is None:
198
+ started_monotonic = self._maybe_start_timer(event)
199
+
200
+ final_text, stats_usage = self._process_single_event(
201
+ event,
202
+ renderer,
203
+ final_text,
204
+ stats_usage,
205
+ meta,
206
+ )
207
+
208
+ if controller and getattr(controller, "enabled", False):
209
+ controller.poll(renderer)
210
+ finally:
211
+ if controller and getattr(controller, "enabled", False):
212
+ controller.on_stream_complete()
153
213
 
154
214
  finished_monotonic = monotonic()
155
215
  return final_text, stats_usage, started_monotonic, finished_monotonic
@@ -194,19 +254,50 @@ class AgentRunRenderingManager:
194
254
  kind = (ev.get("metadata") or {}).get("kind")
195
255
  renderer.on_event(ev)
196
256
 
257
+ handled = self._handle_metadata_kind(
258
+ kind,
259
+ ev,
260
+ final_text,
261
+ stats_usage,
262
+ meta,
263
+ renderer,
264
+ )
265
+ if handled is not None:
266
+ return handled
267
+
268
+ if ev.get("content"):
269
+ final_text = self._handle_content_event(ev, final_text)
270
+
271
+ return final_text, stats_usage
272
+
273
+ def _handle_metadata_kind(
274
+ self,
275
+ kind: str | None,
276
+ ev: dict[str, Any],
277
+ final_text: str,
278
+ stats_usage: dict[str, Any],
279
+ meta: dict[str, Any],
280
+ renderer: RichStreamRenderer,
281
+ ) -> tuple[str, dict[str, Any]] | None:
282
+ """Process well-known metadata kinds and return updated state."""
197
283
  if kind == "artifact":
198
284
  return final_text, stats_usage
199
285
 
200
- if kind == "final_response" and ev.get("content"):
201
- final_text = ev.get("content", "")
202
- elif ev.get("content"):
203
- final_text = self._handle_content_event(ev, final_text)
204
- elif kind == "usage":
286
+ if kind == "final_response":
287
+ content = ev.get("content")
288
+ if content:
289
+ return content, stats_usage
290
+ return final_text, stats_usage
291
+
292
+ if kind == "usage":
205
293
  stats_usage.update(ev.get("usage") or {})
206
- elif kind == "run_info":
294
+ return final_text, stats_usage
295
+
296
+ if kind == "run_info":
207
297
  self._handle_run_info_event(ev, meta, renderer)
298
+ return final_text, stats_usage
208
299
 
209
- return final_text, stats_usage
300
+ return None
210
301
 
211
302
  def _handle_content_event(self, ev: dict[str, Any], final_text: str) -> str:
212
303
  content = ev.get("content", "")
@@ -227,6 +318,16 @@ class AgentRunRenderingManager:
227
318
  meta["run_id"] = ev["run_id"]
228
319
  renderer.on_start(meta)
229
320
 
321
+ def _ensure_renderer_final_content(
322
+ self, renderer: RichStreamRenderer, text: str
323
+ ) -> None:
324
+ """Populate renderer state with final output when the stream omits it."""
325
+ if not text:
326
+ return
327
+
328
+ text_value = _coerce_to_string(text)
329
+ _update_renderer_transcript(renderer, text_value)
330
+
230
331
  # --------------------------------------------------------------------- #
231
332
  # Finalisation helpers
232
333
  # --------------------------------------------------------------------- #
@@ -258,6 +359,10 @@ class AgentRunRenderingManager:
258
359
  except TypeError:
259
360
  rendered_text = ""
260
361
 
362
+ fallback_text = final_text or rendered_text
363
+ if fallback_text:
364
+ self._ensure_renderer_final_content(renderer, fallback_text)
365
+
261
366
  renderer.on_complete(st)
262
367
  return final_text or rendered_text or "No response content received."
263
368
 
glaip_sdk/icons.py CHANGED
@@ -5,10 +5,13 @@ Authors:
5
5
  """
6
6
 
7
7
  ICON_AGENT = "🤖"
8
- ICON_AGENT_STEP = "🧠"
8
+ ICON_AGENT_STEP = "🤖"
9
9
  ICON_TOOL = "🔧"
10
- ICON_TOOL_STEP = "⚙️"
11
- ICON_DELEGATE = ICON_AGENT
10
+ ICON_TOOL_STEP = "🔧"
11
+ ICON_DELEGATE = ICON_AGENT_STEP
12
+ ICON_STATUS_SUCCESS = "✓"
13
+ ICON_STATUS_FAILED = "✗"
14
+ ICON_STATUS_WARNING = "⚠"
12
15
 
13
16
  __all__ = [
14
17
  "ICON_AGENT",
@@ -16,4 +19,7 @@ __all__ = [
16
19
  "ICON_TOOL",
17
20
  "ICON_TOOL_STEP",
18
21
  "ICON_DELEGATE",
22
+ "ICON_STATUS_SUCCESS",
23
+ "ICON_STATUS_FAILED",
24
+ "ICON_STATUS_WARNING",
19
25
  ]
@@ -12,7 +12,14 @@ import time
12
12
  from collections.abc import Callable
13
13
  from typing import Any
14
14
 
15
- from glaip_sdk.icons import ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
15
+ from glaip_sdk.icons import (
16
+ ICON_AGENT_STEP,
17
+ ICON_DELEGATE,
18
+ ICON_STATUS_FAILED,
19
+ ICON_STATUS_SUCCESS,
20
+ ICON_STATUS_WARNING,
21
+ ICON_TOOL_STEP,
22
+ )
16
23
 
17
24
  # Constants for argument formatting
18
25
  DEFAULT_ARGS_MAX_LEN = 100
@@ -37,9 +44,20 @@ SECRET_VALUE_PATTERNS = [
37
44
  re.compile(r"eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"), # JWT tokens
38
45
  ]
39
46
  SENSITIVE_PATTERNS = re.compile(
40
- r"password\s*[:=]\s*[^\s,}]+|secret\s*[:=]\s*[^\s,}]+|token\s*[:=]\s*[^\s,}]+|key\s*[:=]\s*[^\s,}]+|api_key\s*[:=]\s*[^\s,}]+|^password$|^secret$|^token$|^key$|^api_key$",
47
+ r"(?:password|secret|token|key|api_key)(?:\s*[:=]\s*[^\s,}]+)?",
41
48
  re.IGNORECASE,
42
49
  )
50
+ CONNECTOR_VERTICAL = "│ "
51
+ CONNECTOR_EMPTY = " "
52
+ CONNECTOR_BRANCH = "├─ "
53
+ CONNECTOR_LAST = "└─ "
54
+ ROOT_MARKER = ""
55
+ SECRET_MASK = "••••••"
56
+ STATUS_GLYPHS = {
57
+ "success": ICON_STATUS_SUCCESS,
58
+ "failed": ICON_STATUS_FAILED,
59
+ "warning": ICON_STATUS_WARNING,
60
+ }
43
61
 
44
62
 
45
63
  def _truncate_string(s: str, max_len: int) -> str:
@@ -53,7 +71,7 @@ def mask_secrets_in_string(text: str) -> str:
53
71
  """Mask sensitive information in a string."""
54
72
  result = text
55
73
  for pattern in SECRET_VALUE_PATTERNS:
56
- result = re.sub(pattern, "••••••", result)
74
+ result = re.sub(pattern, SECRET_MASK, result)
57
75
  return result
58
76
 
59
77
 
@@ -74,7 +92,7 @@ def _redact_dict_values(text: dict) -> dict:
74
92
  result = {}
75
93
  for key, value in text.items():
76
94
  if _is_sensitive_key(key):
77
- result[key] = "••••••"
95
+ result[key] = SECRET_MASK
78
96
  elif _should_recurse_redaction(value):
79
97
  result[key] = redact_sensitive(value)
80
98
  else:
@@ -92,11 +110,11 @@ def _redact_string_content(text: str) -> str:
92
110
  result = text
93
111
  # First mask secrets
94
112
  for pattern in SECRET_VALUE_PATTERNS:
95
- result = re.sub(pattern, "••••••", result)
113
+ result = re.sub(pattern, SECRET_MASK, result)
96
114
  # Then redact sensitive patterns
97
115
  result = re.sub(
98
116
  SENSITIVE_PATTERNS,
99
- lambda m: m.group(0).split("=")[0] + "=••••••",
117
+ lambda m: m.group(0).split("=")[0] + "=" + SECRET_MASK,
100
118
  result,
101
119
  )
102
120
  return result
@@ -116,6 +134,31 @@ def _should_recurse_redaction(value: Any) -> bool:
116
134
  return isinstance(value, dict | list) or isinstance(value, str)
117
135
 
118
136
 
137
+ def glyph_for_status(icon_key: str | None) -> str | None:
138
+ """Return glyph representing a step status icon key."""
139
+ if not icon_key:
140
+ return None
141
+ return STATUS_GLYPHS.get(icon_key)
142
+
143
+
144
+ def normalise_display_label(label: str | None) -> str:
145
+ """Return a user facing label or the Unknown fallback."""
146
+ label = (label or "").strip()
147
+ return label or "Unknown step detail"
148
+
149
+
150
+ def build_connector_prefix(branch_state: tuple[bool, ...]) -> str:
151
+ """Build connector prefix for a tree line based on ancestry state."""
152
+ if not branch_state:
153
+ return ROOT_MARKER
154
+
155
+ parts: list[str] = []
156
+ for ancestor_is_last in branch_state[:-1]:
157
+ parts.append(CONNECTOR_EMPTY if ancestor_is_last else CONNECTOR_VERTICAL)
158
+ parts.append(CONNECTOR_LAST if branch_state[-1] else CONNECTOR_BRANCH)
159
+ return "".join(parts)
160
+
161
+
119
162
  def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
120
163
  """Format arguments in a pretty way."""
121
164
  if not args:
@@ -132,7 +175,7 @@ def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
132
175
  try:
133
176
  args_str = json.dumps(masked_args, ensure_ascii=False, separators=(",", ":"))
134
177
  return _truncate_string(args_str, max_len)
135
- except (TypeError, ValueError, Exception):
178
+ except Exception:
136
179
  # Fallback to string representation if JSON serialization fails
137
180
  args_str = str(masked_args)
138
181
  return _truncate_string(args_str, max_len)
@@ -30,19 +30,32 @@ class Step:
30
30
  context_id: str | None = None
31
31
  started_at: float = field(default_factory=monotonic)
32
32
  duration_ms: int | None = None
33
-
34
- def finish(self, duration_raw: float | None) -> None:
33
+ duration_source: str | None = None
34
+ display_label: str | None = None
35
+ status_icon: str | None = None
36
+ failure_reason: str | None = None
37
+ branch_failed: bool = False
38
+ is_parallel: bool = False
39
+ server_started_at: float | None = None
40
+ server_finished_at: float | None = None
41
+ duration_unknown: bool = False
42
+
43
+ def finish(self, duration_raw: float | None, *, source: str | None = None) -> None:
35
44
  """Mark the step as finished and calculate duration.
36
45
 
37
46
  Args:
38
47
  duration_raw: Raw duration in seconds, or None to calculate from started_at
48
+ source: Optional duration source tag
39
49
  """
50
+ self.duration_unknown = False
40
51
  if isinstance(duration_raw, int | float) and duration_raw > 0:
41
52
  # Use provided duration if it's a positive number (even if very small)
42
53
  self.duration_ms = round(float(duration_raw) * 1000)
54
+ self.duration_source = source or self.duration_source or "provided"
43
55
  else:
44
56
  # Calculate from started_at if duration_raw is None, negative, or zero
45
57
  self.duration_ms = int((monotonic() - self.started_at) * 1000)
58
+ self.duration_source = source or self.duration_source or "monotonic"
46
59
  self.status = "finished"
47
60
 
48
61
 
@@ -35,7 +35,6 @@ def make_silent_renderer() -> RichStreamRenderer:
35
35
  cfg = RendererConfig(
36
36
  live=False,
37
37
  persist_live=False,
38
- show_delegate_tool_panels=False,
39
38
  render_thinking=False,
40
39
  )
41
40
  return RichStreamRenderer(
@@ -51,7 +50,6 @@ def make_minimal_renderer() -> RichStreamRenderer:
51
50
  cfg = RendererConfig(
52
51
  live=False,
53
52
  persist_live=False,
54
- show_delegate_tool_panels=False,
55
53
  render_thinking=False,
56
54
  )
57
55
  return RichStreamRenderer(console=Console(), cfg=cfg)