glaip-sdk 0.0.5__py3-none-any.whl → 0.0.6a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/branding.py +3 -2
  3. glaip_sdk/cli/commands/__init__.py +1 -1
  4. glaip_sdk/cli/commands/agents.py +444 -268
  5. glaip_sdk/cli/commands/configure.py +12 -11
  6. glaip_sdk/cli/commands/mcps.py +28 -16
  7. glaip_sdk/cli/commands/models.py +5 -3
  8. glaip_sdk/cli/commands/tools.py +109 -102
  9. glaip_sdk/cli/display.py +38 -16
  10. glaip_sdk/cli/io.py +1 -1
  11. glaip_sdk/cli/main.py +26 -5
  12. glaip_sdk/cli/resolution.py +5 -4
  13. glaip_sdk/cli/utils.py +376 -157
  14. glaip_sdk/cli/validators.py +7 -2
  15. glaip_sdk/client/agents.py +184 -89
  16. glaip_sdk/client/base.py +24 -13
  17. glaip_sdk/client/validators.py +154 -94
  18. glaip_sdk/config/constants.py +0 -2
  19. glaip_sdk/models.py +4 -4
  20. glaip_sdk/utils/__init__.py +7 -7
  21. glaip_sdk/utils/client_utils.py +144 -78
  22. glaip_sdk/utils/display.py +4 -2
  23. glaip_sdk/utils/general.py +8 -6
  24. glaip_sdk/utils/import_export.py +55 -24
  25. glaip_sdk/utils/rendering/formatting.py +12 -6
  26. glaip_sdk/utils/rendering/models.py +1 -1
  27. glaip_sdk/utils/rendering/renderer/base.py +412 -248
  28. glaip_sdk/utils/rendering/renderer/console.py +6 -5
  29. glaip_sdk/utils/rendering/renderer/debug.py +94 -52
  30. glaip_sdk/utils/rendering/renderer/stream.py +93 -48
  31. glaip_sdk/utils/rendering/steps.py +103 -39
  32. glaip_sdk/utils/rich_utils.py +1 -1
  33. glaip_sdk/utils/run_renderer.py +1 -1
  34. glaip_sdk/utils/serialization.py +3 -1
  35. glaip_sdk/utils/validation.py +2 -2
  36. glaip_sdk-0.0.6a0.dist-info/METADATA +183 -0
  37. glaip_sdk-0.0.6a0.dist-info/RECORD +55 -0
  38. {glaip_sdk-0.0.5.dist-info → glaip_sdk-0.0.6a0.dist-info}/WHEEL +1 -1
  39. glaip_sdk-0.0.6a0.dist-info/entry_points.txt +3 -0
  40. glaip_sdk-0.0.5.dist-info/METADATA +0 -645
  41. glaip_sdk-0.0.5.dist-info/RECORD +0 -55
  42. glaip_sdk-0.0.5.dist-info/entry_points.txt +0 -2
@@ -24,7 +24,7 @@ from glaip_sdk.utils.rendering.formatting import (
24
24
  get_spinner_char,
25
25
  is_step_finished,
26
26
  )
27
- from glaip_sdk.utils.rendering.models import RunStats
27
+ from glaip_sdk.utils.rendering.models import RunStats, Step
28
28
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
29
29
  from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
