glaip-sdk 0.6.19__py3-none-any.whl → 0.7.27__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.
- glaip_sdk/agents/base.py +283 -30
- glaip_sdk/agents/component.py +233 -0
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/auth.py +14 -8
- glaip_sdk/cli/commands/accounts.py +1 -1
- glaip_sdk/cli/commands/agents/__init__.py +116 -0
- glaip_sdk/cli/commands/agents/_common.py +562 -0
- glaip_sdk/cli/commands/agents/create.py +155 -0
- glaip_sdk/cli/commands/agents/delete.py +64 -0
- glaip_sdk/cli/commands/agents/get.py +89 -0
- glaip_sdk/cli/commands/agents/list.py +129 -0
- glaip_sdk/cli/commands/agents/run.py +264 -0
- glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
- glaip_sdk/cli/commands/agents/update.py +112 -0
- glaip_sdk/cli/commands/common_config.py +1 -1
- glaip_sdk/cli/commands/configure.py +1 -2
- glaip_sdk/cli/commands/mcps/__init__.py +94 -0
- glaip_sdk/cli/commands/mcps/_common.py +459 -0
- glaip_sdk/cli/commands/mcps/connect.py +82 -0
- glaip_sdk/cli/commands/mcps/create.py +152 -0
- glaip_sdk/cli/commands/mcps/delete.py +73 -0
- glaip_sdk/cli/commands/mcps/get.py +212 -0
- glaip_sdk/cli/commands/mcps/list.py +69 -0
- glaip_sdk/cli/commands/mcps/tools.py +235 -0
- glaip_sdk/cli/commands/mcps/update.py +190 -0
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/shared/__init__.py +21 -0
- glaip_sdk/cli/commands/shared/formatters.py +91 -0
- glaip_sdk/cli/commands/tools/__init__.py +69 -0
- glaip_sdk/cli/commands/tools/_common.py +80 -0
- glaip_sdk/cli/commands/tools/create.py +228 -0
- glaip_sdk/cli/commands/tools/delete.py +61 -0
- glaip_sdk/cli/commands/tools/get.py +103 -0
- glaip_sdk/cli/commands/tools/list.py +69 -0
- glaip_sdk/cli/commands/tools/script.py +49 -0
- glaip_sdk/cli/commands/tools/update.py +102 -0
- glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
- glaip_sdk/cli/commands/transcripts/_common.py +9 -0
- glaip_sdk/cli/commands/transcripts/clear.py +5 -0
- glaip_sdk/cli/commands/transcripts/detail.py +5 -0
- glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
- glaip_sdk/cli/commands/update.py +163 -17
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/entrypoint.py +20 -0
- glaip_sdk/cli/main.py +112 -35
- glaip_sdk/cli/pager.py +3 -3
- glaip_sdk/cli/resolution.py +2 -1
- glaip_sdk/cli/slash/accounts_controller.py +3 -1
- glaip_sdk/cli/slash/agent_session.py +1 -1
- glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +343 -20
- glaip_sdk/cli/slash/tui/__init__.py +29 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
- glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
- glaip_sdk/cli/slash/tui/clipboard.py +316 -0
- glaip_sdk/cli/slash/tui/context.py +92 -0
- glaip_sdk/cli/slash/tui/indicators.py +341 -0
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
- glaip_sdk/cli/slash/tui/loading.py +43 -21
- glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
- glaip_sdk/cli/slash/tui/terminal.py +407 -0
- glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
- glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
- glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +388 -0
- glaip_sdk/cli/transcript/history.py +1 -1
- glaip_sdk/cli/transcript/viewer.py +1 -1
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/cli/update_notifier.py +215 -7
- glaip_sdk/cli/validators.py +1 -1
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_schedule_payloads.py +89 -0
- glaip_sdk/client/agents.py +293 -17
- glaip_sdk/client/base.py +25 -0
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -5
- glaip_sdk/client/mcps.py +44 -13
- glaip_sdk/client/payloads/agent/__init__.py +23 -0
- glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
- glaip_sdk/client/payloads/agent/responses.py +43 -0
- glaip_sdk/client/run_rendering.py +109 -30
- glaip_sdk/client/schedules.py +439 -0
- glaip_sdk/client/tools.py +52 -23
- glaip_sdk/config/constants.py +22 -2
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +91 -0
- glaip_sdk/hitl/__init__.py +35 -2
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/callback.py +43 -0
- glaip_sdk/hitl/local.py +1 -31
- glaip_sdk/hitl/remote.py +523 -0
- glaip_sdk/models/__init__.py +47 -1
- glaip_sdk/models/_provider_mappings.py +101 -0
- glaip_sdk/models/_validation.py +97 -0
- glaip_sdk/models/agent.py +2 -1
- glaip_sdk/models/agent_runs.py +2 -1
- glaip_sdk/models/constants.py +141 -0
- glaip_sdk/models/model.py +170 -0
- glaip_sdk/models/schedule.py +224 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/ptc.py +145 -0
- glaip_sdk/registry/tool.py +270 -57
- glaip_sdk/runner/__init__.py +20 -3
- glaip_sdk/runner/deps.py +4 -1
- glaip_sdk/runner/langgraph.py +251 -27
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
- glaip_sdk/runner/ptc_adapter.py +98 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
- glaip_sdk/schedules/__init__.py +22 -0
- glaip_sdk/schedules/base.py +291 -0
- glaip_sdk/tools/base.py +67 -14
- glaip_sdk/utils/__init__.py +1 -0
- glaip_sdk/utils/agent_config.py +8 -2
- glaip_sdk/utils/bundler.py +138 -2
- glaip_sdk/utils/import_resolver.py +427 -49
- glaip_sdk/utils/runtime_config.py +3 -2
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +274 -6
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
- glaip_sdk-0.7.27.dist-info/RECORD +227 -0
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
- glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk-0.6.19.dist-info/RECORD +0 -163
- glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/top_level.txt +0 -0
glaip_sdk/hitl/remote.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remote HITL approval handler with threading and error recovery.
|
|
3
|
+
|
|
4
|
+
Authors:
|
|
5
|
+
GLAIP SDK Team
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from glaip_sdk.exceptions import APIError
|
|
19
|
+
from glaip_sdk.hitl.base import (
|
|
20
|
+
HITLCallback,
|
|
21
|
+
HITLDecision,
|
|
22
|
+
HITLRequest,
|
|
23
|
+
HITLResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from glaip_sdk.client.base import BaseClient
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RemoteHITLHandler:
|
|
33
|
+
"""Handler for remote HITL approval requests.
|
|
34
|
+
|
|
35
|
+
Executes callbacks in background threads to avoid blocking SSE stream.
|
|
36
|
+
Includes timeout enforcement and error handling.
|
|
37
|
+
|
|
38
|
+
Thread Safety:
|
|
39
|
+
This handler is thread-safe for concurrent HITL events. Callbacks
|
|
40
|
+
execute in daemon threads. Use wait_for_pending_decisions() after
|
|
41
|
+
stream completion to ensure all decisions are posted.
|
|
42
|
+
|
|
43
|
+
Environment Variables:
|
|
44
|
+
GLAIP_HITL_AUTO_APPROVE: Set to "true" to auto-approve all requests.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> def my_approver(request: HITLRequest) -> HITLResponse:
|
|
48
|
+
... print(f"Approve {request.tool_name}?")
|
|
49
|
+
... return HITLResponse(decision=HITLDecision.APPROVED)
|
|
50
|
+
>>>
|
|
51
|
+
>>> handler = RemoteHITLHandler(callback=my_approver, client=client)
|
|
52
|
+
>>> client.agents.run_agent(agent_id, message, hitl_handler=handler)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
callback: HITLCallback | None = None,
|
|
58
|
+
*,
|
|
59
|
+
client: "BaseClient",
|
|
60
|
+
auto_approve: bool | None = None,
|
|
61
|
+
max_retries: int = 3,
|
|
62
|
+
on_unrecoverable_error: Callable[[str, Exception], None] | None = None,
|
|
63
|
+
):
|
|
64
|
+
"""Initialize remote HITL handler.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
callback: Function to invoke for approval decisions.
|
|
68
|
+
If None and auto_approve=False, HITL events will be rejected.
|
|
69
|
+
client: BaseClient instance for posting decisions.
|
|
70
|
+
auto_approve: Override GLAIP_HITL_AUTO_APPROVE env var.
|
|
71
|
+
max_retries: Max retries for POST /agents/hitl/decision (default: 3)
|
|
72
|
+
on_unrecoverable_error: Optional callback invoked when both the
|
|
73
|
+
approval callback and fallback rejection POST fail. Receives
|
|
74
|
+
(request_id, exception) for custom alerting/logging.
|
|
75
|
+
"""
|
|
76
|
+
self._callback = callback
|
|
77
|
+
self._client = client
|
|
78
|
+
self._max_retries = max_retries
|
|
79
|
+
self._on_unrecoverable_error = on_unrecoverable_error
|
|
80
|
+
|
|
81
|
+
# Thread tracking for synchronization
|
|
82
|
+
self._active_threads: list[threading.Thread] = []
|
|
83
|
+
self._threads_lock = threading.Lock()
|
|
84
|
+
|
|
85
|
+
# Auto-approve from env or explicit override
|
|
86
|
+
if auto_approve is None:
|
|
87
|
+
auto_approve = os.getenv("GLAIP_HITL_AUTO_APPROVE", "").lower() == "true"
|
|
88
|
+
self._auto_approve = auto_approve
|
|
89
|
+
|
|
90
|
+
# Warn if no decision mechanism
|
|
91
|
+
if not auto_approve and callback is None:
|
|
92
|
+
logger.warning(
|
|
93
|
+
"RemoteHITLHandler: No callback provided and auto_approve=False. "
|
|
94
|
+
"HITL requests will be rejected. Set GLAIP_HITL_AUTO_APPROVE=true "
|
|
95
|
+
"or provide a callback."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def handle_hitl_event(self, event: dict) -> None:
|
|
99
|
+
"""Process HITL event from SSE stream.
|
|
100
|
+
|
|
101
|
+
Runs in background thread to avoid blocking stream.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
event: SSE event dict with metadata.hitl and metadata.tool_info
|
|
105
|
+
"""
|
|
106
|
+
# Validate event structure
|
|
107
|
+
try:
|
|
108
|
+
request = self._parse_hitl_request(event)
|
|
109
|
+
except (KeyError, ValueError) as e:
|
|
110
|
+
logger.error(f"Invalid HITL event structure: {e}")
|
|
111
|
+
logger.debug(f"Event data: {event}")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Execute in background thread to avoid blocking stream
|
|
115
|
+
thread = threading.Thread(
|
|
116
|
+
target=self._process_approval,
|
|
117
|
+
args=(request,),
|
|
118
|
+
daemon=True,
|
|
119
|
+
name=f"hitl-{request.request_id[:8]}",
|
|
120
|
+
)
|
|
121
|
+
thread.start()
|
|
122
|
+
|
|
123
|
+
# Track active threads for synchronization.
|
|
124
|
+
cleanup_snapshot: list[threading.Thread] | None = None
|
|
125
|
+
with self._threads_lock:
|
|
126
|
+
self._active_threads.append(thread)
|
|
127
|
+
if len(self._active_threads) > 10:
|
|
128
|
+
cleanup_snapshot = list(self._active_threads)
|
|
129
|
+
|
|
130
|
+
# Clean up finished threads outside the lock when the list grows.
|
|
131
|
+
if cleanup_snapshot is not None:
|
|
132
|
+
dead_threads = [t for t in cleanup_snapshot if not t.is_alive()]
|
|
133
|
+
if dead_threads:
|
|
134
|
+
with self._threads_lock:
|
|
135
|
+
self._active_threads = [t for t in self._active_threads if t not in dead_threads]
|
|
136
|
+
|
|
137
|
+
def _parse_hitl_request(self, event: dict) -> HITLRequest:
|
|
138
|
+
"""Parse SSE event into HITLRequest.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
KeyError: If required fields missing
|
|
142
|
+
ValueError: If data invalid
|
|
143
|
+
"""
|
|
144
|
+
metadata = event.get("metadata", {})
|
|
145
|
+
hitl_meta = metadata.get("hitl", {})
|
|
146
|
+
tool_info = metadata.get("tool_info", {})
|
|
147
|
+
|
|
148
|
+
# Validate required fields
|
|
149
|
+
request_id = hitl_meta.get("request_id")
|
|
150
|
+
if not request_id:
|
|
151
|
+
raise ValueError("Missing request_id in HITL metadata")
|
|
152
|
+
|
|
153
|
+
return HITLRequest(
|
|
154
|
+
request_id=request_id,
|
|
155
|
+
tool_name=tool_info.get("name", "unknown"),
|
|
156
|
+
tool_args=tool_info.get("args", {}),
|
|
157
|
+
timeout_at=hitl_meta.get("timeout_at", ""),
|
|
158
|
+
timeout_seconds=hitl_meta.get("timeout_seconds", 180),
|
|
159
|
+
hitl_metadata=hitl_meta,
|
|
160
|
+
tool_metadata=tool_info,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _process_approval(self, request: HITLRequest) -> None:
|
|
164
|
+
"""Process approval in background thread.
|
|
165
|
+
|
|
166
|
+
Handles callback execution, timeout, errors, and POST retry.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
# Get decision
|
|
170
|
+
response = self._get_decision(request)
|
|
171
|
+
|
|
172
|
+
# Post to backend with retry
|
|
173
|
+
self._post_decision_with_retry(request.request_id, response)
|
|
174
|
+
|
|
175
|
+
except APIError as e:
|
|
176
|
+
# Handle client errors (4xx) - non-retryable
|
|
177
|
+
if e.status_code and 400 <= e.status_code < 500:
|
|
178
|
+
logger.warning(f"Non-retryable HITL decision error for {request.request_id}: {e}")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
logger.error(
|
|
182
|
+
f"HITL processing failed for {request.request_id}: {e}",
|
|
183
|
+
exc_info=True,
|
|
184
|
+
)
|
|
185
|
+
self._handle_approval_failure(request, e)
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(
|
|
189
|
+
f"HITL processing failed for {request.request_id}: {e}",
|
|
190
|
+
exc_info=True,
|
|
191
|
+
)
|
|
192
|
+
self._handle_approval_failure(request, e)
|
|
193
|
+
|
|
194
|
+
def _handle_approval_failure(self, request: HITLRequest, error: Exception) -> None:
|
|
195
|
+
"""Handle failure during approval processing by attempting fallback rejection.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
request: The HITL request that failed.
|
|
199
|
+
error: The exception that occurred during processing.
|
|
200
|
+
"""
|
|
201
|
+
# Try to post rejection as fallback
|
|
202
|
+
try:
|
|
203
|
+
fallback = HITLResponse(
|
|
204
|
+
decision=HITLDecision.REJECTED,
|
|
205
|
+
operator_input=f"Error: {str(error)[:100]}",
|
|
206
|
+
)
|
|
207
|
+
self._post_decision_with_retry(request.request_id, fallback)
|
|
208
|
+
except Exception as post_err:
|
|
209
|
+
logger.error(f"Failed to post fallback rejection: {post_err}")
|
|
210
|
+
|
|
211
|
+
# Invoke error callback if provided
|
|
212
|
+
if self._on_unrecoverable_error:
|
|
213
|
+
try:
|
|
214
|
+
self._on_unrecoverable_error(request.request_id, post_err)
|
|
215
|
+
except Exception as cb_err:
|
|
216
|
+
logger.error(f"Error callback failed: {cb_err}")
|
|
217
|
+
|
|
218
|
+
def _get_decision(self, request: HITLRequest) -> HITLResponse:
|
|
219
|
+
"""Get approval decision via auto-approve or callback.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
Exception: If callback fails
|
|
223
|
+
"""
|
|
224
|
+
# Auto-approve path
|
|
225
|
+
if self._auto_approve:
|
|
226
|
+
logger.info(f"Auto-approving HITL request {request.request_id}")
|
|
227
|
+
return HITLResponse(
|
|
228
|
+
decision=HITLDecision.APPROVED,
|
|
229
|
+
operator_input="auto-approved",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Callback path
|
|
233
|
+
if self._callback:
|
|
234
|
+
return self._execute_callback(request)
|
|
235
|
+
|
|
236
|
+
# No callback, no auto-approve -> reject
|
|
237
|
+
logger.warning(f"No approval mechanism, rejecting {request.request_id}")
|
|
238
|
+
return HITLResponse(
|
|
239
|
+
decision=HITLDecision.REJECTED,
|
|
240
|
+
operator_input="No approval handler configured",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _execute_callback(self, request: HITLRequest) -> HITLResponse:
|
|
244
|
+
"""Execute callback with timeout and error handling.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
request: HITL request to process
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
HITLResponse from callback or rejection on error
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
# Apply timeout: 80% of remaining backend time (20% buffer)
|
|
254
|
+
timeout = self._compute_callback_timeout(request)
|
|
255
|
+
|
|
256
|
+
# Run callback with timeout
|
|
257
|
+
response = self._run_callback_with_timeout(
|
|
258
|
+
request,
|
|
259
|
+
timeout_seconds=timeout,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Validate return type
|
|
263
|
+
if not isinstance(response, HITLResponse):
|
|
264
|
+
logger.error(
|
|
265
|
+
f"HITL callback returned invalid type {type(response)} "
|
|
266
|
+
f"for {request.request_id}, expected HITLResponse"
|
|
267
|
+
)
|
|
268
|
+
return HITLResponse(
|
|
269
|
+
decision=HITLDecision.REJECTED,
|
|
270
|
+
operator_input="Callback returned invalid response type",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
logger.info(f"HITL callback returned {response.decision} for {request.request_id}")
|
|
274
|
+
return response
|
|
275
|
+
|
|
276
|
+
except TimeoutError:
|
|
277
|
+
logger.error(
|
|
278
|
+
f"HITL callback timeout ({timeout}s) for request {request.request_id} (tool: {request.tool_name})"
|
|
279
|
+
)
|
|
280
|
+
return HITLResponse(
|
|
281
|
+
decision=HITLDecision.REJECTED,
|
|
282
|
+
operator_input="Callback timeout",
|
|
283
|
+
)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.error(
|
|
286
|
+
f"HITL callback failed for {request.request_id}: {e}",
|
|
287
|
+
exc_info=True,
|
|
288
|
+
)
|
|
289
|
+
return HITLResponse(
|
|
290
|
+
decision=HITLDecision.REJECTED,
|
|
291
|
+
operator_input=f"Callback error: {str(e)[:100]}",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _run_callback_with_timeout(
|
|
295
|
+
self,
|
|
296
|
+
request: HITLRequest,
|
|
297
|
+
timeout_seconds: int,
|
|
298
|
+
) -> HITLResponse:
|
|
299
|
+
"""Run callback with timeout using threading.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
request: HITL request
|
|
303
|
+
timeout_seconds: Max execution time
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
HITLResponse from callback
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
TimeoutError: If callback exceeds timeout
|
|
310
|
+
Exception: If callback raises
|
|
311
|
+
"""
|
|
312
|
+
result = [None] # Mutable container for thread result
|
|
313
|
+
exception = [None]
|
|
314
|
+
|
|
315
|
+
def wrapper():
|
|
316
|
+
try:
|
|
317
|
+
result[0] = self._callback(request)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
exception[0] = e
|
|
320
|
+
|
|
321
|
+
thread = threading.Thread(target=wrapper, daemon=True)
|
|
322
|
+
thread.start()
|
|
323
|
+
thread.join(timeout=timeout_seconds)
|
|
324
|
+
|
|
325
|
+
if thread.is_alive():
|
|
326
|
+
# Timeout - thread still running
|
|
327
|
+
logger.warning(f"Callback timeout after {timeout_seconds}s for {request.request_id}")
|
|
328
|
+
raise TimeoutError(f"Callback exceeded {timeout_seconds}s")
|
|
329
|
+
|
|
330
|
+
if exception[0]:
|
|
331
|
+
raise exception[0]
|
|
332
|
+
|
|
333
|
+
if result[0] is None:
|
|
334
|
+
raise ValueError("Callback returned None instead of HITLResponse")
|
|
335
|
+
|
|
336
|
+
return result[0]
|
|
337
|
+
|
|
338
|
+
def _compute_callback_timeout(self, request: HITLRequest) -> int:
|
|
339
|
+
"""Compute callback timeout using timeout_at as the source of truth.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
request: HITL request with timeout information
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Timeout in seconds (minimum 5s)
|
|
346
|
+
"""
|
|
347
|
+
fallback_seconds = max(5, int(request.timeout_seconds * 0.8))
|
|
348
|
+
try:
|
|
349
|
+
# Try ISO format first with Z suffix
|
|
350
|
+
if request.timeout_at.endswith("Z"):
|
|
351
|
+
deadline = datetime.fromisoformat(request.timeout_at.replace("Z", "+00:00"))
|
|
352
|
+
else:
|
|
353
|
+
# Try parsing as-is (may include timezone info)
|
|
354
|
+
deadline = datetime.fromisoformat(request.timeout_at)
|
|
355
|
+
|
|
356
|
+
now = datetime.now(timezone.utc)
|
|
357
|
+
remaining = max(0, int((deadline - now).total_seconds()))
|
|
358
|
+
return max(5, int(remaining * 0.8))
|
|
359
|
+
except (TypeError, ValueError, AttributeError) as e:
|
|
360
|
+
logger.debug(
|
|
361
|
+
f"Failed to parse timeout_at '{request.timeout_at}': {e}, using fallback timeout of {fallback_seconds}s"
|
|
362
|
+
)
|
|
363
|
+
return fallback_seconds
|
|
364
|
+
|
|
365
|
+
def _post_decision_with_retry(
|
|
366
|
+
self,
|
|
367
|
+
request_id: str,
|
|
368
|
+
response: HITLResponse,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Post decision to backend with retry logic.
|
|
371
|
+
|
|
372
|
+
Only retries on server errors (5xx) and network errors.
|
|
373
|
+
Client errors (4xx) fail immediately as they won't succeed on retry.
|
|
374
|
+
404/409 are treated as already resolved.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
request_id: HITL request ID
|
|
378
|
+
response: Decision response
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
Exception: If all retries fail
|
|
382
|
+
"""
|
|
383
|
+
payload = self._build_decision_payload(request_id, response)
|
|
384
|
+
last_error = None
|
|
385
|
+
|
|
386
|
+
for attempt in range(1, self._max_retries + 1):
|
|
387
|
+
try:
|
|
388
|
+
result = self._client._request("POST", "/agents/hitl/decision", json=payload)
|
|
389
|
+
logger.info(
|
|
390
|
+
f"HITL decision posted successfully for {request_id} (attempt {attempt}/{self._max_retries})"
|
|
391
|
+
)
|
|
392
|
+
logger.debug(f"Response: {result}")
|
|
393
|
+
return # Success
|
|
394
|
+
|
|
395
|
+
except APIError as e:
|
|
396
|
+
last_error = e
|
|
397
|
+
if self._handle_api_error(e, request_id, attempt):
|
|
398
|
+
return # Request already resolved
|
|
399
|
+
|
|
400
|
+
except httpx.RequestError as e:
|
|
401
|
+
last_error = e
|
|
402
|
+
logger.warning(f"Network error (attempt {attempt}/{self._max_retries}): {e}")
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
# Unexpected errors - don't retry
|
|
406
|
+
logger.error(f"Unexpected error posting decision: {e}")
|
|
407
|
+
raise
|
|
408
|
+
|
|
409
|
+
# Retry delay only if not last attempt
|
|
410
|
+
if attempt < self._max_retries:
|
|
411
|
+
time.sleep(attempt) # Linear backoff: 1s, 2s, 3s
|
|
412
|
+
|
|
413
|
+
# All retries failed
|
|
414
|
+
self._log_retry_exhausted(request_id, last_error)
|
|
415
|
+
raise last_error
|
|
416
|
+
|
|
417
|
+
def _build_decision_payload(self, request_id: str, response: HITLResponse) -> dict[str, Any]:
|
|
418
|
+
"""Build payload for decision POST request."""
|
|
419
|
+
payload = {
|
|
420
|
+
"request_id": request_id,
|
|
421
|
+
"decision": response.decision.value,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if response.operator_input:
|
|
425
|
+
payload["operator_input"] = response.operator_input
|
|
426
|
+
|
|
427
|
+
return payload
|
|
428
|
+
|
|
429
|
+
def _handle_api_error(self, error: APIError, request_id: str, attempt: int) -> bool:
|
|
430
|
+
"""Handle API error and determine if request is already resolved.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
error: The API error to handle
|
|
434
|
+
request_id: The HITL request ID
|
|
435
|
+
attempt: Current attempt number
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
True if request is already resolved (404/409), False otherwise
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
APIError: If error is not retryable
|
|
442
|
+
"""
|
|
443
|
+
status_code = error.status_code or 0
|
|
444
|
+
RETRYABLE_STATUS_CODES = {500, 502, 503, 504}
|
|
445
|
+
|
|
446
|
+
# 404/409 indicate the request is already resolved
|
|
447
|
+
if status_code in (404, 409):
|
|
448
|
+
logger.info(f"Request already resolved ({status_code}) for {request_id}")
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
# Don't retry client errors (4xx)
|
|
452
|
+
if 400 <= status_code < 500:
|
|
453
|
+
logger.warning(f"Non-retryable error {status_code} for {request_id}: {error}")
|
|
454
|
+
raise error
|
|
455
|
+
|
|
456
|
+
# Retry server errors (5xx)
|
|
457
|
+
if status_code not in RETRYABLE_STATUS_CODES:
|
|
458
|
+
logger.warning(f"Unexpected status {status_code}, not retrying")
|
|
459
|
+
raise error
|
|
460
|
+
|
|
461
|
+
logger.warning(f"Server error {status_code} (attempt {attempt}/{self._max_retries}): {error}")
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
def _log_retry_exhausted(self, request_id: str, last_error: Exception | None) -> None:
|
|
465
|
+
"""Log that retry attempts have been exhausted."""
|
|
466
|
+
logger.error(f"Failed to post HITL decision for {request_id} after {self._max_retries} attempts: {last_error}")
|
|
467
|
+
|
|
468
|
+
def wait_for_pending_decisions(self, timeout: float = 30) -> None:
|
|
469
|
+
"""Wait for all pending HITL decision posts to complete.
|
|
470
|
+
|
|
471
|
+
Call this after SSE stream ends to ensure all background
|
|
472
|
+
threads finish posting decisions before returning from run_agent().
|
|
473
|
+
|
|
474
|
+
Uses adaptive timeout redistribution: when threads complete early,
|
|
475
|
+
their remaining time is redistributed to remaining threads.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
timeout: Maximum seconds to wait for all threads (default: 30)
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
ValueError: If timeout is not positive
|
|
482
|
+
"""
|
|
483
|
+
if timeout <= 0:
|
|
484
|
+
raise ValueError("timeout must be positive")
|
|
485
|
+
|
|
486
|
+
with self._threads_lock:
|
|
487
|
+
threads_to_wait = self._active_threads.copy()
|
|
488
|
+
|
|
489
|
+
if not threads_to_wait:
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
deadline = time.monotonic() + timeout
|
|
493
|
+
remaining_threads = list(threads_to_wait)
|
|
494
|
+
|
|
495
|
+
while remaining_threads:
|
|
496
|
+
time_left = deadline - time.monotonic()
|
|
497
|
+
if time_left <= 0:
|
|
498
|
+
break
|
|
499
|
+
|
|
500
|
+
per_thread_timeout = time_left / len(remaining_threads)
|
|
501
|
+
next_round: list[threading.Thread] = []
|
|
502
|
+
|
|
503
|
+
for thread in remaining_threads:
|
|
504
|
+
thread.join(timeout=per_thread_timeout)
|
|
505
|
+
if thread.is_alive():
|
|
506
|
+
logger.warning(f"HITL thread {thread.name} still running after {per_thread_timeout:.1f}s timeout")
|
|
507
|
+
next_round.append(thread)
|
|
508
|
+
|
|
509
|
+
if len(next_round) == len(remaining_threads):
|
|
510
|
+
# Break to avoid a tight loop when joins return immediately.
|
|
511
|
+
break
|
|
512
|
+
|
|
513
|
+
remaining_threads = next_round
|
|
514
|
+
|
|
515
|
+
# Clean up finished threads
|
|
516
|
+
with self._threads_lock:
|
|
517
|
+
still_alive = [t for t in self._active_threads if t.is_alive()]
|
|
518
|
+
if still_alive:
|
|
519
|
+
logger.error(
|
|
520
|
+
f"{len(still_alive)} HITL threads did not complete within timeout. "
|
|
521
|
+
"Decisions may not have been posted."
|
|
522
|
+
)
|
|
523
|
+
self._active_threads = still_alive
|
glaip_sdk/models/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# pylint: disable=duplicate-code
|
|
1
2
|
"""Models package for AIP SDK.
|
|
2
3
|
|
|
3
4
|
This package provides Pydantic models for API responses.
|
|
@@ -17,7 +18,7 @@ Authors:
|
|
|
17
18
|
|
|
18
19
|
import warnings
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
from glaip_sdk.models._validation import _validate_model, convert_model_for_local_execution
|
|
21
22
|
from glaip_sdk.models.agent import AgentResponse
|
|
22
23
|
from glaip_sdk.models.agent_runs import (
|
|
23
24
|
RunOutputChunk,
|
|
@@ -26,7 +27,32 @@ from glaip_sdk.models.agent_runs import (
|
|
|
26
27
|
RunWithOutput,
|
|
27
28
|
)
|
|
28
29
|
from glaip_sdk.models.common import LanguageModelResponse, TTYRenderer
|
|
30
|
+
|
|
31
|
+
# Pure Pydantic models for API responses (no runtime methods)
|
|
32
|
+
# Import model constants and validation (absolute imports)
|
|
33
|
+
from glaip_sdk.models.constants import (
|
|
34
|
+
DEFAULT_MODEL,
|
|
35
|
+
Anthropic,
|
|
36
|
+
AzureOpenAI,
|
|
37
|
+
Bedrock,
|
|
38
|
+
DeepInfra,
|
|
39
|
+
DeepSeek,
|
|
40
|
+
Google,
|
|
41
|
+
ModelProvider,
|
|
42
|
+
OpenAI,
|
|
43
|
+
)
|
|
29
44
|
from glaip_sdk.models.mcp import MCPResponse
|
|
45
|
+
from glaip_sdk.models.model import Model
|
|
46
|
+
|
|
47
|
+
# Export schedule models
|
|
48
|
+
from glaip_sdk.models.schedule import ( # noqa: F401
|
|
49
|
+
ScheduleConfig,
|
|
50
|
+
ScheduleMetadata,
|
|
51
|
+
ScheduleResponse,
|
|
52
|
+
ScheduleRunOutputChunk,
|
|
53
|
+
ScheduleRunResponse,
|
|
54
|
+
ScheduleRunResult,
|
|
55
|
+
)
|
|
30
56
|
from glaip_sdk.models.tool import ToolResponse
|
|
31
57
|
|
|
32
58
|
|
|
@@ -72,6 +98,19 @@ def __getattr__(name: str) -> type:
|
|
|
72
98
|
|
|
73
99
|
|
|
74
100
|
__all__ = [
|
|
101
|
+
# Model constants and validation
|
|
102
|
+
"OpenAI",
|
|
103
|
+
"Anthropic",
|
|
104
|
+
"Google",
|
|
105
|
+
"AzureOpenAI",
|
|
106
|
+
"DeepInfra",
|
|
107
|
+
"DeepSeek",
|
|
108
|
+
"Bedrock",
|
|
109
|
+
"Model",
|
|
110
|
+
"ModelProvider",
|
|
111
|
+
"DEFAULT_MODEL",
|
|
112
|
+
"_validate_model",
|
|
113
|
+
"convert_model_for_local_execution",
|
|
75
114
|
# Pure Pydantic response models (recommended for type hints)
|
|
76
115
|
"AgentResponse",
|
|
77
116
|
"ToolResponse",
|
|
@@ -87,4 +126,11 @@ __all__ = [
|
|
|
87
126
|
"RunsPage",
|
|
88
127
|
"RunWithOutput",
|
|
89
128
|
"RunOutputChunk",
|
|
129
|
+
# Schedule models
|
|
130
|
+
"ScheduleConfig",
|
|
131
|
+
"ScheduleMetadata",
|
|
132
|
+
"ScheduleResponse",
|
|
133
|
+
"ScheduleRunResponse",
|
|
134
|
+
"ScheduleRunOutputChunk",
|
|
135
|
+
"ScheduleRunResult",
|
|
90
136
|
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Provider configuration for model name standardization.
|
|
2
|
+
|
|
3
|
+
This module centralizes provider configurations, including how provider names
|
|
4
|
+
map to server implementations and their base URLs for local execution.
|
|
5
|
+
|
|
6
|
+
Authors:
|
|
7
|
+
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__ = ["PROVIDERS", "ProviderConfig", "get_driver", "get_base_url"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderConfig:
|
|
14
|
+
"""Configuration for a model provider.
|
|
15
|
+
|
|
16
|
+
Defines how a provider is referenced in SDK constants and how it maps to
|
|
17
|
+
the underlying driver implementation and API endpoints.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
name: str,
|
|
23
|
+
driver: str,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
):
|
|
26
|
+
"""Initialize provider configuration.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
name: User-facing provider name used in SDK constants (e.g., "deepinfra").
|
|
30
|
+
driver: Driver implementation name in language_models.yaml (e.g., "openai-compatible").
|
|
31
|
+
base_url: Base URL for the provider's API (required for local execution).
|
|
32
|
+
"""
|
|
33
|
+
self.name = name
|
|
34
|
+
self.driver = driver
|
|
35
|
+
self.base_url = base_url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Centralized provider configurations
|
|
39
|
+
# Key: provider name (used in SDK constants)
|
|
40
|
+
# Value: ProviderConfig with driver mapping and base URL
|
|
41
|
+
PROVIDERS: dict[str, ProviderConfig] = {
|
|
42
|
+
"deepinfra": ProviderConfig(
|
|
43
|
+
name="deepinfra",
|
|
44
|
+
driver="openai-compatible",
|
|
45
|
+
base_url="https://api.deepinfra.com/v1/openai",
|
|
46
|
+
),
|
|
47
|
+
"deepseek": ProviderConfig(
|
|
48
|
+
name="deepseek",
|
|
49
|
+
driver="openai-compatible",
|
|
50
|
+
base_url="https://api.deepseek.com",
|
|
51
|
+
),
|
|
52
|
+
"custom": ProviderConfig(
|
|
53
|
+
name="custom",
|
|
54
|
+
driver="openai-compatible",
|
|
55
|
+
base_url=None, # User-provided via Model.base_url
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_driver(provider: str) -> str:
|
|
61
|
+
"""Get driver name for a given provider.
|
|
62
|
+
|
|
63
|
+
Maps SDK provider names to their underlying driver implementations.
|
|
64
|
+
For providers not in the config, returns the provider name unchanged
|
|
65
|
+
(assumes provider name matches driver name).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
provider: Provider name from SDK constants (e.g., "deepinfra", "openai").
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Driver name (e.g., "openai-compatible" for deepinfra, "openai" for openai).
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
>>> get_driver("deepinfra")
|
|
75
|
+
"openai-compatible"
|
|
76
|
+
>>> get_driver("openai")
|
|
77
|
+
"openai"
|
|
78
|
+
"""
|
|
79
|
+
config = PROVIDERS.get(provider)
|
|
80
|
+
return config.driver if config else provider
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_base_url(provider: str) -> str | None:
|
|
84
|
+
"""Get default base URL for a provider.
|
|
85
|
+
|
|
86
|
+
Returns the configured base URL for local execution, if available.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
provider: Provider name from SDK constants (e.g., "deepinfra").
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Base URL string, or None if no config exists or no base_url configured.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
>>> get_base_url("deepinfra")
|
|
96
|
+
"https://api.deepinfra.com/v1/openai"
|
|
97
|
+
>>> get_base_url("openai")
|
|
98
|
+
None
|
|
99
|
+
"""
|
|
100
|
+
config = PROVIDERS.get(provider)
|
|
101
|
+
return config.base_url if config else None
|