aip-agents-binary 0.0.0__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 (447) hide show
  1. aip_agents/__init__.py +66 -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 +1125 -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/a2ui/__init__.py +26 -0
  10. aip_agents/a2ui/catalog_provider.py +109 -0
  11. aip_agents/a2ui/examples/0.8/booking_form.json +1 -0
  12. aip_agents/a2ui/examples/0.8/confirmation.json +1 -0
  13. aip_agents/a2ui/examples/0.8/single_column_list.json +1 -0
  14. aip_agents/a2ui/examples/0.9/booking_form.json +1 -0
  15. aip_agents/a2ui/examples/0.9/confirmation.json +1 -0
  16. aip_agents/a2ui/examples/0.9/single_column_list.json +1 -0
  17. aip_agents/a2ui/middleware.py +754 -0
  18. aip_agents/a2ui/models.py +153 -0
  19. aip_agents/a2ui/public_url.py +138 -0
  20. aip_agents/a2ui/stream_filter.py +67 -0
  21. aip_agents/agent/__init__.py +72 -0
  22. aip_agents/agent/base_agent.py +1437 -0
  23. aip_agents/agent/base_langgraph_agent.py +4048 -0
  24. aip_agents/agent/google_adk_agent.py +932 -0
  25. aip_agents/agent/google_adk_constants.py +11 -0
  26. aip_agents/agent/graph_runtime.py +185 -0
  27. aip_agents/agent/hitl/__init__.py +24 -0
  28. aip_agents/agent/hitl/config.py +28 -0
  29. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  30. aip_agents/agent/hitl/manager.py +532 -0
  31. aip_agents/agent/hitl/models.py +18 -0
  32. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  33. aip_agents/agent/hitl/prompt/base.py +42 -0
  34. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  35. aip_agents/agent/hitl/registry.py +149 -0
  36. aip_agents/agent/interface.py +138 -0
  37. aip_agents/agent/interfaces.py +65 -0
  38. aip_agents/agent/langflow_agent.py +464 -0
  39. aip_agents/agent/langgraph_react_agent.py +3741 -0
  40. aip_agents/agent/model_invocation_adapter.py +346 -0
  41. aip_agents/agent/system_instruction_context.py +34 -0
  42. aip_agents/agent/tool_execution_engine.py +587 -0
  43. aip_agents/audio_interface/__init__.py +30 -0
  44. aip_agents/audio_interface/audio_agent_adapter.py +254 -0
  45. aip_agents/audio_interface/config.py +146 -0
  46. aip_agents/audio_interface/errors.py +15 -0
  47. aip_agents/audio_interface/livekit_audio_session.py +504 -0
  48. aip_agents/audio_interface/livekit_realtime_audio_session.py +142 -0
  49. aip_agents/audio_interface/session_factory.py +40 -0
  50. aip_agents/clients/__init__.py +10 -0
  51. aip_agents/clients/langflow/__init__.py +10 -0
  52. aip_agents/clients/langflow/client.py +477 -0
  53. aip_agents/clients/langflow/types.py +18 -0
  54. aip_agents/common/__init__.py +5 -0
  55. aip_agents/common/callable_introspection.py +17 -0
  56. aip_agents/constants.py +23 -0
  57. aip_agents/context/__init__.py +5 -0
  58. aip_agents/context/budget.py +237 -0
  59. aip_agents/context/compaction.py +339 -0
  60. aip_agents/context/metadata.py +169 -0
  61. aip_agents/context/offload.py +449 -0
  62. aip_agents/context/preparation.py +196 -0
  63. aip_agents/context/prompt.py +61 -0
  64. aip_agents/context/runtime.py +149 -0
  65. aip_agents/context/session_recall.py +69 -0
  66. aip_agents/context/state.py +48 -0
  67. aip_agents/context/utils.py +44 -0
  68. aip_agents/credentials/manager.py +132 -0
  69. aip_agents/examples/__init__.py +5 -0
  70. aip_agents/examples/audio_livekit_session.py +116 -0
  71. aip_agents/examples/compare_streaming_client.py +783 -0
  72. aip_agents/examples/compare_streaming_server.py +141 -0
  73. aip_agents/examples/gl_connectors_smoke.py +148 -0
  74. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  75. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  76. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  77. aip_agents/examples/hello_world_a2a_google_adk_server.py +78 -0
  78. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  79. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  80. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  81. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  82. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  83. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +104 -0
  84. aip_agents/examples/hello_world_a2a_langchain_server.py +78 -0
  85. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +77 -0
  86. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  87. aip_agents/examples/hello_world_a2a_langflow_server.py +80 -0
  88. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  89. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  90. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +91 -0
  91. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  92. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  93. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  94. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  95. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  96. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  97. aip_agents/examples/hello_world_a2a_langgraph_server.py +83 -0
  98. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +78 -0
  99. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +131 -0
  100. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  101. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  102. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +250 -0
  103. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  104. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +79 -0
  105. aip_agents/examples/hello_world_filesystem.py +162 -0
  106. aip_agents/examples/hello_world_google_adk.py +41 -0
  107. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  108. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  109. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  110. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  111. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  112. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  113. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  114. aip_agents/examples/hello_world_langchain.py +28 -0
  115. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  116. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  117. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  118. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  119. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  120. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  121. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  122. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  123. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  124. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  125. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  126. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  127. aip_agents/examples/hello_world_langgraph.py +39 -0
  128. aip_agents/examples/hello_world_langgraph_gl_connector_twitter.py +44 -0
  129. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  130. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  131. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  132. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  133. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  134. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  135. aip_agents/examples/hello_world_langgraph_skills.py +62 -0
  136. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  137. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  138. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  139. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  140. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  141. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  142. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  143. aip_agents/examples/hello_world_multi_agent_tool_output_client.py +57 -0
  144. aip_agents/examples/hello_world_multi_agent_tool_output_server.py +131 -0
  145. aip_agents/examples/hello_world_pii_logger.py +21 -0
  146. aip_agents/examples/hello_world_ptc.py +49 -0
  147. aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
  148. aip_agents/examples/hello_world_sentry.py +133 -0
  149. aip_agents/examples/hello_world_step_limits.py +273 -0
  150. aip_agents/examples/hello_world_stock_a2a_server.py +100 -0
  151. aip_agents/examples/hello_world_tool_output_client.py +55 -0
  152. aip_agents/examples/hello_world_tool_output_server.py +113 -0
  153. aip_agents/examples/hitl_demo.py +724 -0
  154. aip_agents/examples/mcp_configs/configs.py +63 -0
  155. aip_agents/examples/mcp_servers/common.py +76 -0
  156. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  157. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  158. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  159. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  160. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  161. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  162. aip_agents/examples/pii_demo_langgraph_server.py +125 -0
  163. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  164. aip_agents/examples/pii_demo_multi_agent_server.py +246 -0
  165. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  166. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +87 -0
  167. aip_agents/examples/tools/__init__.py +27 -0
  168. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  169. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  170. aip_agents/examples/tools/data_generator_tool.py +103 -0
  171. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  172. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  173. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  174. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  175. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  176. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  177. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  178. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  179. aip_agents/examples/tools/multiply_tool.py +43 -0
  180. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  181. aip_agents/examples/tools/random_chart_tool.py +142 -0
  182. aip_agents/examples/tools/serper_tool.py +202 -0
  183. aip_agents/examples/tools/stock_tools.py +82 -0
  184. aip_agents/examples/tools/table_generator_tool.py +167 -0
  185. aip_agents/examples/tools/time_tool.py +82 -0
  186. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  187. aip_agents/guardrails/__init__.py +83 -0
  188. aip_agents/guardrails/engines/__init__.py +69 -0
  189. aip_agents/guardrails/engines/base.py +90 -0
  190. aip_agents/guardrails/engines/nemo.py +101 -0
  191. aip_agents/guardrails/engines/phrase_matcher.py +113 -0
  192. aip_agents/guardrails/exceptions.py +39 -0
  193. aip_agents/guardrails/manager.py +163 -0
  194. aip_agents/guardrails/middleware.py +199 -0
  195. aip_agents/guardrails/schemas.py +63 -0
  196. aip_agents/guardrails/utils.py +45 -0
  197. aip_agents/integration/__init__.py +43 -0
  198. aip_agents/integration/agent.py +58 -0
  199. aip_agents/integration/guardrails.py +52 -0
  200. aip_agents/integration/hitl.py +71 -0
  201. aip_agents/integration/ptc.py +95 -0
  202. aip_agents/integration/skills.py +37 -0
  203. aip_agents/integration/storage.py +81 -0
  204. aip_agents/integration/version.py +103 -0
  205. aip_agents/mcp/__init__.py +1 -0
  206. aip_agents/mcp/client/__init__.py +60 -0
  207. aip_agents/mcp/client/base_mcp_client.py +377 -0
  208. aip_agents/mcp/client/connection_manager.py +231 -0
  209. aip_agents/mcp/client/errors.py +119 -0
  210. aip_agents/mcp/client/google_adk/__init__.py +45 -0
  211. aip_agents/mcp/client/google_adk/client.py +383 -0
  212. aip_agents/mcp/client/langchain/__init__.py +11 -0
  213. aip_agents/mcp/client/langchain/client.py +265 -0
  214. aip_agents/mcp/client/persistent_session.py +704 -0
  215. aip_agents/mcp/client/session_pool.py +358 -0
  216. aip_agents/mcp/client/transports.py +288 -0
  217. aip_agents/mcp/utils/__init__.py +7 -0
  218. aip_agents/mcp/utils/config_validator.py +139 -0
  219. aip_agents/memory/__init__.py +14 -0
  220. aip_agents/memory/adapters/__init__.py +10 -0
  221. aip_agents/memory/adapters/base_adapter.py +815 -0
  222. aip_agents/memory/adapters/mem0.py +286 -0
  223. aip_agents/memory/base.py +84 -0
  224. aip_agents/memory/constants.py +49 -0
  225. aip_agents/memory/factory.py +86 -0
  226. aip_agents/memory/guidance.py +20 -0
  227. aip_agents/memory/simple_memory.py +47 -0
  228. aip_agents/middleware/__init__.py +65 -0
  229. aip_agents/middleware/backends/__init__.py +72 -0
  230. aip_agents/middleware/backends/in_memory.py +578 -0
  231. aip_agents/middleware/backends/local_disk.py +1203 -0
  232. aip_agents/middleware/backends/protocol.py +353 -0
  233. aip_agents/middleware/backends/sandbox.py +1431 -0
  234. aip_agents/middleware/backends/sandbox_files.py +113 -0
  235. aip_agents/middleware/backends/utils.py +293 -0
  236. aip_agents/middleware/base.py +239 -0
  237. aip_agents/middleware/filesystem.py +690 -0
  238. aip_agents/middleware/manager.py +291 -0
  239. aip_agents/middleware/memory.py +532 -0
  240. aip_agents/middleware/pii_middleware.py +192 -0
  241. aip_agents/middleware/schema.py +19 -0
  242. aip_agents/middleware/skills.py +872 -0
  243. aip_agents/middleware/task_delegation.py +1656 -0
  244. aip_agents/middleware/todolist.py +276 -0
  245. aip_agents/middleware/tools/__init__.py +23 -0
  246. aip_agents/middleware/tools/edit_file.py +70 -0
  247. aip_agents/middleware/tools/execute.py +428 -0
  248. aip_agents/middleware/tools/grep_file.py +79 -0
  249. aip_agents/middleware/tools/ls.py +71 -0
  250. aip_agents/middleware/tools/read_file.py +239 -0
  251. aip_agents/middleware/tools/skill_load.py +193 -0
  252. aip_agents/middleware/tools/skill_resource.py +338 -0
  253. aip_agents/middleware/tools/write_file.py +114 -0
  254. aip_agents/middleware/vision.py +322 -0
  255. aip_agents/ptc/__init__.py +87 -0
  256. aip_agents/ptc/custom_tools.py +511 -0
  257. aip_agents/ptc/custom_tools_payload.py +499 -0
  258. aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
  259. aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +24 -0
  260. aip_agents/ptc/custom_tools_templates/custom_init.py.template +26 -0
  261. aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +149 -0
  262. aip_agents/ptc/custom_tools_templates/custom_registry.py.template +112 -0
  263. aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
  264. aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +87 -0
  265. aip_agents/ptc/doc_gen.py +122 -0
  266. aip_agents/ptc/exceptions.py +57 -0
  267. aip_agents/ptc/executor.py +627 -0
  268. aip_agents/ptc/mcp/__init__.py +45 -0
  269. aip_agents/ptc/mcp/sandbox_bridge.py +679 -0
  270. aip_agents/ptc/mcp/templates/__init__.py +1 -0
  271. aip_agents/ptc/mcp/templates/mcp_client.py.template +240 -0
  272. aip_agents/ptc/naming.py +430 -0
  273. aip_agents/ptc/payload.py +26 -0
  274. aip_agents/ptc/prompt_builder.py +674 -0
  275. aip_agents/ptc/ptc_helper.py +16 -0
  276. aip_agents/ptc/sandbox_bridge.py +297 -0
  277. aip_agents/ptc/template_utils.py +33 -0
  278. aip_agents/ptc/templates/__init__.py +1 -0
  279. aip_agents/ptc/templates/ptc_helper.py.template +134 -0
  280. aip_agents/ptc/tool_def_helpers.py +101 -0
  281. aip_agents/ptc/tool_enrichment.py +163 -0
  282. aip_agents/resilience/__init__.py +10 -0
  283. aip_agents/resilience/base.py +46 -0
  284. aip_agents/resilience/tool.py +1661 -0
  285. aip_agents/sandbox/__init__.py +62 -0
  286. aip_agents/sandbox/defaults.py +220 -0
  287. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1-datasaur.Dockerfile +120 -0
  288. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1.Dockerfile +100 -0
  289. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2-datasaur.Dockerfile +143 -0
  290. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2.Dockerfile +119 -0
  291. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3-datasaur.Dockerfile +173 -0
  292. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3.Dockerfile +130 -0
  293. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.Dockerfile +164 -0
  294. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.patch_js_context.py +41 -0
  295. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.requirements.txt +29 -0
  296. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2.Dockerfile +87 -0
  297. aip_agents/sandbox/e2b_runtime.py +27 -0
  298. aip_agents/sandbox/image_registry.py +98 -0
  299. aip_agents/sandbox/providers/__init__.py +76 -0
  300. aip_agents/sandbox/providers/base.py +392 -0
  301. aip_agents/sandbox/providers/bedrock.py +28 -0
  302. aip_agents/sandbox/providers/e2b.py +230 -0
  303. aip_agents/sandbox/providers/opensandbox.py +163 -0
  304. aip_agents/sandbox/sandbox_runtime.py +967 -0
  305. aip_agents/sandbox/template_builder.py +240 -0
  306. aip_agents/sandbox/types.py +141 -0
  307. aip_agents/sandbox/validation.py +50 -0
  308. aip_agents/schema/__init__.py +106 -0
  309. aip_agents/schema/a2a.py +58 -0
  310. aip_agents/schema/agent.py +117 -0
  311. aip_agents/schema/artifacts.py +75 -0
  312. aip_agents/schema/attachments.py +425 -0
  313. aip_agents/schema/hitl.py +157 -0
  314. aip_agents/schema/langgraph.py +47 -0
  315. aip_agents/schema/model_id.py +103 -0
  316. aip_agents/schema/model_usage.py +50 -0
  317. aip_agents/schema/runtime_metadata.py +78 -0
  318. aip_agents/schema/step_limit.py +108 -0
  319. aip_agents/schema/storage.py +40 -0
  320. aip_agents/schema/tool_payloads.py +68 -0
  321. aip_agents/sentry/__init__.py +11 -0
  322. aip_agents/sentry/sentry.py +267 -0
  323. aip_agents/skills/__init__.py +30 -0
  324. aip_agents/skills/errors.py +110 -0
  325. aip_agents/skills/installer.py +479 -0
  326. aip_agents/skills/models.py +769 -0
  327. aip_agents/skills/stager.py +224 -0
  328. aip_agents/skills/validation.py +53 -0
  329. aip_agents/storage/__init__.py +41 -0
  330. aip_agents/storage/base.py +85 -0
  331. aip_agents/storage/clients/__init__.py +12 -0
  332. aip_agents/storage/clients/minio_client.py +318 -0
  333. aip_agents/storage/config.py +62 -0
  334. aip_agents/storage/providers/__init__.py +15 -0
  335. aip_agents/storage/providers/base.py +106 -0
  336. aip_agents/storage/providers/memory.py +114 -0
  337. aip_agents/storage/providers/object_storage.py +214 -0
  338. aip_agents/tools/__init__.py +65 -0
  339. aip_agents/tools/agent_schedule_tool.py +392 -0
  340. aip_agents/tools/browser_use/__init__.py +83 -0
  341. aip_agents/tools/browser_use/action_parser.py +104 -0
  342. aip_agents/tools/browser_use/browser_use_tool.py +1118 -0
  343. aip_agents/tools/browser_use/llm_config.py +120 -0
  344. aip_agents/tools/browser_use/minio_storage.py +203 -0
  345. aip_agents/tools/browser_use/schemas.py +121 -0
  346. aip_agents/tools/browser_use/session.py +13 -0
  347. aip_agents/tools/browser_use/session_errors.py +132 -0
  348. aip_agents/tools/browser_use/steel_session_recording.py +321 -0
  349. aip_agents/tools/browser_use/streaming.py +263 -0
  350. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  351. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  352. aip_agents/tools/browser_use/types.py +37 -0
  353. aip_agents/tools/code_sandbox/__init__.py +48 -0
  354. aip_agents/tools/code_sandbox/code_sandbox_tool.py +350 -0
  355. aip_agents/tools/code_sandbox/constant.py +13 -0
  356. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +346 -0
  357. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +65 -0
  358. aip_agents/tools/computer_use/__init__.py +75 -0
  359. aip_agents/tools/computer_use/computer_use_tool.py +910 -0
  360. aip_agents/tools/computer_use/schemas.py +78 -0
  361. aip_agents/tools/computer_use/streaming.py +198 -0
  362. aip_agents/tools/computer_use/types.py +32 -0
  363. aip_agents/tools/constants.py +177 -0
  364. aip_agents/tools/date_range_tool.py +554 -0
  365. aip_agents/tools/document_loader/__init__.py +44 -0
  366. aip_agents/tools/document_loader/base_reader.py +302 -0
  367. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  368. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  369. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  370. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  371. aip_agents/tools/execute_ptc_code.py +582 -0
  372. aip_agents/tools/gl_connector/__init__.py +5 -0
  373. aip_agents/tools/gl_connector/tool.py +518 -0
  374. aip_agents/tools/gl_connector_tools.py +170 -0
  375. aip_agents/tools/memory_search/__init__.py +29 -0
  376. aip_agents/tools/memory_search/base.py +213 -0
  377. aip_agents/tools/memory_search/mem0.py +424 -0
  378. aip_agents/tools/memory_search/schema.py +81 -0
  379. aip_agents/tools/memory_search_tool.py +34 -0
  380. aip_agents/tools/streaming_common.py +577 -0
  381. aip_agents/tools/time_tool.py +117 -0
  382. aip_agents/tools/tool_config_injector.py +300 -0
  383. aip_agents/tools/web_search/__init__.py +15 -0
  384. aip_agents/tools/web_search/serper_tool.py +187 -0
  385. aip_agents/types/__init__.py +73 -0
  386. aip_agents/types/a2a_events.py +13 -0
  387. aip_agents/utils/__init__.py +91 -0
  388. aip_agents/utils/a2a_connector.py +1758 -0
  389. aip_agents/utils/artifact_helpers.py +542 -0
  390. aip_agents/utils/artifacts/__init__.py +20 -0
  391. aip_agents/utils/artifacts/tracker.py +367 -0
  392. aip_agents/utils/attachment_helpers.py +178 -0
  393. aip_agents/utils/constants.py +22 -0
  394. aip_agents/utils/datetime/__init__.py +34 -0
  395. aip_agents/utils/datetime/normalization.py +231 -0
  396. aip_agents/utils/datetime/timezone.py +206 -0
  397. aip_agents/utils/env_loader.py +27 -0
  398. aip_agents/utils/event_handler_registry.py +58 -0
  399. aip_agents/utils/file_prompt_utils.py +276 -0
  400. aip_agents/utils/file_watchers/__init__.py +33 -0
  401. aip_agents/utils/file_watchers/base.py +46 -0
  402. aip_agents/utils/file_watchers/e2b.py +128 -0
  403. aip_agents/utils/file_watchers/factory.py +39 -0
  404. aip_agents/utils/file_watchers/ls_diff.py +88 -0
  405. aip_agents/utils/file_watchers/sandbox.py +66 -0
  406. aip_agents/utils/file_watchers/watchdog.py +97 -0
  407. aip_agents/utils/final_response_builder.py +211 -0
  408. aip_agents/utils/formatter_llm_client.py +258 -0
  409. aip_agents/utils/langgraph/__init__.py +19 -0
  410. aip_agents/utils/langgraph/converter.py +399 -0
  411. aip_agents/utils/langgraph/delegation_context.py +22 -0
  412. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  413. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  414. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  415. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1329 -0
  416. aip_agents/utils/langgraph/tool_output_management.py +1047 -0
  417. aip_agents/utils/logger.py +195 -0
  418. aip_agents/utils/metadata/__init__.py +27 -0
  419. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  420. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  421. aip_agents/utils/metadata/activity_narrative/builder.py +822 -0
  422. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  423. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  424. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  425. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  426. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  427. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  428. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  429. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  430. aip_agents/utils/metadata_helper.py +363 -0
  431. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  432. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  433. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  434. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  435. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  436. aip_agents/utils/pii/__init__.py +29 -0
  437. aip_agents/utils/pii/pii_handler.py +555 -0
  438. aip_agents/utils/pii/pii_helper.py +225 -0
  439. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  440. aip_agents/utils/reference_helper.py +273 -0
  441. aip_agents/utils/sse_chunk_transformer.py +836 -0
  442. aip_agents/utils/step_limit_manager.py +265 -0
  443. aip_agents/utils/token_usage_helper.py +531 -0
  444. aip_agents_binary-0.0.0.dist-info/METADATA +825 -0
  445. aip_agents_binary-0.0.0.dist-info/RECORD +447 -0
  446. aip_agents_binary-0.0.0.dist-info/WHEEL +5 -0
  447. aip_agents_binary-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1661 @@
