glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.7__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 (116) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +156 -32
  3. glaip_sdk/cli/auth.py +14 -8
  4. glaip_sdk/cli/commands/accounts.py +1 -1
  5. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  6. glaip_sdk/cli/commands/agents/_common.py +561 -0
  7. glaip_sdk/cli/commands/agents/create.py +151 -0
  8. glaip_sdk/cli/commands/agents/delete.py +64 -0
  9. glaip_sdk/cli/commands/agents/get.py +89 -0
  10. glaip_sdk/cli/commands/agents/list.py +129 -0
  11. glaip_sdk/cli/commands/agents/run.py +264 -0
  12. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  13. glaip_sdk/cli/commands/agents/update.py +112 -0
  14. glaip_sdk/cli/commands/common_config.py +15 -12
  15. glaip_sdk/cli/commands/configure.py +2 -3
  16. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  17. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  18. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  19. glaip_sdk/cli/commands/mcps/create.py +152 -0
  20. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  21. glaip_sdk/cli/commands/mcps/get.py +212 -0
  22. glaip_sdk/cli/commands/mcps/list.py +69 -0
  23. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  24. glaip_sdk/cli/commands/mcps/update.py +190 -0
  25. glaip_sdk/cli/commands/models.py +2 -4
  26. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  27. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  28. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  29. glaip_sdk/cli/commands/tools/_common.py +80 -0
  30. glaip_sdk/cli/commands/tools/create.py +228 -0
  31. glaip_sdk/cli/commands/tools/delete.py +61 -0
  32. glaip_sdk/cli/commands/tools/get.py +103 -0
  33. glaip_sdk/cli/commands/tools/list.py +69 -0
  34. glaip_sdk/cli/commands/tools/script.py +49 -0
  35. glaip_sdk/cli/commands/tools/update.py +102 -0
  36. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  37. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  38. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  39. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  40. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  41. glaip_sdk/cli/commands/update.py +163 -17
  42. glaip_sdk/cli/core/output.py +12 -7
  43. glaip_sdk/cli/entrypoint.py +20 -0
  44. glaip_sdk/cli/main.py +127 -39
  45. glaip_sdk/cli/pager.py +3 -3
  46. glaip_sdk/cli/resolution.py +2 -1
  47. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  48. glaip_sdk/cli/slash/agent_session.py +5 -2
  49. glaip_sdk/cli/slash/prompt.py +11 -0
  50. glaip_sdk/cli/slash/remote_runs_controller.py +1 -1
  51. glaip_sdk/cli/slash/session.py +58 -13
  52. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  53. glaip_sdk/cli/slash/tui/accounts.tcss +7 -5
  54. glaip_sdk/cli/slash/tui/accounts_app.py +70 -9
  55. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  56. glaip_sdk/cli/slash/tui/context.py +59 -0
  57. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  58. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  59. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  60. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  61. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  62. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  63. glaip_sdk/cli/slash/tui/toast.py +123 -0
  64. glaip_sdk/cli/transcript/history.py +1 -1
  65. glaip_sdk/cli/transcript/viewer.py +5 -3
  66. glaip_sdk/cli/update_notifier.py +215 -7
  67. glaip_sdk/cli/validators.py +1 -1
  68. glaip_sdk/client/__init__.py +2 -1
  69. glaip_sdk/client/_schedule_payloads.py +89 -0
  70. glaip_sdk/client/agents.py +50 -8
  71. glaip_sdk/client/hitl.py +136 -0
  72. glaip_sdk/client/main.py +7 -1
  73. glaip_sdk/client/mcps.py +44 -13
  74. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  75. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  76. glaip_sdk/client/payloads/agent/responses.py +43 -0
  77. glaip_sdk/client/run_rendering.py +367 -3
  78. glaip_sdk/client/schedules.py +439 -0
  79. glaip_sdk/client/tools.py +57 -26
  80. glaip_sdk/hitl/__init__.py +48 -0
  81. glaip_sdk/hitl/base.py +64 -0
  82. glaip_sdk/hitl/callback.py +43 -0
  83. glaip_sdk/hitl/local.py +121 -0
  84. glaip_sdk/hitl/remote.py +523 -0
  85. glaip_sdk/models/__init__.py +17 -0
  86. glaip_sdk/models/agent_runs.py +2 -1
  87. glaip_sdk/models/schedule.py +224 -0
  88. glaip_sdk/registry/tool.py +273 -59
  89. glaip_sdk/runner/__init__.py +20 -3
  90. glaip_sdk/runner/deps.py +5 -8
  91. glaip_sdk/runner/langgraph.py +317 -42
  92. glaip_sdk/runner/logging_config.py +77 -0
  93. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  94. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  95. glaip_sdk/schedules/__init__.py +22 -0
  96. glaip_sdk/schedules/base.py +291 -0
  97. glaip_sdk/tools/base.py +44 -11
  98. glaip_sdk/utils/__init__.py +1 -0
  99. glaip_sdk/utils/bundler.py +138 -2
  100. glaip_sdk/utils/import_resolver.py +43 -11
  101. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  102. glaip_sdk/utils/runtime_config.py +15 -12
  103. glaip_sdk/utils/sync.py +31 -11
  104. glaip_sdk/utils/tool_detection.py +274 -6
  105. glaip_sdk/utils/tool_storage_provider.py +140 -0
  106. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +47 -37
  107. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  108. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  109. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  110. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  111. glaip_sdk/cli/commands/agents.py +0 -1509
  112. glaip_sdk/cli/commands/mcps.py +0 -1356
  113. glaip_sdk/cli/commands/tools.py +0 -576
  114. glaip_sdk/cli/utils.py +0 -263
  115. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  116. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,77 @@
