glaip-sdk 0.0.5b1__py3-none-any.whl → 0.0.7__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 (43) hide show
  1. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/_version.py +42 -19
  3. glaip_sdk/branding.py +3 -2
  4. glaip_sdk/cli/commands/__init__.py +1 -1
  5. glaip_sdk/cli/commands/agents.py +452 -285
  6. glaip_sdk/cli/commands/configure.py +14 -13
  7. glaip_sdk/cli/commands/mcps.py +30 -20
  8. glaip_sdk/cli/commands/models.py +5 -3
  9. glaip_sdk/cli/commands/tools.py +111 -106
  10. glaip_sdk/cli/display.py +48 -27
  11. glaip_sdk/cli/io.py +1 -1
  12. glaip_sdk/cli/main.py +26 -5
  13. glaip_sdk/cli/resolution.py +5 -4
  14. glaip_sdk/cli/utils.py +437 -188
  15. glaip_sdk/cli/validators.py +7 -2
  16. glaip_sdk/client/agents.py +276 -153
  17. glaip_sdk/client/base.py +69 -27
  18. glaip_sdk/client/tools.py +44 -26
  19. glaip_sdk/client/validators.py +154 -94
  20. glaip_sdk/config/constants.py +0 -2
  21. glaip_sdk/models.py +5 -4
  22. glaip_sdk/utils/__init__.py +7 -7
  23. glaip_sdk/utils/client_utils.py +191 -101
  24. glaip_sdk/utils/display.py +4 -2
  25. glaip_sdk/utils/general.py +8 -6
  26. glaip_sdk/utils/import_export.py +58 -25
  27. glaip_sdk/utils/rendering/formatting.py +12 -6
  28. glaip_sdk/utils/rendering/models.py +1 -1
  29. glaip_sdk/utils/rendering/renderer/base.py +523 -332
  30. glaip_sdk/utils/rendering/renderer/console.py +6 -5
  31. glaip_sdk/utils/rendering/renderer/debug.py +94 -52
  32. glaip_sdk/utils/rendering/renderer/stream.py +93 -48
  33. glaip_sdk/utils/rendering/steps.py +103 -39
  34. glaip_sdk/utils/rich_utils.py +1 -1
  35. glaip_sdk/utils/run_renderer.py +1 -1
  36. glaip_sdk/utils/serialization.py +9 -3
  37. glaip_sdk/utils/validation.py +2 -2
  38. glaip_sdk-0.0.7.dist-info/METADATA +183 -0
  39. glaip_sdk-0.0.7.dist-info/RECORD +55 -0
  40. glaip_sdk-0.0.5b1.dist-info/METADATA +0 -645
  41. glaip_sdk-0.0.5b1.dist-info/RECORD +0 -55
  42. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/WHEEL +0 -0
  43. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -118,40 +121,68 @@ class RichStreamRenderer:
118
121
  self.stream_processor.streaming_started_at = self._started_at
119
122
 
120
123
  # Print compact header and user request (parity with old renderer)
124
+ self._render_header(meta)
125
+ self._render_user_query(meta)
126
+
127
+ def _render_header(self, meta: dict[str, Any]) -> None:
128
+ """Render the agent header with metadata."""
129
+ parts = self._build_header_parts(meta)
130
+ self.header_text = " ".join(parts)
131
+
132
+ if not self.header_text:
133
+ return
134
+
135
+ # Use a rule-like header for readability with fallback
136
+ if not self._render_header_rule():
137
+ self._render_header_fallback()
138
+
139
+ def _build_header_parts(self, meta: dict[str, Any]) -> list[str]:
140
+ """Build header text parts from metadata."""
141
+ parts: list[str] = ["🤖"]
142
+ agent_name = meta.get("agent_name", "agent")
143
+ if agent_name:
144
+ parts.append(agent_name)
145
+
146
+ model = meta.get("model", "")
147
+ if model:
148
+ parts.extend(["•", model])
149
+
150
+ run_id = meta.get("run_id", "")
151
+ if run_id:
152
+ parts.extend(["•", run_id])
153
+
154
+ return parts
155
+
156
+ def _render_header_rule(self) -> bool:
157
+ """Render header as a rule. Returns True if successful."""
121
158
  try:
