glaip-sdk 0.2.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. glaip_sdk/cli/auth.py +2 -1
  2. glaip_sdk/cli/commands/agents.py +51 -36
  3. glaip_sdk/cli/commands/configure.py +2 -1
  4. glaip_sdk/cli/commands/mcps.py +219 -62
  5. glaip_sdk/cli/commands/models.py +3 -5
  6. glaip_sdk/cli/commands/tools.py +27 -16
  7. glaip_sdk/cli/commands/transcripts.py +1 -1
  8. glaip_sdk/cli/constants.py +3 -0
  9. glaip_sdk/cli/display.py +1 -1
  10. glaip_sdk/cli/hints.py +58 -0
  11. glaip_sdk/cli/io.py +6 -3
  12. glaip_sdk/cli/main.py +3 -4
  13. glaip_sdk/cli/slash/agent_session.py +4 -13
  14. glaip_sdk/cli/slash/prompt.py +3 -0
  15. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  16. glaip_sdk/cli/slash/session.py +139 -48
  17. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  18. glaip_sdk/cli/slash/tui/remote_runs_app.py +632 -0
  19. glaip_sdk/cli/transcript/capture.py +1 -1
  20. glaip_sdk/cli/transcript/viewer.py +19 -678
  21. glaip_sdk/cli/update_notifier.py +2 -1
  22. glaip_sdk/cli/utils.py +228 -101
  23. glaip_sdk/cli/validators.py +5 -6
  24. glaip_sdk/client/__init__.py +2 -1
  25. glaip_sdk/client/agent_runs.py +147 -0
  26. glaip_sdk/client/agents.py +40 -22
  27. glaip_sdk/client/main.py +2 -6
  28. glaip_sdk/client/mcps.py +13 -5
  29. glaip_sdk/client/run_rendering.py +90 -111
  30. glaip_sdk/client/shared.py +21 -0
  31. glaip_sdk/client/tools.py +2 -3
  32. glaip_sdk/config/constants.py +11 -0
  33. glaip_sdk/models/__init__.py +56 -0
  34. glaip_sdk/models/agent_runs.py +117 -0
  35. glaip_sdk/models.py +8 -7
  36. glaip_sdk/rich_components.py +58 -2
  37. glaip_sdk/utils/client_utils.py +13 -0
  38. glaip_sdk/utils/display.py +23 -15
  39. glaip_sdk/utils/export.py +143 -0
  40. glaip_sdk/utils/import_export.py +6 -9
  41. glaip_sdk/utils/rendering/__init__.py +115 -1
  42. glaip_sdk/utils/rendering/formatting.py +5 -30
  43. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  44. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  45. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  46. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  47. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  48. glaip_sdk/utils/rendering/models.py +1 -0
  49. glaip_sdk/utils/rendering/renderer/__init__.py +10 -28
  50. glaip_sdk/utils/rendering/renderer/base.py +217 -1476
  51. glaip_sdk/utils/rendering/renderer/debug.py +24 -1
  52. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  53. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  54. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  55. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  56. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  57. glaip_sdk/utils/rendering/state.py +204 -0
  58. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  59. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -439
  60. glaip_sdk/utils/rendering/steps/format.py +176 -0
  61. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  62. glaip_sdk/utils/rendering/timing.py +36 -0
  63. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  64. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  65. glaip_sdk/utils/resource_refs.py +26 -15
  66. glaip_sdk/utils/validation.py +13 -21
  67. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/METADATA +24 -2
  68. glaip_sdk-0.4.0.dist-info/RECORD +110 -0
  69. glaip_sdk-0.2.2.dist-info/RECORD +0 -87
  70. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/WHEEL +0 -0
  71. {glaip_sdk-0.2.2.dist-info → glaip_sdk-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ """Agent run models for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ from datetime import datetime, timedelta
9
+ from typing import Any, Literal
10
+ from uuid import UUID
11
+
12
+ from pydantic import BaseModel, Field, field_validator, model_validator
13
+
14
+
15
+ # Type alias for SSE event dictionaries
16
+ RunOutputChunk = dict[str, Any]
17
+
18
+
19
+ class RunSummary(BaseModel):
20
+ """Represents a single agent run in list/table views with metadata only."""
21
+
22
+ id: UUID
23
+ agent_id: UUID
24
+ run_type: Literal["manual", "schedule"]
25
+ schedule_id: UUID | None = None
26
+ status: Literal["started", "success", "failed", "cancelled", "aborted", "unavailable"]
27
+ started_at: datetime
28
+ completed_at: datetime | None = None
29
+ input: str | None = None
30
+ config: dict[str, Any] | None = None
31
+ created_at: datetime
32
+ updated_at: datetime
33
+
34
+ @field_validator("completed_at")
35
+ @classmethod
36
+ def validate_completed_after_started(cls, v: datetime | None, info) -> datetime | None:
37
+ """Validate that completed_at is after started_at if present."""
38
+ if v is not None and "started_at" in info.data:
39
+ started_at = info.data["started_at"]
40
+ if v < started_at:
41
+ raise ValueError("completed_at must be after started_at")
42
+ return v
43
+
44
+ def duration(self) -> timedelta | None:
45
+ """Calculate duration from started_at to completed_at.
46
+
47
+ Returns:
48
+ Duration as timedelta if completed_at exists, None otherwise
49
+ """
50
+ if self.completed_at is not None:
51
+ return self.completed_at - self.started_at
52
+ return None
53
+
54
+ def duration_formatted(self) -> str:
55
+ """Format duration as HH:MM:SS string.
56
+
57
+ Returns:
58
+ Formatted duration string or "—" if not completed
59
+ """
60
+ duration = self.duration()
61
+ if duration is None:
62
+ return "—"
63
+ total_seconds = int(duration.total_seconds())
64
+ hours = total_seconds // 3600
65
+ minutes = (total_seconds % 3600) // 60
66
+ seconds = total_seconds % 60
67
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
68
+
69
+ def input_preview(self, max_length: int = 120) -> str:
70
+ """Generate truncated input preview for table display.
71
+
72
+ Args:
73
+ max_length: Maximum length of preview string
74
+
75
+ Returns:
76
+ Truncated input string or "—" if input is None or empty
77
+ """
78
+ if not self.input:
79
+ return "—"
80
+ # Strip newlines and collapse whitespace
81
+ preview = " ".join(self.input.split())
82
+ if len(preview) > max_length:
83
+ return preview[:max_length] + "…"
84
+ return preview
85
+
86
+
87
+ class RunsPage(BaseModel):
88
+ """Represents a paginated collection of run summaries from the list endpoint."""
89
+
90
+ data: list[RunSummary]
91
+ total: int = Field(ge=0)
92
+ page: int = Field(ge=1)
93
+ limit: int = Field(ge=1, le=100)
94
+ has_next: bool
95
+ has_prev: bool
96
+
97
+ @model_validator(mode="after")
98
+ def validate_pagination_consistency(self) -> "RunsPage":
99
+ """Validate pagination consistency."""
100
+ # If has_next is True, then page * limit < total
101
+ if self.has_next and self.page * self.limit >= self.total:
102
+ raise ValueError("has_next inconsistency: page * limit must be < total when has_next is True")
103
+ return self
104
+
105
+
106
+ class RunWithOutput(RunSummary):
107
+ """Extends RunSummary with the complete SSE event stream for detailed viewing."""
108
+
109
+ output: list[RunOutputChunk] = Field(default_factory=list)
110
+
111
+ @field_validator("output", mode="before")
112
+ @classmethod
113
+ def normalize_output(cls, v: Any) -> list[RunOutputChunk]:
114
+ """Normalize output field to empty list when null."""
115
+ if v is None:
116
+ return []
117
+ return v
glaip_sdk/models.py CHANGED
@@ -23,21 +23,22 @@ class Agent(BaseModel):
23
23
  id: str
24
24
  name: str
25
25
  instruction: str | None = None
26
- description: str | None = None # Add missing description field
26
+ description: str | None = None
27
27
  type: str | None = None
28
28
  framework: str | None = None
29
29
  version: str | None = None
30
- tools: list[dict[str, Any]] | None = None # Backend returns ToolReference objects
31
- agents: list[dict[str, Any]] | None = None # Backend returns AgentReference objects
32
- mcps: list[dict[str, Any]] | None = None # Backend returns MCPReference objects
33
- tool_configs: dict[str, Any] | None = None # Backend returns tool configurations keyed by tool UUID
30
+ tools: list[dict[str, Any]] | None = None
31
+ agents: list[dict[str, Any]] | None = None
32
+ mcps: list[dict[str, Any]] | None = None
33
+ tool_configs: dict[str, Any] | None = None
34
+ mcp_configs: dict[str, Any] | None = None
34
35
  agent_config: dict[str, Any] | None = None
35
36
  timeout: int = DEFAULT_AGENT_RUN_TIMEOUT
36
37
  metadata: dict[str, Any] | None = None
37
38
  language_model_id: str | None = None
38
39
  a2a_profile: dict[str, Any] | None = None
39
- created_at: datetime | None = None # Backend returns creation timestamp
40
- updated_at: datetime | None = None # Backend returns last update timestamp
40
+ created_at: datetime | None = None
41
+ updated_at: datetime | None = None
41
42
  _client: Any = None
42
43
 
43
44
  def _set_client(self, client: Any) -> "Agent":
@@ -1,10 +1,15 @@
1
- """Custom Rich components with copy-friendly defaults."""
1
+ """Custom Rich components with copy-friendly defaults.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
5
9
  from rich import box
6
10
  from rich.panel import Panel
7
11
  from rich.table import Table
12
+ from rich.text import Text
8
13
 
9
14
 
10
15
  class AIPPanel(Panel):
@@ -66,4 +71,55 @@ class AIPGrid(Table):
66
71
  )
