glaip-sdk 0.7.1__py3-none-any.whl → 0.7.3__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 +32 -9
- glaip_sdk/client/agents.py +26 -0
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +2 -0
- glaip_sdk/client/run_rendering.py +33 -1
- glaip_sdk/hitl/__init__.py +14 -1
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/remote.py +523 -0
- {glaip_sdk-0.7.1.dist-info → glaip_sdk-0.7.3.dist-info}/METADATA +1 -1
- {glaip_sdk-0.7.1.dist-info → glaip_sdk-0.7.3.dist-info}/RECORD +13 -10
- {glaip_sdk-0.7.1.dist-info → glaip_sdk-0.7.3.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.1.dist-info → glaip_sdk-0.7.3.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.1.dist-info → glaip_sdk-0.7.3.dist-info}/top_level.txt +0 -0
glaip_sdk/agents/base.py
CHANGED
|
@@ -46,8 +46,6 @@ import inspect
|
|
|
46
46
|
import logging
|
|
47
47
|
import warnings
|
|
48
48
|
from collections.abc import AsyncGenerator
|
|
49
|
-
|
|
50
|
-
|
|
51
49
|
from pathlib import Path
|
|
52
50
|
from typing import TYPE_CHECKING, Any
|
|
53
51
|
|
|
@@ -942,7 +940,16 @@ class Agent:
|
|
|
942
940
|
|
|
943
941
|
if check_local_runtime_available():
|
|
944
942
|
return get_default_runner()
|
|
945
|
-
|
|
943
|
+
|
|
944
|
+
# If agent is not deployed, it *must* use local runtime
|
|
945
|
+
if not self.id:
|
|
946
|
+
raise ValueError(f"{_AGENT_NOT_DEPLOYED_MSG}\n\n{get_local_runtime_missing_message()}")
|
|
947
|
+
|
|
948
|
+
# If agent IS deployed but local execution was forced (local=True)
|
|
949
|
+
raise ValueError(
|
|
950
|
+
f"Local execution override was requested, but local runtime is missing.\n\n"
|
|
951
|
+
f"{get_local_runtime_missing_message()}"
|
|
952
|
+
)
|
|
946
953
|
|
|
947
954
|
def _prepare_local_runner_kwargs(
|
|
948
955
|
self,
|
|
@@ -977,6 +984,7 @@ class Agent:
|
|
|
977
984
|
self,
|
|
978
985
|
message: str,
|
|
979
986
|
verbose: bool = False,
|
|
987
|
+
local: bool = False,
|
|
980
988
|
runtime_config: dict[str, Any] | None = None,
|
|
981
989
|
chat_history: list[dict[str, str]] | None = None,
|
|
982
990
|
**kwargs: Any,
|
|
@@ -989,9 +997,13 @@ class Agent:
|
|
|
989
997
|
- **Local**: When the agent is not deployed and glaip-sdk[local] is installed,
|
|
990
998
|
execution happens locally via aip-agents (no server required).
|
|
991
999
|
|
|
1000
|
+
You can force local execution for a deployed agent by passing `local=True`.
|
|
1001
|
+
|
|
992
1002
|
Args:
|
|
993
1003
|
message: The message to send to the agent.
|
|
994
1004
|
verbose: If True, print streaming output to console. Defaults to False.
|
|
1005
|
+
local: If True, force local execution even if the agent is deployed.
|
|
1006
|
+
Defaults to False.
|
|
995
1007
|
runtime_config: Optional runtime configuration for tools, MCPs, and agents.
|
|
996
1008
|
Keys can be SDK objects, UUIDs, or names. Example:
|
|
997
1009
|
{
|
|
@@ -1013,16 +1025,19 @@ class Agent:
|
|
|
1013
1025
|
RuntimeError: If server-backed execution fails due to client issues.
|
|
1014
1026
|
"""
|
|
1015
1027
|
# Backend routing: deployed agents use server, undeployed use local (if available)
|
|
1016
|
-
if self.id:
|
|
1028
|
+
if self.id and not local:
|
|
1017
1029
|
# Server-backed execution path (agent is deployed)
|
|
1018
1030
|
agent_client, call_kwargs = self._prepare_run_kwargs(
|
|
1019
|
-
message,
|
|
1031
|
+
message,
|
|
1032
|
+
verbose,
|
|
1033
|
+
runtime_config or kwargs.get("runtime_config"),
|
|
1034
|
+
**kwargs,
|
|
1020
1035
|
)
|
|
1021
1036
|
if chat_history is not None:
|
|
1022
1037
|
call_kwargs["chat_history"] = chat_history
|
|
1023
1038
|
return agent_client.run_agent(**call_kwargs)
|
|
1024
1039
|
|
|
1025
|
-
# Local execution path (agent is not deployed)
|
|
1040
|
+
# Local execution path (agent is not deployed OR local=True)
|
|
1026
1041
|
runner = self._get_local_runner_or_raise()
|
|
1027
1042
|
local_kwargs = self._prepare_local_runner_kwargs(message, verbose, runtime_config, chat_history, **kwargs)
|
|
1028
1043
|
return runner.run(**local_kwargs)
|
|
@@ -1031,6 +1046,7 @@ class Agent:
|
|
|
1031
1046
|
self,
|
|
1032
1047
|
message: str,
|
|
1033
1048
|
verbose: bool = False,
|
|
1049
|
+
local: bool = False,
|
|
1034
1050
|
runtime_config: dict[str, Any] | None = None,
|
|
1035
1051
|
chat_history: list[dict[str, str]] | None = None,
|
|
1036
1052
|
**kwargs: Any,
|
|
@@ -1043,9 +1059,13 @@ class Agent:
|
|
|
1043
1059
|
- **Local**: When the agent is not deployed and glaip-sdk[local] is installed,
|
|
1044
1060
|
execution happens locally via aip-agents (no server required).
|
|
1045
1061
|
|
|
1062
|
+
You can force local execution for a deployed agent by passing `local=True`.
|
|
1063
|
+
|
|
1046
1064
|
Args:
|
|
1047
1065
|
message: The message to send to the agent.
|
|
1048
1066
|
verbose: If True, print streaming output to console. Defaults to False.
|
|
1067
|
+
local: If True, force local execution even if the agent is deployed.
|
|
1068
|
+
Defaults to False.
|
|
1049
1069
|
runtime_config: Optional runtime configuration for tools, MCPs, and agents.
|
|
1050
1070
|
Keys can be SDK objects, UUIDs, or names. Example:
|
|
1051
1071
|
{
|
|
@@ -1067,10 +1087,13 @@ class Agent:
|
|
|
1067
1087
|
RuntimeError: If server-backed execution fails due to client issues.
|
|
1068
1088
|
"""
|
|
1069
1089
|
# Backend routing: deployed agents use server, undeployed use local (if available)
|
|
1070
|
-
if self.id:
|
|
1090
|
+
if self.id and not local:
|
|
1071
1091
|
# Server-backed execution path (agent is deployed)
|
|
1072
1092
|
agent_client, call_kwargs = self._prepare_run_kwargs(
|
|
1073
|
-
message,
|
|
1093
|
+
message,
|
|
1094
|
+
verbose,
|
|
1095
|
+
runtime_config or kwargs.get("runtime_config"),
|
|
1096
|
+
**kwargs,
|
|
1074
1097
|
)
|
|
1075
1098
|
if chat_history is not None:
|
|
1076
1099
|
call_kwargs["chat_history"] = chat_history
|
|
@@ -1079,7 +1102,7 @@ class Agent:
|
|
|
1079
1102
|
yield chunk
|
|
1080
1103
|
return
|
|
1081
1104
|
|
|
1082
|
-
# Local execution path (agent is not deployed)
|
|
1105
|
+
# Local execution path (agent is not deployed OR local=True)
|
|
1083
1106
|
runner = self._get_local_runner_or_raise()
|
|
1084
1107
|
local_kwargs = self._prepare_local_runner_kwargs(message, verbose, runtime_config, chat_history, **kwargs)
|
|
1085
1108
|
result = await runner.arun(**local_kwargs)
|
glaip_sdk/client/agents.py
CHANGED
|
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, BinaryIO
|
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from glaip_sdk.client.schedules import ScheduleClient
|
|
20
|
+
from glaip_sdk.hitl.remote import RemoteHITLHandler
|
|
20
21
|
|
|
21
22
|
import httpx
|
|
22
23
|
from glaip_sdk.agents import Agent
|
|
@@ -415,6 +416,7 @@ class AgentClient(BaseClient):
|
|
|
415
416
|
timeout_seconds: float,
|
|
416
417
|
agent_name: str | None,
|
|
417
418
|
meta: dict[str, Any],
|
|
419
|
+
hitl_handler: "RemoteHITLHandler | None" = None,
|
|
418
420
|
) -> tuple[str, dict[str, Any], float | None, float | None]:
|
|
419
421
|
"""Process stream events from an HTTP response.
|
|
420
422
|
|
|
@@ -424,6 +426,7 @@ class AgentClient(BaseClient):
|
|
|
424
426
|
timeout_seconds: Timeout in seconds.
|
|
425
427
|
agent_name: Optional agent name.
|
|
426
428
|
meta: Metadata dictionary.
|
|
429
|
+
hitl_handler: Optional HITL handler for approval callbacks.
|
|
427
430
|
|
|
428
431
|
Returns:
|
|
429
432
|
Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
|
|
@@ -435,6 +438,7 @@ class AgentClient(BaseClient):
|
|
|
435
438
|
timeout_seconds,
|
|
436
439
|
agent_name,
|
|
437
440
|
meta,
|
|
441
|
+
hitl_handler=hitl_handler,
|
|
438
442
|
)
|
|
439
443
|
|
|
440
444
|
def _finalize_renderer(
|
|
@@ -1112,6 +1116,7 @@ class AgentClient(BaseClient):
|
|
|
1112
1116
|
*,
|
|
1113
1117
|
renderer: RichStreamRenderer | str | None = "auto",
|
|
1114
1118
|
runtime_config: dict[str, Any] | None = None,
|
|
1119
|
+
hitl_handler: "RemoteHITLHandler | None" = None,
|
|
1115
1120
|
**kwargs,
|
|
1116
1121
|
) -> str:
|
|
1117
1122
|
"""Run an agent with a message, streaming via a renderer.
|
|
@@ -1129,6 +1134,8 @@ class AgentClient(BaseClient):
|
|
|
1129
1134
|
"mcp_configs": {"mcp-id": {"setting": "on"}},
|
|
1130
1135
|
"agent_config": {"planning": True},
|
|
1131
1136
|
}
|
|
1137
|
+
hitl_handler: Optional RemoteHITLHandler for approval callbacks.
|
|
1138
|
+
Set GLAIP_HITL_AUTO_APPROVE=true for auto-approval without handler.
|
|
1132
1139
|
**kwargs: Additional arguments to pass to the run API.
|
|
1133
1140
|
|
|
1134
1141
|
Returns:
|
|
@@ -1185,6 +1192,7 @@ class AgentClient(BaseClient):
|
|
|
1185
1192
|
timeout_seconds,
|
|
1186
1193
|
agent_name,
|
|
1187
1194
|
meta,
|
|
1195
|
+
hitl_handler=hitl_handler,
|
|
1188
1196
|
)
|
|
1189
1197
|
|
|
1190
1198
|
except KeyboardInterrupt:
|
|
@@ -1201,6 +1209,13 @@ class AgentClient(BaseClient):
|
|
|
1201
1209
|
if multipart_data:
|
|
1202
1210
|
multipart_data.close()
|
|
1203
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
|
+
|
|
1204
1219
|
return self._finalize_renderer(
|
|
1205
1220
|
r,
|
|
1206
1221
|
final_text,
|
|
@@ -1282,6 +1297,7 @@ class AgentClient(BaseClient):
|
|
|
1282
1297
|
*,
|
|
1283
1298
|
request_timeout: float | None = None,
|
|
1284
1299
|
runtime_config: dict[str, Any] | None = None,
|
|
1300
|
+
hitl_handler: "RemoteHITLHandler | None" = None,
|
|
1285
1301
|
**kwargs,
|
|
1286
1302
|
) -> AsyncGenerator[dict, None]:
|
|
1287
1303
|
"""Async run an agent with a message, yielding streaming JSON chunks.
|
|
@@ -1298,16 +1314,26 @@ class AgentClient(BaseClient):
|
|
|
1298
1314
|
"mcp_configs": {"mcp-id": {"setting": "on"}},
|
|
1299
1315
|
"agent_config": {"planning": True},
|
|
1300
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.
|
|
1301
1321
|
**kwargs: Additional arguments (chat_history, pii_mapping, etc.)
|
|
1302
1322
|
|
|
1303
1323
|
Yields:
|
|
1304
1324
|
Dictionary containing parsed JSON chunks from the streaming response
|
|
1305
1325
|
|
|
1306
1326
|
Raises:
|
|
1327
|
+
NotImplementedError: If hitl_handler is provided (async HITL not yet supported)
|
|
1307
1328
|
AgentTimeoutError: When agent execution times out
|
|
1308
1329
|
httpx.TimeoutException: When general timeout occurs
|
|
1309
1330
|
Exception: For other unexpected errors
|
|
1310
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
|
+
)
|
|
1311
1337
|
# Include runtime_config in kwargs only when caller hasn't already provided it
|
|
1312
1338
|
if runtime_config is not None and "runtime_config" not in kwargs:
|
|
1313
1339
|
kwargs["runtime_config"] = runtime_config
|
glaip_sdk/client/hitl.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""HITL REST client for manual approval operations.
|
|
3
|
+
|
|
4
|
+
Authors:
|
|
5
|
+
GLAIP SDK Team
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from glaip_sdk.client.base import BaseClient
|
|
11
|
+
from glaip_sdk.hitl.base import HITLDecision
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HITLClient(BaseClient):
|
|
15
|
+
"""Client for HITL REST endpoints.
|
|
16
|
+
|
|
17
|
+
Use for manual approval workflows separate from agent runs.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> # List pending approvals
|
|
21
|
+
>>> pending = client.hitl.list_pending()
|
|
22
|
+
>>>
|
|
23
|
+
>>> # Approve a request
|
|
24
|
+
>>> client.hitl.approve(
|
|
25
|
+
... request_id="bc4d0a77-7800-470e-a91c-7fd663a66b4d",
|
|
26
|
+
... operator_input="Verified and approved",
|
|
27
|
+
... )
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def approve(
|
|
31
|
+
self,
|
|
32
|
+
request_id: str,
|
|
33
|
+
operator_input: str | None = None,
|
|
34
|
+
run_id: str | None = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Approve a HITL request.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
request_id: HITL request ID from SSE stream
|
|
40
|
+
operator_input: Optional notes/reason for approval
|
|
41
|
+
run_id: Optional client-side run correlation ID
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Response dict: {"status": "ok", "message": "..."}
|
|
45
|
+
"""
|
|
46
|
+
return self._post_decision(
|
|
47
|
+
request_id,
|
|
48
|
+
HITLDecision.APPROVED,
|
|
49
|
+
operator_input,
|
|
50
|
+
run_id,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def reject(
|
|
54
|
+
self,
|
|
55
|
+
request_id: str,
|
|
56
|
+
operator_input: str | None = None,
|
|
57
|
+
run_id: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Reject a HITL request.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
request_id: HITL request ID
|
|
63
|
+
operator_input: Optional reason for rejection
|
|
64
|
+
run_id: Optional run correlation ID
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Response dict
|
|
68
|
+
"""
|
|
69
|
+
return self._post_decision(
|
|
70
|
+
request_id,
|
|
71
|
+
HITLDecision.REJECTED,
|
|
72
|
+
operator_input,
|
|
73
|
+
run_id,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def skip(
|
|
77
|
+
self,
|
|
78
|
+
request_id: str,
|
|
79
|
+
operator_input: str | None = None,
|
|
80
|
+
run_id: str | None = None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""Skip a HITL request.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
request_id: HITL request ID
|
|
86
|
+
operator_input: Optional notes
|
|
87
|
+
run_id: Optional run correlation ID
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Response dict
|
|
91
|
+
"""
|
|
92
|
+
return self._post_decision(
|
|
93
|
+
request_id,
|
|
94
|
+
HITLDecision.SKIPPED,
|
|
95
|
+
operator_input,
|
|
96
|
+
run_id,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _post_decision(
|
|
100
|
+
self,
|
|
101
|
+
request_id: str,
|
|
102
|
+
decision: HITLDecision,
|
|
103
|
+
operator_input: str | None,
|
|
104
|
+
run_id: str | None,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Post HITL decision to backend."""
|
|
107
|
+
payload = {
|
|
108
|
+
"request_id": request_id,
|
|
109
|
+
"decision": decision.value,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if operator_input:
|
|
113
|
+
payload["operator_input"] = operator_input
|
|
114
|
+
if run_id:
|
|
115
|
+
payload["run_id"] = run_id
|
|
116
|
+
|
|
117
|
+
return self._request("POST", "/agents/hitl/decision", json=payload)
|
|
118
|
+
|
|
119
|
+
def list_pending(self) -> list[dict[str, Any]]:
|
|
120
|
+
"""List all pending HITL requests.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of pending request dicts with metadata:
|
|
124
|
+
[
|
|
125
|
+
{
|
|
126
|
+
"request_id": "...",
|
|
127
|
+
"tool": "...",
|
|
128
|
+
"arguments": {...},
|
|
129
|
+
"created_at": "...",
|
|
130
|
+
"agent_id": "...",
|
|
131
|
+
"hitl_metadata": {...},
|
|
132
|
+
},
|
|
133
|
+
...
|
|
134
|
+
]
|
|
135
|
+
"""
|
|
136
|
+
return self._request("GET", "/agents/hitl/pending")
|
glaip_sdk/client/main.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
12
12
|
|
|
13
13
|
from glaip_sdk.client.agents import AgentClient
|
|
14
14
|
from glaip_sdk.client.base import BaseClient
|
|
15
|
+
from glaip_sdk.client.hitl import HITLClient
|
|
15
16
|
from glaip_sdk.client.mcps import MCPClient
|
|
16
17
|
from glaip_sdk.client.schedules import ScheduleClient
|
|
17
18
|
from glaip_sdk.client.shared import build_shared_config
|
|
@@ -40,6 +41,7 @@ class Client(BaseClient):
|
|
|
40
41
|
self.tools = ToolClient(**shared_config)
|
|
41
42
|
self.mcps = MCPClient(**shared_config)
|
|
42
43
|
self.schedules = ScheduleClient(**shared_config)
|
|
44
|
+
self.hitl = HITLClient(**shared_config)
|
|
43
45
|
|
|
44
46
|
# ---- Core API Methods (Public Interface) ----
|
|
45
47
|
|
|
@@ -11,7 +11,10 @@ import json
|
|
|
11
11
|
import logging
|
|
12
12
|
from collections.abc import AsyncIterable, Callable
|
|
13
13
|
from time import monotonic
|
|
14
|
-
from typing import Any
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from glaip_sdk.hitl.remote import RemoteHITLHandler
|
|
15
18
|
|
|
16
19
|
import httpx
|
|
17
20
|
from rich.console import Console as _Console
|
|
@@ -130,6 +133,7 @@ class AgentRunRenderingManager:
|
|
|
130
133
|
timeout_seconds: float,
|
|
131
134
|
agent_name: str | None,
|
|
132
135
|
meta: dict[str, Any],
|
|
136
|
+
hitl_handler: RemoteHITLHandler | None = None,
|
|
133
137
|
) -> tuple[str, dict[str, Any], float | None, float | None]:
|
|
134
138
|
"""Process streaming events and accumulate response."""
|
|
135
139
|
final_text = ""
|
|
@@ -153,6 +157,7 @@ class AgentRunRenderingManager:
|
|
|
153
157
|
final_text,
|
|
154
158
|
stats_usage,
|
|
155
159
|
meta,
|
|
160
|
+
hitl_handler=hitl_handler,
|
|
156
161
|
)
|
|
157
162
|
|
|
158
163
|
if controller and getattr(controller, "enabled", False):
|
|
@@ -504,6 +509,7 @@ class AgentRunRenderingManager:
|
|
|
504
509
|
final_text: str,
|
|
505
510
|
stats_usage: dict[str, Any],
|
|
506
511
|
meta: dict[str, Any],
|
|
512
|
+
hitl_handler: RemoteHITLHandler | None = None,
|
|
507
513
|
) -> tuple[str, dict[str, Any]]:
|
|
508
514
|
"""Process a single streaming event.
|
|
509
515
|
|
|
@@ -513,6 +519,7 @@ class AgentRunRenderingManager:
|
|
|
513
519
|
final_text: Accumulated text so far.
|
|
514
520
|
stats_usage: Usage statistics dictionary.
|
|
515
521
|
meta: Metadata dictionary.
|
|
522
|
+
hitl_handler: Optional HITL handler for approval callbacks.
|
|
516
523
|
|
|
517
524
|
Returns:
|
|
518
525
|
Tuple of (updated_final_text, updated_stats_usage).
|
|
@@ -523,6 +530,17 @@ class AgentRunRenderingManager:
|
|
|
523
530
|
self._logger.debug("Non-JSON SSE fragment skipped")
|
|
524
531
|
return final_text, stats_usage
|
|
525
532
|
|
|
533
|
+
# Handle HITL event (non-blocking via thread)
|
|
534
|
+
if hitl_handler and self._is_hitl_pending_event(ev):
|
|
535
|
+
try:
|
|
536
|
+
hitl_handler.handle_hitl_event(ev)
|
|
537
|
+
except Exception as e:
|
|
538
|
+
# Log but don't crash stream
|
|
539
|
+
self._logger.error(
|
|
540
|
+
f"HITL handler error: {e}",
|
|
541
|
+
exc_info=True,
|
|
542
|
+
)
|
|
543
|
+
|
|
526
544
|
kind = (ev.get("metadata") or {}).get("kind")
|
|
527
545
|
renderer.on_event(ev)
|
|
528
546
|
|
|
@@ -590,6 +608,20 @@ class AgentRunRenderingManager:
|
|
|
590
608
|
return content
|
|
591
609
|
return final_text
|
|
592
610
|
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _is_hitl_pending_event(event: dict[str, Any]) -> bool:
|
|
613
|
+
"""Check if event is a pending HITL approval request.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
event: Parsed event dictionary.
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
True if event is a pending HITL request.
|
|
620
|
+
"""
|
|
621
|
+
metadata = event.get("metadata", {})
|
|
622
|
+
hitl_meta = metadata.get("hitl", {})
|
|
623
|
+
return hitl_meta.get("required") is True and hitl_meta.get("decision") == "pending"
|
|
624
|
+
|
|
593
625
|
def _handle_run_info_event(
|
|
594
626
|
self,
|
|
595
627
|
ev: dict[str, Any],
|
glaip_sdk/hitl/__init__.py
CHANGED
|
@@ -6,10 +6,23 @@ and remote agent execution modes.
|
|
|
6
6
|
For local development, LocalPromptHandler is automatically injected when
|
|
7
7
|
agent_config.hitl_enabled is True. No manual setup required.
|
|
8
8
|
|
|
9
|
+
For remote execution, use RemoteHITLHandler to handle HITL events programmatically.
|
|
10
|
+
|
|
9
11
|
Authors:
|
|
10
12
|
Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
|
|
13
|
+
GLAIP SDK Team
|
|
11
14
|
"""
|
|
12
15
|
|
|
16
|
+
from glaip_sdk.hitl.base import HITLCallback, HITLDecision, HITLRequest, HITLResponse
|
|
13
17
|
from glaip_sdk.hitl.local import LocalPromptHandler, PauseResumeCallback
|
|
18
|
+
from glaip_sdk.hitl.remote import RemoteHITLHandler
|
|
14
19
|
|
|
15
|
-
__all__ = [
|
|
20
|
+
__all__ = [
|
|
21
|
+
"LocalPromptHandler",
|
|
22
|
+
"PauseResumeCallback",
|
|
23
|
+
"HITLCallback",
|
|
24
|
+
"HITLDecision",
|
|
25
|
+
"HITLRequest",
|
|
26
|
+
"HITLResponse",
|
|
27
|
+
"RemoteHITLHandler",
|
|
28
|
+
]
|
glaip_sdk/hitl/base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Base types for HITL approval handling.
|
|
3
|
+
|
|
4
|
+
Authors:
|
|
5
|
+
GLAIP SDK Team
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HITLDecision(str, Enum):
|
|
14
|
+
"""HITL decision types."""
|
|
15
|
+
|
|
16
|
+
APPROVED = "approved"
|
|
17
|
+
REJECTED = "rejected"
|
|
18
|
+
SKIPPED = "skipped"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HITLRequest:
|
|
23
|
+
"""HITL approval request from SSE stream."""
|
|
24
|
+
|
|
25
|
+
request_id: str
|
|
26
|
+
tool_name: str
|
|
27
|
+
tool_args: dict[str, Any]
|
|
28
|
+
timeout_at: str # ISO 8601, authoritative deadline
|
|
29
|
+
timeout_seconds: int # Informational, fallback only
|
|
30
|
+
|
|
31
|
+
# Raw metadata for advanced use cases
|
|
32
|
+
hitl_metadata: dict[str, Any]
|
|
33
|
+
tool_metadata: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class HITLResponse:
|
|
38
|
+
"""HITL decision response."""
|
|
39
|
+
|
|
40
|
+
decision: HITLDecision
|
|
41
|
+
operator_input: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class HITLCallback(Protocol):
|
|
46
|
+
"""Protocol for HITL approval callbacks.
|
|
47
|
+
|
|
48
|
+
Callbacks should complete within the computed callback timeout.
|
|
49
|
+
Callbacks should handle exceptions internally or let them propagate.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __call__(self, request: HITLRequest) -> HITLResponse:
|
|
53
|
+
"""Handle HITL approval request.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
request: HITL request with tool info and metadata
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
HITLResponse with decision and optional operator input
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
Any exception will be caught, logged, and treated as REJECTED.
|
|
63
|
+
"""
|
|
64
|
+
...
|
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
|
|
@@ -5,7 +5,7 @@ glaip_sdk/exceptions.py,sha256=iAChFClkytXRBLP0vZq1_YjoZxA9i4m4bW1gDLiGR1g,2321
|
|
|
5
5
|
glaip_sdk/icons.py,sha256=J5THz0ReAmDwIiIooh1_G3Le-mwTJyEjhJDdJ13KRxM,524
|
|
6
6
|
glaip_sdk/rich_components.py,sha256=44Z0V1ZQleVh9gUDGwRR5mriiYFnVGOhm7fFxZYbP8c,4052
|
|
7
7
|
glaip_sdk/agents/__init__.py,sha256=VfYov56edbWuySXFEbWJ_jLXgwnFzPk1KB-9-mfsUCc,776
|
|
8
|
-
glaip_sdk/agents/base.py,sha256=
|
|
8
|
+
glaip_sdk/agents/base.py,sha256=GQnzCw2cqlrbxwdoWFfhBcBlEDgubY4tlD6gr1b3zps,44539
|
|
9
9
|
glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
|
|
10
10
|
glaip_sdk/cli/account_store.py,sha256=TK4iTV93Q1uD9mCY_2ZMT6EazHKU2jX0qhgWfEM4V-4,18459
|
|
11
11
|
glaip_sdk/cli/agent_config.py,sha256=YAbFKrTNTRqNA6b0i0Q3pH-01rhHDRi5v8dxSFwGSwM,2401
|
|
@@ -102,11 +102,12 @@ glaip_sdk/cli/transcript/viewer.py,sha256=Y4G40WR6v1g4TfxRbGSZqdrqhLcqBxoWkQgToQ
|
|
|
102
102
|
glaip_sdk/client/__init__.py,sha256=s2REOumgE8Z8lA9dWJpwXqpgMdzSELSuCQkZ7sYngX0,381
|
|
103
103
|
glaip_sdk/client/_schedule_payloads.py,sha256=9BXa75CCx3clsKgwmG9AWyvhPY6kVwzQtoLvTTw40CQ,2759
|
|
104
104
|
glaip_sdk/client/agent_runs.py,sha256=tZSFEZZ3Yx0uYRgnwkLe-X0TlmgKJQ-ivzb6SrVnxY8,4862
|
|
105
|
-
glaip_sdk/client/agents.py,sha256=
|
|
105
|
+
glaip_sdk/client/agents.py,sha256=kUNklI6QS39dgPFWMy1F0uw8En4rZ2fDiPW9B87jaVU,49724
|
|
106
106
|
glaip_sdk/client/base.py,sha256=BhNaC2TJJ2jVWRTYmfxD3WjYgAyIuWNz9YURdNXXjJo,18245
|
|
107
|
-
glaip_sdk/client/
|
|
107
|
+
glaip_sdk/client/hitl.py,sha256=dO_q-43miI0oGrJDyUrZ9MbettQp0hai4kjvPaYm010,3545
|
|
108
|
+
glaip_sdk/client/main.py,sha256=FqN-JIoCTN1pV-3O-ElIbe8t_cWYUDV4S0N8W018De0,9362
|
|
108
109
|
glaip_sdk/client/mcps.py,sha256=-JdaIkg0QE3egJ8p93eoOPULup8KbM2WRCcwlvqlqrA,14492
|
|
109
|
-
glaip_sdk/client/run_rendering.py,sha256=
|
|
110
|
+
glaip_sdk/client/run_rendering.py,sha256=L1mPDPQW_NLs7yi6S1dtJlnTUWVl9R9ioxVzcC8xBrU,27262
|
|
110
111
|
glaip_sdk/client/schedules.py,sha256=ZfPzCYzk4YRuPkjkTTgLe5Rqa07mi-h2WmP4H91mMZ0,14113
|
|
111
112
|
glaip_sdk/client/shared.py,sha256=esHlsR0LEfL-pFDaWebQjKKOLl09jsRY-2pllBUn4nU,522
|
|
112
113
|
glaip_sdk/client/tools.py,sha256=NzQTIsn-bjYN9EfGWCBqqawCIVs7auaccFv7BM_3oCc,23871
|
|
@@ -115,8 +116,10 @@ glaip_sdk/client/payloads/agent/__init__.py,sha256=gItEH2zt2secVq6n60oGA-ztdE5mc
|
|
|
115
116
|
glaip_sdk/client/payloads/agent/requests.py,sha256=5FuGEuypaEXlWBhB07JrDca_ecLg4bvo8mjyFBxAV9U,17139
|
|
116
117
|
glaip_sdk/client/payloads/agent/responses.py,sha256=1eRMI4JAIGqTB5zY_7D9ILQDRHPXR06U7JqHSmRp3Qs,1243
|
|
117
118
|
glaip_sdk/config/constants.py,sha256=Y03c6op0e7K0jTQ8bmWXhWAqsnjWxkAhWniq8Z0iEKY,1081
|
|
118
|
-
glaip_sdk/hitl/__init__.py,sha256=
|
|
119
|
+
glaip_sdk/hitl/__init__.py,sha256=PHayqIUL9BOh9QZQilisThisPL3G7ylG-XaPdllC_zc,851
|
|
120
|
+
glaip_sdk/hitl/base.py,sha256=EUN2igzydlYZ6_qmHU46Gyk3Bk9uyalZkCJ06XMRKJ8,1484
|
|
119
121
|
glaip_sdk/hitl/local.py,sha256=rzmaRK15BxgRX7cmklUcGQUotMYg8x2Gd9BWf39k6hw,5661
|
|
122
|
+
glaip_sdk/hitl/remote.py,sha256=cdO-wWwRGdyb0HYNMwIvHfvKwOqhqp-l7efnaC9b85M,18914
|
|
120
123
|
glaip_sdk/mcps/__init__.py,sha256=4jYrt8K__oxrxexHRcmnRBXt-W_tbJN61H9Kf2lVh4Q,551
|
|
121
124
|
glaip_sdk/mcps/base.py,sha256=jWwHjDF67_mtDGRp9p5SolANjVeB8jt1PSwPBtX876M,11654
|
|
122
125
|
glaip_sdk/models/__init__.py,sha256=Yfy5CKe9xt3MkyoRIiZ4PYy19LbEfv3GXi2PgqXW7iU,2973
|
|
@@ -200,8 +203,8 @@ glaip_sdk/utils/rendering/steps/format.py,sha256=Chnq7OBaj8XMeBntSBxrX5zSmrYeGcO
|
|
|
200
203
|
glaip_sdk/utils/rendering/steps/manager.py,sha256=BiBmTeQMQhjRMykgICXsXNYh1hGsss-fH9BIGVMWFi0,13194
|
|
201
204
|
glaip_sdk/utils/rendering/viewer/__init__.py,sha256=XrxmE2cMAozqrzo1jtDFm8HqNtvDcYi2mAhXLXn5CjI,457
|
|
202
205
|
glaip_sdk/utils/rendering/viewer/presenter.py,sha256=mlLMTjnyeyPVtsyrAbz1BJu9lFGQSlS-voZ-_Cuugv0,5725
|
|
203
|
-
glaip_sdk-0.7.
|
|
204
|
-
glaip_sdk-0.7.
|
|
205
|
-
glaip_sdk-0.7.
|
|
206
|
-
glaip_sdk-0.7.
|
|
207
|
-
glaip_sdk-0.7.
|
|
206
|
+
glaip_sdk-0.7.3.dist-info/METADATA,sha256=9tLJ5ibX4VkSGKji1SulcZiJ58d6Jefg8ts_UaLdU_E,8365
|
|
207
|
+
glaip_sdk-0.7.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
208
|
+
glaip_sdk-0.7.3.dist-info/entry_points.txt,sha256=65vNPUggyYnVGhuw7RhNJ8Fp2jygTcX0yxJBcBY3iLU,48
|
|
209
|
+
glaip_sdk-0.7.3.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
|
|
210
|
+
glaip_sdk-0.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|