122
- parts: list[str] = ["🤖"]
123
- agent_name = meta.get("agent_name", "agent")
124
- if agent_name:
125
- parts.append(agent_name)
126
- model = meta.get("model", "")
127
- if model:
128
- parts.extend(["•", model])
129
- run_id = meta.get("run_id", "")
130
- if run_id:
131
- parts.extend(["•", run_id])
132
- self.header_text = " ".join(parts)
133
- if self.header_text:
134
- try:
135
- # Use a rule-like header for readability
136
- self.console.rule(self.header_text)
137
- except Exception:
138
- self.console.print(self.header_text)
159
+ self.console.rule(self.header_text)
160
+ return True
161
+ except Exception: # pragma: no cover - defensive fallback
162
+ logger.exception("Failed to render header rule")
163
+ return False
164
+
165
+ def _render_header_fallback(self) -> None:
166
+ """Fallback header rendering."""
167
+ try:
168
+ self.console.print(self.header_text)
169
+ except Exception:
170
+ logger.exception("Failed to print header fallback")
171
+
172
+ def _render_user_query(self, meta: dict[str, Any]) -> None:
173
+ """Render the user query panel."""
174
+ query = meta.get("input_message") or meta.get("query") or meta.get("message")
175
+ if not query:
176
+ return
139
177
 
140
- query = (
141
- meta.get("input_message") or meta.get("query") or meta.get("message")
178
+ self.console.print(
179
+ AIPPanel(
180
+ Markdown(f"**Query:** {query}"),
181
+ title="User Request",
182
+ border_style="yellow",
183
+ padding=(0, 1),
142
184
  )
143
- if query:
144
- self.console.print(
145
- AIPPanel(
146
- Markdown(f"**Query:** {query}"),
147
- title="User Request",
148
- border_style="yellow",
149
- padding=(0, 1),
150
- )
151
- )
152
- except Exception:
153
- # Non-fatal: header is nice-to-have
154
- pass
185
+ )
155
186
 
156
187
  def on_event(self, ev: dict[str, Any]) -> None:
157
188
  """Handle streaming events from the backend."""
@@ -209,9 +240,12 @@ class RichStreamRenderer:
209
240
  # Note: Thinking gaps are primarily a visual aid. Keep minimal here.
210
241
 
211
242
  # Extract tool information
212
- tool_name, tool_args, tool_out, tool_calls_info = (
213
- self.stream_processor.parse_tool_calls(ev)
214
- )
243
+ (
244
+ tool_name,
245
+ tool_args,
246
+ tool_out,
247
+ tool_calls_info,
248
+ ) = self.stream_processor.parse_tool_calls(ev)
215
249
 
216
250
  # Track tools and sub-agents
217
251
  self.stream_processor.track_tools_and_agents(
@@ -224,48 +258,57 @@ class RichStreamRenderer:
224
258
  # Update live display
225
259
  self._ensure_live()
226
260
 
227
- def on_complete(self, _stats: RunStats):
261
+ def _finish_running_steps(self) -> None:
262
+ """Mark any running steps as finished to avoid lingering spinners."""
263
+ for st in list(self.steps.by_id.values()):
264
+ if not is_step_finished(st):
265
+ st.finish(None)
266
+
267
+ def _finish_tool_panels(self) -> None:
268
+ """Mark unfinished tool panels as finished."""
269
+ try:
270
+ items = list(self.tool_panels.items())
271
+ except Exception: # pragma: no cover - defensive guard
272
+ logger.exception("Failed to iterate tool panels during cleanup")
273
+ return
274
+
275
+ for _sid, meta in items:
276
+ if meta.get("status") != "finished":
277
+ meta["status"] = "finished"
278
+
279
+ def _stop_live_display(self) -> None:
280
+ """Stop live display and clean up."""
281
+ self._shutdown_live()
282
+
283
+ def _print_final_panel_if_needed(self) -> None:
284
+ """Print final result panel if verbose mode and content available."""
285
+ if self.verbose and not self.state.printed_final_panel:
286
+ body = ("".join(self.state.buffer) or "").strip()
287
+ if body:
288
+ final_panel = create_final_panel(body, theme=self.cfg.theme)
289
+ self.console.print(final_panel)
290
+ self.state.printed_final_panel = True
291
+
292
+ def on_complete(self, _stats: RunStats) -> None:
228
293
  """Handle completion event."""
229
294
  self.state.finalizing_ui = True
230
295
 
231
296
  # Mark any running steps as finished to avoid lingering spinners
232
- try:
233
- for st in list(self.steps.by_id.values()):
234
- if not is_step_finished(st):
235
- st.finish(None)
236
- except Exception:
237
- pass
297
+ self._finish_running_steps()
238
298
 
239
299
  # Mark unfinished tool panels as finished
240
- try:
241
- for _sid, meta in list(self.tool_panels.items()):
242
- if meta.get("status") != "finished":
243
- meta["status"] = "finished"
244
- except Exception:
245
- pass
300
+ self._finish_tool_panels()
246
301
 
247
302
  # Final refresh
248
303
  self._ensure_live()
249
304
 
250
305
  # Stop live display
251
- if self.live:
252
- self.live.stop()
253
- self.live = None
306
+ self._stop_live_display()
254
307
 
255
- # If no explicit final_response was printed, but we have buffered content,
256
- # print a final result panel so users still see the outcome (especially in --verbose).
257
- try:
258
- if self.verbose and not self.state.printed_final_panel:
259
- body = ("".join(self.state.buffer) or "").strip()
260
- if body:
261
- final_panel = create_final_panel(body, theme=self.cfg.theme)
262
- self.console.print(final_panel)
263
- self.state.printed_final_panel = True
264
- except Exception:
265
- # Non-fatal; renderer best-effort
266
- pass
308
+ # Print final panel if needed
309
+ self._print_final_panel_if_needed()
267
310
 
268
- def _ensure_live(self):
311
+ def _ensure_live(self) -> None:
269
312
  """Ensure live display is updated."""
270
313
  # Lazily create Live if needed
271
314
  if self.live is None and self.cfg.live:
@@ -292,14 +335,16 @@ class RichStreamRenderer:
292
335
  panels.extend(self._render_tool_panels())
293
336
  self.live.update(Group(*panels))
294
337
 
295
- def _render_main_panel(self):
338
+ def _render_main_panel(self) -> Any:
296
339
  """Render the main content panel."""
297
340
  body = "".join(self.state.buffer).strip()
298
341
  # Dynamic title with spinner + elapsed/hints
299
342
  title = self._format_enhanced_main_title()
300
343
  return create_main_panel(body, title, self.cfg.theme)
301
344
 
302
- def _maybe_insert_thinking_gap(self, task_id: str | None, context_id: str | None):
345
+ def _maybe_insert_thinking_gap(
346
+ self, task_id: str | None, context_id: str | None
347
+ ) -> None:
303
348
  """Insert thinking gap if needed."""
304
349
  # Implementation would track thinking states
305
350
  pass
@@ -345,7 +390,7 @@ class RichStreamRenderer:
345
390
  tool_name: str,
346
391
  tool_args: Any,
347
392
  _tool_sid: str,
348
- ):
393
+ ) -> Step | None:
349
394
  """Start or get a step for a tool."""
