legate 0.1.0

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 (317) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +345 -0
  4. data/bin/legate +13 -0
  5. data/examples/00_quickstart.rb +51 -0
  6. data/examples/01_simple_agent.rb +105 -0
  7. data/examples/02_multi_tool_agent.rb +140 -0
  8. data/examples/03_custom_tool.rb +93 -0
  9. data/examples/04_agent_instructions.rb +84 -0
  10. data/examples/05_state_and_sessions.rb +91 -0
  11. data/examples/06_callbacks.rb +186 -0
  12. data/examples/07_async_jobs.rb +112 -0
  13. data/examples/08_loop_agent.rb +197 -0
  14. data/examples/09_sequential_workflow.rb +40 -0
  15. data/examples/10_parallel_workflow.rb +34 -0
  16. data/examples/11_agent_delegation.rb +24 -0
  17. data/examples/12_http_client_tool.rb +156 -0
  18. data/examples/13_authentication.rb +220 -0
  19. data/examples/14_mcp_client.rb +154 -0
  20. data/examples/15_mcp_server.rb +79 -0
  21. data/examples/16_webhooks.rb +91 -0
  22. data/examples/README_sequential_agents.md +164 -0
  23. data/examples/advanced/auth/cookie_auth_tool.rb +146 -0
  24. data/examples/advanced/auth/custom_auth_flows_example.rb +626 -0
  25. data/examples/advanced/auth/excon_middleware.rb +317 -0
  26. data/examples/advanced/auth/excon_middleware_auth.rb +399 -0
  27. data/examples/advanced/auth/fiber_auth_example.rb +281 -0
  28. data/examples/advanced/auth/fiber_oidc_example.rb +403 -0
  29. data/examples/advanced/auth/httpbin_bearer_tool.rb +159 -0
  30. data/examples/advanced/auth/oauth2_auth.rb +419 -0
  31. data/examples/advanced/auth/oidc_auth.rb +514 -0
  32. data/examples/advanced/auth/openweather_api.rb +251 -0
  33. data/examples/advanced/auth/openweather_tool.rb +153 -0
  34. data/examples/advanced/auth/query_param_middleware_test.rb +138 -0
  35. data/examples/advanced/auth/service_account.rb +135 -0
  36. data/examples/advanced/auth/test_with_httpbin.rb +202 -0
  37. data/examples/advanced/auth/token_lifecycle_example.rb +428 -0
  38. data/examples/advanced/callback_monitoring.rb +679 -0
  39. data/examples/advanced/mas/fixed_delegation_example.rb +191 -0
  40. data/examples/advanced/mas/loop_workflow.rb +28 -0
  41. data/examples/advanced/mas/mock_planner.rb +77 -0
  42. data/examples/advanced/mas/proper_delegation_example.rb +276 -0
  43. data/examples/advanced/mcp/legate_mcp_server_resource_example.rb +182 -0
  44. data/examples/advanced/mcp/mcp_resource_server_example.rb +309 -0
  45. data/examples/advanced/mcp/mcp_server_async.rb +76 -0
  46. data/examples/advanced/mcp/mcp_server_async_tools.rb +122 -0
  47. data/examples/advanced/mcp/mcp_server_legate_agent.rb +95 -0
  48. data/examples/advanced/mcp/mcp_server_rack.rb +89 -0
  49. data/examples/advanced/random_calculator.rb +104 -0
  50. data/examples/advanced/sleep_agent.rb +153 -0
  51. data/examples/advanced/webhooks/webhook_e2e_runner.rb +110 -0
  52. data/examples/advanced/webhooks/webhook_receiver_agent.rb +58 -0
  53. data/examples/advanced/workflows/task_refinement_loop_agent.rb +278 -0
  54. data/examples/advanced/workflows/travel_planner_auto_sequential.rb +444 -0
  55. data/examples/advanced/workflows/travel_planner_parallel.rb +656 -0
  56. data/examples/advanced/workflows/travel_planner_sequential.rb +512 -0
  57. data/examples/tools/oauth2_example.rb +136 -0
  58. data/examples/tools/sleepy_tool.rb +42 -0
  59. data/lib/legate/activity_log.rb +71 -0
  60. data/lib/legate/agent.rb +959 -0
  61. data/lib/legate/agent_code_generator.rb +185 -0
  62. data/lib/legate/agent_definition.rb +812 -0
  63. data/lib/legate/agentic/decision.rb +49 -0
  64. data/lib/legate/agentic/loop.rb +134 -0
  65. data/lib/legate/agentic.rb +5 -0
  66. data/lib/legate/agents/loop_agent.rb +248 -0
  67. data/lib/legate/agents/parallel_agent.rb +163 -0
  68. data/lib/legate/agents/sequential_agent.rb +190 -0
  69. data/lib/legate/agents.rb +14 -0
  70. data/lib/legate/auth/config.rb +148 -0
  71. data/lib/legate/auth/coordinator.rb +218 -0
  72. data/lib/legate/auth/coordinators/oauth2_coordinator.rb +99 -0
  73. data/lib/legate/auth/coordinators/oidc_coordinator.rb +68 -0
  74. data/lib/legate/auth/coordinators/service_account_coordinator.rb +122 -0
  75. data/lib/legate/auth/credential.rb +157 -0
  76. data/lib/legate/auth/encryption.rb +108 -0
  77. data/lib/legate/auth/error.rb +94 -0
  78. data/lib/legate/auth/exchanged_credential.rb +180 -0
  79. data/lib/legate/auth/excon_middleware.rb +285 -0
  80. data/lib/legate/auth/http_client_utils.rb +364 -0
  81. data/lib/legate/auth/manager.rb +531 -0
  82. data/lib/legate/auth/manager_store.rb +394 -0
  83. data/lib/legate/auth/middleware_factory.rb +290 -0
  84. data/lib/legate/auth/runner.rb +279 -0
  85. data/lib/legate/auth/scheme.rb +125 -0
  86. data/lib/legate/auth/schemes/api_key.rb +212 -0
  87. data/lib/legate/auth/schemes/google_service_account.rb +108 -0
  88. data/lib/legate/auth/schemes/http_bearer.rb +98 -0
  89. data/lib/legate/auth/schemes/oauth2.rb +396 -0
  90. data/lib/legate/auth/schemes/openid_connect.rb +346 -0
  91. data/lib/legate/auth/schemes/service_account.rb +388 -0
  92. data/lib/legate/auth/schemes.rb +40 -0
  93. data/lib/legate/auth/token_manager.rb +362 -0
  94. data/lib/legate/auth/token_store.rb +86 -0
  95. data/lib/legate/auth/tool_context_extension.rb +97 -0
  96. data/lib/legate/auth/tool_integration.rb +188 -0
  97. data/lib/legate/auth/url_guard.rb +81 -0
  98. data/lib/legate/auth.rb +453 -0
  99. data/lib/legate/callbacks/callback_context.rb +71 -0
  100. data/lib/legate/cli/agent_commands.rb +950 -0
  101. data/lib/legate/cli/auth_commands.rb +520 -0
  102. data/lib/legate/cli/base_command.rb +24 -0
  103. data/lib/legate/cli/deployment_commands.rb +934 -0
  104. data/lib/legate/cli/output_helper.rb +108 -0
  105. data/lib/legate/cli/session_commands.rb +138 -0
  106. data/lib/legate/cli/skaffold_commands.rb +223 -0
  107. data/lib/legate/cli/tool_commands.rb +261 -0
  108. data/lib/legate/cli/web_commands.rb +182 -0
  109. data/lib/legate/cli.rb +40 -0
  110. data/lib/legate/configuration/webhooks.rb +113 -0
  111. data/lib/legate/configuration.rb +39 -0
  112. data/lib/legate/definition_store.rb +23 -0
  113. data/lib/legate/errors.rb +118 -0
  114. data/lib/legate/event.rb +161 -0
  115. data/lib/legate/gemini_ai_beta_patch.rb +39 -0
  116. data/lib/legate/generators/agent_generator.rb +412 -0
  117. data/lib/legate/generators/code_validator.rb +48 -0
  118. data/lib/legate/generators/legate/install_generator.rb +35 -0
  119. data/lib/legate/generators/legate/templates/create_legate_tables.rb.tt +36 -0
  120. data/lib/legate/generators/legate/templates/initializer.rb +18 -0
  121. data/lib/legate/generators/runtime_tool_loader.rb +76 -0
  122. data/lib/legate/generators/tool_generator.rb +408 -0
  123. data/lib/legate/generators.rb +11 -0
  124. data/lib/legate/global_definition_registry.rb +506 -0
  125. data/lib/legate/global_tool_manager.rb +135 -0
  126. data/lib/legate/llm/adapter.rb +69 -0
  127. data/lib/legate/llm/gemini.rb +172 -0
  128. data/lib/legate/llm/ollama.rb +80 -0
  129. data/lib/legate/llm.rb +34 -0
  130. data/lib/legate/mcp/client.rb +320 -0
  131. data/lib/legate/mcp/connection/sse.rb +292 -0
  132. data/lib/legate/mcp/connection/stdio.rb +273 -0
  133. data/lib/legate/mcp/connection_manager.rb +103 -0
  134. data/lib/legate/mcp/server/legate_agent_adapter.rb +170 -0
  135. data/lib/legate/mcp/server/legate_direct_agent_adapter.rb +140 -0
  136. data/lib/legate/mcp/server/legate_tool_adapter.rb +119 -0
  137. data/lib/legate/mcp/tool_wrapper.rb +138 -0
  138. data/lib/legate/mcp/util/schema_converter.rb +134 -0
  139. data/lib/legate/mcp.rb +23 -0
  140. data/lib/legate/plan_executor.rb +375 -0
  141. data/lib/legate/planner.rb +839 -0
  142. data/lib/legate/rails/railtie.rb +43 -0
  143. data/lib/legate/rails.rb +9 -0
  144. data/lib/legate/redaction.rb +32 -0
  145. data/lib/legate/session.rb +299 -0
  146. data/lib/legate/session_service/active_record.rb +300 -0
  147. data/lib/legate/session_service/base.rb +68 -0
  148. data/lib/legate/session_service/event_broadcast.rb +74 -0
  149. data/lib/legate/session_service/in_memory.rb +188 -0
  150. data/lib/legate/tool/metadata_dsl.rb +122 -0
  151. data/lib/legate/tool.rb +276 -0
  152. data/lib/legate/tool_code_generator.rb +103 -0
  153. data/lib/legate/tool_context.rb +350 -0
  154. data/lib/legate/tool_loader.rb +39 -0
  155. data/lib/legate/tool_registry.rb +73 -0
  156. data/lib/legate/tool_result.rb +61 -0
  157. data/lib/legate/tools/agent_tool.rb +187 -0
  158. data/lib/legate/tools/base/http_client.rb +319 -0
  159. data/lib/legate/tools/base/safe_url.rb +56 -0
  160. data/lib/legate/tools/base_async_job_tool.rb +91 -0
  161. data/lib/legate/tools/calculator.rb +89 -0
  162. data/lib/legate/tools/cat_facts.rb +81 -0
  163. data/lib/legate/tools/check_job_status_tool.rb +48 -0
  164. data/lib/legate/tools/current_time_tool.rb +64 -0
  165. data/lib/legate/tools/echo.rb +43 -0
  166. data/lib/legate/tools/http_request_tool.rb +105 -0
  167. data/lib/legate/tools/random_number_tool.rb +64 -0
  168. data/lib/legate/tools/read_webpage_tool.rb +92 -0
  169. data/lib/legate/tools/sleepy_tool.rb +74 -0
  170. data/lib/legate/tools/webhook_tool.rb +146 -0
  171. data/lib/legate/version.rb +5 -0
  172. data/lib/legate/web/app.rb +984 -0
  173. data/lib/legate/web/public/css/main.css +4980 -0
  174. data/lib/legate/web/public/images/favicon-256.png +0 -0
  175. data/lib/legate/web/public/images/favicon-32.png +0 -0
  176. data/lib/legate/web/public/images/legate-logo-dark.png +0 -0
  177. data/lib/legate/web/public/images/legate-logo-light.png +0 -0
  178. data/lib/legate/web/public/js/legate.js +616 -0
  179. data/lib/legate/web/public/styles/main.scss +4402 -0
  180. data/lib/legate/web/routes/agent_authentication_routes.rb +530 -0
  181. data/lib/legate/web/routes/agent_definition_routes.rb +803 -0
  182. data/lib/legate/web/routes/agent_generator_routes.rb +80 -0
  183. data/lib/legate/web/routes/agent_interaction_routes.rb +734 -0
  184. data/lib/legate/web/routes/agent_runtime_routes.rb +323 -0
  185. data/lib/legate/web/routes/api_routes.rb +56 -0
  186. data/lib/legate/web/routes/authentication_routes.rb +1541 -0
  187. data/lib/legate/web/routes/core_routes.rb +111 -0
  188. data/lib/legate/web/routes/documentation_routes.rb +220 -0
  189. data/lib/legate/web/routes/tool_generator_routes.rb +81 -0
  190. data/lib/legate/web/routes/tools_ui_routes.rb +207 -0
  191. data/lib/legate/web/sass_compiler.rb +73 -0
  192. data/lib/legate/web/views/_active_session_info.slim +25 -0
  193. data/lib/legate/web/views/_activity_list.slim +55 -0
  194. data/lib/legate/web/views/_agent_card.slim +56 -0
  195. data/lib/legate/web/views/_agent_generator_modal.slim +382 -0
  196. data/lib/legate/web/views/_agent_status_controls.slim +71 -0
  197. data/lib/legate/web/views/_agent_tool_table.slim +74 -0
  198. data/lib/legate/web/views/_chat_message.slim +95 -0
  199. data/lib/legate/web/views/_display_agent_configuration.slim +26 -0
  200. data/lib/legate/web/views/_display_agent_description.slim +11 -0
  201. data/lib/legate/web/views/_display_agent_fallback.slim +15 -0
  202. data/lib/legate/web/views/_display_agent_hierarchy.slim +93 -0
  203. data/lib/legate/web/views/_display_agent_instruction.slim +17 -0
  204. data/lib/legate/web/views/_display_agent_mcp.slim +13 -0
  205. data/lib/legate/web/views/_display_agent_model.slim +17 -0
  206. data/lib/legate/web/views/_display_agent_name.slim +42 -0
  207. data/lib/legate/web/views/_display_agent_output_key.slim +26 -0
  208. data/lib/legate/web/views/_display_agent_type.slim +65 -0
  209. data/lib/legate/web/views/_edit_agent_configuration.slim +74 -0
  210. data/lib/legate/web/views/_edit_agent_description.slim +16 -0
  211. data/lib/legate/web/views/_edit_agent_fallback.slim +25 -0
  212. data/lib/legate/web/views/_edit_agent_hierarchy.slim +98 -0
  213. data/lib/legate/web/views/_edit_agent_instruction.slim +49 -0
  214. data/lib/legate/web/views/_edit_agent_mcp.slim +33 -0
  215. data/lib/legate/web/views/_edit_agent_model.slim +23 -0
  216. data/lib/legate/web/views/_edit_agent_output_key.slim +36 -0
  217. data/lib/legate/web/views/_edit_agent_tools.slim +40 -0
  218. data/lib/legate/web/views/_edit_agent_type.slim +67 -0
  219. data/lib/legate/web/views/_session_error.slim +4 -0
  220. data/lib/legate/web/views/_skeleton.slim +69 -0
  221. data/lib/legate/web/views/_tool_card.slim +9 -0
  222. data/lib/legate/web/views/_tool_generator_modal.slim +311 -0
  223. data/lib/legate/web/views/agent.slim +436 -0
  224. data/lib/legate/web/views/agent_auth.slim +562 -0
  225. data/lib/legate/web/views/agents.slim +369 -0
  226. data/lib/legate/web/views/auth.slim +112 -0
  227. data/lib/legate/web/views/auth_credential_detail.slim +327 -0
  228. data/lib/legate/web/views/auth_credentials.slim +261 -0
  229. data/lib/legate/web/views/auth_debug.slim +94 -0
  230. data/lib/legate/web/views/auth_mapping_detail.slim +151 -0
  231. data/lib/legate/web/views/auth_mapping_new.slim +123 -0
  232. data/lib/legate/web/views/auth_mappings.slim +120 -0
  233. data/lib/legate/web/views/auth_scheme_detail.slim +274 -0
  234. data/lib/legate/web/views/auth_schemes.slim +259 -0
  235. data/lib/legate/web/views/auth_test.slim +418 -0
  236. data/lib/legate/web/views/chat.slim +192 -0
  237. data/lib/legate/web/views/docs_index.slim +105 -0
  238. data/lib/legate/web/views/docs_show.slim +105 -0
  239. data/lib/legate/web/views/error_404.slim +5 -0
  240. data/lib/legate/web/views/index.slim +148 -0
  241. data/lib/legate/web/views/layout.slim +144 -0
  242. data/lib/legate/web/views/tool_detail.slim +87 -0
  243. data/lib/legate/web/views/tools.slim +50 -0
  244. data/lib/legate/web/webhook_listener.rb +367 -0
  245. data/lib/legate/web.rb +9 -0
  246. data/lib/legate.rb +220 -0
  247. data/public/docs/advanced/callbacks.md +828 -0
  248. data/public/docs/advanced/mcp_schema_conversion.md +59 -0
  249. data/public/docs/authentication/api_reference/config.md +210 -0
  250. data/public/docs/authentication/api_reference/credential.md +246 -0
  251. data/public/docs/authentication/api_reference/encryption.md +218 -0
  252. data/public/docs/authentication/api_reference/exchanged_credential.md +271 -0
  253. data/public/docs/authentication/api_reference/excon_middleware.md +175 -0
  254. data/public/docs/authentication/api_reference/index.md +30 -0
  255. data/public/docs/authentication/api_reference/scheme.md +250 -0
  256. data/public/docs/authentication/api_reference/schemes/api_key.md +175 -0
  257. data/public/docs/authentication/api_reference/schemes/google_service_account.md +221 -0
  258. data/public/docs/authentication/api_reference/schemes/http_bearer.md +169 -0
  259. data/public/docs/authentication/api_reference/schemes/oauth2.md +343 -0
  260. data/public/docs/authentication/api_reference/schemes/oidc.md +73 -0
  261. data/public/docs/authentication/api_reference/schemes/openid_connect.md +311 -0
  262. data/public/docs/authentication/api_reference/schemes/service_account.md +287 -0
  263. data/public/docs/authentication/api_reference/token_manager.md +221 -0
  264. data/public/docs/authentication/api_reference/token_store.md +146 -0
  265. data/public/docs/authentication/api_reference/tool_context_extension.md +166 -0
  266. data/public/docs/authentication/guides/api_key.md +190 -0
  267. data/public/docs/authentication/guides/bearer.md +172 -0
  268. data/public/docs/authentication/guides/configuration.md +255 -0
  269. data/public/docs/authentication/guides/custom_flow.md +523 -0
  270. data/public/docs/authentication/guides/index.md +24 -0
  271. data/public/docs/authentication/guides/migration.md +435 -0
  272. data/public/docs/authentication/guides/oauth2.md +252 -0
  273. data/public/docs/authentication/guides/oidc.md +241 -0
  274. data/public/docs/authentication/guides/overview.md +155 -0
  275. data/public/docs/authentication/guides/secure_storage.md +301 -0
  276. data/public/docs/authentication/guides/service_account.md +228 -0
  277. data/public/docs/authentication/guides/token_lifecycle.md +295 -0
  278. data/public/docs/authentication/guides/web_ui_integration.md +504 -0
  279. data/public/docs/authentication/index.md +58 -0
  280. data/public/docs/authentication/troubleshooting/credential_storage.md +550 -0
  281. data/public/docs/authentication/troubleshooting/environment_variables.md +540 -0
  282. data/public/docs/authentication/troubleshooting/index.md +11 -0
  283. data/public/docs/authentication/troubleshooting/oauth2_issues.md +220 -0
  284. data/public/docs/authentication/troubleshooting/oidc_issues.md +412 -0
  285. data/public/docs/authentication/troubleshooting/token_refresh.md +338 -0
  286. data/public/docs/cli/legate_cli_usage.md +363 -0
  287. data/public/docs/core_concepts/legate_agent_lifecycle.md +124 -0
  288. data/public/docs/core_concepts/legate_architecture_overview.md +110 -0
  289. data/public/docs/core_concepts/legate_configuration.md +116 -0
  290. data/public/docs/core_concepts/legate_definition_store.md +102 -0
  291. data/public/docs/core_concepts/legate_planner.md +94 -0
  292. data/public/docs/core_concepts/legate_session_service.md +104 -0
  293. data/public/docs/error_handling/legate_error_handling.md +122 -0
  294. data/public/docs/examples.md +199 -0
  295. data/public/docs/getting_started.md +111 -0
  296. data/public/docs/guides/agentic_agents.md +137 -0
  297. data/public/docs/guides/ai_code_generators.md +437 -0
  298. data/public/docs/guides/auto_loading.md +326 -0
  299. data/public/docs/guides/configuring_agent_webhooks.md +219 -0
  300. data/public/docs/guides/http_client_usage.md +264 -0
  301. data/public/docs/guides/llm_providers.md +137 -0
  302. data/public/docs/guides/mcp_client_integration.md +232 -0
  303. data/public/docs/guides/mcp_server_exposure.md +206 -0
  304. data/public/docs/guides/rails_integration.md +128 -0
  305. data/public/docs/guides/sending_outbound_webhooks.md +227 -0
  306. data/public/docs/guides/streaming.md +112 -0
  307. data/public/docs/guides/webhooks.md +288 -0
  308. data/public/docs/introduction.md +51 -0
  309. data/public/docs/multi_agent_systems/advanced_features.md +57 -0
  310. data/public/docs/multi_agent_systems/agent_delegation.md +190 -0
  311. data/public/docs/multi_agent_systems/agent_hierarchy.md +49 -0
  312. data/public/docs/multi_agent_systems/state_management.md +47 -0
  313. data/public/docs/multi_agent_systems/workflow_agents.md +72 -0
  314. data/public/docs/tools/legate_built_in_tools.md +332 -0
  315. data/public/docs/tools/legate_tools_and_registry.md +263 -0
  316. data/public/docs/web_ui/legate_web_ui.md +137 -0
  317. metadata +823 -0
