glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__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 (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,125 @@
1
+ """Typed loader/saver for TUI preferences stored in config.yaml.
2
+
3
+ This module implements the TUI preferences store as defined in
4
+ `specs/architecture/tui-preferences-store/spec.md`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, cast
11
+
12
+ from glaip_sdk.cli.account_store import AccountStore, get_account_store
13
+
14
+ ThemeModeValue = Literal["auto", "light", "dark"]
15
+
16
+ _DEFAULT_THEME_MODE: ThemeModeValue = "auto"
17
+ _DEFAULT_LEADER = "ctrl+x"
18
+ _DEFAULT_MOUSE_CAPTURE = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TUISettings:
23
+ """Resolved TUI preferences from config.yaml."""
24
+
25
+ theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
26
+ theme_name: str | None = None
27
+ leader: str = _DEFAULT_LEADER
28
+ keybind_overrides: dict[str, str] = field(default_factory=dict)
29
+ mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
30
+
31
+
32
+ def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
33
+ """Load TUI preferences from the CLI config file."""
34
+ store = store or get_account_store()
35
+ config = store.load_config()
36
+
37
+ tui = _as_dict(config.get("tui"))
38
+ theme = _as_dict(tui.get("theme"))
39
+ mode = _coerce_theme_mode(theme.get("mode"))
40
+ name = _normalize_theme_name(theme.get("name"))
41
+
42
+ keybinds = _as_dict(tui.get("keybinds"))
43
+ leader = keybinds.get("leader")
44
+ if not isinstance(leader, str) or not leader.strip():
45
+ leader = _DEFAULT_LEADER
46
+
47
+ overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
48
+
49
+ mouse_capture = tui.get("mouse_capture")
50
+ if not isinstance(mouse_capture, bool):
51
+ mouse_capture = _DEFAULT_MOUSE_CAPTURE
52
+
53
+ return TUISettings(
54
+ theme_mode=mode,
55
+ theme_name=name,
56
+ leader=leader,
57
+ keybind_overrides=overrides,
58
+ mouse_capture=mouse_capture,
59
+ )
60
+
61
+
62
+ def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
63
+ """Update TUI preferences, preserving unrelated config keys."""
64
+ store = store or get_account_store()
65
+ config = store.load_config()
66
+
67
+ existing = _as_dict(config.get("tui"))
68
+ config["tui"] = _merge_dict(existing, patch)
69
+
70
+ store.save_config_updates(config)
71
+
72
+
73
+ def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
74
+ """Persist theme preferences in the tui.theme namespace."""
75
+ update_tui_settings(
76
+ {"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
77
+ store=store,
78
+ )
79
+
80
+
81
+ def _as_dict(value: Any) -> dict[str, Any]:
82
+ if isinstance(value, dict):
83
+ return dict(value)
84
+ return {}
85
+
86
+
87
+ def _coerce_theme_mode(value: Any) -> ThemeModeValue:
88
+ if isinstance(value, str):
89
+ lowered = value.strip().lower()
90
+ if lowered in {"auto", "light", "dark"}:
91
+ return cast(ThemeModeValue, lowered)
92
+ return _DEFAULT_THEME_MODE
93
+
94
+
95
+ def _normalize_theme_name(value: Any) -> str | None:
96
+ if not isinstance(value, str):
97
+ return None
98
+ cleaned = value.strip()
99
+ if not cleaned or cleaned.lower() == "default":
100
+ return None
101
+ return cleaned
102
+
103
+
104
+ def _serialize_theme_name(name: str | None) -> str:
105
+ if isinstance(name, str):
106
+ cleaned = name.strip()
107
+ if cleaned:
108
+ return cleaned
109
+ return "default"
110
+
111
+
112
+ def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
113
+ if not isinstance(value, dict):
114
+ return {}
115
+ return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
116
+
117
+
118
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
119
+ merged = dict(base)
120
+ for key, value in patch.items():
121
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
122
+ merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
123
+ else:
124
+ merged[key] = value
125
+ return merged
@@ -22,13 +22,19 @@ from rich.console import Console
22
22
  from glaip_sdk.branding import (
23
23
  ACCENT_STYLE,
24
24
  ERROR_STYLE,
25
+ INFO_STYLE,
25
26
  SUCCESS_STYLE,
26
27
  WARNING_STYLE,
27
28
  )
28
- from glaip_sdk.cli.commands.update import update_command
29
+ from glaip_sdk.cli.commands.update import (
30
+ PACKAGE_NAME,
31
+ _build_command_parts,
32
+ _build_manual_upgrade_command,
33
+ _is_uv_managed_environment,
34
+ update_command,
35
+ )
29
36
  from glaip_sdk.cli.constants import UPDATE_CHECK_ENABLED
30
- from glaip_sdk.cli.hints import format_command_hint
31
- from glaip_sdk.cli.utils import command_hint
37
+ from glaip_sdk.cli.hints import command_hint, format_command_hint
32
38
  from glaip_sdk.rich_components import AIPPanel
33
39
 
34
40
  FetchLatestVersion = Callable[[], str | None]
@@ -37,6 +43,7 @@ PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
37
43
  DEFAULT_TIMEOUT = 1.5 # seconds
38
44
 
39
45
  _LOGGER = logging.getLogger(__name__)
46
+ _UPDATE_VERSIONS_KEY = "_update_notifier_versions"
40
47
 
41
48
 
42
49
  def _parse_version(value: str) -> Version | None:
@@ -146,6 +153,7 @@ def maybe_notify_update(
146
153
  "- choose Update now or Skip to continue."
147
154
  )
148
155
  active_console.print(message)
156
+ _stash_update_versions(ctx, current_version, latest_version)
149
157
  _handle_update_decision(active_console, ctx)
150
158
  return
151
159
 
@@ -161,6 +169,7 @@ def maybe_notify_update(
161
169
  )
162
170
  active_console.print(panel)
163
171
  if should_prompt:
172
+ _stash_update_versions(ctx, current_version, latest_version)
164
173
  _handle_update_decision(active_console, ctx)
165
174
 
166
175
 
@@ -201,7 +210,13 @@ def _prompt_update_decision(console: Console) -> Literal["update", "skip"]:
201
210
 
202
211
  while True:
203
212
  try:
204
- response = console.input("Choice [1/2]: ").strip().lower()
213
+ raw_response = console.input("Choice [1/2]: ")
214
+ # Strip whitespace and convert to lowercase
215
+ response = raw_response.strip().lower()
216
+ # Remove any non-printable control characters (but keep printable chars)
217
+ # This handles cases where ANSI escape sequences or other control chars leak into input
218
+ response = "".join(char for char in response if char.isprintable() or char.isspace())
219
+ response = response.strip() # Strip again after filtering
205
220
  except (KeyboardInterrupt, EOFError):
206
221
  console.print(f"\n[{WARNING_STYLE}]Update skipped.[/]")
207
222
  return "skip"
@@ -214,19 +229,93 @@ def _prompt_update_decision(console: Console) -> Literal["update", "skip"]:
214
229
  console.print(f"[{ERROR_STYLE}]Please enter 1 to update now or 2 to skip.[/]")
215
230
 
216
231
 
232
+ def _get_manual_upgrade_command(is_uv: bool) -> str:
233
+ """Get the manual upgrade command for the given environment type.
234
+
235
+ Args:
236
+ is_uv: True if running in uv tool environment, False for pip environment.
237
+
238
+ Returns:
239
+ Manual upgrade command string.
240
+ """
241
+ try:
242
+ return _build_manual_upgrade_command(include_prerelease=False, is_uv=is_uv)
243
+ except Exception:
244
+ # Fallback: rebuild from shared command parts to avoid hardcoded strings.
245
+ try:
246
+ command_parts, _ = _build_command_parts(
247
+ package_name=PACKAGE_NAME,
248
+ is_uv=is_uv,
249
+ force_reinstall=False,
250
+ include_prerelease=False,
251
+ )
252
+ except Exception:
253
+ command_parts = (
254
+ ["uv", "tool", "install", "--upgrade", PACKAGE_NAME]
255
+ if is_uv
256
+ else ["pip", "install", "--upgrade", PACKAGE_NAME]
257
+ )
258
+ return " ".join(command_parts)
259
+
260
+
261
+ def _show_proactive_uv_guidance(console: Console, is_uv: bool) -> None:
262
+ """Show proactive guidance for uv environments before update attempt.
263
+
264
+ Args:
265
+ console: Rich console for output.
266
+ is_uv: True if running in uv tool environment.
267
+ """
268
+ if not is_uv:
269
+ return
270
+
271
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
272
+ console.print(
273
+ f"[{INFO_STYLE}]💡 Detected uv tool environment.[/] "
274
+ f"If automatic update fails, run: [{ACCENT_STYLE}]{manual_cmd}[/]"
275
+ )
276
+
277
+
278
+ def _show_error_guidance(console: Console, is_uv: bool) -> None:
279
+ """Show error guidance with correct manual command based on environment.
280
+
281
+ Args:
282
+ console: Rich console for output.
283
+ is_uv: True if running in uv tool environment.
284
+ """
285
+ try:
286
+ manual_cmd = _get_manual_upgrade_command(is_uv=is_uv)
287
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Run this command manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
288
+ except Exception as exc: # pragma: no cover - defensive guard
289
+ _LOGGER.debug("Failed to render update tip: %s", exc, exc_info=True)
290
+
291
+
217
292
  def _run_update_command(console: Console, ctx: Any) -> None:
218
293
  """Invoke the built-in update command and surface any errors."""
294
+ # Detect uv environment proactively before attempting update
295
+ is_uv = _is_uv_managed_environment()
296
+
297
+ # Provide proactive guidance for uv environments
298
+ # This helps users on older versions (e.g., 0.6.19) that don't have uv detection
299
+ # in their update command
300
+ _show_proactive_uv_guidance(console, is_uv)
301
+
219
302
  try:
220
303
  ctx.invoke(update_command)
221
304
  except click.ClickException as exc:
222
305
  exc.show()
223
306
  console.print(f"[{ERROR_STYLE}]Update command exited with an error.[/]")
307
+ _show_error_guidance(console, is_uv)
224
308
  except click.Abort:
225
309
  console.print(f"[{WARNING_STYLE}]Update aborted by user.[/]")
226
310
  except Exception as exc: # pragma: no cover - defensive guard
227
311
  console.print(f"[{ERROR_STYLE}]Unexpected error while running update: {exc}[/]")
312
+ # Also provide guidance for unexpected errors in uv environments
313
+ if is_uv:
314
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
315
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Try running manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
228
316
  else:
229
- _refresh_installed_version(console, ctx)
317
+ new_version = _refresh_installed_version(console, ctx)
318
+ _maybe_retry_update(console, ctx, new_version, is_uv)
230
319
 
231
320
 
232
321
  @contextmanager
@@ -247,7 +336,7 @@ def _suppress_library_logging(
247
336
  logger.setLevel(previous_level)
248
337
 
249
338
 
250
- def _refresh_installed_version(console: Console, ctx: Any) -> None:
339
+ def _refresh_installed_version(console: Console, ctx: Any) -> str | None:
251
340
  """Reload runtime metadata after an in-process upgrade."""
252
341
  new_version: str | None = None
253
342
  branding_module: Any | None = None
@@ -271,12 +360,13 @@ def _refresh_installed_version(console: Console, ctx: Any) -> None:
271
360
  try:
272
361
  branding_cls = getattr(branding_module, "AIPBranding", None) if branding_module else None
273
362
  session.refresh_branding(new_version, branding_cls=branding_cls)
274
- return
363
+ return new_version
275
364
  except Exception as exc: # pragma: no cover - defensive guard
276
365
  _LOGGER.debug("Failed to refresh active slash session: %s", exc, exc_info=True)
277
366
 
278
367
  if new_version:
279
368
  console.print(f"[{SUCCESS_STYLE}]CLI now running glaip-sdk {new_version}.[/]")
369
+ return new_version
280
370
 
281
371
 
282
372
  def _get_slash_session(ctx: Any) -> Any | None:
@@ -287,4 +377,122 @@ def _get_slash_session(ctx: Any) -> Any | None:
287
377
  return None
288
378
 
289
379
 
380
+ def _stash_update_versions(ctx: Any | None, current_version: str, latest_version: str) -> None:
381
+ """Persist update versions in the Click context for post-update checks."""
382
+ if ctx is None:
383
+ return
384
+ ctx_obj = getattr(ctx, "obj", None)
385
+ if isinstance(ctx_obj, dict):
386
+ ctx_obj[_UPDATE_VERSIONS_KEY] = {"current": current_version, "latest": latest_version}
387
+
388
+
389
+ def _get_update_versions(ctx: Any | None) -> tuple[str | None, str | None]:
390
+ """Return current/latest versions captured during the update prompt."""
391
+ if ctx is None:
392
+ return None, None
393
+ ctx_obj = getattr(ctx, "obj", None)
394
+ if not isinstance(ctx_obj, dict):
395
+ return None, None
396
+ payload = ctx_obj.get(_UPDATE_VERSIONS_KEY)
397
+ if not isinstance(payload, dict):
398
+ return None, None
399
+ current = payload.get("current")
400
+ latest = payload.get("latest")
401
+ return (
402
+ current if isinstance(current, str) else None,
403
+ latest if isinstance(latest, str) else None,
404
+ )
405
+
406
+
407
+ def _should_retry_update(
408
+ ctx: Any,
409
+ console: Console,
410
+ new_version: str | None,
411
+ ) -> tuple[str, str, Version, Version, Version] | None:
412
+ """Check if update retry is needed and return parsed versions if so."""
413
+ if ctx is None or not hasattr(ctx, "invoke"):
414
+ return None
415
+ if not _should_prompt_for_action(console, ctx):
416
+ return None
417
+
418
+ current_version, latest_version = _get_update_versions(ctx)
419
+ if not current_version or not latest_version or not new_version:
420
+ return None
421
+
422
+ current = _parse_version(current_version)
423
+ latest = _parse_version(latest_version)
424
+ installed = _parse_version(new_version)
425
+ if current is None or latest is None or installed is None:
426
+ return None
427
+
428
+ if installed >= latest:
429
+ return None
430
+
431
+ # Note: installed > current case is handled in _maybe_retry_update()
432
+ # to allow warning message to be printed before returning
433
+
434
+ return current_version, latest_version, current, latest, installed
435
+
436
+
437
+ def _handle_reinstall_error(console: Console, exc: Exception, is_uv: bool) -> None:
438
+ """Handle errors during reinstall attempt."""
439
+ if isinstance(exc, click.ClickException):
440
+ exc.show()
441
+ console.print(f"[{ERROR_STYLE}]Reinstall attempt failed.[/]")
442
+ _show_error_guidance(console, is_uv)
443
+ elif isinstance(exc, click.Abort):
444
+ console.print(f"[{WARNING_STYLE}]Reinstall skipped by user.[/]")
445
+ else:
446
+ console.print(f"[{ERROR_STYLE}]Unexpected error while reinstalling: {exc}[/]")
447
+ if is_uv:
448
+ manual_cmd = _get_manual_upgrade_command(is_uv=True)
449
+ console.print(f"[{INFO_STYLE}]💡 Tip:[/] Try running manually:\n [{ACCENT_STYLE}]{manual_cmd}[/]")
450
+
451
+
452
+ def _check_final_version(
453
+ console: Console, new_version: str | None, latest_version: str, latest: Version, is_uv: bool
454
+ ) -> None:
455
+ """Check and report final version status after reinstall."""
456
+ installed = _parse_version(new_version) if isinstance(new_version, str) else None
457
+ if installed is None or installed < latest:
458
+ console.print(
459
+ f"[{WARNING_STYLE}]Still on {new_version}. Your package index may not have {latest_version} yet.[/]"
460
+ )
461
+ if is_uv:
462
+ console.print(
463
+ f"[{INFO_STYLE}]💡 Tip:[/] If you need PyPI immediately, set "
464
+ f"[{ACCENT_STYLE}]UV_INDEX_URL=https://pypi.org/simple[/]."
465
+ )
466
+
467
+
468
+ def _maybe_retry_update(
469
+ console: Console,
470
+ ctx: Any,
471
+ new_version: str | None,
472
+ is_uv: bool,
473
+ ) -> None:
474
+ """Retry once with reinstall when the update did not advance versions."""
475
+ versions = _should_retry_update(ctx, console, new_version)
476
+ if versions is None:
477
+ return
478
+
479
+ current_version, latest_version, current, latest, installed = versions
480
+ if installed > current:
481
+ console.print(f"[{WARNING_STYLE}]Update installed {new_version}, but {latest_version} is still available.[/]")
482
+ return
483
+
484
+ console.print(
485
+ f"[{WARNING_STYLE}]Update completed but version stayed at {new_version}. Retrying with reinstall...[/]"
486
+ )
487
+
488
+ try:
489
+ ctx.invoke(update_command, force_reinstall=True)
490
+ except Exception as exc:
491
+ _handle_reinstall_error(console, exc, is_uv)
492
+ return
493
+
494
+ new_version = _refresh_installed_version(console, ctx)
495
+ _check_final_version(console, new_version, latest_version, latest, is_uv)
496
+
497
+
290
498
  __all__ = ["maybe_notify_update"]
@@ -13,7 +13,7 @@ from typing import Any
13
13
 
14
14
  import click
15
15
 
16
- from glaip_sdk.cli.utils import handle_best_effort_check
16
+ from glaip_sdk.cli.core.context import handle_best_effort_check
17
17
  from glaip_sdk.utils.validation import (
18
18
  coerce_timeout,
19
19
  validate_agent_instruction,
@@ -7,5 +7,6 @@ Authors:
7
7
 
8
8
  from glaip_sdk.client.agent_runs import AgentRunsClient
9
9
  from glaip_sdk.client.main import Client
10
+ from glaip_sdk.client.schedules import AgentScheduleManager, ScheduleClient
10
11
 
11
- __all__ = ["AgentRunsClient", "Client"]
12
+ __all__ = ["AgentRunsClient", "AgentScheduleManager", "Client", "ScheduleClient"]
@@ -0,0 +1,89 @@
1
+ """Schedule request payload builders for AIP SDK.
2
+
3
+ Authors:
4
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from glaip_sdk.models.schedule import ScheduleConfig
11
+
12
+
13
+ @dataclass
14
+ class ScheduleListParams:
15
+ """Parameters for listing schedules.
16
+
17
+ Args:
18
+ limit: Maximum number of schedules to return (1-100, default 50)
19
+ page: Page number for pagination (default 1)
20
+ agent_id: Filter schedules by agent ID
21
+ """
22
+
23
+ limit: int | None = None
24
+ page: int | None = None
25
+ agent_id: str | None = None
26
+
27
+ def to_query_params(self) -> dict[str, Any]:
28
+ """Convert to query parameters dictionary.
29
+
30
+ Returns:
31
+ Dictionary of non-None parameters for the API request
32
+ """
33
+ params: dict[str, Any] = {}
34
+ if self.limit is not None:
35
+ params["limit"] = self.limit
36
+ if self.page is not None:
37
+ params["page"] = self.page
38
+ if self.agent_id is not None:
39
+ params["agent_id"] = self.agent_id
40
+ return params
41
+
42
+
43
+ def normalize_schedule(
44
+ schedule: ScheduleConfig | dict[str, str] | str | None,
45
+ ) -> dict[str, str] | None:
46
+ """Normalize schedule input to a dictionary for API requests.
47
+
48
+ Accepts multiple input formats for user convenience:
49
+ - ScheduleConfig: Pydantic model with cron fields
50
+ - dict: Dictionary with cron fields (minute, hour, etc.)
51
+ - str: Cron string like "0 9 * * 1-5"
52
+ - None: Returns None
53
+
54
+ Args:
55
+ schedule: Schedule in various formats
56
+
57
+ Returns:
58
+ Dictionary suitable for API request or None
59
+
60
+ Raises:
61
+ ValueError: If cron string format is invalid
62
+ TypeError: If schedule is an unsupported type
63
+
64
+ Examples:
65
+ >>> normalize_schedule(ScheduleConfig(minute="0", hour="9"))
66
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '*'}
67
+
68
+ >>> normalize_schedule({"minute": "0", "hour": "9"})
69
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '*'}
70
+
71
+ >>> normalize_schedule("0 9 * * 1-5")
72
+ {'minute': '0', 'hour': '9', 'day_of_month': '*', 'month': '*', 'day_of_week': '1-5'}
73
+ """
74
+ if schedule is None:
75
+ return None
76
+
77
+ if isinstance(schedule, ScheduleConfig):
78
+ return schedule.model_dump()
79
+
80
+ if isinstance(schedule, dict):
81
+ # Validate and merge with defaults
82
+ return ScheduleConfig(**schedule).model_dump()
83
+
84
+ if isinstance(schedule, str):
85
+ # Parse cron string
86
+ config = ScheduleConfig.from_cron_string(schedule)
87
+ return config.model_dump()
88
+
89
+ raise TypeError(f"schedule must be ScheduleConfig, dict, or str, got {type(schedule).__name__}")
@@ -13,11 +13,15 @@ from collections.abc import AsyncGenerator, Callable, Iterator, Mapping
13
13
  from contextlib import asynccontextmanager
14
14
  from os import PathLike
15
15
  from pathlib import Path
16
- from typing import Any, BinaryIO
16
+ from typing import TYPE_CHECKING, Any, BinaryIO
17
+
18
+ if TYPE_CHECKING:
19
+ from glaip_sdk.client.schedules import ScheduleClient
20
+ from glaip_sdk.hitl.remote import RemoteHITLHandler
17
21
 
18
22
  import httpx
19
23
  from glaip_sdk.agents import Agent
20
- from glaip_sdk.client._agent_payloads import (
24
+ from glaip_sdk.client.payloads.agent import (
21
25
  AgentCreateRequest,
22
26
  AgentListParams,
23
27
  AgentListResult,
@@ -264,6 +268,7 @@ class AgentClient(BaseClient):
264
268
  self._tool_client: ToolClient | None = None
265
269
  self._mcp_client: MCPClient | None = None
266
270
  self._runs_client: AgentRunsClient | None = None
271
+ self._schedule_client: ScheduleClient | None = None
267
272
 
268
273
  def list_agents(
269
274
  self,
@@ -411,6 +416,7 @@ class AgentClient(BaseClient):
411
416
  timeout_seconds: float,
412
417
  agent_name: str | None,
413
418
  meta: dict[str, Any],
419
+ hitl_handler: "RemoteHITLHandler | None" = None,
414
420
  ) -> tuple[str, dict[str, Any], float | None, float | None]:
415
421
  """Process stream events from an HTTP response.
416
422
 
@@ -420,6 +426,7 @@ class AgentClient(BaseClient):
420
426
  timeout_seconds: Timeout in seconds.
421
427
  agent_name: Optional agent name.
422
428
  meta: Metadata dictionary.
429
+ hitl_handler: Optional HITL handler for approval callbacks.
423
430
 
424
431
  Returns:
425
432
  Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
@@ -431,6 +438,7 @@ class AgentClient(BaseClient):
431
438
  timeout_seconds,
432
439
  agent_name,
433
440
  meta,
441
+ hitl_handler=hitl_handler,
434
442
  )
435
443
 
436
444
  def _finalize_renderer(
@@ -453,13 +461,11 @@ class AgentClient(BaseClient):
453
461
  Returns:
454
462
  Final text string.
455
463
  """
464
+ from glaip_sdk.client.run_rendering import finalize_render_manager # noqa: PLC0415
465
+
456
466
  manager = self._get_renderer_manager()
457
- return manager.finalize_renderer(
458
- renderer,
459
- final_text,
460
- stats_usage,
461
- started_monotonic,
462
- finished_monotonic,
467
+ return finalize_render_manager(
468
+ manager, renderer, final_text, stats_usage, started_monotonic, finished_monotonic
463
469
  )
464
470
 
465
471
  def _get_tool_client(self) -> ToolClient:
@@ -482,6 +488,20 @@ class AgentClient(BaseClient):
482
488
  self._mcp_client = MCPClient(parent_client=self)
483
489
  return self._mcp_client
484
490
 
491
+ @property
492
+ def schedules(self) -> "ScheduleClient":
493
+ """Get or create the schedule client instance.
494
+
495
+ Returns:
496
+ ScheduleClient instance.
497
+ """
498
+ if self._schedule_client is None:
499
+ # Import here to avoid circular import
500
+ from glaip_sdk.client.schedules import ScheduleClient # noqa: PLC0415
501
+
502
+ self._schedule_client = ScheduleClient(parent_client=self)
503
+ return self._schedule_client
504
+
485
505
  def _normalise_reference_entry(
486
506
  self,
487
507
  entry: Any,
@@ -1096,6 +1116,7 @@ class AgentClient(BaseClient):
1096
1116
  *,
1097
1117
  renderer: RichStreamRenderer | str | None = "auto",
1098
1118
  runtime_config: dict[str, Any] | None = None,
1119
+ hitl_handler: "RemoteHITLHandler | None" = None,
1099
1120
  **kwargs,
1100
1121
  ) -> str:
1101
1122
  """Run an agent with a message, streaming via a renderer.
@@ -1113,6 +1134,8 @@ class AgentClient(BaseClient):
1113
1134
  "mcp_configs": {"mcp-id": {"setting": "on"}},
1114
1135
  "agent_config": {"planning": True},
1115
1136
  }
1137
+ hitl_handler: Optional RemoteHITLHandler for approval callbacks.
1138
+ Set GLAIP_HITL_AUTO_APPROVE=true for auto-approval without handler.
1116
1139
  **kwargs: Additional arguments to pass to the run API.
1117
1140
 
1118
1141
  Returns:
@@ -1169,6 +1192,7 @@ class AgentClient(BaseClient):
1169
1192
  timeout_seconds,
1170
1193
  agent_name,
1171
1194
  meta,
1195
+ hitl_handler=hitl_handler,
1172
1196
  )
1173
1197
 
1174
1198
  except KeyboardInterrupt:
@@ -1185,6 +1209,13 @@ class AgentClient(BaseClient):
1185
1209
  if multipart_data:
1186
1210
  multipart_data.close()
1187
1211
 
1212
+ # Wait for pending HITL decisions before returning
1213
+ if hitl_handler and hasattr(hitl_handler, "wait_for_pending_decisions"):
1214
+ try:
1215
+ hitl_handler.wait_for_pending_decisions(timeout=30)
1216
+ except Exception as e:
1217
+ logger.warning(f"Error waiting for HITL decisions: {e}")
1218
+
1188
1219
  return self._finalize_renderer(
1189
1220
  r,
1190
1221
  final_text,
@@ -1266,6 +1297,7 @@ class AgentClient(BaseClient):
1266
1297
  *,
1267
1298
  request_timeout: float | None = None,
1268
1299
  runtime_config: dict[str, Any] | None = None,
1300
+ hitl_handler: "RemoteHITLHandler | None" = None,
1269
1301
  **kwargs,
1270
1302
  ) -> AsyncGenerator[dict, None]:
1271
1303
  """Async run an agent with a message, yielding streaming JSON chunks.
@@ -1282,16 +1314,26 @@ class AgentClient(BaseClient):
1282
1314
  "mcp_configs": {"mcp-id": {"setting": "on"}},
1283
1315
  "agent_config": {"planning": True},
1284
1316
  }
1317
+ hitl_handler: Optional HITL handler for remote approval requests.
1318
+ Note: Async HITL support is currently deferred. This parameter
1319
+ is accepted for API consistency but will raise NotImplementedError
1320
+ if provided.
1285
1321
  **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
1286
1322
 
1287
1323
  Yields:
1288
1324
  Dictionary containing parsed JSON chunks from the streaming response
1289
1325
 
1290
1326
  Raises:
1327
+ NotImplementedError: If hitl_handler is provided (async HITL not yet supported)
1291
1328
  AgentTimeoutError: When agent execution times out
1292
1329
  httpx.TimeoutException: When general timeout occurs
1293
1330
  Exception: For other unexpected errors
1294
1331
  """
1332
+ if hitl_handler is not None:
1333
+ raise NotImplementedError(
1334
+ "Async HITL support is currently deferred. "
1335
+ "Please use the synchronous run_agent() method with hitl_handler."
1336
+ )
1295
1337
  # Include runtime_config in kwargs only when caller hasn't already provided it
1296
1338
  if runtime_config is not None and "runtime_config" not in kwargs:
1297
1339
  kwargs["runtime_config"] = runtime_config