350
395
  if is_delegation_tool(tool_name):
351
396
  st = self.steps.start_or_get(
@@ -373,8 +418,12 @@ class RichStreamRenderer:
373
418
  return st
374
419
 
375
420
  def _process_additional_tool_calls(
376
- self, tool_calls_info: list, tool_name: str, task_id: str, context_id: str
377
- ):
421
+ self,
422
+ tool_calls_info: list[tuple[str, Any, Any]],
423
+ tool_name: str,
424
+ task_id: str,
425
+ context_id: str,
426
+ ) -> None:
378
427
  """Process additional tool calls to avoid duplicates."""
379
428
  for call_name, call_args, _ in tool_calls_info or []:
380
429
  if call_name and call_name != tool_name:
@@ -421,55 +470,122 @@ class RichStreamRenderer:
421
470
 
422
471
  return False, None, None
423
472
 
473
+ def _get_tool_session_id(
474
+ self, finished_tool_name: str, task_id: str, context_id: str
475
+ ) -> str:
476
+ """Generate tool session ID."""
477
+ return f"tool_{finished_tool_name}_{task_id}_{context_id}"
478
+
479
+ def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
480
+ """Calculate tool duration from metadata."""
481
+ server_now = self.stream_processor.server_elapsed_time
482
+ server_start = meta.get("server_started_at")
483
+ dur = None
484
+
485
+ try:
486
+ if isinstance(server_now, (int, float)) and server_start is not None:
487
+ dur = max(0.0, float(server_now) - float(server_start))
488
+ else:
489
+ started_at = meta.get("started_at")
490
+ if started_at is not None:
491
+ started_at_float = float(started_at)
492
+ dur = max(0.0, float(monotonic()) - started_at_float)
493
+ except (TypeError, ValueError):
494
+ logger.exception("Failed to calculate tool duration")
495
+ return None
496
+
497
+ return dur
498
+
499
+ def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
500
+ """Update tool metadata with duration information."""
501
+ if dur is not None:
502
+ meta["duration_seconds"] = dur
503
+ meta["server_finished_at"] = (
504
+ self.stream_processor.server_elapsed_time
505
+ if isinstance(self.stream_processor.server_elapsed_time, int | float)
506
+ else None
507
+ )
508
+ meta["finished_at"] = monotonic()
509
+
510
+ def _add_tool_output_to_panel(
511
+ self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
512
+ ) -> None:
513
+ """Add tool output to panel metadata."""
514
+ if finished_tool_output is not None:
515
+ meta["chunks"].append(
516
+ self._format_output_block(finished_tool_output, finished_tool_name)
517
+ )
518
+ meta["output"] = finished_tool_output
519
+
520
+ def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
521
+ """Mark panel as finished and ensure visibility."""
522
+ if meta.get("status") != "finished":
523
+ meta["status"] = "finished"
524
+
525
+ dur = self._calculate_tool_duration(meta)
526
+ self._update_tool_metadata(meta, dur)
527
+
528
+ # Ensure this finished panel is visible in this frame
529
+ self.stream_processor.current_event_finished_panels.add(tool_sid)
530
+
424
531
  def _finish_tool_panel(
425
532
  self,
426
533
  finished_tool_name: str,
427
534
  finished_tool_output: Any,
428
535
  task_id: str,
429
536
  context_id: str,
430
- ):
537
+ ) -> None:
431
538
  """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")
539
+ tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
540
+ if tool_sid not in self.tool_panels:
541
+ return
436
542
 
437
- if prev_status != "finished":
438
- meta["status"] = "finished"
543
+ meta = self.tool_panels[tool_sid]
544
+ self._mark_panel_as_finished(meta, tool_sid)
545
+ self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
439
546
 
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
547
+ def _get_step_duration(
548
+ self, finished_tool_name: str, task_id: str, context_id: str
549
+ ) -> float | None:
550
+ """Get step duration from tool panels."""
551
+ tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
552
+ return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
470
553
 
471
- # Ensure this finished panel is visible in this frame
472
- self.stream_processor.current_event_finished_panels.add(tool_sid)
554
+ def _finish_delegation_step(
555
+ self,
556
+ finished_tool_name: str,
557
+ finished_tool_output: Any,
558
+ task_id: str,
559
+ context_id: str,
560
+ step_duration: float | None,
561
+ ) -> None:
562
+ """Finish a delegation step."""
563
+ self.steps.finish(
564
+ task_id=task_id,
565
+ context_id=context_id,
566
+ kind="delegate",
567
+ name=finished_tool_name,
568
+ output=finished_tool_output,
569
+ duration_raw=step_duration,
570
+ )
571
+
572
+ def _finish_tool_step_type(
573
+ self,
574
+ finished_tool_name: str,
575
+ finished_tool_output: Any,
576
+ task_id: str,
577
+ context_id: str,
578
+ step_duration: float | None,
579
+ ) -> None:
580
+ """Finish a regular tool step."""
581
+ self.steps.finish(
582
+ task_id=task_id,
583
+ context_id=context_id,
584
+ kind="tool",
585
+ name=finished_tool_name,
586
+ output=finished_tool_output,
587
+ duration_raw=step_duration,
588
+ )
473
589
 
474
590
  def _finish_tool_step(
475
591
  self,
@@ -477,88 +593,113 @@ class RichStreamRenderer:
477
593
  finished_tool_output: Any,
478
594
  task_id: str,
479
595
  context_id: str,
480
- ):
596
+ ) -> None:
481
597
  """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