@@ -0,0 +1,187 @@
1
+ # File: lib/legate/tools/agent_tool.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../tool'
5
+ require_relative '../agent'
6
+ # ToolRegistry is NOT needed directly by AgentTool for loading target tools
7
+ # require_relative '../tool_registry'
8
+ require_relative '../session'
9
+ require_relative '../session_service/in_memory'
10
+ require 'json'
11
+ require 'securerandom'
12
+ require_relative '../global_definition_registry'
13
+ require_relative '../global_tool_manager' # Make sure this is required
14
+
15
+ module Legate
16
+ module Tools
17
+ class AgentTool < Legate::Tool
18
+ self.explicit_tool_name = :delegate_task
19
+
20
+ tool_description 'Delegates a specified task to another agent identified by its unique name. Use this when a specific agent is better suited for the sub-task.'
21
+
22
+ parameter :target_agent_name,
23
+ type: :string,
24
+ description: 'The unique name of the agent definition to delegate the task to.',
25
+ required: true
26
+
27
+ parameter :task,
28
+ type: :string,
29
+ description: 'The specific task description to be executed by the target agent.',
30
+ required: true
31
+
32
+ parameter :use_calling_session,
33
+ type: :boolean,
34
+ description: 'If true, the target agent executes within the same session context as the caller. If false, a new isolated session is created. Defaults to false.',
35
+ required: false
36
+
37
+ private
38
+
39
+ def perform_execution(params, context) # context is the ToolContext of the calling agent
40
+ # Validate that the context has a valid tool registry
41
+ unless context && context.respond_to?(:tool_registry) && context.tool_registry
42
+ msg = 'Tool registry not found or invalid in the provided context'
43
+ Legate.logger.error("AgentTool: #{msg}")
44
+ raise Legate::ToolError, msg
45
+ end
46
+
47
+ target_agent_name_str = params.fetch(:target_agent_name) do
48
+ raise Legate::ToolArgumentError, 'Missing required parameter: target_agent_name'
49
+ end.to_s # Ensure it's a string for store lookup
50
+
51
+ task_to_delegate = params.fetch(:task) { raise Legate::ToolArgumentError, 'Missing required parameter: task' }
52
+ use_calling_session = params.fetch(:use_calling_session, false)
53
+
54
+ Legate.logger.info("AgentTool: Attempting to delegate task '#{task_to_delegate}' to agent '#{target_agent_name_str}' (Session reuse: #{use_calling_session})")
55
+
56
+ # Load definition from the GlobalDefinitionRegistry
57
+ agent_definition_object = Legate::GlobalDefinitionRegistry.find(target_agent_name_str.to_sym)
58
+
59
+ # If not found as AgentDefinition, try getting as hash
60
+ definition_hash = (Legate::GlobalDefinitionRegistry.get_definition(target_agent_name_str.to_sym) if agent_definition_object)
61
+
62
+ unless definition_hash
63
+ msg = "Target agent definition '#{target_agent_name_str}' could not be loaded from registry."
64
+ Legate.logger.error("AgentTool: #{msg}")
65
+ raise Legate::ToolArgumentError, msg
66
+ end
67
+
68
+ # Ensure we have essential fields for a valid definition
69
+ definition_hash = definition_hash.transform_keys(&:to_sym) if definition_hash.respond_to?(:transform_keys)
70
+
71
+ # Ensure the definition has all required fields
72
+ definition_hash[:name] = target_agent_name_str.to_sym unless definition_hash.key?(:name)
73
+ definition_hash[:description] = definition_hash[:description] || "Delegated agent #{target_agent_name_str}"
74
+ definition_hash[:instruction] = definition_hash[:instruction] || "Perform the delegated task: #{task_to_delegate}"
75
+
76
+ # Handle 'tools' field: parse if JSON string, convert to array of symbols
77
+ if definition_hash.key?(:tools)
78
+ tool_array = if definition_hash[:tools].is_a?(String)
79
+ begin
80
+ parsed = JSON.parse(definition_hash[:tools])
81
+ parsed.is_a?(Array) ? parsed : []
82
+ rescue JSON::ParserError
83
+ Legate.logger.warn("AgentTool: Could not parse :tools JSON for agent '#{target_agent_name_str}'. Defaulting to empty tools array.")
84
+ []
85
+ end
86
+ else
87
+ Array(definition_hash[:tools])
88
+ end
89
+
90
+ # Convert to symbols for the definition
91
+ definition_hash[:tools] = tool_array.map(&:to_sym)
92
+ elsif !definition_hash.key?(:tool_names) # Ensure some form of tools field exists
93
+ definition_hash[:tools] = []
94
+ end
95
+
96
+ # Handle 'mcp_servers_json' field: if present and no mcp_servers, rename
97
+ definition_hash[:mcp_servers] = definition_hash.delete(:mcp_servers_json) if definition_hash.key?(:mcp_servers_json) && !definition_hash.key?(:mcp_servers)
98
+
99
+ # Ensure fallback_mode is symbolized
100
+ definition_hash[:fallback_mode] = definition_hash[:fallback_mode].to_sym if definition_hash[:fallback_mode].is_a?(String)
101
+
102
+ # Convert hash to an Legate::AgentDefinition object
103
+ target_definition_object = Legate::AgentDefinition.from_hash(definition_hash)
104
+
105
+ unless target_definition_object
106
+ msg = "Failed to create a valid AgentDefinition object for target '#{target_agent_name_str}' from loaded hash."
107
+ Legate.logger.error("AgentTool: #{msg} Hash was: #{definition_hash.inspect}")
108
+ raise Legate::ToolError, msg # More generic error as it's post-load
109
+ end
110
+
111
+ Legate.logger.debug("AgentTool: Instantiating target agent '#{target_definition_object.name}' using its definition object.")
112
+
113
+ # Determine session service and session ID to use
114
+ delegate_session_service = nil
115
+ delegate_session_id = nil
116
+
117
+ if use_calling_session
118
+ if context.session_service
119
+ delegate_session_service = context.session_service
120
+ delegate_session_id = context.session_id
121
+ Legate.logger.debug("AgentTool: Reusing session service and session ID '#{delegate_session_id}' from caller.")
122
+ else
123
+ Legate.logger.warn('AgentTool: use_calling_session is true but context has no session_service. Falling back to new isolated session.')
124
+ end
125
+ end
126
+
127
+ # Fallback if reuse failed or not requested
128
+ unless delegate_session_service
129
+ delegate_session_service = Legate::SessionService::InMemory.new
130
+ # Create a new session
131
+ new_session = delegate_session_service.create_session(
132
+ app_name: target_definition_object.name.to_s,
133
+ user_id: "delegation_#{SecureRandom.hex(4)}"
134
+ )
135
+ delegate_session_id = new_session.id
136
+ Legate.logger.debug("AgentTool: Created new isolated session '#{delegate_session_id}' for target agent.")
137
+ end
138
+
139
+ # Create the ephemeral agent using its definition object
140
+ target_agent = Legate::Agent.new(
141
+ definition: target_definition_object,
142
+ session_service: delegate_session_service
143
+ )
144
+
145
+ # Check if tools are configured for this agent
146
+ Legate.logger.warn("AgentTool: Target agent '#{target_agent_name_str}' has no tools configured.") if target_definition_object.tool_names.empty?
147
+
148
+ # Register tools - get the class objects for each tool name and register with the agent
149
+ tool_names = Array(definition_hash[:tools] || definition_hash[:tool_names] || [])
150
+ tool_names.each do |tool_name|
151
+ tool_class = Legate::GlobalToolManager.find_class(tool_name.to_sym)
152
+ if tool_class
153
+ target_agent.register_tool_class(tool_class)
154
+ Legate.logger.debug("AgentTool: Registered tool '#{tool_name}' with target agent.")
155
+ else
156
+ Legate.logger.warn("AgentTool: Could not find tool class for '#{tool_name}' in GlobalToolManager.")
157
+ end
158
+ end
159
+
160
+ target_agent.start
161
+ Legate.logger.info("AgentTool: Running task '#{task_to_delegate}' on target agent '#{target_agent.name}' (Session: #{delegate_session_id})")
162
+
163
+ agent_event = target_agent.run_task(
164
+ session_id: delegate_session_id,
165
+ user_input: task_to_delegate,
166
+ session_service: delegate_session_service # Pass the correct service
167
+ )
168
+
169
+ target_result = agent_event.respond_to?(:content) ? agent_event.content : agent_event
170
+ Legate.logger.info("AgentTool: Target agent '#{target_agent.name}' finished task. Result: #{target_result.inspect}")
171
+
172
+ { status: :success, result: target_result }
173
+ rescue Legate::ToolArgumentError => e
174
+ Legate.logger.error("AgentTool ArgumentError: #{e.message}")
175
+ raise e
176
+ rescue Legate::ToolError => e
177
+ Legate.logger.error("AgentTool ToolError: #{e.message}")
178
+ raise e
179
+ rescue StandardError => e
180
+ msg = "AgentTool: Unexpected error during delegation to '#{target_agent_name_str}': #{e.class} - #{e.message}"
181
+ Legate.logger.error(msg)
182
+ Legate.logger.error(e.backtrace.first(5).join("\n"))
183
+ raise Legate::ToolError, msg
184
+ end # end perform_execution
185
+ end # End AgentTool class
186
+ end # End Tools module
187
+ end # End Legate module
@@ -0,0 +1,319 @@
1
+ # File: lib/legate/tools/base/http_client.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'excon'
5
+ require 'json'
6
+ require 'uri' # For URI.join
7
+
8
+ # Tool errors are defined in legate/errors.rb (loaded by lib/legate.rb)
9
+ require_relative '../../version'
10
+
11
+ module Legate
12
+ module Tools
13
+ module Base
14
+ # Mixin module providing standardized, reusable HTTP client capabilities
15
+ # for Legate tools, built upon the Excon gem.
16
+ #
17
+ # Include this module in your Tool class and call `setup_http_client`
18
+ # in your `initialize` method.
19
+ #
20
+ # It offers helper methods (http_get, http_head, http_post, etc.) for common requests,
21
+ # handles base URL joining, JSON encoding/decoding (optional), logging,
22
+ # and wraps Excon errors into standardized Legate::ToolError subclasses.
23
+ module HttpClient
24
+ # Custom instrumentor that only logs errors
25
+ class QuietInstrumentor < Excon::StandardInstrumentor
26
+ def instrument(name, params = {})
27
+ # Only log if there's an error
28
+ Legate.logger.error("[#{name}] #{params[:error]}") if params[:error]
29
+ yield if block_given?
30
+ end
31
+ end
32
+
33
+ attr_reader :http_client, :http_base_url
34
+
35
+ # Make request helpers public API for tools including this module
36
+
37
+ def http_get(path, query: {}, headers: {}, options: {})
38
+ make_request(:get, path, query: query, headers: headers, options: options)
39
+ end
40
+
41
+ def http_head(path, query: {}, headers: {}, options: {})
42
+ make_request(:head, path, query: query, headers: headers, options: options)
43
+ end
44
+
45
+ def http_post(path, body: nil, query: {}, headers: {}, options: {})
46
+ make_request(:post, path, body: body, query: query, headers: headers, options: options)
47
+ end
48
+
49
+ def http_put(path, body: nil, query: {}, headers: {}, options: {})
50
+ make_request(:put, path, body: body, query: query, headers: headers, options: options)
51
+ end
52
+
53
+ def http_delete(path, query: {}, headers: {}, options: {})
54
+ make_request(:delete, path, query: query, headers: headers, options: options)
55
+ end
56
+
57
+ protected
58
+
59
+ # Initializes the Excon HTTP client instance.
60
+ # Should be called by the including tool, typically in its `initialize` method.
61
+ # Stores the connection instance in `@http_client` and base URL in `@http_base_url`.
62
+ #
63
+ # @param base_url [String] The base URL for the API. Must be a valid URI string.
64
+ # @param headers [Hash] Default headers to include in every request (merged with defaults).
65
+ # @param options [Hash] Options passed directly to `Excon.new`. See Excon documentation for available options
66
+ # (e.g., :read_timeout, :write_timeout, :connect_timeout, :persistent, :proxy, :ssl_verify_peer).
67
+ # Allows overriding the default instrumentor via `:instrumentor` key.
68
+ # @return [void]
69
+ # @raise [Legate::ToolError] If the base_url is invalid or Excon initialization fails.
70
+ def setup_http_client(base_url:, headers: {}, options: {})
71
+ # Revert: base_url is required again
72
+ begin
73
+ @http_base_url = URI.parse(base_url.to_s)
74
+ raise URI::InvalidURIError, 'Scheme must be http or https' unless @http_base_url.is_a?(URI::HTTP) || @http_base_url.is_a?(URI::HTTPS)
75
+ rescue URI::InvalidURIError => e
76
+ raise Legate::ToolError.new("Invalid base_url provided: #{base_url} - #{e.message}", cause: e)
77
+ end
78
+
79
+ default_user_agent = "Legate-Ruby/#{Legate::VERSION} #{Excon::USER_AGENT}"
80
+ default_headers = { 'User-Agent' => default_user_agent }
81
+ merged_headers = default_headers.merge(headers)
82
+ default_options = { persistent: true, connect_timeout: 5, read_timeout: 15, write_timeout: 15,
83
+ instrumentor: QuietInstrumentor.new }
84
+ final_options = default_options.merge(options)
85
+ final_options[:headers] = merged_headers unless merged_headers.empty?
86
+
87
+ # Store connection options for potential use in make_request for absolute URLs
88
+ @http_connection_options = final_options.dup
89
+
90
+ begin
91
+ log_options_for_debug = final_options.dup
92
+ log_options_for_debug[:headers] = '[REDACTED]' unless Legate.logger.level == Logger::DEBUG
93
+ Legate.logger.debug("Setting up Excon client for #{self.class.name} with base URL: #{@http_base_url}")
94
+ Legate.logger.debug("Excon options: #{log_options_for_debug}")
95
+
96
+ # Create connection using the (mandatory) base URL
97
+ @http_client = Excon.new(@http_base_url.to_s, final_options)
98
+
99
+ # Store default *request* options and headers separately
100
+ @http_default_request_options = final_options.reject { |k, _|
101
+ %i[headers instrumentor instrumentor_params].include?(k)
102
+ }
103
+ @http_default_headers = merged_headers
104
+ rescue Excon::Error::Socket => e
105
+ err_msg = "Failed to initialize Excon connection for #{self.class.name} to #{@http_base_url}: #{e.message}"
106
+ Legate.logger.error(err_msg)
107
+ @http_client = nil
108
+ raise Legate::ToolNetworkError.new(err_msg, cause: e)
109
+ rescue StandardError => e
110
+ err_msg = "Unexpected error initializing Excon for #{self.class.name}: #{e.class} - #{e.message}"
111
+ Legate.logger.error(err_msg)
112
+ @http_client = nil
113
+ raise Legate::ToolError.new(err_msg, cause: e)
114
+ end
115
+ end
116
+
117
+ # TODO: Add any private helper methods if needed
118
+
119
+ private
120
+
121
+ def resolve_target_uri(path)
122
+ parsed_path = URI.parse(path.to_s)
123
+ if parsed_path.is_a?(URI::HTTP) || parsed_path.is_a?(URI::HTTPS)
124
+ [parsed_path, true]
125
+ else
126
+ [URI.join(@http_base_url, path), false]
127
+ end
128
+ rescue URI::InvalidURIError => e
129
+ raise Legate::ToolError.new("Invalid URL or path provided: #{path} - #{e.message}", cause: e)
130
+ end
131
+
132
+ # Centralized method for making HTTP requests and handling common errors/wrapping.
133
+ def make_request(method, path, body: nil, query: {}, headers: {}, options: {})
134
+ # Extract SSRF protection options before merging into Excon params
135
+ resolved_ip = options.delete(:resolved_ip)
136
+ original_host = options.delete(:original_host)
137
+
138
+ # Ensure setup was called, but @http_client might not be used if path is absolute
139
+ unless @http_connection_options
140
+ raise Legate::ToolError,
141
+ 'HTTP client options not initialized. Call setup_http_client first.'
142
+ end
143
+ raise Legate::ToolError, 'Base URL not set properly during setup.' unless @http_base_url
144
+
145
+ request_params = @http_default_request_options.merge(options)
146
+ request_params[:method] = method
147
+
148
+ target_uri, is_absolute = resolve_target_uri(path)
149
+
150
+ # Path/Query setup differs slightly for absolute vs relative
151
+ if is_absolute
152
+ # For absolute URLs, the full path/query is part of target_uri
153
+ # We don't need to set host/scheme/port in request_params
154
+ # as Excon.new will use the full target_uri.to_s
155
+ else
156
+ # For relative URLs, use the persistent client and set path/query
157
+ end
158
+ request_params[:path] = target_uri.request_uri
159
+
160
+ # Merge explicit query params with any existing in the URI
161
+ uri_query = URI.decode_www_form(target_uri.query || '').to_h
162
+ final_query = uri_query.merge(query)
163
+ request_params[:query] = final_query unless final_query.empty?
164
+ # Update path if query was added/changed (remove original query part if exists)
165
+ request_params[:path] = target_uri.path
166
+
167
+ # 3. Merge Headers
168
+ request_params[:headers] = @http_default_headers.merge(headers)
169
+
170
+ # Determine if Content-Type was explicitly passed
171
+ custom_content_type_provided = headers.keys.any? { |k| k.to_s.casecmp('Content-Type').zero? }
172
+ content_type_key = request_params[:headers].keys.find { |k|
173
+ k.to_s.casecmp('Content-Type').zero?
174
+ } || 'Content-Type'
175
+
176
+ # 4. Handle Request Body and Content-Type logic
177
+ if body.is_a?(Hash) && %i[post put patch].include?(method)
178
+ # Only default to application/json if Content-Type was not explicitly provided
179
+ request_params[:headers][content_type_key] = 'application/json; charset=utf-8' unless custom_content_type_provided
180
+ # Get the final effective content type for encoding check
181
+ final_content_type = request_params[:headers].find { |k, _| k.to_s.casecmp('Content-Type').zero? }&.last
182
+
183
+ if final_content_type&.start_with?('application/json')
184
+ # ... JSON encode body ...
185
+ begin
186
+ request_params[:body] = JSON.generate(body)
187
+ rescue JSON::GeneratorError => e
188
+ # raise Legate::ToolError, "Failed to encode request body as JSON: #{e.message}" # No cause
189
+ # Add cause for better debugging
190
+ raise Legate::ToolError.new("Failed to encode request body as JSON: #{e.message}", cause: e)
191
+ end
192
+ else
193
+ # ... Handle Hash body with non-JSON CT ...
194
+ Legate.logger.warn "Sending Hash body with non-JSON Content-Type (#{final_content_type}) for #{target_uri}"
195
+ request_params[:body] = body
196
+ end
197
+ elsif body # Body is not a Hash (likely a String)
198
+ request_params[:body] = body
199
+ # If body is string AND Content-Type wasn't explicitly passed, remove the default one.
200
+ unless custom_content_type_provided
201
+ key_to_delete = request_params[:headers].keys.find { |k| k.to_s.casecmp('Content-Type').zero? }
202
+ request_params[:headers].delete(key_to_delete) if key_to_delete
203
+ end
204
+ end
205
+
206
+ # 5. Execute Request: Choose client based on absolute vs relative path
207
+ Legate.logger.info "Executing HTTP #{method.to_s.upcase} request to #{target_uri}"
208
+
209
+ response = nil
210
+ if is_absolute
211
+ Legate.logger.debug "Using temporary Excon client for absolute URL: #{target_uri}"
212
+
213
+ # Prepare options for the temporary Excon client instance
214
+ temp_client_options = @http_connection_options.reject { |k, _| k == :headers }
215
+ # Deep duplicate headers hash to avoid modifying the original
216
+ final_headers_for_new = Marshal.load(Marshal.dump(@http_connection_options[:headers] || {}))
217
+ # Merge the fully processed request_params[:headers] (which includes defaults and customs)
218
+ final_headers_for_new.merge!(request_params[:headers].transform_keys(&:to_s))
219
+
220
+ # DNS pinning: connect to pre-resolved IP to prevent DNS rebinding (TOCTOU)
221
+ connect_uri = target_uri
222
+ if resolved_ip
223
+ connect_uri = target_uri.dup
224
+ # #hostname= brackets IPv6 literals so the URI stays valid.
225
+ connect_uri.hostname = resolved_ip
226
+ host_for_tls = original_host || target_uri.host
227
+ final_headers_for_new['Host'] ||= host_for_tls
228
+ if target_uri.scheme == 'https'
229
+ # Use the real hostname for SNI and certificate verification even
230
+ # though we connect to the pinned IP (otherwise the cert check
231
+ # compares against the IP and every HTTPS request fails).
232
+ temp_client_options[:hostname] = host_for_tls
233
+ temp_client_options[:ssl_verify_peer_host] = host_for_tls
234
+ end
235
+ end
236
+
237
+ temp_client_options[:headers] = final_headers_for_new
238
+
239
+ temp_client = Excon.new(connect_uri.to_s, temp_client_options)
240
+
241
+ # Prepare the params for the .request call (method, body, query, etc., NO headers)
242
+ request_params_for_absolute = request_params.reject { |k, _| k == :headers }
243
+
244
+ Legate.logger.debug "Excon Temp Request Params (for .request call): #{request_params_for_absolute.inspect}"
245
+ Legate.logger.debug "Excon Temp Client Options (for .new call): #{temp_client_options.inspect}"
246
+ response = temp_client.request(request_params_for_absolute)
247
+ else
248
+ Legate.logger.debug "Using persistent Excon client for relative path: #{target_uri}"
249
+ # Use the persistent client setup with the base URL
250
+ raise Legate::ToolError, 'Persistent HTTP client not initialized.' unless @http_client
251
+
252
+ Legate.logger.debug "Excon Persistent Request Params: #{request_params.inspect}"
253
+ response = @http_client.request(request_params)
254
+ end
255
+
256
+ Legate.logger.info "Received HTTP response: Status #{response.status}"
257
+ Legate.logger.debug "Response Body: #{response.body[0..500]}..."
258
+
259
+ unless (200..299).cover?(response.status)
260
+ err_msg = "HTTP Error: Received status #{response.status} for #{method.to_s.upcase} #{target_uri}"
261
+ Legate.logger.error(err_msg)
262
+ raise Excon::Error::HTTPStatus.new(err_msg, nil, response)
263
+ end
264
+
265
+ response
266
+
267
+ # Step 7: Error Wrapping Logic
268
+ rescue Excon::Error::Timeout => e
269
+ err_msg = "Timeout during #{method.to_s.upcase} request to #{target_uri || path}: #{e.message}"
270
+ Legate.logger.error(err_msg)
271
+ raise Legate::ToolTimeoutError.new(err_msg, cause: e)
272
+ rescue Excon::Error::Socket => e
273
+ err_msg = "Network/Socket error during #{method.to_s.upcase} request to #{target_uri || path}: #{e.message}"
274
+ Legate.logger.error(err_msg)
275
+ raise Legate::ToolNetworkError.new(err_msg, cause: e)
276
+ rescue Excon::Error::Certificate => e
277
+ err_msg = "SSL Certificate error during #{method.to_s.upcase} request to #{target_uri || path}: #{e.message}"
278
+ Legate.logger.error(err_msg)
279
+ raise Legate::ToolCertificateError.new(err_msg, cause: e)
280
+ rescue Excon::Error::HTTPStatus => e
281
+ status = e.response&.status || 'N/A'
282
+ body_preview = e.response&.body&.slice(0, 500)
283
+ err_msg = "HTTP Error: Received status #{status} for #{method.to_s.upcase} #{target_uri || path}"
284
+ Legate.logger.error("#{err_msg} - Response Body: #{body_preview}...")
285
+ raise Legate::ToolHttpError.new(err_msg, response: e.response, cause: e)
286
+ rescue Excon::Error => e
287
+ status = e.respond_to?(:response) && e.response ? e.response.status : 'N/A'
288
+ err_msg = "Excon error during #{method.to_s.upcase} request to #{target_uri || path} (Status: #{status}): #{e.class} - #{e.message}"
289
+ Legate.logger.error(err_msg)
290
+ raise Legate::ToolError.new(err_msg, cause: e)
291
+ # Catch Legate::ToolError explicitly first to prevent re-wrapping
292
+ rescue Legate::ToolError => e
293
+ raise e
294
+ # Catch StandardError last, covering errors during setup (like URI.join, JSON.generate if not caught above)
295
+ rescue StandardError => e
296
+ # Avoid re-wrapping Legate::ToolErrors that might bubble up (e.g., from URI.join failure)
297
+ # raise if e.is_a?(Legate::ToolError) # Handled by the rescue above now
298
+
299
+ # Make error message generation safer but include original message
300
+ error_class_name = begin
301
+ e.class.name
302
+ rescue StandardError
303
+ 'UnknownError'
304
+ end
305
+ error_message = begin
306
+ e.message
307
+ rescue StandardError
308
+ 'No message available'
309
+ end
310
+ err_msg = "Unexpected error during #{method.to_s.upcase} request logic: #{error_class_name} - #{error_message}"
311
+ Legate.logger.error(err_msg)
312
+ # Safely log backtrace if available - REMOVED as it might cause issues with already wrapped Legate::ToolErrors
313
+ # Raise without cause for StandardError as it can cause issues
314
+ raise Legate::ToolError, err_msg
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,56 @@
1
+ # File: lib/legate/tools/base/safe_url.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+ require 'ipaddr'
6
+ require_relative '../../auth/url_guard'
7
+ require_relative '../../errors'
8
+
9
+ module Legate
10
+ module Tools
11
+ module Base
12
+ # SSRF guard for outbound tool requests.
13
+ #
14
+ # Validates a URL and returns the IP to pin the connection to (defeating
15
+ # DNS-rebinding TOCTOU). It reuses the canonical {Legate::Auth::UrlGuard}
16
+ # block-list so tools and the auth layer can never drift out of sync, and
17
+ # raises a tool-appropriate {Legate::ToolArgumentError} on a bad target.
18
+ #
19
+ # Set LEGATE_ALLOW_PRIVATE_TOOL_URLS=1 to reach private/loopback hosts in
20
+ # development (returns no pin so the request connects directly).
21
+ module SafeUrl
22
+ module_function
23
+
24
+ # @param url [String] the target URL
25
+ # @return [Array(URI, String|nil)] the parsed URI and the IP to pin to
26
+ # (nil when the dev bypass is active)
27
+ # @raise [Legate::ToolArgumentError] if the URL is not http(s), cannot be
28
+ # resolved, or resolves to a restricted (loopback / private / link-local
29
+ # / CGNAT / 0.0.0.0-8) address
30
+ def resolve!(url)
31
+ uri = URI.parse(url.to_s)
32
+ raise Legate::ToolArgumentError, "URL must use http or https: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
33
+
34
+ return [uri, nil] if ENV['LEGATE_ALLOW_PRIVATE_TOOL_URLS']
35
+
36
+ ips = Legate::Auth::UrlGuard.resolved_ips(uri.host)
37
+ raise Legate::ToolArgumentError, "Could not resolve host: #{uri.host}" if ips.empty?
38
+
39
+ ips.each do |ip_str|
40
+ ip = IPAddr.new(ip_str)
41
+ next unless Legate::Auth::UrlGuard.restricted?(ip)
42
+
43
+ raise Legate::ToolArgumentError,
44
+ "Blocked request to restricted network address (#{uri.host} -> #{ip_str})"
45
+ rescue IPAddr::InvalidAddressError
46
+ raise Legate::ToolArgumentError, "Invalid IP resolved for #{uri.host}: #{ip_str}"
47
+ end
48
+
49
+ [uri, ips.first]
50
+ rescue URI::InvalidURIError => e
51
+ raise Legate::ToolArgumentError.new("Invalid URL: #{url} - #{e.message}", cause: e)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,91 @@
1
+ # File: lib/legate/tools/base_async_job_tool.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../tool'
5
+ require_relative '../errors'
6
+ require 'concurrent'
7
+ require 'securerandom'
8
+ require 'json'
9
+
10
+ module Legate
11
+ module Tools
12
+ # Abstract base class for tools that initiate asynchronous background tasks via threads.
13
+ class BaseAsyncJobTool < Legate::Tool
14
+ tool_description "Base class for tools that initiate long-running tasks via background threads. Subclasses must implement `worker_class` and `prepare_job_arguments`. Use 'check_job_status' tool to retrieve results."
15
+
16
+ # --- In-Memory Job Results Storage ---
17
+ class << self
18
+ def job_results
19
+ @job_results ||= Concurrent::Map.new
20
+ end
21
+ end
22
+
23
+ # Subclasses MUST override this method to return the worker class
24
+ # that should be executed.
25
+ # @return [Class] The worker class (must respond to #perform).
26
+ def worker_class
27
+ raise NotImplementedError, "#{self.class.name} must implement #worker_class"
28
+ end
29
+
30
+ # Subclasses MUST override this method to prepare the arguments
31
+ # for the worker's perform method based on the Legate tool's parameters and context.
32
+ # Note: Arguments must be simple types serializable to JSON (strings, numbers, bools, arrays, hashes).
33
+ # @param params [Hash] The validated parameters passed to the Legate tool.
34
+ # @param context [Legate::ToolContext] Contextual information (session_id, etc.).
35
+ # @return [Array] An array of arguments to be passed to the worker's perform method.
36
+ def prepare_job_arguments(params, context)
37
+ raise NotImplementedError, "#{self.class.name} must implement #prepare_job_arguments(params, context)"
38
+ end
39
+
40
+ # Overrides Legate::Tool#perform_execution to spawn a background thread.
41
+ # @param params [Hash] The validated parameters.
42
+ # @param context [Legate::ToolContext] The execution context.
43
+ # @return [Hash] { status: :pending, job_id: ... } or { status: :error, ... }
44
+ private def perform_execution(params, context)
45
+ jid = SecureRandom.uuid
46
+ worker = worker_class.new
47
+ args = prepare_job_arguments(params, context)
48
+
49
+ BaseAsyncJobTool.job_results[jid] = { 'status' => 'pending' }
50
+
51
+ Legate.logger.info("Spawning background task for worker '#{worker_class.name}' for tool '#{name}'. Job ID: #{jid}")
52
+ Legate.logger.debug("Job Args: #{args.inspect}")
53
+
54
+ Concurrent::Promises.future do
55
+ worker.perform(jid, *args)
56
+ rescue StandardError => e
57
+ BaseAsyncJobTool.job_results[jid] = { 'status' => 'error', 'error_message' => "#{e.class}: #{e.message}" }
58
+ end
59
+
60
+ { status: :pending, job_id: jid, message: "Job #{jid} has been submitted." }
61
+ end
62
+
63
+ # --- Static Helpers for Workers to Store Status/Results --- #
64
+
65
+ # Helper method for workers to call at the beginning of their perform method
66
+ # to indicate the job has started processing.
67
+ # @param jid [String] The Job ID.
68
+ def self.store_job_pending(jid)
69
+ job_results[jid] = { 'status' => 'pending' }
70
+ Legate.logger.debug("Stored pending status for job #{jid}")
71
+ end
72
+
73
+ # Helper method for workers to call upon completion to store their results.
74
+ # @param jid [String] The Job ID.
75
+ # @param result [Object] The result data.
76
+ def self.store_job_result(jid, result)
77
+ job_results[jid] = { 'status' => 'completed', 'result' => result.is_a?(String) ? result : result.to_json }
78
+ Legate.logger.debug("Stored successful result for job #{jid}")
79
+ end
80
+
81
+ # Helper method for workers to call upon failure to store error information.
82
+ # @param jid [String] The Job ID.
83
+ # @param error_message [String] The error message.
84
+ # @param _error_class [String] The class name of the error (kept for API compatibility).
85
+ def self.store_job_error(jid, error_message, _error_class = 'StandardError')
86
+ job_results[jid] = { 'status' => 'error', 'error_message' => error_message }
87
+ Legate.logger.debug("Stored error result for job #{jid}")
88
+ end
89
+ end
90
+ end
91
+ end