1
+ """Logging configuration for CLI to suppress noisy dependency warnings.
2
+
3
+ This module provides centralized logging suppression for optional dependencies
4
+ that emit noisy warnings during CLI usage. Warnings are suppressed by default
5
+ but can be shown using GLAIP_LOG_LEVEL=DEBUG.
6
+
7
+ Authors:
8
+ Raymond Christopher (raymond.christopher@gdplabs.id)
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import warnings
14
+
15
+ NOISY_LOGGERS = ["transformers", "gllm_privacy", "google.cloud.aiplatform"]
16
+
17
+
18
+ class NameFilter(logging.Filter):
19
+ """Filter logs by logger name prefix."""
20
+
21
+ def __init__(self, prefixes: list[str]) -> None:
22
+ """Initialize filter with logger name prefixes to suppress.
23
+
24
+ Args:
25
+ prefixes: List of logger name prefixes to filter out.
26
+ """
27
+ super().__init__()
28
+ self.prefixes = prefixes
29
+
30
+ def filter(self, record: logging.LogRecord) -> bool:
31
+ """Filter log records by name prefix.
32
+
33
+ Args:
34
+ record: Log record to filter.
35
+
36
+ Returns:
37
+ False if record should be suppressed, True otherwise.
38
+ """
39
+ return not any(record.name.startswith(p) for p in self.prefixes)
40
+
41
+
42
+ def setup_cli_logging() -> None:
43
+ """Suppress INFO from noisy third-party libraries.
44
+
45
+ Use GLAIP_LOG_LEVEL=DEBUG to see all warnings.
46
+ This function is idempotent - calling it multiple times is safe.
47
+ """
48
+ # Check env level FIRST before any suppression
49
+ env_level = os.getenv("GLAIP_LOG_LEVEL", "").upper()
50
+ is_debug = env_level == "DEBUG"
51
+
52
+ if is_debug:
53
+ # Debug mode: show everything, no suppression
54
+ if env_level and hasattr(logging, env_level):
55
+ logging.basicConfig(level=getattr(logging, env_level))
56
+ return
57
+
58
+ # Default mode: suppress noisy warnings
59
+ if env_level and hasattr(logging, env_level):
60
+ logging.basicConfig(level=getattr(logging, env_level))
61
+
62
+ # Add handler filter to suppress by name prefix (handles child loggers)
63
+ # Check if filter already exists to ensure idempotency
64
+ root_logger = logging.getLogger()
65
+ has_name_filter = any(isinstance(f, NameFilter) for h in root_logger.handlers for f in h.filters)
66
+
67
+ if not has_name_filter:
68
+ handler = logging.StreamHandler()
69
+ handler.addFilter(NameFilter(NOISY_LOGGERS))
70
+ root_logger.addHandler(handler)
71
+
72
+ # Suppress FutureWarning for GCS (idempotent - multiple calls are safe)
73
+ warnings.filterwarnings(
74
+ "ignore",
75
+ category=FutureWarning,
76
+ message=r".*google-cloud-storage.*",
77
+ )
@@ -9,10 +9,9 @@ Authors:
9
9
 
10
10
  from typing import Any
11
11
 
12
- from gllm_core.utils import LoggerManager
13
-
14
12
  from glaip_sdk.runner.mcp_adapter.base_mcp_adapter import BaseMCPAdapter
15
13
  from glaip_sdk.runner.mcp_adapter.mcp_config_builder import MCPConfigBuilder
14
+ from gllm_core.utils import LoggerManager
16
15
 
17
16
  logger = LoggerManager().get_logger(__name__)
18
17
 
@@ -115,17 +114,117 @@ class LangChainMCPAdapter(BaseMCPAdapter):
115
114
  if "server_url" in config and "url" not in config:
116
115
  config["url"] = config.pop("server_url")
117
116
 
117
+ self._validate_converted_config(
118
+ mcp_name=mcp.name,
119
+ transport=mcp.transport,
120
+ config=config,
121
+ )
122
+
118
123
  # Convert authentication to headers using MCPConfigBuilder
124
+ # Merge with existing headers (auth headers take precedence for conflicts)
119
125
  if hasattr(mcp, "authentication") and mcp.authentication:
120
- headers = MCPConfigBuilder.build_headers_from_auth(mcp.authentication)
121
- if headers:
122
- config["headers"] = headers
126
+ auth_headers = MCPConfigBuilder.build_headers_from_auth(mcp.authentication)
127
+ if auth_headers:
128
+ existing_headers = config.get("headers", {})
129
+ config["headers"] = {**existing_headers, **auth_headers}
123
130
  else:
124
131
  logger.warning("Failed to build headers from authentication for MCP '%s'", mcp.name)
125
132
 
126
133
  logger.debug("Converted MCP '%s' with transport '%s'", mcp.name, mcp.transport)
127
134
  return config
128
135
 
136
+ def _validate_converted_config(self, mcp_name: str, transport: str, config: dict[str, Any]) -> None:
137
+ """Validate converted MCP config matches aip-agents schema expectations.
138
+
139
+ This method performs transport-specific validation after the glaip-sdk MCP
140
+ has been converted into the `aip-agents` `mcp_config` dictionary.
141
+
142
+ Args:
143
+ mcp_name: The MCP server name.
144
+ transport: The MCP transport type.
145
+ config: The converted MCP configuration dictionary.
146
+
147
+ Raises:
148
+ ValueError: If the configuration is invalid for the chosen transport.
149
+ """
150
+ self._validate_transport_config(mcp_name, transport)
151
+ if transport in ("http", "sse"):
152
+ self._validate_http_sse_config(
153
+ mcp_name=mcp_name,
154
+ transport=transport,
155
+ config=config,
156
+ )
157
+ return
158
+ if transport == "stdio":
159
+ self._validate_stdio_config(
160
+ mcp_name=mcp_name,
161
+ config=config,
162
+ )
163
+
164
+ def _validate_transport_config(self, mcp_name: str, transport: str) -> None:
165
+ """Validate that the MCP transport is supported by local mode.
166
+
167
+ Args:
168
+ mcp_name: The MCP server name.
169
+ transport: The MCP transport type.
170
+
171
+ Raises:
172
+ ValueError: If the transport is not one of 'http', 'sse', or 'stdio'.
173
+ """
174
+ if transport not in ("http", "sse", "stdio"):
175
+ raise ValueError(
176
+ f"Invalid MCP config for '{mcp_name}': transport must be one of "
177
+ f"'http', 'sse', or 'stdio'. Got: {transport!r}"
178
+ )
179
+
180
+ def _validate_http_sse_config(self, mcp_name: str, transport: str, config: dict[str, Any]) -> None:
181
+ """Validate http/sse config has a usable URL.
182
+
183
+ Args:
184
+ mcp_name: The MCP server name.
185
+ transport: The MCP transport type ('http' or 'sse').
186
+ config: The converted MCP configuration dictionary.
187
+
188
+ Raises:
189
+ ValueError: If url is missing/empty or does not use http(s) scheme.
190
+ """
191
+ url = config.get("url")
192
+ if not isinstance(url, str) or not url:
193
+ raise ValueError(
194
+ f"Invalid MCP config for '{mcp_name}': transport='{transport}' "
195
+ "requires config['url'] as a non-empty string."
196
+ )
197
+
198
+ if not (url.startswith("http://") or url.startswith("https://")):
199
+ raise ValueError(
200
+ f"Invalid MCP config for '{mcp_name}': config['url'] must start with "
201
+ f"'http://' or 'https://'. Got: {url!r}"
202
+ )
203
+
204
+ def _validate_stdio_config(self, mcp_name: str, config: dict[str, Any]) -> None:
205
+ """Validate stdio config has a usable command and optional args list.
206
+
207
+ Args:
208
+ mcp_name: The MCP server name.
209
+ config: The converted MCP configuration dictionary.
210
+
211
+ Raises:
212
+ ValueError: If command is missing/empty or args is not a list of strings.
213
+ """
214
+ command = config.get("command")
215
+ if not isinstance(command, str) or not command:
216
+ raise ValueError(
217
+ f"Invalid MCP config for '{mcp_name}': transport='stdio' "
218
+ "requires config['command'] as a non-empty string."
219
+ )
220
+
221
+ args = config.get("args")
222
+ if args is not None and (not isinstance(args, list) or any(not isinstance(x, str) for x in args)):
223
+ raise ValueError(
224
+ f"Invalid MCP config for '{mcp_name}': transport='stdio' expects "
225
+ "config['args'] to be a list[str] if provided."
226
+ )
227
+
129
228
  def _is_platform_mcp(self, ref: Any) -> bool:
130
229
  """Check if ref is platform-specific (not supported locally)."""
131
230
  # MCP.from_native() or MCP.from_id() instances
@@ -15,6 +15,9 @@ from glaip_sdk.runner.tool_adapter.base_tool_adapter import BaseToolAdapter
15
15
 
16
16
  logger = LoggerManager().get_logger(__name__)
17
17
 
18
+ # Constant for unknown tool name placeholder
19
+ _UNKNOWN_TOOL_NAME = "<unknown>"
20
+
18
21
 
19
22
  class LangChainToolAdapter(BaseToolAdapter):
20
23
  """Adapts glaip-sdk tools to LangChain BaseTool format for aip-agents.