598
+ step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
489
599
 
490
600
  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,
601
+ self._finish_delegation_step(
602
+ finished_tool_name,
603
+ finished_tool_output,
604
+ task_id,
605
+ context_id,
606
+ step_duration,
498
607
  )
499
608
  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,
609
+ self._finish_tool_step_type(
610
+ finished_tool_name,
611
+ finished_tool_output,
612
+ task_id,
613
+ context_id,
614
+ step_duration,
507
615
  )
508
616
 
617
+ def _should_create_snapshot(self, tool_sid: str) -> bool:
618
+ """Check if a snapshot should be created."""
619
+ return self.cfg.append_finished_snapshots and not self.tool_panels.get(
620
+ tool_sid, {}
621
+ ).get("snapshot_printed")
622
+
623
+ def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
624
+ """Get the title for the snapshot."""
625
+ adjusted_title = meta.get("title") or finished_tool_name
626
+
627
+ # Add elapsed time to title
628
+ dur = meta.get("duration_seconds")
629
+ if isinstance(dur, int | float):
630
+ elapsed_str = self._format_snapshot_duration(dur)
631
+ adjusted_title = f"{adjusted_title} · {elapsed_str}"
632
+
633
+ return adjusted_title
634
+
635
+ def _format_snapshot_duration(self, dur: int | float) -> str:
636
+ """Format duration for snapshot title."""
637
+ try:
638
+ # Handle invalid types
639
+ if not isinstance(dur, (int, float)):
640
+ return "<1ms"
641
+
642
+ if dur >= 1:
643
+ return f"{dur:.2f}s"
644
+ elif int(dur * 1000) > 0:
645
+ return f"{int(dur * 1000)}ms"
646
+ else:
647
+ return "<1ms"
648
+ except (TypeError, ValueError, OverflowError):
649
+ return "<1ms"
650
+
651
+ def _clamp_snapshot_body(self, body_text: str) -> str:
652
+ """Clamp snapshot body to configured limits."""
653
+ max_lines = int(self.cfg.snapshot_max_lines or 0) or 60
654
+ lines = body_text.splitlines()
655
+ if len(lines) > max_lines:
656
+ lines = lines[:max_lines] + ["… (truncated)"]
657
+ body_text = "\n".join(lines)
658
+
659
+ max_chars = int(self.cfg.snapshot_max_chars or 0) or 4000
660
+ if len(body_text) > max_chars:
661
+ body_text = body_text[: max_chars - 12] + "\n… (truncated)"
662
+
663
+ return body_text
664
+
665
+ def _create_snapshot_panel(
666
+ self, adjusted_title: str, body_text: str, finished_tool_name: str
667
+ ) -> Any:
668
+ """Create the snapshot panel."""
669
+ return create_tool_panel(
670
+ title=adjusted_title,
671
+ content=body_text or "(no output)",
672
+ status="finished",
673
+ theme=self.cfg.theme,
674
+ is_delegation=is_delegation_tool(finished_tool_name),
675
+ )
676
+
677
+ def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
678
+ """Print snapshot and mark as printed."""
679
+ self.console.print(snapshot_panel)
680
+ self.tool_panels[tool_sid]["snapshot_printed"] = True
681
+
509
682
  def _create_tool_snapshot(
510
683
  self, finished_tool_name: str, task_id: str, context_id: str
511
- ):
684
+ ) -> None:
512
685
  """Create and print a snapshot for a finished tool."""
