glaip-sdk 0.0.2__py3-none-any.whl → 0.0.4__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 (40) hide show
  1. glaip_sdk/__init__.py +2 -2
  2. glaip_sdk/_version.py +51 -0
  3. glaip_sdk/branding.py +145 -0
  4. glaip_sdk/cli/commands/agents.py +876 -166
  5. glaip_sdk/cli/commands/configure.py +46 -104
  6. glaip_sdk/cli/commands/init.py +43 -118
  7. glaip_sdk/cli/commands/mcps.py +86 -161
  8. glaip_sdk/cli/commands/tools.py +196 -57
  9. glaip_sdk/cli/main.py +43 -29
  10. glaip_sdk/cli/utils.py +258 -27
  11. glaip_sdk/client/__init__.py +54 -2
  12. glaip_sdk/client/agents.py +196 -237
  13. glaip_sdk/client/base.py +62 -2
  14. glaip_sdk/client/mcps.py +63 -20
  15. glaip_sdk/client/tools.py +236 -81
  16. glaip_sdk/config/constants.py +10 -3
  17. glaip_sdk/exceptions.py +13 -0
  18. glaip_sdk/models.py +21 -5
  19. glaip_sdk/utils/__init__.py +116 -18
  20. glaip_sdk/utils/client_utils.py +284 -0
  21. glaip_sdk/utils/rendering/__init__.py +1 -0
  22. glaip_sdk/utils/rendering/formatting.py +211 -0
  23. glaip_sdk/utils/rendering/models.py +53 -0
  24. glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
  25. glaip_sdk/utils/rendering/renderer/base.py +827 -0
  26. glaip_sdk/utils/rendering/renderer/config.py +33 -0
  27. glaip_sdk/utils/rendering/renderer/console.py +54 -0
  28. glaip_sdk/utils/rendering/renderer/debug.py +82 -0
  29. glaip_sdk/utils/rendering/renderer/panels.py +123 -0
  30. glaip_sdk/utils/rendering/renderer/progress.py +118 -0
  31. glaip_sdk/utils/rendering/renderer/stream.py +198 -0
  32. glaip_sdk/utils/rendering/steps.py +168 -0
  33. glaip_sdk/utils/run_renderer.py +22 -1086
  34. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/METADATA +8 -36
  35. glaip_sdk-0.0.4.dist-info/RECORD +41 -0
  36. glaip_sdk/cli/config.py +0 -592
  37. glaip_sdk/utils.py +0 -167
  38. glaip_sdk-0.0.2.dist-info/RECORD +0 -28
  39. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/WHEEL +0 -0
  40. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/entry_points.txt +0 -0
@@ -4,1102 +4,38 @@
4
4
  This module provides a modern CLI experience similar to Claude Code and Gemini CLI,
5
5
  with compact headers, streaming markdown, collapsible tool steps, and clean output.
6
6
 
7
+ This is a compatibility shim that re-exports components from the new modular renderer package.
8
+
7
9
  Authors:
8
10
  Raymond Christopher (raymond.christopher@gdplabs.id)
