aip-agents-binary 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (447) hide show
  1. aip_agents/__init__.py +66 -0
  2. aip_agents/a2a/__init__.py +19 -0
  3. aip_agents/a2a/server/__init__.py +10 -0
  4. aip_agents/a2a/server/base_executor.py +1125 -0
  5. aip_agents/a2a/server/google_adk_executor.py +198 -0
  6. aip_agents/a2a/server/langflow_executor.py +180 -0
  7. aip_agents/a2a/server/langgraph_executor.py +270 -0
  8. aip_agents/a2a/types.py +232 -0
  9. aip_agents/a2ui/__init__.py +26 -0
  10. aip_agents/a2ui/catalog_provider.py +109 -0
  11. aip_agents/a2ui/examples/0.8/booking_form.json +1 -0
  12. aip_agents/a2ui/examples/0.8/confirmation.json +1 -0
  13. aip_agents/a2ui/examples/0.8/single_column_list.json +1 -0
  14. aip_agents/a2ui/examples/0.9/booking_form.json +1 -0
  15. aip_agents/a2ui/examples/0.9/confirmation.json +1 -0
  16. aip_agents/a2ui/examples/0.9/single_column_list.json +1 -0
  17. aip_agents/a2ui/middleware.py +754 -0
  18. aip_agents/a2ui/models.py +153 -0
  19. aip_agents/a2ui/public_url.py +138 -0
  20. aip_agents/a2ui/stream_filter.py +67 -0
  21. aip_agents/agent/__init__.py +72 -0
  22. aip_agents/agent/base_agent.py +1437 -0
  23. aip_agents/agent/base_langgraph_agent.py +4048 -0
  24. aip_agents/agent/google_adk_agent.py +932 -0
  25. aip_agents/agent/google_adk_constants.py +11 -0
  26. aip_agents/agent/graph_runtime.py +185 -0
  27. aip_agents/agent/hitl/__init__.py +24 -0
  28. aip_agents/agent/hitl/config.py +28 -0
  29. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  30. aip_agents/agent/hitl/manager.py +532 -0
  31. aip_agents/agent/hitl/models.py +18 -0
  32. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  33. aip_agents/agent/hitl/prompt/base.py +42 -0
  34. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  35. aip_agents/agent/hitl/registry.py +149 -0
  36. aip_agents/agent/interface.py +138 -0
  37. aip_agents/agent/interfaces.py +65 -0
  38. aip_agents/agent/langflow_agent.py +464 -0
  39. aip_agents/agent/langgraph_react_agent.py +3741 -0
  40. aip_agents/agent/model_invocation_adapter.py +346 -0
  41. aip_agents/agent/system_instruction_context.py +34 -0
  42. aip_agents/agent/tool_execution_engine.py +587 -0
  43. aip_agents/audio_interface/__init__.py +30 -0
  44. aip_agents/audio_interface/audio_agent_adapter.py +254 -0
  45. aip_agents/audio_interface/config.py +146 -0
  46. aip_agents/audio_interface/errors.py +15 -0
  47. aip_agents/audio_interface/livekit_audio_session.py +504 -0
  48. aip_agents/audio_interface/livekit_realtime_audio_session.py +142 -0
  49. aip_agents/audio_interface/session_factory.py +40 -0
  50. aip_agents/clients/__init__.py +10 -0
  51. aip_agents/clients/langflow/__init__.py +10 -0
  52. aip_agents/clients/langflow/client.py +477 -0
  53. aip_agents/clients/langflow/types.py +18 -0
  54. aip_agents/common/__init__.py +5 -0
  55. aip_agents/common/callable_introspection.py +17 -0
  56. aip_agents/constants.py +23 -0
  57. aip_agents/context/__init__.py +5 -0
  58. aip_agents/context/budget.py +237 -0
  59. aip_agents/context/compaction.py +339 -0
  60. aip_agents/context/metadata.py +169 -0
  61. aip_agents/context/offload.py +449 -0
  62. aip_agents/context/preparation.py +196 -0
  63. aip_agents/context/prompt.py +61 -0
  64. aip_agents/context/runtime.py +149 -0
  65. aip_agents/context/session_recall.py +69 -0
  66. aip_agents/context/state.py +48 -0
  67. aip_agents/context/utils.py +44 -0
  68. aip_agents/credentials/manager.py +132 -0
  69. aip_agents/examples/__init__.py +5 -0
  70. aip_agents/examples/audio_livekit_session.py +116 -0
  71. aip_agents/examples/compare_streaming_client.py +783 -0
  72. aip_agents/examples/compare_streaming_server.py +141 -0
  73. aip_agents/examples/gl_connectors_smoke.py +148 -0
  74. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  75. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  76. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  77. aip_agents/examples/hello_world_a2a_google_adk_server.py +78 -0
  78. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  79. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  80. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  81. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  82. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  83. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +104 -0
  84. aip_agents/examples/hello_world_a2a_langchain_server.py +78 -0
  85. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +77 -0
  86. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  87. aip_agents/examples/hello_world_a2a_langflow_server.py +80 -0
  88. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  89. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  90. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +91 -0
  91. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  92. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  93. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  94. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  95. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  96. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  97. aip_agents/examples/hello_world_a2a_langgraph_server.py +83 -0
  98. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +78 -0
  99. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +131 -0
  100. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  101. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  102. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +250 -0
  103. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  104. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +79 -0
  105. aip_agents/examples/hello_world_filesystem.py +162 -0
  106. aip_agents/examples/hello_world_google_adk.py +41 -0
  107. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  108. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  109. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  110. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  111. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  112. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  113. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  114. aip_agents/examples/hello_world_langchain.py +28 -0
  115. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  116. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  117. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  118. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  119. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  120. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  121. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  122. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  123. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  124. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  125. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  126. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  127. aip_agents/examples/hello_world_langgraph.py +39 -0
  128. aip_agents/examples/hello_world_langgraph_gl_connector_twitter.py +44 -0
  129. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  130. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  131. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  132. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  133. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  134. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  135. aip_agents/examples/hello_world_langgraph_skills.py +62 -0
  136. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  137. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  138. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  139. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  140. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  141. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  142. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  143. aip_agents/examples/hello_world_multi_agent_tool_output_client.py +57 -0
  144. aip_agents/examples/hello_world_multi_agent_tool_output_server.py +131 -0
  145. aip_agents/examples/hello_world_pii_logger.py +21 -0
  146. aip_agents/examples/hello_world_ptc.py +49 -0
  147. aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
  148. aip_agents/examples/hello_world_sentry.py +133 -0
  149. aip_agents/examples/hello_world_step_limits.py +273 -0
  150. aip_agents/examples/hello_world_stock_a2a_server.py +100 -0
  151. aip_agents/examples/hello_world_tool_output_client.py +55 -0
  152. aip_agents/examples/hello_world_tool_output_server.py +113 -0
  153. aip_agents/examples/hitl_demo.py +724 -0
  154. aip_agents/examples/mcp_configs/configs.py +63 -0
  155. aip_agents/examples/mcp_servers/common.py +76 -0
  156. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  157. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  158. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  159. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  160. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  161. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  162. aip_agents/examples/pii_demo_langgraph_server.py +125 -0
  163. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  164. aip_agents/examples/pii_demo_multi_agent_server.py +246 -0
  165. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  166. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +87 -0
  167. aip_agents/examples/tools/__init__.py +27 -0
  168. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  169. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  170. aip_agents/examples/tools/data_generator_tool.py +103 -0
  171. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  172. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  173. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  174. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  175. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  176. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  177. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  178. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  179. aip_agents/examples/tools/multiply_tool.py +43 -0
  180. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  181. aip_agents/examples/tools/random_chart_tool.py +142 -0
  182. aip_agents/examples/tools/serper_tool.py +202 -0
  183. aip_agents/examples/tools/stock_tools.py +82 -0
  184. aip_agents/examples/tools/table_generator_tool.py +167 -0
  185. aip_agents/examples/tools/time_tool.py +82 -0
  186. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  187. aip_agents/guardrails/__init__.py +83 -0
  188. aip_agents/guardrails/engines/__init__.py +69 -0
  189. aip_agents/guardrails/engines/base.py +90 -0
  190. aip_agents/guardrails/engines/nemo.py +101 -0
  191. aip_agents/guardrails/engines/phrase_matcher.py +113 -0
  192. aip_agents/guardrails/exceptions.py +39 -0
  193. aip_agents/guardrails/manager.py +163 -0
  194. aip_agents/guardrails/middleware.py +199 -0
  195. aip_agents/guardrails/schemas.py +63 -0
  196. aip_agents/guardrails/utils.py +45 -0
  197. aip_agents/integration/__init__.py +43 -0
  198. aip_agents/integration/agent.py +58 -0
  199. aip_agents/integration/guardrails.py +52 -0
  200. aip_agents/integration/hitl.py +71 -0
  201. aip_agents/integration/ptc.py +95 -0
  202. aip_agents/integration/skills.py +37 -0
  203. aip_agents/integration/storage.py +81 -0
  204. aip_agents/integration/version.py +103 -0
  205. aip_agents/mcp/__init__.py +1 -0
  206. aip_agents/mcp/client/__init__.py +60 -0
  207. aip_agents/mcp/client/base_mcp_client.py +377 -0
  208. aip_agents/mcp/client/connection_manager.py +231 -0
  209. aip_agents/mcp/client/errors.py +119 -0
  210. aip_agents/mcp/client/google_adk/__init__.py +45 -0
  211. aip_agents/mcp/client/google_adk/client.py +383 -0
  212. aip_agents/mcp/client/langchain/__init__.py +11 -0
  213. aip_agents/mcp/client/langchain/client.py +265 -0
  214. aip_agents/mcp/client/persistent_session.py +704 -0
  215. aip_agents/mcp/client/session_pool.py +358 -0
  216. aip_agents/mcp/client/transports.py +288 -0
  217. aip_agents/mcp/utils/__init__.py +7 -0
  218. aip_agents/mcp/utils/config_validator.py +139 -0
  219. aip_agents/memory/__init__.py +14 -0
  220. aip_agents/memory/adapters/__init__.py +10 -0
  221. aip_agents/memory/adapters/base_adapter.py +815 -0
  222. aip_agents/memory/adapters/mem0.py +286 -0
  223. aip_agents/memory/base.py +84 -0
  224. aip_agents/memory/constants.py +49 -0
  225. aip_agents/memory/factory.py +86 -0
  226. aip_agents/memory/guidance.py +20 -0
  227. aip_agents/memory/simple_memory.py +47 -0
  228. aip_agents/middleware/__init__.py +65 -0
  229. aip_agents/middleware/backends/__init__.py +72 -0
  230. aip_agents/middleware/backends/in_memory.py +578 -0
  231. aip_agents/middleware/backends/local_disk.py +1203 -0
  232. aip_agents/middleware/backends/protocol.py +353 -0
  233. aip_agents/middleware/backends/sandbox.py +1431 -0
  234. aip_agents/middleware/backends/sandbox_files.py +113 -0
  235. aip_agents/middleware/backends/utils.py +293 -0
  236. aip_agents/middleware/base.py +239 -0
  237. aip_agents/middleware/filesystem.py +690 -0
  238. aip_agents/middleware/manager.py +291 -0
  239. aip_agents/middleware/memory.py +532 -0
  240. aip_agents/middleware/pii_middleware.py +192 -0
  241. aip_agents/middleware/schema.py +19 -0
  242. aip_agents/middleware/skills.py +872 -0
  243. aip_agents/middleware/task_delegation.py +1656 -0
  244. aip_agents/middleware/todolist.py +276 -0
  245. aip_agents/middleware/tools/__init__.py +23 -0
  246. aip_agents/middleware/tools/edit_file.py +70 -0
  247. aip_agents/middleware/tools/execute.py +428 -0
  248. aip_agents/middleware/tools/grep_file.py +79 -0
  249. aip_agents/middleware/tools/ls.py +71 -0
  250. aip_agents/middleware/tools/read_file.py +239 -0
  251. aip_agents/middleware/tools/skill_load.py +193 -0
  252. aip_agents/middleware/tools/skill_resource.py +338 -0
  253. aip_agents/middleware/tools/write_file.py +114 -0
  254. aip_agents/middleware/vision.py +322 -0
  255. aip_agents/ptc/__init__.py +87 -0
  256. aip_agents/ptc/custom_tools.py +511 -0
  257. aip_agents/ptc/custom_tools_payload.py +499 -0
  258. aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
  259. aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +24 -0
  260. aip_agents/ptc/custom_tools_templates/custom_init.py.template +26 -0
  261. aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +149 -0
  262. aip_agents/ptc/custom_tools_templates/custom_registry.py.template +112 -0
  263. aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
  264. aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +87 -0
  265. aip_agents/ptc/doc_gen.py +122 -0
  266. aip_agents/ptc/exceptions.py +57 -0
  267. aip_agents/ptc/executor.py +627 -0
  268. aip_agents/ptc/mcp/__init__.py +45 -0
  269. aip_agents/ptc/mcp/sandbox_bridge.py +679 -0
  270. aip_agents/ptc/mcp/templates/__init__.py +1 -0
  271. aip_agents/ptc/mcp/templates/mcp_client.py.template +240 -0
  272. aip_agents/ptc/naming.py +430 -0
  273. aip_agents/ptc/payload.py +26 -0
  274. aip_agents/ptc/prompt_builder.py +674 -0
  275. aip_agents/ptc/ptc_helper.py +16 -0
  276. aip_agents/ptc/sandbox_bridge.py +297 -0
  277. aip_agents/ptc/template_utils.py +33 -0
  278. aip_agents/ptc/templates/__init__.py +1 -0
  279. aip_agents/ptc/templates/ptc_helper.py.template +134 -0
  280. aip_agents/ptc/tool_def_helpers.py +101 -0
  281. aip_agents/ptc/tool_enrichment.py +163 -0
  282. aip_agents/resilience/__init__.py +10 -0
  283. aip_agents/resilience/base.py +46 -0
  284. aip_agents/resilience/tool.py +1661 -0
  285. aip_agents/sandbox/__init__.py +62 -0
  286. aip_agents/sandbox/defaults.py +220 -0
  287. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1-datasaur.Dockerfile +120 -0
  288. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1.Dockerfile +100 -0
  289. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2-datasaur.Dockerfile +143 -0
  290. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2.Dockerfile +119 -0
  291. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3-datasaur.Dockerfile +173 -0
  292. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3.Dockerfile +130 -0
  293. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.Dockerfile +164 -0
  294. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.patch_js_context.py +41 -0
  295. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.requirements.txt +29 -0
  296. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2.Dockerfile +87 -0
  297. aip_agents/sandbox/e2b_runtime.py +27 -0
  298. aip_agents/sandbox/image_registry.py +98 -0
  299. aip_agents/sandbox/providers/__init__.py +76 -0
  300. aip_agents/sandbox/providers/base.py +392 -0
  301. aip_agents/sandbox/providers/bedrock.py +28 -0
  302. aip_agents/sandbox/providers/e2b.py +230 -0
  303. aip_agents/sandbox/providers/opensandbox.py +163 -0
  304. aip_agents/sandbox/sandbox_runtime.py +967 -0
  305. aip_agents/sandbox/template_builder.py +240 -0
  306. aip_agents/sandbox/types.py +141 -0
  307. aip_agents/sandbox/validation.py +50 -0
  308. aip_agents/schema/__init__.py +106 -0
  309. aip_agents/schema/a2a.py +58 -0
  310. aip_agents/schema/agent.py +117 -0
  311. aip_agents/schema/artifacts.py +75 -0
  312. aip_agents/schema/attachments.py +425 -0
  313. aip_agents/schema/hitl.py +157 -0
  314. aip_agents/schema/langgraph.py +47 -0
  315. aip_agents/schema/model_id.py +103 -0
  316. aip_agents/schema/model_usage.py +50 -0
  317. aip_agents/schema/runtime_metadata.py +78 -0
  318. aip_agents/schema/step_limit.py +108 -0
  319. aip_agents/schema/storage.py +40 -0
  320. aip_agents/schema/tool_payloads.py +68 -0
  321. aip_agents/sentry/__init__.py +11 -0
  322. aip_agents/sentry/sentry.py +267 -0
  323. aip_agents/skills/__init__.py +30 -0
  324. aip_agents/skills/errors.py +110 -0
  325. aip_agents/skills/installer.py +479 -0
  326. aip_agents/skills/models.py +769 -0
  327. aip_agents/skills/stager.py +224 -0
  328. aip_agents/skills/validation.py +53 -0
  329. aip_agents/storage/__init__.py +41 -0
  330. aip_agents/storage/base.py +85 -0
  331. aip_agents/storage/clients/__init__.py +12 -0
  332. aip_agents/storage/clients/minio_client.py +318 -0
  333. aip_agents/storage/config.py +62 -0
  334. aip_agents/storage/providers/__init__.py +15 -0
  335. aip_agents/storage/providers/base.py +106 -0
  336. aip_agents/storage/providers/memory.py +114 -0
  337. aip_agents/storage/providers/object_storage.py +214 -0
  338. aip_agents/tools/__init__.py +65 -0
  339. aip_agents/tools/agent_schedule_tool.py +392 -0
  340. aip_agents/tools/browser_use/__init__.py +83 -0
  341. aip_agents/tools/browser_use/action_parser.py +104 -0
  342. aip_agents/tools/browser_use/browser_use_tool.py +1118 -0
  343. aip_agents/tools/browser_use/llm_config.py +120 -0
  344. aip_agents/tools/browser_use/minio_storage.py +203 -0
  345. aip_agents/tools/browser_use/schemas.py +121 -0
  346. aip_agents/tools/browser_use/session.py +13 -0
  347. aip_agents/tools/browser_use/session_errors.py +132 -0
  348. aip_agents/tools/browser_use/steel_session_recording.py +321 -0
  349. aip_agents/tools/browser_use/streaming.py +263 -0
  350. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  351. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  352. aip_agents/tools/browser_use/types.py +37 -0
  353. aip_agents/tools/code_sandbox/__init__.py +48 -0
  354. aip_agents/tools/code_sandbox/code_sandbox_tool.py +350 -0
  355. aip_agents/tools/code_sandbox/constant.py +13 -0
  356. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +346 -0
  357. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +65 -0
  358. aip_agents/tools/computer_use/__init__.py +75 -0
  359. aip_agents/tools/computer_use/computer_use_tool.py +910 -0
  360. aip_agents/tools/computer_use/schemas.py +78 -0
  361. aip_agents/tools/computer_use/streaming.py +198 -0
  362. aip_agents/tools/computer_use/types.py +32 -0
  363. aip_agents/tools/constants.py +177 -0
  364. aip_agents/tools/date_range_tool.py +554 -0
  365. aip_agents/tools/document_loader/__init__.py +44 -0
  366. aip_agents/tools/document_loader/base_reader.py +302 -0
  367. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  368. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  369. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  370. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  371. aip_agents/tools/execute_ptc_code.py +582 -0
  372. aip_agents/tools/gl_connector/__init__.py +5 -0
  373. aip_agents/tools/gl_connector/tool.py +518 -0
  374. aip_agents/tools/gl_connector_tools.py +170 -0
  375. aip_agents/tools/memory_search/__init__.py +29 -0
  376. aip_agents/tools/memory_search/base.py +213 -0
  377. aip_agents/tools/memory_search/mem0.py +424 -0
  378. aip_agents/tools/memory_search/schema.py +81 -0
  379. aip_agents/tools/memory_search_tool.py +34 -0
  380. aip_agents/tools/streaming_common.py +577 -0
  381. aip_agents/tools/time_tool.py +117 -0
  382. aip_agents/tools/tool_config_injector.py +300 -0
  383. aip_agents/tools/web_search/__init__.py +15 -0
  384. aip_agents/tools/web_search/serper_tool.py +187 -0
  385. aip_agents/types/__init__.py +73 -0
  386. aip_agents/types/a2a_events.py +13 -0
  387. aip_agents/utils/__init__.py +91 -0
  388. aip_agents/utils/a2a_connector.py +1758 -0
  389. aip_agents/utils/artifact_helpers.py +542 -0
  390. aip_agents/utils/artifacts/__init__.py +20 -0
  391. aip_agents/utils/artifacts/tracker.py +367 -0
  392. aip_agents/utils/attachment_helpers.py +178 -0
  393. aip_agents/utils/constants.py +22 -0
  394. aip_agents/utils/datetime/__init__.py +34 -0
  395. aip_agents/utils/datetime/normalization.py +231 -0
  396. aip_agents/utils/datetime/timezone.py +206 -0
  397. aip_agents/utils/env_loader.py +27 -0
  398. aip_agents/utils/event_handler_registry.py +58 -0
  399. aip_agents/utils/file_prompt_utils.py +276 -0
  400. aip_agents/utils/file_watchers/__init__.py +33 -0
  401. aip_agents/utils/file_watchers/base.py +46 -0
  402. aip_agents/utils/file_watchers/e2b.py +128 -0
  403. aip_agents/utils/file_watchers/factory.py +39 -0
  404. aip_agents/utils/file_watchers/ls_diff.py +88 -0
  405. aip_agents/utils/file_watchers/sandbox.py +66 -0
  406. aip_agents/utils/file_watchers/watchdog.py +97 -0
  407. aip_agents/utils/final_response_builder.py +211 -0
  408. aip_agents/utils/formatter_llm_client.py +258 -0
  409. aip_agents/utils/langgraph/__init__.py +19 -0
  410. aip_agents/utils/langgraph/converter.py +399 -0
  411. aip_agents/utils/langgraph/delegation_context.py +22 -0
  412. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  413. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  414. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  415. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1329 -0
  416. aip_agents/utils/langgraph/tool_output_management.py +1047 -0
  417. aip_agents/utils/logger.py +195 -0
  418. aip_agents/utils/metadata/__init__.py +27 -0
  419. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  420. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  421. aip_agents/utils/metadata/activity_narrative/builder.py +822 -0
  422. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  423. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  424. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  425. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  426. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  427. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  428. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  429. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  430. aip_agents/utils/metadata_helper.py +363 -0
  431. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  432. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  433. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  434. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  435. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  436. aip_agents/utils/pii/__init__.py +29 -0
  437. aip_agents/utils/pii/pii_handler.py +555 -0
  438. aip_agents/utils/pii/pii_helper.py +225 -0
  439. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  440. aip_agents/utils/reference_helper.py +273 -0
  441. aip_agents/utils/sse_chunk_transformer.py +836 -0
  442. aip_agents/utils/step_limit_manager.py +265 -0
  443. aip_agents/utils/token_usage_helper.py +531 -0
  444. aip_agents_binary-0.0.0.dist-info/METADATA +825 -0
  445. aip_agents_binary-0.0.0.dist-info/RECORD +447 -0
  446. aip_agents_binary-0.0.0.dist-info/WHEEL +5 -0
  447. aip_agents_binary-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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()