513
686
  tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
514
687
 
515
- try:
516
- if not (
517
- self.cfg.append_finished_snapshots
518
- and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
519
- ):
520
- return
521
-
522
- 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}"
534
-
535
- # Compose body from chunks and clamp
536
- 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),
553
- )
688
+ if not self._should_create_snapshot(tool_sid):
689
+ return
554
690
 
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
691
+ meta = self.tool_panels[tool_sid]
692
+ adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
559
693
 
560
- except Exception:
561
- pass
694
+ # Compose body from chunks and clamp
695
+ body_text = "".join(meta.get("chunks") or [])
696
+ body_text = self._clamp_snapshot_body(body_text)
697
+
698
+ snapshot_panel = self._create_snapshot_panel(
699
+ adjusted_title, body_text, finished_tool_name
700
+ )
701
+
702
+ self._print_and_mark_snapshot(tool_sid, snapshot_panel)
562
703
 
563
704
  def _handle_agent_step(
564
705
  self,
@@ -566,8 +707,8 @@ class RichStreamRenderer:
566
707
  tool_name: str | None,
567
708
  tool_args: Any,
568
709
  _tool_out: Any,
569
- tool_calls_info: list,
570
- ):
710
+ tool_calls_info: list[tuple[str, Any, Any]],
711
+ ) -> None:
571
712
  """Handle agent step event."""