9
11
  """
10
12
 
11
13
  from __future__ import annotations
12
14
 
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
15
  # 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_weather-sub-agent, delegate_to_math_specialist, delegate, spawn_agent, etc.
341
- return bool(
342
- re.search(
343
- r"delegate_to_|(?:^|_)delegate(?:_|$)|spawn_|sub_agent", tool_name, re.I
344
- )
345
- )
346
-
347
- def _main_title(self) -> str:
348
- """Generate main panel title with spinner and status chips."""
349
- # base name
350
- name = (self.header_text or "").strip() or "Assistant"
351
- # strip leading rule emojis if present
352
- name = name.replace("—", " ").strip()
353
- # spinner if still working
354
- mark = "✓" if not self._has_running_steps() else self._spinner()
355
- # show a tiny hint if there's an active delegate
356
- active_delegates = sum(
357
- 1
358
- for sid, st in self.steps.by_id.items()
359
- if st.kind == "delegate" and st.status != "finished"
360
- )
361
- chip = f" • delegating ({active_delegates})" if active_delegates else ""
362
- # show tools count for parity
363
- active_tools = sum(
364
- 1
365
- for st in self.steps.by_id.values()
366
- if st.kind == "tool" and st.status != "finished"
367
- )
368
- chip2 = f" • tools ({active_tools})" if active_tools else ""
369
- return f"{name} {mark}{chip}{chip2}"
370
-
371
- def _render_main_panel(self):
372
- """Render the main panel with content or placeholder."""
373
- body = "".join(self.buffer).strip()
374
- if body:
375
- return Panel(
376
- self._render_stream(),
377
- title=self._main_title(),
378
- border_style="green",
379
- )
380
- # fallback placeholder if no content yet
381
- placeholder = Text("working…", style="dim")
382
- if self._has_running_steps():
383
- placeholder = Text("working… running steps", style="dim")
384
- return Panel(
385
- placeholder,
386
- title=self._main_title(),
387
- border_style="green",
388
- )
389
-
390
- def _norm_markdown(self, s: str) -> str:
391
- """Reuse LaTeX normalization for panel bodies."""
392
- s = self._normalize_math(s)
393
- return s
394
-
395
- def _render_context_panels(self):
396
- """Render sub-agent panels."""
397
- panels = []
398
- for cid in self.context_order:
399
- chunks = self.context_panels.get(cid) or []
400
- meta = self.context_meta.get(cid) or {}
401
- title = meta.get("title") or f"Sub-agent {cid[:6]}…"
402
- status = meta.get("status") or "running"
403
- mark = "✓" if status == "finished" else self._spinner()
404
- body = self._norm_markdown("".join(chunks))
405
- panels.append(
406
- Panel(
407
- Markdown(
408
- body,
409
- code_theme=("monokai" if self.theme == "dark" else "github"),
410
- ),
411
- title=f"{title} {mark}",
412
- border_style="magenta"
413
- if meta.get("kind") == "delegate"
414
- else "cyan",
415
- )
416
- )
417
- return panels
418
-
419
- def _render_tool_panels(self):
420
- """Render tool output panels."""
421
- panels = []
422
- for sid in self.tool_order:
423
- meta = self.tool_panels.get(sid) or {}
424
- title = meta.get("title") or "Tool"
425
- status = meta.get("status") or "running"
426
- mark = "✓" if status == "finished" else self._spinner()
427
- body = self._norm_markdown("".join(meta.get("chunks") or []))
428
- panels.append(
429
- Panel(
430
- Markdown(
431
- body,
432
- code_theme=("monokai" if self.theme == "dark" else "github"),
433
- ),
434
- title=f"{title} {mark}",
435
- border_style="blue",
436
- )
437
- )
438
- return panels
439
-
440
- def _process_tool_output_for_sub_agents(
441
- self, tool_name: str, output: str, task_id: str, context_id: str
442
- ) -> bool:
443
- """Process tool output to extract and create sub-agent panels."""
444
- if not output:
445
- return False
446
-
447
- # Check if this is a delegation tool (contains sub-agent responses)
448
- if "delegate" in (tool_name or "").lower() or "math_specialist" in output:
449
- # Extract sub-agent name from output (e.g., "[math_specialist] ...")
450
- import re
451
-
452
- agent_match = re.search(r"^\s*\[([^\]]+)\]\s*(.*)$", output, re.S)
453
- if agent_match:
454
- agent_name = agent_match.group(1).strip()
455
- agent_content = agent_match.group(2).strip()
456
-
457
- # Create a unique context ID for this sub-agent response
458
- sub_context_id = f"{context_id}_sub_{agent_name}"
459
-
460
- # Create sub-agent panel if it doesn't exist
461
- if sub_context_id not in self.context_panels:
462
- self.context_panels[sub_context_id] = []
463
- self.context_meta[sub_context_id] = {
464
- "title": f"Sub-Agent: {agent_name}",
465
- "kind": "delegate",
466
- "status": "finished", # Already completed
467
- }
468
- self.context_order.append(sub_context_id)
469
-
470
- # Add the content to the sub-agent panel
471
- self.context_panels[sub_context_id].append(agent_content)
472
-
473
- # Mark as finished since it's already complete
474
- self.context_meta[sub_context_id]["status"] = "finished"
475
- return True
476
- return False
477
-
478
- def _render_tools(self):
479
- if not (self.steps.order or self.steps.children):
480
- return Text("")
481
-
482
- if not self.verbose:
483
- # collapsed: one line per top-level step (children are summarized)
484
- lines = []
485
- # Get terminal width for better spacing
486
- width = max(40, self.console.size.width - 8)
487
- args_budget = min(60, width // 3)
488
-
489
- for sid in self.steps.order:
490
- st = self.steps.by_id[sid]
491
- icon = (
492
- "⚙️ "
493
- if (self.use_emoji and st.kind == "tool")
494
- else ("🤝 " if (self.use_emoji and st.kind == "delegate") else "")
495
- )
496
- dur = f"[{st.duration_ms}ms]" if st.duration_ms is not None else ""
497
-
498
- # Truncate args to fit budget
499
- args = _pretty_args(st.args, max_len=args_budget)
500
- rhs = f"({args})" if args else ""
501
-
502
- # Use spinner for running steps, checkmark for finished
503
- tail = " ✓" if st.status == "finished" else f" {self._spinner()}"
504
-
505
- # Show actual tool name or improved step name
506
- display_name = st.name if st.name != "step" else f"{st.kind} step"
507
- lines.append(f"{icon}{display_name}{rhs} {dur}{tail}".rstrip())
508
- return Text("\n".join(lines), style="dim")
509
-
510
- # verbose: full tree
511
- def add_children(node: Tree, sid: str):
512
- st = self.steps.by_id[sid]
513
- dur = f"[{st.duration_ms}ms]" if st.duration_ms is not None else ""
514
- args_str = _pretty_args(st.args)
515
- icon = (
516
- "⚙️" if st.kind == "tool" else ("🤝" if st.kind == "delegate" else "🧠")
517
- )
518
-
519
- # Build detailed label for verbose mode
520
- label = f"{icon} {st.name}"
521
- if args_str:
522
- label += f"({args_str})"
523
-
524
- # Add tool output for finished tools (truncated for display)
525
- if st.status == "finished" and st.output and st.kind == "tool":
526
- # For verbose mode, show more output details
527
- if len(st.output) <= 200:
528
- # Short output: show inline
529
- output_preview = _pretty_out(st.output, max_len=200)
530
- if output_preview:
531
- label += f" → {output_preview}"
532
- else:
533
- # Long output: show first part with indicator
534
- first_line = st.output.split("\n")[0][:100]
535
- if first_line:
536
- label += f" → {first_line}... (truncated, see tool panel for full output)"
537
-
538
- label += f" {dur} {'✓' if st.status=='finished' else '…'}"
539
- node2 = node.add(label)
540
- for child_id in self.steps.children.get(sid, []):
541
- add_children(node2, child_id)
542
-
543
- root = Tree("Steps")
544
- for sid in self.steps.order:
545
- add_children(root, sid)
546
- return root
547
-
548
- def _normalize_math(self, md: str) -> str:
549
- """Robust LaTeX normalization for better display."""
550
- import re
551
-
552
- # \text{...} → plain
553
- md = re.sub(r"\\text\{([^}]*)\}", r"\1", md)
554
-
555
- # simple symbols
556
- md = md.replace(r"\times", "×").replace(r"\cdot", "·")
557
-
558
- # \boxed{...} → **...**
559
- md = re.sub(r"\\boxed\{([^}]*)\}", r"**\1**", md)
560
-
561
- # \begin{array}{...} ... \end{array} → code block
562
- def _array_to_block(m):
563
- body = m.group("body")
564
- # drop leading alignment spec {c@{}c@{}c} etc at start of body if present
565
- body = re.sub(r"^\{[^}]*\}\s*", "", body.strip())
566
-
567
- # split on LaTeX row separator \\ and replace alignment & with spacing
568
- rows = []
569
- for raw in re.split(r"\\\\", body):
570
- line = raw.strip()
571
- if not line:
572
- continue
573
- # remove explicit + / bullets spacing issues and align with double-spaces
574
- line = line.replace("&", " ")
575
- line = re.sub(r"\s{3,}", " ", line)
576
- rows.append(line)
577
-
578
- return "```\n" + "\n".join(rows) + "\n```"
579
-
580
- md = re.sub(
581
- r"\\begin\{array\}\{[^}]*\}(?P<body>.*?)\\end\{array\}",
582
- _array_to_block,
583
- md,
584
- flags=re.S,
585
- )
586
-
587
- # Block math \[...\] → fenced code
588
- md = re.sub(r"\\\[(.*?)\\\]", r"```\n\1\n```", md, flags=re.S)
589
-
590
- # Inline math \(...\) → inline code
591
- md = re.sub(r"\\\((.*?)\\\)", r"`\1`", md, flags=re.S)
592
-
593
- # Strip any remaining begin/end environments harmlessly
594
- md = re.sub(r"\\begin\{[^}]*\}|\\end\{[^}]*\}", "", md)
595
-
596
- return md
597
-
598
- def _render_stream(self) -> Markdown:
599
- """Render the streaming markdown content."""
600
- content = "".join(self.buffer)
601
- content = self._normalize_math(content)
602
- code_theme = "monokai" if self.theme == "dark" else "github"
603
- return Markdown(content, code_theme=code_theme)
604
-
605
- def _ensure_live(self):
606
- """Ensure live area is started for any first event."""
607
- if self._live:
608
- return
609
- # Start live view without outer panel to fix double boxing
610
- self._live = Live(
611
- refresh_per_second=24,
612
- console=self.console,
613
- transient=not self.persist_live, # Respect flag
614
- auto_refresh=True,
615
- )
616
- self._live.start()
617
-
618
- def _refresh(self) -> None:
619
- """Refresh the live display with stacked panels."""
620
- if not self._live:
621
- return
622
-
623
- panels = [self._render_main_panel()] # Main assistant content
624
-
625
- # Only include Steps panel when there are actual steps
626
- if self.steps.order or self.steps.children:
627
- panels.append(
628
- Panel(
629
- self._render_tools(),
630
- title="Steps (collapsed)" if not self.verbose else "Steps",
631
- border_style="blue",
632
- )
633
- )
634
-
635
- context_panels = self._render_context_panels()
636
-
637
- panels.extend(context_panels) # Sub-agent panels
638
- panels.extend(self._render_tool_panels()) # tools
639
-
640
- self._live.update(Group(*panels))
641
-
642
- def _refresh_thread_safe(self) -> None:
643
- """Thread-safe refresh method for use from background threads."""
644
- if not self._live:
645
- return
646
-
647
- try:
648
- # Use console.call_from_thread for thread-safe updates
649
- self._live.console.call_from_thread(self._refresh)
650
- except Exception:
651
- # Fallback to direct call if call_from_thread fails
652
- try:
653
- self._refresh()
654
- except Exception:
655
- pass # Ignore refresh errors
656
-
657
- def on_start(self, meta: dict[str, Any]) -> None:
658
- """Handle agent run start."""
659
- parts = []
660
-
661
- # Add emoji if enabled
662
- if self.use_emoji:
663
- parts.append("🤖")
664
-
665
- # Add agent name
666
- agent_name = meta.get("agent_name", "agent")
667
- if agent_name:
668
- parts.append(agent_name)
669
-
670
- # Add model if available
671
- model = meta.get("model", "")
672
- if model:
673
- parts.append("•")
674
- parts.append(model)
675
-
676
- # Add run ID if available
677
- run_id = meta.get("run_id", "")
678
- if run_id:
679
- parts.append("•")
680
- parts.append(run_id)
681
-
682
- self.header_text = " ".join(parts)
683
-
684
- # Show a compact header once (de-duplicated)
685
- self._print_header_once(self.header_text)
686
-
687
- # Show the original query for context
688
- query = meta.get("input_message") or meta.get("query") or meta.get("message")
689
- if query:
690
- from rich.markdown import Markdown
691
-
692
- self.console.print(
693
- Panel(
694
- Markdown(f"**Query:** {query}"),
695
- title="User Request",
696
- border_style="yellow",
697
- padding=(0, 1),
698
- )
699
- )
700
-
701
- # Don't start live display immediately - wait for actual content
702
- self._live = None
703
-
704
- def on_event(self, ev: dict[str, Any]) -> None:
705
- """Handle streaming events from the backend."""
706
- try:
707
- # Handle different event types based on backend's SSE format
708
- metadata = ev.get("metadata", {})
709
- kind = metadata.get("kind", "")
710
-
711
- if kind == "artifact":
712
- return # Hidden by default
713
-
714
- # --- tool steps (collapsed) ---
715
- if kind == "agent_step":
716
- # Extract task and context IDs from metadata or direct fields
717
- task_id = ev.get("task_id") or metadata.get("task_id")
718
- context_id = ev.get("context_id") or metadata.get("context_id")
719
-
720
- status = metadata.get("status", "running")
721
-
722
- # Tool name + args + (optional) output
723
- tool_name = None
724
- tool_args = {}
725
- tool_out = None
726
-
727
- # First try the tool_calls field (legacy)
728
- tc = ev.get("tool_calls")
729
- if isinstance(tc, list) and tc:
730
- tool_name = (tc[0] or {}).get("name")
731
- tool_args = (tc[0] or {}).get("args") or {}
732
- elif isinstance(tc, dict):
733
- tool_name = tc.get("name")
734
- tool_args = tc.get("args") or {}
735
- tool_out = tc.get("output")
736
-
737
- # Then try the tool_info field in metadata (new format)
738
- tool_info = metadata.get("tool_info", {})
739
- if tool_info and not tool_name:
740
- # Handle running tool calls
741
- tool_calls = tool_info.get("tool_calls", [])
742
- if tool_calls and isinstance(tool_calls, list):
743
- first_call = tool_calls[0] if tool_calls else {}
744
- tool_name = first_call.get("name")
745
- tool_args = first_call.get("args", {})
746
-
747
- # Handle finished tool with output
748
- if tool_info.get("name"):
749
- tool_name = tool_info.get("name")
750
- tool_args = tool_info.get("args", {})
751
- tool_out = tool_info.get("output")
752
-
753
- # Heuristic: delegation events (sub-agent) signalled by message, or parent/child context ids
754
- message_en = ev.get("metadata", {}).get("message", {}).get("en", "")
755
- maybe_delegate = bool(
756
- re.search(
757
- r"\bdelegat(e|ion|ed)\b|\bspawn(ed)?\b|\bsub[- ]?agent\b",
758
- message_en,
759
- re.I,
760
- )
761
- )
762
- child_ctx = ev.get("child_context_id") or ev.get("sub_context_id")
763
-
764
- # Check if this is a delegation tool (like delegate_to_weather-sub-agent)
765
- is_delegation_tool = tool_name and self._is_delegation_tool(tool_name)
766
-
767
- # Parent mapping: if this step spawns a child context, remember who spawned it
768
- parent_id = None
769
- if maybe_delegate and child_ctx:
770
- # start a delegate step at current context; child steps will hang under it
771
- parent_step = self.steps.start_or_get(
772
- task_id=task_id,
773
- context_id=context_id,
774
- kind="delegate",
775
- name=ev.get("delegate_name")
776
- or ev.get("agent_name")
777
- or "delegate",
778
- args={},
779
- )
780
- self.context_parent[child_ctx] = parent_step.step_id
781
-
782
- # NEW: reserve a sub-agent panel immediately (spinner until content arrives)
783
- title = (
784
- ev.get("delegate_name")
785
- or ev.get("agent_name")
786
- or f"Sub-agent {child_ctx[:6]}…"
787
- )
788
- if child_ctx not in self.context_panels:
789
- self.context_panels[child_ctx] = []
790
- self.context_meta[child_ctx] = {
791
- "title": f"Sub-Agent: {title}",
792
- "kind": "delegate",
793
- "status": "running",
794
- }
795
- self.context_order.append(child_ctx)
796
-
797
- self._ensure_live() # Ensure live for step-first runs
798
- self._refresh()
799
- return
800
-
801
- # If this is a delegation tool, create a sub-agent panel immediately
802
- if is_delegation_tool and not self.show_delegate_tool_panels:
803
- # Extract sub-agent name from tool name (e.g., "delegate_to_weather-sub-agent" -> "weather-sub-agent")
804
- sub_agent_name = tool_name.replace("delegate_to_", "").replace(
805
- "delegate_", ""
806
- )
807
-
808
- # Create a unique context ID for this delegation
809
- delegation_context_id = f"{context_id}_delegation_{tool_name}"
810
-
811
- # Create sub-agent panel immediately
812
- if delegation_context_id not in self.context_panels:
813
- self.context_panels[delegation_context_id] = []
814
- self.context_meta[delegation_context_id] = {
815
- "title": f"Sub-Agent: {sub_agent_name}",
816
- "kind": "delegate",
817
- "status": "running",
818
- }
819
- self.context_order.append(delegation_context_id)
820
-
821
- # Mark this as a delegation tool to avoid creating tool panels
822
- is_delegation_tool = True
823
-
824
- # Pick kind for this step
825
- kind_name = (
826
- "tool" if tool_name else ("delegate" if maybe_delegate else "agent")
827
- )
828
- name = tool_name or (
829
- ev.get("delegate_name") or ev.get("agent_name") or "step"
830
- )
831
-
832
- # Parent: if this event belongs to a child context, attach under its spawner
833
- parent_id = self.context_parent.get(context_id)
834
-
835
- # Determine if this is a running or finished step
836
- # If tool_calls is a list, it's starting; if it's a dict with output, it's finishing
837
- if isinstance(tc, list):
838
- status = "running"
839
- elif isinstance(tc, dict) and tc.get("output"):
840
- status = "finished"
841
- else:
842
- status = status or ev.get("status") or "running"
843
- dur_raw = ev.get("metadata", {}).get("time")
844
-
845
- if status == "running":
846
- st = self.steps.find_running(
847
- task_id=task_id,
848
- context_id=context_id,
849
- kind=kind_name,
850
- name=name,
851
- )
852
- if not st:
853
- st = self.steps.start_or_get(
854
- task_id=task_id,
855
- context_id=context_id,
856
- kind=kind_name,
857
- name=name,
858
- parent_id=parent_id,
859
- args=tool_args,
860
- )
861
-
862
- # If it's a tool, ensure a tool panel exists and is running
863
- if kind_name == "tool":
864
- # Suppress tool panel for delegation tools unless explicitly enabled
865
- should_show_panel = (
866
- not self._is_delegation_tool(name)
867
- or self.show_delegate_tool_panels
868
- )
869
- if should_show_panel:
870
- sid = st.step_id
871
- if sid not in self.tool_panels:
872
- self.tool_panels[sid] = {
873
- "title": f"Tool: {name}",
874
- "status": "running",
875
- "chunks": [],
876
- }
877
- self.tool_order.append(sid)
878
- else:
879
- st = self.steps.finish(
880
- task_id=task_id,
881
- context_id=context_id,
882
- kind=kind_name,
883
- name=name,
884
- output=tool_out,
885
- duration_raw=dur_raw,
886
- )
887
-
888
- if kind_name == "tool":
889
- sid = st.step_id
890
-
891
- out = tool_out or ""
892
-
893
- # Handle delegation tools by updating sub-agent panels
894
- if (
895
- self._is_delegation_tool(name)
896
- and not self.show_delegate_tool_panels
897
- ):
898
- # Find the corresponding sub-agent panel and update it
899
- delegation_context_id = f"{context_id}_delegation_{name}"
900
- if delegation_context_id in self.context_panels:
901
- # Update the sub-agent panel with the delegation tool output
902
- self.context_panels[delegation_context_id].append(out)
903
- self.context_meta[delegation_context_id]["status"] = (
904
- "finished"
905
- )
906
-
907
- # Remove any accidentally created tool panel
908
- if sid in self.tool_panels:
909
- self.tool_panels.pop(sid, None)
910
- try:
911
- self.tool_order.remove(sid)
912
- except ValueError:
913
- pass
914
-
915
- self._ensure_live()
916
- self._refresh()
917
- return
918
-
919
- # First, see if this created a sub-agent panel
920
- self._process_tool_output_for_sub_agents(
921
- name, out, task_id, context_id
922
- )
923
-
924
- # If it's a delegation tool and we don't want its panel, suppress it
925
- if (
926
- self._is_delegation_tool(name)
927
- and not self.show_delegate_tool_panels
928
- ):
929
- # If a running tool panel was accidentally created, remove it
930
- if sid in self.tool_panels:
931
- self.tool_panels.pop(sid, None)
932
- try:
933
- self.tool_order.remove(sid)
934
- except ValueError:
935
- pass
936
- self._ensure_live()
937
- self._refresh()
938
- return
939
-
940
- # Normal (non-delegation) tool panel behavior
941
- panel = self.tool_panels.get(sid)
942
- if not panel:
943
- panel = {
944
- "title": f"Tool: {name}",
945
- "status": "running",
946
- "chunks": [],
947
- }
948
- self.tool_panels[sid] = panel
949
- self.tool_order.append(sid)
950
-
951
- if bool(out) and (
952
- (out.strip().startswith("{") and out.strip().endswith("}"))
953
- or (
954
- out.strip().startswith("[")
955
- and out.strip().endswith("]")
956
- )
957
- ):
958
- panel["chunks"].append("```json\n" + out + "\n```")
959
- else:
960
- panel["chunks"].append(out)
961
-
962
- # trim for memory
963
- if sum(len(x) for x in panel["chunks"]) > 20000:
964
- joined = "".join(panel["chunks"])[-10000:]
965
- panel["chunks"] = [joined]
966
-
967
- panel["status"] = "finished"
968
-
969
- self._ensure_live() # Ensure live for step-first runs
970
- self._refresh()
971
- return
972
-
973
- # --- status updates (backend sends status: "streaming_started", "execution_started", etc.) ---
974
- if "status" in ev and ev.get("metadata", {}).get("kind") != "agent_step":
975
- status_msg = ev.get("status", "")
976
- if status_msg in ("streaming_started", "execution_started"):
977
- # These are informational status updates, no need to display
978
- return
979
- self._last_status = status_msg # keep it if you want chips later
980
- # no rule printing here; _main_title() already animates
981
- return
982
-
983
- # --- content streaming with boundary spacing ---
984
- if "content" in ev and ev["content"]:
985
- content = ev["content"]
986
-
987
- if "Artifact received:" in content:
988
- return
989
-
990
- cid = ev.get("context_id") or metadata.get("context_id")
991
-
992
- # establish root context on first content
993
- if self.root_context_id is None and cid:
994
- self.root_context_id = cid
995
-
996
- # sub-agent / child context streaming → stream into its own panel
997
- if cid and self.root_context_id and cid != self.root_context_id:
998
- self._ensure_live()
999
- if cid not in self.context_panels:
1000
- # Create the panel the first time we see content
1001
- title = (
1002
- ev.get("agent_name")
1003
- or ev.get("delegate_name")
1004
- or f"Sub-agent {cid[:6]}…"
1005
- )
1006
- self.context_panels[cid] = []
1007
- self.context_meta[cid] = {
1008
- "title": f"Sub-Agent: {title}",
1009
- "kind": "delegate",
1010
- "status": "running",
1011
- }
1012
- self.context_order.append(cid)
1013
-
1014
- # append & trim (memory guard)
1015
- buf = self.context_panels[cid]
1016
- buf.append(content)
1017
- if sum(len(x) for x in buf) > 20000:
1018
- # keep last ~10k chars
1019
- joined = "".join(buf)[-10000:]
1020
- self.context_panels[cid] = [joined]
1021
-
1022
- self._refresh()
1023
- return
1024
-
1025
- # root / unknown context → assistant
1026
- self._ensure_live() # Ensure live for content-first runs
1027
-
1028
- # insert a space at boundary when needed
1029
- if (
1030
- self.buffer
1031
- and self.buffer[-1]
1032
- and self.buffer[-1][-1].isalnum()
1033
- and content
1034
- and content[0].isalnum()
1035
- ):
1036
- self.buffer.append(" ")
1037
- self.buffer.append(content)
1038
-
1039
- # Memory guard: trim main buffer if it gets too large
1040
- joined = "".join(self.buffer)
1041
- if len(joined) > 200_000: # ~200KB
1042
- self.buffer = [joined[-100_000:]] # keep last ~100KB
1043
-
1044
- self._refresh()
1045
- return
16
+ import logging
1046
17
 
1047
- # final_response handled in on_complete
1048
- except Exception as e:
1049
- # Log the error and ensure Live is stopped to prevent terminal corruption
1050
- logger.error(f"Error in event handler: {e}")
1051
- try:
1052
- if self._live:
1053
- self._live.stop()
1054
- except Exception:
1055
- pass # Ignore cleanup errors
1056
- raise # Re-raise the original exception
18
+ from glaip_sdk.utils.rendering.models import RunStats
1057
19
 
1058
- def on_complete(self, final: str, stats: RunStats) -> None:
1059
- """Handle agent run completion."""
1060
- try:
1061
- # ensure final text is in buffer
1062
- if final:
1063
- whole = "".join(self.buffer)
1064
- if not whole or final not in whole:
1065
- if (
1066
- self.buffer
1067
- and self.buffer[-1]
1068
- and self.buffer[-1][-1].isalnum()
1069
- and final
1070
- and final[0].isalnum()
1071
- ):
1072
- self.buffer.append(" ")
1073
- self.buffer.append(final)
20
+ # Re-export main components from the new modular renderer package
21
+ from glaip_sdk.utils.rendering.renderer.base import RichStreamRenderer
22
+ from glaip_sdk.utils.rendering.renderer.config import RendererConfig
23
+ from glaip_sdk.utils.rendering.renderer.console import CapturingConsole
1074
24
 
1075
- # Mark all sub-agent panels as finished
1076
- for cid in list(self.context_meta.keys()):
1077
- self.context_meta[cid]["status"] = "finished"
25
+ # Legacy imports for backward compatibility
26
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
27
+ from glaip_sdk.utils.rendering.steps import StepManager
1078
28
 
1079
- # make sure live exists for a clean last frame
1080
- if self._live is None:
1081
- self._ensure_live()
29
+ logger = logging.getLogger("glaip_sdk.run_renderer")
1082
30
 
1083
- # update both panels one last time
1084
- self._refresh()
1085
31
 
1086
- # print footer (works with both transient & persistent)
1087
- footer = []
1088
- if stats.duration_s is not None:
1089
- footer.append(f"✓ Done in {stats.duration_s}s")
1090
- u = stats.usage or {}
1091
- if u.get("input_tokens") or u.get("output_tokens"):
1092
- footer.append(
1093
- f"{u.get('input_tokens',0)} tok in / {u.get('output_tokens',0)} tok out"
1094
- )
1095
- if u.get("cost"):
1096
- footer.append(f"${u['cost']}")
1097
- if footer:
1098
- self.console.print(Text(" • ".join(footer), style="bold green"))
1099
- finally:
1100
- # Always ensure Live is stopped, even if an exception occurs
1101
- try:
1102
- if self._live:
1103
- self._live.stop()
1104
- except Exception:
1105
- pass # Ignore errors during cleanup
32
+ # The full implementation has been moved to glaip_sdk.utils.rendering.renderer.base
33
+ # This file now serves as a compatibility shim for existing imports.
34
+ __all__ = [
35
+ "CapturingConsole",
36
+ "RendererConfig",
37
+ "RichStreamRenderer",
38
+ "render_debug_event",
39
+ "RunStats",
40
+ "StepManager",
41
+ ]