@@ -71,8 +74,30 @@ class LangChainToolAdapter(BaseToolAdapter):
71
74
  if self._is_langchain_tool(tool_ref):
72
75
  return self._instantiate_langchain_tool(tool_ref)
73
76
 
74
- # 3. Platform tools (not supported)
77
+ # 3. Native tools with discovered class
75
78
  if self._is_platform_tool(tool_ref):
79
+ # Try to discover local implementation for native tool
80
+ from glaip_sdk.utils.tool_detection import ( # noqa: PLC0415
81
+ find_aip_agents_tool_class,
82
+ get_tool_name,
83
+ )
84
+
85
+ # Get tool name from reference
86
+ tool_name = get_tool_name(tool_ref) if not isinstance(tool_ref, str) else tool_ref
87
+
88
+ if tool_name:
89
+ discovered_class = find_aip_agents_tool_class(tool_name)
90
+ if discovered_class:
91
+ logger.info("Instantiating native tool locally: %s", tool_name)
92
+ try:
93
+ return discovered_class()
94
+ except TypeError as exc:
95
+ raise ValueError(
96
+ f"Could not instantiate native tool '{tool_name}'. "
97
+ "Ensure it has a zero-argument constructor or adjust the instantiation logic."
98
+ ) from exc
99
+
100
+ # If no local class found, raise platform tool error
76
101
  raise ValueError(self._get_platform_tool_error(tool_ref))