572
713
  metadata = event.get("metadata", {})
573
714
  task_id = event.get("task_id")
@@ -587,9 +728,11 @@ class RichStreamRenderer:
587
728
  )
588
729
 
589
730
  # Check for tool completion
590
- is_tool_finished, finished_tool_name, finished_tool_output = (
591
- self._detect_tool_completion(metadata, content)
592
- )
731
+ (
732
+ is_tool_finished,
733
+ finished_tool_name,
734
+ finished_tool_output,
735
+ ) = self._detect_tool_completion(metadata, content)
593
736
 
594
737
  if is_tool_finished and finished_tool_name:
595
738
  self._finish_tool_panel(
@@ -614,21 +757,30 @@ class RichStreamRenderer:
614
757
 
615
758
  def close(self) -> None:
616
759
  """Gracefully stop any live rendering and release resources."""
760
+ self._shutdown_live()
761
+
762
+ def __del__(self) -> None:
763
+ # Destructors must never raise
617
764
  try:
618
- if self.live:
619
- try:
620
- self.live.stop()
621
- finally:
622
- self.live = None
623
- except Exception:
765
+ self._shutdown_live(reset_attr=False)
766
+ except Exception: # pragma: no cover - destructor safety net
624
767
  pass
625
768
 
626
- def __del__(self):
769
+ def _shutdown_live(self, reset_attr: bool = True) -> None:
770
+ """Stop the live renderer without letting exceptions escape."""
771
+ live = getattr(self, "live", None)
772
+ if not live:
773
+ if reset_attr and not hasattr(self, "live"):
774
+ self.live = None
775
+ return
776
+
627
777
  try:
628
- if self.live:
629
- self.live.stop()
778
+ live.stop()
630
779
  except Exception:
631
- pass
780
+ logger.exception("Failed to stop live display")
781
+ finally:
782
+ if reset_attr:
783
+ self.live = None
632
784
 
633
785
  def _get_analysis_progress_info(self) -> dict[str, Any]:
634
786
  total_steps = len(self.steps.order)
@@ -643,15 +795,9 @@ class RichStreamRenderer:
643
795
  # Prefer server elapsed time when available
644
796
  elapsed = 0.0
645
797
  if isinstance(self.stream_processor.server_elapsed_time, int | float):
646
- try:
647
- elapsed = float(self.stream_processor.server_elapsed_time)
648
- except Exception:
649
- elapsed = 0.0
798
+ elapsed = float(self.stream_processor.server_elapsed_time)
650
799
  elif self._started_at is not None:
651
- try:
652
- elapsed = monotonic() - self._started_at
653
- except Exception:
654
- elapsed = 0.0
800
+ elapsed = monotonic() - self._started_at
655
801
  progress_percent = (
656
802
  int((completed_steps / total_steps) * 100) if total_steps else 0
657
803
  )
@@ -705,13 +851,13 @@ class RichStreamRenderer:
705
851
  return "🧠"
706
852
  return ""
707
853
 
708
- def _format_step_status(self, step) -> str:
854
+ def _format_step_status(self, step: Step) -> str:
709
855
  """Format step status with elapsed time or duration."""
710
856
  if is_step_finished(step):
711
857
  if step.duration_ms is None:
712
858
  return "[<1ms]"
713
859
  elif step.duration_ms >= 1000:
714
- return f"[{step.duration_ms/1000:.2f}s]"
860
+ return f"[{step.duration_ms / 1000:.2f}s]"
715
861
  elif step.duration_ms > 0:
716
862
  return f"[{step.duration_ms}ms]"
717
863
  return "[<1ms]"
@@ -723,7 +869,7 @@ class RichStreamRenderer:
723
869
  ms = int(elapsed * 1000)
724
870
  return f"[{ms}ms]" if ms > 0 else "[<1ms]"
725
871
 
726
- def _calculate_step_elapsed_time(self, step) -> float:
872
+ def _calculate_step_elapsed_time(self, step: Step) -> float:
727
873
  """Calculate elapsed time for a running step."""
728
874
  server_elapsed = self.stream_processor.server_elapsed_time
729
875
  server_start = self._step_server_start_times.get(step.step_id)
@@ -738,7 +884,7 @@ class RichStreamRenderer:
738
884
  except Exception:
739
885
  return 0.0
740
886
 
741
- def _get_step_display_name(self, step) -> str:
887
+ def _get_step_display_name(self, step: Step) -> str:
742
888
  """Get display name for a step."""
743
889
  if step.name and step.name != "step":
744
890
  return step.name
@@ -779,142 +925,187 @@ class RichStreamRenderer:
779
925
 
780
926
  return Text("\n".join(lines), style="dim")
781
927
 
928
+ def _should_skip_finished_panel(self, sid: str, status: str) -> bool:
929
+ """Check if a finished panel should be skipped."""
930
+ if status != "finished":
931
+ return False
932
+
933
+ if getattr(self.cfg, "append_finished_snapshots", False):
934
+ return True
935
+
936
+ return (
937
+ not self.state.finalizing_ui
938
+ and sid not in self.stream_processor.current_event_finished_panels
939
+ )
940
+
941
+ def _calculate_elapsed_time(self, meta: dict[str, Any]) -> str:
942
+ """Calculate elapsed time string for running tools."""
943
+ server_elapsed = self.stream_processor.server_elapsed_time
944
+ server_start = meta.get("server_started_at")
945
+
946
+ if isinstance(server_elapsed, int | float) and isinstance(
947
+ server_start, int | float
948
+ ):
949
+ elapsed = max(0.0, float(server_elapsed) - float(server_start))
950
+ else:
951
+ elapsed = max(0.0, monotonic() - (meta.get("started_at") or 0.0))
952
+
953
+ return self._format_elapsed_time(elapsed)
954
+
955
+ def _format_elapsed_time(self, elapsed: float) -> str:
956
+ """Format elapsed time as a readable string."""
957
+ if elapsed >= 1:
958
+ return f"{elapsed:.2f}s"
959
+ elif int(elapsed * 1000) > 0:
960
+ return f"{int(elapsed * 1000)}ms"
961
+ else:
962
+ return "<1ms"
963
+
964
+ def _calculate_finished_duration(self, meta: dict[str, Any]) -> str | None:
965
+ """Calculate duration string for finished tools."""
966
+ dur = meta.get("duration_seconds")
967
+ if isinstance(dur, int | float):
968
+ return self._format_elapsed_time(dur)
969
+
970
+ try:
971
+ server_now = self.stream_processor.server_elapsed_time
972
+ server_start = meta.get("server_started_at")
973
+ if isinstance(server_now, int | float) and isinstance(
974
+ server_start, int | float
975
+ ):
976
+ dur = max(0.0, float(server_now) - float(server_start))
977
+ elif meta.get("started_at") is not None:
978
+ dur = max(0.0, float(monotonic() - meta.get("started_at")))
979
+ except Exception:
980
+ dur = None
981
+
982
+ return self._format_elapsed_time(dur) if isinstance(dur, int | float) else None
983
+
984
+ def _process_running_tool_panel(
985
+ self, title: str, meta: dict[str, Any], body: str
986
+ ) -> tuple[str, str]:
987
+ """Process a running tool panel."""
988
+ elapsed_str = self._calculate_elapsed_time(meta)
989
+ adjusted_title = f"{title} · {elapsed_str}"
990
+ chip = f"⏱ {elapsed_str}"
991
+
992
+ if not body:
993
+ body = chip
994
+ else:
995
+ body = f"{body}\n\n{chip}"
996
+
997
+ return adjusted_title, body
998
+
999
+ def _process_finished_tool_panel(self, title: str, meta: dict[str, Any]) -> str:
1000
+ """Process a finished tool panel."""
1001
+ duration_str = self._calculate_finished_duration(meta)
1002
+ return f"{title} · {duration_str}" if duration_str else title
1003
+
1004
+ def _create_tool_panel_for_session(
1005
+ self, sid: str, meta: dict[str, Any]
1006
+ ) -> AIPPanel | None:
1007
+ """Create a single tool panel for the session."""
1008
+ title = meta.get("title") or "Tool"
1009
+ status = meta.get("status") or "running"
1010
+ chunks = meta.get("chunks") or []
1011
+ is_delegation = bool(meta.get("is_delegation"))
1012
+
1013
+ if self._should_skip_finished_panel(sid, status):
1014
+ return None
1015
+
1016
+ body = "".join(chunks)
1017
+ adjusted_title = title
1018
+
1019
+ if status == "running":
1020
+ adjusted_title, body = self._process_running_tool_panel(title, meta, body)
1021
+ elif status == "finished":
1022
+ adjusted_title = self._process_finished_tool_panel(title, meta)
1023
+
1024
+ return create_tool_panel(
1025
+ title=adjusted_title,
1026
+ content=body or "Processing...",
1027
+ status=status,
1028
+ theme=self.cfg.theme,
1029
+ is_delegation=is_delegation,
1030
+ )
1031
+
782
1032
  def _render_tool_panels(self) -> list[AIPPanel]:
783
1033
  """Render tool execution output panels."""
784
1034
  panels: list[AIPPanel] = []
785
1035
  for sid in self.tool_order:
786
1036
  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}"
