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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +345 -0
- data/bin/legate +13 -0
- data/examples/00_quickstart.rb +51 -0
- data/examples/01_simple_agent.rb +105 -0
- data/examples/02_multi_tool_agent.rb +140 -0
- data/examples/03_custom_tool.rb +93 -0
- data/examples/04_agent_instructions.rb +84 -0
- data/examples/05_state_and_sessions.rb +91 -0
- data/examples/06_callbacks.rb +186 -0
- data/examples/07_async_jobs.rb +112 -0
- data/examples/08_loop_agent.rb +197 -0
- data/examples/09_sequential_workflow.rb +40 -0
- data/examples/10_parallel_workflow.rb +34 -0
- data/examples/11_agent_delegation.rb +24 -0
- data/examples/12_http_client_tool.rb +156 -0
- data/examples/13_authentication.rb +220 -0
- data/examples/14_mcp_client.rb +154 -0
- data/examples/15_mcp_server.rb +79 -0
- data/examples/16_webhooks.rb +91 -0
- data/examples/README_sequential_agents.md +164 -0
- data/examples/advanced/auth/cookie_auth_tool.rb +146 -0
- data/examples/advanced/auth/custom_auth_flows_example.rb +626 -0
- data/examples/advanced/auth/excon_middleware.rb +317 -0
- data/examples/advanced/auth/excon_middleware_auth.rb +399 -0
- data/examples/advanced/auth/fiber_auth_example.rb +281 -0
- data/examples/advanced/auth/fiber_oidc_example.rb +403 -0
- data/examples/advanced/auth/httpbin_bearer_tool.rb +159 -0
- data/examples/advanced/auth/oauth2_auth.rb +419 -0
- data/examples/advanced/auth/oidc_auth.rb +514 -0
- data/examples/advanced/auth/openweather_api.rb +251 -0
- data/examples/advanced/auth/openweather_tool.rb +153 -0
- data/examples/advanced/auth/query_param_middleware_test.rb +138 -0
- data/examples/advanced/auth/service_account.rb +135 -0
- data/examples/advanced/auth/test_with_httpbin.rb +202 -0
- data/examples/advanced/auth/token_lifecycle_example.rb +428 -0
- data/examples/advanced/callback_monitoring.rb +679 -0
- data/examples/advanced/mas/fixed_delegation_example.rb +191 -0
- data/examples/advanced/mas/loop_workflow.rb +28 -0
- data/examples/advanced/mas/mock_planner.rb +77 -0
- data/examples/advanced/mas/proper_delegation_example.rb +276 -0
- data/examples/advanced/mcp/legate_mcp_server_resource_example.rb +182 -0
- data/examples/advanced/mcp/mcp_resource_server_example.rb +309 -0
- data/examples/advanced/mcp/mcp_server_async.rb +76 -0
- data/examples/advanced/mcp/mcp_server_async_tools.rb +122 -0
- data/examples/advanced/mcp/mcp_server_legate_agent.rb +95 -0
- data/examples/advanced/mcp/mcp_server_rack.rb +89 -0
- data/examples/advanced/random_calculator.rb +104 -0
- data/examples/advanced/sleep_agent.rb +153 -0
- data/examples/advanced/webhooks/webhook_e2e_runner.rb +110 -0
- data/examples/advanced/webhooks/webhook_receiver_agent.rb +58 -0
- data/examples/advanced/workflows/task_refinement_loop_agent.rb +278 -0
- data/examples/advanced/workflows/travel_planner_auto_sequential.rb +444 -0
- data/examples/advanced/workflows/travel_planner_parallel.rb +656 -0
- data/examples/advanced/workflows/travel_planner_sequential.rb +512 -0
- data/examples/tools/oauth2_example.rb +136 -0
- data/examples/tools/sleepy_tool.rb +42 -0
- data/lib/legate/activity_log.rb +71 -0
- data/lib/legate/agent.rb +959 -0
- data/lib/legate/agent_code_generator.rb +185 -0
- data/lib/legate/agent_definition.rb +812 -0
- data/lib/legate/agentic/decision.rb +49 -0
- data/lib/legate/agentic/loop.rb +134 -0
- data/lib/legate/agentic.rb +5 -0
- data/lib/legate/agents/loop_agent.rb +248 -0
- data/lib/legate/agents/parallel_agent.rb +163 -0
- data/lib/legate/agents/sequential_agent.rb +190 -0
- data/lib/legate/agents.rb +14 -0
- data/lib/legate/auth/config.rb +148 -0
- data/lib/legate/auth/coordinator.rb +218 -0
- data/lib/legate/auth/coordinators/oauth2_coordinator.rb +99 -0
- data/lib/legate/auth/coordinators/oidc_coordinator.rb +68 -0
- data/lib/legate/auth/coordinators/service_account_coordinator.rb +122 -0
- data/lib/legate/auth/credential.rb +157 -0
- data/lib/legate/auth/encryption.rb +108 -0
- data/lib/legate/auth/error.rb +94 -0
- data/lib/legate/auth/exchanged_credential.rb +180 -0
- data/lib/legate/auth/excon_middleware.rb +285 -0
- data/lib/legate/auth/http_client_utils.rb +364 -0
- data/lib/legate/auth/manager.rb +531 -0
- data/lib/legate/auth/manager_store.rb +394 -0
- data/lib/legate/auth/middleware_factory.rb +290 -0
- data/lib/legate/auth/runner.rb +279 -0
- data/lib/legate/auth/scheme.rb +125 -0
- data/lib/legate/auth/schemes/api_key.rb +212 -0
- data/lib/legate/auth/schemes/google_service_account.rb +108 -0
- data/lib/legate/auth/schemes/http_bearer.rb +98 -0
- data/lib/legate/auth/schemes/oauth2.rb +396 -0
- data/lib/legate/auth/schemes/openid_connect.rb +346 -0
- data/lib/legate/auth/schemes/service_account.rb +388 -0
- data/lib/legate/auth/schemes.rb +40 -0
- data/lib/legate/auth/token_manager.rb +362 -0
- data/lib/legate/auth/token_store.rb +86 -0
- data/lib/legate/auth/tool_context_extension.rb +97 -0
- data/lib/legate/auth/tool_integration.rb +188 -0
- data/lib/legate/auth/url_guard.rb +81 -0
- data/lib/legate/auth.rb +453 -0
- data/lib/legate/callbacks/callback_context.rb +71 -0
- data/lib/legate/cli/agent_commands.rb +950 -0
- data/lib/legate/cli/auth_commands.rb +520 -0
- data/lib/legate/cli/base_command.rb +24 -0
- data/lib/legate/cli/deployment_commands.rb +934 -0
- data/lib/legate/cli/output_helper.rb +108 -0
- data/lib/legate/cli/session_commands.rb +138 -0
- data/lib/legate/cli/skaffold_commands.rb +223 -0
- data/lib/legate/cli/tool_commands.rb +261 -0
- data/lib/legate/cli/web_commands.rb +182 -0
- data/lib/legate/cli.rb +40 -0
- data/lib/legate/configuration/webhooks.rb +113 -0
- data/lib/legate/configuration.rb +39 -0
- data/lib/legate/definition_store.rb +23 -0
- data/lib/legate/errors.rb +118 -0
- data/lib/legate/event.rb +161 -0
- data/lib/legate/gemini_ai_beta_patch.rb +39 -0
- data/lib/legate/generators/agent_generator.rb +412 -0
- data/lib/legate/generators/code_validator.rb +48 -0
- data/lib/legate/generators/legate/install_generator.rb +35 -0
- data/lib/legate/generators/legate/templates/create_legate_tables.rb.tt +36 -0
- data/lib/legate/generators/legate/templates/initializer.rb +18 -0
- data/lib/legate/generators/runtime_tool_loader.rb +76 -0
- data/lib/legate/generators/tool_generator.rb +408 -0
- data/lib/legate/generators.rb +11 -0
- data/lib/legate/global_definition_registry.rb +506 -0
- data/lib/legate/global_tool_manager.rb +135 -0
- data/lib/legate/llm/adapter.rb +69 -0
- data/lib/legate/llm/gemini.rb +172 -0
- data/lib/legate/llm/ollama.rb +80 -0
- data/lib/legate/llm.rb +34 -0
- data/lib/legate/mcp/client.rb +320 -0
- data/lib/legate/mcp/connection/sse.rb +292 -0
- data/lib/legate/mcp/connection/stdio.rb +273 -0
- data/lib/legate/mcp/connection_manager.rb +103 -0
- data/lib/legate/mcp/server/legate_agent_adapter.rb +170 -0
- data/lib/legate/mcp/server/legate_direct_agent_adapter.rb +140 -0
- data/lib/legate/mcp/server/legate_tool_adapter.rb +119 -0
- data/lib/legate/mcp/tool_wrapper.rb +138 -0
- data/lib/legate/mcp/util/schema_converter.rb +134 -0
- data/lib/legate/mcp.rb +23 -0
- data/lib/legate/plan_executor.rb +375 -0
- data/lib/legate/planner.rb +839 -0
- data/lib/legate/rails/railtie.rb +43 -0
- data/lib/legate/rails.rb +9 -0
- data/lib/legate/redaction.rb +32 -0
- data/lib/legate/session.rb +299 -0
- data/lib/legate/session_service/active_record.rb +300 -0
- data/lib/legate/session_service/base.rb +68 -0
- data/lib/legate/session_service/event_broadcast.rb +74 -0
- data/lib/legate/session_service/in_memory.rb +188 -0
- data/lib/legate/tool/metadata_dsl.rb +122 -0
- data/lib/legate/tool.rb +276 -0
- data/lib/legate/tool_code_generator.rb +103 -0
- data/lib/legate/tool_context.rb +350 -0
- data/lib/legate/tool_loader.rb +39 -0
- data/lib/legate/tool_registry.rb +73 -0
- data/lib/legate/tool_result.rb +61 -0
- data/lib/legate/tools/agent_tool.rb +187 -0
- data/lib/legate/tools/base/http_client.rb +319 -0
- data/lib/legate/tools/base/safe_url.rb +56 -0
- data/lib/legate/tools/base_async_job_tool.rb +91 -0
- data/lib/legate/tools/calculator.rb +89 -0
- data/lib/legate/tools/cat_facts.rb +81 -0
- data/lib/legate/tools/check_job_status_tool.rb +48 -0
- data/lib/legate/tools/current_time_tool.rb +64 -0
- data/lib/legate/tools/echo.rb +43 -0
- data/lib/legate/tools/http_request_tool.rb +105 -0
- data/lib/legate/tools/random_number_tool.rb +64 -0
- data/lib/legate/tools/read_webpage_tool.rb +92 -0
- data/lib/legate/tools/sleepy_tool.rb +74 -0
- data/lib/legate/tools/webhook_tool.rb +146 -0
- data/lib/legate/version.rb +5 -0
- data/lib/legate/web/app.rb +984 -0
- data/lib/legate/web/public/css/main.css +4980 -0
- data/lib/legate/web/public/images/favicon-256.png +0 -0
- data/lib/legate/web/public/images/favicon-32.png +0 -0
- data/lib/legate/web/public/images/legate-logo-dark.png +0 -0
- data/lib/legate/web/public/images/legate-logo-light.png +0 -0
- data/lib/legate/web/public/js/legate.js +616 -0
- data/lib/legate/web/public/styles/main.scss +4402 -0
- data/lib/legate/web/routes/agent_authentication_routes.rb +530 -0
- data/lib/legate/web/routes/agent_definition_routes.rb +803 -0
- data/lib/legate/web/routes/agent_generator_routes.rb +80 -0
- data/lib/legate/web/routes/agent_interaction_routes.rb +734 -0
- data/lib/legate/web/routes/agent_runtime_routes.rb +323 -0
- data/lib/legate/web/routes/api_routes.rb +56 -0
- data/lib/legate/web/routes/authentication_routes.rb +1541 -0
- data/lib/legate/web/routes/core_routes.rb +111 -0
- data/lib/legate/web/routes/documentation_routes.rb +220 -0
- data/lib/legate/web/routes/tool_generator_routes.rb +81 -0
- data/lib/legate/web/routes/tools_ui_routes.rb +207 -0
- data/lib/legate/web/sass_compiler.rb +73 -0
- data/lib/legate/web/views/_active_session_info.slim +25 -0
- data/lib/legate/web/views/_activity_list.slim +55 -0
- data/lib/legate/web/views/_agent_card.slim +56 -0
- data/lib/legate/web/views/_agent_generator_modal.slim +382 -0
- data/lib/legate/web/views/_agent_status_controls.slim +71 -0
- data/lib/legate/web/views/_agent_tool_table.slim +74 -0
- data/lib/legate/web/views/_chat_message.slim +95 -0
- data/lib/legate/web/views/_display_agent_configuration.slim +26 -0
- data/lib/legate/web/views/_display_agent_description.slim +11 -0
- data/lib/legate/web/views/_display_agent_fallback.slim +15 -0
- data/lib/legate/web/views/_display_agent_hierarchy.slim +93 -0
- data/lib/legate/web/views/_display_agent_instruction.slim +17 -0
- data/lib/legate/web/views/_display_agent_mcp.slim +13 -0
- data/lib/legate/web/views/_display_agent_model.slim +17 -0
- data/lib/legate/web/views/_display_agent_name.slim +42 -0
- data/lib/legate/web/views/_display_agent_output_key.slim +26 -0
- data/lib/legate/web/views/_display_agent_type.slim +65 -0
- data/lib/legate/web/views/_edit_agent_configuration.slim +74 -0
- data/lib/legate/web/views/_edit_agent_description.slim +16 -0
- data/lib/legate/web/views/_edit_agent_fallback.slim +25 -0
- data/lib/legate/web/views/_edit_agent_hierarchy.slim +98 -0
- data/lib/legate/web/views/_edit_agent_instruction.slim +49 -0
- data/lib/legate/web/views/_edit_agent_mcp.slim +33 -0
- data/lib/legate/web/views/_edit_agent_model.slim +23 -0
- data/lib/legate/web/views/_edit_agent_output_key.slim +36 -0
- data/lib/legate/web/views/_edit_agent_tools.slim +40 -0
- data/lib/legate/web/views/_edit_agent_type.slim +67 -0
- data/lib/legate/web/views/_session_error.slim +4 -0
- data/lib/legate/web/views/_skeleton.slim +69 -0
- data/lib/legate/web/views/_tool_card.slim +9 -0
- data/lib/legate/web/views/_tool_generator_modal.slim +311 -0
- data/lib/legate/web/views/agent.slim +436 -0
- data/lib/legate/web/views/agent_auth.slim +562 -0
- data/lib/legate/web/views/agents.slim +369 -0
- data/lib/legate/web/views/auth.slim +112 -0
- data/lib/legate/web/views/auth_credential_detail.slim +327 -0
- data/lib/legate/web/views/auth_credentials.slim +261 -0
- data/lib/legate/web/views/auth_debug.slim +94 -0
- data/lib/legate/web/views/auth_mapping_detail.slim +151 -0
- data/lib/legate/web/views/auth_mapping_new.slim +123 -0
- data/lib/legate/web/views/auth_mappings.slim +120 -0
- data/lib/legate/web/views/auth_scheme_detail.slim +274 -0
- data/lib/legate/web/views/auth_schemes.slim +259 -0
- data/lib/legate/web/views/auth_test.slim +418 -0
- data/lib/legate/web/views/chat.slim +192 -0
- data/lib/legate/web/views/docs_index.slim +105 -0
- data/lib/legate/web/views/docs_show.slim +105 -0
- data/lib/legate/web/views/error_404.slim +5 -0
- data/lib/legate/web/views/index.slim +148 -0
- data/lib/legate/web/views/layout.slim +144 -0
- data/lib/legate/web/views/tool_detail.slim +87 -0
- data/lib/legate/web/views/tools.slim +50 -0
- data/lib/legate/web/webhook_listener.rb +367 -0
- data/lib/legate/web.rb +9 -0
- data/lib/legate.rb +220 -0
- data/public/docs/advanced/callbacks.md +828 -0
- data/public/docs/advanced/mcp_schema_conversion.md +59 -0
- data/public/docs/authentication/api_reference/config.md +210 -0
- data/public/docs/authentication/api_reference/credential.md +246 -0
- data/public/docs/authentication/api_reference/encryption.md +218 -0
- data/public/docs/authentication/api_reference/exchanged_credential.md +271 -0
- data/public/docs/authentication/api_reference/excon_middleware.md +175 -0
- data/public/docs/authentication/api_reference/index.md +30 -0
- data/public/docs/authentication/api_reference/scheme.md +250 -0
- data/public/docs/authentication/api_reference/schemes/api_key.md +175 -0
- data/public/docs/authentication/api_reference/schemes/google_service_account.md +221 -0
- data/public/docs/authentication/api_reference/schemes/http_bearer.md +169 -0
- data/public/docs/authentication/api_reference/schemes/oauth2.md +343 -0
- data/public/docs/authentication/api_reference/schemes/oidc.md +73 -0
- data/public/docs/authentication/api_reference/schemes/openid_connect.md +311 -0
- data/public/docs/authentication/api_reference/schemes/service_account.md +287 -0
- data/public/docs/authentication/api_reference/token_manager.md +221 -0
- data/public/docs/authentication/api_reference/token_store.md +146 -0
- data/public/docs/authentication/api_reference/tool_context_extension.md +166 -0
- data/public/docs/authentication/guides/api_key.md +190 -0
- data/public/docs/authentication/guides/bearer.md +172 -0
- data/public/docs/authentication/guides/configuration.md +255 -0
- data/public/docs/authentication/guides/custom_flow.md +523 -0
- data/public/docs/authentication/guides/index.md +24 -0
- data/public/docs/authentication/guides/migration.md +435 -0
- data/public/docs/authentication/guides/oauth2.md +252 -0
- data/public/docs/authentication/guides/oidc.md +241 -0
- data/public/docs/authentication/guides/overview.md +155 -0
- data/public/docs/authentication/guides/secure_storage.md +301 -0
- data/public/docs/authentication/guides/service_account.md +228 -0
- data/public/docs/authentication/guides/token_lifecycle.md +295 -0
- data/public/docs/authentication/guides/web_ui_integration.md +504 -0
- data/public/docs/authentication/index.md +58 -0
- data/public/docs/authentication/troubleshooting/credential_storage.md +550 -0
- data/public/docs/authentication/troubleshooting/environment_variables.md +540 -0
- data/public/docs/authentication/troubleshooting/index.md +11 -0
- data/public/docs/authentication/troubleshooting/oauth2_issues.md +220 -0
- data/public/docs/authentication/troubleshooting/oidc_issues.md +412 -0
- data/public/docs/authentication/troubleshooting/token_refresh.md +338 -0
- data/public/docs/cli/legate_cli_usage.md +363 -0
- data/public/docs/core_concepts/legate_agent_lifecycle.md +124 -0
- data/public/docs/core_concepts/legate_architecture_overview.md +110 -0
- data/public/docs/core_concepts/legate_configuration.md +116 -0
- data/public/docs/core_concepts/legate_definition_store.md +102 -0
- data/public/docs/core_concepts/legate_planner.md +94 -0
- data/public/docs/core_concepts/legate_session_service.md +104 -0
- data/public/docs/error_handling/legate_error_handling.md +122 -0
- data/public/docs/examples.md +199 -0
- data/public/docs/getting_started.md +111 -0
- data/public/docs/guides/agentic_agents.md +137 -0
- data/public/docs/guides/ai_code_generators.md +437 -0
- data/public/docs/guides/auto_loading.md +326 -0
- data/public/docs/guides/configuring_agent_webhooks.md +219 -0
- data/public/docs/guides/http_client_usage.md +264 -0
- data/public/docs/guides/llm_providers.md +137 -0
- data/public/docs/guides/mcp_client_integration.md +232 -0
- data/public/docs/guides/mcp_server_exposure.md +206 -0
- data/public/docs/guides/rails_integration.md +128 -0
- data/public/docs/guides/sending_outbound_webhooks.md +227 -0
- data/public/docs/guides/streaming.md +112 -0
- data/public/docs/guides/webhooks.md +288 -0
- data/public/docs/introduction.md +51 -0
- data/public/docs/multi_agent_systems/advanced_features.md +57 -0
- data/public/docs/multi_agent_systems/agent_delegation.md +190 -0
- data/public/docs/multi_agent_systems/agent_hierarchy.md +49 -0
- data/public/docs/multi_agent_systems/state_management.md +47 -0
- data/public/docs/multi_agent_systems/workflow_agents.md +72 -0
- data/public/docs/tools/legate_built_in_tools.md +332 -0
- data/public/docs/tools/legate_tools_and_registry.md +263 -0
- data/public/docs/web_ui/legate_web_ui.md +137 -0
- 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
|