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 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
- raise ValueError(f"{_AGENT_NOT_DEPLOYED_MSG}\n\n{get_local_runtime_missing_message()}")
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, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
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, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
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)
@@ -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
@@ -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],
@@ -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__ = ["LocalPromptHandler", "PauseResumeCallback"]
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
+ ...
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glaip-sdk
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Python SDK and CLI for GL AIP (GDP Labs AI Agent Package) - Build, run, and manage AI agents
5
5
  Author-email: Raymond Christopher <raymond.christopher@gdplabs.id>
6
6
  License: MIT
@@ -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=D3Ll2l_uxv2kIwPCuDhfy_bbSxHrHPaxp6op2c7TBl0,43580
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=jR6DmpW1XRvpa_ewtXfzOH-q2ipTIF--yHZDdasq5Mk,48249
105
+ glaip_sdk/client/agents.py,sha256=kUNklI6QS39dgPFWMy1F0uw8En4rZ2fDiPW9B87jaVU,49724
106
106
  glaip_sdk/client/base.py,sha256=BhNaC2TJJ2jVWRTYmfxD3WjYgAyIuWNz9YURdNXXjJo,18245
107
- glaip_sdk/client/main.py,sha256=hhMw2c64BfxYavvA2Hz0xCTQPe7EcOXjGKIahwsX_DI,9269
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=kERp78v50jojsNWHrjNEkbC8sgOpMacaqUdw5YZuK6A,26074
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=sg92Rpu8_vJIGi1ZEhx0-qWa1nGdvfrKyJAxtoDSKzo,494
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.1.dist-info/METADATA,sha256=Pb8KaBV3ypCfgVvXTZd8mWSP_MgdaqFFdUTEmgPal5I,8365
204
- glaip_sdk-0.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
205
- glaip_sdk-0.7.1.dist-info/entry_points.txt,sha256=65vNPUggyYnVGhuw7RhNJ8Fp2jygTcX0yxJBcBY3iLU,48
206
- glaip_sdk-0.7.1.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
207
- glaip_sdk-0.7.1.dist-info/RECORD,,
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,,