77
102
 
78
103
  # 4. Unknown type
@@ -81,6 +106,15 @@ class LangChainToolAdapter(BaseToolAdapter):
81
106
  "Local mode only supports LangChain BaseTool classes/instances."
82
107
  )
83
108
 
109
+ def _has_explicit_attr(self, ref: Any, attr: str) -> bool:
110
+ """Check if attribute is explicitly set on the object.
111
+
112
+ This avoids false positives from objects like MagicMock, where hasattr()
113
+ can return True even if the attribute was never set.
114
+ """
115
+ ref_dict = getattr(ref, "__dict__", None)
116
+ return isinstance(ref_dict, dict) and attr in ref_dict
117
+
84
118
  def _is_tool_wrapper(self, ref: Any) -> bool:
85
119
  """Check if ref is a Tool.from_langchain() wrapper.
86
120
 
@@ -90,7 +124,13 @@ class LangChainToolAdapter(BaseToolAdapter):
90
124
  Returns:
91
125
  True if ref is a Tool.from_langchain() wrapper.
92
126
  """
93
- return hasattr(ref, "langchain_tool") and hasattr(ref, "id") and hasattr(ref, "name")
127
+ if self._has_explicit_attr(ref, "langchain_tool") and hasattr(ref, "id") and hasattr(ref, "name"):
128
+ return True
129
+
130
+ if self._has_explicit_attr(ref, "tool_class"):
131
+ return getattr(ref, "tool_class", None) is not None
132
+
133
+ return False
94
134
 