67
72
 
68
73
 
69
- __all__ = ["AIPPanel", "AIPTable", "AIPGrid"]
74
+ class RemoteRunsTable(AIPTable):
75
+ """Rich Table for displaying remote agent runs with pagination support."""
76
+
77
+ def __init__(self, *args, **kwargs):
78
+ """Initialize RemoteRunsTable with columns for run display.
79
+
80
+ Args:
81
+ *args: Positional arguments passed to AIPTable
82
+ **kwargs: Keyword arguments passed to AIPTable
83
+ """
84
+ kwargs.setdefault("row_styles", ("dim", "none"))
85
+ kwargs.setdefault("show_header", True)
86
+ super().__init__(*args, **kwargs)
87
+ # Add columns for run display
88
+ self.add_column("", width=2, no_wrap=True) # Selection gutter
89
+ self.add_column("Run UUID", style="cyan", width=36, no_wrap=True)
90
+ self.add_column("Type", style="yellow", width=8, no_wrap=True)
91
+ self.add_column("Status", style="magenta", width=12, no_wrap=True)
92
+ self.add_column("Started (UTC)", style="dim", width=20, no_wrap=True)
93
+ self.add_column("Completed (UTC)", style="dim", width=20, no_wrap=True)
94
+ self.add_column("Duration", style="green", width=10, no_wrap=True)
95
+ self.add_column("Input Preview", style="white", width=40, overflow="ellipsis")
96
+
97
+ def add_run_row(
98
+ self,
99
+ run_uuid: str,
100
+ run_type: str,
101
+ status: str,
102
+ started: str,
103
+ completed: str,
104
+ duration: str,
105
+ preview: str,
106
+ *,
107
+ selected: bool = False,
108
+ ) -> None:
109
+ """Append a run row with optional selection styling."""
110
+ gutter = Text("› ", style="bold bright_cyan") if selected else Text(" ")
111
+ row_style = "reverse" if selected else None
112
+ self.add_row(
113
+ gutter,
114
+ run_uuid,
115
+ run_type,
116
+ status,
117
+ started,
118
+ completed,
119
+ duration,
120
+ preview,
121
+ style=row_style,
122
+ )
123
+
124
+
125
+ __all__ = ["AIPPanel", "AIPTable", "AIPGrid", "RemoteRunsTable"]
@@ -426,6 +426,19 @@ def _prepare_stream_entry(
426
426
  )
