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,69 @@
1
+ # File: lib/legate/llm/adapter.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Legate
5
+ # LLM provider abstraction. The planner (and code generators) talk to an
6
+ # Adapter rather than a specific provider client, so Legate is not hardwired to
7
+ # one model vendor. Gemini is the first adapter; others (OpenAI, Anthropic,
8
+ # Ollama, ...) implement the same interface.
9
+ module LLM
10
+ # Abstract base for LLM provider adapters.
11
+ class Adapter
12
+ # @return [Boolean] whether the adapter can make calls (e.g. an API key is
13
+ # present and the client constructed successfully).
14
+ def available?
15
+ false
16
+ end
17
+
18
+ # The resolved model identifier, or nil if the adapter is unavailable.
19
+ # @return [String, nil]
20
+ def model_name
21
+ nil
22
+ end
23
+
24
+ # Generates a text completion for a single user prompt.
25
+ # @param prompt [String] the user prompt
26
+ # @param json [Boolean] request raw-JSON output where the provider supports it
27
+ # @param schema [Hash, nil] an optional response schema (provider-native
28
+ # structured output) to constrain the JSON shape. Ignored by adapters
29
+ # that don't support it; see {#supports_structured_output?}.
30
+ # @return [String, nil] the model's text output, or nil if unavailable
31
+ # @raise [StandardError] on a non-retryable provider error
32
+ def generate(prompt, json: false, schema: nil)
33
+ raise NotImplementedError, "#{self.class} must implement #generate"
34
+ end
35
+
36
+ # Whether this adapter can constrain output to a schema (structured output)
37
+ # via the `schema:` argument to {#generate}. When true, the planner uses it
38
+ # to guarantee valid plan JSON instead of parsing it out of prose. Default
39
+ # false (the prompt-and-parse path).
40
+ # @return [Boolean]
41
+ def supports_structured_output?
42
+ false
43
+ end
44
+
45
+ # Whether this adapter can use the provider's native function/tool-calling
46
+ # API. When true, the agentic loop selects its next action via
47
+ # {#generate_with_tools} (structured, reliable) instead of parsing JSON out
48
+ # of prose; when false it falls back to the JSON-prompt path. Default false.
49
+ # @return [Boolean]
50
+ def supports_function_calling?
51
+ false
52
+ end
53
+
54
+ # Chooses the next action with the given tool schemas available to the
55
+ # model, using native function calling. Only meaningful when
56
+ # {#supports_function_calling?} is true.
57
+ #
58
+ # @param prompt [String] instructions + context + observation transcript
59
+ # @param tools [Array<Hash>] each { name:, description:, parameters: <JSON Schema> }
60
+ # @return [Hash] a provider-neutral choice, one of:
61
+ # * `{ kind: :tool, name: String, arguments: Hash, thought: String }`
62
+ # * `{ kind: :final, text: String, thought: String }`
63
+ # @raise [StandardError] on a non-retryable provider error
64
+ def generate_with_tools(prompt, tools:)
65
+ raise NotImplementedError, "#{self.class} must implement #generate_with_tools"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,172 @@
1
+ # File: lib/legate/llm/gemini.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'gemini-ai'
5
+ require_relative '../gemini_ai_beta_patch' # Apply monkey patch for v1beta API
6
+ require_relative '../redaction'
7
+ require_relative 'adapter'
8
+
9
+ module Legate
10
+ module LLM
11
+ # LLM adapter backed by the gemini-ai gem (Google Gemini, v1beta endpoint).
12
+ class Gemini < Adapter
13
+ MAX_RETRIES = 2
14
+ RETRY_BASE_DELAY = 1 # seconds, exponential: 1s, 2s
15
+
16
+ # @param model [String] the Gemini model id
17
+ # @param api_key [String, nil] defaults to ENV['GOOGLE_API_KEY'], then
18
+ # ENV['GEMINI_API_KEY'] — so either env var works directly (no need to go
19
+ # through Legate.load_environment for the alias).
20
+ # @param logger [Logger, nil] defaults to Legate.logger
21
+ def initialize(model:, api_key: nil, logger: nil)
22
+ super()
23
+ @model = model
24
+ @api_key = api_key || ENV['GOOGLE_API_KEY'] || ENV['GEMINI_API_KEY']
25
+ @logger = logger || Legate.logger
26
+ @client = build_client
27
+ end
28
+
29
+ def available?
30
+ !@client.nil?
31
+ end
32
+
33
+ def model_name
34
+ available? ? @model : nil
35
+ end
36
+
37
+ # @see Legate::LLM::Adapter#generate
38
+ def generate(prompt, json: false, schema: nil)
39
+ return nil unless @client
40
+
41
+ response = request_with_retry(build_text_payload(prompt, json: json, schema: schema))
42
+ response.dig('candidates', 0, 'content', 'parts', 0, 'text')
43
+ end
44
+
45
+ # Gemini supports structured output via responseSchema on the v1beta endpoint.
46
+ def supports_structured_output?
47
+ true
48
+ end
49
+
50
+ # Gemini supports native function calling on the v1beta endpoint.
51
+ def supports_function_calling?
52
+ true
53
+ end
54
+
55
+ # @see Legate::LLM::Adapter#generate_with_tools
56
+ def generate_with_tools(prompt, tools:)
57
+ return { kind: :final, text: nil, thought: nil } unless @client
58
+
59
+ response = request_with_retry(build_tools_payload(prompt, tools))
60
+ parse_tool_response(response)
61
+ end
62
+
63
+ private
64
+
65
+ def build_text_payload(prompt, json:, schema: nil)
66
+ payload = { contents: [{ role: 'user', parts: { text: prompt } }] }
67
+ # Ask Gemini to return raw JSON (v1beta field names; the gem sends the
68
+ # payload through verbatim). A responseSchema additionally constrains the
69
+ # output to that shape (structured output) — guaranteed-valid JSON.
70
+ if json || schema
71
+ config = { responseMimeType: 'application/json' }
72
+ config[:responseSchema] = schema if schema
73
+ payload[:generationConfig] = config
74
+ end
75
+ payload
76
+ end
77
+
78
+ def build_tools_payload(prompt, tools)
79
+ {
80
+ contents: [{ role: 'user', parts: { text: prompt } }],
81
+ tools: [{ functionDeclarations: Array(tools).map { |t| function_declaration(t) } }]
82
+ }
83
+ end
84
+
85
+ # Convert a neutral tool schema { name:, description:, parameters: <JSON Schema> }
86
+ # into a Gemini functionDeclaration (OpenAPI subset, uppercase type names).
87
+ def function_declaration(tool)
88
+ {
89
+ name: tool[:name].to_s,
90
+ description: tool[:description].to_s,
91
+ parameters: to_openapi_schema(tool[:parameters])
92
+ }
93
+ end
94
+
95
+ def to_openapi_schema(schema)
96
+ return { type: 'OBJECT', properties: {} } unless schema.is_a?(Hash)
97
+
98
+ props = (schema[:properties] || {}).transform_values do |prop|
99
+ out = { type: (prop[:type] || 'string').to_s.upcase }
100
+ out[:description] = prop[:description].to_s if prop[:description]
101
+ out[:items] = { type: (prop.dig(:items, :type) || 'string').to_s.upcase } if out[:type] == 'ARRAY'
102
+ out
103
+ end
104
+ result = { type: 'OBJECT', properties: props }
105
+ required = Array(schema[:required]).map(&:to_s)
106
+ result[:required] = required unless required.empty?
107
+ result
108
+ end
109
+
110
+ # A functionCall part -> tool choice; otherwise the text parts -> final.
111
+ def parse_tool_response(response)
112
+ parts = response.dig('candidates', 0, 'content', 'parts') || []
113
+ text = parts.filter_map { |p| p['text'] }.join.strip
114
+ text = nil if text.empty?
115
+
116
+ call = parts.find { |p| p['functionCall'] }
117
+ if call
118
+ fc = call['functionCall']
119
+ return { kind: :tool, name: fc['name'].to_s, arguments: fc['args'] || {}, thought: text }
120
+ end
121
+
122
+ { kind: :final, text: text, thought: nil }
123
+ end
124
+
125
+ def build_client
126
+ if @api_key.nil? || @api_key.empty?
127
+ @logger.error('GOOGLE_API_KEY not found. The Gemini LLM adapter requires an API key.')
128
+ return nil
129
+ end
130
+
131
+ client = ::Gemini.new(
132
+ credentials: { service: 'generative-language-api', api_key: @api_key },
133
+ options: { model: @model, server_sent_events: false }
134
+ )
135
+ @logger.info("Gemini LLM adapter initialized with model: #{@model}")
136
+ client
137
+ rescue StandardError => e
138
+ @logger.error("Failed to initialize Gemini client (model '#{@model}'): #{e.class}: #{Legate::Redaction.redact(e.message)}")
139
+ @logger.error(Legate::Redaction.redact(e.backtrace.join("\n"))) if e.backtrace
140
+ nil
141
+ end
142
+
143
+ def request_with_retry(payload)
144
+ attempt = 0
145
+ begin
146
+ attempt += 1
147
+ @client.generate_content(payload)
148
+ rescue StandardError => e
149
+ if attempt <= MAX_RETRIES && retryable_error?(e)
150
+ delay = RETRY_BASE_DELAY * (2**(attempt - 1))
151
+ @logger.warn("Gemini API attempt #{attempt}/#{MAX_RETRIES + 1} failed (#{e.class}), retrying in #{delay}s...")
152
+ sleep(delay)
153
+ retry
154
+ end
155
+ # Re-raise with the API key scrubbed from the message (the gemini-ai gem
156
+ # embeds the full request URL, including ?key=..., in its errors).
157
+ raise e.class, Legate::Redaction.redact(e.message), e.backtrace
158
+ end
159
+ end
160
+
161
+ def retryable_error?(error)
162
+ msg = error.message.to_s
163
+ return true if error.is_a?(Errno::ECONNRESET) || error.is_a?(Errno::ECONNREFUSED) || error.is_a?(Errno::ETIMEDOUT)
164
+ return true if error.is_a?(Net::OpenTimeout) || error.is_a?(Net::ReadTimeout)
165
+ return true if msg.match?(/429|rate.limit/i)
166
+ return true if msg.match?(/^5\d{2}\b|server.error|service.unavailable|internal.server/i)
167
+
168
+ false
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,80 @@
1
+ # File: lib/legate/llm/ollama.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+ require_relative 'adapter'
8
+
9
+ module Legate
10
+ module LLM
11
+ # LLM adapter backed by a local Ollama server (https://ollama.com).
12
+ #
13
+ # Talks to Ollama's /api/generate HTTP endpoint — no API key, no cost, fully
14
+ # local. Configure the host via the :host option or the OLLAMA_HOST env var
15
+ # (default http://localhost:11434). Wire it up globally with:
16
+ #
17
+ # Legate::LLM.default_adapter_factory = lambda do |model:, **|
18
+ # Legate::LLM::Ollama.new(model: model)
19
+ # end
20
+ class Ollama < Adapter
21
+ DEFAULT_HOST = 'http://localhost:11434'
22
+
23
+ # @param model [String] the Ollama model tag, e.g. 'llama3' or 'qwen2.5'
24
+ # @param host [String, nil] base URL of the Ollama server
25
+ # @param logger [Logger, nil]
26
+ # @param read_timeout [Integer] seconds to wait for a completion (default 120)
27
+ def initialize(model:, host: nil, logger: nil, read_timeout: 120, **_ignored)
28
+ super()
29
+ @model = model
30
+ @host = (host || ENV['OLLAMA_HOST'] || DEFAULT_HOST).to_s.chomp('/')
31
+ @logger = logger || Legate.logger
32
+ @read_timeout = read_timeout
33
+ end
34
+
35
+ # Ollama is a local server; assume it's reachable rather than pinging it on
36
+ # every planner init. A real failure surfaces from #generate with a clear
37
+ # message.
38
+ def available?
39
+ true
40
+ end
41
+
42
+ def model_name
43
+ @model
44
+ end
45
+
46
+ # @see Legate::LLM::Adapter#generate
47
+ # `schema:` is accepted for interface parity but ignored (Ollama's
48
+ # `format: json` is the only structured constraint used here).
49
+ def generate(prompt, json: false, schema: nil) # rubocop:disable Lint/UnusedMethodArgument
50
+ body = { model: @model, prompt: prompt, stream: false }
51
+ # Ollama supports constrained JSON output via the "format" field.
52
+ body[:format] = 'json' if json
53
+
54
+ post_json('/api/generate', body)['response']
55
+ rescue StandardError => e
56
+ @logger.error("Ollama generate failed (#{@host}, model '#{@model}'): #{e.class}: #{e.message}")
57
+ raise
58
+ end
59
+
60
+ private
61
+
62
+ def post_json(path, body)
63
+ uri = URI.join("#{@host}/", path.sub(%r{\A/}, ''))
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = (uri.scheme == 'https')
66
+ http.open_timeout = 5
67
+ http.read_timeout = @read_timeout
68
+
69
+ request = Net::HTTP::Post.new(uri.request_uri)
70
+ request['Content-Type'] = 'application/json'
71
+ request.body = JSON.generate(body)
72
+
73
+ response = http.request(request)
74
+ raise "Ollama HTTP #{response.code}: #{response.body.to_s[0, 300]}" unless response.is_a?(Net::HTTPSuccess)
75
+
76
+ JSON.parse(response.body)
77
+ end
78
+ end
79
+ end
80
+ end
data/lib/legate/llm.rb ADDED
@@ -0,0 +1,34 @@
1
+ # File: lib/legate/llm.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'llm/adapter'
5
+ require_relative 'llm/gemini'
6
+ require_relative 'llm/ollama'
7
+
8
+ module Legate
9
+ module LLM
10
+ class << self
11
+ # A factory for the default LLM adapter, called as
12
+ # `factory.call(model:, api_key:, logger:)` and expected to return a
13
+ # Legate::LLM::Adapter. Set this to use a provider other than Gemini for
14
+ # every agent, e.g.:
15
+ # Legate::LLM.default_adapter_factory = ->(model:, api_key:, logger:) {
16
+ # MyProvider::Adapter.new(model: model, logger: logger)
17
+ # }
18
+ # Nil means use the built-in Gemini adapter.
19
+ # @return [#call, nil]
20
+ attr_accessor :default_adapter_factory
21
+ end
22
+
23
+ # Builds an adapter using the configured factory, or the default Gemini
24
+ # adapter. Per-planner overrides take precedence over this.
25
+ # @return [Legate::LLM::Adapter]
26
+ def self.build_adapter(model:, api_key: nil, logger: nil)
27
+ if default_adapter_factory
28
+ default_adapter_factory.call(model: model, api_key: api_key, logger: logger)
29
+ else
30
+ Gemini.new(model: model, api_key: api_key, logger: logger)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,320 @@
1
+ # File: lib/legate/mcp/client.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'connection/stdio'
6
+ require_relative 'connection/sse'
7
+ require_relative '../errors'
8
+
9
+ module Legate
10
+ module Mcp
11
+ class Client
12
+ DEFAULT_RESPONSE_TIMEOUT = 30
13
+ PROCESS_START_TIMEOUT = Connection::Stdio::PROCESS_START_TIMEOUT
14
+ # --- Define the protocol version Legate Client supports ---
15
+ CLIENT_PROTOCOL_VERSION = '2024-11-05'
16
+ # -----------------------------------------------------
17
+
18
+ attr_reader :connection_params, :server_capabilities, :last_error
19
+
20
+ # ... (initialize remains the same) ...
21
+ def initialize(connection_params)
22
+ @connection_params = connection_params
23
+ @connection = nil
24
+ @server_capabilities = nil
25
+ @connected = false
26
+ @pending_requests = {}
27
+ @lock = Mutex.new
28
+ @last_error = nil
29
+
30
+ # Validate connection params based on type
31
+ case @connection_params[:type]
32
+ when :stdio
33
+ raise ArgumentError, 'Missing :command for :stdio connection' unless @connection_params[:command]
34
+ when :sse
35
+ raise ArgumentError, 'Missing :url for :sse connection' unless @connection_params[:url]
36
+ else
37
+ raise ArgumentError, "Unsupported connection type: #{@connection_params[:type]}"
38
+ end
39
+ end
40
+
41
+ def connected?
42
+ @connected && @connection&.connected?
43
+ end
44
+
45
+ def connect
46
+ return true if connected?
47
+
48
+ error_occurred = nil
49
+
50
+ @lock.synchronize do
51
+ return true if @connected # Double check
52
+
53
+ Legate.logger.info('MCP Client connecting...')
54
+ @last_error = nil
55
+ @connection = nil
56
+ @connected = false
57
+
58
+ begin
59
+ case @connection_params[:type]
60
+ when :stdio
61
+ @connection = Connection::Stdio.new(
62
+ command: @connection_params[:command],
63
+ args: @connection_params[:args] || []
64
+ )
65
+ when :sse
66
+ # require_relative 'connection/sse' # Ensure loaded if not globally required
67
+ @connection = Connection::Sse.new(url: @connection_params[:url])
68
+ else
69
+ raise ConnectionError, "Cannot connect: Unsupported connection type: #{@connection_params[:type]}"
70
+ end
71
+
72
+ @connection.connect
73
+ @connected = true # Assume connected for handshake
74
+
75
+ Legate.logger.info('Performing MCP initialize handshake...')
76
+ id = @connection.next_request_id
77
+ # --- MODIFICATION: Add protocolVersion to params ---
78
+ request = {
79
+ jsonrpc: '2.0', id: id, method: 'initialize',
80
+ params: {
81
+ protocolVersion: CLIENT_PROTOCOL_VERSION,
82
+ clientInfo: { name: 'legate-client', version: Legate::VERSION },
83
+ capabilities: {} # Keep capabilities empty for now
84
+ }
85
+ }
86
+ # --- End Modification ---
87
+ Legate.logger.info("Initialize Request: #{request.inspect}") # Log modified request
88
+
89
+ response = send_request_and_wait(request, timeout: PROCESS_START_TIMEOUT)
90
+
91
+ unless response && response[:result]
92
+ error_msg = 'MCP Initialize failed: No response or missing result.'
93
+ if response&.dig(:error)
94
+ err = response[:error]
95
+ error_msg += " Server Error: #{err[:message]} (Code: #{err[:code]})"
96
+ elsif !response
97
+ error_msg += ' Connection likely closed or timed out.'
98
+ else
99
+ error_msg += " Response: #{response.inspect}"
100
+ end
101
+ @last_error = error_msg
102
+ raise ConnectionError, @last_error # Raise to be caught below
103
+ end
104
+
105
+ # --- Optional: Validate Server Protocol Version ---
106
+ server_protocol_version = response.dig(:result, :protocolVersion)
107
+ if server_protocol_version && server_protocol_version != CLIENT_PROTOCOL_VERSION
108
+ Legate.logger.warn("MCP Protocol version mismatch. Client: #{CLIENT_PROTOCOL_VERSION}, Server: #{server_protocol_version}")
109
+ # Decide if this is a critical error - for now, just log a warning
110
+ end
111
+ # --- End Protocol Version Check ---
112
+
113
+ @server_capabilities = response.dig(:result, :capabilities) || {}
114
+ Legate.logger.info("MCP Handshake successful. Server capabilities: #{@server_capabilities.inspect}")
115
+ Legate.logger.info('MCP Client connected successfully.')
116
+ rescue ConnectionError => e
117
+ Legate.logger.error("MCP Client connection/handshake failed: #{e.message}")
118
+ error_occurred = e
119
+ @connected = false
120
+ rescue StandardError => e
121
+ @last_error = "MCP Client unexpected error during connect: #{e.class} - #{e.message}"
122
+ Legate.logger.error("#{@last_error}\n#{e.backtrace.join("\n")}")
123
+ error_occurred = ConnectionError.new(@last_error)
124
+ @connected = false
125
+ end
126
+ end # Lock released
127
+
128
+ if error_occurred
129
+ disconnect
130
+ raise error_occurred
131
+ end
132
+
133
+ true
134
+ end
135
+
136
+ # Disconnects from the MCP server.
137
+ def disconnect
138
+ @lock.synchronize do
139
+ return unless @connected || @connection # Check if there's anything to disconnect
140
+
141
+ Legate.logger.info('MCP Client disconnecting...')
142
+ @connected = false
143
+ @server_capabilities = nil
144
+ @pending_requests.clear
145
+
146
+ begin
147
+ @connection&.disconnect
148
+ rescue StandardError => e
149
+ Legate.logger.error("MCP Client error during disconnect: #{e.message}")
150
+ end
151
+
152
+ @connection = nil
153
+ Legate.logger.info('MCP Client disconnected.')
154
+ end
155
+ ensure
156
+ # Ensure state is updated even if disconnect fails
157
+ @connected = false
158
+ @connection = nil
159
+ end
160
+
161
+ # Lists available tools from the MCP server.
162
+ # @return [Array<Hash>] List of MCP tool schemas.
163
+ # @raise [ConnectionError] if not connected.
164
+ # @raise [ProtocolError] if the server response is invalid.
165
+ def list_tools
166
+ raise ConnectionError, 'Not connected' unless connected?
167
+
168
+ Legate.logger.debug('Requesting tools list from MCP server...')
169
+ id = @connection.next_request_id
170
+ request = {
171
+ jsonrpc: '2.0',
172
+ id: id,
173
+ method: 'tools/list',
174
+ params: {}
175
+ }
176
+
177
+ response = send_request_and_wait(request)
178
+
179
+ if response&.key?(:result)
180
+ tools = response.dig(:result, :tools)
181
+ unless tools.is_a?(Array)
182
+ @last_error = "MCP tools/list invalid response: 'result.tools' is not an Array. Response: #{response.inspect}"
183
+ raise ProtocolError, @last_error
184
+ end
185
+ Legate.logger.debug("Received #{tools.count} tools from MCP server.")
186
+ tools
187
+ elsif response&.key?(:error)
188
+ err = response[:error]
189
+ @last_error = "MCP tools/list failed: #{err[:message]} (Code: #{err[:code]})"
190
+ Legate.logger.error(@last_error)
191
+ raise RemoteToolError.new(@last_error, err[:code], err[:data])
192
+ else
193
+ @last_error = "MCP tools/list failed: Invalid or missing response. #{response ? "Resp: #{response.inspect}" : 'Connection likely closed.'}"
194
+ raise ProtocolError, @last_error
195
+ end
196
+ end
197
+
198
+ # Calls a tool on the MCP server.
199
+ # @param name [String] The name of the tool to call.
200
+ # @param arguments [Hash] The arguments for the tool.
201
+ # @return [Any] The result payload from the tool execution.
202
+ # @raise [ConnectionError] if not connected.
203
+ # @raise [ProtocolError] if the server response is invalid.
204
+ # @raise [RemoteToolError] if the server returns a tool execution error.
205
+ def call_tool(name, arguments)
206
+ raise ConnectionError, 'Not connected' unless connected?
207
+ raise ArgumentError, 'Arguments must be a Hash' unless arguments.is_a?(Hash)
208
+
209
+ Legate.logger.debug("Calling MCP tool '#{name}' with args: #{arguments.inspect}")
210
+ id = @connection.next_request_id
211
+ request = {
212
+ jsonrpc: '2.0',
213
+ id: id,
214
+ method: 'tools/call',
215
+ params: { name: name, arguments: arguments }
216
+ }
217
+
218
+ response = send_request_and_wait(request)
219
+
220
+ if response&.key?(:result)
221
+ Legate.logger.debug("MCP tool '#{name}' call successful. Result: #{response[:result].inspect}")
222
+ response[:result]
223
+ elsif response&.key?(:error)
224
+ err = response[:error]
225
+ @last_error = "MCP tool '#{name}' call failed: #{err[:message]} (Code: #{err[:code]})"
226
+ Legate.logger.error("#{@last_error} Data: #{err[:data].inspect}")
227
+ raise RemoteToolError.new(err[:message], err[:code], err[:data])
228
+ else
229
+ @last_error = "MCP tool '#{name}' call failed: Invalid or missing response. #{response ? "Resp: #{response.inspect}" : 'Connection likely closed.'}"
230
+ raise ProtocolError, @last_error
231
+ end
232
+ end
233
+
234
+ # Reads the next *notification* received from the server via the connection.
235
+ # This is primarily useful for SSE connections.
236
+ # @param timeout [Numeric] Seconds to wait (default 0.1).
237
+ # @return [Hash, nil] Notification hash or nil.
238
+ def read_notification(timeout = 0.1)
239
+ return nil unless connected?
240
+
241
+ # Delegate to connection-specific method if it exists, otherwise return nil
242
+ if @connection.respond_to?(:read_notification)
243
+ @connection.read_notification(timeout)
244
+ else
245
+ Legate.logger.debug("Connection type #{@connection_params[:type]} does not support read_notification.")
246
+ nil
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ def send_request_and_wait(request, timeout: DEFAULT_RESPONSE_TIMEOUT)
253
+ raise ArgumentError, 'Request must have an ID' unless request[:id]
254
+
255
+ request_id = request[:id]
256
+ @pending_requests[request_id] = true # Mark as waiting
257
+
258
+ begin
259
+ # Raise connection error immediately if low-level connection is dead
260
+ raise ConnectionError, 'Connection is not alive.' unless @connection&.connected?
261
+
262
+ @connection.send_request(request)
263
+ Legate.logger.debug("Sent request ID #{request_id}, waiting for response (timeout: #{timeout}s)")
264
+
265
+ start_time = Time.now
266
+ message_buffer = []
267
+ loop do
268
+ # Check if the low-level connection died
269
+ unless @connection&.connected?
270
+ raise ConnectionError,
271
+ "Connection lost while waiting for response ID #{request_id}"
272
+ end
273
+
274
+ # Check buffer first
275
+ message_buffer.reject! do |buffered_message|
276
+ if buffered_message[:id] == request_id
277
+ Legate.logger.debug("Found matching response for ID #{request_id} in buffer")
278
+ return buffered_message
279
+ end
280
+ false # Keep non-matching messages
281
+ end
282
+
283
+ # Calculate remaining timeout
284
+ elapsed_time = Time.now - start_time
285
+ remaining_time = timeout - elapsed_time
286
+ if remaining_time <= 0
287
+ @last_error = "MCP Client timeout waiting for response ID #{request_id}"
288
+ Legate.logger.error(@last_error)
289
+ return nil # Timeout occurred
290
+ end
291
+
292
+ # Read the next message using the remaining timeout
293
+ Legate.logger.debug("Calling read_message with timeout: #{remaining_time.round(3)}s")
294
+ message = @connection.read_message(remaining_time)
295
+
296
+ if message
297
+ Legate.logger.debug("[Client send_request_and_wait] Received message: #{JSON.pretty_generate(message)} while waiting for ID #{request_id}")
298
+ if message[:id] == request_id
299
+ Legate.logger.debug("Received matching response for ID #{request_id}")
300
+ return message
301
+ elsif message[:id]
302
+ Legate.logger.warn("Received unexpected response ID #{message[:id]} while waiting for #{request_id}. Buffering.")
303
+ message_buffer << message
304
+ else
305
+ Legate.logger.debug("Received notification or non-response message while waiting for ID #{request_id}: #{message.inspect}")
306
+ end
307
+ else
308
+ # read_message returned nil, indicating timeout within read_message itself
309
+ @last_error = "MCP Client timeout waiting for response ID #{request_id} (read_message returned nil)"
310
+ Legate.logger.error(@last_error)
311
+ return nil
312
+ end
313
+ end
314
+ ensure
315
+ @pending_requests.delete(request_id) # Clear pending status
316
+ end
317
+ end # --- End send_request_and_wait ---
318
+ end # End Client class
319
+ end # End Mcp module
320
+ end # End Legate module