glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__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 (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +104 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +241 -121
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +291 -35
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +466 -89
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,566 @@
1
+ """Remote runs controller for browsing agent run history.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import click
15
+ from rich.console import Group
16
+ from rich.text import Text
17
+
18
+ try: # pragma: no cover - optional dependency
19
+ import questionary
20
+ from questionary import Choice
21
+ except Exception: # pragma: no cover - optional dependency
22
+ questionary = None # type: ignore[assignment]
23
+ Choice = None # type: ignore[assignment]
24
+
25
+ from glaip_sdk.branding import (
26
+ ERROR_STYLE,
27
+ INFO_STYLE,
28
+ SUCCESS_STYLE,
29
+ WARNING_STYLE,
30
+ )
31
+ from glaip_sdk.cli.constants import DEFAULT_REMOTE_RUNS_PAGE_LIMIT
32
+ from glaip_sdk.cli.slash.tui.remote_runs_app import RemoteRunsTUICallbacks, run_remote_runs_textual
33
+ from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
34
+ from glaip_sdk.exceptions import (
35
+ AuthenticationError,
36
+ ForbiddenError,
37
+ NotFoundError,
38
+ TimeoutError,
39
+ ValidationError,
40
+ )
41
+ from glaip_sdk.rich_components import RemoteRunsTable
42
+ from glaip_sdk.utils.export import export_remote_transcript_jsonl
43
+ from glaip_sdk.utils.rendering import render_remote_sse_transcript
44
+
45
+ if TYPE_CHECKING: # pragma: no cover - type checking only
46
+ from glaip_sdk.cli.slash.session import SlashSession
47
+
48
+
49
+ class RemoteRunsController:
50
+ """Controller for browsing remote agent run history."""
51
+
52
+ def __init__(self, session: SlashSession) -> None:
53
+ """Initialize the remote runs controller.
54
+
55
+ Args:
56
+ session: The slash session context.
57
+ """
58
+ self.session = session
59
+ self.console = session.console
60
+ self.ctx = session.ctx
61
+
62
+ self._snapshot_notice_shown = False
63
+
64
+ def handle_runs_command(self, args: list[str]) -> bool:
65
+ """Handle the /runs command for browsing remote agent run history.
66
+
67
+ Args:
68
+ args: Command arguments (optional run_id for detail view).
69
+
70
+ Returns:
71
+ True to continue session.
72
+ """
73
+ current_agent = getattr(self.session, "_current_agent", None)
74
+ if not current_agent:
75
+ self.console.print(
76
+ f"[{WARNING_STYLE}]Open /agents and select an agent first to browse remote run history.[/]"
77
+ )
78
+ return self._continue_session()
79
+
80
+ agent_id = str(getattr(current_agent, "id", ""))
81
+ if not agent_id:
82
+ self.console.print(f"[{ERROR_STYLE}]Invalid agent context.[/]")
83
+ return self._continue_session()
84
+
85
+ if args:
86
+ run_id = args[0]
87
+ self.show_run_detail(agent_id, run_id)
88
+ return self._continue_session()
89
+
90
+ client = self._get_client_or_fail()
91
+ if not client:
92
+ return self._continue_session()
93
+
94
+ agent_name = getattr(current_agent, "name", "") or None
95
+ self.open_remote_runs_browser(client, agent_id, agent_name=agent_name)
96
+ return self._continue_session()
97
+
98
+ def open_remote_runs_browser(self, client: Any, agent_id: str, *, agent_name: str | None = None) -> None:
99
+ """Fetch and render the remote runs table for the current agent.
100
+
101
+ Args:
102
+ client: API client instance.
103
+ agent_id: UUID of the agent.
104
+ agent_name: Optional display name for the agent.
105
+ """
106
+ state = self._get_runs_state(agent_id)
107
+ runs_page = self._fetch_remote_runs_page(client, agent_id, state)
108
+ if runs_page is None:
109
+ return
110
+
111
+ state["page"] = runs_page.page
112
+ state["limit"] = runs_page.limit
113
+ cursor = state.get("cursor", 0)
114
+ if runs_page.data:
115
+ cursor = max(0, min(cursor, len(runs_page.data) - 1))
116
+ state["cursor"] = cursor
117
+
118
+ if self._should_use_textual_browser():
119
+ self._run_textual_browser(client, agent_id, runs_page, state, agent_name=agent_name)
120
+ return
121
+
122
+ if not self._snapshot_notice_shown:
123
+ self.console.print(
124
+ f"[{INFO_STYLE}]Interactive remote history requires a TTY. Showing the latest snapshot instead.[/]"
125
+ )
126
+ self._snapshot_notice_shown = True
127
+ self._render_runs_table(runs_page, agent_id, cursor_idx=cursor)
128
+
129
+ def _get_runs_state(self, agent_id: str) -> dict[str, Any]:
130
+ """Return the persisted pagination state for an agent.
131
+
132
+ Args:
133
+ agent_id: UUID of the agent.
134
+
135
+ Returns:
136
+ Dictionary with page, limit, and cursor state.
137
+ """
138
+ pagination_state = getattr(self.session, "_runs_pagination_state", {})
139
+ state = pagination_state.setdefault(
140
+ agent_id,
141
+ {"page": 1, "limit": DEFAULT_REMOTE_RUNS_PAGE_LIMIT, "cursor": 0},
142
+ )
143
+ state.setdefault("page", 1)
144
+ state.setdefault("limit", DEFAULT_REMOTE_RUNS_PAGE_LIMIT)
145
+ state.setdefault("cursor", 0)
146
+ return state
147
+
148
+ def _fetch_remote_runs_page(
149
+ self,
150
+ client: Any,
151
+ agent_id: str,
152
+ state: dict[str, Any],
153
+ *,
154
+ allow_reset: bool = True,
155
+ ) -> Any | None:
156
+ """Fetch a RunsPage while handling common error flows.
157
+
158
+ Args:
159
+ client: API client instance.
160
+ agent_id: UUID of the agent.
161
+ state: Pagination state dictionary.
162
+ allow_reset: Whether to reset pagination on validation errors.
163
+
164
+ Returns:
165
+ RunsPage instance or None on error.
166
+ """
167
+ try:
168
+ return client.agents.runs.list_runs(agent_id, limit=state["limit"], page=state["page"])
169
+ except AuthenticationError:
170
+ self.console.print(f"[{ERROR_STYLE}]Authentication failed. Run /login to refresh credentials.[/]")
171
+ except ForbiddenError as exc:
172
+ self.console.print(f"[{ERROR_STYLE}]Access denied: {exc}[/]")
173
+ except NotFoundError:
174
+ self.console.print(
175
+ f"[{WARNING_STYLE}]Agent not found or access revoked. Re-open /agents to select again.[/]"
176
+ )
177
+ pagination_state = getattr(self.session, "_runs_pagination_state", {})
178
+ pagination_state.pop(agent_id, None)
179
+ except TimeoutError:
180
+ ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else {}
181
+ timeout_seconds = ctx_obj.get("timeout", 30)
182
+ self.console.print(
183
+ f"[{WARNING_STYLE}]Remote history timed out after {timeout_seconds}s. Press Enter to retry.[/]"
184
+ )
185
+ except ValidationError:
186
+ if allow_reset:
187
+ self.console.print(
188
+ f"[{WARNING_STYLE}]Invalid pagination request (page {state['page']}, limit {state['limit']}). "
189
+ "Resetting to defaults.[/]"
190
+ )
191
+ state["page"] = 1
192
+ state["limit"] = DEFAULT_REMOTE_RUNS_PAGE_LIMIT
193
+ return self._fetch_remote_runs_page(client, agent_id, state, allow_reset=False)
194
+ self.console.print(f"[{ERROR_STYLE}]Pagination request rejected by backend.[/]")
195
+ except Exception as exc: # pragma: no cover - unexpected API failure
196
+ self.console.print(f"[{ERROR_STYLE}]Error fetching runs: {exc}[/]")
197
+ return None
198
+
199
+ def _build_runs_table_renderable(
200
+ self,
201
+ runs_page: Any,
202
+ agent_id: str,
203
+ *,
204
+ cursor_idx: int = 0,
205
+ ) -> Group:
206
+ """Build the Rich renderable for the runs table view."""
207
+ current_agent = getattr(self.session, "_current_agent", None)
208
+ agent_label = getattr(current_agent, "name", agent_id) if current_agent else agent_id
209
+ total_pages = 1
210
+ if runs_page.limit:
211
+ total_pages = max(1, math.ceil(runs_page.total / runs_page.limit))
212
+ header = (
213
+ f"[dim]Agent: {agent_label} ({agent_id}) · Limit={runs_page.limit} · "
214
+ f"Page {runs_page.page}/{total_pages} (use ←/→ to paginate)[/]"
215
+ )
216
+ renderables: list[Any] = [Text.from_markup(f"\n{header}")]
217
+
218
+ if runs_page.total == 0:
219
+ renderables.append(
220
+ Text.from_markup(f"[{WARNING_STYLE}]No remote runs yet. Trigger `/agents run` to create a run.[/]")
221
+ )
222
+ return Group(*renderables)
223
+
224
+ table = RemoteRunsTable(title="Remote Runs — ↑/↓ rows · ←/→ pages · q/Esc exit")
225
+ for idx, run in enumerate(runs_page.data):
226
+ run_type_str = run.run_type.title()
227
+ status_str = run.status.upper()
228
+ started_str = run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "—"
229
+ completed_str = run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "—"
230
+ duration_str = run.duration_formatted()
231
+ input_preview = run.input_preview()
232
+ table.add_run_row(
233
+ str(run.id),
234
+ run_type_str,
235
+ status_str,
236
+ started_str,
237
+ completed_str,
238
+ duration_str,
239
+ input_preview,
240
+ selected=idx == cursor_idx,
241
+ )
242
+
243
+ renderables.append(table)
244
+ renderables.append(Text.from_markup("[dim]Enter detail · e export JSONL · q/Esc exit[/]"))
245
+ return Group(*renderables)
246
+
247
+ def _render_runs_table(self, runs_page: Any, agent_id: str, *, cursor_idx: int = 0) -> None:
248
+ """Render runs table with pagination info."""
249
+ renderable = self._build_runs_table_renderable(
250
+ runs_page,
251
+ agent_id,
252
+ cursor_idx=cursor_idx,
253
+ )
254
+ self.console.print(renderable)
255
+
256
+ def _should_use_textual_browser(self) -> bool:
257
+ """Return True when Textual-based navigation can be used."""
258
+ ctx_obj = getattr(self.session.ctx, "obj", {})
259
+ interactive = bool(getattr(self.session, "_interactive", False))
260
+ if not interactive and isinstance(ctx_obj, dict) and ctx_obj.get("tty"):
261
+ interactive = True
262
+ if not interactive:
263
+ return False
264
+ try:
265
+ stdin_tty = sys.stdin.isatty()
266
+ stdout_tty = sys.stdout.isatty()
267
+ except Exception:
268
+ return False
269
+ return bool(stdin_tty and stdout_tty)
270
+
271
+ def _run_textual_browser(
272
+ self,
273
+ client: Any,
274
+ agent_id: str,
275
+ runs_page: Any,
276
+ state: dict[str, Any],
277
+ *,
278
+ agent_name: str | None = None,
279
+ ) -> None:
280
+ """Launch the Textual UI for browsing runs."""
281
+
282
+ def fetch_page(page: int, limit: int) -> Any | None:
283
+ fetch_state = {"page": page, "limit": limit}
284
+ return self._fetch_remote_runs_page(client, agent_id, fetch_state)
285
+
286
+ def fetch_detail(run_id: str) -> Any | None:
287
+ return self._load_run_detail(client, agent_id, run_id)
288
+
289
+ def export_run(run_id: str, detail: Any | None) -> bool:
290
+ return self.export_remote_run(agent_id, run_id, client, detail)
291
+
292
+ callbacks = RemoteRunsTUICallbacks(
293
+ fetch_page=fetch_page,
294
+ fetch_detail=fetch_detail,
295
+ export_run=export_run,
296
+ )
297
+ page, limit, cursor = run_remote_runs_textual(
298
+ runs_page,
299
+ state.get("cursor", 0),
300
+ callbacks,
301
+ agent_name=agent_name,
302
+ agent_id=agent_id,
303
+ )
304
+ state["page"] = page
305
+ state["limit"] = limit
306
+ state["cursor"] = cursor
307
+
308
+ def _load_run_detail(self, client: Any, agent_id: str, run_id: str) -> Any | None:
309
+ """Return detailed run payload, handling errors."""
310
+ try:
311
+ return client.agents.runs.get_run(agent_id, run_id)
312
+ except AuthenticationError:
313
+ self.console.print(f"[{ERROR_STYLE}]Authentication failed while loading run detail.[/]")
314
+ except NotFoundError:
315
+ self.console.print(f"[{WARNING_STYLE}]Run no longer exists. It may have been cleaned up.[/]")
316
+ except TimeoutError:
317
+ self.console.print(f"[{WARNING_STYLE}]Fetching remote transcript timed out. Try again.[/]")
318
+ except Exception as exc: # pragma: no cover - unexpected API failure
319
+ self.console.print(f"[{ERROR_STYLE}]Error fetching run detail: {exc}[/]")
320
+ return None
321
+
322
+ def show_run_detail(self, agent_id: str, run_id: str) -> Any | None:
323
+ """Show detailed run information with SSE events.
324
+
325
+ Args:
326
+ agent_id: UUID of the agent.
327
+ run_id: UUID of the run.
328
+
329
+ Returns:
330
+ RunWithOutput instance or None on error.
331
+ """
332
+ client = self._get_client_or_fail()
333
+ if not client:
334
+ return None
335
+
336
+ run_detail = self._load_run_detail(client, agent_id, run_id)
337
+ if run_detail is None:
338
+ return None
339
+
340
+ self.console.print()
341
+ render_remote_sse_transcript(run_detail, self.console, show_metadata=True)
342
+ return run_detail
343
+
344
+ def export_remote_run(
345
+ self,
346
+ agent_id: str,
347
+ run_id: str,
348
+ client: Any,
349
+ detail: Any | None,
350
+ ) -> bool:
351
+ """Export the selected remote run to JSONL.
352
+
353
+ Args:
354
+ agent_id: UUID of the agent.
355
+ run_id: UUID of the run.
356
+ client: API client instance.
357
+ detail: Cached RunWithOutput instance or None.
358
+ """
359
+ run_detail = detail or self._load_run_detail(client, agent_id, run_id)
360
+ if run_detail is None:
361
+ return False
362
+
363
+ destination = self._prompt_remote_export_path(run_id)
364
+ if destination is None:
365
+ self.console.print("[dim]Export cancelled.[/]")
366
+ return False
367
+
368
+ overwrite = False
369
+ if destination.exists():
370
+ if not click.confirm(f"{destination} already exists. Overwrite?", default=False):
371
+ self.console.print("[dim]Export cancelled.[/]")
372
+ return False
373
+ overwrite = True
374
+
375
+ try:
376
+ agent_label = self._resolve_agent_name()
377
+ exported = export_remote_transcript_jsonl(
378
+ run_detail,
379
+ destination,
380
+ overwrite=overwrite,
381
+ agent_name=agent_label,
382
+ )
383
+ self.console.print(f"[{SUCCESS_STYLE}]Remote transcript exported to {exported}[/]")
384
+ return True
385
+ except FileExistsError:
386
+ self.console.print(f"[{WARNING_STYLE}]File already exists and overwrite was disabled: {destination}[/]")
387
+ except Exception as exc: # pragma: no cover - unexpected IO failures
388
+ self.console.print(f"[{ERROR_STYLE}]Failed to export transcript: {exc}[/]")
389
+ return False
390
+
391
+ def _resolve_agent_name(self) -> str | None:
392
+ """Return the friendly agent name for the active session if available."""
393
+ current_agent = getattr(self.session, "_current_agent", None)
394
+ if current_agent is None:
395
+ return None
396
+ return getattr(current_agent, "name", None) or getattr(current_agent, "display_name", None)
397
+
398
+ def _prompt_remote_export_path(self, run_id: str) -> Path | None:
399
+ """Prompt the operator for an export destination.
400
+
401
+ Args:
402
+ run_id: UUID of the run.
403
+
404
+ Returns:
405
+ Path object or None if cancelled.
406
+ """
407
+ # Default to current working directory for exports (user can override via prompt).
408
+ # This is safe as the user explicitly initiates the export operation.
409
+ default_path = Path.cwd() / f"run_{run_id}.jsonl" # noqa: S108
410
+ try:
411
+ result = self._handle_questionary_export_prompt(default_path)
412
+ if result is not None:
413
+ return result
414
+ return None
415
+ except RuntimeError:
416
+ pass
417
+
418
+ is_terminal = bool(getattr(self.console, "is_terminal", False))
419
+ if not is_terminal:
420
+ return default_path
421
+
422
+ return self._prompt_cli_export_choice(default_path)
423
+
424
+ def _handle_questionary_export_prompt(self, default_path: Path) -> Path | None:
425
+ """Handle questionary-based export prompt with error handling.
426
+
427
+ Args:
428
+ default_path: Default export path.
429
+
430
+ Returns:
431
+ Selected path or None if cancelled.
432
+
433
+ Raises:
434
+ RuntimeError: If questionary prompt is unavailable or fails.
435
+ """
436
+ selection = self._prompt_questionary_export_choice(default_path)
437
+ if selection is None:
438
+ return None
439
+
440
+ choice, _ = selection
441
+ if choice == "default":
442
+ return default_path
443
+
444
+ if choice == "custom":
445
+ return self._prompt_questionary_custom_destination(default_path)
446
+
447
+ # choice == "cancel" or any other value
448
+ return None
449
+
450
+ def _prompt_questionary_export_choice(self, default_path: Path) -> tuple[str, Path | None] | None:
451
+ """Render the questionary export menu and return the selected action."""
452
+ display_path = self._format_export_display_path(default_path)
453
+ result = prompt_export_choice_questionary(default_path, display_path)
454
+ if result is None:
455
+ raise RuntimeError("Questionary prompt unavailable")
456
+ return result
457
+
458
+ def _prompt_questionary_custom_destination(self, default_path: Path) -> Path | None:
459
+ """Prompt for a custom destination using questionary path picker."""
460
+ if questionary is None:
461
+ raise RuntimeError("Questionary prompt unavailable")
462
+
463
+ try:
464
+ prompt = questionary.path(
465
+ "Destination path (Tab to autocomplete):",
466
+ default="",
467
+ only_directories=False,
468
+ )
469
+ response = questionary_safe_ask(prompt)
470
+ except Exception as exc: # pragma: no cover - questionary failure
471
+ raise RuntimeError("Questionary path prompt failed") from exc
472
+
473
+ return self._resolve_export_path(response, default_path, allow_default=False)
474
+
475
+ def _prompt_cli_export_choice(self, default_path: Path) -> Path | None:
476
+ """Render a click-based export menu when questionary isn't available."""
477
+ display_path = self._format_export_display_path(default_path)
478
+ self.console.print()
479
+ self.console.print("Remote export options:")
480
+ self.console.print(f" 1. Save to default ({display_path})")
481
+ self.console.print(" 2. Choose a different path")
482
+ self.console.print(" 3. Cancel")
483
+ try:
484
+ selection = click.prompt(
485
+ "Select an option",
486
+ type=click.Choice(["1", "2", "3"]),
487
+ default="1",
488
+ show_choices=False,
489
+ )
490
+ except (click.Abort, EOFError, KeyboardInterrupt):
491
+ return None
492
+
493
+ if selection == "1":
494
+ return default_path
495
+ if selection == "2":
496
+ return self._prompt_click_export_path(default_path)
497
+ return None
498
+
499
+ def _prompt_click_export_path(self, default_path: Path) -> Path | None:
500
+ """Prompt for a custom export destination using click only."""
501
+ default_ref = self._format_export_display_path(default_path)
502
+ self.console.print(
503
+ f"[dim]Enter a custom destination path. Leave blank to cancel. Default reference: {default_ref}[/]"
504
+ )
505
+ try:
506
+ response = click.prompt(
507
+ "Custom path",
508
+ default="",
509
+ show_default=False,
510
+ )
511
+ except (click.Abort, EOFError, KeyboardInterrupt):
512
+ return None
513
+
514
+ return self._resolve_export_path(response, default_path, allow_default=False)
515
+
516
+ def _resolve_export_path(self, response: str | None, default_path: Path, *, allow_default: bool) -> Path | None:
517
+ """Normalise export path input into a Path instance."""
518
+ value = (response or "").strip()
519
+ if not value:
520
+ return default_path if allow_default else None
521
+
522
+ candidate = Path(value).expanduser()
523
+ if not candidate.is_absolute():
524
+ # Resolve relative paths against current working directory.
525
+ # This is safe as the user explicitly provided this path via prompt.
526
+ candidate = Path.cwd() / candidate # noqa: S108
527
+ return candidate
528
+
529
+ def _format_export_display_path(self, path: Path) -> str:
530
+ """Return a user-friendly string for default export paths."""
531
+ cwd = Path.cwd()
532
+ try:
533
+ relative = path.relative_to(cwd)
534
+ return str(Path(".") / relative)
535
+ except ValueError:
536
+ pass
537
+
538
+ home = Path.home()
539
+ try:
540
+ relative_home = path.relative_to(home)
541
+ suffix = f"/{relative_home}" if relative_home.parts else ""
542
+ return f"~{suffix}"
543
+ except ValueError:
544
+ pass
545
+
546
+ return str(path)
547
+
548
+ def _get_client_or_fail(self) -> Any:
549
+ """Get client or handle failure and return None.
550
+
551
+ Returns:
552
+ API client instance or None on error.
553
+ """
554
+ try:
555
+ return self.session._get_client()
556
+ except click.ClickException as exc:
557
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
558
+ return None
559
+
560
+ def _continue_session(self) -> bool:
561
+ """Signal that the slash session should remain active.
562
+
563
+ Returns:
564
+ True to continue session.
565
+ """
566
+ return not getattr(self.session, "_should_exit", False)