427
427
 
428
428
 
429
+ def add_kwargs_to_payload(payload: dict[str, Any], kwargs: dict[str, Any], excluded_keys: set[str]) -> None:
430
+ """Add kwargs to payload excluding specified keys.
431
+
432
+ Args:
433
+ payload: Payload dictionary to update.
434
+ kwargs: Keyword arguments to add.
435
+ excluded_keys: Keys to exclude from kwargs.
436
+ """
437
+ for key, value in kwargs.items():
438
+ if key not in excluded_keys:
439
+ payload[key] = value
440
+
441
+
429
442
  def prepare_multipart_data(message: str, files: list[str | BinaryIO]) -> MultipartData:
430
443
  """Prepare multipart form data for file uploads.
431
444
 
@@ -4,6 +4,9 @@ Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
9
+ from importlib import import_module
7
10
  from typing import TYPE_CHECKING, Any
8
11
 
9
12
  from glaip_sdk.branding import SUCCESS, SUCCESS_STYLE
@@ -13,42 +16,47 @@ if TYPE_CHECKING: # pragma: no cover - import-time typing helpers
13
16
  from rich.console import Console
14
17
  from rich.text import Text
15
18
 
16
- from glaip_sdk.rich_components import AIPPanel
19
+ from glaip_sdk.rich_components import AIPanel
20
+ else: # pragma: no cover - runtime fallback for type checking
21
+ AIPanel = Any # type: ignore[assignment]
17
22
 
18
23
 
19
24
  def _check_rich_available() -> bool:
20
- """Check if Rich and our custom components can be imported."""
25
+ """Return True when core Rich display dependencies are importable."""
21
26
  try:
22
27
  __import__("rich.console")
23
28
  __import__("rich.text")
24
29
  __import__("glaip_sdk.rich_components")
25
- return True
26
30
  except Exception:
27
31
  return False
32
+ return True
28
33
 
29
34
 
30
35
  RICH_AVAILABLE = _check_rich_available()
31
36
 
32
37
 
33
- def _create_console() -> "Console":
38
+ def _create_console() -> Console:
34
39
  """Return a Console instance with lazy import to ease mocking."""
35
- from rich.console import Console # Local import for test friendliness
36
-
37
- return Console()
40
+ if not RICH_AVAILABLE: # pragma: no cover - defensive guard
41
+ raise RuntimeError("Rich Console is not available")
42
+ console_module = import_module("rich.console")
43
+ return console_module.Console()
38
44
 
39
45
 
40
- def _create_text(*args: Any, **kwargs: Any) -> "Text":
46
+ def _create_text(*args: Any, **kwargs: Any) -> Text:
41
47
  """Return a Text instance with lazy import to ease mocking."""
42
- from rich.text import Text # Local import for test friendliness
48
+ if not RICH_AVAILABLE: # pragma: no cover - defensive guard
49
+ raise RuntimeError("Rich Text is not available")
50
+ text_module = import_module("rich.text")
51
+ return text_module.Text(*args, **kwargs)
43
52
 
44
- return Text(*args, **kwargs)
45
53
 
46
-
47
- def _create_panel(*args: Any, **kwargs: Any) -> "AIPPanel":
54
+ def _create_panel(*args: Any, **kwargs: Any) -> AIPanel:
48
55
  """Return an AIPPanel instance with lazy import to ease mocking."""
49
- from glaip_sdk.rich_components import AIPPanel # Local import for test friendliness
50
-
51
- return AIPPanel(*args, **kwargs)
56
+ if not RICH_AVAILABLE: # pragma: no cover - defensive guard
57
+ raise RuntimeError("AIPPanel is not available")
58
+ components = import_module("glaip_sdk.rich_components")
59
+ return components.AIPPanel(*args, **kwargs)
52
60
 
53
61
 
54
62
  def print_agent_output(output: str, title: str = "Agent Output") -> None:
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Export utilities for remote agent run transcripts.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ import json
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from glaip_sdk.models.agent_runs import RunWithOutput, RunOutputChunk
14
+
15
+
16
+ def export_remote_transcript_jsonl(
17
+ run: RunWithOutput,
18
+ destination: Path,
19
+ *,
20
+ overwrite: bool = False,
21
+ agent_name: str | None = None,
22
+ model: str | None = None,
23
+ ) -> Path:
24
+ """Export a remote run transcript to JSONL format compatible with local transcript viewers.
25
+
26
+ Args:
27
+ run: RunWithOutput instance to export
28
+ destination: Target file path for JSONL export
29
+ overwrite: Whether to overwrite existing file
30
+ agent_name: Optional agent name for metadata
31
+ model: Optional model name for metadata (extracted from run.config if not provided)
32
+
33
+ Returns:
34
+ Path to the exported file
35
+
36
+ Raises:
37
+ FileExistsError: If destination exists and overwrite is False
38
+ OSError: If file cannot be written
39
+ """
40
+ if destination.exists() and not overwrite:
41
+ raise FileExistsError(f"File already exists: {destination}")
42
+
43
+ # Ensure parent directory exists
44
+ destination.parent.mkdir(parents=True, exist_ok=True)
45
+
46
+ model_name = model or _extract_model(run)
47
+ final_output_text = _extract_final_output(run.output) or ""
48
+
49
+ meta_payload = _build_meta_payload(run, agent_name, model_name)
50
+ meta_record = _build_meta_record(run, agent_name, model_name, final_output_text, meta_payload)
51
+
52
+ _write_jsonl_file(destination, meta_record, run.output)
53
+
54
+ return destination
55
+
56
+
57
+ def _build_meta_payload(run: RunWithOutput, agent_name: str | None, model_name: str | None) -> dict[str, Any]:
58
+ """Build the meta payload dictionary."""
59
+ return {
60
+ "agent_name": agent_name,
61
+ "model": model_name,
62
+ "input_message": run.input,
63
+ "status": run.status,
64
+ "run_type": run.run_type,
65
+ "schedule_id": str(run.schedule_id) if run.schedule_id else None,
66
+ "config": run.config or {},
67
+ "created_at": run.created_at.isoformat() if run.created_at else None,
68
+ "updated_at": run.updated_at.isoformat() if run.updated_at else None,
69
+ "event_count": len(run.output),
70
+ }
71
+
72
+
73
+ def _build_meta_record(
74
+ run: RunWithOutput,
75
+ agent_name: str | None,
76
+ model_name: str | None,
77
+ final_output_text: str,
78
+ meta_payload: dict[str, Any],
79
+ ) -> dict[str, Any]:
80
+ """Build the meta record dictionary."""
81
+ return {
82
+ "type": "meta",
83
+ "run_id": str(run.id),
84
+ "agent_id": str(run.agent_id),
85
+ "agent_name": agent_name,
86
+ "model": model_name,
87
+ "created_at": run.created_at.isoformat() if run.created_at else None,
88
+ "default_output": final_output_text,
89
+ "final_output": final_output_text,
90
+ "server_run_id": str(run.id),
91
+ "started_at": run.started_at.isoformat() if run.started_at else None,
92
+ "finished_at": run.completed_at.isoformat() if run.completed_at else None,
93
+ "meta": meta_payload,
94
+ "source": "remote_history",
95
+ # Back-compat fields used by older tooling
96
+ "run_type": run.run_type,
97
+ "schedule_id": str(run.schedule_id) if run.schedule_id else None,
98
+ "status": run.status,
99
+ "input": run.input,
100
+ "config": run.config or {},
101
+ "updated_at": run.updated_at.isoformat() if run.updated_at else None,
102
+ }
103
+
104
+
105
+ def _write_jsonl_file(destination: Path, meta_record: dict[str, Any], events: list[RunOutputChunk]) -> None:
106
+ """Write the JSONL file with meta and event records."""
107
+ records: list[dict[str, Any]] = [meta_record]
108
+ records.extend({"type": "event", "event": event} for event in events)
109
+
110
+ with destination.open("w", encoding="utf-8") as fh:
111
+ for idx, record in enumerate(records):
112
+ json.dump(record, fh, ensure_ascii=False, indent=2, default=_json_default)
113
+ fh.write("\n")
114
+ if idx != len(records) - 1:
115
+ fh.write("\n")
116
+
117
+
118
+ def _extract_model(run: RunWithOutput) -> str | None:
119
+ """Best-effort extraction of the model name from run metadata."""
120
+ config = run.config or {}
121
+ if isinstance(config, dict):
122
+ model = config.get("model") or config.get("llm", {}).get("model")
123
+ if isinstance(model, str):
124
+ return model
125
+ return None
126
+
127
+
128
+ def _extract_final_output(events: list[RunOutputChunk]) -> str | None:
129
+ """Return the final response content from the event stream."""
130
+ for chunk in reversed(events):
131
+ content = chunk.get("content")
132
+ if not content:
133
+ continue
134
+ if chunk.get("event_type") == "final_response" or chunk.get("final"):
135
+ return str(content)
136
+ return None
137
+
138
+
139
+ def _json_default(obj: Any) -> Any:
140
+ """JSON serializer for datetime objects."""
141
+ if isinstance(obj, datetime):
142
+ return obj.isoformat()
143
+ raise TypeError(f"Type {type(obj)} not serializable")
@@ -9,8 +9,10 @@ Authors:
9
9
 
10
10
  from typing import Any
11
11
 
12
+ from glaip_sdk.utils.resource_refs import _extract_id_from_item
12
13
 
13
- def extract_ids_from_export(items: list[Any]) -> list[str]: # pylint: disable=duplicate-code
14
+
15
+ def extract_ids_from_export(items: list[Any]) -> list[str]:
14
16
  """Extract IDs from export format (list of dicts with id/name fields).