95
135
  def _extract_from_wrapper(self, wrapper: Any) -> Any:
96
136
  """Extract underlying LangChain tool from Tool.from_langchain().
@@ -100,8 +140,29 @@ class LangChainToolAdapter(BaseToolAdapter):
100
140
 
101
141
  Returns:
102
142
  LangChain BaseTool instance.
143
+
144
+ Raises:
145
+ ValueError: If the wrapper's underlying tool is not a valid LangChain tool.
103
146
  """
104
- langchain_tool = wrapper.langchain_tool
147
+ langchain_tool = getattr(wrapper, "langchain_tool", None)
148
+ if langchain_tool is None:
149
+ langchain_tool = getattr(wrapper, "tool_class", None)
150
+
151
+ # Validate the extracted object is a valid LangChain tool
152
+ if langchain_tool is None:
153
+ wrapper_name = getattr(wrapper, "name", _UNKNOWN_TOOL_NAME)
154
+ raise ValueError(
155
+ f"Tool wrapper '{wrapper_name}' does not contain a valid LangChain tool. "
156
+ "Ensure Tool.from_langchain() was called with a LangChain BaseTool class or instance."
157
+ )
158
+
159
+ # Validate it's actually a LangChain tool (class or instance)
160
+ if not self._is_langchain_tool(langchain_tool):
161
+ wrapper_name = getattr(wrapper, "name", _UNKNOWN_TOOL_NAME)
162
+ raise ValueError(
163
+ f"Tool wrapper '{wrapper_name}' contains an invalid tool type: {type(langchain_tool)}. "
164
+ "Expected a LangChain BaseTool class or instance."
165
+ )
105
166
 
106
167
  # If it's a class, instantiate it
107
168
  if isinstance(langchain_tool, type):
@@ -109,7 +170,7 @@ class LangChainToolAdapter(BaseToolAdapter):
109
170
 
110
171
  logger.debug(
111
172
  "Extracted LangChain tool from wrapper: %s",
112
- getattr(langchain_tool, "name", "<unknown>"),
173
+ getattr(langchain_tool, "name", _UNKNOWN_TOOL_NAME),
113
174
  )
114
175
  return langchain_tool
115
176
 
@@ -155,8 +216,10 @@ class LangChainToolAdapter(BaseToolAdapter):
155
216
  return True
156
217
 
157
218
  # Tool.from_native() instances
158
- if hasattr(ref, "id") and hasattr(ref, "name") and not hasattr(ref, "langchain_tool"):
159
- return True
219
+ if hasattr(ref, "id") and hasattr(ref, "name") and not self._has_explicit_attr(ref, "langchain_tool"):
220
+ tool_class = getattr(ref, "tool_class", None) if self._has_explicit_attr(ref, "tool_class") else None
221
+ if tool_class is None:
222
+ return True
160
223
 
