glaip-sdk 0.0.1b5__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.
@@ -0,0 +1,1009 @@
1
+ #!/usr/bin/env python3
2
+ """Modern run renderer for agent execution with clean streaming output.
3
+
4
+ This module provides a modern CLI experience similar to Claude Code and Gemini CLI,
5
+ with compact headers, streaming markdown, collapsible tool steps, and clean output.
6
+
7
+ Authors:
8
+ Raymond Christopher (raymond.christopher@gdplabs.id)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from time import monotonic
18
+ from typing import Any
19
+
20
+ from rich.console import Console, Group
21
+ from rich.live import Live
22
+ from rich.markdown import Markdown
23
+ from rich.panel import Panel
24
+ from rich.text import Text
25
+ from rich.tree import Tree
26
+
27
+ # Configure logger
28
+ logger = logging.getLogger("glaip_sdk.run_renderer")
29
+
30
+
31
+ def _pretty_args(d: dict | None, max_len: int = 80) -> str:
32
+ if not d:
33
+ return ""
34
+ try:
35
+ import json
36
+
37
+ s = json.dumps(d, ensure_ascii=False)
38
+ except Exception:
39
+ s = str(d)
40
+ return s if len(s) <= max_len else s[: max_len - 1] + "…"
41
+
42
+
43
+ def _pretty_out(s: str | None, max_len: int = 80) -> str:
44
+ if not s:
45
+ return ""
46
+ s = s.strip().replace("\n", " ")
47
+ # strip common LaTeX markers so collapsed lines are clean
48
+ s = re.sub(r"\\\((.*?)\\\)", r"\1", s)
49
+ s = re.sub(r"\\\[(.*?)\\\]", r"\1", s)
50
+ s = re.sub(r"\\begin\{.*?\}|\\end\{.*?\}", "", s)
51
+ return s if len(s) <= max_len else s[: max_len - 1] + "…"
52
+
53
+
54
+ @dataclass
55
+ class Step:
56
+ step_id: str
57
+ kind: str # "tool" | "delegate" | "agent"
58
+ name: str
59
+ status: str = "running"
60
+ args: dict = field(default_factory=dict)
61
+ output: str = ""
62
+ parent_id: str | None = None
63
+ task_id: str | None = None
64
+ context_id: str | None = None
65
+ started_at: float = field(default_factory=monotonic)
66
+ duration_ms: int | None = None
67
+
68
+ def finish(self, duration_raw: float | None):
69
+ if isinstance(duration_raw, int | float):
70
+ self.duration_ms = int(duration_raw * 1000)
71
+ else:
72
+ self.duration_ms = int((monotonic() - self.started_at) * 1000)
73
+ self.status = "finished"
74
+
75
+
76
+ class StepManager:
77
+ def __init__(self, max_steps: int = 200):
78
+ self.by_id: dict[str, Step] = {}
79
+ self.order: list[str] = [] # top-level order
80
+ self.children: dict[str, list[str]] = {}
81
+ self.key_index: dict[
82
+ tuple, str
83
+ ] = {} # (task_id, context_id, kind, name, slot) -> step_id
84
+ self.slot_counter: dict[tuple, int] = {}
85
+ self.max_steps = max_steps
86
+
87
+ def _alloc_slot(self, task_id, context_id, kind, name) -> int:
88
+ k = (task_id, context_id, kind, name)
89
+ self.slot_counter[k] = self.slot_counter.get(k, 0) + 1
90
+ return self.slot_counter[k]
91
+
92
+ def _key(self, task_id, context_id, kind, name, slot) -> tuple:
93
+ return (task_id, context_id, kind, name, slot)
94
+
95
+ def _make_id(self, task_id, context_id, kind, name, slot) -> str:
96
+ return f"{task_id or 't'}::{context_id or 'c'}::{kind}::{name}::{slot}"
97
+
98
+ def start_or_get(
99
+ self, *, task_id, context_id, kind, name, parent_id=None, args=None
100
+ ) -> Step:
101
+ slot = self._alloc_slot(task_id, context_id, kind, name)
102
+ key = self._key(task_id, context_id, kind, name, slot)
103
+ step_id = self._make_id(task_id, context_id, kind, name, slot)
104
+ st = Step(
105
+ step_id=step_id,
106
+ kind=kind,
107
+ name=name,
108
+ parent_id=parent_id,
109
+ task_id=task_id,
110
+ context_id=context_id,
111
+ args=args or {},
112
+ )
113
+ self.by_id[step_id] = st
114
+ if parent_id:
115
+ self.children.setdefault(parent_id, []).append(step_id)
116
+ else:
117
+ self.order.append(step_id)
118
+ self.key_index[key] = step_id
119
+
120
+ # Prune old steps if we exceed the limit
121
+ self._prune_steps()
122
+
123
+ return st
124
+
125
+ def _prune_steps(self):
126
+ """Remove oldest finished steps (and their children) to stay within max_steps limit."""
127
+ # Count total (top-level + children)
128
+ total = len(self.order) + sum(len(v) for v in self.children.values())
129
+ if total <= self.max_steps:
130
+ return
131
+
132
+ while self.order and total > self.max_steps:
133
+ oldest = self.order[0]
134
+ st = self.by_id.get(oldest)
135
+ if not st or st.status != "finished":
136
+ # don't remove running/unknown steps
137
+ break
138
+
139
+ # remove oldest + its children
140
+ self.order.pop(0)
141
+ kids = self.children.pop(oldest, [])
142
+ total -= 1 + len(kids)
143
+ for cid in kids:
144
+ self.by_id.pop(cid, None)
145
+ self.by_id.pop(oldest, None)
146
+
147
+ def get_child_count(self, step_id: str) -> int:
148
+ """Get the number of child steps for a given step."""
149
+ return len(self.children.get(step_id, []))
150
+
151
+ def get_step_summary(self, step_id: str, verbose: bool = False) -> str:
152
+ """Get a formatted summary of a step with child count information."""
153
+ step = self.by_id.get(step_id)
154
+ if not step:
155
+ return "Unknown step"
156
+
157
+ # Basic step info
158
+ status = step.status
159
+ duration = f"{step.duration_ms}ms" if step.duration_ms else "running"
160
+
161
+ if verbose:
162
+ # Verbose view: show full details
163
+ summary = f"{step.name} → {status} [{duration}]"
164
+ if step.args:
165
+ summary += f" | Args: {_pretty_args(step.args)}"
166
+ if step.output:
167
+ summary += f" | Output: {_pretty_out(step.output)}"
168
+ else:
169
+ # Compact view: show child count if applicable
170
+ child_count = self.get_child_count(step_id)
171
+ if child_count > 0:
172
+ summary = (
173
+ f"{step.name} → {status} [{duration}] ✓ ({child_count} sub-steps)"
174
+ )
175
+ else:
176
+ summary = f"{step.name} → {status} [{duration}]"
177
+
178
+ return summary
179
+
180
+ def find_running(self, *, task_id, context_id, kind, name) -> Step | None:
181
+ # Find the last started with same (task, context, kind, name) still running
182
+ for sid in reversed(
183
+ self.order + sum([self.children.get(k, []) for k in self.order], [])
184
+ ):
185
+ st = self.by_id[sid]
186
+ if (st.task_id, st.context_id, st.kind, st.name) == (
187
+ task_id,
188
+ context_id,
189
+ kind,
190
+ name,
191
+ ) and st.status != "finished":
192
+ return st
193
+ return None
194
+
195
+ def finish(
196
+ self, *, task_id, context_id, kind, name, output=None, duration_raw=None
197
+ ):
198
+ st = self.find_running(
199
+ task_id=task_id, context_id=context_id, kind=kind, name=name
200
+ )
201
+ if not st:
202
+ # if no running step, create and immediately finish
203
+ st = self.start_or_get(
204
+ task_id=task_id, context_id=context_id, kind=kind, name=name
205
+ )
206
+ if output:
207
+ st.output = output
208
+ st.finish(duration_raw)
209
+ return st
210
+
211
+
212
+ @dataclass
213
+ class RunStats:
214
+ """Statistics for agent run execution."""
215
+
216
+ started_at: float = field(default_factory=monotonic)
217
+ finished_at: float | None = None
218
+ usage: dict[str, Any] = field(default_factory=dict)
219
+
220
+ def stop(self) -> None:
221
+ """Stop timing and record finish time."""
222
+ self.finished_at = monotonic()
223
+
224
+ @property
225
+ def duration_s(self) -> float | None:
226
+ """Get duration in seconds."""
227
+ return (
228
+ None
229
+ if self.finished_at is None
230
+ else round(self.finished_at - self.started_at, 2)
231
+ )
232
+
233
+
234
+ class RichStreamRenderer:
235
+ """
236
+ Live, modern terminal view:
237
+ - Compact header
238
+ - Streaming Markdown for assistant content
239
+ - Collapsed tool steps unless verbose=True
240
+ """
241
+
242
+ def __init__(
243
+ self,
244
+ console: Console,
245
+ verbose: bool = False,
246
+ theme: str | None = None,
247
+ use_emoji: bool | None = None,
248
+ ):
249
+ # Allow environment variable overrides
250
+
251
+ # Choose defaults first
252
+ _theme = theme or os.getenv("AIP_THEME", "dark")
253
+ _emoji = (
254
+ use_emoji
255
+ if use_emoji is not None
256
+ else os.getenv("AIP_NO_EMOJI", "").lower() != "true"
257
+ )
258
+ _persist_live = (
259
+ os.getenv("AIP_PERSIST_LIVE", "1") != "0"
260
+ ) # default: keep live as final
261
+
262
+ """Initialize the rich stream renderer."""
263
+ self.console = console
264
+ self.verbose = verbose
265
+ self.theme = _theme
266
+ self.use_emoji = _emoji
267
+ self.persist_live = _persist_live
268
+ self.buffer: list[str] = [] # accumulated assistant text chunks
269
+ self.tools: list[dict[str, Any]] = []
270
+ self.header_text = ""
271
+ self.stats = RunStats()
272
+ self._live: Live | None = None
273
+ self.steps = StepManager()
274
+ self.context_parent: dict[str, str] = {} # child_context_id -> parent step_id
275
+ self.root_context_id: str | None = (
276
+ None # Track root context for sub-agent routing
277
+ )
278
+
279
+ # sub-agent (child context) panels
280
+ self.context_panels: dict[str, list[str]] = {} # context_id -> list[str] chunks
281
+ self.context_meta: dict[
282
+ str, dict
283
+ ] = {} # context_id -> {"title","kind","status"}
284
+ self.context_order: list[str] = [] # preserve creation order
285
+
286
+ # tool panels keyed by StepManager step_id
287
+ self.tool_panels: dict[
288
+ str, dict
289
+ ] = {} # step_id -> {"title","status","chunks":[str]}
290
+ self.tool_order: list[str] = []
291
+
292
+ # header / status de-dup
293
+ self._last_status: str | None = None
294
+ self._last_header_rule: str | None = None
295
+ self._header_rules_enabled = os.getenv("AIP_HEADER_STATUS_RULES", "0") == "1"
296
+ self.show_delegate_tool_panels = (
297
+ os.getenv("AIP_SHOW_DELEGATE_PANELS", "0") == "1"
298
+ )
299
+
300
+ def __del__(self):
301
+ """Destructor to ensure Live is always stopped."""
302
+ try:
303
+ if hasattr(self, "_live") and self._live:
304
+ self._live.stop()
305
+ except Exception:
306
+ pass # Ignore cleanup errors during destruction
307
+
308
+ def _print_header_once(self, text: str, style: str | None = None):
309
+ """Print header only when changed to avoid duplicates."""
310
+ if not self._header_rules_enabled:
311
+ # don't draw rule; store the text so _main_title can still use name
312
+ self._last_header_rule = text
313
+ self.header_text = text
314
+ return
315
+ if text and text != self._last_header_rule:
316
+ try:
317
+ self.console.rule(text, style=style)
318
+ except Exception:
319
+ self.console.print(text)
320
+ self._last_header_rule = text
321
+
322
+ def _spinner(self) -> str:
323
+ """Return animated spinner character."""
324
+ frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
325
+ import time
326
+
327
+ return frames[int(time.time() * 10) % len(frames)]
328
+
329
+ def _has_running_steps(self) -> bool:
330
+ """Check if any non-finished step is present."""
331
+ for _sid, st in self.steps.by_id.items():
332
+ if st.status != "finished":
333
+ return True
334
+ return False
335
+
336
+ def _is_delegation_tool(self, tool_name: str) -> bool:
337
+ """Check if a tool name indicates delegation functionality."""
338
+ if not tool_name:
339
+ return False
340
+ # common patterns: delegate_to_math_specialist, delegate, spawn_agent, etc.
341
+ return bool(re.search(r"(?:^|_)delegate(?:_|$)|^delegate_to_", tool_name, re.I))
342
+
343
+ def _main_title(self) -> str:
344
+ """Generate main panel title with spinner and status chips."""
345
+ # base name
346
+ name = (self.header_text or "").strip() or "Assistant"
347
+ # strip leading rule emojis if present
348
+ name = name.replace("—", " ").strip()
349
+ # spinner if still working
350
+ mark = "✓" if not self._has_running_steps() else self._spinner()
351
+ # show a tiny hint if there's an active delegate
352
+ active_delegates = sum(
353
+ 1
354
+ for sid, st in self.steps.by_id.items()
355
+ if st.kind == "delegate" and st.status != "finished"
356
+ )
357
+ chip = f" • delegating ({active_delegates})" if active_delegates else ""
358
+ # show tools count for parity
359
+ active_tools = sum(
360
+ 1
361
+ for st in self.steps.by_id.values()
362
+ if st.kind == "tool" and st.status != "finished"
363
+ )
364
+ chip2 = f" • tools ({active_tools})" if active_tools else ""
365
+ return f"{name} {mark}{chip}{chip2}"
366
+
367
+ def _render_main_panel(self):
368
+ """Render the main panel with content or placeholder."""
369
+ body = "".join(self.buffer).strip()
370
+ if body:
371
+ return Panel(
372
+ self._render_stream(),
373
+ title=self._main_title(),
374
+ border_style="green",
375
+ )
376
+ # fallback placeholder if no content yet
377
+ placeholder = Text("working…", style="dim")
378
+ if self._has_running_steps():
379
+ placeholder = Text("working… running steps", style="dim")
380
+ return Panel(
381
+ placeholder,
382
+ title=self._main_title(),
383
+ border_style="green",
384
+ )
385
+
386
+ def _norm_markdown(self, s: str) -> str:
387
+ """Reuse LaTeX normalization for panel bodies."""
388
+ s = self._normalize_math(s)
389
+ return s
390
+
391
+ def _render_context_panels(self):
392
+ """Render sub-agent panels."""
393
+ panels = []
394
+ for cid in self.context_order:
395
+ chunks = self.context_panels.get(cid) or []
396
+ meta = self.context_meta.get(cid) or {}
397
+ title = meta.get("title") or f"Sub-agent {cid[:6]}…"
398
+ status = meta.get("status") or "running"
399
+ mark = "✓" if status == "finished" else self._spinner()
400
+ body = self._norm_markdown("".join(chunks))
401
+ panels.append(
402
+ Panel(
403
+ Markdown(
404
+ body,
405
+ code_theme=("monokai" if self.theme == "dark" else "github"),
406
+ ),
407
+ title=f"{title} {mark}",
408
+ border_style="magenta"
409
+ if meta.get("kind") == "delegate"
410
+ else "cyan",
411
+ )
412
+ )
413
+ return panels
414
+
415
+ def _render_tool_panels(self):
416
+ """Render tool output panels."""
417
+ panels = []
418
+ for sid in self.tool_order:
419
+ meta = self.tool_panels.get(sid) or {}
420
+ title = meta.get("title") or "Tool"
421
+ status = meta.get("status") or "running"
422
+ mark = "✓" if status == "finished" else self._spinner()
423
+ body = self._norm_markdown("".join(meta.get("chunks") or []))
424
+ panels.append(
425
+ Panel(
426
+ Markdown(
427
+ body,
428
+ code_theme=("monokai" if self.theme == "dark" else "github"),
429
+ ),
430
+ title=f"{title} {mark}",
431
+ border_style="blue",
432
+ )
433
+ )
434
+ return panels
435
+
436
+ def _process_tool_output_for_sub_agents(
437
+ self, tool_name: str, output: str, task_id: str, context_id: str
438
+ ) -> bool:
439
+ """Process tool output to extract and create sub-agent panels."""
440
+ if not output:
441
+ return False
442
+
443
+ # Check if this is a delegation tool (contains sub-agent responses)
444
+ if "delegate" in (tool_name or "").lower() or "math_specialist" in output:
445
+ # Extract sub-agent name from output (e.g., "[math_specialist] ...")
446
+ import re
447
+
448
+ agent_match = re.search(r"^\s*\[([^\]]+)\]\s*(.*)$", output, re.S)
449
+ if agent_match:
450
+ agent_name = agent_match.group(1).strip()
451
+ agent_content = agent_match.group(2).strip()
452
+
453
+ # Create a unique context ID for this sub-agent response
454
+ sub_context_id = f"{context_id}_sub_{agent_name}"
455
+
456
+ # Create sub-agent panel if it doesn't exist
457
+ if sub_context_id not in self.context_panels:
458
+ self.context_panels[sub_context_id] = []
459
+ self.context_meta[sub_context_id] = {
460
+ "title": f"Sub-Agent: {agent_name}",
461
+ "kind": "delegate",
462
+ "status": "finished", # Already completed
463
+ }
464
+ self.context_order.append(sub_context_id)
465
+
466
+ # Add the content to the sub-agent panel
467
+ self.context_panels[sub_context_id].append(agent_content)
468
+
469
+ # Mark as finished since it's already complete
470
+ self.context_meta[sub_context_id]["status"] = "finished"
471
+ return True
472
+ return False
473
+
474
+ def _render_tools(self):
475
+ if not (self.steps.order or self.steps.children):
476
+ return Text("")
477
+
478
+ if not self.verbose:
479
+ # collapsed: one line per top-level step (children are summarized)
480
+ lines = []
481
+ # Get terminal width for better spacing
482
+ width = max(40, self.console.size.width - 8)
483
+ args_budget = min(60, width // 3)
484
+
485
+ for sid in self.steps.order:
486
+ st = self.steps.by_id[sid]
487
+ icon = (
488
+ "⚙️ "
489
+ if (self.use_emoji and st.kind == "tool")
490
+ else ("🤝 " if (self.use_emoji and st.kind == "delegate") else "")
491
+ )
492
+ dur = f"[{st.duration_ms}ms]" if st.duration_ms is not None else ""
493
+
494
+ # Truncate args to fit budget
495
+ args = _pretty_args(st.args, max_len=args_budget)
496
+ rhs = f"({args})" if args else ""
497
+
498
+ # Use spinner for running steps, checkmark for finished
499
+ tail = " ✓" if st.status == "finished" else f" {self._spinner()}"
500
+ lines.append(f"{icon}{st.name}{rhs} {dur}{tail}".rstrip())
501
+ return Text("\n".join(lines), style="dim")
502
+
503
+ # verbose: full tree
504
+ def add_children(node: Tree, sid: str):
505
+ st = self.steps.by_id[sid]
506
+ dur = f"[{st.duration_ms}ms]" if st.duration_ms is not None else ""
507
+ args_str = _pretty_args(st.args)
508
+ icon = (
509
+ "⚙️" if st.kind == "tool" else ("🤝" if st.kind == "delegate" else "🧠")
510
+ )
511
+ label = f"{icon} {st.name}"
512
+ if args_str:
513
+ label += f"({args_str})"
514
+ label += f" {dur} {'✓' if st.status=='finished' else '…'}"
515
+ node2 = node.add(label)
516
+ for child_id in self.steps.children.get(sid, []):
517
+ add_children(node2, child_id)
518
+
519
+ root = Tree("Steps")
520
+ for sid in self.steps.order:
521
+ add_children(root, sid)
522
+ return root
523
+
524
+ def _normalize_math(self, md: str) -> str:
525
+ """Robust LaTeX normalization for better display."""
526
+ import re
527
+
528
+ # \text{...} → plain
529
+ md = re.sub(r"\\text\{([^}]*)\}", r"\1", md)
530
+
531
+ # simple symbols
532
+ md = md.replace(r"\times", "×").replace(r"\cdot", "·")
533
+
534
+ # \boxed{...} → **...**
535
+ md = re.sub(r"\\boxed\{([^}]*)\}", r"**\1**", md)
536
+
537
+ # \begin{array}{...} ... \end{array} → code block
538
+ def _array_to_block(m):
539
+ body = m.group("body")
540
+ # drop leading alignment spec {c@{}c@{}c} etc at start of body if present
541
+ body = re.sub(r"^\{[^}]*\}\s*", "", body.strip())
542
+
543
+ # split on LaTeX row separator \\ and replace alignment & with spacing
544
+ rows = []
545
+ for raw in re.split(r"\\\\", body):
546
+ line = raw.strip()
547
+ if not line:
548
+ continue
549
+ # remove explicit + / bullets spacing issues and align with double-spaces
550
+ line = line.replace("&", " ")
551
+ line = re.sub(r"\s{3,}", " ", line)
552
+ rows.append(line)
553
+
554
+ return "```\n" + "\n".join(rows) + "\n```"
555
+
556
+ md = re.sub(
557
+ r"\\begin\{array\}\{[^}]*\}(?P<body>.*?)\\end\{array\}",
558
+ _array_to_block,
559
+ md,
560
+ flags=re.S,
561
+ )
562
+
563
+ # Block math \[...\] → fenced code
564
+ md = re.sub(r"\\\[(.*?)\\\]", r"```\n\1\n```", md, flags=re.S)
565
+
566
+ # Inline math \(...\) → inline code
567
+ md = re.sub(r"\\\((.*?)\\\)", r"`\1`", md, flags=re.S)
568
+
569
+ # Strip any remaining begin/end environments harmlessly
570
+ md = re.sub(r"\\begin\{[^}]*\}|\\end\{[^}]*\}", "", md)
571
+
572
+ return md
573
+
574
+ def _render_stream(self) -> Markdown:
575
+ """Render the streaming markdown content."""
576
+ content = "".join(self.buffer)
577
+ content = self._normalize_math(content)
578
+ code_theme = "monokai" if self.theme == "dark" else "github"
579
+ return Markdown(content, code_theme=code_theme)
580
+
581
+ def _ensure_live(self):
582
+ """Ensure live area is started for any first event."""
583
+ if self._live:
584
+ return
585
+ # Start live view without outer panel to fix double boxing
586
+ self._live = Live(
587
+ refresh_per_second=24,
588
+ console=self.console,
589
+ transient=not self.persist_live, # Respect flag
590
+ auto_refresh=True,
591
+ )
592
+ self._live.start()
593
+
594
+ def _refresh(self) -> None:
595
+ """Refresh the live display with stacked panels."""
596
+ if not self._live:
597
+ return
598
+
599
+ panels = [self._render_main_panel()] # Main assistant content
600
+
601
+ # Only include Steps panel when there are actual steps
602
+ if self.steps.order or self.steps.children:
603
+ panels.append(
604
+ Panel(
605
+ self._render_tools(),
606
+ title="Steps (collapsed)" if not self.verbose else "Steps",
607
+ border_style="blue",
608
+ )
609
+ )
610
+
611
+ context_panels = self._render_context_panels()
612
+
613
+ panels.extend(context_panels) # Sub-agent panels
614
+ panels.extend(self._render_tool_panels()) # tools
615
+
616
+ self._live.update(Group(*panels))
617
+
618
+ def _refresh_thread_safe(self) -> None:
619
+ """Thread-safe refresh method for use from background threads."""
620
+ if not self._live:
621
+ return
622
+
623
+ try:
624
+ # Use console.call_from_thread for thread-safe updates
625
+ self._live.console.call_from_thread(self._refresh)
626
+ except Exception:
627
+ # Fallback to direct call if call_from_thread fails
628
+ try:
629
+ self._refresh()
630
+ except Exception:
631
+ pass # Ignore refresh errors
632
+
633
+ def on_start(self, meta: dict[str, Any]) -> None:
634
+ """Handle agent run start."""
635
+ parts = []
636
+
637
+ # Add emoji if enabled
638
+ if self.use_emoji:
639
+ parts.append("🤖")
640
+
641
+ # Add agent name
642
+ agent_name = meta.get("agent_name", "agent")
643
+ if agent_name:
644
+ parts.append(agent_name)
645
+
646
+ # Add model if available
647
+ model = meta.get("model", "")
648
+ if model:
649
+ parts.append("•")
650
+ parts.append(model)
651
+
652
+ # Add run ID if available
653
+ run_id = meta.get("run_id", "")
654
+ if run_id:
655
+ parts.append("•")
656
+ parts.append(run_id)
657
+
658
+ self.header_text = " ".join(parts)
659
+
660
+ # Show a compact header once (de-duplicated)
661
+ self._print_header_once(self.header_text)
662
+
663
+ # Show the original query for context
664
+ query = meta.get("input_message") or meta.get("query") or meta.get("message")
665
+ if query:
666
+ from rich.markdown import Markdown
667
+
668
+ self.console.print(
669
+ Panel(
670
+ Markdown(f"**Query:** {query}"),
671
+ title="User Request",
672
+ border_style="yellow",
673
+ padding=(0, 1),
674
+ )
675
+ )
676
+
677
+ # Don't start live display immediately - wait for actual content
678
+ self._live = None
679
+
680
+ def on_event(self, ev: dict[str, Any]) -> None:
681
+ """Handle streaming events from the backend."""
682
+ try:
683
+ # Handle different event types based on backend's SSE format
684
+ metadata = ev.get("metadata", {})
685
+ kind = metadata.get("kind", "")
686
+
687
+ if kind == "artifact":
688
+ return # Hidden by default
689
+
690
+ # --- tool steps (collapsed) ---
691
+ if kind == "agent_step":
692
+ # Extract task and context IDs from metadata or direct fields
693
+ task_id = ev.get("task_id") or metadata.get("task_id")
694
+ context_id = ev.get("context_id") or metadata.get("context_id")
695
+
696
+ status = metadata.get("status", "running")
697
+
698
+ # Tool name + args + (optional) output
699
+ tool_name = None
700
+ tool_args = {}
701
+ tool_out = None
702
+ tc = ev.get("tool_calls")
703
+ if isinstance(tc, list) and tc:
704
+ tool_name = (tc[0] or {}).get("name")
705
+ tool_args = (tc[0] or {}).get("args") or {}
706
+ elif isinstance(tc, dict):
707
+ tool_name = tc.get("name")
708
+ tool_args = tc.get("args") or {}
709
+ tool_out = tc.get("output")
710
+
711
+ # Heuristic: delegation events (sub-agent) signalled by message, or parent/child context ids
712
+ message_en = ev.get("metadata", {}).get("message", {}).get("en", "")
713
+ maybe_delegate = bool(
714
+ re.search(
715
+ r"\bdelegat(e|ion|ed)\b|\bspawn(ed)?\b|\bsub[- ]?agent\b",
716
+ message_en,
717
+ re.I,
718
+ )
719
+ )
720
+ child_ctx = ev.get("child_context_id") or ev.get("sub_context_id")
721
+
722
+ # Parent mapping: if this step spawns a child context, remember who spawned it
723
+ parent_id = None
724
+ if maybe_delegate and child_ctx:
725
+ # start a delegate step at current context; child steps will hang under it
726
+ parent_step = self.steps.start_or_get(
727
+ task_id=task_id,
728
+ context_id=context_id,
729
+ kind="delegate",
730
+ name=ev.get("delegate_name")
731
+ or ev.get("agent_name")
732
+ or "delegate",
733
+ args={},
734
+ )
735
+ self.context_parent[child_ctx] = parent_step.step_id
736
+
737
+ # NEW: reserve a sub-agent panel immediately (spinner until content arrives)
738
+ title = (
739
+ ev.get("delegate_name")
740
+ or ev.get("agent_name")
741
+ or f"Sub-agent {child_ctx[:6]}…"
742
+ )
743
+ if child_ctx not in self.context_panels:
744
+ self.context_panels[child_ctx] = []
745
+ self.context_meta[child_ctx] = {
746
+ "title": f"Sub-Agent: {title}",
747
+ "kind": "delegate",
748
+ "status": "running",
749
+ }
750
+ self.context_order.append(child_ctx)
751
+
752
+ self._ensure_live() # Ensure live for step-first runs
753
+ self._refresh()
754
+ return
755
+
756
+ # Pick kind for this step
757
+ kind_name = (
758
+ "tool" if tool_name else ("delegate" if maybe_delegate else "agent")
759
+ )
760
+ name = tool_name or (
761
+ ev.get("delegate_name") or ev.get("agent_name") or "step"
762
+ )
763
+
764
+ # Parent: if this event belongs to a child context, attach under its spawner
765
+ parent_id = self.context_parent.get(context_id)
766
+
767
+ # Determine if this is a running or finished step
768
+ # If tool_calls is a list, it's starting; if it's a dict with output, it's finishing
769
+ if isinstance(tc, list):
770
+ status = "running"
771
+ elif isinstance(tc, dict) and tc.get("output"):
772
+ status = "finished"
773
+ else:
774
+ status = status or ev.get("status") or "running"
775
+ dur_raw = ev.get("metadata", {}).get("time")
776
+
777
+ if status == "running":
778
+ st = self.steps.find_running(
779
+ task_id=task_id,
780
+ context_id=context_id,
781
+ kind=kind_name,
782
+ name=name,
783
+ )
784
+ if not st:
785
+ st = self.steps.start_or_get(
786
+ task_id=task_id,
787
+ context_id=context_id,
788
+ kind=kind_name,
789
+ name=name,
790
+ parent_id=parent_id,
791
+ args=tool_args,
792
+ )
793
+
794
+ # If it's a tool, ensure a tool panel exists and is running
795
+ if kind_name == "tool":
796
+ # Suppress tool panel for delegation tools unless explicitly enabled
797
+ if not (
798
+ self._is_delegation_tool(name)
799
+ and not self.show_delegate_tool_panels
800
+ ):
801
+ sid = st.step_id
802
+ if sid not in self.tool_panels:
803
+ self.tool_panels[sid] = {
804
+ "title": f"Tool: {name}",
805
+ "status": "running",
806
+ "chunks": [],
807
+ }
808
+ self.tool_order.append(sid)
809
+ else:
810
+ st = self.steps.finish(
811
+ task_id=task_id,
812
+ context_id=context_id,
813
+ kind=kind_name,
814
+ name=name,
815
+ output=tool_out,
816
+ duration_raw=dur_raw,
817
+ )
818
+
819
+ if kind_name == "tool":
820
+ sid = st.step_id
821
+
822
+ out = tool_out or ""
823
+ # First, see if this created a sub-agent panel
824
+ self._process_tool_output_for_sub_agents(
825
+ name, out, task_id, context_id
826
+ )
827
+
828
+ # If it's a delegation tool and we don't want its panel, suppress it
829
+ if (
830
+ self._is_delegation_tool(name)
831
+ and not self.show_delegate_tool_panels
832
+ ):
833
+ # If a running tool panel was accidentally created, remove it
834
+ if sid in self.tool_panels:
835
+ self.tool_panels.pop(sid, None)
836
+ try:
837
+ self.tool_order.remove(sid)
838
+ except ValueError:
839
+ pass
840
+ self._ensure_live()
841
+ self._refresh()
842
+ return
843
+
844
+ # Normal (non-delegation) tool panel behavior
845
+ panel = self.tool_panels.get(sid)
846
+ if not panel:
847
+ panel = {
848
+ "title": f"Tool: {name}",
849
+ "status": "running",
850
+ "chunks": [],
851
+ }
852
+ self.tool_panels[sid] = panel
853
+ self.tool_order.append(sid)
854
+
855
+ if bool(out) and (
856
+ (out.strip().startswith("{") and out.strip().endswith("}"))
857
+ or (
858
+ out.strip().startswith("[")
859
+ and out.strip().endswith("]")
860
+ )
861
+ ):
862
+ panel["chunks"].append("```json\n" + out + "\n```")
863
+ else:
864
+ panel["chunks"].append(out)
865
+
866
+ # trim for memory
867
+ if sum(len(x) for x in panel["chunks"]) > 20000:
868
+ joined = "".join(panel["chunks"])[-10000:]
869
+ panel["chunks"] = [joined]
870
+
871
+ panel["status"] = "finished"
872
+
873
+ self._ensure_live() # Ensure live for step-first runs
874
+ self._refresh()
875
+ return
876
+
877
+ # --- status updates (backend sends status: "streaming_started", "execution_started", etc.) ---
878
+ if "status" in ev and ev.get("metadata", {}).get("kind") != "agent_step":
879
+ status_msg = ev.get("status", "")
880
+ if status_msg in ("streaming_started", "execution_started"):
881
+ # These are informational status updates, no need to display
882
+ return
883
+ self._last_status = status_msg # keep it if you want chips later
884
+ # no rule printing here; _main_title() already animates
885
+ return
886
+
887
+ # --- content streaming with boundary spacing ---
888
+ if "content" in ev and ev["content"]:
889
+ content = ev["content"]
890
+
891
+ if "Artifact received:" in content:
892
+ return
893
+
894
+ cid = ev.get("context_id") or metadata.get("context_id")
895
+
896
+ # establish root context on first content
897
+ if self.root_context_id is None and cid:
898
+ self.root_context_id = cid
899
+
900
+ # sub-agent / child context streaming → stream into its own panel
901
+ if cid and self.root_context_id and cid != self.root_context_id:
902
+ self._ensure_live()
903
+ if cid not in self.context_panels:
904
+ # Create the panel the first time we see content
905
+ title = (
906
+ ev.get("agent_name")
907
+ or ev.get("delegate_name")
908
+ or f"Sub-agent {cid[:6]}…"
909
+ )
910
+ self.context_panels[cid] = []
911
+ self.context_meta[cid] = {
912
+ "title": f"Sub-Agent: {title}",
913
+ "kind": "delegate",
914
+ "status": "running",
915
+ }
916
+ self.context_order.append(cid)
917
+
918
+ # append & trim (memory guard)
919
+ buf = self.context_panels[cid]
920
+ buf.append(content)
921
+ if sum(len(x) for x in buf) > 20000:
922
+ # keep last ~10k chars
923
+ joined = "".join(buf)[-10000:]
924
+ self.context_panels[cid] = [joined]
925
+
926
+ self._refresh()
927
+ return
928
+
929
+ # root / unknown context → assistant
930
+ self._ensure_live() # Ensure live for content-first runs
931
+
932
+ # insert a space at boundary when needed
933
+ if (
934
+ self.buffer
935
+ and self.buffer[-1]
936
+ and self.buffer[-1][-1].isalnum()
937
+ and content
938
+ and content[0].isalnum()
939
+ ):
940
+ self.buffer.append(" ")
941
+ self.buffer.append(content)
942
+
943
+ # Memory guard: trim main buffer if it gets too large
944
+ joined = "".join(self.buffer)
945
+ if len(joined) > 200_000: # ~200KB
946
+ self.buffer = [joined[-100_000:]] # keep last ~100KB
947
+
948
+ self._refresh()
949
+ return
950
+
951
+ # final_response handled in on_complete
952
+ except Exception as e:
953
+ # Log the error and ensure Live is stopped to prevent terminal corruption
954
+ logger.error(f"Error in event handler: {e}")
955
+ try:
956
+ if self._live:
957
+ self._live.stop()
958
+ except Exception:
959
+ pass # Ignore cleanup errors
960
+ raise # Re-raise the original exception
961
+
962
+ def on_complete(self, final: str, stats: RunStats) -> None:
963
+ """Handle agent run completion."""
964
+ try:
965
+ # ensure final text is in buffer
966
+ if final:
967
+ whole = "".join(self.buffer)
968
+ if not whole or final not in whole:
969
+ if (
970
+ self.buffer
971
+ and self.buffer[-1]
972
+ and self.buffer[-1][-1].isalnum()
973
+ and final
974
+ and final[0].isalnum()
975
+ ):
976
+ self.buffer.append(" ")
977
+ self.buffer.append(final)
978
+
979
+ # Mark all sub-agent panels as finished
980
+ for cid in list(self.context_meta.keys()):
981
+ self.context_meta[cid]["status"] = "finished"
982
+
983
+ # make sure live exists for a clean last frame
984
+ if self._live is None:
985
+ self._ensure_live()
986
+
987
+ # update both panels one last time
988
+ self._refresh()
989
+
990
+ # print footer (works with both transient & persistent)
991
+ footer = []
992
+ if stats.duration_s is not None:
993
+ footer.append(f"✓ Done in {stats.duration_s}s")
994
+ u = stats.usage or {}
995
+ if u.get("input_tokens") or u.get("output_tokens"):
996
+ footer.append(
997
+ f"{u.get('input_tokens',0)} tok in / {u.get('output_tokens',0)} tok out"
998
+ )
999
+ if u.get("cost"):
1000
+ footer.append(f"${u['cost']}")
1001
+ if footer:
1002
+ self.console.print(Text(" • ".join(footer), style="bold green"))
1003
+ finally:
1004
+ # Always ensure Live is stopped, even if an exception occurs
1005
+ try:
1006
+ if self._live:
1007
+ self._live.stop()
1008
+ except Exception:
1009
+ pass # Ignore errors during cleanup