1037
+ panel = self._create_tool_panel_for_session(sid, meta)
1038
+ if panel:
1039
+ panels.append(panel)
859
1040
 
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
- )
1041
+ return panels
1042
+
1043
+ def _format_dict_or_list_output(self, output_value: dict | list) -> str:
1044
+ """Format dict/list output as pretty JSON."""
1045
+ try:
1046
+ return (
1047
+ self.OUTPUT_PREFIX
1048
+ + "```json\n"
1049
+ + json.dumps(output_value, indent=2)
1050
+ + "\n```\n"
868
1051
  )
1052
+ except Exception:
1053
+ return self.OUTPUT_PREFIX + str(output_value) + "\n"
1054
+
1055
+ def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
1056
+ """Clean sub-agent name prefix from output."""
1057
+ if not (tool_name and is_delegation_tool(tool_name)):
1058
+ return output
1059
+
1060
+ sub = tool_name
1061
+ if tool_name.startswith("delegate_to_"):
1062
+ sub = tool_name.replace("delegate_to_", "")
1063
+ elif tool_name.startswith("delegate_"):
1064
+ sub = tool_name.replace("delegate_", "")
1065
+ prefix = f"[{sub}]"
1066
+ if output.startswith(prefix):
1067
+ return output[len(prefix) :].lstrip()
1068
+
1069
+ return output
1070
+
1071
+ def _format_json_string_output(self, output: str) -> str:
1072
+ """Format string that looks like JSON."""
1073
+ try:
1074
+ parsed = json.loads(output)
1075
+ return (
1076
+ self.OUTPUT_PREFIX
1077
+ + "```json\n"
1078
+ + json.dumps(parsed, indent=2)
1079
+ + "\n```\n"
1080
+ )
1081
+ except Exception:
1082
+ return self.OUTPUT_PREFIX + output + "\n"
869
1083
 