161
224
  return False
162
225
 
@@ -173,5 +236,7 @@ class LangChainToolAdapter(BaseToolAdapter):
173
236
  get_local_mode_not_supported_for_tool_message,
174
237
  )
175
238
 
176
- tool_name = ref if isinstance(ref, str) else getattr(ref, "name", "<unknown>")
239
+ tool_name = ref if isinstance(ref, str) else getattr(ref, "name", None)
240
+ if tool_name is None:
241
+ tool_name = getattr(getattr(ref, "tool_class", None), "__name__", _UNKNOWN_TOOL_NAME)
177
242
  return get_local_mode_not_supported_for_tool_message(tool_name)
@@ -0,0 +1,22 @@
1
+ """Schedules runtime package.
2
+
3
+ This package contains runtime schedule resource objects (class-based) that
4
+ encapsulate behavior and API interactions via attached clients.
5
+
6
+ Authors:
7
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
8
+ """
9
+
10
+ from glaip_sdk.schedules.base import (
11
+ Schedule,
12
+ ScheduleListResult,
13
+ ScheduleRun,
14
+ ScheduleRunListResult,
15
+ )
16
+
17
+ __all__ = [
18
+ "Schedule",
19
+ "ScheduleListResult",
20
+ "ScheduleRun",
21
+ "ScheduleRunListResult",
22
+ ]
@@ -0,0 +1,291 @@
1
+ """Schedule runtime resources.
2
+
3
+ This module contains class-based runtime resources for schedules.
4
+
5
+ The runtime resources:
6
+ - Are not Pydantic models.
7
+ - Are returned from public client APIs.
8
+ - Delegate API operations to a bound ScheduleClient.
9
+
10
+ Authors:
11
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from typing import TYPE_CHECKING
19
+
20
+ from glaip_sdk.models.agent_runs import RunStatus
21
+ from glaip_sdk.models.schedule import (
22
+ ScheduleConfig,
23
+ ScheduleMetadata,
24
+ ScheduleResponse,
25
+ ScheduleRunResponse,
26
+ ScheduleRunResult,
27
+ )
28
+
29
+ if TYPE_CHECKING: # pragma: no cover
30
+ from glaip_sdk.client.schedules import ScheduleClient
31
+
32
+ _SCHEDULE_CLIENT_REQUIRED_MSG = "No client available. Use client.schedules.get() to get a client-connected schedule."
33
+ _SCHEDULE_RUN_CLIENT_REQUIRED_MSG = (
34
+ "No client available. Use client.schedules.list_runs() to get a client-connected schedule run."
35
+ )
36
+
37
+
38
+ class Schedule:
39
+ """Runtime schedule resource.
40
+
41
+ Attributes:
42
+ id (str): The schedule ID.
43
+ next_run_time (str | None): Next run time as returned by the API.
44
+ time_until_next_run (str | None): Human readable duration until next run.
45
+ metadata (ScheduleMetadata | None): Schedule metadata.
46
+ created_at (datetime | None): Creation timestamp.
47
+ updated_at (datetime | None): Update timestamp.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ id: str,
54
+ next_run_time: str | None = None,
55
+ time_until_next_run: str | None = None,
56
+ metadata: ScheduleMetadata | None = None,
57
+ created_at: datetime | None = None,
58
+ updated_at: datetime | None = None,
59
+ _client: ScheduleClient | None = None,
60
+ ) -> None:
61
+ """Initialize a runtime Schedule."""
62
+ self.id = id
63
+ self.next_run_time = next_run_time
64
+ self.time_until_next_run = time_until_next_run
65
+ self.metadata = metadata
66
+ self.created_at = created_at
67
+ self.updated_at = updated_at
68
+ self._client = _client
69
+
70
+ @classmethod
71
+ def from_response(cls, response: ScheduleResponse, *, client: ScheduleClient) -> Schedule:
72
+ """Build a runtime Schedule from a DTO response.
73
+
74
+ Args:
75
+ response: Parsed schedule response DTO.
76
+ client: ScheduleClient to bind.
77
+
78
+ Returns:
79
+ Runtime Schedule.
80
+ """
81
+ return cls(
82
+ id=response.id,
83
+ next_run_time=response.next_run_time,
84
+ time_until_next_run=response.time_until_next_run,
85
+ metadata=response.metadata,
86
+ created_at=response.created_at,
87
+ updated_at=response.updated_at,
88
+ _client=client,
89
+ )
90
+
91
+ @property
92
+ def agent_id(self) -> str | None:
93
+ """Agent ID derived from metadata."""
94
+ return self.metadata.agent_id if self.metadata else None
95
+
96
+ @property
97
+ def input(self) -> str | None:
98
+ """Input text derived from metadata."""
99
+ return self.metadata.input if self.metadata else None
100
+
101
+ @property
102
+ def schedule_config(self) -> ScheduleConfig | None:
103
+ """Schedule configuration derived from metadata."""
104
+ return self.metadata.schedule if self.metadata else None
105
+
106
+ def update(
107
+ self,
108
+ *,
109
+ input: str | None = None,
110
+ schedule: ScheduleConfig | dict[str, str] | str | None = None,
111
+ ) -> Schedule:
112
+ """Update this schedule."""
113
+ if self._client is None:
114
+ raise RuntimeError(_SCHEDULE_CLIENT_REQUIRED_MSG)
115
+ return self._client.update(self.id, input=input, schedule=schedule)
116
+
117
+ def delete(self) -> None:
118
+ """Delete this schedule."""
119
+ if self._client is None:
120
+ raise RuntimeError(_SCHEDULE_CLIENT_REQUIRED_MSG)
121
+ self._client.delete(self.id)
122
+
123
+ def list_runs(
124
+ self,
125
+ *,
126
+ status: RunStatus | None = None,
127
+ limit: int | None = None,
128
+ page: int | None = None,
129
+ ) -> ScheduleRunListResult:
130
+ """List runs for this schedule."""
131
+ if self._client is None:
132
+ raise RuntimeError(_SCHEDULE_CLIENT_REQUIRED_MSG)
133
+ if self.agent_id is None:
134
+ raise ValueError("Schedule has no agent_id")
135
+ return self._client.list_runs(
136
+ self.agent_id,
137
+ schedule_id=self.id,
138
+ status=status,
139
+ limit=limit,
140
+ page=page,
141
+ )
142
+
143
+ def __repr__(self) -> str:
144
+ """Return a developer-friendly representation."""
145
+ parts: list[str] = [f"id={self.id!r}"]
146
+ if self.agent_id is not None:
147
+ parts.append(f"agent_id={self.agent_id!r}")
148
+ if self.next_run_time is not None:
149
+ parts.append(f"next_run_time={self.next_run_time!r}")
150
+ if self.time_until_next_run is not None:
151
+ parts.append(f"time_until_next_run={self.time_until_next_run!r}")
152
+ if self.created_at is not None:
153
+ parts.append(f"created_at={self.created_at!r}")
154
+ return f"Schedule({', '.join(parts)})"
155
+
156
+ def __str__(self) -> str:
157
+ """Return a readable string representation."""
158
+ return self.__repr__()
159
+
160
+
161
+ class ScheduleRun:
162
+ """Runtime schedule run resource."""
163
+
164
+ def __init__(
165
+ self,
166
+ *,
167
+ id: str,
168
+ agent_id: str,
169
+ schedule_id: str | None = None,
170
+ status: RunStatus,
171
+ run_type: str | None = None,
172
+ started_at: datetime | None = None,
173
+ completed_at: datetime | None = None,
174
+ input: str | None = None,
175
+ config: ScheduleConfig | dict[str, str] | None = None,
176
+ created_at: datetime | None = None,
177
+ updated_at: datetime | None = None,
178
+ _client: ScheduleClient | None = None,
179
+ ) -> None:
180
+ """Initialize a runtime ScheduleRun."""
181
+ self.id = id
182
+ self.agent_id = agent_id
183
+ self.schedule_id = schedule_id
184
+ self.status = status
185
+ self.run_type = run_type
186
+ self.started_at = started_at
187
+ self.completed_at = completed_at
188
+ self.input = input
189
+ self.config = config
190
+ self.created_at = created_at
191
+ self.updated_at = updated_at
192
+ self._client = _client
193
+
194
+ @classmethod
195
+ def from_response(cls, response: ScheduleRunResponse, *, client: ScheduleClient) -> ScheduleRun:
196
+ """Build a runtime ScheduleRun from a DTO response."""
197
+ return cls(
198
+ id=response.id,
199
+ agent_id=response.agent_id,
200
+ schedule_id=response.schedule_id,
201
+ status=response.status,
202
+ run_type=response.run_type,
203
+ started_at=response.started_at,
204
+ completed_at=response.completed_at,
205
+ input=response.input,
206
+ config=response.config,
207
+ created_at=response.created_at,
208
+ updated_at=response.updated_at,
209
+ _client=client,
210
+ )
211
+
212
+ def get_result(self) -> ScheduleRunResult:
213
+ """Retrieve the full output payload for this run."""
214
+ if self._client is None:
215
+ raise RuntimeError(_SCHEDULE_RUN_CLIENT_REQUIRED_MSG)
216
+ if self.agent_id is None:
217
+ raise ValueError("Schedule run has no agent_id")
218
+ return self._client.get_run_result(self.agent_id, self.id)
219
+
220
+ @property
221
+ def duration(self) -> str | None:
222
+ """Formatted duration (HH:MM:SS) when both timestamps are available."""
223
+ if not self.started_at or not self.completed_at:
224
+ return None
225
+
226
+ total_seconds = int((self.completed_at - self.started_at).total_seconds())
227
+ minutes, seconds = divmod(total_seconds, 60)
228
+ hours, minutes = divmod(minutes, 60)
229
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
230
+
231
+ def __repr__(self) -> str:
232
+ """Return a developer-friendly representation."""
233
+ parts: list[str] = [f"id={self.id!r}", f"status={self.status!r}"]
234
+ if self.started_at is not None:
235
+ parts.append(f"started_at={self.started_at.isoformat()!r}")
236
+ duration = self.duration
237
+ if duration is not None:
238
+ parts.append(f"duration={duration!r}")
239
+ return f"ScheduleRun({', '.join(parts)})"
240
+
241
+ def __str__(self) -> str:
242
+ """Return a readable string representation."""
243
+ return self.__repr__()
244
+
245
+
246
+ @dataclass
247
+ class ScheduleListResult:
248
+ """Paginated list wrapper for runtime schedules."""
249
+
250
+ items: list[Schedule]
251
+ total: int | None = field(default=None)
252
+ page: int | None = field(default=None)
253
+ limit: int | None = field(default=None)
254
+ has_next: bool | None = field(default=None)
255
+ has_prev: bool | None = field(default=None)
256
+
257
+ def __iter__(self):
258
+ """Iterate over schedules."""
259
+ yield from self.items
260
+
261
+ def __len__(self) -> int:
262
+ """Return the number of schedules in this page."""
263
+ return self.items.__len__()
264
+
265
+ def __getitem__(self, index: int) -> Schedule:
266
+ """Return the schedule at the given index."""
267
+ return self.items[index]
268
+
269
+
270
+ @dataclass
271
+ class ScheduleRunListResult:
272
+ """Paginated list wrapper for runtime schedule runs."""
273
+
274
+ items: list[ScheduleRun]
275
+ total: int | None = field(default=None)
276
+ page: int | None = field(default=None)
277
+ limit: int | None = field(default=None)
278
+ has_next: bool | None = field(default=None)
279
+ has_prev: bool | None = field(default=None)
280
+
281
+ def __iter__(self):
282
+ """Iterate over schedule runs."""
283
+ yield from self.items
284
+
285
+ def __len__(self) -> int:
286
+ """Return the number of runs in this page."""
287
+ return self.items.__len__()
288
+
289
+ def __getitem__(self, index: int) -> ScheduleRun:
290
+ """Return the run at the given index."""
291
+ return self.items[index]