glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,43 @@
1
+ """Pause/resume callback for HITL renderer control.
2
+
3
+ This module provides PauseResumeCallback which allows HITL prompt handlers
4
+ to control the live renderer without directly coupling to the renderer implementation.
5
+
6
+ Author:
7
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
8
+ """
9
+
10
+ from typing import Any
11
+
12
+
13
+ class PauseResumeCallback:
14
+ """Simple callback object for pausing/resuming the live renderer.
15
+
16
+ This allows the LocalPromptHandler to control the renderer without
17
+ directly coupling to the renderer implementation.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ """Initialize the callback."""
22
+ self._renderer: Any | None = None
23
+
24
+ def set_renderer(self, renderer: Any) -> None:
25
+ """Set the renderer instance.
26
+
27
+ Args:
28
+ renderer: RichStreamRenderer instance with pause_live() and resume_live() methods.
29
+ """
30
+ self._renderer = renderer
31
+
32
+ def pause(self) -> None:
33
+ """Pause the live renderer before prompting."""
34
+ if self._renderer and hasattr(self._renderer, "_shutdown_live"):
35
+ self._renderer._shutdown_live()
36
+
37
+ def resume(self) -> None:
38
+ """Resume the live renderer after prompting."""
39
+ if self._renderer and hasattr(self._renderer, "_ensure_live"):
40
+ self._renderer._ensure_live()
41
+
42
+
43
+ __all__ = ["PauseResumeCallback"]
@@ -0,0 +1,121 @@
1
+ """Local HITL prompt handler with interactive console support.
2
+
3
+ Author:
4
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
+ """
6
+
7
+ import os
8
+ from typing import Any
9
+
10
+ try:
11
+ from aip_agents.agent.hitl.prompt.base import BasePromptHandler
12
+ from aip_agents.schema.hitl import ApprovalDecision, ApprovalDecisionType, ApprovalRequest
13
+ except ImportError as e:
14
+ raise ImportError("aip_agents is required for local HITL. Install with: pip install 'glaip-sdk[local]'") from e
15
+
16
+ from rich.console import Console
17
+ from rich.prompt import Prompt
18
+
19
+
20
+ class LocalPromptHandler(BasePromptHandler):
21
+ """Local HITL prompt handler with interactive console prompts.
22
+
23
+ Experimental local HITL implementation with known limitations:
24
+ - Timeouts are not enforced (interactive prompts wait indefinitely)
25
+ - Relies on private renderer methods for pause/resume
26
+ - Only supports interactive terminal environments
27
+
28
+ The key insight from Rich documentation is that Live must be stopped before
29
+ using Prompt/input(), otherwise the input won't render properly.
30
+
31
+ Environment variables:
32
+ GLAIP_HITL_AUTO_APPROVE: Set to "true" (case-insensitive) to auto-approve
33
+ all requests without user interaction. Useful for integration tests and CI.
34
+ """
35
+
36
+ def __init__(self, *, pause_resume_callback: Any | None = None) -> None:
37
+ """Initialize the prompt handler.
38
+
39
+ Args:
40
+ pause_resume_callback: Optional callable with pause() and resume() methods
41
+ to control the live renderer during prompts. This is needed because
42
+ Rich Live interferes with Prompt/input().
43
+ """
44
+ super().__init__()
45
+ self._pause_resume = pause_resume_callback
46
+ self._console = Console()
47
+
48
+ async def prompt_for_decision(
49
+ self,
50
+ request: ApprovalRequest,
51
+ timeout_seconds: int,
52
+ context_keys: list[str] | None = None,
53
+ ) -> ApprovalDecision:
54
+ """Prompt for approval decision with live renderer pause/resume.
55
+
56
+ Supports auto-approval via GLAIP_HITL_AUTO_APPROVE environment variable
57
+ for integration testing and CI environments. Set to "true" (case-insensitive) to enable.
58
+ """
59
+ _ = (timeout_seconds, context_keys) # Suppress unused parameter warnings.
60
+
61
+ # Check for auto-approve mode (for integration tests/CI)
62
+ auto_approve = os.getenv("GLAIP_HITL_AUTO_APPROVE", "").lower() == "true"
63
+
64
+ if auto_approve:
65
+ # Auto-approve without user interaction
66
+ return ApprovalDecision(
67
+ request_id=request.request_id,
68
+ decision=ApprovalDecisionType.APPROVED,
69
+ operator_input="auto-approved",
70
+ )
71
+
72
+ # Pause the live renderer if callback is available
73
+ if self._pause_resume:
74
+ self._pause_resume.pause()
75
+
76
+ try:
77
+ # POC/MVP: Show what we're approving (still auto-approve for now)
78
+ self._print_request_info(request)
79
+
80
+ # POC/MVP: For testing, we can do actual input here
81
+ # Uncomment to enable real prompting:
82
+ response = Prompt.ask(
83
+ "\n[yellow]Approve this tool call?[/yellow] [dim](y/n/s)[/dim]",
84
+ console=self._console,
85
+ default="y",
86
+ )
87
+ response = response.lower().strip()
88
+
89
+ if response in ("y", "yes"):
90
+ decision = ApprovalDecisionType.APPROVED
91
+ elif response in ("n", "no"):
92
+ decision = ApprovalDecisionType.REJECTED
93
+ else:
94
+ decision = ApprovalDecisionType.SKIPPED
95
+
96
+ return ApprovalDecision(
97
+ request_id=request.request_id,
98
+ decision=decision,
99
+ operator_input=response if decision != ApprovalDecisionType.SKIPPED else None,
100
+ )
101
+ finally:
102
+ # Always resume the live renderer
103
+ if self._pause_resume:
104
+ self._pause_resume.resume()
105
+
106
+ def _print_request_info(self, request: ApprovalRequest) -> None:
107
+ """Print the approval request information."""
108
+ self._console.print()
109
+ self._console.rule("[yellow]HITL Approval Request[/yellow]", style="yellow")
110
+
111
+ tool_name = request.tool_name or "unknown"
112
+ self._console.print(f"[cyan]Tool:[/cyan] {tool_name}")
113
+
114
+ if hasattr(request, "arguments_preview") and request.arguments_preview:
115
+ self._console.print(f"[cyan]Arguments:[/cyan] {request.arguments_preview}")
116
+
117
+ if request.context:
118
+ self._console.print(f"[dim]Context: {request.context}[/dim]")
119
+
120
+
121
+ __all__ = ["LocalPromptHandler"]
@@ -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