870
- return panels
1084
+ def _format_string_output(self, output: str, tool_name: str | None) -> str:
1085
+ """Format string output with optional prefix cleaning."""
1086
+ s = output.strip()
1087
+ s = self._clean_sub_agent_prefix(s, tool_name)
871
1088
 
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
1089
+ # If looks like JSON, pretty print it
1090
+ if (s.startswith("{") and s.endswith("}")) or (
1091
+ s.startswith("[") and s.endswith("]")
1092
+ ):
1093
+ return self._format_json_string_output(s)
884
1094
 
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"
1095
+ return self.OUTPUT_PREFIX + s + "\n"
914
1096
 
1097
+ def _format_other_output(self, output_value: Any) -> str:
1098
+ """Format other types of output."""
915
1099
  try:
916
- return "**Output:**\n" + json.dumps(output_value, indent=2) + "\n"
1100
+ return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
917
1101
  except Exception:
918
- return "**Output:**\n" + str(output_value) + "\n"
1102
+ return self.OUTPUT_PREFIX + str(output_value) + "\n"
919
1103
 
920
- # No legacy surface helpers are exposed; use modern interfaces only
1104
+ def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
1105
+ """Format an output value for panel display."""
1106
+ if isinstance(output_value, dict | list):
1107
+ return self._format_dict_or_list_output(output_value)
1108
+ elif isinstance(output_value, str):
1109
+ return self._format_string_output(output_value, tool_name)
1110
+ else:
1111
+ return self._format_other_output(output_value)