30
30
  from glaip_sdk.utils.rendering.renderer.panels import (
@@ -56,7 +56,7 @@ class RendererState:
56
56
  printed_final_panel: bool = False
57
57
  finalizing_ui: bool = False
58
58
 
59
- def __post_init__(self):
59
+ def __post_init__(self) -> None:
60
60
  if self.buffer is None:
61
61
  self.buffer = []
62
62
 
@@ -66,11 +66,11 @@ class RichStreamRenderer:
66
66
 
67
67
  def __init__(
68
68
  self,
69
- console=None,
69
+ console: RichConsole | None = None,
70
70
  *,
71
71
  cfg: RendererConfig | None = None,
72
72
  verbose: bool = False,
73
- ):
73
+ ) -> None:
74
74
  """Initialize the renderer.
75
75
 
76
76
  Args:
@@ -107,7 +107,10 @@ class RichStreamRenderer:
107
107
  # Track per-step server start times for accurate elapsed labels
108
108
  self._step_server_start_times: dict[str, float] = {}
109
109
 
110
- def on_start(self, meta: dict[str, Any]):
110
+ # Output formatting constants
111
+ self.OUTPUT_PREFIX: str = "**Output:**\n"
112
+
113
+ def on_start(self, meta: dict[str, Any]) -> None:
111
114
  """Handle renderer start event."""
112
115
  if self.cfg.live:
113
116
  # Defer creating Live to _ensure_live so tests and prod both work
@@ -209,9 +212,12 @@ class RichStreamRenderer:
209
212
  # Note: Thinking gaps are primarily a visual aid. Keep minimal here.
210
213
 
211
214
  # Extract tool information
212
- tool_name, tool_args, tool_out, tool_calls_info = (
213
- self.stream_processor.parse_tool_calls(ev)
214
- )
215
+ (
216
+ tool_name,
217
+ tool_args,
218
+ tool_out,
219
+ tool_calls_info,
220
+ ) = self.stream_processor.parse_tool_calls(ev)
215
221
 
216
222
  # Track tools and sub-agents
217
223
  self.stream_processor.track_tools_and_agents(
@@ -224,7 +230,7 @@ class RichStreamRenderer:
224
230
  # Update live display
225
231
  self._ensure_live()
226
232
 
227
- def on_complete(self, _stats: RunStats):
233
+ def on_complete(self, _stats: RunStats) -> None:
228
234
  """Handle completion event."""
229
235
  self.state.finalizing_ui = True
230
236
 
@@ -265,7 +271,7 @@ class RichStreamRenderer:
265
271
  # Non-fatal; renderer best-effort
266
272
  pass
267
273
 
268
- def _ensure_live(self):
274
+ def _ensure_live(self) -> None:
269
275
  """Ensure live display is updated."""
270
276
  # Lazily create Live if needed
271
277
  if self.live is None and self.cfg.live:
@@ -292,14 +298,16 @@ class RichStreamRenderer:
292
298
  panels.extend(self._render_tool_panels())
293
299
  self.live.update(Group(*panels))
294
300
 
295
- def _render_main_panel(self):
301
+ def _render_main_panel(self) -> Any:
296
302
  """Render the main content panel."""
297
303
  body = "".join(self.state.buffer).strip()
298
304
  # Dynamic title with spinner + elapsed/hints
299
305
  title = self._format_enhanced_main_title()
300
306
  return create_main_panel(body, title, self.cfg.theme)
301
307
 
302
- def _maybe_insert_thinking_gap(self, task_id: str | None, context_id: str | None):
308
+ def _maybe_insert_thinking_gap(
309
+ self, task_id: str | None, context_id: str | None
310
+ ) -> None:
303
311
  """Insert thinking gap if needed."""
304
312
  # Implementation would track thinking states
305
313
  pass
@@ -345,7 +353,7 @@ class RichStreamRenderer:
345
353
  tool_name: str,
346
354
  tool_args: Any,
347
355
  _tool_sid: str,
348
- ):
356
+ ) -> Step | None:
349
357
  """Start or get a step for a tool."""
350
358
  if is_delegation_tool(tool_name):
351
359
  st = self.steps.start_or_get(
@@ -373,8 +381,12 @@ class RichStreamRenderer:
373
381
  return st
374
382
 
375
383
  def _process_additional_tool_calls(
376
- self, tool_calls_info: list, tool_name: str, task_id: str, context_id: str
377
- ):
384
+ self,
385
+ tool_calls_info: list[tuple[str, Any, Any]],
386
+ tool_name: str,
387
+ task_id: str,
388
+ context_id: str,
389
+ ) -> None:
378
390
  """Process additional tool calls to avoid duplicates."""
379
391
  for call_name, call_args, _ in tool_calls_info or []:
380
392
  if call_name and call_name != tool_name:
@@ -421,55 +433,123 @@ class RichStreamRenderer:
421
433
 
422
434
  return False, None, None
423
435
 
436
+ def _get_tool_session_id(
437
+ self, finished_tool_name: str, task_id: str, context_id: str
438
+ ) -> str:
439
+ """Generate tool session ID."""
440
+ return f"tool_{finished_tool_name}_{task_id}_{context_id}"
441
+
442
+ def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
443
+ """Calculate tool duration from metadata."""
444
+ try:
445
+ server_now = self.stream_processor.server_elapsed_time
446
+ server_start = meta.get("server_started_at")
447
+ dur = None
448
+
449
+ if isinstance(server_now, int | float) and isinstance(
450
+ server_start, int | float
451
+ ):
452
+ dur = max(0.0, float(server_now) - float(server_start))
453
+ elif meta.get("started_at") is not None:
454
+ dur = max(0.0, float(monotonic() - meta.get("started_at")))
455
+
456
+ return dur
457
+ except Exception:
458
+ return None
459
+
460
+ def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
461
+ """Update tool metadata with duration information."""
462
+ if dur is not None:
463
+ meta["duration_seconds"] = dur
464
+ meta["server_finished_at"] = (
465
+ self.stream_processor.server_elapsed_time
466
+ if isinstance(self.stream_processor.server_elapsed_time, int | float)
467
+ else None
468
+ )
469
+ meta["finished_at"] = monotonic()
470
+
471
+ def _add_tool_output_to_panel(
472
+ self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
473
+ ) -> None:
474
+ """Add tool output to panel metadata."""
475
+ if finished_tool_output is not None:
476
+ meta["chunks"].append(
477
+ self._format_output_block(finished_tool_output, finished_tool_name)
478
+ )
479
+ meta["output"] = finished_tool_output
480
+
481
+ def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
482
+ """Mark panel as finished and ensure visibility."""
483
+ if meta.get("status") != "finished":
484
+ meta["status"] = "finished"
485
+
486
+ dur = self._calculate_tool_duration(meta)
487
+ self._update_tool_metadata(meta, dur)
488
+
489
+ # Ensure this finished panel is visible in this frame
490
+ self.stream_processor.current_event_finished_panels.add(tool_sid)
491
+
424
492
  def _finish_tool_panel(
425
493
  self,
426
494
  finished_tool_name: str,
427
495
  finished_tool_output: Any,
428
496
  task_id: str,
429
497
  context_id: str,
430
- ):
498
+ ) -> None:
431
499
  """Finish a tool panel and update its status."""
432
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
433
- if tool_sid in self.tool_panels:
434
- meta = self.tool_panels[tool_sid]
435
- prev_status = meta.get("status")
500
+ tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
501
+ if tool_sid not in self.tool_panels:
502
+ return
436
503
 
437
- if prev_status != "finished":
438
- meta["status"] = "finished"
504
+ meta = self.tool_panels[tool_sid]
505
+ self._mark_panel_as_finished(meta, tool_sid)
506
+ self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
439
507
 
440
- # Compute and store duration
441
- try:
442
- server_now = self.stream_processor.server_elapsed_time
443
- server_start = meta.get("server_started_at")
444
- dur = None
445
-
446
- if isinstance(server_now, int | float) and isinstance(
447
- server_start, int | float
448
- ):
449
- dur = max(0.0, float(server_now) - float(server_start))
450
- elif meta.get("started_at") is not None:
451
- dur = max(0.0, float(monotonic() - meta.get("started_at")))
452
-
453
- if dur is not None:
454
- meta["duration_seconds"] = dur
455
- meta["server_finished_at"] = (
456
- server_now if isinstance(server_now, int | float) else None
457
- )
458
- meta["finished_at"] = monotonic()
459
- except Exception:
460
- pass
461
-
462
- # Add output to panel
463
- if finished_tool_output is not None:
464
- meta["chunks"].append(
465
- self._format_output_block(
466
- finished_tool_output, finished_tool_name
467
- )
468
- )
469
- meta["output"] = finished_tool_output
508
+ def _get_step_duration(
509
+ self, finished_tool_name: str, task_id: str, context_id: str
510
+ ) -> float | None:
511
+ """Get step duration from tool panels."""
512
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
513
+ try:
514
+ return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
515
+ except Exception:
516
+ return None
470
517
 
471
- # Ensure this finished panel is visible in this frame
472
- self.stream_processor.current_event_finished_panels.add(tool_sid)
518
+ def _finish_delegation_step(
519
+ self,
520
+ finished_tool_name: str,
521
+ finished_tool_output: Any,
522
+ task_id: str,
523
+ context_id: str,
524
+ step_duration: float | None,
525
+ ) -> None:
526
+ """Finish a delegation step."""
527
+ self.steps.finish(
528
+ task_id=task_id,
529
+ context_id=context_id,
530
+ kind="delegate",
531
+ name=finished_tool_name,
532
+ output=finished_tool_output,
533
+ duration_raw=step_duration,
534
+ )
535
+
536
+ def _finish_tool_step_type(
537
+ self,
538
+ finished_tool_name: str,
539
+ finished_tool_output: Any,
540
+ task_id: str,
541
+ context_id: str,
542
+ step_duration: float | None,
543
+ ) -> None:
544
+ """Finish a regular tool step."""
545
+ self.steps.finish(
546
+ task_id=task_id,
547
+ context_id=context_id,
548
+ kind="tool",
549
+ name=finished_tool_name,
550
+ output=finished_tool_output,
551
+ duration_raw=step_duration,
552
+ )
473
553
 
474
554
  def _finish_tool_step(
475
555
  self,
@@ -477,85 +557,114 @@ class RichStreamRenderer:
477
557
  finished_tool_output: Any,
478
558
  task_id: str,
479
559
  context_id: str,
480
- ):
560
+ ) -> None:
481
561
  """Finish the corresponding step for a completed tool."""
482
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
483
- step_duration = None
484
-
485
- try:
486
- step_duration = self.tool_panels.get(tool_sid, {}).get("duration_seconds")
487
- except Exception:
488
- step_duration = None
562
+ step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
489
563
 
490
564
  if is_delegation_tool(finished_tool_name):
491
- self.steps.finish(
492
- task_id=task_id,
493
- context_id=context_id,
494
- kind="delegate",
495
- name=finished_tool_name,
496
- output=finished_tool_output,
497
- duration_raw=step_duration,
565
+ self._finish_delegation_step(
566
+ finished_tool_name,
567
+ finished_tool_output,
568
+ task_id,
569
+ context_id,
570
+ step_duration,
498
571
  )
499
572
  else:
500
- self.steps.finish(
501
- task_id=task_id,
502
- context_id=context_id,
503
- kind="tool",
504
- name=finished_tool_name,
505
- output=finished_tool_output,
506
- duration_raw=step_duration,
573
+ self._finish_tool_step_type(
574
+ finished_tool_name,
575
+ finished_tool_output,
576
+ task_id,
577
+ context_id,
578
+ step_duration,
507
579
  )
508
580
 
581
+ def _should_create_snapshot(self, tool_sid: str) -> bool:
582
+ """Check if a snapshot should be created."""
583
+ return self.cfg.append_finished_snapshots and not self.tool_panels.get(
584
+ tool_sid, {}
585
+ ).get("snapshot_printed")
586
+
587
+ def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
588
+ """Get the title for the snapshot."""
589
+ adjusted_title = meta.get("title") or finished_tool_name
590
+
591
+ # Add elapsed time to title
592
+ dur = meta.get("duration_seconds")
593
+ if isinstance(dur, int | float):
594
+ elapsed_str = self._format_snapshot_duration(dur)
595
+ adjusted_title = f"{adjusted_title} · {elapsed_str}"
596
+
597
+ return adjusted_title
598
+
599
+ def _format_snapshot_duration(self, dur: int | float) -> str:
600
+ """Format duration for snapshot title."""
601
+ try:
602
+ # Handle invalid types
603
+ if not isinstance(dur, (int, float)):
604
+ return "<1ms"
605
+
606
+ if dur >= 1:
607
+ return f"{dur:.2f}s"
608
+ elif int(dur * 1000) > 0:
609
+ return f"{int(dur * 1000)}ms"
610
+ else:
611
+ return "<1ms"
612
+ except (TypeError, ValueError, OverflowError):
613
+ return "<1ms"
614
+
615
+ def _clamp_snapshot_body(self, body_text: str) -> str:
616
+ """Clamp snapshot body to configured limits."""
617
+ max_lines = int(self.cfg.snapshot_max_lines or 0) or 60
618
+ lines = body_text.splitlines()
619
+ if len(lines) > max_lines:
620
+ lines = lines[:max_lines] + ["… (truncated)"]
621
+ body_text = "\n".join(lines)
622
+
623
+ max_chars = int(self.cfg.snapshot_max_chars or 0) or 4000
624
+ if len(body_text) > max_chars:
625
+ body_text = body_text[: max_chars - 12] + "\n… (truncated)"
626
+
627
+ return body_text
628
+
629
+ def _create_snapshot_panel(
630
+ self, adjusted_title: str, body_text: str, finished_tool_name: str
631
+ ) -> Any:
632
+ """Create the snapshot panel."""
633
+ return create_tool_panel(
634
+ title=adjusted_title,
635
+ content=body_text or "(no output)",
636
+ status="finished",
637
+ theme=self.cfg.theme,
638
+ is_delegation=is_delegation_tool(finished_tool_name),
639
+ )
640
+
641
+ def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
642
+ """Print snapshot and mark as printed."""
643
+ self.console.print(snapshot_panel)
644
+ self.tool_panels[tool_sid]["snapshot_printed"] = True
645
+
509
646
  def _create_tool_snapshot(
510
647
  self, finished_tool_name: str, task_id: str, context_id: str
511
- ):
648
+ ) -> None:
512
649
  """Create and print a snapshot for a finished tool."""
513
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
514
-
515
650
  try:
516
- if not (
517
- self.cfg.append_finished_snapshots
518
- and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
519
- ):
651
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
652
+
653
+ if not self._should_create_snapshot(tool_sid):
520
654
  return
521
655
 
522
656
  meta = self.tool_panels[tool_sid]
523
- adjusted_title = meta.get("title") or finished_tool_name
524
-
525
- # Add elapsed time to title
526
- dur = meta.get("duration_seconds")
527
- if isinstance(dur, int | float):
528
- elapsed_str = (
529
- f"{dur:.2f}s"
530
- if dur >= 1
531
- else (f"{int(dur * 1000)}ms" if int(dur * 1000) > 0 else "<1ms")
532
- )
533
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
657
+ adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
534
658
 
535
659
  # Compose body from chunks and clamp
536
660
  body_text = "".join(meta.get("chunks") or [])
537
- max_lines = int(self.cfg.snapshot_max_lines or 0) or 60
538
- lines = body_text.splitlines()
539
- if len(lines) > max_lines:
540
- lines = lines[:max_lines] + ["… (truncated)"]
541
- body_text = "\n".join(lines)
542
-
543
- max_chars = int(self.cfg.snapshot_max_chars or 0) or 4000
544
- if len(body_text) > max_chars:
545
- body_text = body_text[: max_chars - 12] + "\n… (truncated)"
546
-
547
- snapshot_panel = create_tool_panel(
548
- title=adjusted_title,
549
- content=body_text or "(no output)",
550
- status="finished",
551
- theme=self.cfg.theme,
552
- is_delegation=is_delegation_tool(finished_tool_name),
661
+ body_text = self._clamp_snapshot_body(body_text)
662
+
663
+ snapshot_panel = self._create_snapshot_panel(
664
+ adjusted_title, body_text, finished_tool_name
553
665
  )
554
666
 
555
- # Print as a snapshot entry
556
- self.console.print(snapshot_panel)
557
- # Guard so we don't print snapshot twice
558
- self.tool_panels[tool_sid]["snapshot_printed"] = True
667
+ self._print_and_mark_snapshot(tool_sid, snapshot_panel)
559
668
 
560
669
  except Exception:
561
670
  pass
@@ -566,8 +675,8 @@ class RichStreamRenderer:
566
675
  tool_name: str | None,
567
676
  tool_args: Any,
568
677
  _tool_out: Any,
569
- tool_calls_info: list,
570
- ):
678
+ tool_calls_info: list[tuple[str, Any, Any]],
679
+ ) -> None:
571
680
  """Handle agent step event."""
572
681
  metadata = event.get("metadata", {})
573
682
  task_id = event.get("task_id")
@@ -587,9 +696,11 @@ class RichStreamRenderer:
587
696
  )
588
697
 
589
698
  # Check for tool completion
590
- is_tool_finished, finished_tool_name, finished_tool_output = (
591
- self._detect_tool_completion(metadata, content)
592
- )
699
+ (
700
+ is_tool_finished,
701
+ finished_tool_name,
702
+ finished_tool_output,
703
+ ) = self._detect_tool_completion(metadata, content)
593
704
 
594
705
  if is_tool_finished and finished_tool_name:
595
706
  self._finish_tool_panel(
@@ -623,7 +734,7 @@ class RichStreamRenderer:
623
734
  except Exception:
624
735
  pass
625
736
 
626
- def __del__(self):
737
+ def __del__(self) -> None:
627
738
  try:
628
739
  if self.live:
629
740
  self.live.stop()
@@ -705,13 +816,13 @@ class RichStreamRenderer:
705
816
  return "🧠"
706
817
  return ""
707
818
 
708
- def _format_step_status(self, step) -> str:
819
+ def _format_step_status(self, step: Step) -> str:
709
820
  """Format step status with elapsed time or duration."""
710
821
  if is_step_finished(step):
711
822
  if step.duration_ms is None:
712
823
  return "[<1ms]"
713
824
  elif step.duration_ms >= 1000:
714
- return f"[{step.duration_ms/1000:.2f}s]"
825
+ return f"[{step.duration_ms / 1000:.2f}s]"
715
826
  elif step.duration_ms > 0:
716
827
  return f"[{step.duration_ms}ms]"
717
828
  return "[<1ms]"
@@ -723,7 +834,7 @@ class RichStreamRenderer:
723
834
  ms = int(elapsed * 1000)
724
835
  return f"[{ms}ms]" if ms > 0 else "[<1ms]"
725
836
 
726
- def _calculate_step_elapsed_time(self, step) -> float:
837
+ def _calculate_step_elapsed_time(self, step: Step) -> float:
727
838
  """Calculate elapsed time for a running step."""
728
839
  server_elapsed = self.stream_processor.server_elapsed_time
729
840
  server_start = self._step_server_start_times.get(step.step_id)
@@ -738,7 +849,7 @@ class RichStreamRenderer:
738
849
  except Exception:
739
850
  return 0.0
740
851
 
741
- def _get_step_display_name(self, step) -> str:
852
+ def _get_step_display_name(self, step: Step) -> str:
742
853
  """Get display name for a step."""
743
854
  if step.name and step.name != "step":
744
855
  return step.name
@@ -779,142 +890,195 @@ class RichStreamRenderer:
779
890
 
780
891
  return Text("\n".join(lines), style="dim")
781
892
 
893
+ def _should_skip_finished_panel(self, sid: str, status: str) -> bool:
894
+ """Check if a finished panel should be skipped."""
895
+ if status != "finished":
896
+ return False
897
+
898
+ if getattr(self.cfg, "append_finished_snapshots", False):
899
+ return True
900
+
901
+ return (
902
+ not self.state.finalizing_ui
903
+ and sid not in self.stream_processor.current_event_finished_panels
904
+ )
905
+
906
+ def _calculate_elapsed_time(self, meta: dict[str, Any]) -> str:
907
+ """Calculate elapsed time string for running tools."""
908
+ server_elapsed = self.stream_processor.server_elapsed_time
909
+ server_start = meta.get("server_started_at")
910
+
911
+ if isinstance(server_elapsed, int | float) and isinstance(
912
+ server_start, int | float
913
+ ):
914
+ elapsed = max(0.0, float(server_elapsed) - float(server_start))
915
+ else:
916
+ try:
917
+ elapsed = max(0.0, monotonic() - (meta.get("started_at") or 0.0))
918
+ except Exception:
919
+ elapsed = 0.0
920
+
921
+ return self._format_elapsed_time(elapsed)
922
+
923
+ def _format_elapsed_time(self, elapsed: float) -> str:
924
+ """Format elapsed time as a readable string."""
925
+ if elapsed >= 1:
926
+ return f"{elapsed:.2f}s"
927
+ elif int(elapsed * 1000) > 0:
928
+ return f"{int(elapsed * 1000)}ms"
929
+ else:
930
+ return "<1ms"
931
+
932
+ def _calculate_finished_duration(self, meta: dict[str, Any]) -> str | None:
933
+ """Calculate duration string for finished tools."""
934
+ dur = meta.get("duration_seconds")
935
+ if isinstance(dur, int | float):
936
+ return self._format_elapsed_time(dur)
937
+
938
+ try:
939
+ server_now = self.stream_processor.server_elapsed_time
940
+ server_start = meta.get("server_started_at")
941
+ if isinstance(server_now, int | float) and isinstance(
942
+ server_start, int | float
943
+ ):
944
+ dur = max(0.0, float(server_now) - float(server_start))
945
+ elif meta.get("started_at") is not None:
946
+ dur = max(0.0, float(monotonic() - meta.get("started_at")))
947
+ except Exception:
948
+ dur = None
949
+
950
+ return self._format_elapsed_time(dur) if isinstance(dur, int | float) else None
951
+
952
+ def _process_running_tool_panel(
953
+ self, title: str, meta: dict[str, Any], body: str
954
+ ) -> tuple[str, str]:
955
+ """Process a running tool panel."""
956
+ elapsed_str = self._calculate_elapsed_time(meta)
957
+ adjusted_title = f"{title} · {elapsed_str}"
958
+ chip = f"⏱ {elapsed_str}"
959
+
960
+ if not body:
961
+ body = chip
962
+ else:
963
+ body = f"{body}\n\n{chip}"
964
+
965
+ return adjusted_title, body
966
+
967
+ def _process_finished_tool_panel(self, title: str, meta: dict[str, Any]) -> str:
968
+ """Process a finished tool panel."""
969
+ duration_str = self._calculate_finished_duration(meta)
970
+ return f"{title} · {duration_str}" if duration_str else title
971
+
972
+ def _create_tool_panel_for_session(
973
+ self, sid: str, meta: dict[str, Any]
974
+ ) -> AIPPanel | None:
975
+ """Create a single tool panel for the session."""
976
+ title = meta.get("title") or "Tool"
977
+ status = meta.get("status") or "running"
978
+ chunks = meta.get("chunks") or []
979
+ is_delegation = bool(meta.get("is_delegation"))
980
+
981
+ if self._should_skip_finished_panel(sid, status):
982
+ return None
983
+
984
+ body = "".join(chunks)
985
+ adjusted_title = title
986
+
987
+ if status == "running":
988
+ adjusted_title, body = self._process_running_tool_panel(title, meta, body)
989
+ elif status == "finished":
990
+ adjusted_title = self._process_finished_tool_panel(title, meta)
991
+
992
+ return create_tool_panel(
993
+ title=adjusted_title,
994
+ content=body or "Processing...",
995
+ status=status,
996
+ theme=self.cfg.theme,
997
+ is_delegation=is_delegation,
998
+ )
999
+
782
1000
  def _render_tool_panels(self) -> list[AIPPanel]:
783
1001
  """Render tool execution output panels."""
784
1002
  panels: list[AIPPanel] = []
785
1003
  for sid in self.tool_order:
786
1004
  meta = self.tool_panels.get(sid) or {}
787
- title = meta.get("title") or "Tool"
788
- status = meta.get("status") or "running"
789
- chunks = meta.get("chunks") or []
790
- is_delegation = bool(meta.get("is_delegation"))
791
-
792
- # Finished panels visibility rules
793
- if status == "finished":
794
- if getattr(self.cfg, "append_finished_snapshots", False):
795
- # When snapshots are enabled, don't also render finished panels in the live area
796
- # (prevents duplicates both mid-run and at the end)
797
- continue
798
- if (
799
- not self.state.finalizing_ui
800
- and sid not in self.stream_processor.current_event_finished_panels
801
- ):
802
- continue
803
-
804
- body = "".join(chunks)
805
- adjusted_title = title
806
- if status == "running":
807
- # Prefer server-based elapsed from when this tool panel started
808
- server_elapsed = self.stream_processor.server_elapsed_time
809
- server_start = meta.get("server_started_at")
810
- if isinstance(server_elapsed, int | float) and isinstance(
811
- server_start, int | float
812
- ):
813
- elapsed = max(0.0, float(server_elapsed) - float(server_start))
814
- else:
815
- try:
816
- elapsed = max(
817
- 0.0, monotonic() - (meta.get("started_at") or 0.0)
818
- )
819
- except Exception:
820
- elapsed = 0.0
821
- elapsed_str = (
822
- f"{elapsed:.2f}s"
823
- if elapsed >= 1
824
- else (
825
- f"{int(elapsed * 1000)}ms"
826
- if int(elapsed * 1000) > 0
827
- else "<1ms"
828
- )
829
- )
830
- # Add a small elapsed hint to the title and panel body (standardized)
831
- adjusted_title = f"{title} · {elapsed_str}"
832
- chip = f"⏱ {elapsed_str}"
833
- if not body:
834
- body = chip
835
- else:
836
- body = f"{body}\n\n{chip}"
837
- elif status == "finished":
838
- # Use stored duration if present; otherwise try to compute once more
839
- dur = meta.get("duration_seconds")
840
- if not isinstance(dur, int | float):
841
- try:
842
- server_now = self.stream_processor.server_elapsed_time
843
- server_start = meta.get("server_started_at")
844
- if isinstance(server_now, int | float) and isinstance(
845
- server_start, int | float
846
- ):
847
- dur = max(0.0, float(server_now) - float(server_start))
848
- elif meta.get("started_at") is not None:
849
- dur = max(0.0, float(monotonic() - meta.get("started_at")))
850
- except Exception:
851
- dur = None
852
- if isinstance(dur, int | float):
853
- elapsed_str = (
854
- f"{dur:.2f}s"
855
- if dur >= 1
856
- else (f"{int(dur * 1000)}ms" if int(dur * 1000) > 0 else "<1ms")
857
- )
858
- adjusted_title = f"{title} · {elapsed_str}"
1005
+ panel = self._create_tool_panel_for_session(sid, meta)
1006
+ if panel:
1007
+ panels.append(panel)
859
1008
 
860
- panels.append(
861
- create_tool_panel(
862
- title=adjusted_title,
863
- content=body or "Processing...",
864
- status=status,
865
- theme=self.cfg.theme,
866
- is_delegation=is_delegation,
867
- )
1009
+ return panels
1010
+
1011
+ def _format_dict_or_list_output(self, output_value: dict | list) -> str:
1012
+ """Format dict/list output as pretty JSON."""
1013
+ try:
1014
+ return (
1015
+ self.OUTPUT_PREFIX
1016
+ + "```json\n"
1017
+ + json.dumps(output_value, indent=2)
1018
+ + "\n```\n"
868
1019
  )
1020
+ except Exception:
1021
+ return self.OUTPUT_PREFIX + str(output_value) + "\n"
869
1022
 
870
- return panels
1023
+ def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
1024
+ """Clean sub-agent name prefix from output."""
1025
+ if not (tool_name and is_delegation_tool(tool_name)):
1026
+ return output
871
1027
 
872
- def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
873
- """Format an output value for panel display."""
874
- # If dict/list -> pretty JSON
875
- if isinstance(output_value, dict | list):
876
- try:
877
- return (
878
- "**Output:**\n```json\n"
879
- + json.dumps(output_value, indent=2)
880
- + "\n```\n"
881
- )
882
- except Exception:
883
- pass
1028
+ try:
1029
+ sub = tool_name
1030
+ if tool_name.startswith("delegate_to_"):
1031
+ sub = tool_name.replace("delegate_to_", "")
1032
+ elif tool_name.startswith("delegate_"):
1033
+ sub = tool_name.replace("delegate_", "")
1034
+ prefix = f"[{sub}]"
1035
+ if output.startswith(prefix):
1036
+ return output[len(prefix) :].lstrip()
1037
+ except Exception:
1038
+ pass
884
1039
 
885
- if isinstance(output_value, str):
886
- s = output_value.strip()
887
- # Clean sub-agent name prefix like "[research_compiler_agent_testing] "
888
- try:
889
- if tool_name and is_delegation_tool(tool_name):
890
- sub = tool_name
891
- if tool_name.startswith("delegate_to_"):
892
- sub = tool_name.replace("delegate_to_", "")
893
- elif tool_name.startswith("delegate_"):
894
- sub = tool_name.replace("delegate_", "")
895
- prefix = f"[{sub}]"
896
- if s.startswith(prefix):
897
- s = s[len(prefix) :].lstrip()
898
- except Exception:
899
- pass
900
- # If looks like JSON, pretty print it
901
- if (s.startswith("{") and s.endswith("}")) or (
902
- s.startswith("[") and s.endswith("]")
903
- ):
904
- try:
905
- parsed = json.loads(s)
906
- return (
907
- "**Output:**\n```json\n"
908
- + json.dumps(parsed, indent=2)
909
- + "\n```\n"
910
- )
911
- except Exception:
912
- pass
913
- return "**Output:**\n" + s + "\n"
1040
+ return output
914
1041
 
1042
+ def _format_json_string_output(self, output: str) -> str:
1043
+ """Format string that looks like JSON."""
915
1044
  try:
916
- return "**Output:**\n" + json.dumps(output_value, indent=2) + "\n"
1045
+ parsed = json.loads(output)
1046
+ return (
1047
+ self.OUTPUT_PREFIX
1048
+ + "```json\n"
1049
+ + json.dumps(parsed, indent=2)
1050
+ + "\n```\n"
1051
+ )
917
1052
  except Exception:
918
- return "**Output:**\n" + str(output_value) + "\n"
1053
+ return self.OUTPUT_PREFIX + output + "\n"
1054
+
1055
+ def _format_string_output(self, output: str, tool_name: str | None) -> str:
1056
+ """Format string output with optional prefix cleaning."""
1057
+ s = output.strip()
1058
+ s = self._clean_sub_agent_prefix(s, tool_name)
1059
+
1060
+ # If looks like JSON, pretty print it
1061
+ if (s.startswith("{") and s.endswith("}")) or (
1062
+ s.startswith("[") and s.endswith("]")
1063
+ ):
1064
+ return self._format_json_string_output(s)
1065
+
1066
+ return self.OUTPUT_PREFIX + s + "\n"
1067
+
1068
+ def _format_other_output(self, output_value: Any) -> str:
1069
+ """Format other types of output."""
1070
+ try:
1071
+ return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
1072
+ except Exception:
1073
+ return self.OUTPUT_PREFIX + str(output_value) + "\n"
1074
+
1075
+ def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
1076
+ """Format an output value for panel display."""
1077
+ if isinstance(output_value, dict | list):
1078
+ return self._format_dict_or_list_output(output_value)
1079
+ elif isinstance(output_value, str):
1080
+ return self._format_string_output(output_value, tool_name)
1081
+ else:
1082
+ return self._format_other_output(output_value)
919
1083
 
920
1084
  # No legacy surface helpers are exposed; use modern interfaces only