1
+ """Tool-specific resilience policies and execution adapter.
2
+
3
+ Author:
4
+ Saul Sayers (saul.sayers@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from langchain_core.tools import BaseTool
15
+ from pybreaker import CircuitBreaker
16
+ from tenacity import wait_exponential
17
+
18
+ from aip_agents.resilience.base import BaseResilienceExecutor, ResilienceRequest
19
+
20
+ DEFAULT_TOOL_TIMEOUT_SECONDS = 60.0
21
+ DEFAULT_RETRY_ENABLED = False
22
+ DEFAULT_RETRY_MAX_ATTEMPTS = 3
23
+ DEFAULT_RETRY_BACKOFF_MIN_SECONDS = 1.0
24
+ DEFAULT_RETRY_BACKOFF_MAX_SECONDS = 8.0
25
+ DEFAULT_RETRYABLE_ERROR_KINDS = (
26
+ "timeout",
27
+ "connection_error",
28
+ "endpoint_unreachable",
29
+ "upstream_5xx",
30
+ "rate_limited",
31
+ )
32
+ DEFAULT_CIRCUIT_BREAKER_ENABLED = False
33
+ DEFAULT_CIRCUIT_FAIL_MAX = 5
34
+ DEFAULT_CIRCUIT_RESET_TIMEOUT_SECONDS = 60.0
35
+ DEFAULT_CIRCUIT_HALF_OPEN_MAX_CALLS = 1
36
+
37
+ TOOL_TIMEOUT_SECONDS_KEY = "tool_timeout_seconds"
38
+ TOOL_SOURCE_KEY = "tool_source"
39
+ RESILIENCE_KEY = "resilience"
40
+ TIMEOUT_SECONDS_KEY = "timeout_seconds"
41
+ RETRY_KEY = "retry"
42
+ RETRY_ENABLED_KEY = "enabled"
43
+ RETRY_MAX_ATTEMPTS_KEY = "max_attempts"
44
+ RETRY_BACKOFF_MIN_SECONDS_KEY = "backoff_min_seconds"
45
+ RETRY_BACKOFF_MAX_SECONDS_KEY = "backoff_max_seconds"
46
+ RETRYABLE_ERROR_KINDS_KEY = "retryable_error_kinds"
47
+ CIRCUIT_BREAKER_KEY = "circuit_breaker"
48
+ CIRCUIT_BREAKER_ENABLED_KEY = "enabled"
49
+ CIRCUIT_BREAKER_FAIL_MAX_KEY = "fail_max"
50
+ CIRCUIT_BREAKER_RESET_TIMEOUT_SECONDS_KEY = "reset_timeout_seconds"
51
+ CIRCUIT_BREAKER_HALF_OPEN_MAX_CALLS_KEY = "half_open_max_calls"
52
+
53
+ CATEGORY_TIMEOUT = "timeout"
54
+ CATEGORY_INVALID_TIMEOUT_CONFIG = "invalid_timeout_config"
55
+ CATEGORY_INVALID_RETRY_CONFIG = "invalid_retry_config"
56
+ CATEGORY_INVALID_CIRCUIT_CONFIG = "invalid_circuit_breaker_config"
57
+ CATEGORY_UNAUTHORIZED = "unauthorized"
58
+ CATEGORY_FORBIDDEN = "forbidden"
59
+ CATEGORY_RATE_LIMITED = "rate_limited"
60
+ CATEGORY_ENDPOINT_UNREACHABLE = "endpoint_unreachable"
61
+ CATEGORY_UPSTREAM_FAILURE = "upstream_failure"
62
+ CATEGORY_RETRY_EXHAUSTED = "retry_exhausted"
63
+ CATEGORY_CIRCUIT_OPEN = "circuit_open"
64
+
65
+ KIND_TIMEOUT = "timeout"
66
+ KIND_CONNECTION_ERROR = "connection_error"
67
+ KIND_ENDPOINT_UNREACHABLE = "endpoint_unreachable"
68
+ KIND_UPSTREAM_5XX = "upstream_5xx"
69
+ KIND_RATE_LIMITED = "rate_limited"
70
+ KIND_UNAUTHORIZED = "unauthorized"
71
+ KIND_FORBIDDEN = "forbidden"
72
+ KIND_UPSTREAM_FAILURE = "upstream_failure"
73
+ KIND_CIRCUIT_OPEN = "circuit_open"
74
+
75
+ _KNOWN_TOOL_SOURCES = {"gl_connector", "mcp", "custom", "ptc", "unknown"}
76
+ _NON_RETRYABLE_KINDS = {KIND_UNAUTHORIZED, KIND_FORBIDDEN}
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class RetryPolicy:
81
+ """Resolved retry policy for one tool invocation."""
82
+
83
+ enabled: bool
84
+ max_attempts: int
85
+ backoff_min_seconds: float
86
+ backoff_max_seconds: float
87
+ retryable_error_kinds: tuple[str, ...]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class CircuitBreakerPolicy:
92
+ """Resolved circuit-breaker policy for one tool invocation."""
93
+
94
+ enabled: bool
95
+ fail_max: int
96
+ reset_timeout_seconds: float
97
+ half_open_max_calls: int
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class CircuitBreakerLease:
102
+ """Lease returned by circuit registry before one tool attempt."""
103
+
104
+ tool_key: str
105
+ policy: CircuitBreakerPolicy
106
+ state: str
107
+ allowed: bool
108
+ half_open_acquired: bool
109
+ last_error_kind: str | None
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class CircuitBreakerTransition:
114
+ """Circuit breaker transition/result snapshot for metadata emission."""
115
+
116
+ tool_key: str
117
+ state: str
118
+ previous_state: str
119
+ transitioned: bool
120
+ consecutive_failures: int
121
+ inflight_half_open_calls: int
122
+ opened_at_unix_seconds: float | None
123
+ last_error_kind: str | None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class ToolFailureDetail:
128
+ """Classified tool failure detail used for retry and output mapping."""
129
+
130
+ category: str
131
+ kind: str
132
+ message: str
133
+ code: str
134
+ http_status: int | None
135
+ is_retryable_default: bool
136
+ cancelled: bool | None = None
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class _ResolvedToolPolicies:
141
+ timeout_seconds: float
142
+ retry: RetryPolicy
143
+ circuit_breaker: CircuitBreakerPolicy
144
+
145
+
146
+ class ToolResilienceExecutor(BaseResilienceExecutor):
147
+ """Resilience executor for tool invocations.
148
+
149
+ This executor keeps timeout validation and timeout-error shaping outside the
150
+ LangGraph agent implementation, so the agent only needs to provide the
151
+ invocation callback.
152
+ """
153
+
154
+ def __init__(self, *, circuit_breakers: ToolCircuitBreakerRegistry | None = None) -> None:
155
+ """Initialize executor with optional shared circuit-breaker registry.
156
+
157
+ Args:
158
+ circuit_breakers: Optional shared registry instance for circuit state.
159
+
160
+ Returns:
161
+ None.
162
+ """
163
+ self._circuit_breakers = circuit_breakers or ToolCircuitBreakerRegistry()
164
+
165
+ async def execute(self, request: ResilienceRequest) -> Any:
166
+ """Execute one tool request with timeout and retry handling.
167
+
168
+ Args:
169
+ request: Tool resilience request with metadata and invocation callback.
170
+
171
+ Returns:
172
+ Tool output or structured resilience error payload.
173
+ """
174
+ policies_or_error = self._resolve_policies_or_error(request)
175
+ if isinstance(policies_or_error, dict):
176
+ return policies_or_error
177
+ policies = policies_or_error
178
+ return await self._execute_with_policies(request, policies)
179
+
180
+ async def _execute_with_policies(self, request: ResilienceRequest, policies: _ResolvedToolPolicies) -> Any:
181
+ start_time = time.monotonic()
182
+ attempts_used = 0
183
+ circuit_tool_key = f"{request.operation_source}:{request.operation_name}"
184
+ last_failure: ToolFailureDetail | None = None
185
+ last_timeout_failure: ToolFailureDetail | None = None
186
+
187
+ while True:
188
+ attempts_used += 1
189
+ remaining_timeout_seconds = policies.timeout_seconds - (time.monotonic() - start_time)
190
+ caught_error: Exception | None = None
191
+
192
+ if remaining_timeout_seconds <= 0:
193
+ return self._build_timeout_budget_output(request, policies, attempts_used, last_failure=last_failure)
194
+
195
+ circuit_lease = await self._acquire_circuit_lease(request, policies, circuit_tool_key)
196
+ if isinstance(circuit_lease, dict):
197
+ return circuit_lease
198
+
199
+ try:
200
+ return await self._invoke_attempt(
201
+ request,
202
+ policies,
203
+ attempts_used=attempts_used,
204
+ remaining_timeout_seconds=remaining_timeout_seconds,
205
+ circuit_lease=circuit_lease,
206
+ last_timeout_failure=last_timeout_failure,
207
+ )
208
+ except TimeoutError as timeout_error:
209
+ failure = classify_tool_exception(timeout_error)
210
+ except Exception as execution_error: # noqa: BLE001
211
+ caught_error = execution_error
212
+ failure = classify_tool_exception(execution_error)
213
+
214
+ last_failure = failure
215
+ if failure.category == CATEGORY_TIMEOUT:
216
+ last_timeout_failure = failure
217
+
218
+ circuit_transition = await self._record_circuit_failure(policies, circuit_lease, failure)
219
+ retry_output = await self._handle_retry_candidate(
220
+ request,
221
+ policies,
222
+ attempts_used=attempts_used,
223
+ failure=failure,
224
+ start_time=start_time,
225
+ circuit_transition=circuit_transition,
226
+ )
227
+ route = self._route_post_attempt_result(
228
+ request,
229
+ policies,
230
+ attempts_used=attempts_used,
231
+ caught_error=caught_error,
232
+ failure=failure,
233
+ retry_output=retry_output,
234
+ circuit_transition=circuit_transition,
235
+ )
236
+ if route == "continue":
237
+ continue
238
+ if route is not None:
239
+ return route
240
+
241
+ def _route_post_attempt_result(
242
+ self,
243
+ request: ResilienceRequest,
244
+ policies: _ResolvedToolPolicies,
245
+ *,
246
+ attempts_used: int,
247
+ caught_error: Exception | None,
248
+ failure: Any,
249
+ retry_output: str | dict[str, Any] | None,
250
+ circuit_transition: Any,
251
+ ) -> str | dict[str, Any] | None:
252
+ if retry_output == "continue":
253
+ return "continue"
254
+ if retry_output is not None:
255
+ return retry_output
256
+ if caught_error is not None and not policies.retry.enabled and not policies.circuit_breaker.enabled:
257
+ raise caught_error
258
+ return self._build_final_failure_output(
259
+ request,
260
+ policies,
261
+ attempts_used=attempts_used,
262
+ failure=failure,
263
+ circuit_transition=circuit_transition,
264
+ )
265
+
266
+ def _resolve_policies_or_error(self, request: ResilienceRequest) -> _ResolvedToolPolicies | dict[str, Any]:
267
+ try:
268
+ return _ResolvedToolPolicies(
269
+ timeout_seconds=resolve_tool_timeout_seconds(request.metadata),
270
+ retry=resolve_retry_policy(request.metadata),
271
+ circuit_breaker=resolve_circuit_breaker_policy(request.metadata),
272
+ )
273
+ except ValueError as exc:
274
+ return self._build_invalid_policy_output(request, str(exc))
275
+
276
+ @staticmethod
277
+ def _build_invalid_policy_output(request: ResilienceRequest, error_text: str) -> dict[str, Any]:
278
+ if error_text.startswith("retry"):
279
+ return build_invalid_retry_config_output(
280
+ tool_name=request.operation_name,
281
+ tool_source=request.operation_source,
282
+ error=error_text,
283
+ )
284
+ if error_text.startswith(("circuit_breaker", "circuit breaker")):
285
+ return build_invalid_circuit_breaker_config_output(
286
+ tool_name=request.operation_name,
287
+ tool_source=request.operation_source,
288
+ error=error_text,
289
+ )
290
+ return build_invalid_timeout_config_output(
291
+ tool_name=request.operation_name,
292
+ tool_source=request.operation_source,
293
+ error=error_text,
294
+ )
295
+
296
+ @staticmethod
297
+ def _build_timeout_budget_output(
298
+ request: ResilienceRequest,
299
+ policies: _ResolvedToolPolicies,
300
+ attempts_used: int,
301
+ last_failure: ToolFailureDetail | None = None,
302
+ ) -> dict[str, Any]:
303
+ if policies.retry.enabled and attempts_used > 1:
304
+ return build_retry_exhausted_error_output(
305
+ tool_name=request.operation_name,
306
+ tool_source=request.operation_source,
307
+ timeout_seconds=policies.timeout_seconds,
308
+ attempts_used=attempts_used,
309
+ max_attempts=policies.retry.max_attempts,
310
+ last_failure=last_failure or classify_tool_exception(TimeoutError("tool retry budget exhausted")),
311
+ retry_budget_exhausted=True,
312
+ )
313
+ return build_timeout_error_output(
314
+ tool_name=request.operation_name,
315
+ timeout_seconds=policies.timeout_seconds,
316
+ tool_source=request.operation_source,
317
+ cancelled=last_failure.cancelled if last_failure is not None else None,
318
+ )
319
+
320
+ async def _acquire_circuit_lease(
321
+ self,
322
+ request: ResilienceRequest,
323
+ policies: _ResolvedToolPolicies,
324
+ circuit_tool_key: str,
325
+ ) -> CircuitBreakerLease | dict[str, Any] | None:
326
+ if not policies.circuit_breaker.enabled:
327
+ return None
328
+ lease = await self._circuit_breakers.acquire(tool_key=circuit_tool_key, policy=policies.circuit_breaker)
329
+ if lease.allowed:
330
+ return lease
331
+ return build_circuit_open_error_output(
332
+ tool_name=request.operation_name,
333
+ tool_source=request.operation_source,
334
+ reset_timeout_seconds=policies.circuit_breaker.reset_timeout_seconds,
335
+ circuit_state=lease.state,
336
+ failure_kind=lease.last_error_kind,
337
+ )
338
+
339
+ async def _invoke_attempt(
340
+ self,
341
+ request: ResilienceRequest,
342
+ policies: _ResolvedToolPolicies,
343
+ *,
344
+ attempts_used: int,
345
+ remaining_timeout_seconds: float,
346
+ circuit_lease: CircuitBreakerLease | None,
347
+ last_timeout_failure: ToolFailureDetail | None,
348
+ ) -> Any:
349
+ tool_output = await request.invoke_with_timeout(remaining_timeout_seconds)
350
+ circuit_transition = None
351
+ if policies.circuit_breaker.enabled and circuit_lease:
352
+ circuit_transition = await self._circuit_breakers.record_success(circuit_lease)
353
+ if policies.retry.enabled and attempts_used > 1 and isinstance(tool_output, dict):
354
+ tool_output = add_retry_attempt_metadata(
355
+ tool_output,
356
+ attempts_used=attempts_used,
357
+ max_attempts=policies.retry.max_attempts,
358
+ retry_budget_exhausted=False,
359
+ )
360
+ if (
361
+ isinstance(tool_output, dict)
362
+ and last_timeout_failure is not None
363
+ and last_timeout_failure.cancelled is not None
364
+ ):
365
+ metadata = tool_output.setdefault("metadata", {})
366
+ if not isinstance(metadata, dict):
367
+ metadata = {}
368
+ tool_output["metadata"] = metadata
369
+ execution = metadata.setdefault("tool_execution", {})
370
+ if not isinstance(execution, dict):
371
+ execution = {}
372
+ metadata["tool_execution"] = execution
373
+ execution["previous_timeout_cancelled"] = last_timeout_failure.cancelled
374
+ return self._with_circuit_metadata(tool_output, circuit_transition)
375
+
376
+ async def _record_circuit_failure(
377
+ self,
378
+ policies: _ResolvedToolPolicies,
379
+ circuit_lease: CircuitBreakerLease | None,
380
+ failure: ToolFailureDetail,
381
+ ) -> CircuitBreakerTransition | None:
382
+ if not policies.circuit_breaker.enabled or not circuit_lease:
383
+ return None
384
+ return await self._circuit_breakers.record_failure(circuit_lease, error_kind=failure.kind)
385
+
386
+ async def _handle_retry_candidate(
387
+ self,
388
+ request: ResilienceRequest,
389
+ policies: _ResolvedToolPolicies,
390
+ *,
391
+ attempts_used: int,
392
+ failure: ToolFailureDetail,
393
+ start_time: float,
394
+ circuit_transition: CircuitBreakerTransition | None,
395
+ ) -> dict[str, Any] | str | None:
396
+ retry_candidate = should_retry_failure(failure, policies.retry)
397
+ if retry_candidate and attempts_used < policies.retry.max_attempts:
398
+ retry_delay_seconds = compute_retry_backoff_seconds(attempts_used, policies.retry)
399
+ remaining_budget = policies.timeout_seconds - (time.monotonic() - start_time)
400
+ if retry_delay_seconds >= remaining_budget:
401
+ return self._build_retry_exhausted_output(
402
+ request, policies, attempts_used, failure, True, circuit_transition
403
+ )
404
+ await asyncio.sleep(retry_delay_seconds)
405
+ return "continue"
406
+ if retry_candidate and policies.retry.enabled:
407
+ return self._build_retry_exhausted_output(
408
+ request, policies, attempts_used, failure, False, circuit_transition
409
+ )
410
+ return None
411
+
412
+ @staticmethod
413
+ def _build_retry_exhausted_output(
414
+ request: ResilienceRequest,
415
+ policies: _ResolvedToolPolicies,
416
+ attempts_used: int,
417
+ failure: ToolFailureDetail,
418
+ retry_budget_exhausted: bool,
419
+ circuit_transition: CircuitBreakerTransition | None,
420
+ ) -> dict[str, Any]:
421
+ tool_output = build_retry_exhausted_error_output(
422
+ tool_name=request.operation_name,
423
+ tool_source=request.operation_source,
424
+ timeout_seconds=policies.timeout_seconds,
425
+ attempts_used=attempts_used,
426
+ max_attempts=policies.retry.max_attempts,
427
+ last_failure=failure,
428
+ retry_budget_exhausted=retry_budget_exhausted,
429
+ )
430
+ return ToolResilienceExecutor._with_circuit_metadata(tool_output, circuit_transition)
431
+
432
+ def _build_final_failure_output(
433
+ self,
434
+ request: ResilienceRequest,
435
+ policies: _ResolvedToolPolicies,
436
+ *,
437
+ attempts_used: int,
438
+ failure: ToolFailureDetail,
439
+ circuit_transition: CircuitBreakerTransition | None,
440
+ ) -> dict[str, Any]:
441
+ if failure.category == CATEGORY_TIMEOUT:
442
+ tool_output = build_timeout_error_output(
443
+ tool_name=request.operation_name,
444
+ timeout_seconds=policies.timeout_seconds,
445
+ tool_source=request.operation_source,
446
+ cancelled=failure.cancelled,
447
+ )
448
+ if policies.retry.enabled:
449
+ tool_output = add_retry_attempt_metadata(
450
+ tool_output,
451
+ attempts_used=attempts_used,
452
+ max_attempts=policies.retry.max_attempts,
453
+ retry_budget_exhausted=False,
454
+ )
455
+ return self._with_circuit_metadata(tool_output, circuit_transition)
456
+
457
+ tool_output = build_classified_error_output(
458
+ tool_name=request.operation_name,
459
+ tool_source=request.operation_source,
460
+ attempts_used=attempts_used,
461
+ max_attempts=policies.retry.max_attempts,
462
+ failure=failure,
463
+ )
464
+ return self._with_circuit_metadata(tool_output, circuit_transition)
465
+
466
+ @staticmethod
467
+ def _with_circuit_metadata(tool_output: Any, transition: CircuitBreakerTransition | None) -> Any:
468
+ if isinstance(tool_output, dict) and transition:
469
+ return add_circuit_breaker_metadata(tool_output, transition=transition)
470
+ return tool_output
471
+
472
+
473
+ @dataclass
474
+ class _CircuitState:
475
+ """Mutable state container for one circuit key."""
476
+
477
+ breaker: CircuitBreaker
478
+ policy: CircuitBreakerPolicy
479
+ inflight_half_open_calls: int = 0
480
+ opened_at_unix_seconds: float | None = None
481
+ last_error_kind: str | None = None
482
+
483
+
484
+ @dataclass(frozen=True)
485
+ class _TenacityWaitState:
486
+ """Minimal wait state used by tenacity wait strategies."""
487
+
488
+ attempt_number: int
489
+
490
+
491
+ def _circuit_success_probe() -> bool:
492
+ """Return a successful probe value for circuit breaker transitions.
493
+
494
+ Args:
495
+ None.
496
+
497
+ Returns:
498
+ ``True`` to mark a successful probe call.
499
+ """
500
+ return True
501
+
502
+
503
+ def _raise_circuit_failure() -> None:
504
+ """Raise a sentinel failure used to trip circuit state transitions.
505
+
506
+ Args:
507
+ None.
508
+
509
+ Returns:
510
+ None.
511
+ """
512
+ raise RuntimeError("tool failure")
513
+
514
+
515
+ class ToolCircuitBreakerRegistry:
516
+ """In-memory registry for per-tool circuit breaker transitions.
517
+
518
+ Uses ``pybreaker`` to drive circuit state transitions while preserving
519
+ per-tool metadata snapshots for observability.
520
+ """
521
+
522
+ def __init__(self) -> None:
523
+ """Initialize an empty registry.
524
+
525
+ Args:
526
+ None.
527
+
528
+ Returns:
529
+ None.
530
+ """
531
+ self._states: dict[str, _CircuitState] = {}
532
+ self._lock = asyncio.Lock()
533
+
534
+ async def acquire(self, *, tool_key: str, policy: CircuitBreakerPolicy) -> CircuitBreakerLease:
535
+ """Attempt to acquire permission for one tool attempt.
536
+
537
+ Args:
538
+ tool_key: Stable circuit identity (for example ``"custom:search"``).
539
+ policy: Resolved per-tool circuit breaker policy.
540
+
541
+ Returns:
542
+ CircuitBreakerLease with allow/deny decision and acquisition metadata.
543
+ """
544
+ async with self._lock:
545
+ now = time.time()
546
+ state = self._get_or_create_state(tool_key=tool_key, policy=policy)
547
+ circuit_state = self._normalize_circuit_state(state.breaker.current_state)
548
+
549
+ if circuit_state == "open":
550
+ opened_at = state.opened_at_unix_seconds
551
+ if opened_at is not None and now - opened_at >= policy.reset_timeout_seconds:
552
+ state.breaker.half_open()
553
+ state.inflight_half_open_calls = 0
554
+ circuit_state = self._normalize_circuit_state(state.breaker.current_state)
555
+ else:
556
+ return CircuitBreakerLease(
557
+ tool_key=tool_key,
558
+ policy=policy,
559
+ state=circuit_state,
560
+ allowed=False,
561
+ half_open_acquired=False,
562
+ last_error_kind=state.last_error_kind,
563
+ )
564
+
565
+ if circuit_state == "half_open":
566
+ if state.inflight_half_open_calls >= policy.half_open_max_calls:
567
+ return CircuitBreakerLease(
568
+ tool_key=tool_key,
569
+ policy=policy,
570
+ state=circuit_state,
571
+ allowed=False,
572
+ half_open_acquired=False,
573
+ last_error_kind=state.last_error_kind,
574
+ )
575
+
576
+ state.inflight_half_open_calls += 1
577
+ return CircuitBreakerLease(
578
+ tool_key=tool_key,
579
+ policy=policy,
580
+ state=circuit_state,
581
+ allowed=True,
582
+ half_open_acquired=True,
583
+ last_error_kind=state.last_error_kind,
584
+ )
585
+
586
+ return CircuitBreakerLease(
587
+ tool_key=tool_key,
588
+ policy=policy,
589
+ state=circuit_state,
590
+ allowed=True,
591
+ half_open_acquired=False,
592
+ last_error_kind=state.last_error_kind,
593
+ )
594
+
595
+ async def record_success(self, lease: CircuitBreakerLease) -> CircuitBreakerTransition:
596
+ """Record successful attempt and update breaker state.
597
+
598
+ Args:
599
+ lease: Lease returned by ``acquire`` for the completed attempt.
600
+
601
+ Returns:
602
+ CircuitBreakerTransition snapshot after state mutation.
603
+ """
604
+ async with self._lock:
605
+ state = self._get_or_create_state(tool_key=lease.tool_key, policy=lease.policy)
606
+ previous_state = self._normalize_circuit_state(state.breaker.current_state)
607
+
608
+ if lease.half_open_acquired:
609
+ state.inflight_half_open_calls = max(0, state.inflight_half_open_calls - 1)
610
+
611
+ state.breaker.call(_circuit_success_probe)
612
+
613
+ state_name = self._normalize_circuit_state(state.breaker.current_state)
614
+ if state_name == "closed":
615
+ state.opened_at_unix_seconds = None
616
+ state.last_error_kind = None
617
+ transitioned = previous_state != state_name
618
+
619
+ return self._snapshot(lease.tool_key, previous_state, transitioned)
620
+
621
+ async def record_failure(self, lease: CircuitBreakerLease, *, error_kind: str) -> CircuitBreakerTransition:
622
+ """Record failed attempt and update breaker state.
623
+
624
+ Args:
625
+ lease: Lease returned by ``acquire`` for the completed attempt.
626
+ error_kind: Classified failure kind for observability metadata.
627
+
628
+ Returns:
629
+ CircuitBreakerTransition snapshot after state mutation.
630
+ """
631
+ async with self._lock:
632
+ now = time.time()
633
+ state = self._get_or_create_state(tool_key=lease.tool_key, policy=lease.policy)
634
+ previous_state = self._normalize_circuit_state(state.breaker.current_state)
635
+ state.last_error_kind = error_kind
636
+
637
+ if lease.half_open_acquired:
638
+ state.inflight_half_open_calls = max(0, state.inflight_half_open_calls - 1)
639
+
640
+ try:
641
+ state.breaker.call(_raise_circuit_failure)
642
+ except Exception:
643
+ pass
644
+
645
+ state_name = self._normalize_circuit_state(state.breaker.current_state)
646
+ if state_name == "open" and previous_state != "open":
647
+ state.opened_at_unix_seconds = now
648
+ transitioned = previous_state != state_name
649
+
650
+ return self._snapshot(lease.tool_key, previous_state, transitioned)
651
+
652
+ def _snapshot(self, tool_key: str, previous_state: str, transitioned: bool) -> CircuitBreakerTransition:
653
+ """Create a transition snapshot for metadata emission.
654
+
655
+ Args:
656
+ tool_key: Circuit key associated with the transition.
657
+ previous_state: Circuit state before mutation.
658
+ transitioned: Whether the state changed in this mutation.
659
+
660
+ Returns:
661
+ CircuitBreakerTransition with current breaker counters and state.
662
+ """
663
+ state = self._states[tool_key]
664
+ current_state = self._normalize_circuit_state(state.breaker.current_state)
665
+ return CircuitBreakerTransition(
666
+ tool_key=tool_key,
667
+ state=current_state,
668
+ previous_state=previous_state,
669
+ transitioned=transitioned,
670
+ consecutive_failures=state.breaker.fail_counter,
671
+ inflight_half_open_calls=state.inflight_half_open_calls,
672
+ opened_at_unix_seconds=state.opened_at_unix_seconds,
673
+ last_error_kind=state.last_error_kind,
674
+ )
675
+
676
+ def _get_or_create_state(self, *, tool_key: str, policy: CircuitBreakerPolicy) -> _CircuitState:
677
+ """Get an existing circuit state or create one from policy.
678
+
679
+ Args:
680
+ tool_key: Stable key used to store breaker state.
681
+ policy: Policy used to build a new ``pybreaker.CircuitBreaker``.
682
+
683
+ Returns:
684
+ Mutable state holder for the given tool key.
685
+ """
686
+ state = self._states.get(tool_key)
687
+ if state and state.policy == policy:
688
+ return state
689
+
690
+ breaker = CircuitBreaker(
691
+ fail_max=policy.fail_max,
692
+ reset_timeout=policy.reset_timeout_seconds,
693
+ success_threshold=1,
694
+ throw_new_error_on_trip=False,
695
+ )
696
+ state = _CircuitState(breaker=breaker, policy=policy)
697
+ self._states[tool_key] = state
698
+ return state
699
+
700
+ @staticmethod
701
+ def _normalize_circuit_state(raw_state: str) -> str:
702
+ """Normalize pybreaker state labels to metadata format.
703
+
704
+ Args:
705
+ raw_state: Raw pybreaker state label.
706
+
707
+ Returns:
708
+ Normalized state label using underscores.
709
+ """
710
+ return str(raw_state).replace("-", "_")
711
+
712
+
713
+ def resolve_tool_timeout_seconds(
714
+ metadata: dict[str, Any] | None,
715
+ *,
716
+ default_timeout_seconds: float = DEFAULT_TOOL_TIMEOUT_SECONDS,
717
+ ) -> float:
718
+ """Resolve effective timeout for a tool invocation.
719
+
720
+ Args:
721
+ metadata: Optional operation metadata containing resilience overrides.
722
+ default_timeout_seconds: Fallback timeout when metadata has no override.
723
+
724
+ Returns:
725
+ Effective timeout in seconds.
726
+
727
+ Metadata precedence:
728
+ 1) ``metadata["resilience"]["timeout_seconds"]``
729
+ 2) ``metadata["timeout_seconds"]``
730
+ 3) ``metadata["tool_timeout_seconds"]``
731
+ 4) provided default
732
+ """
733
+ raw_value = _extract_timeout_value(metadata)
734
+ if raw_value is None:
735
+ return float(default_timeout_seconds)
736
+ return _validate_positive_timeout(raw_value)
737
+
738
+
739
+ def resolve_retry_policy(metadata: dict[str, Any] | None) -> RetryPolicy:
740
+ """Resolve retry policy from tool metadata with defaults and validation.
741
+
742
+ Args:
743
+ metadata: Optional operation metadata containing retry configuration.
744
+
745
+ Returns:
746
+ Normalized retry policy for the current tool invocation.
747
+ """
748
+ raw_retry = _extract_retry_value(metadata)
749
+ if raw_retry is None:
750
+ return RetryPolicy(
751
+ enabled=DEFAULT_RETRY_ENABLED,
752
+ max_attempts=DEFAULT_RETRY_MAX_ATTEMPTS,
753
+ backoff_min_seconds=DEFAULT_RETRY_BACKOFF_MIN_SECONDS,
754
+ backoff_max_seconds=DEFAULT_RETRY_BACKOFF_MAX_SECONDS,
755
+ retryable_error_kinds=DEFAULT_RETRYABLE_ERROR_KINDS,
756
+ )
757
+
758
+ if not isinstance(raw_retry, dict):
759
+ raise ValueError("retry must be a dictionary")
760
+
761
+ enabled_raw = raw_retry.get(RETRY_ENABLED_KEY, DEFAULT_RETRY_ENABLED)
762
+ enabled = bool(enabled_raw)
763
+
764
+ max_attempts = _validate_positive_int(
765
+ raw_retry.get(RETRY_MAX_ATTEMPTS_KEY, DEFAULT_RETRY_MAX_ATTEMPTS),
766
+ field_name="retry.max_attempts",
767
+ )
768
+ backoff_min_seconds = _validate_positive_timeout(
769
+ raw_retry.get(RETRY_BACKOFF_MIN_SECONDS_KEY, DEFAULT_RETRY_BACKOFF_MIN_SECONDS)
770
+ )
771
+ backoff_max_seconds = _validate_positive_timeout(
772
+ raw_retry.get(RETRY_BACKOFF_MAX_SECONDS_KEY, DEFAULT_RETRY_BACKOFF_MAX_SECONDS)
773
+ )
774
+
775
+ if backoff_min_seconds > backoff_max_seconds:
776
+ raise ValueError("retry.backoff_min_seconds must be less than or equal to retry.backoff_max_seconds")
777
+
778
+ retryable_error_kinds = _validate_retryable_error_kinds(
779
+ raw_retry.get(RETRYABLE_ERROR_KINDS_KEY, list(DEFAULT_RETRYABLE_ERROR_KINDS))
780
+ )
781
+
782
+ return RetryPolicy(
783
+ enabled=enabled,
784
+ max_attempts=max_attempts,
785
+ backoff_min_seconds=backoff_min_seconds,
786
+ backoff_max_seconds=backoff_max_seconds,
787
+ retryable_error_kinds=retryable_error_kinds,
788
+ )
789
+
790
+
791
+ def resolve_circuit_breaker_policy(metadata: dict[str, Any] | None) -> CircuitBreakerPolicy:
792
+ """Resolve circuit-breaker policy from tool metadata.
793
+
794
+ Args:
795
+ metadata: Optional tool metadata dictionary.
796
+
797
+ Returns:
798
+ CircuitBreakerPolicy with validated values and defaults applied.
799
+
800
+ Raises:
801
+ ValueError: If the configured policy contains invalid values.
802
+ """
803
+ raw_circuit_breaker = _extract_circuit_breaker_value(metadata)
804
+ if raw_circuit_breaker is None:
805
+ return CircuitBreakerPolicy(
806
+ enabled=DEFAULT_CIRCUIT_BREAKER_ENABLED,
807
+ fail_max=DEFAULT_CIRCUIT_FAIL_MAX,
808
+ reset_timeout_seconds=DEFAULT_CIRCUIT_RESET_TIMEOUT_SECONDS,
809
+ half_open_max_calls=DEFAULT_CIRCUIT_HALF_OPEN_MAX_CALLS,
810
+ )
811
+
812
+ if not isinstance(raw_circuit_breaker, dict):
813
+ raise ValueError("circuit_breaker must be a dictionary")
814
+
815
+ enabled = bool(raw_circuit_breaker.get(CIRCUIT_BREAKER_ENABLED_KEY, DEFAULT_CIRCUIT_BREAKER_ENABLED))
816
+ fail_max = _validate_positive_int(
817
+ raw_circuit_breaker.get(CIRCUIT_BREAKER_FAIL_MAX_KEY, DEFAULT_CIRCUIT_FAIL_MAX),
818
+ field_name="circuit_breaker.fail_max",
819
+ )
820
+ reset_timeout_seconds = _validate_positive_timeout(
821
+ raw_circuit_breaker.get(
822
+ CIRCUIT_BREAKER_RESET_TIMEOUT_SECONDS_KEY,
823
+ DEFAULT_CIRCUIT_RESET_TIMEOUT_SECONDS,
824
+ )
825
+ )
826
+ half_open_max_calls = _validate_positive_int(
827
+ raw_circuit_breaker.get(
828
+ CIRCUIT_BREAKER_HALF_OPEN_MAX_CALLS_KEY,
829
+ DEFAULT_CIRCUIT_HALF_OPEN_MAX_CALLS,
830
+ ),
831
+ field_name="circuit_breaker.half_open_max_calls",
832
+ )
833
+
834
+ return CircuitBreakerPolicy(
835
+ enabled=enabled,
836
+ fail_max=fail_max,
837
+ reset_timeout_seconds=reset_timeout_seconds,
838
+ half_open_max_calls=half_open_max_calls,
839
+ )
840
+
841
+
842
+ def infer_tool_source(tool: BaseTool | None, metadata: dict[str, Any] | None) -> str:
843
+ """Infer tool source label for diagnostics and metadata.
844
+
845
+ Args:
846
+ tool: Tool instance used to infer source from module namespace.
847
+ metadata: Optional metadata that can override source classification.
848
+
849
+ Returns:
850
+ Normalized source label (``gl_connector``, ``mcp``, ``custom``, ``ptc``, ``unknown``).
851
+ """
852
+ metadata_source = _extract_source_override(metadata)
853
+ if metadata_source:
854
+ return metadata_source
855
+
856
+ if tool is None:
857
+ return "unknown"
858
+
859
+ module_name = getattr(tool.__class__, "__module__", "").lower()
860
+ if module_name == "aip_agents.tools.execute_ptc_code":
861
+ return "ptc"
862
+ if "mcp" in module_name:
863
+ return "mcp"
864
+ if "connector" in module_name or "gl_connectors" in module_name:
865
+ return "gl_connector"
866
+ return "custom"
867
+
868
+
869
+ def build_timeout_error_output(
870
+ *,
871
+ tool_name: str,
872
+ timeout_seconds: float,
873
+ tool_source: str,
874
+ cancelled: bool | None = None,
875
+ ) -> dict[str, Any]:
876
+ """Build a structured timeout output for tool execution.
877
+
878
+ Args:
879
+ tool_name: Name of the tool that timed out.
880
+ timeout_seconds: Effective timeout budget used by this invocation.
881
+ tool_source: Source classification for the tool.
882
+ cancelled: Whether the timed-out attempt was confirmed cancelled.
883
+
884
+ Returns:
885
+ Structured timeout payload containing user-facing text and metadata.
886
+ """
887
+ timeout_display = _format_timeout(timeout_seconds)
888
+ content = (
889
+ f"Tool '{tool_name}' timed out after {timeout_display}s "
890
+ f"(source: {tool_source}). The upstream endpoint may be unavailable. "
891
+ "Retry later or increase timeout via metadata.tool_configs.<tool>.resilience.timeout_seconds."
892
+ )
893
+ if cancelled is False:
894
+ content += " Cancellation could not be confirmed, so do not assume the previous attempt was safely aborted."
895
+
896
+ tool_execution: dict[str, Any] = {
897
+ "status": "timeout",
898
+ "category": CATEGORY_TIMEOUT,
899
+ "tool_name": tool_name,
900
+ "tool_source": tool_source,
901
+ "timeout_seconds": timeout_seconds,
902
+ "is_retryable": cancelled is not False,
903
+ }
904
+ if cancelled is not None:
905
+ tool_execution["cancelled"] = cancelled
906
+
907
+ return {"result": content, "metadata": {"tool_execution": tool_execution}}
908
+
909
+
910
+ def build_invalid_timeout_config_output(
911
+ *,
912
+ tool_name: str,
913
+ tool_source: str,
914
+ error: str,
915
+ ) -> dict[str, Any]:
916
+ """Build a structured invalid-timeout-config output.
917
+
918
+ Args:
919
+ tool_name: Name of the tool with invalid timeout config.
920
+ tool_source: Source classification for the tool.
921
+ error: Validation error message describing the invalid value.
922
+
923
+ Returns:
924
+ Structured payload describing invalid timeout configuration.
925
+ """
926
+ content = (
927
+ f"Invalid timeout configuration for tool '{tool_name}' (source: {tool_source}): {error}. "
928
+ "Use a positive numeric timeout value in metadata.tool_configs.<tool>.resilience.timeout_seconds."
929
+ )
930
+
931
+ return {
932
+ "result": content,
933
+ "metadata": {
934
+ "tool_execution": {
935
+ "status": "error",
936
+ "category": CATEGORY_INVALID_TIMEOUT_CONFIG,
937
+ "tool_name": tool_name,
938
+ "tool_source": tool_source,
939
+ "is_retryable": False,
940
+ }
941
+ },
942
+ }
943
+
944
+
945
+ def build_invalid_retry_config_output(
946
+ *,
947
+ tool_name: str,
948
+ tool_source: str,
949
+ error: str,
950
+ ) -> dict[str, Any]:
951
+ """Build a structured invalid-retry-config output.
952
+
953
+ Args:
954
+ tool_name: Name of the tool with invalid retry config.
955
+ tool_source: Source classification for the tool.
956
+ error: Validation error message describing the invalid value.
957
+
958
+ Returns:
959
+ Structured payload describing invalid retry configuration.
960
+ """
961
+ content = (
962
+ f"Invalid retry configuration for tool '{tool_name}' (source: {tool_source}): {error}. "
963
+ "Use retry.enabled, retry.max_attempts >= 1, and positive backoff values."
964
+ )
965
+
966
+ return {
967
+ "result": content,
968
+ "metadata": {
969
+ "tool_execution": {
970
+ "status": "error",
971
+ "category": CATEGORY_INVALID_RETRY_CONFIG,
972
+ "tool_name": tool_name,
973
+ "tool_source": tool_source,
974
+ "is_retryable": False,
975
+ }
976
+ },
977
+ }
978
+
979
+
980
+ def build_invalid_circuit_breaker_config_output(
981
+ *,
982
+ tool_name: str,
983
+ tool_source: str,
984
+ error: str,
985
+ ) -> dict[str, Any]:
986
+ """Build structured output for invalid circuit-breaker configuration.
987
+
988
+ Args:
989
+ tool_name: Tool name from the active tool call.
990
+ tool_source: Source label for the tool.
991
+ error: Validation error message.
992
+
993
+ Returns:
994
+ Tool output payload with categorized metadata.
995
+ """
996
+ content = (
997
+ f"Invalid circuit breaker configuration for tool '{tool_name}' (source: {tool_source}): {error}. "
998
+ "Use circuit_breaker.enabled, fail_max >= 1, reset_timeout_seconds > 0, and half_open_max_calls >= 1."
999
+ )
1000
+
1001
+ return {
1002
+ "result": content,
1003
+ "metadata": {
1004
+ "tool_execution": {
1005
+ "status": "error",
1006
+ "category": CATEGORY_INVALID_CIRCUIT_CONFIG,
1007
+ "tool_name": tool_name,
1008
+ "tool_source": tool_source,
1009
+ "is_retryable": False,
1010
+ }
1011
+ },
1012
+ }
1013
+
1014
+
1015
+ def build_circuit_open_error_output(
1016
+ *,
1017
+ tool_name: str,
1018
+ tool_source: str,
1019
+ reset_timeout_seconds: float,
1020
+ circuit_state: str,
1021
+ failure_kind: str | None,
1022
+ ) -> dict[str, Any]:
1023
+ """Build structured output for circuit-open fail-fast behavior.
1024
+
1025
+ Args:
1026
+ tool_name: Tool name from the active tool call.
1027
+ tool_source: Source label for the tool.
1028
+ reset_timeout_seconds: Cooldown duration before half-open probe.
1029
+ circuit_state: Current circuit state label.
1030
+ failure_kind: Last known failure kind, when available.
1031
+
1032
+ Returns:
1033
+ Tool output payload with circuit-open metadata.
1034
+ """
1035
+ content = (
1036
+ f"Tool '{tool_name}' is temporarily unavailable because the circuit is {circuit_state} "
1037
+ f"(source: {tool_source}). Retry after about {_format_timeout(reset_timeout_seconds)}s cooldown."
1038
+ )
1039
+
1040
+ metadata: dict[str, Any] = {
1041
+ "tool_execution": {
1042
+ "status": CATEGORY_CIRCUIT_OPEN,
1043
+ "category": CATEGORY_CIRCUIT_OPEN,
1044
+ "error_kind": KIND_CIRCUIT_OPEN,
1045
+ "tool_name": tool_name,
1046
+ "tool_source": tool_source,
1047
+ "circuit_state": circuit_state,
1048
+ "circuit_reset_timeout_seconds": reset_timeout_seconds,
1049
+ "is_retryable": False,
1050
+ }
1051
+ }
1052
+ if failure_kind:
1053
+ metadata["tool_execution"]["last_error_kind"] = failure_kind
1054
+
1055
+ return {"result": content, "metadata": metadata}
1056
+
1057
+
1058
+ def build_retry_exhausted_error_output(
1059
+ *,
1060
+ tool_name: str,
1061
+ tool_source: str,
1062
+ timeout_seconds: float,
1063
+ attempts_used: int,
1064
+ max_attempts: int,
1065
+ last_failure: ToolFailureDetail,
1066
+ retry_budget_exhausted: bool,
1067
+ ) -> dict[str, Any]:
1068
+ """Build a terminal retry-exhausted output with attempt summary metadata.
1069
+
1070
+ Args:
1071
+ tool_name: Name of the tool that exhausted retries.
1072
+ tool_source: Source classification for the tool.
1073
+ timeout_seconds: Timeout budget enforced for this execution.
1074
+ attempts_used: Total attempts consumed.
1075
+ max_attempts: Maximum attempts allowed by policy.
1076
+ last_failure: Classified failure detail from the final attempt.
1077
+ retry_budget_exhausted: Whether timeout budget prevented additional retry.
1078
+
1079
+ Returns:
1080
+ Structured payload describing retry exhaustion.
1081
+ """
1082
+ budget_note = "retry budget exhausted" if retry_budget_exhausted else "attempt cap reached"
1083
+ content = (
1084
+ f"Tool '{tool_name}' failed after {attempts_used}/{max_attempts} attempt(s) "
1085
+ f"within {_format_timeout(timeout_seconds)}s timeout budget (source: {tool_source}, {budget_note}). "
1086
+ f"Last failure category: {last_failure.category}. {last_failure.message}"
1087
+ )
1088
+
1089
+ metadata: dict[str, Any] = {
1090
+ "tool_execution": {
1091
+ "status": CATEGORY_RETRY_EXHAUSTED,
1092
+ "category": CATEGORY_RETRY_EXHAUSTED,
1093
+ "tool_name": tool_name,
1094
+ "tool_source": tool_source,
1095
+ "timeout_seconds": timeout_seconds,
1096
+ "attempts_used": attempts_used,
1097
+ "max_attempts": max_attempts,
1098
+ "retry_budget_exhausted": retry_budget_exhausted,
1099
+ "last_error_category": last_failure.category,
1100
+ "last_error_kind": last_failure.kind,
1101
+ "is_retryable": False,
1102
+ }
1103
+ }
1104
+ if last_failure.http_status is not None:
1105
+ metadata["tool_execution"]["http_status"] = last_failure.http_status
1106
+ if last_failure.cancelled is not None:
1107
+ metadata["tool_execution"]["cancelled"] = last_failure.cancelled
1108
+ if last_failure.cancelled is False:
1109
+ content += " Cancellation could not be confirmed for the timed-out attempt."
1110
+
1111
+ return {
1112
+ "result": content,
1113
+ "metadata": metadata,
1114
+ }
1115
+
1116
+
1117
+ def build_classified_error_output(
1118
+ *,
1119
+ tool_name: str,
1120
+ tool_source: str,
1121
+ attempts_used: int,
1122
+ max_attempts: int,
1123
+ failure: ToolFailureDetail,
1124
+ ) -> dict[str, Any]:
1125
+ """Build structured classified error output for non-retried failures.
1126
+
1127
+ Args:
1128
+ tool_name: Name of the tool that failed.
1129
+ tool_source: Source classification for the tool.
1130
+ attempts_used: Attempts consumed before terminal failure.
1131
+ max_attempts: Maximum attempts allowed by policy.
1132
+ failure: Classified failure detail to encode in response metadata.
1133
+
1134
+ Returns:
1135
+ Structured payload describing the classified terminal failure.
1136
+ """
1137
+ recovery_hint = _recovery_hint_for_category(failure.category)
1138
+ content = (
1139
+ f"Tool '{tool_name}' failed ({failure.category}) (source: {tool_source}): {failure.message}. {recovery_hint}"
1140
+ )
1141
+
1142
+ metadata: dict[str, Any] = {
1143
+ "tool_execution": {
1144
+ "status": "error",
1145
+ "category": failure.category,
1146
+ "error_kind": failure.kind,
1147
+ "code": failure.code,
1148
+ "tool_name": tool_name,
1149
+ "tool_source": tool_source,
1150
+ "attempts_used": attempts_used,
1151
+ "max_attempts": max_attempts,
1152
+ "is_retryable": failure.is_retryable_default,
1153
+ }
1154
+ }
1155
+ if failure.http_status is not None:
1156
+ metadata["tool_execution"]["http_status"] = failure.http_status
1157
+
1158
+ return {
1159
+ "result": content,
1160
+ "metadata": metadata,
1161
+ }
1162
+
1163
+
1164
+ def add_retry_attempt_metadata(
1165
+ tool_output: dict[str, Any],
1166
+ *,
1167
+ attempts_used: int,
1168
+ max_attempts: int,
1169
+ retry_budget_exhausted: bool,
1170
+ ) -> dict[str, Any]:
1171
+ """Attach retry attempt summary to existing tool metadata.
1172
+
1173
+ Args:
1174
+ tool_output: Structured tool output dictionary to enrich.
1175
+ attempts_used: Total attempts consumed.
1176
+ max_attempts: Maximum attempts allowed by policy.
1177
+ retry_budget_exhausted: Whether timeout budget prevented additional retry.
1178
+
1179
+ Returns:
1180
+ The same tool output dictionary with retry attempt metadata attached.
1181
+ """
1182
+ metadata = tool_output.setdefault("metadata", {})
1183
+ if not isinstance(metadata, dict):
1184
+ metadata = {}
1185
+ tool_output["metadata"] = metadata
1186
+
1187
+ execution = metadata.setdefault("tool_execution", {})
1188
+ if not isinstance(execution, dict):
1189
+ execution = {}
1190
+ metadata["tool_execution"] = execution
1191
+
1192
+ execution.update(
1193
+ {
1194
+ "attempts_used": attempts_used,
1195
+ "max_attempts": max_attempts,
1196
+ "retry_budget_exhausted": retry_budget_exhausted,
1197
+ }
1198
+ )
1199
+ return tool_output
1200
+
1201
+
1202
+ def add_timeout_cancellation_metadata(
1203
+ tool_output: dict[str, Any],
1204
+ *,
1205
+ cancelled: bool | None,
1206
+ ) -> dict[str, Any]:
1207
+ """Attach timeout cancellation truthfulness to existing tool metadata."""
1208
+ if cancelled is None:
1209
+ return tool_output
1210
+
1211
+ metadata = tool_output.setdefault("metadata", {})
1212
+ if not isinstance(metadata, dict):
1213
+ metadata = {}
1214
+ tool_output["metadata"] = metadata
1215
+
1216
+ execution = metadata.setdefault("tool_execution", {})
1217
+ if not isinstance(execution, dict):
1218
+ execution = {}
1219
+ metadata["tool_execution"] = execution
1220
+
1221
+ execution["cancelled"] = cancelled
1222
+ return tool_output
1223
+
1224
+
1225
+ def add_circuit_breaker_metadata(
1226
+ tool_output: dict[str, Any],
1227
+ *,
1228
+ transition: CircuitBreakerTransition,
1229
+ ) -> dict[str, Any]:
1230
+ """Attach circuit-breaker transition metadata to tool output.
1231
+
1232
+ Args:
1233
+ tool_output: Existing tool output payload.
1234
+ transition: Circuit transition snapshot for this attempt.
1235
+
1236
+ Returns:
1237
+ Tool output payload with merged circuit metadata.
1238
+ """
1239
+ metadata = tool_output.setdefault("metadata", {})
1240
+ if not isinstance(metadata, dict):
1241
+ metadata = {}
1242
+ tool_output["metadata"] = metadata
1243
+
1244
+ execution = metadata.setdefault("tool_execution", {})
1245
+ if not isinstance(execution, dict):
1246
+ execution = {}
1247
+ metadata["tool_execution"] = execution
1248
+
1249
+ execution.update(
1250
+ {
1251
+ "circuit_tool_key": transition.tool_key,
1252
+ "circuit_state": transition.state,
1253
+ "circuit_previous_state": transition.previous_state,
1254
+ "circuit_transitioned": transition.transitioned,
1255
+ "circuit_consecutive_failures": transition.consecutive_failures,
1256
+ "circuit_inflight_half_open_calls": transition.inflight_half_open_calls,
1257
+ }
1258
+ )
1259
+
1260
+ if transition.opened_at_unix_seconds is not None:
1261
+ execution["circuit_opened_at_unix_seconds"] = transition.opened_at_unix_seconds
1262
+
1263
+ if transition.last_error_kind:
1264
+ execution["circuit_last_error_kind"] = transition.last_error_kind
1265
+
1266
+ return tool_output
1267
+
1268
+
1269
+ def classify_tool_exception(error: Exception) -> ToolFailureDetail:
1270
+ """Classify tool exception into descriptive category and retry metadata.
1271
+
1272
+ Args:
1273
+ error: Raised exception from tool execution.
1274
+
1275
+ Returns:
1276
+ Classified failure detail used by retry and output mapping logic.
1277
+ """
1278
+ message = _normalize_error_message(error)
1279
+ lowered_message = message.lower()
1280
+ type_name = type(error).__name__.lower()
1281
+
1282
+ status_code = _extract_http_status(error)
1283
+ if status_code == 401:
1284
+ return ToolFailureDetail(
1285
+ category=CATEGORY_UNAUTHORIZED,
1286
+ kind=KIND_UNAUTHORIZED,
1287
+ code="tool.unauthorized",
1288
+ message=message,
1289
+ http_status=status_code,
1290
+ is_retryable_default=False,
1291
+ )
1292
+ if status_code == 403:
1293
+ return ToolFailureDetail(
1294
+ category=CATEGORY_FORBIDDEN,
1295
+ kind=KIND_FORBIDDEN,
1296
+ code="tool.forbidden",
1297
+ message=message,
1298
+ http_status=status_code,
1299
+ is_retryable_default=False,
1300
+ )
1301
+ if status_code == 429:
1302
+ return ToolFailureDetail(
1303
+ category=CATEGORY_RATE_LIMITED,
1304
+ kind=KIND_RATE_LIMITED,
1305
+ code="tool.rate_limited",
1306
+ message=message,
1307
+ http_status=status_code,
1308
+ is_retryable_default=True,
1309
+ )
1310
+ if status_code is not None and 500 <= status_code < 600:
1311
+ return ToolFailureDetail(
1312
+ category=CATEGORY_UPSTREAM_FAILURE,
1313
+ kind=KIND_UPSTREAM_5XX,
1314
+ code="tool.upstream_5xx",
1315
+ message=message,
1316
+ http_status=status_code,
1317
+ is_retryable_default=True,
1318
+ )
1319
+
1320
+ if isinstance(error, TimeoutError) or "timeout" in lowered_message or "timed out" in lowered_message:
1321
+ return ToolFailureDetail(
1322
+ category=CATEGORY_TIMEOUT,
1323
+ kind=KIND_TIMEOUT,
1324
+ code="tool.timeout",
1325
+ message=message or "Tool call timed out",
1326
+ http_status=status_code,
1327
+ is_retryable_default=True,
1328
+ cancelled=getattr(error, "cancelled", None),
1329
+ )
1330
+
1331
+ endpoint_keywords = (
1332
+ "connection refused",
1333
+ "name or service not known",
1334
+ "temporary failure in name resolution",
1335
+ "dns",
1336
+ "network is unreachable",
1337
+ "failed to establish a new connection",
1338
+ "nodename nor servname",
1339
+ )
1340
+ if any(keyword in lowered_message for keyword in endpoint_keywords):
1341
+ return ToolFailureDetail(
1342
+ category=CATEGORY_ENDPOINT_UNREACHABLE,
1343
+ kind=KIND_ENDPOINT_UNREACHABLE,
1344
+ code="tool.endpoint_unreachable",
1345
+ message=message,
1346
+ http_status=status_code,
1347
+ is_retryable_default=True,
1348
+ )
1349
+
1350
+ if isinstance(error, ConnectionError) or "connection" in type_name or "connection" in lowered_message:
1351
+ return ToolFailureDetail(
1352
+ category=CATEGORY_ENDPOINT_UNREACHABLE,
1353
+ kind=KIND_CONNECTION_ERROR,
1354
+ code="tool.connection_error",
1355
+ message=message,
1356
+ http_status=status_code,
1357
+ is_retryable_default=True,
1358
+ )
1359
+
1360
+ return ToolFailureDetail(
1361
+ category=CATEGORY_UPSTREAM_FAILURE,
1362
+ kind=KIND_UPSTREAM_FAILURE,
1363
+ code="tool.upstream_failure",
1364
+ message=message,
1365
+ http_status=status_code,
1366
+ is_retryable_default=False,
1367
+ )
1368
+
1369
+
1370
+ def should_retry_failure(failure: ToolFailureDetail, retry_policy: RetryPolicy) -> bool:
1371
+ """Return True when failure is retryable under policy rules.
1372
+
1373
+ Args:
1374
+ failure: Classified failure detail for the attempt.
1375
+ retry_policy: Resolved retry policy for current invocation.
1376
+
1377
+ Returns:
1378
+ ``True`` when the failure should be retried, otherwise ``False``.
1379
+ """
1380
+ if not retry_policy.enabled:
1381
+ return False
1382
+ if failure.kind in _NON_RETRYABLE_KINDS:
1383
+ return False
1384
+ if failure.kind == KIND_TIMEOUT and failure.cancelled is False:
1385
+ return False
1386
+ return failure.kind in retry_policy.retryable_error_kinds
1387
+
1388
+
1389
+ def compute_retry_backoff_seconds(attempt_number: int, retry_policy: RetryPolicy) -> float:
1390
+ """Compute bounded exponential backoff for the next retry attempt.
1391
+
1392
+ Uses ``tenacity.wait_exponential`` so retry timing logic stays aligned with
1393
+ the same strategy used by runtime retry orchestration.
1394
+
1395
+ Args:
1396
+ attempt_number: 1-based retry attempt number.
1397
+ retry_policy: Resolved retry policy.
1398
+
1399
+ Returns:
1400
+ Delay in seconds for the next retry attempt.
1401
+ """
1402
+ wait_strategy = wait_exponential(
1403
+ multiplier=retry_policy.backoff_min_seconds,
1404
+ min=retry_policy.backoff_min_seconds,
1405
+ max=retry_policy.backoff_max_seconds,
1406
+ )
1407
+ return float(wait_strategy(_TenacityWaitState(attempt_number=attempt_number)))
1408
+
1409
+
1410
+ def _extract_timeout_value(metadata: dict[str, Any] | None) -> Any:
1411
+ """Extract timeout override from metadata.
1412
+
1413
+ Args:
1414
+ metadata: Optional tool metadata dictionary.
1415
+
1416
+ Returns:
1417
+ Raw timeout override value, or ``None`` when absent.
1418
+ """
1419
+ if not isinstance(metadata, dict):
1420
+ return None
1421
+
1422
+ resilience = metadata.get(RESILIENCE_KEY)
1423
+ if isinstance(resilience, dict) and TIMEOUT_SECONDS_KEY in resilience:
1424
+ return resilience[TIMEOUT_SECONDS_KEY]
1425
+
1426
+ if TIMEOUT_SECONDS_KEY in metadata:
1427
+ return metadata[TIMEOUT_SECONDS_KEY]
1428
+
1429
+ if TOOL_TIMEOUT_SECONDS_KEY in metadata:
1430
+ return metadata[TOOL_TIMEOUT_SECONDS_KEY]
1431
+
1432
+ return None
1433
+
1434
+
1435
+ def _extract_retry_value(metadata: dict[str, Any] | None) -> Any:
1436
+ """Extract retry override from metadata.
1437
+
1438
+ Args:
1439
+ metadata: Optional tool metadata dictionary.
1440
+
1441
+ Returns:
1442
+ Raw retry configuration value, or ``None`` when absent.
1443
+ """
1444
+ if not isinstance(metadata, dict):
1445
+ return None
1446
+
1447
+ resilience = metadata.get(RESILIENCE_KEY)
1448
+ if isinstance(resilience, dict) and RETRY_KEY in resilience:
1449
+ return resilience[RETRY_KEY]
1450
+
1451
+ if RETRY_KEY in metadata:
1452
+ return metadata[RETRY_KEY]
1453
+
1454
+ return None
1455
+
1456
+
1457
+ def _extract_circuit_breaker_value(metadata: dict[str, Any] | None) -> Any:
1458
+ """Extract raw circuit-breaker metadata from supported locations.
1459
+
1460
+ Args:
1461
+ metadata: Optional tool metadata dictionary.
1462
+
1463
+ Returns:
1464
+ Raw circuit breaker config value or ``None`` when absent.
1465
+ """
1466
+ if not isinstance(metadata, dict):
1467
+ return None
1468
+
1469
+ resilience = metadata.get(RESILIENCE_KEY)
1470
+ if isinstance(resilience, dict) and CIRCUIT_BREAKER_KEY in resilience:
1471
+ return resilience[CIRCUIT_BREAKER_KEY]
1472
+
1473
+ if CIRCUIT_BREAKER_KEY in metadata:
1474
+ return metadata[CIRCUIT_BREAKER_KEY]
1475
+
1476
+ return None
1477
+
1478
+
1479
+ def _extract_source_override(metadata: dict[str, Any] | None) -> str | None:
1480
+ """Extract source override from metadata.
1481
+
1482
+ Args:
1483
+ metadata: Optional tool metadata dictionary.
1484
+
1485
+ Returns:
1486
+ Normalized source override value, or ``None`` when absent/invalid.
1487
+ """
1488
+ if not isinstance(metadata, dict):
1489
+ return None
1490
+
1491
+ direct = _normalize_source(metadata.get(TOOL_SOURCE_KEY))
1492
+ if direct:
1493
+ return direct
1494
+
1495
+ resilience = metadata.get(RESILIENCE_KEY)
1496
+ if isinstance(resilience, dict):
1497
+ nested = _normalize_source(resilience.get(TOOL_SOURCE_KEY))
1498
+ if nested:
1499
+ return nested
1500
+
1501
+ return None
1502
+
1503
+
1504
+ def _normalize_source(value: Any) -> str | None:
1505
+ """Normalize a source override into a known source label.
1506
+
1507
+ Args:
1508
+ value: Candidate source value.
1509
+
1510
+ Returns:
1511
+ Normalized source label when valid, otherwise ``None``.
1512
+ """
1513
+ if not isinstance(value, str):
1514
+ return None
1515
+
1516
+ normalized = value.strip().lower()
1517
+ if normalized in _KNOWN_TOOL_SOURCES:
1518
+ return normalized
1519
+
1520
+ return None
1521
+
1522
+
1523
+ def _validate_positive_timeout(value: Any) -> float:
1524
+ """Validate and parse a positive timeout value.
1525
+
1526
+ Args:
1527
+ value: Candidate timeout value.
1528
+
1529
+ Returns:
1530
+ Parsed positive timeout in seconds.
1531
+ """
1532
+ try:
1533
+ timeout = float(value)
1534
+ except (TypeError, ValueError) as exc:
1535
+ raise ValueError("timeout must be a numeric value") from exc
1536
+
1537
+ if timeout <= 0:
1538
+ raise ValueError("timeout must be greater than 0")
1539
+
1540
+ return timeout
1541
+
1542
+
1543
+ def _validate_positive_int(value: Any, *, field_name: str) -> int:
1544
+ """Validate and parse a positive integer field.
1545
+
1546
+ Args:
1547
+ value: Candidate integer value.
1548
+ field_name: Field name used in validation error messages.
1549
+
1550
+ Returns:
1551
+ Parsed integer value guaranteed to be greater than or equal to ``1``.
1552
+ """
1553
+ try:
1554
+ parsed = int(value)
1555
+ except (TypeError, ValueError) as exc:
1556
+ raise ValueError(f"{field_name} must be an integer") from exc
1557
+
1558
+ if parsed < 1:
1559
+ raise ValueError(f"{field_name} must be greater than or equal to 1")
1560
+
1561
+ return parsed
1562
+
1563
+
1564
+ def _validate_retryable_error_kinds(value: Any) -> tuple[str, ...]:
1565
+ """Validate and normalize configured retryable error kinds.
1566
+
1567
+ Args:
1568
+ value: Candidate list of retryable error-kind strings.
1569
+
1570
+ Returns:
1571
+ Normalized, deduplicated tuple of retryable error kinds.
1572
+ """
1573
+ if value is None:
1574
+ return DEFAULT_RETRYABLE_ERROR_KINDS
1575
+
1576
+ if not isinstance(value, list):
1577
+ raise ValueError("retry.retryable_error_kinds must be a list of strings")
1578
+
1579
+ normalized: list[str] = []
1580
+ for item in value:
1581
+ if not isinstance(item, str):
1582
+ raise ValueError("retry.retryable_error_kinds must contain only strings")
1583
+ kind = item.strip().lower()
1584
+ if kind:
1585
+ normalized.append(kind)
1586
+
1587
+ if not normalized:
1588
+ raise ValueError("retry.retryable_error_kinds cannot be empty")
1589
+
1590
+ return tuple(dict.fromkeys(normalized))
1591
+
1592
+
1593
+ def _extract_http_status(error: Exception) -> int | None:
1594
+ """Extract HTTP status code from exception objects.
1595
+
1596
+ Args:
1597
+ error: Exception raised during tool execution.
1598
+
1599
+ Returns:
1600
+ HTTP status code when available, otherwise ``None``.
1601
+ """
1602
+ response = getattr(error, "response", None)
1603
+ if response is not None:
1604
+ status = getattr(response, "status_code", None)
1605
+ if isinstance(status, int):
1606
+ return status
1607
+
1608
+ status = getattr(error, "status_code", None)
1609
+ if isinstance(status, int):
1610
+ return status
1611
+
1612
+ return None
1613
+
1614
+
1615
+ def _normalize_error_message(error: Exception) -> str:
1616
+ """Normalize an exception into a non-empty message string.
1617
+
1618
+ Args:
1619
+ error: Exception raised during tool execution.
1620
+
1621
+ Returns:
1622
+ Normalized human-readable error message.
1623
+ """
1624
+ message = str(error).strip()
1625
+ if message:
1626
+ return message
1627
+ return f"{type(error).__name__} occurred during tool execution"
1628
+
1629
+
1630
+ def _recovery_hint_for_category(category: str) -> str:
1631
+ """Return a user-facing recovery hint for a failure category.
1632
+
1633
+ Args:
1634
+ category: Failure category identifier.
1635
+
1636
+ Returns:
1637
+ Recovery hint string tailored to the given category.
1638
+ """
1639
+ if category == CATEGORY_UNAUTHORIZED:
1640
+ return "Verify authentication credentials and token scope."
1641
+ if category == CATEGORY_FORBIDDEN:
1642
+ return "Verify authorization permissions for this endpoint."
1643
+ if category == CATEGORY_RATE_LIMITED:
1644
+ return "Retry later or reduce request rate."
1645
+ if category == CATEGORY_ENDPOINT_UNREACHABLE:
1646
+ return "Verify endpoint reachability, DNS, and network path."
1647
+ if category == CATEGORY_TIMEOUT:
1648
+ return "Retry later or increase timeout configuration for this tool."
1649
+ return "Check upstream service health and tool configuration."
1650
+
1651
+
1652
+ def _format_timeout(timeout_seconds: float) -> str:
1653
+ """Format timeout seconds for user-facing messages.
1654
+
1655
+ Args:
1656
+ timeout_seconds: Timeout value in seconds.
1657
+
1658
+ Returns:
1659
+ String representation without trailing zero-only decimals.
1660
+ """
1661
+ return f"{timeout_seconds:.3f}".rstrip("0").rstrip(".")