15
17
 
16
18
  This function is similar to `extract_ids` in `resource_refs.py` but differs in behavior:
@@ -36,14 +38,9 @@ def extract_ids_from_export(items: list[Any]) -> list[str]: # pylint: disable=d
36
38
 
37
39
  ids = []
38
40
  for item in items:
39
- if isinstance(item, str):
40
- ids.append(item)
41
- elif hasattr(item, "id"):
42
- ids.append(str(item.id))
43
- elif isinstance(item, dict) and "id" in item:
44
- ids.append(str(item["id"]))
45
- # Skip items without ID (don't convert to string)
46
- # Note: This differs from extract_ids() in resource_refs.py which converts all items to strings
41
+ extracted = _extract_id_from_item(item, skip_missing=True)
42
+ if extracted is not None:
43
+ ids.append(extracted)
47
44
 
48
45
  return ids
49
46
 
@@ -1 +1,115 @@
1
- """Rendering utilities package (formatting, models, steps, debug)."""
1
+ """Rendering utilities package (formatting, models, steps, debug).
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+
12
+ from glaip_sdk.models.agent_runs import RunWithOutput
13
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event, render_debug_event_stream
14
+
15
+
16
+ def _parse_event_received_timestamp(event: dict[str, Any]) -> datetime | None:
17
+ """Parse received_at timestamp from SSE event.
18
+
19
+ Args:
20
+ event: SSE event dictionary
21
+
22
+ Returns:
23
+ Parsed datetime or None if not available
24
+ """
25
+ received_at = event.get("received_at")
26
+ if not received_at:
27
+ return None
28
+
29
+ if isinstance(received_at, datetime):
30
+ return received_at
31
+
32
+ if isinstance(received_at, str):
33
+ try:
34
+ # Try ISO format first
35
+ return datetime.fromisoformat(received_at.replace("Z", "+00:00"))
36
+ except ValueError:
37
+ try:
38
+ # Try common formats
39
+ return datetime.strptime(received_at, "%Y-%m-%dT%H:%M:%S.%fZ")
40
+ except ValueError:
41
+ return None
42
+
43
+ return None
44
+
45
+
46
+ def render_remote_sse_transcript(
47
+ run: RunWithOutput,
48
+ console: Console,
49
+ *,
50
+ show_metadata: bool = True,
51
+ ) -> None:
52
+ """Render remote SSE transcript events for a RunWithOutput.
53
+
54
+ Args:
55
+ run: RunWithOutput instance containing events
56
+ console: Rich console to render to
57
+ show_metadata: Whether to show run metadata summary
58
+ """
59
+ if show_metadata:
60
+ # Render metadata summary
61
+ console.print(f"[bold]Run: {run.id}[/bold]")
62
+ console.print(f"[dim]Agent: {run.agent_id}[/dim]")
63
+ console.print(f"[dim]Status: {run.status}[/dim]")
64
+ console.print(f"[dim]Type: {run.run_type}[/dim]")
65
+ if run.schedule_id:
66
+ console.print(f"[dim]Schedule ID: {run.schedule_id}[/dim]")
67
+ else:
68
+ console.print("[dim]Schedule: —[/dim]")
69
+ console.print(f"[dim]Started: {run.started_at.isoformat()}[/dim]")
70
+ if run.completed_at:
71
+ console.print(f"[dim]Completed: {run.completed_at.isoformat()}[/dim]")
72
+ console.print(f"[dim]Duration: {run.duration_formatted()}[/dim]")
73
+ console.print()
74
+
75
+ # Render events
76
+ if not run.output:
77
+ console.print("[dim]No SSE events available for this run.[/dim]")
78
+ return
79
+
80
+ console.print("[bold]SSE Events[/bold]")
81
+ console.print("[dim]────────────────────────────────────────────────────────[/dim]")
82
+
83
+ render_debug_event_stream(
84
+ run.output,
85
+ console,
86
+ resolve_timestamp=_parse_event_received_timestamp,
87
+ )
88
+ console.print()
89
+
90
+
91
+ class RemoteSSETranscriptRenderer:
92
+ """Renderer for remote SSE transcripts from RunWithOutput."""
93
+
94
+ def __init__(self, console: Console | None = None):
95
+ """Initialize the renderer.
96
+
97
+ Args:
98
+ console: Rich console instance (creates default if None)
99
+ """
100
+ self.console = console or Console()
101
+
102
+ def render(self, run: RunWithOutput, *, show_metadata: bool = True) -> None:
103
+ """Render a remote run transcript.
104
+
105
+ Args:
106
+ run: RunWithOutput instance to render
107
+ show_metadata: Whether to show run metadata summary
108
+ """
109
+ render_remote_sse_transcript(run, self.console, show_metadata=show_metadata)
110
+
111
+
112
+ __all__ = [
113
+ "render_remote_sse_transcript",
114
+ "RemoteSSETranscriptRenderer",
115
+ ]
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import re
11
- import time
12
11
  from collections.abc import Callable
13
12
  from typing import Any
14
13
 
@@ -47,11 +46,6 @@ SENSITIVE_PATTERNS = re.compile(
47
46
  r"(?:password|secret|token|key|api_key)(?:\s*[:=]\s*[^\s,}]+)?",
48
47
  re.IGNORECASE,
49
48
  )
50
- CONNECTOR_VERTICAL = "│ "
51
- CONNECTOR_EMPTY = " "
52
- CONNECTOR_BRANCH = "├─ "
53
- CONNECTOR_LAST = "└─ "
54
- ROOT_MARKER = ""
55
49
  SECRET_MASK = "••••••"
56
50
  STATUS_GLYPHS = {
57
51
  "success": ICON_STATUS_SUCCESS,
@@ -140,20 +134,11 @@ def glyph_for_status(icon_key: str | None) -> str | None:
140
134
 
141
135
  def normalise_display_label(label: str | None) -> str:
142
136
  """Return a user facing label or the Unknown fallback."""
143
- label = (label or "").strip()
144
- return label or "Unknown step detail"
145
-
146
-
147
- def build_connector_prefix(branch_state: tuple[bool, ...]) -> str:
148
- """Build connector prefix for a tree line based on ancestry state."""
149
- if not branch_state:
150
- return ROOT_MARKER
151
-
152
- parts: list[str] = []
153
- for ancestor_is_last in branch_state[:-1]:
154
- parts.append(CONNECTOR_EMPTY if ancestor_is_last else CONNECTOR_VERTICAL)
155
- parts.append(CONNECTOR_LAST if branch_state[-1] else CONNECTOR_BRANCH)
156
- return "".join(parts)
137
+ if not isinstance(label, str):
138
+ text = ""
139
+ else:
140
+ text = label.strip()
141
+ return text or "Unknown step detail"
157
142
 
158
143
 
159
144
  def pretty_args(args: dict | None, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
@@ -202,16 +187,6 @@ def pretty_out(output: any, max_len: int = DEFAULT_ARGS_MAX_LEN) -> str:
202
187
  return _truncate_string(output_str, max_len)
203
188
 
204
189
 
205
- def get_spinner_char() -> str:
206
- """Get the next character for a spinner animation.
207
-
208
- Returns:
209
- A single character from the spinner frames based on current time
210
- """
211
- frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
212
- return frames[int(time.time() * 10) % len(frames)]
213
-
214
-
215
190
  def get_step_icon(step_kind: str) -> str:
216
191
  """Get the appropriate icon for a step kind."""
217
192
  if step_kind == "tool":