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,1431 @@
|
|
|
1
|
+
"""E2B sandbox filesystem backend implementation.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import fnmatch
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import unicodedata
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from gllm_tools.code_interpreter.code_template.models import SandboxProvider
|
|
20
|
+
|
|
21
|
+
from aip_agents.middleware.backends.protocol import (
|
|
22
|
+
BackendProtocol,
|
|
23
|
+
EditResult,
|
|
24
|
+
ExecuteResult,
|
|
25
|
+
FileInfo,
|
|
26
|
+
GrepMatch,
|
|
27
|
+
WriteResult,
|
|
28
|
+
)
|
|
29
|
+
from aip_agents.middleware.backends.sandbox_files import STALE_FILE_UPLOAD_MARKER, GenericSandboxFiles
|
|
30
|
+
from aip_agents.middleware.backends.utils import (
|
|
31
|
+
format_content_with_line_numbers,
|
|
32
|
+
perform_string_replacement,
|
|
33
|
+
redact_secrets,
|
|
34
|
+
)
|
|
35
|
+
from aip_agents.sandbox.defaults import DEFAULT_SANDBOX_TEMPLATE
|
|
36
|
+
from aip_agents.sandbox.sandbox_runtime import SandboxRuntime
|
|
37
|
+
from aip_agents.utils.logger import get_logger
|
|
38
|
+
|
|
39
|
+
__all__ = ["SandboxBackend"]
|
|
40
|
+
|
|
41
|
+
logger = get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
_METADATA_DIR_NAME = ".aip-backend-metadata"
|
|
44
|
+
_BINARY_INDEX_FILENAME = "binary-paths.json"
|
|
45
|
+
|
|
46
|
+
# Fallback base_dir when the caller does not pass one and ``$HOME`` cannot be
|
|
47
|
+
# resolved. Matches E2B's default unprivileged user home (``/home/user``) rather
|
|
48
|
+
# than a root-level ``/workspace`` the sandbox ``user`` cannot create.
|
|
49
|
+
_DEFAULT_BASE_DIR = "/home/user/workspace"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SandboxBackend(BackendProtocol):
|
|
53
|
+
"""BackendProtocol implementation backed by E2B sandbox files API."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
template: str | None = DEFAULT_SANDBOX_TEMPLATE,
|
|
58
|
+
base_dir: str | None = None,
|
|
59
|
+
allow_execute: bool = True,
|
|
60
|
+
env: dict[str, str] | None = None,
|
|
61
|
+
timeout_seconds: int = 300,
|
|
62
|
+
auto_build_template: bool = True,
|
|
63
|
+
provider: SandboxProvider | None = None,
|
|
64
|
+
runtime: SandboxRuntime | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize sandbox backend.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
template: E2B template ID. Defaults to ``DEFAULT_SANDBOX_TEMPLATE``
|
|
70
|
+
(project-managed template alias). If that template fails to create,
|
|
71
|
+
the runtime falls back to E2B's default code-interpreter template.
|
|
72
|
+
Pass ``None`` to always use E2B's default template directly.
|
|
73
|
+
The template must include a running Jupyter kernel on port 49999;
|
|
74
|
+
otherwise ``execute`` calls will fail with a "port is not open"
|
|
75
|
+
502 error.
|
|
76
|
+
base_dir: Base workspace directory inside sandbox. When ``None``
|
|
77
|
+
(the default), it is resolved at runtime to ``$HOME/workspace``
|
|
78
|
+
inside the sandbox (falling back to ``/home/user/workspace``),
|
|
79
|
+
so the directory is always writable by the sandbox user rather
|
|
80
|
+
than requiring root for a root-level ``/workspace``.
|
|
81
|
+
allow_execute: Whether execute is enabled.
|
|
82
|
+
env: Optional environment variables to pass to sandbox command
|
|
83
|
+
execution. Values are forwarded only for execute calls.
|
|
84
|
+
timeout_seconds: Default runtime execution timeout.
|
|
85
|
+
auto_build_template: If True and the requested template returns 404,
|
|
86
|
+
automatically build it before falling back to the default sandbox.
|
|
87
|
+
Building can take several minutes. Defaults to True.
|
|
88
|
+
provider: Sandbox backend (``SandboxProvider``). When None, defaults to the
|
|
89
|
+
``SANDBOX_PROVIDER`` env, then ``SandboxProvider.E2B``.
|
|
90
|
+
runtime: Optional injected runtime for testing or sharing.
|
|
91
|
+
"""
|
|
92
|
+
self.template = template
|
|
93
|
+
self._base_dir_explicit = base_dir is not None
|
|
94
|
+
self.base_dir = (base_dir or _DEFAULT_BASE_DIR).rstrip("/") or "/"
|
|
95
|
+
self.allow_execute = allow_execute
|
|
96
|
+
self._env = dict(env) if env else None
|
|
97
|
+
self.timeout_seconds = timeout_seconds
|
|
98
|
+
self.auto_build_template = auto_build_template
|
|
99
|
+
self.provider = provider
|
|
100
|
+
self._runtime = runtime
|
|
101
|
+
self._runtime_lock = asyncio.Lock()
|
|
102
|
+
self._runtime_loop: asyncio.AbstractEventLoop | None = None
|
|
103
|
+
self._runtime_reset_listener_registered = False
|
|
104
|
+
self._base_dir_initialized = False
|
|
105
|
+
self._files_client: Any | None = None
|
|
106
|
+
self._binary_paths: set[str] = set()
|
|
107
|
+
self._capture_runtime_loop()
|
|
108
|
+
self._attach_runtime_reset_listener(self._runtime)
|
|
109
|
+
|
|
110
|
+
def _capture_runtime_loop(self) -> None:
|
|
111
|
+
"""Capture the current event loop if one is running, for use in _run_sync."""
|
|
112
|
+
try:
|
|
113
|
+
loop = asyncio.get_running_loop()
|
|
114
|
+
except RuntimeError:
|
|
115
|
+
return
|
|
116
|
+
if loop.is_closed():
|
|
117
|
+
return
|
|
118
|
+
self._runtime_loop = loop
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def runtime(self) -> SandboxRuntime:
|
|
122
|
+
"""Get initialized runtime.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
RuntimeError: If runtime was not initialized yet.
|
|
126
|
+
"""
|
|
127
|
+
if self._runtime is None:
|
|
128
|
+
raise RuntimeError("Runtime not initialized.")
|
|
129
|
+
return self._runtime
|
|
130
|
+
|
|
131
|
+
async def _ensure_runtime_instance(self) -> Any:
|
|
132
|
+
"""Create the sandbox runtime lazily without initializing filesystem state."""
|
|
133
|
+
self._capture_runtime_loop()
|
|
134
|
+
|
|
135
|
+
if self._runtime is None:
|
|
136
|
+
async with self._runtime_lock:
|
|
137
|
+
if self._runtime is None:
|
|
138
|
+
self._runtime = SandboxRuntime(
|
|
139
|
+
template=self.template,
|
|
140
|
+
auto_build_template=self.auto_build_template,
|
|
141
|
+
provider=self.provider,
|
|
142
|
+
)
|
|
143
|
+
self._attach_runtime_reset_listener(self._runtime)
|
|
144
|
+
return self.runtime
|
|
145
|
+
|
|
146
|
+
async def _ensure_runtime(self) -> SandboxRuntime:
|
|
147
|
+
"""Create and initialize the sandbox runtime lazily.
|
|
148
|
+
|
|
149
|
+
Creates a new ``SandboxRuntime`` the first time it is called and
|
|
150
|
+
caches the instance for subsequent calls. After the runtime is
|
|
151
|
+
available the method also ensures that ``base_dir`` has been created
|
|
152
|
+
inside the sandbox (idempotent, guarded by ``_base_dir_initialized``).
|
|
153
|
+
All state mutations are serialised through ``_runtime_lock`` to avoid
|
|
154
|
+
races in concurrent async callers.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The initialised :class:`SandboxRuntime` instance.
|
|
158
|
+
"""
|
|
159
|
+
await self._ensure_runtime_instance()
|
|
160
|
+
|
|
161
|
+
if not self._base_dir_initialized:
|
|
162
|
+
async with self._runtime_lock:
|
|
163
|
+
if not self._base_dir_initialized:
|
|
164
|
+
files = await self._get_files_client()
|
|
165
|
+
if not self._base_dir_explicit and getattr(self.runtime, "files", None) is None:
|
|
166
|
+
await self._resolve_home_base_dir()
|
|
167
|
+
await files.make_dir(self.base_dir)
|
|
168
|
+
self._base_dir_initialized = True
|
|
169
|
+
return self.runtime
|
|
170
|
+
|
|
171
|
+
async def _resolve_home_base_dir(self) -> None:
|
|
172
|
+
"""Point the default base_dir at the sandbox user's own home directory.
|
|
173
|
+
|
|
174
|
+
The default ``base_dir`` assumes an E2B-style ``/home/user`` home, but
|
|
175
|
+
other providers may run the sandbox as a different user (e.g. root).
|
|
176
|
+
When the caller did not pass an explicit ``base_dir``, query ``$HOME``
|
|
177
|
+
inside the sandbox and use ``<home>/workspace`` so the base directory is
|
|
178
|
+
always writable by the sandbox user. If the query yields no usable
|
|
179
|
+
absolute path, the ``/home/user/workspace`` default is kept.
|
|
180
|
+
"""
|
|
181
|
+
result = await self.runtime.execute_command('printf %s "$HOME"', timeout=min(self.timeout_seconds, 10))
|
|
182
|
+
# Only trust stdout when the probe clearly succeeded; a non-zero exit means
|
|
183
|
+
# $HOME is unknown, so keep the /home/user/workspace default.
|
|
184
|
+
home = (result.stdout or "").strip() if getattr(result, "exit_code", 1) == 0 else ""
|
|
185
|
+
if home.startswith("/"):
|
|
186
|
+
home = home.rstrip("/")
|
|
187
|
+
if home: # guard against HOME="/" collapsing to a root-level "/workspace"
|
|
188
|
+
self.base_dir = f"{home}/workspace"
|
|
189
|
+
|
|
190
|
+
async def ensure_runtime(self) -> SandboxRuntime:
|
|
191
|
+
"""Return the initialized runtime for callers that need to share this sandbox."""
|
|
192
|
+
return await self._ensure_runtime()
|
|
193
|
+
|
|
194
|
+
async def _get_files_client(self) -> Any:
|
|
195
|
+
"""Return a generic filesystem client over the runtime's sandbox.
|
|
196
|
+
|
|
197
|
+
Triggers sandbox creation with a lightweight ``true`` command, then wraps the
|
|
198
|
+
runtime's ``CodeInterpreterSandbox`` in :class:`GenericSandboxFiles` (which uses
|
|
199
|
+
``download_file`` / ``execute_code`` / ``execute_command`` — provider-neutral,
|
|
200
|
+
binary-safe). The adapter is cached on the instance so repeat calls never
|
|
201
|
+
re-enter sandbox initialization (important because synchronous callers like
|
|
202
|
+
``ExecuteTool._read_file_bytes`` run via ``_run_async_in_thread`` on a fresh
|
|
203
|
+
event loop).
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A :class:`GenericSandboxFiles` exposing ``read`` / ``write`` / ``list`` /
|
|
207
|
+
``make_dir``.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
RuntimeError: If the sandbox cannot be initialized.
|
|
211
|
+
"""
|
|
212
|
+
if self._files_client is not None:
|
|
213
|
+
return self._files_client
|
|
214
|
+
|
|
215
|
+
runtime = self.runtime
|
|
216
|
+
runtime_files = getattr(runtime, "files", None)
|
|
217
|
+
if runtime_files is not None:
|
|
218
|
+
self._files_client = runtime_files
|
|
219
|
+
return self._files_client
|
|
220
|
+
|
|
221
|
+
await runtime.execute_command("true", timeout=self.timeout_seconds)
|
|
222
|
+
sandbox = runtime.sandbox
|
|
223
|
+
if sandbox is None:
|
|
224
|
+
raise RuntimeError("Sandbox is not available")
|
|
225
|
+
self._files_client = GenericSandboxFiles(sandbox)
|
|
226
|
+
return self._files_client
|
|
227
|
+
|
|
228
|
+
def _invalidate_cached_files_client(self) -> None:
|
|
229
|
+
"""Drop backend-owned file client state after loop-bound runtime recovery."""
|
|
230
|
+
self._files_client = None
|
|
231
|
+
self._base_dir_initialized = False
|
|
232
|
+
|
|
233
|
+
def _attach_runtime_reset_listener(self, runtime: SandboxRuntime | None) -> None:
|
|
234
|
+
"""Register a cache invalidation callback on runtime-backed sandboxes once."""
|
|
235
|
+
if self._runtime_reset_listener_registered or runtime is None:
|
|
236
|
+
return
|
|
237
|
+
if not isinstance(getattr(runtime, "_reset_listeners", None), list):
|
|
238
|
+
return
|
|
239
|
+
runtime._register_reset_listener(self._invalidate_cached_files_client)
|
|
240
|
+
self._runtime_reset_listener_registered = True
|
|
241
|
+
|
|
242
|
+
def _resolve_path(self, path: str) -> str:
|
|
243
|
+
"""Resolve a given path to an absolute path within base_dir.
|
|
244
|
+
|
|
245
|
+
This method ensures all paths are resolved within the sandbox's base_dir
|
|
246
|
+
to prevent directory traversal attacks. It handles various input formats
|
|
247
|
+
including relative paths, absolute paths, and paths containing parent
|
|
248
|
+
directory references ("..").
|
|
249
|
+
|
|
250
|
+
The security check uses Path.relative_to() which raises ValueError if the
|
|
251
|
+
resolved path is not a subpath of base_dir, effectively blocking traversal
|
|
252
|
+
attempts like "../../../etc/passwd".
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
path: The input path to resolve. Can be:
|
|
256
|
+
- Relative path (e.g., "subdir/file.txt")
|
|
257
|
+
- Absolute path within base_dir (e.g., "/workspace/subdir/file.txt")
|
|
258
|
+
- Root reference ("/")
|
|
259
|
+
- Paths with parent references (e.g., "../file.txt") - will be blocked
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A normalized absolute path string within base_dir.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ValueError: If the path escapes base_dir through directory traversal
|
|
266
|
+
(e.g., using ".." to navigate above the workspace root).
|
|
267
|
+
|
|
268
|
+
Examples:
|
|
269
|
+
>>> backend = SandboxBackend(base_dir="/workspace")
|
|
270
|
+
|
|
271
|
+
# Relative paths are resolved against base_dir
|
|
272
|
+
>>> backend._resolve_path("subdir/file.txt")
|
|
273
|
+
'/workspace/subdir/file.txt'
|
|
274
|
+
|
|
275
|
+
# Absolute paths within base_dir are preserved
|
|
276
|
+
>>> backend._resolve_path("/workspace/subdir/file.txt")
|
|
277
|
+
'/workspace/subdir/file.txt'
|
|
278
|
+
|
|
279
|
+
# Root reference returns base_dir
|
|
280
|
+
>>> backend._resolve_path("/")
|
|
281
|
+
'/workspace'
|
|
282
|
+
|
|
283
|
+
# Parent directory references within bounds are normalized
|
|
284
|
+
>>> backend._resolve_path("subdir/../file.txt")
|
|
285
|
+
'/workspace/file.txt'
|
|
286
|
+
|
|
287
|
+
# Path traversal attempts raise ValueError
|
|
288
|
+
>>> backend._resolve_path("../../../etc/passwd")
|
|
289
|
+
ValueError: Path escapes base_dir: '../../../etc/passwd'
|
|
290
|
+
|
|
291
|
+
>>> backend._resolve_path("/etc/passwd")
|
|
292
|
+
ValueError: Path escapes base_dir: '/etc/passwd'
|
|
293
|
+
|
|
294
|
+
Note:
|
|
295
|
+
This method is critical for security. Never skip the relative_to()
|
|
296
|
+
check as it prevents access to files outside the sandbox workspace.
|
|
297
|
+
"""
|
|
298
|
+
normalized_path = path.replace("\\", "/")
|
|
299
|
+
if normalized_path == "/":
|
|
300
|
+
candidate_path = self.base_dir
|
|
301
|
+
elif normalized_path.startswith(f"{self.base_dir}/") or normalized_path == self.base_dir:
|
|
302
|
+
candidate_path = normalized_path
|
|
303
|
+
elif normalized_path.startswith("/"):
|
|
304
|
+
candidate_path = f"{self.base_dir}/{normalized_path.lstrip('/')}"
|
|
305
|
+
else:
|
|
306
|
+
candidate_path = f"{self.base_dir}/{normalized_path}"
|
|
307
|
+
|
|
308
|
+
resolved_path = self._normalize_sandbox_path(candidate_path)
|
|
309
|
+
base_dir_prefix = "/" if self.base_dir == "/" else f"{self.base_dir}/"
|
|
310
|
+
if resolved_path != self.base_dir and not resolved_path.startswith(base_dir_prefix):
|
|
311
|
+
raise ValueError(f"Path escapes base_dir: {path!r}")
|
|
312
|
+
|
|
313
|
+
return resolved_path
|
|
314
|
+
|
|
315
|
+
def _to_relative_path(self, path: str) -> str:
|
|
316
|
+
"""Convert an absolute sandbox path to a base_dir-relative path.
|
|
317
|
+
|
|
318
|
+
This method strips the base_dir prefix from absolute paths to produce
|
|
319
|
+
relative paths that are portable and consistent across different backends.
|
|
320
|
+
It's used to normalize paths returned by filesystem operations.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
path: An absolute path within the sandbox (e.g., "/workspace/file.txt").
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
A relative path string with base_dir removed. Returns "/" if the path
|
|
327
|
+
equals base_dir, or the original path if it doesn't start with base_dir.
|
|
328
|
+
|
|
329
|
+
Examples:
|
|
330
|
+
>>> backend = SandboxBackend(base_dir="/workspace")
|
|
331
|
+
|
|
332
|
+
# Normal case: path within workspace
|
|
333
|
+
>>> backend._to_relative_path("/workspace/subdir/file.txt")
|
|
334
|
+
'subdir/file.txt'
|
|
335
|
+
|
|
336
|
+
# Path is exactly base_dir
|
|
337
|
+
>>> backend._to_relative_path("/workspace")
|
|
338
|
+
'/'
|
|
339
|
+
|
|
340
|
+
# Path doesn't start with base_dir (edge case)
|
|
341
|
+
>>> backend._to_relative_path("/other/path")
|
|
342
|
+
'other/path'
|
|
343
|
+
"""
|
|
344
|
+
if path.startswith(f"{self.base_dir}/"):
|
|
345
|
+
return path[len(self.base_dir) + 1 :]
|
|
346
|
+
if path == self.base_dir:
|
|
347
|
+
return "/"
|
|
348
|
+
return path.lstrip("/")
|
|
349
|
+
|
|
350
|
+
def _metadata_dir_path(self) -> str:
|
|
351
|
+
"""Return the private backend metadata directory path."""
|
|
352
|
+
if self.base_dir == "/":
|
|
353
|
+
return f"/{_METADATA_DIR_NAME}"
|
|
354
|
+
return f"{self.base_dir}/{_METADATA_DIR_NAME}"
|
|
355
|
+
|
|
356
|
+
def _binary_index_path(self) -> str:
|
|
357
|
+
"""Return the persisted binary provenance index path."""
|
|
358
|
+
return f"{self._metadata_dir_path()}/{_BINARY_INDEX_FILENAME}"
|
|
359
|
+
|
|
360
|
+
def _is_metadata_path(self, full_path: str) -> bool:
|
|
361
|
+
"""Return whether a sandbox path points inside private backend metadata."""
|
|
362
|
+
metadata_dir = self._metadata_dir_path()
|
|
363
|
+
return full_path == metadata_dir or full_path.startswith(f"{metadata_dir}/")
|
|
364
|
+
|
|
365
|
+
def _binary_marker(self, full_path: str) -> str:
|
|
366
|
+
"""Return a stable marker for backend-local file provenance."""
|
|
367
|
+
return self._to_relative_path(full_path)
|
|
368
|
+
|
|
369
|
+
async def _load_binary_paths(self) -> None:
|
|
370
|
+
"""Load byte-write provenance markers persisted by earlier backend instances."""
|
|
371
|
+
files_client = await self._get_files_client()
|
|
372
|
+
try:
|
|
373
|
+
content = await files_client.read(self._binary_index_path())
|
|
374
|
+
except Exception:
|
|
375
|
+
return
|
|
376
|
+
try:
|
|
377
|
+
data = json.loads(content)
|
|
378
|
+
except (TypeError, json.JSONDecodeError):
|
|
379
|
+
return
|
|
380
|
+
if isinstance(data, list):
|
|
381
|
+
self._binary_paths.update(item for item in data if isinstance(item, str))
|
|
382
|
+
|
|
383
|
+
async def _save_binary_paths(self) -> None:
|
|
384
|
+
"""Persist byte-write provenance markers for future backend instances."""
|
|
385
|
+
files_client = await self._get_files_client()
|
|
386
|
+
try:
|
|
387
|
+
await files_client.make_dir(self._metadata_dir_path())
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
await files_client.write(self._binary_index_path(), json.dumps(sorted(self._binary_paths), indent=2))
|
|
391
|
+
|
|
392
|
+
async def _is_binary_path(self, full_path: str) -> bool:
|
|
393
|
+
"""Return whether a path was written through the byte API."""
|
|
394
|
+
marker = self._binary_marker(full_path)
|
|
395
|
+
if marker not in self._binary_paths:
|
|
396
|
+
await self._load_binary_paths()
|
|
397
|
+
return marker in self._binary_paths
|
|
398
|
+
|
|
399
|
+
def _normalize_sandbox_path(self, path: str) -> str:
|
|
400
|
+
"""Normalize a workspace-relative path to prevent directory traversal."""
|
|
401
|
+
parts: list[str] = []
|
|
402
|
+
for part in path.split("/"):
|
|
403
|
+
if not part or part == ".":
|
|
404
|
+
continue
|
|
405
|
+
if part == "..":
|
|
406
|
+
if parts:
|
|
407
|
+
parts.pop()
|
|
408
|
+
continue
|
|
409
|
+
parts.append(part)
|
|
410
|
+
return "/" + "/".join(parts)
|
|
411
|
+
|
|
412
|
+
@staticmethod
|
|
413
|
+
def _has_binary_controls(content: str) -> bool:
|
|
414
|
+
"""Return whether text contains non-printable, non-whitespace controls."""
|
|
415
|
+
return any(not ch.isprintable() and not ch.isspace() and unicodedata.category(ch) != "Cf" for ch in content)
|
|
416
|
+
|
|
417
|
+
def _is_binary_payload(self, full_path: str, content: Any) -> bool:
|
|
418
|
+
"""Return whether a sandbox file should be treated as binary."""
|
|
419
|
+
if self._binary_marker(full_path) in self._binary_paths or isinstance(content, bytes | bytearray):
|
|
420
|
+
return True
|
|
421
|
+
if isinstance(content, str):
|
|
422
|
+
return self._has_binary_controls(content)
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
def _reject_binary_text(self, full_path: str, file_path: str, content: Any) -> None:
|
|
426
|
+
"""Reject text operations on known or detected binary payloads."""
|
|
427
|
+
if self._is_binary_payload(full_path, content):
|
|
428
|
+
raise ValueError(f"File '{file_path}' is binary and cannot be read as text")
|
|
429
|
+
|
|
430
|
+
async def _read_text_file(self, file_path: str) -> tuple[Any, str, str]:
|
|
431
|
+
"""Resolve and read a text file with stale-transport-aware error mapping."""
|
|
432
|
+
await self._ensure_runtime()
|
|
433
|
+
files_client = await self._get_files_client()
|
|
434
|
+
full_path = self._resolve_path(file_path)
|
|
435
|
+
if await self._is_binary_path(full_path):
|
|
436
|
+
raise ValueError(f"File '{file_path}' is binary and cannot be read as text")
|
|
437
|
+
content = await files_client.read(full_path)
|
|
438
|
+
return files_client, full_path, content
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _slice_line_window(content: str, line_offset: int, limit: int) -> str:
|
|
442
|
+
"""Return a numbered line window from *content*."""
|
|
443
|
+
lines = content.split("\n")
|
|
444
|
+
selected_lines = lines[max(0, line_offset) : max(0, line_offset) + max(0, limit)]
|
|
445
|
+
selected_content = "\n".join(selected_lines)
|
|
446
|
+
if not selected_content:
|
|
447
|
+
return ""
|
|
448
|
+
return format_content_with_line_numbers(selected_content, offset=max(0, line_offset))
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _slice_char_window(content: str, char_offset: int, max_chars: int) -> str:
|
|
452
|
+
"""Return a raw character window from *content*."""
|
|
453
|
+
start = max(0, char_offset)
|
|
454
|
+
if len(content) == 0:
|
|
455
|
+
return ""
|
|
456
|
+
if start >= len(content):
|
|
457
|
+
return "[End of file reached]"
|
|
458
|
+
end = min(len(content), start + max_chars)
|
|
459
|
+
return content[start:end]
|
|
460
|
+
|
|
461
|
+
async def _write_text_with_stale_retry(
|
|
462
|
+
self,
|
|
463
|
+
files_client: Any,
|
|
464
|
+
full_path: str,
|
|
465
|
+
content: str,
|
|
466
|
+
*,
|
|
467
|
+
operation: str,
|
|
468
|
+
) -> bool:
|
|
469
|
+
"""Write text once, retrying a stale transport exactly once."""
|
|
470
|
+
retry_after_stale = False
|
|
471
|
+
try:
|
|
472
|
+
await files_client.write(full_path, content)
|
|
473
|
+
return True
|
|
474
|
+
except Exception as error:
|
|
475
|
+
if not SandboxRuntime._is_stale_runtime_error(error):
|
|
476
|
+
raise
|
|
477
|
+
|
|
478
|
+
logger.info(
|
|
479
|
+
"%s: detected stale filesystem transport (%r), resetting and retrying once",
|
|
480
|
+
operation,
|
|
481
|
+
error,
|
|
482
|
+
)
|
|
483
|
+
self._reset_backend_runtime()
|
|
484
|
+
files_client = await self._get_files_client()
|
|
485
|
+
retry_after_stale = True
|
|
486
|
+
|
|
487
|
+
if retry_after_stale:
|
|
488
|
+
try:
|
|
489
|
+
await files_client.write(full_path, content)
|
|
490
|
+
except Exception as retry_error:
|
|
491
|
+
if SandboxRuntime._is_stale_runtime_error(retry_error):
|
|
492
|
+
return False
|
|
493
|
+
raise retry_error
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _coerce_text_content(file_data: Any) -> str:
|
|
498
|
+
"""Coerce batch file payloads to text."""
|
|
499
|
+
return file_data if isinstance(file_data, str) else str(file_data)
|
|
500
|
+
|
|
501
|
+
async def _write_files_batch(self, files_client: Any, files: dict[str, Any]) -> None:
|
|
502
|
+
"""Write a batch of workspace files using a shared files client."""
|
|
503
|
+
for file_path, file_data in files.items():
|
|
504
|
+
await self._ensure_parent_dir(file_path)
|
|
505
|
+
await files_client.write(self._resolve_path(file_path), self._coerce_text_content(file_data))
|
|
506
|
+
|
|
507
|
+
def _is_directory_entry(self, entry: Any) -> bool:
|
|
508
|
+
"""Determine if a filesystem entry from the E2B files client is a directory."""
|
|
509
|
+
entry_type = getattr(entry, "type", None)
|
|
510
|
+
type_value = getattr(entry_type, "value", entry_type)
|
|
511
|
+
return str(type_value).lower() == "dir"
|
|
512
|
+
|
|
513
|
+
def _resolve_child_path(self, directory: str, child_name: str) -> str | None:
|
|
514
|
+
"""Resolve a listed child entry to a safe absolute sandbox path.
|
|
515
|
+
|
|
516
|
+
This helper is used by :meth:`aget_files` when recursively collecting
|
|
517
|
+
checkpoint state from the sandbox workspace. It joins a directory returned
|
|
518
|
+
by the E2B files client with a child entry name, normalizes ``.`` / ``..``
|
|
519
|
+
segments, and rejects any child that would escape ``self.base_dir``.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
directory: Absolute sandbox directory currently being traversed
|
|
523
|
+
(for example ``"/workspace"`` or ``"/workspace/subdir"``).
|
|
524
|
+
child_name: Raw child entry name returned by the files client.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
The normalized absolute child path when it stays within
|
|
528
|
+
``self.base_dir``. Returns ``None`` when the child would escape the
|
|
529
|
+
sandbox workspace and should be skipped.
|
|
530
|
+
|
|
531
|
+
Examples:
|
|
532
|
+
Normal child paths remain inside the workspace:
|
|
533
|
+
>>> backend = SandboxBackend(base_dir="/workspace")
|
|
534
|
+
>>> backend._resolve_child_path("/workspace", "notes.txt")
|
|
535
|
+
'/workspace/notes.txt'
|
|
536
|
+
Nested relative paths are normalized safely:
|
|
537
|
+
>>> backend._resolve_child_path("/workspace/config", "./app.conf")
|
|
538
|
+
'/workspace/config/app.conf'
|
|
539
|
+
Parent-directory escapes are rejected:
|
|
540
|
+
>>> backend._resolve_child_path("/workspace", "../escape.txt") is None
|
|
541
|
+
True
|
|
542
|
+
Traversal that stays inside the workspace is allowed:
|
|
543
|
+
>>> backend._resolve_child_path("/workspace/nested", "../safe.txt")
|
|
544
|
+
'/workspace/safe.txt'
|
|
545
|
+
"""
|
|
546
|
+
child_path = self._normalize_sandbox_path(f"{directory}/{child_name}")
|
|
547
|
+
base_dir_with_sep = "/" if self.base_dir == "/" else f"{self.base_dir}/"
|
|
548
|
+
if child_path == self.base_dir or child_path.startswith(base_dir_with_sep):
|
|
549
|
+
return child_path
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
async def _collect_sandbox_files(
|
|
553
|
+
self,
|
|
554
|
+
files_client: Any,
|
|
555
|
+
directory: str,
|
|
556
|
+
files: dict[str, Any],
|
|
557
|
+
visited: set[str],
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Recursively collect text files under *directory* into *files*."""
|
|
560
|
+
if directory in visited:
|
|
561
|
+
return
|
|
562
|
+
visited.add(directory)
|
|
563
|
+
|
|
564
|
+
entries = await files_client.list(directory)
|
|
565
|
+
for entry in entries:
|
|
566
|
+
child_path = self._resolve_child_path(directory, str(getattr(entry, "name", "")))
|
|
567
|
+
if child_path is None or self._is_metadata_path(child_path):
|
|
568
|
+
continue
|
|
569
|
+
if self._is_directory_entry(entry):
|
|
570
|
+
await self._collect_sandbox_files(files_client, child_path, files, visited)
|
|
571
|
+
continue
|
|
572
|
+
files[self._to_relative_path(child_path)] = await files_client.read(child_path)
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _close_coroutine_if_needed(coro: Any) -> None:
|
|
576
|
+
if asyncio.iscoroutine(coro):
|
|
577
|
+
coro.close()
|
|
578
|
+
|
|
579
|
+
def _raise_missing_owner_loop(self, coro: Any) -> None:
|
|
580
|
+
self._close_coroutine_if_needed(coro)
|
|
581
|
+
raise RuntimeError(
|
|
582
|
+
"SandboxBackend sync bridge has no owning loop in this worker thread. "
|
|
583
|
+
"Call an async backend method (e.g. await backend._ensure_runtime()) "
|
|
584
|
+
"on the owning loop before invoking sync methods from executors."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _raise_sync_on_owner_loop(self, coro: Any) -> None:
|
|
588
|
+
self._close_coroutine_if_needed(coro)
|
|
589
|
+
raise RuntimeError(
|
|
590
|
+
"SandboxBackend sync methods cannot run on the owning event-loop thread. "
|
|
591
|
+
"Use the async backend methods instead."
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def _run_sync(self, coro: Any) -> Any:
|
|
595
|
+
"""Run an async coroutine synchronously, bridging across a running event loop when present.
|
|
596
|
+
|
|
597
|
+
Convenience wrapper used by the synchronous public API (e.g.
|
|
598
|
+
:meth:`read`, :meth:`write`, :meth:`execute`) so that callers that
|
|
599
|
+
cannot use ``await`` can still drive async backend operations.
|
|
600
|
+
|
|
601
|
+
When called from a non-async thread while **no** event loop is running
|
|
602
|
+
(the common case in tests and standalone scripts), ``asyncio.run`` is
|
|
603
|
+
used directly, which creates a fresh loop and tears it down.
|
|
604
|
+
|
|
605
|
+
When called from a worker thread spawned by a running event loop (the
|
|
606
|
+
production case — LangGraph tool execution inside a uvloop server),
|
|
607
|
+
``asyncio.run_coroutine_threadsafe`` schedules the coroutine onto the
|
|
608
|
+
**original** running loop so that all asyncio primitives (``Lock``,
|
|
609
|
+
``Event``, etc.) stay bound to that loop. The calling thread blocks on
|
|
610
|
+
``Future.result()`` until the coroutine completes.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
coro: An awaitable / coroutine to execute.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
The return value produced by *coro*.
|
|
617
|
+
"""
|
|
618
|
+
loop = self._runtime_loop
|
|
619
|
+
running_loop: asyncio.AbstractEventLoop | None = None
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
running_loop = asyncio.get_running_loop()
|
|
623
|
+
except RuntimeError:
|
|
624
|
+
pass
|
|
625
|
+
|
|
626
|
+
if loop is None or loop.is_closed():
|
|
627
|
+
if running_loop is None:
|
|
628
|
+
if threading.current_thread() is not threading.main_thread():
|
|
629
|
+
self._raise_missing_owner_loop(coro)
|
|
630
|
+
return asyncio.run(coro)
|
|
631
|
+
self._runtime_loop = running_loop
|
|
632
|
+
loop = running_loop
|
|
633
|
+
|
|
634
|
+
if running_loop is loop:
|
|
635
|
+
self._raise_sync_on_owner_loop(coro)
|
|
636
|
+
|
|
637
|
+
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
638
|
+
try:
|
|
639
|
+
return fut.result()
|
|
640
|
+
except concurrent.futures.CancelledError as exc:
|
|
641
|
+
raise asyncio.CancelledError() from exc
|
|
642
|
+
|
|
643
|
+
async def _ensure_parent_dir(self, file_path: str) -> None:
|
|
644
|
+
"""Ensure the parent directory of *file_path* exists in the sandbox.
|
|
645
|
+
|
|
646
|
+
E2B's files API returns HTTP 404 when the target path's parent directory
|
|
647
|
+
does not exist. This method creates it via ``make_dir`` before every
|
|
648
|
+
write, swallowing errors so that an already-existing directory does not
|
|
649
|
+
surface as a failure.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
file_path: Workspace-relative or absolute destination path whose
|
|
653
|
+
parent directory should be pre-created.
|
|
654
|
+
"""
|
|
655
|
+
resolved: str = self._resolve_path(file_path)
|
|
656
|
+
parent = str(PurePosixPath(resolved).parent)
|
|
657
|
+
if parent in ("", "/", self.base_dir):
|
|
658
|
+
return
|
|
659
|
+
files_client = await self._get_files_client()
|
|
660
|
+
try:
|
|
661
|
+
await files_client.make_dir(parent)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
|
|
665
|
+
def ls_info(self, path: str) -> list[FileInfo]:
|
|
666
|
+
"""List files and subdirectories at *path* (synchronous).
|
|
667
|
+
|
|
668
|
+
Synchronous wrapper around :meth:`als_info`.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
path: Workspace-relative or absolute path of the directory to list.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.FileInfo`
|
|
675
|
+
objects describing each entry in the directory.
|
|
676
|
+
"""
|
|
677
|
+
return self._run_sync(self.als_info(path))
|
|
678
|
+
|
|
679
|
+
async def als_info(self, path: str) -> list[FileInfo]:
|
|
680
|
+
"""List files and subdirectories at *path* (async).
|
|
681
|
+
|
|
682
|
+
Resolves *path* to an absolute sandbox path, calls the E2B files
|
|
683
|
+
client ``list`` API, and converts the raw entries into
|
|
684
|
+
:class:`~aip_agents.middleware.backends.protocol.FileInfo` objects
|
|
685
|
+
with workspace-relative paths.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
path: Workspace-relative or absolute path of the directory to list.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.FileInfo`
|
|
692
|
+
objects describing each entry in the directory.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
async def _attempt() -> list[FileInfo]:
|
|
696
|
+
await self._ensure_runtime()
|
|
697
|
+
files_client = await self._get_files_client()
|
|
698
|
+
full_path = self._resolve_path(path)
|
|
699
|
+
try:
|
|
700
|
+
entries = await files_client.list(full_path)
|
|
701
|
+
except FileNotFoundError:
|
|
702
|
+
if full_path != self.base_dir:
|
|
703
|
+
raise
|
|
704
|
+
self._base_dir_initialized = False
|
|
705
|
+
await self._ensure_runtime()
|
|
706
|
+
files_client = await self._get_files_client()
|
|
707
|
+
entries = await files_client.list(full_path)
|
|
708
|
+
|
|
709
|
+
results: list[FileInfo] = []
|
|
710
|
+
for entry in entries:
|
|
711
|
+
entry_path = f"{full_path}/{entry.name}".replace("//", "/")
|
|
712
|
+
if self._is_metadata_path(entry_path):
|
|
713
|
+
continue
|
|
714
|
+
rel_path = self._to_relative_path(entry_path)
|
|
715
|
+
is_dir = self._is_directory_entry(entry)
|
|
716
|
+
if is_dir:
|
|
717
|
+
results.append(FileInfo(path=rel_path, type="directory", size=None))
|
|
718
|
+
else:
|
|
719
|
+
results.append(
|
|
720
|
+
FileInfo(path=rel_path, type="file", size=getattr(entry, "size", None)),
|
|
721
|
+
)
|
|
722
|
+
return results
|
|
723
|
+
|
|
724
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="als_info")
|
|
725
|
+
|
|
726
|
+
def read(
|
|
727
|
+
self,
|
|
728
|
+
file_path: str,
|
|
729
|
+
line_offset: int = 0,
|
|
730
|
+
limit: int = 2000,
|
|
731
|
+
*,
|
|
732
|
+
offset: int | None = None,
|
|
733
|
+
thread_id: str = "default",
|
|
734
|
+
) -> str:
|
|
735
|
+
"""Read a file's contents with line numbers (synchronous).
|
|
736
|
+
|
|
737
|
+
Synchronous wrapper around :meth:`aread`.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
741
|
+
line_offset: Zero-based index of the first line to return. Defaults to 0.
|
|
742
|
+
limit: Maximum number of lines to return. Defaults to 2000.
|
|
743
|
+
offset: Deprecated alias for ``line_offset``.
|
|
744
|
+
thread_id: Unused for sandbox backends. Accepted for protocol compatibility.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
A string of the requested lines prefixed with 1-based line numbers,
|
|
748
|
+
or an empty string if the selected region is empty.
|
|
749
|
+
"""
|
|
750
|
+
return self._run_sync(
|
|
751
|
+
self.aread(
|
|
752
|
+
file_path,
|
|
753
|
+
line_offset=line_offset,
|
|
754
|
+
limit=limit,
|
|
755
|
+
offset=offset,
|
|
756
|
+
thread_id=thread_id,
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
async def aread(
|
|
761
|
+
self,
|
|
762
|
+
file_path: str,
|
|
763
|
+
line_offset: int = 0,
|
|
764
|
+
limit: int = 2000,
|
|
765
|
+
*,
|
|
766
|
+
offset: int | None = None,
|
|
767
|
+
thread_id: str = "default",
|
|
768
|
+
) -> str:
|
|
769
|
+
"""Read a file's contents with line numbers (async).
|
|
770
|
+
|
|
771
|
+
Reads the full file from the sandbox via the E2B files client, then
|
|
772
|
+
slices the requested line window and delegates formatting to
|
|
773
|
+
:func:`~aip_agents.middleware.backends.utils.format_content_with_line_numbers`.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
777
|
+
line_offset: Zero-based index of the first line to return. Defaults to 0.
|
|
778
|
+
limit: Maximum number of lines to return. Defaults to 2000.
|
|
779
|
+
offset: Deprecated alias for ``line_offset``.
|
|
780
|
+
thread_id: Unused for sandbox backends. Accepted for protocol compatibility.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
A string of the requested lines prefixed with 1-based line numbers,
|
|
784
|
+
or an empty string if the selected region is empty.
|
|
785
|
+
"""
|
|
786
|
+
_ = thread_id
|
|
787
|
+
if offset is not None:
|
|
788
|
+
if line_offset != 0 and line_offset != offset:
|
|
789
|
+
raise ValueError("Conflicting values provided for line_offset and offset")
|
|
790
|
+
line_offset = offset
|
|
791
|
+
|
|
792
|
+
async def _attempt() -> str:
|
|
793
|
+
_, full_path, content = await self._read_text_file(file_path)
|
|
794
|
+
self._reject_binary_text(full_path, file_path, content)
|
|
795
|
+
return self._slice_line_window(content, line_offset, limit)
|
|
796
|
+
|
|
797
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aread")
|
|
798
|
+
|
|
799
|
+
async def aread_bytes(self, file_path: str) -> bytes:
|
|
800
|
+
"""Read file content as raw bytes (async).
|
|
801
|
+
|
|
802
|
+
Attempts to call ``read_bytes`` on the E2B files client when available
|
|
803
|
+
(avoiding a UTF-8 decode/encode round-trip); falls back to the text
|
|
804
|
+
``read`` API and encodes the result as UTF-8.
|
|
805
|
+
|
|
806
|
+
Args:
|
|
807
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
The raw byte content of the file.
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
async def _attempt() -> bytes:
|
|
814
|
+
await self._ensure_runtime()
|
|
815
|
+
files_client = await self._get_files_client()
|
|
816
|
+
full_path = self._resolve_path(file_path)
|
|
817
|
+
# The files client raises FileNotFoundError / IsADirectoryError directly.
|
|
818
|
+
content = await files_client.read(full_path, format="bytes")
|
|
819
|
+
if isinstance(content, bytes | bytearray):
|
|
820
|
+
return bytes(content)
|
|
821
|
+
return str(content).encode("utf-8")
|
|
822
|
+
|
|
823
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aread_bytes")
|
|
824
|
+
|
|
825
|
+
def read_bytes(self, file_path: str) -> bytes:
|
|
826
|
+
"""Read file content as raw bytes (synchronous).
|
|
827
|
+
|
|
828
|
+
Synchronous wrapper around :meth:`aread_bytes`.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
The raw byte content of the file.
|
|
835
|
+
"""
|
|
836
|
+
return self._run_sync(self.aread_bytes(file_path))
|
|
837
|
+
|
|
838
|
+
def read_chars(self, file_path: str, char_offset: int = 0, max_chars: int = 20000) -> str:
|
|
839
|
+
"""Read a file's contents by character position (synchronous).
|
|
840
|
+
|
|
841
|
+
Synchronous wrapper around :meth:`aread_chars`.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
845
|
+
char_offset: Character position to start from (0-indexed). Defaults to 0.
|
|
846
|
+
max_chars: Maximum number of characters to read. Defaults to 20000.
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
File content without line numbers (raw characters).
|
|
850
|
+
"""
|
|
851
|
+
return self._run_sync(self.aread_chars(file_path, char_offset=char_offset, max_chars=max_chars))
|
|
852
|
+
|
|
853
|
+
async def aread_chars(self, file_path: str, char_offset: int = 0, max_chars: int = 20000) -> str:
|
|
854
|
+
"""Read a file's contents by character position (async).
|
|
855
|
+
|
|
856
|
+
Provides character-based pagination for reading large files that may have
|
|
857
|
+
very long single lines. Reads the full file and slices the requested
|
|
858
|
+
character window.
|
|
859
|
+
|
|
860
|
+
Args:
|
|
861
|
+
file_path: Workspace-relative or absolute path of the file to read.
|
|
862
|
+
char_offset: Character position to start from (0-indexed). Defaults to 0.
|
|
863
|
+
max_chars: Maximum number of characters to read. Defaults to 20000.
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
File content without line numbers (raw characters).
|
|
867
|
+
|
|
868
|
+
Raises:
|
|
869
|
+
FileNotFoundError: If file does not exist.
|
|
870
|
+
"""
|
|
871
|
+
|
|
872
|
+
async def _attempt() -> str:
|
|
873
|
+
_, full_path, content = await self._read_text_file(file_path)
|
|
874
|
+
self._reject_binary_text(full_path, file_path, content)
|
|
875
|
+
return self._slice_char_window(content, char_offset, max_chars)
|
|
876
|
+
|
|
877
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aread_chars")
|
|
878
|
+
|
|
879
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
880
|
+
"""Write *content* to a file, creating it if necessary (synchronous).
|
|
881
|
+
|
|
882
|
+
Synchronous wrapper around :meth:`awrite`.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
file_path: Workspace-relative or absolute destination path.
|
|
886
|
+
content: Text content to write.
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
A :class:`~aip_agents.middleware.backends.protocol.WriteResult`
|
|
890
|
+
indicating success and the resolved path.
|
|
891
|
+
"""
|
|
892
|
+
return self._run_sync(self.awrite(file_path, content))
|
|
893
|
+
|
|
894
|
+
def write_bytes(self, file_path: str, content: bytes) -> WriteResult:
|
|
895
|
+
"""Write raw bytes to a file (synchronous)."""
|
|
896
|
+
self._run_sync(self.awrite_bytes(file_path, content))
|
|
897
|
+
return WriteResult(path=file_path, success=True)
|
|
898
|
+
|
|
899
|
+
async def awrite(self, file_path: str, content: str) -> WriteResult:
|
|
900
|
+
"""Write *content* to a file, creating it if necessary (async).
|
|
901
|
+
|
|
902
|
+
Resolves *file_path* to an absolute sandbox path, ensures the parent
|
|
903
|
+
directory exists via :meth:`_ensure_parent_dir`, then delegates to the
|
|
904
|
+
E2B files client ``write`` API.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
file_path: Workspace-relative or absolute destination path.
|
|
908
|
+
content: Text content to write.
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
A :class:`~aip_agents.middleware.backends.protocol.WriteResult`
|
|
912
|
+
indicating success and the resolved path.
|
|
913
|
+
"""
|
|
914
|
+
|
|
915
|
+
async def _attempt() -> WriteResult:
|
|
916
|
+
await self._ensure_runtime()
|
|
917
|
+
await self._ensure_parent_dir(file_path)
|
|
918
|
+
files_client = await self._get_files_client()
|
|
919
|
+
full_path = self._resolve_path(file_path)
|
|
920
|
+
await files_client.write(full_path, content)
|
|
921
|
+
marker = self._binary_marker(full_path)
|
|
922
|
+
if marker in self._binary_paths:
|
|
923
|
+
self._binary_paths.discard(marker)
|
|
924
|
+
await self._save_binary_paths()
|
|
925
|
+
return WriteResult(path=file_path, success=True)
|
|
926
|
+
|
|
927
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="awrite")
|
|
928
|
+
|
|
929
|
+
async def awrite_bytes(self, file_path: str, content_bytes: bytes) -> None:
|
|
930
|
+
"""Write raw bytes to a file, creating it if necessary (async).
|
|
931
|
+
|
|
932
|
+
Binary counterpart of :meth:`awrite`. Ensures the parent directory
|
|
933
|
+
exists before writing so that callers (e.g. the AIP runner hydration
|
|
934
|
+
path) do not need to reach into ``_get_files_client`` or
|
|
935
|
+
``_resolve_path`` directly.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
file_path: Workspace-relative or absolute destination path.
|
|
939
|
+
content_bytes: Raw bytes to write.
|
|
940
|
+
"""
|
|
941
|
+
|
|
942
|
+
async def _attempt() -> None:
|
|
943
|
+
await self._ensure_runtime()
|
|
944
|
+
await self._ensure_parent_dir(file_path)
|
|
945
|
+
files_client = await self._get_files_client()
|
|
946
|
+
full_path = self._resolve_path(file_path)
|
|
947
|
+
await files_client.write(full_path, content_bytes)
|
|
948
|
+
self._binary_paths.add(self._binary_marker(full_path))
|
|
949
|
+
await self._save_binary_paths()
|
|
950
|
+
|
|
951
|
+
await self._run_with_stale_files_recovery(_attempt, operation="awrite_bytes")
|
|
952
|
+
|
|
953
|
+
def edit(
|
|
954
|
+
self,
|
|
955
|
+
file_path: str,
|
|
956
|
+
old_string: str,
|
|
957
|
+
new_string: str,
|
|
958
|
+
replace_all: bool = False,
|
|
959
|
+
) -> EditResult:
|
|
960
|
+
"""Replace a string inside a file (synchronous).
|
|
961
|
+
|
|
962
|
+
Synchronous wrapper around :meth:`aedit`.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
file_path: Workspace-relative or absolute path of the file to edit.
|
|
966
|
+
old_string: The exact substring to search for.
|
|
967
|
+
new_string: The replacement text.
|
|
968
|
+
replace_all: When ``True``, replace every occurrence of
|
|
969
|
+
*old_string*; when ``False`` (default), replace only the
|
|
970
|
+
first occurrence.
|
|
971
|
+
|
|
972
|
+
Returns:
|
|
973
|
+
An :class:`~aip_agents.middleware.backends.protocol.EditResult`
|
|
974
|
+
with ``success=True`` and an occurrence count on success, or
|
|
975
|
+
``success=False`` and an ``error`` message on failure.
|
|
976
|
+
"""
|
|
977
|
+
return self._run_sync(self.aedit(file_path, old_string, new_string, replace_all=replace_all))
|
|
978
|
+
|
|
979
|
+
async def aedit(
|
|
980
|
+
self,
|
|
981
|
+
file_path: str,
|
|
982
|
+
old_string: str,
|
|
983
|
+
new_string: str,
|
|
984
|
+
replace_all: bool = False,
|
|
985
|
+
) -> EditResult:
|
|
986
|
+
"""Replace a string inside a file (async).
|
|
987
|
+
|
|
988
|
+
Reads the current file content from the sandbox, performs the string
|
|
989
|
+
replacement via
|
|
990
|
+
:func:`~aip_agents.middleware.backends.utils.perform_string_replacement`,
|
|
991
|
+
and writes the result back. Returns an error result if the file does
|
|
992
|
+
not exist or the replacement fails.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
file_path: Workspace-relative or absolute path of the file to edit.
|
|
996
|
+
old_string: The exact substring to search for.
|
|
997
|
+
new_string: The replacement text.
|
|
998
|
+
replace_all: When ``True``, replace every occurrence of
|
|
999
|
+
*old_string*; when ``False`` (default), replace only the
|
|
1000
|
+
first occurrence.
|
|
1001
|
+
|
|
1002
|
+
Returns:
|
|
1003
|
+
An :class:`~aip_agents.middleware.backends.protocol.EditResult`
|
|
1004
|
+
with ``success=True`` and an occurrence count on success, or
|
|
1005
|
+
``success=False`` and an ``error`` message on failure.
|
|
1006
|
+
"""
|
|
1007
|
+
|
|
1008
|
+
async def _attempt() -> EditResult:
|
|
1009
|
+
try:
|
|
1010
|
+
files_client, full_path, content = await self._read_text_file(file_path)
|
|
1011
|
+
except ValueError:
|
|
1012
|
+
return EditResult(
|
|
1013
|
+
path=file_path,
|
|
1014
|
+
error=f"Error: '{file_path}' is binary and cannot be edited",
|
|
1015
|
+
success=False,
|
|
1016
|
+
)
|
|
1017
|
+
except Exception as error:
|
|
1018
|
+
if SandboxRuntime._is_stale_runtime_error(error):
|
|
1019
|
+
raise
|
|
1020
|
+
return EditResult(
|
|
1021
|
+
path=file_path,
|
|
1022
|
+
error=f"Error: File '{file_path}' not found ({error})",
|
|
1023
|
+
success=False,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
if self._is_binary_payload(full_path, content):
|
|
1027
|
+
return EditResult(
|
|
1028
|
+
path=file_path, error=f"Error: '{file_path}' is binary and cannot be edited", success=False
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
replacement = perform_string_replacement(content, old_string, new_string, replace_all)
|
|
1032
|
+
if isinstance(replacement, str):
|
|
1033
|
+
return EditResult(path=file_path, error=replacement, success=False)
|
|
1034
|
+
|
|
1035
|
+
new_content, occurrences = replacement
|
|
1036
|
+
if not await self._write_text_with_stale_retry(
|
|
1037
|
+
files_client,
|
|
1038
|
+
full_path,
|
|
1039
|
+
new_content,
|
|
1040
|
+
operation="aedit",
|
|
1041
|
+
):
|
|
1042
|
+
return EditResult(
|
|
1043
|
+
path=file_path,
|
|
1044
|
+
error=f"Error: stale transport remained after retry while editing '{file_path}'",
|
|
1045
|
+
success=False,
|
|
1046
|
+
)
|
|
1047
|
+
marker = self._binary_marker(full_path)
|
|
1048
|
+
if marker in self._binary_paths:
|
|
1049
|
+
self._binary_paths.discard(marker)
|
|
1050
|
+
await self._save_binary_paths()
|
|
1051
|
+
return EditResult(path=file_path, occurrences=occurrences, success=True)
|
|
1052
|
+
|
|
1053
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aedit")
|
|
1054
|
+
|
|
1055
|
+
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
1056
|
+
"""Find files matching a glob pattern (synchronous).
|
|
1057
|
+
|
|
1058
|
+
Synchronous wrapper around :meth:`aglob_info`.
|
|
1059
|
+
|
|
1060
|
+
Args:
|
|
1061
|
+
pattern: A glob pattern (e.g. ``"**/*.py"``) matched against
|
|
1062
|
+
workspace-relative paths and bare file names.
|
|
1063
|
+
path: Root directory to search. Defaults to the workspace root
|
|
1064
|
+
(``"/"``).
|
|
1065
|
+
|
|
1066
|
+
Returns:
|
|
1067
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.FileInfo`
|
|
1068
|
+
objects for all matching entries.
|
|
1069
|
+
"""
|
|
1070
|
+
return self._run_sync(self.aglob_info(pattern, path=path))
|
|
1071
|
+
|
|
1072
|
+
async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
1073
|
+
"""Find files matching a glob pattern (async).
|
|
1074
|
+
|
|
1075
|
+
Recursively lists all entries under *path* using the E2B files client
|
|
1076
|
+
and filters them with :func:`fnmatch.fnmatch` against both the
|
|
1077
|
+
workspace-relative path and the bare file name.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
pattern: A glob pattern (e.g. ``"**/*.py"``) matched against
|
|
1081
|
+
workspace-relative paths and bare file names.
|
|
1082
|
+
path: Root directory to search. Defaults to the workspace root
|
|
1083
|
+
(``"/"``).
|
|
1084
|
+
|
|
1085
|
+
Returns:
|
|
1086
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.FileInfo`
|
|
1087
|
+
objects for all matching entries.
|
|
1088
|
+
"""
|
|
1089
|
+
|
|
1090
|
+
async def _attempt() -> list[FileInfo]:
|
|
1091
|
+
await self._ensure_runtime()
|
|
1092
|
+
files_client = await self._get_files_client()
|
|
1093
|
+
base_path = self._resolve_path(path)
|
|
1094
|
+
entries = await files_client.list(base_path)
|
|
1095
|
+
|
|
1096
|
+
matches: list[FileInfo] = []
|
|
1097
|
+
for entry in entries:
|
|
1098
|
+
full_entry_path = f"{base_path}/{entry.name}".replace("//", "/")
|
|
1099
|
+
if self._is_metadata_path(full_entry_path):
|
|
1100
|
+
continue
|
|
1101
|
+
rel_path = self._to_relative_path(full_entry_path)
|
|
1102
|
+
if not (fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(entry.name, pattern)):
|
|
1103
|
+
continue
|
|
1104
|
+
|
|
1105
|
+
is_dir = "dir" in str(getattr(entry, "type", ""))
|
|
1106
|
+
if is_dir:
|
|
1107
|
+
matches.append(FileInfo(path=rel_path, type="directory", size=None))
|
|
1108
|
+
else:
|
|
1109
|
+
matches.append(FileInfo(path=rel_path, type="file", size=getattr(entry, "size", None)))
|
|
1110
|
+
return matches
|
|
1111
|
+
|
|
1112
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aglob_info")
|
|
1113
|
+
|
|
1114
|
+
def grep_raw(self, pattern: str, path: str | None = None) -> list[GrepMatch] | str:
|
|
1115
|
+
"""Search for a text pattern across files (synchronous).
|
|
1116
|
+
|
|
1117
|
+
Synchronous wrapper around :meth:`agrep_raw`.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
pattern: Literal string to search for (passed to ``rg -F``).
|
|
1121
|
+
path: Directory or file to search within. Defaults to
|
|
1122
|
+
``base_dir`` when ``None``.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.GrepMatch`
|
|
1126
|
+
objects on success.
|
|
1127
|
+
"""
|
|
1128
|
+
return self._run_sync(self.agrep_raw(pattern, path=path))
|
|
1129
|
+
|
|
1130
|
+
async def agrep_raw(self, pattern: str, path: str | None = None) -> list[GrepMatch] | str:
|
|
1131
|
+
"""Search for a literal text pattern across sandbox files (async).
|
|
1132
|
+
|
|
1133
|
+
Uses the sandbox files API instead of shell execution so pattern and path
|
|
1134
|
+
arguments never become executable command text.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
pattern: Literal string to search for.
|
|
1138
|
+
path: Directory or file to search within. Defaults to
|
|
1139
|
+
``base_dir`` when ``None``.
|
|
1140
|
+
|
|
1141
|
+
Returns:
|
|
1142
|
+
A list of :class:`~aip_agents.middleware.backends.protocol.GrepMatch`
|
|
1143
|
+
objects on success.
|
|
1144
|
+
"""
|
|
1145
|
+
|
|
1146
|
+
async def _attempt() -> list[GrepMatch] | str:
|
|
1147
|
+
await self._ensure_runtime()
|
|
1148
|
+
files_client = await self._get_files_client()
|
|
1149
|
+
search_path = self._resolve_path(path or self.base_dir)
|
|
1150
|
+
|
|
1151
|
+
try:
|
|
1152
|
+
return await self._agrep_directory(files_client, search_path, pattern)
|
|
1153
|
+
except Exception as exc:
|
|
1154
|
+
if SandboxRuntime._is_stale_runtime_error(exc):
|
|
1155
|
+
raise
|
|
1156
|
+
return await self._agrep_file(files_client, search_path, pattern)
|
|
1157
|
+
|
|
1158
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="agrep_raw")
|
|
1159
|
+
|
|
1160
|
+
async def _agrep_directory(self, files_client: Any, directory: str, pattern: str) -> list[GrepMatch]:
|
|
1161
|
+
"""Search a sandbox directory recursively via the files API."""
|
|
1162
|
+
matches: list[GrepMatch] = []
|
|
1163
|
+
entries = await files_client.list(directory)
|
|
1164
|
+
for entry in entries:
|
|
1165
|
+
full_path = self._resolve_child_path(directory, entry.name)
|
|
1166
|
+
if full_path is None or self._is_metadata_path(full_path):
|
|
1167
|
+
continue
|
|
1168
|
+
if self._is_directory_entry(entry):
|
|
1169
|
+
matches.extend(await self._agrep_directory(files_client, full_path, pattern))
|
|
1170
|
+
continue
|
|
1171
|
+
matches.extend(await self._agrep_file(files_client, full_path, pattern))
|
|
1172
|
+
return matches
|
|
1173
|
+
|
|
1174
|
+
async def _agrep_file(self, files_client: Any, full_path: str, pattern: str) -> list[GrepMatch]:
|
|
1175
|
+
"""Search one sandbox file via the files API."""
|
|
1176
|
+
if self._is_metadata_path(full_path) or await self._is_binary_path(full_path):
|
|
1177
|
+
return []
|
|
1178
|
+
try:
|
|
1179
|
+
content = await files_client.read(full_path)
|
|
1180
|
+
except Exception as exc:
|
|
1181
|
+
if SandboxRuntime._is_stale_runtime_error(exc):
|
|
1182
|
+
raise
|
|
1183
|
+
return []
|
|
1184
|
+
if not isinstance(content, str) or self._is_binary_payload(full_path, content):
|
|
1185
|
+
return []
|
|
1186
|
+
return [
|
|
1187
|
+
GrepMatch(path=self._to_relative_path(full_path), line_number=line_number, content=line_text)
|
|
1188
|
+
for line_number, line_text in enumerate(content.splitlines(), start=1)
|
|
1189
|
+
if pattern in line_text
|
|
1190
|
+
]
|
|
1191
|
+
|
|
1192
|
+
def grep(self, pattern: str, path: str | None = None) -> list[GrepMatch]:
|
|
1193
|
+
"""Search for a text pattern, always returning a list (synchronous).
|
|
1194
|
+
|
|
1195
|
+
Calls :meth:`grep_raw` and suppresses the error-string variant,
|
|
1196
|
+
returning an empty list whenever the underlying search fails.
|
|
1197
|
+
|
|
1198
|
+
Args:
|
|
1199
|
+
pattern: Literal string to search for.
|
|
1200
|
+
path: Directory or file to search within. Defaults to
|
|
1201
|
+
``base_dir`` when ``None``.
|
|
1202
|
+
|
|
1203
|
+
Returns:
|
|
1204
|
+
A (possibly empty) list of
|
|
1205
|
+
:class:`~aip_agents.middleware.backends.protocol.GrepMatch` objects.
|
|
1206
|
+
"""
|
|
1207
|
+
result = self.grep_raw(pattern, path=path)
|
|
1208
|
+
if isinstance(result, str):
|
|
1209
|
+
return []
|
|
1210
|
+
return result
|
|
1211
|
+
|
|
1212
|
+
def execute(self, command: str, timeout: int = 30) -> ExecuteResult:
|
|
1213
|
+
"""Execute a shell command inside the sandbox (synchronous).
|
|
1214
|
+
|
|
1215
|
+
Synchronous wrapper around :meth:`aexecute`.
|
|
1216
|
+
|
|
1217
|
+
Args:
|
|
1218
|
+
command: Shell command string to run.
|
|
1219
|
+
timeout: Maximum number of seconds to wait for the command to
|
|
1220
|
+
finish. Defaults to 30.
|
|
1221
|
+
|
|
1222
|
+
Returns:
|
|
1223
|
+
An :class:`~aip_agents.middleware.backends.protocol.ExecuteResult`
|
|
1224
|
+
containing ``stdout``, ``stderr``, and ``exit_code``.
|
|
1225
|
+
|
|
1226
|
+
Raises:
|
|
1227
|
+
PermissionError: If ``allow_execute`` is ``False``.
|
|
1228
|
+
"""
|
|
1229
|
+
return self._run_sync(self.aexecute(command, timeout=timeout))
|
|
1230
|
+
|
|
1231
|
+
def _reset_backend_runtime(self) -> None:
|
|
1232
|
+
"""Clear cached loop-bound state for fresh recreation on the current loop.
|
|
1233
|
+
|
|
1234
|
+
Resets the SandboxRuntime's loop-bound sandbox client (if any) and
|
|
1235
|
+
clears the cached files-client and base-dir flags so that the next
|
|
1236
|
+
``_ensure_runtime()`` re-initialises transport on the current loop
|
|
1237
|
+
without discarding an injected runtime reference.
|
|
1238
|
+
"""
|
|
1239
|
+
runtime = self._runtime
|
|
1240
|
+
if isinstance(runtime, SandboxRuntime):
|
|
1241
|
+
runtime._reset_for_new_loop()
|
|
1242
|
+
self._invalidate_cached_files_client()
|
|
1243
|
+
|
|
1244
|
+
async def _run_with_stale_files_recovery(
|
|
1245
|
+
self,
|
|
1246
|
+
attempt: Callable[[], Awaitable[Any]],
|
|
1247
|
+
*,
|
|
1248
|
+
operation: str,
|
|
1249
|
+
) -> Any:
|
|
1250
|
+
"""Run one filesystem operation and retry once after stale transport reset."""
|
|
1251
|
+
try:
|
|
1252
|
+
return await attempt()
|
|
1253
|
+
except Exception as exc:
|
|
1254
|
+
if SandboxRuntime._is_stale_runtime_error(exc) or self._is_stale_files_error(exc):
|
|
1255
|
+
logger.info(
|
|
1256
|
+
"%s: detected stale filesystem transport (%r), resetting and retrying once",
|
|
1257
|
+
operation,
|
|
1258
|
+
exc,
|
|
1259
|
+
)
|
|
1260
|
+
self._reset_backend_runtime()
|
|
1261
|
+
return await attempt()
|
|
1262
|
+
raise
|
|
1263
|
+
|
|
1264
|
+
@staticmethod
|
|
1265
|
+
def _is_stale_files_error(error: BaseException) -> bool:
|
|
1266
|
+
"""Return True for filesystem adapter errors caused by dropped uploads."""
|
|
1267
|
+
current: BaseException | None = error
|
|
1268
|
+
seen: set[int] = set()
|
|
1269
|
+
while current is not None and id(current) not in seen:
|
|
1270
|
+
seen.add(id(current))
|
|
1271
|
+
if STALE_FILE_UPLOAD_MARKER in str(current).lower():
|
|
1272
|
+
return True
|
|
1273
|
+
current = current.__cause__ or current.__context__
|
|
1274
|
+
return False
|
|
1275
|
+
|
|
1276
|
+
async def _aexecute_runtime_result(self, command: str, timeout: int) -> Any:
|
|
1277
|
+
"""Execute a single runtime command attempt without backend recovery."""
|
|
1278
|
+
runtime = await self._ensure_runtime_instance()
|
|
1279
|
+
runtime_any = cast(Any, runtime)
|
|
1280
|
+
command_env = self._env
|
|
1281
|
+
execute_command = getattr(runtime_any, "execute_command", None)
|
|
1282
|
+
if callable(execute_command):
|
|
1283
|
+
return await execute_command(command, timeout=timeout, env=command_env)
|
|
1284
|
+
return await runtime_any.execute(command, timeout=timeout, env=command_env)
|
|
1285
|
+
|
|
1286
|
+
def _to_execute_result(self, result: Any) -> ExecuteResult:
|
|
1287
|
+
"""Convert a runtime execution result into the backend protocol shape."""
|
|
1288
|
+
return ExecuteResult(
|
|
1289
|
+
stdout=redact_secrets(result.stdout, self._env),
|
|
1290
|
+
stderr=redact_secrets(result.stderr, self._env),
|
|
1291
|
+
exit_code=result.exit_code,
|
|
1292
|
+
truncated=False,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
@staticmethod
|
|
1296
|
+
def _is_diagnostic_stale_result(result: Any) -> bool:
|
|
1297
|
+
"""Return True when runtime diagnostics explicitly mark a stale transport."""
|
|
1298
|
+
if getattr(result, "exit_code", 0) == 0:
|
|
1299
|
+
return False
|
|
1300
|
+
diagnostics = getattr(result, "diagnostics", {}) or {}
|
|
1301
|
+
return diagnostics.get("stale_runtime_transport") is True
|
|
1302
|
+
|
|
1303
|
+
async def _aexecute_impl(self, command: str, timeout: int) -> ExecuteResult:
|
|
1304
|
+
"""Execute a single command attempt (internal, without recovery)."""
|
|
1305
|
+
result = await self._aexecute_runtime_result(command, timeout)
|
|
1306
|
+
return self._to_execute_result(result)
|
|
1307
|
+
|
|
1308
|
+
async def aexecute(self, command: str, timeout: int = 30) -> ExecuteResult:
|
|
1309
|
+
"""Execute a shell command inside the sandbox (async).
|
|
1310
|
+
|
|
1311
|
+
Delegates shell execution to runtime when available. For
|
|
1312
|
+
:class:`SandboxRuntime`, this uses its dedicated command path.
|
|
1313
|
+
|
|
1314
|
+
When the runtime or its underlying transport was bound to a now-closed
|
|
1315
|
+
event loop (uvloop ``TCPTransport closed``, asyncio
|
|
1316
|
+
``Event loop is closed``, etc.), this method resets the cached runtime
|
|
1317
|
+
and retries once.
|
|
1318
|
+
|
|
1319
|
+
Args:
|
|
1320
|
+
command: Shell command string to run.
|
|
1321
|
+
timeout: Maximum number of seconds to wait for the command to
|
|
1322
|
+
finish. Defaults to 30.
|
|
1323
|
+
|
|
1324
|
+
Returns:
|
|
1325
|
+
An :class:`~aip_agents.middleware.backends.protocol.ExecuteResult`
|
|
1326
|
+
containing ``stdout``, ``stderr``, ``exit_code``, and
|
|
1327
|
+
``truncated=False``.
|
|
1328
|
+
|
|
1329
|
+
Raises:
|
|
1330
|
+
PermissionError: If ``allow_execute`` is ``False``.
|
|
1331
|
+
"""
|
|
1332
|
+
if not self.allow_execute:
|
|
1333
|
+
raise PermissionError("execute() disabled for this backend")
|
|
1334
|
+
try:
|
|
1335
|
+
result = await self._aexecute_runtime_result(command, timeout)
|
|
1336
|
+
except Exception as exc:
|
|
1337
|
+
if SandboxRuntime._is_stale_runtime_error(exc):
|
|
1338
|
+
logger.info(
|
|
1339
|
+
"aexecute: detected stale runtime/transport (%r), resetting and retrying once",
|
|
1340
|
+
exc,
|
|
1341
|
+
)
|
|
1342
|
+
self._reset_backend_runtime()
|
|
1343
|
+
return await self._aexecute_impl(command, timeout)
|
|
1344
|
+
raise
|
|
1345
|
+
if self._is_diagnostic_stale_result(result):
|
|
1346
|
+
logger.info("aexecute: detected stale runtime/transport result, resetting and retrying once")
|
|
1347
|
+
self._reset_backend_runtime()
|
|
1348
|
+
return await self._aexecute_impl(command, timeout)
|
|
1349
|
+
return self._to_execute_result(result)
|
|
1350
|
+
|
|
1351
|
+
def set_files(self, files: dict[str, Any], thread_id: str = "default") -> None:
|
|
1352
|
+
"""Write a batch of files into the sandbox workspace (synchronous).
|
|
1353
|
+
|
|
1354
|
+
Synchronous wrapper around :meth:`aset_files`.
|
|
1355
|
+
|
|
1356
|
+
Args:
|
|
1357
|
+
files: Mapping of workspace-relative file paths to their text
|
|
1358
|
+
content. Non-string values are coerced with ``str()``.
|
|
1359
|
+
thread_id: Logical thread identifier (reserved for future
|
|
1360
|
+
partitioned workspace support). Defaults to ``"default"``.
|
|
1361
|
+
"""
|
|
1362
|
+
self._run_sync(self.aset_files(files, thread_id=thread_id))
|
|
1363
|
+
|
|
1364
|
+
async def aset_files(self, files: dict[str, Any], thread_id: str = "default") -> None:
|
|
1365
|
+
"""Write a batch of files into the sandbox workspace (async).
|
|
1366
|
+
|
|
1367
|
+
Iterates over *files* and writes each entry to the sandbox via the E2B
|
|
1368
|
+
files client. Paths are resolved relative to ``base_dir``.
|
|
1369
|
+
|
|
1370
|
+
Args:
|
|
1371
|
+
files: Mapping of workspace-relative file paths to their text
|
|
1372
|
+
content. Non-string values are coerced with ``str()``.
|
|
1373
|
+
thread_id: Logical thread identifier (reserved for future
|
|
1374
|
+
partitioned workspace support). Defaults to ``"default"``.
|
|
1375
|
+
"""
|
|
1376
|
+
_ = thread_id
|
|
1377
|
+
|
|
1378
|
+
async def _attempt() -> None:
|
|
1379
|
+
await self._ensure_runtime()
|
|
1380
|
+
files_client = await self._get_files_client()
|
|
1381
|
+
await self._write_files_batch(files_client, files)
|
|
1382
|
+
|
|
1383
|
+
await self._run_with_stale_files_recovery(_attempt, operation="aset_files")
|
|
1384
|
+
|
|
1385
|
+
def get_files(self, thread_id: str = "default") -> dict[str, Any]:
|
|
1386
|
+
"""Read all files from the sandbox workspace for checkpointing (synchronous).
|
|
1387
|
+
|
|
1388
|
+
Synchronous wrapper around :meth:`aget_files`.
|
|
1389
|
+
|
|
1390
|
+
Args:
|
|
1391
|
+
thread_id: Logical thread identifier (reserved for future
|
|
1392
|
+
partitioned workspace support). Defaults to ``"default"``.
|
|
1393
|
+
|
|
1394
|
+
Returns:
|
|
1395
|
+
A mapping of workspace-relative file paths to their text content.
|
|
1396
|
+
"""
|
|
1397
|
+
return self._run_sync(self.aget_files(thread_id=thread_id))
|
|
1398
|
+
|
|
1399
|
+
async def aget_files(self, thread_id: str = "default") -> dict[str, Any]:
|
|
1400
|
+
"""Read all files from the sandbox workspace for checkpointing (async).
|
|
1401
|
+
|
|
1402
|
+
Recursively walks entries under ``base_dir`` and reads the text content
|
|
1403
|
+
of each file via the E2B files client.
|
|
1404
|
+
|
|
1405
|
+
Args:
|
|
1406
|
+
thread_id: Logical thread identifier (reserved for future
|
|
1407
|
+
partitioned workspace support). Defaults to ``"default"``.
|
|
1408
|
+
|
|
1409
|
+
Returns:
|
|
1410
|
+
A mapping of workspace-relative file paths to their text content.
|
|
1411
|
+
"""
|
|
1412
|
+
_ = thread_id
|
|
1413
|
+
|
|
1414
|
+
async def _attempt() -> dict[str, Any]:
|
|
1415
|
+
await self._ensure_runtime()
|
|
1416
|
+
files_client = await self._get_files_client()
|
|
1417
|
+
files: dict[str, Any] = {}
|
|
1418
|
+
await self._collect_sandbox_files(files_client, self.base_dir, files, set())
|
|
1419
|
+
return files
|
|
1420
|
+
|
|
1421
|
+
return await self._run_with_stale_files_recovery(_attempt, operation="aget_files")
|
|
1422
|
+
|
|
1423
|
+
async def cleanup(self) -> None:
|
|
1424
|
+
"""Release all sandbox resources and reset internal state."""
|
|
1425
|
+
if self._runtime is not None:
|
|
1426
|
+
await self._runtime.cleanup()
|
|
1427
|
+
self._runtime = None
|
|
1428
|
+
self._files_client = None
|
|
1429
|
+
self._runtime_loop = None
|
|
1430
|
+
self._base_dir_initialized = False
|
|
1431
|
+
self._binary_paths.clear()
|