aip-agents-binary 0.5.20__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 (280) hide show
  1. aip_agents/__init__.py +65 -0
  2. aip_agents/a2a/__init__.py +19 -0
  3. aip_agents/a2a/server/__init__.py +10 -0
  4. aip_agents/a2a/server/base_executor.py +1086 -0
  5. aip_agents/a2a/server/google_adk_executor.py +198 -0
  6. aip_agents/a2a/server/langflow_executor.py +180 -0
  7. aip_agents/a2a/server/langgraph_executor.py +270 -0
  8. aip_agents/a2a/types.py +232 -0
  9. aip_agents/agent/__init__.py +27 -0
  10. aip_agents/agent/base_agent.py +970 -0
  11. aip_agents/agent/base_langgraph_agent.py +2942 -0
  12. aip_agents/agent/google_adk_agent.py +926 -0
  13. aip_agents/agent/google_adk_constants.py +6 -0
  14. aip_agents/agent/hitl/__init__.py +24 -0
  15. aip_agents/agent/hitl/config.py +28 -0
  16. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  17. aip_agents/agent/hitl/manager.py +532 -0
  18. aip_agents/agent/hitl/models.py +18 -0
  19. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  20. aip_agents/agent/hitl/prompt/base.py +42 -0
  21. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  22. aip_agents/agent/hitl/registry.py +149 -0
  23. aip_agents/agent/interface.py +138 -0
  24. aip_agents/agent/interfaces.py +65 -0
  25. aip_agents/agent/langflow_agent.py +464 -0
  26. aip_agents/agent/langgraph_memory_enhancer_agent.py +433 -0
  27. aip_agents/agent/langgraph_react_agent.py +2514 -0
  28. aip_agents/agent/system_instruction_context.py +34 -0
  29. aip_agents/clients/__init__.py +10 -0
  30. aip_agents/clients/langflow/__init__.py +10 -0
  31. aip_agents/clients/langflow/client.py +477 -0
  32. aip_agents/clients/langflow/types.py +18 -0
  33. aip_agents/constants.py +23 -0
  34. aip_agents/credentials/manager.py +132 -0
  35. aip_agents/examples/__init__.py +5 -0
  36. aip_agents/examples/compare_streaming_client.py +783 -0
  37. aip_agents/examples/compare_streaming_server.py +142 -0
  38. aip_agents/examples/demo_memory_recall.py +401 -0
  39. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  40. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  41. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  42. aip_agents/examples/hello_world_a2a_google_adk_server.py +79 -0
  43. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  44. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  45. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  46. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  47. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  48. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +105 -0
  49. aip_agents/examples/hello_world_a2a_langchain_server.py +79 -0
  50. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +78 -0
  51. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  52. aip_agents/examples/hello_world_a2a_langflow_server.py +82 -0
  53. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  54. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  55. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +92 -0
  56. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  57. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  58. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  59. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  60. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  61. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  62. aip_agents/examples/hello_world_a2a_langgraph_server.py +84 -0
  63. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +79 -0
  64. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +132 -0
  65. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  66. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  67. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +251 -0
  68. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  69. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +80 -0
  70. aip_agents/examples/hello_world_google_adk.py +41 -0
  71. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  72. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  73. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  74. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  75. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  76. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  77. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  78. aip_agents/examples/hello_world_langchain.py +28 -0
  79. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  80. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  81. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  82. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  83. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  84. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  85. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  86. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  87. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  88. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  89. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  90. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  91. aip_agents/examples/hello_world_langgraph.py +39 -0
  92. aip_agents/examples/hello_world_langgraph_bosa_twitter.py +41 -0
  93. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  94. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  95. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  96. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  97. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  98. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  99. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  100. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  101. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  102. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  103. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  104. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  105. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  106. aip_agents/examples/hello_world_pii_logger.py +21 -0
  107. aip_agents/examples/hello_world_sentry.py +133 -0
  108. aip_agents/examples/hello_world_step_limits.py +273 -0
  109. aip_agents/examples/hello_world_stock_a2a_server.py +103 -0
  110. aip_agents/examples/hello_world_tool_output_client.py +46 -0
  111. aip_agents/examples/hello_world_tool_output_server.py +114 -0
  112. aip_agents/examples/hitl_demo.py +724 -0
  113. aip_agents/examples/mcp_configs/configs.py +63 -0
  114. aip_agents/examples/mcp_servers/common.py +76 -0
  115. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  116. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  117. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  118. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  119. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  120. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  121. aip_agents/examples/pii_demo_langgraph_server.py +126 -0
  122. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  123. aip_agents/examples/pii_demo_multi_agent_server.py +247 -0
  124. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  125. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +88 -0
  126. aip_agents/examples/tools/__init__.py +27 -0
  127. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  128. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  129. aip_agents/examples/tools/data_generator_tool.py +103 -0
  130. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  131. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  132. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  133. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  134. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  135. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  136. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  137. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  138. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  139. aip_agents/examples/tools/random_chart_tool.py +142 -0
  140. aip_agents/examples/tools/serper_tool.py +202 -0
  141. aip_agents/examples/tools/stock_tools.py +82 -0
  142. aip_agents/examples/tools/table_generator_tool.py +167 -0
  143. aip_agents/examples/tools/time_tool.py +82 -0
  144. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  145. aip_agents/executor/agent_executor.py +473 -0
  146. aip_agents/executor/base.py +48 -0
  147. aip_agents/mcp/__init__.py +1 -0
  148. aip_agents/mcp/client/__init__.py +14 -0
  149. aip_agents/mcp/client/base_mcp_client.py +369 -0
  150. aip_agents/mcp/client/connection_manager.py +193 -0
  151. aip_agents/mcp/client/google_adk/__init__.py +11 -0
  152. aip_agents/mcp/client/google_adk/client.py +381 -0
  153. aip_agents/mcp/client/langchain/__init__.py +11 -0
  154. aip_agents/mcp/client/langchain/client.py +265 -0
  155. aip_agents/mcp/client/persistent_session.py +359 -0
  156. aip_agents/mcp/client/session_pool.py +351 -0
  157. aip_agents/mcp/client/transports.py +215 -0
  158. aip_agents/mcp/utils/__init__.py +7 -0
  159. aip_agents/mcp/utils/config_validator.py +139 -0
  160. aip_agents/memory/__init__.py +14 -0
  161. aip_agents/memory/adapters/__init__.py +10 -0
  162. aip_agents/memory/adapters/base_adapter.py +717 -0
  163. aip_agents/memory/adapters/mem0.py +84 -0
  164. aip_agents/memory/base.py +84 -0
  165. aip_agents/memory/constants.py +49 -0
  166. aip_agents/memory/factory.py +86 -0
  167. aip_agents/memory/guidance.py +20 -0
  168. aip_agents/memory/simple_memory.py +47 -0
  169. aip_agents/middleware/__init__.py +17 -0
  170. aip_agents/middleware/base.py +88 -0
  171. aip_agents/middleware/manager.py +128 -0
  172. aip_agents/middleware/todolist.py +274 -0
  173. aip_agents/schema/__init__.py +69 -0
  174. aip_agents/schema/a2a.py +56 -0
  175. aip_agents/schema/agent.py +111 -0
  176. aip_agents/schema/hitl.py +157 -0
  177. aip_agents/schema/langgraph.py +37 -0
  178. aip_agents/schema/model_id.py +97 -0
  179. aip_agents/schema/step_limit.py +108 -0
  180. aip_agents/schema/storage.py +40 -0
  181. aip_agents/sentry/__init__.py +11 -0
  182. aip_agents/sentry/sentry.py +151 -0
  183. aip_agents/storage/__init__.py +41 -0
  184. aip_agents/storage/base.py +85 -0
  185. aip_agents/storage/clients/__init__.py +12 -0
  186. aip_agents/storage/clients/minio_client.py +318 -0
  187. aip_agents/storage/config.py +62 -0
  188. aip_agents/storage/providers/__init__.py +15 -0
  189. aip_agents/storage/providers/base.py +106 -0
  190. aip_agents/storage/providers/memory.py +114 -0
  191. aip_agents/storage/providers/object_storage.py +214 -0
  192. aip_agents/tools/__init__.py +33 -0
  193. aip_agents/tools/bosa_tools.py +105 -0
  194. aip_agents/tools/browser_use/__init__.py +82 -0
  195. aip_agents/tools/browser_use/action_parser.py +103 -0
  196. aip_agents/tools/browser_use/browser_use_tool.py +1112 -0
  197. aip_agents/tools/browser_use/llm_config.py +120 -0
  198. aip_agents/tools/browser_use/minio_storage.py +198 -0
  199. aip_agents/tools/browser_use/schemas.py +119 -0
  200. aip_agents/tools/browser_use/session.py +76 -0
  201. aip_agents/tools/browser_use/session_errors.py +132 -0
  202. aip_agents/tools/browser_use/steel_session_recording.py +317 -0
  203. aip_agents/tools/browser_use/streaming.py +813 -0
  204. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  205. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  206. aip_agents/tools/browser_use/types.py +78 -0
  207. aip_agents/tools/code_sandbox/__init__.py +26 -0
  208. aip_agents/tools/code_sandbox/constant.py +13 -0
  209. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +257 -0
  210. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +411 -0
  211. aip_agents/tools/constants.py +165 -0
  212. aip_agents/tools/document_loader/__init__.py +44 -0
  213. aip_agents/tools/document_loader/base_reader.py +302 -0
  214. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  215. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  216. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  217. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  218. aip_agents/tools/gl_connector/__init__.py +5 -0
  219. aip_agents/tools/gl_connector/tool.py +351 -0
  220. aip_agents/tools/memory_search/__init__.py +22 -0
  221. aip_agents/tools/memory_search/base.py +200 -0
  222. aip_agents/tools/memory_search/mem0.py +258 -0
  223. aip_agents/tools/memory_search/schema.py +48 -0
  224. aip_agents/tools/memory_search_tool.py +26 -0
  225. aip_agents/tools/time_tool.py +117 -0
  226. aip_agents/tools/tool_config_injector.py +300 -0
  227. aip_agents/tools/web_search/__init__.py +15 -0
  228. aip_agents/tools/web_search/serper_tool.py +187 -0
  229. aip_agents/types/__init__.py +70 -0
  230. aip_agents/types/a2a_events.py +13 -0
  231. aip_agents/utils/__init__.py +79 -0
  232. aip_agents/utils/a2a_connector.py +1757 -0
  233. aip_agents/utils/artifact_helpers.py +502 -0
  234. aip_agents/utils/constants.py +22 -0
  235. aip_agents/utils/datetime/__init__.py +34 -0
  236. aip_agents/utils/datetime/normalization.py +231 -0
  237. aip_agents/utils/datetime/timezone.py +206 -0
  238. aip_agents/utils/env_loader.py +27 -0
  239. aip_agents/utils/event_handler_registry.py +58 -0
  240. aip_agents/utils/file_prompt_utils.py +176 -0
  241. aip_agents/utils/final_response_builder.py +211 -0
  242. aip_agents/utils/formatter_llm_client.py +231 -0
  243. aip_agents/utils/langgraph/__init__.py +19 -0
  244. aip_agents/utils/langgraph/converter.py +128 -0
  245. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  246. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  247. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  248. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1071 -0
  249. aip_agents/utils/langgraph/tool_output_management.py +967 -0
  250. aip_agents/utils/logger.py +195 -0
  251. aip_agents/utils/metadata/__init__.py +27 -0
  252. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  253. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  254. aip_agents/utils/metadata/activity_narrative/builder.py +817 -0
  255. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  256. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  257. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  258. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  259. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  260. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  261. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  262. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  263. aip_agents/utils/metadata_helper.py +358 -0
  264. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  265. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  266. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  267. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  268. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  269. aip_agents/utils/pii/__init__.py +25 -0
  270. aip_agents/utils/pii/pii_handler.py +397 -0
  271. aip_agents/utils/pii/pii_helper.py +207 -0
  272. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  273. aip_agents/utils/reference_helper.py +273 -0
  274. aip_agents/utils/sse_chunk_transformer.py +831 -0
  275. aip_agents/utils/step_limit_manager.py +265 -0
  276. aip_agents/utils/token_usage_helper.py +156 -0
  277. aip_agents_binary-0.5.20.dist-info/METADATA +681 -0
  278. aip_agents_binary-0.5.20.dist-info/RECORD +280 -0
  279. aip_agents_binary-0.5.20.dist-info/WHEEL +5 -0
  280. aip_agents_binary-0.5.20.dist-info/top_level.txt +1 -0
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env python3
2
+ """Interactive HITL (Human-in-the-Loop) Approval Demo.
3
+
4
+ This demo creates a recruitment-focused LangGraph agent that requires human approval for
5
+ critical steps in a candidate workflow.
6
+ You'll be prompted to approve/reject tool calls in real-time.
7
+
8
+ Usage:
9
+ python -m aip_agents.examples.hitl_demo
10
+
11
+ Requirements:
12
+ - OPENAI_API_KEY in environment variables or .env file (auto-loaded)
13
+ - Internet connection for LLM API calls
14
+ """
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import json
19
+ import logging
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from http import HTTPStatus
23
+
24
+ import httpx
25
+ import uvicorn
26
+ from langchain_core.tools import tool
27
+ from starlette.applications import Starlette
28
+ from starlette.requests import Request
29
+ from starlette.responses import JSONResponse
30
+ from starlette.routing import Route
31
+
32
+ from aip_agents.agent import LangGraphReactAgent
33
+ from aip_agents.schema.hitl import ApprovalRequest
34
+ from aip_agents.utils.env_loader import load_local_env
35
+ from aip_agents.utils.logger import get_logger
36
+
37
+ # Load environment variables for local development
38
+ load_local_env()
39
+
40
+ # Get logger instance for this demo
41
+ logger = get_logger("aip_agents.examples.hitl_demo", logging.CRITICAL)
42
+
43
+
44
+ @tool
45
+ def check_candidate_inbox(candidate_email: str) -> str:
46
+ """Retrieve the latest email from a candidate (safe tool).
47
+
48
+ Args:
49
+ candidate_email (str): The email address of the candidate.
50
+
51
+ Returns:
52
+ str: The latest email content from the candidate.
53
+ """
54
+ profile = CANDIDATE_PROFILES.get(candidate_email.lower())
55
+ if profile and isinstance(profile.get("inbox"), str):
56
+ return profile["inbox"]
57
+ return f"Email from {candidate_email}: Thank you for the update. Looking forward to next steps."
58
+
59
+
60
+ @tool
61
+ def validate_candidate(candidate_name: str, role: str, score: int) -> str:
62
+ """Record the candidate decision in the applicant tracking system.
63
+
64
+ Args:
65
+ candidate_name (str): The name of the candidate.
66
+ role (str): The role the candidate is being evaluated for.
67
+ score (int): The evaluation score for the candidate.
68
+
69
+ Returns:
70
+ str: The validation result with recommendation and notes.
71
+ """
72
+ profile = NAME_INDEX.get(candidate_name.lower())
73
+ if profile is None:
74
+ return (
75
+ f"Candidate {candidate_name} evaluated for {role}. Final assessment score: {score}. "
76
+ "Recommendation data not found; manual review required."
77
+ )
78
+
79
+ recommendation = profile.get("recommendation", "pending")
80
+ summary = profile.get("notes", "No additional notes provided.")
81
+ actual_score = profile.get("score", score)
82
+ if recommendation == "approved":
83
+ status_text = "ATS recommendation: move forward"
84
+ elif recommendation == "rejected":
85
+ status_text = "ATS recommendation: do not proceed"
86
+ else:
87
+ status_text = "ATS recommendation: pending hiring committee review"
88
+
89
+ return (
90
+ f"Candidate {profile['name']} evaluated for {role}. Final assessment score: {actual_score}. "
91
+ f"{summary} {status_text}."
92
+ )
93
+
94
+
95
+ @tool
96
+ def send_candidate_email(candidate_email: str, subject: str, body: str) -> str:
97
+ """Send an email update to the candidate.
98
+
99
+ Args:
100
+ candidate_email (str): The email address of the candidate.
101
+ subject (str): The subject line of the email.
102
+ body (str): The body content of the email.
103
+
104
+ Returns:
105
+ str: Confirmation message that the email was sent.
106
+ """
107
+ profile = CANDIDATE_PROFILES.get(candidate_email.lower())
108
+ if profile is not None:
109
+ profile["last_email_subject"] = subject
110
+ profile["last_email_body"] = body
111
+ return f"Email sent to {candidate_email} with subject '{subject}'"
112
+
113
+
114
+ CANDIDATE_PROFILES: dict[str, dict[str, str | int | bool]] = {
115
+ "jane.doe@example.com": {
116
+ "email": "jane.doe@example.com",
117
+ "name": "Jane Doe",
118
+ "role": "Senior Backend Engineer",
119
+ "score": 87,
120
+ "recommendation": "approved",
121
+ "inbox": (
122
+ "Hi team, thanks for the update! I'm excited about the opportunity and available for a call next week."
123
+ ),
124
+ "notes": "Strong performance in system design and coding exercises.",
125
+ "offer_subject": "Offer Confirmation — Senior Backend Engineer",
126
+ "pending_subject": "Interview Update — Senior Backend Engineer",
127
+ "rejection_subject": "Application Update — Senior Backend Engineer",
128
+ },
129
+ "sam.lee@example.com": {
130
+ "email": "sam.lee@example.com",
131
+ "name": "Sam Lee",
132
+ "role": "Senior Backend Engineer",
133
+ "score": 68,
134
+ "recommendation": "rejected",
135
+ "inbox": (
136
+ "Hello recruiter, I appreciate the opportunity. Please let me know if you need any "
137
+ "additional information from my end."
138
+ ),
139
+ "notes": "Great collaboration skills but struggled with distributed systems questions.",
140
+ "offer_subject": "Offer Confirmation — Senior Backend Engineer",
141
+ "pending_subject": "Interview Update — Senior Backend Engineer",
142
+ "rejection_subject": "Application Update — Senior Backend Engineer",
143
+ },
144
+ }
145
+
146
+ CANDIDATE_SEQUENCE: list[dict[str, str | int | bool]] = [
147
+ CANDIDATE_PROFILES["jane.doe@example.com"],
148
+ CANDIDATE_PROFILES["sam.lee@example.com"],
149
+ ]
150
+
151
+ NAME_INDEX = {profile["name"].lower(): profile for profile in CANDIDATE_SEQUENCE}
152
+
153
+
154
+ def _normalize_timeout_decision(decision: str | None) -> str:
155
+ """Treat timeout (or missing) decisions as skips for downstream handling.
156
+
157
+ Args:
158
+ decision (str | None): The decision to normalize, or None if timed out.
159
+
160
+ Returns:
161
+ str: The normalized decision ("approved", "rejected", or "skipped").
162
+ """
163
+ if decision in {None, "timeout"}:
164
+ return "skipped"
165
+ return decision
166
+
167
+
168
+ def _summarize_outcome(
169
+ name: str,
170
+ validation: str,
171
+ email: str,
172
+ *,
173
+ validation_timeout: bool,
174
+ email_timeout: bool,
175
+ ) -> str:
176
+ if validation_timeout:
177
+ validation_text = f"left {name}'s hiring decision pending (timed out)"
178
+ elif validation == "approved":
179
+ validation_text = f"approved {name}'s hiring decision"
180
+ elif validation == "rejected":
181
+ validation_text = f"rejected {name}'s hiring decision"
182
+ else:
183
+ validation_text = f"left {name}'s hiring decision pending"
184
+
185
+ if email_timeout:
186
+ email_text = "no email update was sent (skipped due to timeout)"
187
+ elif email == "approved":
188
+ email_text = "an email update was sent"
189
+ elif email == "skipped":
190
+ email_text = "no email update was sent yet"
191
+ else:
192
+ email_text = f"email outcome recorded as '{email}'"
193
+
194
+ return f" - You {validation_text}; {email_text}."
195
+
196
+
197
+ def _print_intro() -> None:
198
+ print("šŸš€ HITL Approval Demo")
199
+ print("This demo requires real LLM API access and will prompt for human input.")
200
+ print(
201
+ "Scenario: recruitment coordinator processing candidate updates."
202
+ " Steps: check candidate inbox (safe), validate candidate (approval), send candidate email (approval)."
203
+ )
204
+ print("This demo walks through two candidates: one recommended to move forward and one declined.")
205
+ print(
206
+ "Commands are shown for each step (e.g., a/r or send/cancel). "
207
+ "You can append optional comments like 'send looks good'."
208
+ )
209
+ print()
210
+
211
+
212
+ def _format_json_block(raw: str) -> str:
213
+ try:
214
+ parsed = json.loads(raw)
215
+ except (TypeError, ValueError):
216
+ return raw
217
+ return json.dumps(parsed, indent=2, ensure_ascii=False)
218
+
219
+
220
+ def _format_timeout(request: ApprovalRequest) -> str:
221
+ if request.timeout_at and isinstance(request.timeout_at, datetime):
222
+ return request.timeout_at.isoformat()
223
+ return "n/a"
224
+
225
+
226
+ def _print_multiline(label: str, content: str) -> None:
227
+ print(f"{label}:")
228
+ if not content.strip():
229
+ print(" (none)")
230
+ return
231
+ for line in content.splitlines():
232
+ print(f" {line}")
233
+
234
+
235
+ def _print_command_help(tool_name: str) -> None:
236
+ print("Commands:")
237
+ if tool_name == "validate_candidate":
238
+ options = [
239
+ ("a", "approve"),
240
+ ("r", "reject"),
241
+ ]
242
+ elif tool_name == "send_candidate_email":
243
+ options = [
244
+ ("send", "send email"),
245
+ ("cancel", "cancel email"),
246
+ ]
247
+ else:
248
+ options = [
249
+ ("a", "approve"),
250
+ ("s", "skip"),
251
+ ("r", "reject"),
252
+ ]
253
+
254
+ for key, description in options:
255
+ print(f" [{key}] {description}")
256
+ print(" (optional comment after command, e.g. 'send looks good')")
257
+
258
+
259
+ def _decision_mapping(tool_name: str) -> tuple[dict[str, str], str]:
260
+ if tool_name == "validate_candidate":
261
+ return (
262
+ {
263
+ "a": "approved",
264
+ "approve": "approved",
265
+ "approved": "approved",
266
+ "r": "rejected",
267
+ "reject": "rejected",
268
+ "rejected": "rejected",
269
+ },
270
+ "Please enter a or r.",
271
+ )
272
+
273
+ if tool_name == "send_candidate_email":
274
+ return (
275
+ {
276
+ "send": "approved",
277
+ "s": "approved",
278
+ "approved": "approved",
279
+ "cancel": "skipped",
280
+ "c": "skipped",
281
+ "skip": "skipped",
282
+ },
283
+ "Please enter send or cancel.",
284
+ )
285
+
286
+ return (
287
+ {
288
+ "a": "approved",
289
+ "approve": "approved",
290
+ "approved": "approved",
291
+ "s": "skipped",
292
+ "skip": "skipped",
293
+ "skipped": "skipped",
294
+ "r": "rejected",
295
+ "reject": "rejected",
296
+ "rejected": "rejected",
297
+ },
298
+ "Please enter a, s, or r.",
299
+ )
300
+
301
+
302
+ def _print_pending_request(request: ApprovalRequest) -> None:
303
+ border = "═" * 70
304
+ print(f"\n{border}")
305
+ print("šŸ”’ Approval Required")
306
+ print(border)
307
+ print(f"Tool : {request.tool_name}")
308
+ print(f"Request ID : {request.request_id}")
309
+ print(f"Timeout At : {_format_timeout(request)}")
310
+
311
+ arguments_block = _format_json_block(request.arguments_preview)
312
+ _print_multiline("Arguments", arguments_block)
313
+
314
+ context_text = json.dumps(request.context, indent=2, ensure_ascii=False) if request.context else ""
315
+ _print_multiline("Context", context_text)
316
+
317
+ print()
318
+ _print_command_help(request.tool_name)
319
+ print(border)
320
+
321
+
322
+ def _build_demo_agent() -> "LangGraphReactAgent":
323
+ return LangGraphReactAgent(
324
+ name="HITL Demo Agent",
325
+ instruction=(
326
+ "You are a recruitment coordinator preparing a candidate update. "
327
+ "When asked for the latest message from a candidate, call check_candidate_inbox with their email. "
328
+ "When directed to record the hiring decision, you must call validate_candidate "
329
+ "before taking any other action. "
330
+ "If the validation is rejected you must halt the workflow and inform the user; if it is skipped, "
331
+ "you may continue but clarify in follow-up actions that the decision remains pending "
332
+ "and do not attempt to validate again unless explicitly asked. "
333
+ "Use send_candidate_email to notify the candidate once the decision status is settled."
334
+ ),
335
+ model="openai/gpt-4.1",
336
+ tools=[
337
+ check_candidate_inbox,
338
+ validate_candidate,
339
+ send_candidate_email,
340
+ ],
341
+ tool_configs={
342
+ "tool_configs": {
343
+ "send_candidate_email": {"hitl": {"timeout_seconds": 30}},
344
+ "validate_candidate": {"hitl": {"timeout_seconds": 10}},
345
+ }
346
+ },
347
+ )
348
+
349
+
350
+ def _candidate_inbox_query(profile: dict[str, str | int | bool]) -> str:
351
+ return (
352
+ f"Check the candidate inbox for {profile['email']} using check_candidate_inbox and "
353
+ "summarise key information they provided."
354
+ )
355
+
356
+
357
+ def _validate_candidate_query(profile: dict[str, str | int | bool]) -> str:
358
+ return (
359
+ f"Validate {profile['name']} for the {profile['role']} role using validate_candidate "
360
+ f"with a final score of {profile['score']}. Highlight the main strengths noted during interviews."
361
+ )
362
+
363
+
364
+ def _candidate_email_query(
365
+ profile: dict[str, str | int | bool],
366
+ validation_status: str | None,
367
+ ) -> str:
368
+ if validation_status == "approved":
369
+ subject = profile.get("offer_subject", "Offer Confirmation")
370
+ return (
371
+ "You must notify the candidate of the offer. "
372
+ f"Call send_candidate_email to send an offer confirmation to {profile['email']} with subject '{subject}'. "
373
+ "Do not call validate_candidate again; the decision has already been recorded."
374
+ )
375
+
376
+ if validation_status in {"skipped", "timeout", None}:
377
+ subject = profile.get("pending_subject", "Interview Update")
378
+ return (
379
+ "The decision is still pending. "
380
+ f"Call send_candidate_email to provide an update to {profile['email']} with subject '{subject}'. "
381
+ "Do not attempt to re-run validate_candidate; simply let the candidate know the decision is pending."
382
+ )
383
+
384
+ subject = profile.get("rejection_subject", "Application Update")
385
+ return (
386
+ "The candidate has been declined. "
387
+ f"Call send_candidate_email to send a polite rejection email to {profile['email']} with subject '{subject}'. "
388
+ "Do not call validate_candidate again during this follow-up."
389
+ )
390
+
391
+
392
+ def _create_server_app(agent: "LangGraphReactAgent") -> tuple[Starlette, asyncio.Queue[ApprovalRequest]]:
393
+ pending_queue: asyncio.Queue[ApprovalRequest] = asyncio.Queue()
394
+
395
+ def notifier(request: ApprovalRequest) -> None:
396
+ try:
397
+ loop = asyncio.get_running_loop()
398
+ loop.call_soon_threadsafe(pending_queue.put_nowait, request)
399
+ except RuntimeError:
400
+ pending_queue.put_nowait(request)
401
+
402
+ agent.register_hitl_notifier(notifier)
403
+ _ = agent.hitl_manager
404
+
405
+ async def run_agent(request: Request) -> JSONResponse:
406
+ payload = await request.json()
407
+ message = payload.get("message", "")
408
+ result = await agent.arun(message, recursion_limit=5)
409
+ output = result.get("output")
410
+ serialized_state = repr(result.get("full_final_state"))
411
+ return JSONResponse({"output": output, "state": serialized_state})
412
+
413
+ async def hitl_decision(request: Request) -> JSONResponse:
414
+ payload = await request.json()
415
+ request_id = payload.get("request_id")
416
+ decision = payload.get("decision")
417
+ operator_input = payload.get("operator_input", "")
418
+ try:
419
+ agent.hitl_manager.resolve_pending_request(request_id, decision, operator_input=operator_input)
420
+ return JSONResponse({"status": "ok"})
421
+ except KeyError as exc:
422
+ return JSONResponse({"error": str(exc)}, status_code=404)
423
+ except ValueError as exc:
424
+ return JSONResponse({"error": str(exc)}, status_code=400)
425
+
426
+ routes = [
427
+ Route("/agent/run", run_agent, methods=["POST"]),
428
+ Route("/hitl/decision", hitl_decision, methods=["POST"]),
429
+ ]
430
+
431
+ app = Starlette(routes=routes)
432
+ return app, pending_queue
433
+
434
+
435
+ class _ServerContext:
436
+ def __init__(self, app: Starlette, host: str, port: int) -> None:
437
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
438
+ self._server = uvicorn.Server(config)
439
+ self._server.install_signal_handlers = False
440
+ self._task: asyncio.Task | None = None
441
+
442
+ async def __aenter__(self) -> "_ServerContext":
443
+ self._task = asyncio.create_task(self._server.serve())
444
+ while not self._server.started:
445
+ await asyncio.sleep(0.05)
446
+ return self
447
+
448
+ async def __aexit__(self, exc_type, exc, _tb) -> None:
449
+ self._server.should_exit = True
450
+ if self._task:
451
+ await self._task
452
+
453
+
454
+ def _drain_queue(queue: asyncio.Queue[ApprovalRequest]) -> None:
455
+ try:
456
+ while True:
457
+ queue.get_nowait()
458
+ except asyncio.QueueEmpty:
459
+ pass
460
+
461
+
462
+ async def _invoke_run_endpoint(
463
+ http_client: httpx.AsyncClient,
464
+ host: str,
465
+ port: int,
466
+ message: str,
467
+ ) -> dict[str, str]:
468
+ response = await http_client.post(
469
+ f"http://{host}:{port}/agent/run",
470
+ json={"message": message},
471
+ timeout=None,
472
+ )
473
+ response.raise_for_status()
474
+ return response.json()
475
+
476
+
477
+ async def _prompt_and_send_decision(
478
+ http_client: httpx.AsyncClient,
479
+ host: str,
480
+ port: int,
481
+ request: ApprovalRequest,
482
+ ) -> str:
483
+ _print_pending_request(request)
484
+ mapping, invalid_message = _decision_mapping(request.tool_name)
485
+
486
+ while True:
487
+ user_input = await asyncio.to_thread(input, "> ")
488
+ stripped = user_input.strip()
489
+ if not stripped:
490
+ print(invalid_message)
491
+ continue
492
+
493
+ first_token, *rest = stripped.split(maxsplit=1)
494
+ token = first_token.lower()
495
+ decision = mapping.get(token)
496
+
497
+ if decision is None:
498
+ print(invalid_message)
499
+ continue
500
+
501
+ resp = await http_client.post(
502
+ f"http://{host}:{port}/hitl/decision",
503
+ json={
504
+ "request_id": request.request_id,
505
+ "decision": decision,
506
+ "operator_input": stripped,
507
+ },
508
+ timeout=None,
509
+ )
510
+ if resp.status_code != HTTPStatus.OK:
511
+ if resp.status_code in {HTTPStatus.NOT_FOUND, HTTPStatus.GONE}:
512
+ print(f"ā±ļø Request {request.request_id} expired (status {resp.status_code}). Skipping this action.")
513
+ return "timeout"
514
+
515
+ print(f"Failed to submit decision ({resp.status_code}): {resp.text}")
516
+ continue
517
+
518
+ comment = rest[0] if rest else ""
519
+ base_msg = f"Submitted '{decision}' for request {request.request_id}"
520
+ if comment:
521
+ base_msg += f" with comment: {comment}"
522
+ print(base_msg + ". Waiting for agent...\n")
523
+ return decision
524
+
525
+
526
+ @dataclass
527
+ class RunContext:
528
+ """Context object containing runtime dependencies for the HITL demo server."""
529
+
530
+ http_client: httpx.AsyncClient
531
+ host: str
532
+ port: int
533
+ pending_queue: asyncio.Queue[ApprovalRequest]
534
+
535
+
536
+ async def _run_step(
537
+ context: RunContext,
538
+ message: str,
539
+ *,
540
+ step_name: str,
541
+ allowed_tools: set[str] | None = None,
542
+ ) -> tuple[str, dict[str, str]]:
543
+ print(f"\n—— {step_name} ——")
544
+
545
+ _drain_queue(context.pending_queue)
546
+
547
+ run_task = asyncio.create_task(_invoke_run_endpoint(context.http_client, context.host, context.port, message))
548
+ queue_task = asyncio.create_task(context.pending_queue.get())
549
+ decisions: dict[str, str] = {}
550
+
551
+ try:
552
+ while True:
553
+ done, _ = await asyncio.wait({run_task, queue_task}, return_when=asyncio.FIRST_COMPLETED)
554
+
555
+ if run_task in done:
556
+ result = run_task.result()
557
+ queue_task.cancel()
558
+ with contextlib.suppress(asyncio.CancelledError):
559
+ await queue_task
560
+
561
+ output = result.get("output", "No output")
562
+ if output:
563
+ print("šŸ¤– AI response:")
564
+ print(output)
565
+ else:
566
+ print("šŸ¤– AI response: (no message returned)")
567
+ if decisions:
568
+ print("šŸ“ HITL decisions during this step:")
569
+ for tool, decision in decisions.items():
570
+ print(f" - {tool}: {decision}")
571
+ return output, decisions
572
+
573
+ if queue_task in done:
574
+ request = queue_task.result()
575
+
576
+ if allowed_tools is not None and request.tool_name not in allowed_tools:
577
+ print(f"āš™ļø Auto-skipping unexpected tool '{request.tool_name}' during {step_name}.")
578
+ await context.http_client.post(
579
+ f"http://{context.host}:{context.port}/hitl/decision",
580
+ json={
581
+ "request_id": request.request_id,
582
+ "decision": "skipped",
583
+ "operator_input": "AUTO_SKIP_UNEXPECTED_TOOL",
584
+ },
585
+ timeout=None,
586
+ )
587
+ else:
588
+ decision = await _prompt_and_send_decision(context.http_client, context.host, context.port, request)
589
+ decisions[request.tool_name] = decision
590
+
591
+ queue_task = asyncio.create_task(context.pending_queue.get())
592
+
593
+ except Exception as exc: # noqa: BLE001
594
+ run_task.cancel()
595
+ queue_task.cancel()
596
+ with contextlib.suppress(asyncio.CancelledError):
597
+ await run_task
598
+ await queue_task
599
+ _handle_run_error(exc)
600
+ return "", {}
601
+
602
+
603
+ async def _run_workflow(context: RunContext) -> None:
604
+ summaries: list[dict[str, str | bool]] = []
605
+
606
+ print("šŸ¤– Workflow starting.")
607
+ print(" Step 1 (candidate inbox) runs automatically without approval.")
608
+ print(
609
+ " Steps 2 and 3 will prompt you for HITL decisions; use the keys listed in the command panel "
610
+ "(e.g., a/r or send/cancel)."
611
+ )
612
+
613
+ for profile in CANDIDATE_SEQUENCE:
614
+ name = profile["name"]
615
+ email = profile["email"]
616
+
617
+ print("\n==================================================")
618
+ print(f"Processing candidate: {name} ({email}) — {profile['role']}")
619
+ print("==================================================")
620
+ recommendation = profile.get("recommendation", "pending")
621
+ score = profile.get("score", "n/a")
622
+ print(f"ATS recommendation: {recommendation} (score: {score})")
623
+
624
+ await _run_step(
625
+ context,
626
+ _candidate_inbox_query(profile),
627
+ step_name=f"{name}: Inbox review (no approval)",
628
+ allowed_tools=None,
629
+ )
630
+
631
+ _, validation_decisions = await _run_step(
632
+ context,
633
+ _validate_candidate_query(profile),
634
+ step_name=f"{name}: Validation (approval required)",
635
+ allowed_tools={"validate_candidate"},
636
+ )
637
+
638
+ raw_validation_status = validation_decisions.get("validate_candidate")
639
+ validation_status = _normalize_timeout_decision(raw_validation_status)
640
+
641
+ if raw_validation_status == "timeout":
642
+ print("ā±ļø Validation timed out — treating as skipped; the decision remains pending.")
643
+ elif validation_status == "rejected":
644
+ print("āŒ Validation rejected — the candidate will be notified of the decision.")
645
+ elif validation_status == "skipped":
646
+ print("āš ļø Validation skipped — notification will indicate the decision is still pending.")
647
+ else:
648
+ print("āœ… Candidate validation approved — proceeding to send offer confirmation.")
649
+
650
+ _, email_decisions = await _run_step(
651
+ context,
652
+ _candidate_email_query(profile, validation_status),
653
+ step_name=f"{name}: Candidate email (approval required)",
654
+ allowed_tools={"send_candidate_email"},
655
+ )
656
+
657
+ raw_email_status = email_decisions.get("send_candidate_email")
658
+ email_status = _normalize_timeout_decision(raw_email_status)
659
+
660
+ if raw_email_status == "timeout":
661
+ print("ā±ļø Email send timed out — treating as skipped; no notification was sent.")
662
+
663
+ summaries.append(
664
+ {
665
+ "name": name,
666
+ "validation": validation_status,
667
+ "validation_timeout": raw_validation_status == "timeout",
668
+ "email_decision": email_status,
669
+ "email_timeout": raw_email_status == "timeout",
670
+ }
671
+ )
672
+
673
+ summary_messages: list[str] = [
674
+ _summarize_outcome(
675
+ name=summary["name"],
676
+ validation=summary["validation"],
677
+ email=summary["email_decision"],
678
+ validation_timeout=bool(summary.get("validation_timeout", False)),
679
+ email_timeout=bool(summary.get("email_timeout", False)),
680
+ )
681
+ for summary in summaries
682
+ ]
683
+
684
+ print("\nšŸ“‹ What happened:")
685
+ for message in summary_messages:
686
+ print(message)
687
+
688
+ print("\nšŸŽ‰ Workflow completed successfully!")
689
+
690
+
691
+ def _handle_run_error(error: Exception) -> None:
692
+ message = str(error)
693
+ if "api_key" in message.lower():
694
+ print("\nāŒ Error: OpenAI API key not found!")
695
+ print("Make sure OPENAI_API_KEY is set in your environment or .env file.")
696
+ print("The demo automatically loads from .env files, so create one with:")
697
+ print(" echo 'OPENAI_API_KEY=your-key-here' > .env")
698
+ else:
699
+ print(f"\nāŒ Error: {error}")
700
+
701
+
702
+ async def main():
703
+ """Interactive HITL approval demo."""
704
+ _print_intro()
705
+ agent = _build_demo_agent()
706
+ app, pending_queue = _create_server_app(agent)
707
+ host, port = "127.0.0.1", 8787
708
+
709
+ async with _ServerContext(app, host, port):
710
+ print(f"šŸ–„ļø HITL API listening on http://{host}:{port}")
711
+ print(' POST /agent/run {"message": ...}')
712
+ print(' POST /hitl/decision {"request_id": ..., "decision": ...}')
713
+ async with httpx.AsyncClient(timeout=None) as http_client:
714
+ context = RunContext(
715
+ http_client=http_client,
716
+ host=host,
717
+ port=port,
718
+ pending_queue=pending_queue,
719
+ )
720
+ await _run_workflow(context)
721
+
722
+
723
+ if __name__ == "__main__":
724
+ asyncio.run(main())