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.
- aip_agents/__init__.py +66 -0
- aip_agents/a2a/__init__.py +19 -0
- aip_agents/a2a/server/__init__.py +10 -0
- aip_agents/a2a/server/base_executor.py +1125 -0
- aip_agents/a2a/server/google_adk_executor.py +198 -0
- aip_agents/a2a/server/langflow_executor.py +180 -0
- aip_agents/a2a/server/langgraph_executor.py +270 -0
- aip_agents/a2a/types.py +232 -0
- aip_agents/a2ui/__init__.py +26 -0
- aip_agents/a2ui/catalog_provider.py +109 -0
- aip_agents/a2ui/examples/0.8/booking_form.json +1 -0
- aip_agents/a2ui/examples/0.8/confirmation.json +1 -0
- aip_agents/a2ui/examples/0.8/single_column_list.json +1 -0
- aip_agents/a2ui/examples/0.9/booking_form.json +1 -0
- aip_agents/a2ui/examples/0.9/confirmation.json +1 -0
- aip_agents/a2ui/examples/0.9/single_column_list.json +1 -0
- aip_agents/a2ui/middleware.py +754 -0
- aip_agents/a2ui/models.py +153 -0
- aip_agents/a2ui/public_url.py +138 -0
- aip_agents/a2ui/stream_filter.py +67 -0
- aip_agents/agent/__init__.py +72 -0
- aip_agents/agent/base_agent.py +1437 -0
- aip_agents/agent/base_langgraph_agent.py +4048 -0
- aip_agents/agent/google_adk_agent.py +932 -0
- aip_agents/agent/google_adk_constants.py +11 -0
- aip_agents/agent/graph_runtime.py +185 -0
- aip_agents/agent/hitl/__init__.py +24 -0
- aip_agents/agent/hitl/config.py +28 -0
- aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
- aip_agents/agent/hitl/manager.py +532 -0
- aip_agents/agent/hitl/models.py +18 -0
- aip_agents/agent/hitl/prompt/__init__.py +9 -0
- aip_agents/agent/hitl/prompt/base.py +42 -0
- aip_agents/agent/hitl/prompt/deferred.py +73 -0
- aip_agents/agent/hitl/registry.py +149 -0
- aip_agents/agent/interface.py +138 -0
- aip_agents/agent/interfaces.py +65 -0
- aip_agents/agent/langflow_agent.py +464 -0
- aip_agents/agent/langgraph_react_agent.py +3741 -0
- aip_agents/agent/model_invocation_adapter.py +346 -0
- aip_agents/agent/system_instruction_context.py +34 -0
- aip_agents/agent/tool_execution_engine.py +587 -0
- aip_agents/audio_interface/__init__.py +30 -0
- aip_agents/audio_interface/audio_agent_adapter.py +254 -0
- aip_agents/audio_interface/config.py +146 -0
- aip_agents/audio_interface/errors.py +15 -0
- aip_agents/audio_interface/livekit_audio_session.py +504 -0
- aip_agents/audio_interface/livekit_realtime_audio_session.py +142 -0
- aip_agents/audio_interface/session_factory.py +40 -0
- aip_agents/clients/__init__.py +10 -0
- aip_agents/clients/langflow/__init__.py +10 -0
- aip_agents/clients/langflow/client.py +477 -0
- aip_agents/clients/langflow/types.py +18 -0
- aip_agents/common/__init__.py +5 -0
- aip_agents/common/callable_introspection.py +17 -0
- aip_agents/constants.py +23 -0
- aip_agents/context/__init__.py +5 -0
- aip_agents/context/budget.py +237 -0
- aip_agents/context/compaction.py +339 -0
- aip_agents/context/metadata.py +169 -0
- aip_agents/context/offload.py +449 -0
- aip_agents/context/preparation.py +196 -0
- aip_agents/context/prompt.py +61 -0
- aip_agents/context/runtime.py +149 -0
- aip_agents/context/session_recall.py +69 -0
- aip_agents/context/state.py +48 -0
- aip_agents/context/utils.py +44 -0
- aip_agents/credentials/manager.py +132 -0
- aip_agents/examples/__init__.py +5 -0
- aip_agents/examples/audio_livekit_session.py +116 -0
- aip_agents/examples/compare_streaming_client.py +783 -0
- aip_agents/examples/compare_streaming_server.py +141 -0
- aip_agents/examples/gl_connectors_smoke.py +148 -0
- aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
- aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
- aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
- aip_agents/examples/hello_world_a2a_google_adk_server.py +78 -0
- aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
- aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
- aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
- aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
- aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
- aip_agents/examples/hello_world_a2a_langchain_reference_server.py +104 -0
- aip_agents/examples/hello_world_a2a_langchain_server.py +78 -0
- aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +77 -0
- aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
- aip_agents/examples/hello_world_a2a_langflow_server.py +80 -0
- aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
- aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
- aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +91 -0
- aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
- aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
- aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
- aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
- aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
- aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
- aip_agents/examples/hello_world_a2a_langgraph_server.py +83 -0
- aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +78 -0
- aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +131 -0
- aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
- aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
- aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +250 -0
- aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
- aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +79 -0
- aip_agents/examples/hello_world_filesystem.py +162 -0
- aip_agents/examples/hello_world_google_adk.py +41 -0
- aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
- aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
- aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
- aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
- aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
- aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
- aip_agents/examples/hello_world_google_adk_stream.py +44 -0
- aip_agents/examples/hello_world_langchain.py +28 -0
- aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
- aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
- aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
- aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
- aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
- aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
- aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
- aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
- aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
- aip_agents/examples/hello_world_langchain_stream.py +36 -0
- aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
- aip_agents/examples/hello_world_langflow_agent.py +163 -0
- aip_agents/examples/hello_world_langgraph.py +39 -0
- aip_agents/examples/hello_world_langgraph_gl_connector_twitter.py +44 -0
- aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
- aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
- aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
- aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
- aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
- aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
- aip_agents/examples/hello_world_langgraph_skills.py +62 -0
- aip_agents/examples/hello_world_langgraph_stream.py +43 -0
- aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
- aip_agents/examples/hello_world_model_switch_cli.py +210 -0
- aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
- aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
- aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
- aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
- aip_agents/examples/hello_world_multi_agent_tool_output_client.py +57 -0
- aip_agents/examples/hello_world_multi_agent_tool_output_server.py +131 -0
- aip_agents/examples/hello_world_pii_logger.py +21 -0
- aip_agents/examples/hello_world_ptc.py +49 -0
- aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
- aip_agents/examples/hello_world_sentry.py +133 -0
- aip_agents/examples/hello_world_step_limits.py +273 -0
- aip_agents/examples/hello_world_stock_a2a_server.py +100 -0
- aip_agents/examples/hello_world_tool_output_client.py +55 -0
- aip_agents/examples/hello_world_tool_output_server.py +113 -0
- aip_agents/examples/hitl_demo.py +724 -0
- aip_agents/examples/mcp_configs/configs.py +63 -0
- aip_agents/examples/mcp_servers/common.py +76 -0
- aip_agents/examples/mcp_servers/mcp_name.py +29 -0
- aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
- aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
- aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
- aip_agents/examples/mcp_servers/mcp_time.py +10 -0
- aip_agents/examples/pii_demo_langgraph_client.py +69 -0
- aip_agents/examples/pii_demo_langgraph_server.py +125 -0
- aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
- aip_agents/examples/pii_demo_multi_agent_server.py +246 -0
- aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
- aip_agents/examples/todolist_planning_a2a_langgraph_server.py +87 -0
- aip_agents/examples/tools/__init__.py +27 -0
- aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
- aip_agents/examples/tools/adk_weather_tool.py +60 -0
- aip_agents/examples/tools/data_generator_tool.py +103 -0
- aip_agents/examples/tools/data_visualization_tool.py +312 -0
- aip_agents/examples/tools/image_artifact_tool.py +136 -0
- aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
- aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
- aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
- aip_agents/examples/tools/langchain_weather_tool.py +48 -0
- aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
- aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
- aip_agents/examples/tools/multiply_tool.py +43 -0
- aip_agents/examples/tools/pii_demo_tools.py +189 -0
- aip_agents/examples/tools/random_chart_tool.py +142 -0
- aip_agents/examples/tools/serper_tool.py +202 -0
- aip_agents/examples/tools/stock_tools.py +82 -0
- aip_agents/examples/tools/table_generator_tool.py +167 -0
- aip_agents/examples/tools/time_tool.py +82 -0
- aip_agents/examples/tools/weather_forecast_tool.py +38 -0
- aip_agents/guardrails/__init__.py +83 -0
- aip_agents/guardrails/engines/__init__.py +69 -0
- aip_agents/guardrails/engines/base.py +90 -0
- aip_agents/guardrails/engines/nemo.py +101 -0
- aip_agents/guardrails/engines/phrase_matcher.py +113 -0
- aip_agents/guardrails/exceptions.py +39 -0
- aip_agents/guardrails/manager.py +163 -0
- aip_agents/guardrails/middleware.py +199 -0
- aip_agents/guardrails/schemas.py +63 -0
- aip_agents/guardrails/utils.py +45 -0
- aip_agents/integration/__init__.py +43 -0
- aip_agents/integration/agent.py +58 -0
- aip_agents/integration/guardrails.py +52 -0
- aip_agents/integration/hitl.py +71 -0
- aip_agents/integration/ptc.py +95 -0
- aip_agents/integration/skills.py +37 -0
- aip_agents/integration/storage.py +81 -0
- aip_agents/integration/version.py +103 -0
- aip_agents/mcp/__init__.py +1 -0
- aip_agents/mcp/client/__init__.py +60 -0
- aip_agents/mcp/client/base_mcp_client.py +377 -0
- aip_agents/mcp/client/connection_manager.py +231 -0
- aip_agents/mcp/client/errors.py +119 -0
- aip_agents/mcp/client/google_adk/__init__.py +45 -0
- aip_agents/mcp/client/google_adk/client.py +383 -0
- aip_agents/mcp/client/langchain/__init__.py +11 -0
- aip_agents/mcp/client/langchain/client.py +265 -0
- aip_agents/mcp/client/persistent_session.py +704 -0
- aip_agents/mcp/client/session_pool.py +358 -0
- aip_agents/mcp/client/transports.py +288 -0
- aip_agents/mcp/utils/__init__.py +7 -0
- aip_agents/mcp/utils/config_validator.py +139 -0
- aip_agents/memory/__init__.py +14 -0
- aip_agents/memory/adapters/__init__.py +10 -0
- aip_agents/memory/adapters/base_adapter.py +815 -0
- aip_agents/memory/adapters/mem0.py +286 -0
- aip_agents/memory/base.py +84 -0
- aip_agents/memory/constants.py +49 -0
- aip_agents/memory/factory.py +86 -0
- aip_agents/memory/guidance.py +20 -0
- aip_agents/memory/simple_memory.py +47 -0
- aip_agents/middleware/__init__.py +65 -0
- aip_agents/middleware/backends/__init__.py +72 -0
- aip_agents/middleware/backends/in_memory.py +578 -0
- aip_agents/middleware/backends/local_disk.py +1203 -0
- aip_agents/middleware/backends/protocol.py +353 -0
- aip_agents/middleware/backends/sandbox.py +1431 -0
- aip_agents/middleware/backends/sandbox_files.py +113 -0
- aip_agents/middleware/backends/utils.py +293 -0
- aip_agents/middleware/base.py +239 -0
- aip_agents/middleware/filesystem.py +690 -0
- aip_agents/middleware/manager.py +291 -0
- aip_agents/middleware/memory.py +532 -0
- aip_agents/middleware/pii_middleware.py +192 -0
- aip_agents/middleware/schema.py +19 -0
- aip_agents/middleware/skills.py +872 -0
- aip_agents/middleware/task_delegation.py +1656 -0
- aip_agents/middleware/todolist.py +276 -0
- aip_agents/middleware/tools/__init__.py +23 -0
- aip_agents/middleware/tools/edit_file.py +70 -0
- aip_agents/middleware/tools/execute.py +428 -0
- aip_agents/middleware/tools/grep_file.py +79 -0
- aip_agents/middleware/tools/ls.py +71 -0
- aip_agents/middleware/tools/read_file.py +239 -0
- aip_agents/middleware/tools/skill_load.py +193 -0
- aip_agents/middleware/tools/skill_resource.py +338 -0
- aip_agents/middleware/tools/write_file.py +114 -0
- aip_agents/middleware/vision.py +322 -0
- aip_agents/ptc/__init__.py +87 -0
- aip_agents/ptc/custom_tools.py +511 -0
- aip_agents/ptc/custom_tools_payload.py +499 -0
- aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
- aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +24 -0
- aip_agents/ptc/custom_tools_templates/custom_init.py.template +26 -0
- aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +149 -0
- aip_agents/ptc/custom_tools_templates/custom_registry.py.template +112 -0
- aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
- aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +87 -0
- aip_agents/ptc/doc_gen.py +122 -0
- aip_agents/ptc/exceptions.py +57 -0
- aip_agents/ptc/executor.py +627 -0
- aip_agents/ptc/mcp/__init__.py +45 -0
- aip_agents/ptc/mcp/sandbox_bridge.py +679 -0
- aip_agents/ptc/mcp/templates/__init__.py +1 -0
- aip_agents/ptc/mcp/templates/mcp_client.py.template +240 -0
- aip_agents/ptc/naming.py +430 -0
- aip_agents/ptc/payload.py +26 -0
- aip_agents/ptc/prompt_builder.py +674 -0
- aip_agents/ptc/ptc_helper.py +16 -0
- aip_agents/ptc/sandbox_bridge.py +297 -0
- aip_agents/ptc/template_utils.py +33 -0
- aip_agents/ptc/templates/__init__.py +1 -0
- aip_agents/ptc/templates/ptc_helper.py.template +134 -0
- aip_agents/ptc/tool_def_helpers.py +101 -0
- aip_agents/ptc/tool_enrichment.py +163 -0
- aip_agents/resilience/__init__.py +10 -0
- aip_agents/resilience/base.py +46 -0
- aip_agents/resilience/tool.py +1661 -0
- aip_agents/sandbox/__init__.py +62 -0
- aip_agents/sandbox/defaults.py +220 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1-datasaur.Dockerfile +120 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1.Dockerfile +100 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2-datasaur.Dockerfile +143 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2.Dockerfile +119 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3-datasaur.Dockerfile +173 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3.Dockerfile +130 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.Dockerfile +164 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.patch_js_context.py +41 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.requirements.txt +29 -0
- aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2.Dockerfile +87 -0
- aip_agents/sandbox/e2b_runtime.py +27 -0
- aip_agents/sandbox/image_registry.py +98 -0
- aip_agents/sandbox/providers/__init__.py +76 -0
- aip_agents/sandbox/providers/base.py +392 -0
- aip_agents/sandbox/providers/bedrock.py +28 -0
- aip_agents/sandbox/providers/e2b.py +230 -0
- aip_agents/sandbox/providers/opensandbox.py +163 -0
- aip_agents/sandbox/sandbox_runtime.py +967 -0
- aip_agents/sandbox/template_builder.py +240 -0
- aip_agents/sandbox/types.py +141 -0
- aip_agents/sandbox/validation.py +50 -0
- aip_agents/schema/__init__.py +106 -0
- aip_agents/schema/a2a.py +58 -0
- aip_agents/schema/agent.py +117 -0
- aip_agents/schema/artifacts.py +75 -0
- aip_agents/schema/attachments.py +425 -0
- aip_agents/schema/hitl.py +157 -0
- aip_agents/schema/langgraph.py +47 -0
- aip_agents/schema/model_id.py +103 -0
- aip_agents/schema/model_usage.py +50 -0
- aip_agents/schema/runtime_metadata.py +78 -0
- aip_agents/schema/step_limit.py +108 -0
- aip_agents/schema/storage.py +40 -0
- aip_agents/schema/tool_payloads.py +68 -0
- aip_agents/sentry/__init__.py +11 -0
- aip_agents/sentry/sentry.py +267 -0
- aip_agents/skills/__init__.py +30 -0
- aip_agents/skills/errors.py +110 -0
- aip_agents/skills/installer.py +479 -0
- aip_agents/skills/models.py +769 -0
- aip_agents/skills/stager.py +224 -0
- aip_agents/skills/validation.py +53 -0
- aip_agents/storage/__init__.py +41 -0
- aip_agents/storage/base.py +85 -0
- aip_agents/storage/clients/__init__.py +12 -0
- aip_agents/storage/clients/minio_client.py +318 -0
- aip_agents/storage/config.py +62 -0
- aip_agents/storage/providers/__init__.py +15 -0
- aip_agents/storage/providers/base.py +106 -0
- aip_agents/storage/providers/memory.py +114 -0
- aip_agents/storage/providers/object_storage.py +214 -0
- aip_agents/tools/__init__.py +65 -0
- aip_agents/tools/agent_schedule_tool.py +392 -0
- aip_agents/tools/browser_use/__init__.py +83 -0
- aip_agents/tools/browser_use/action_parser.py +104 -0
- aip_agents/tools/browser_use/browser_use_tool.py +1118 -0
- aip_agents/tools/browser_use/llm_config.py +120 -0
- aip_agents/tools/browser_use/minio_storage.py +203 -0
- aip_agents/tools/browser_use/schemas.py +121 -0
- aip_agents/tools/browser_use/session.py +13 -0
- aip_agents/tools/browser_use/session_errors.py +132 -0
- aip_agents/tools/browser_use/steel_session_recording.py +321 -0
- aip_agents/tools/browser_use/streaming.py +263 -0
- aip_agents/tools/browser_use/structured_data_parser.py +257 -0
- aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
- aip_agents/tools/browser_use/types.py +37 -0
- aip_agents/tools/code_sandbox/__init__.py +48 -0
- aip_agents/tools/code_sandbox/code_sandbox_tool.py +350 -0
- aip_agents/tools/code_sandbox/constant.py +13 -0
- aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +346 -0
- aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +65 -0
- aip_agents/tools/computer_use/__init__.py +75 -0
- aip_agents/tools/computer_use/computer_use_tool.py +910 -0
- aip_agents/tools/computer_use/schemas.py +78 -0
- aip_agents/tools/computer_use/streaming.py +198 -0
- aip_agents/tools/computer_use/types.py +32 -0
- aip_agents/tools/constants.py +177 -0
- aip_agents/tools/date_range_tool.py +554 -0
- aip_agents/tools/document_loader/__init__.py +44 -0
- aip_agents/tools/document_loader/base_reader.py +302 -0
- aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
- aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
- aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
- aip_agents/tools/document_loader/pdf_splitter.py +169 -0
- aip_agents/tools/execute_ptc_code.py +582 -0
- aip_agents/tools/gl_connector/__init__.py +5 -0
- aip_agents/tools/gl_connector/tool.py +518 -0
- aip_agents/tools/gl_connector_tools.py +170 -0
- aip_agents/tools/memory_search/__init__.py +29 -0
- aip_agents/tools/memory_search/base.py +213 -0
- aip_agents/tools/memory_search/mem0.py +424 -0
- aip_agents/tools/memory_search/schema.py +81 -0
- aip_agents/tools/memory_search_tool.py +34 -0
- aip_agents/tools/streaming_common.py +577 -0
- aip_agents/tools/time_tool.py +117 -0
- aip_agents/tools/tool_config_injector.py +300 -0
- aip_agents/tools/web_search/__init__.py +15 -0
- aip_agents/tools/web_search/serper_tool.py +187 -0
- aip_agents/types/__init__.py +73 -0
- aip_agents/types/a2a_events.py +13 -0
- aip_agents/utils/__init__.py +91 -0
- aip_agents/utils/a2a_connector.py +1758 -0
- aip_agents/utils/artifact_helpers.py +542 -0
- aip_agents/utils/artifacts/__init__.py +20 -0
- aip_agents/utils/artifacts/tracker.py +367 -0
- aip_agents/utils/attachment_helpers.py +178 -0
- aip_agents/utils/constants.py +22 -0
- aip_agents/utils/datetime/__init__.py +34 -0
- aip_agents/utils/datetime/normalization.py +231 -0
- aip_agents/utils/datetime/timezone.py +206 -0
- aip_agents/utils/env_loader.py +27 -0
- aip_agents/utils/event_handler_registry.py +58 -0
- aip_agents/utils/file_prompt_utils.py +276 -0
- aip_agents/utils/file_watchers/__init__.py +33 -0
- aip_agents/utils/file_watchers/base.py +46 -0
- aip_agents/utils/file_watchers/e2b.py +128 -0
- aip_agents/utils/file_watchers/factory.py +39 -0
- aip_agents/utils/file_watchers/ls_diff.py +88 -0
- aip_agents/utils/file_watchers/sandbox.py +66 -0
- aip_agents/utils/file_watchers/watchdog.py +97 -0
- aip_agents/utils/final_response_builder.py +211 -0
- aip_agents/utils/formatter_llm_client.py +258 -0
- aip_agents/utils/langgraph/__init__.py +19 -0
- aip_agents/utils/langgraph/converter.py +399 -0
- aip_agents/utils/langgraph/delegation_context.py +22 -0
- aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
- aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
- aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
- aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1329 -0
- aip_agents/utils/langgraph/tool_output_management.py +1047 -0
- aip_agents/utils/logger.py +195 -0
- aip_agents/utils/metadata/__init__.py +27 -0
- aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
- aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
- aip_agents/utils/metadata/activity_narrative/builder.py +822 -0
- aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
- aip_agents/utils/metadata/activity_narrative/context.py +49 -0
- aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
- aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
- aip_agents/utils/metadata/schemas/__init__.py +16 -0
- aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
- aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
- aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
- aip_agents/utils/metadata_helper.py +363 -0
- aip_agents/utils/name_preprocessor/__init__.py +17 -0
- aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
- aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
- aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
- aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
- aip_agents/utils/pii/__init__.py +29 -0
- aip_agents/utils/pii/pii_handler.py +555 -0
- aip_agents/utils/pii/pii_helper.py +225 -0
- aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
- aip_agents/utils/reference_helper.py +273 -0
- aip_agents/utils/sse_chunk_transformer.py +836 -0
- aip_agents/utils/step_limit_manager.py +265 -0
- aip_agents/utils/token_usage_helper.py +531 -0
- aip_agents_binary-0.0.0.dist-info/METADATA +825 -0
- aip_agents_binary-0.0.0.dist-info/RECORD +447 -0
- aip_agents_binary-0.0.0.dist-info/WHEEL +5 -0
- 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(".")
|