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,43 @@
|
|
|
1
|
+
# File: lib/legate/rails/railtie.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# In a booted Rails app these are already loaded; require them explicitly so a
|
|
5
|
+
# cold `require 'legate/rails'` (e.g. in tests) doesn't trip on missing
|
|
6
|
+
# ActiveSupport core extensions that rails/railtie assumes.
|
|
7
|
+
require 'active_support'
|
|
8
|
+
require 'active_support/core_ext/module/delegation'
|
|
9
|
+
require 'rails/railtie'
|
|
10
|
+
|
|
11
|
+
module Legate
|
|
12
|
+
module Rails
|
|
13
|
+
# Integrates Legate with a host Rails application. Loaded via
|
|
14
|
+
# `require 'legate/rails'` (e.g. `gem 'legate', require: 'legate/rails'`),
|
|
15
|
+
# never by `require 'legate'`.
|
|
16
|
+
#
|
|
17
|
+
# It registers the `legate:install` generator and exposes `config.legate`.
|
|
18
|
+
# The wiring itself (pointing the session store at ActiveRecord, reading the
|
|
19
|
+
# API key) lives in the generated `config/initializers/legate.rb`, so apps
|
|
20
|
+
# stay in control — the Railtie forces nothing.
|
|
21
|
+
class Railtie < ::Rails::Railtie
|
|
22
|
+
config.legate = ::ActiveSupport::OrderedOptions.new
|
|
23
|
+
|
|
24
|
+
# Make `rails generate legate:install` discoverable.
|
|
25
|
+
generators do
|
|
26
|
+
require 'legate/generators/legate/install_generator'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Optional convenience: if the app sets `config.legate.use_active_record_store`
|
|
30
|
+
# truthy, point Legate at the ActiveRecord store after initialization
|
|
31
|
+
# (when the DB connection is ready). Apps that prefer to do this themselves
|
|
32
|
+
# in the initializer simply leave it unset.
|
|
33
|
+
initializer 'legate.session_store' do |app|
|
|
34
|
+
if app.config.legate.use_active_record_store
|
|
35
|
+
require 'legate/session_service/active_record'
|
|
36
|
+
Legate.configure do |c|
|
|
37
|
+
c.session_service = Legate::SessionService::ActiveRecord.new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/legate/rails.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# File: lib/legate/rails.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Opt-in Rails integration. Require this (not just 'legate') inside a Rails app —
|
|
5
|
+
# e.g. in the Gemfile: `gem 'legate', require: 'legate/rails'`. It loads the
|
|
6
|
+
# Railtie, which registers the `legate:install` generator and the optional
|
|
7
|
+
# ActiveRecord session-store wiring. `require 'legate'` alone never touches Rails.
|
|
8
|
+
require 'legate'
|
|
9
|
+
require_relative 'rails/railtie'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# File: lib/legate/redaction.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Legate
|
|
5
|
+
# Strips secrets out of strings before they're logged or surfaced to users.
|
|
6
|
+
#
|
|
7
|
+
# LLM/HTTP client errors routinely embed the request URL — which for Gemini
|
|
8
|
+
# carries the API key as a `?key=...` query parameter — so error messages and
|
|
9
|
+
# logs must be scrubbed before they leave the process.
|
|
10
|
+
module Redaction
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
REPLACEMENT = '[REDACTED]'
|
|
14
|
+
|
|
15
|
+
# `key=`, `api_key=`, `access_token=`, `token=` query/form parameters.
|
|
16
|
+
SECRET_PARAM = /([?&](?:key|api[_-]?key|access_token|token)=)[^&\s"']+/i
|
|
17
|
+
# `Authorization: Bearer <token>`.
|
|
18
|
+
BEARER = %r{(Bearer\s+)[A-Za-z0-9\-._~+/]+=*}i
|
|
19
|
+
# Google API keys by their `AIza` prefix — a belt-and-suspenders catch even
|
|
20
|
+
# if the key shows up somewhere the patterns above don't match.
|
|
21
|
+
GOOGLE_KEY = /AIza[0-9A-Za-z\-_]{10,}/
|
|
22
|
+
|
|
23
|
+
# @param text [Object] anything stringifiable
|
|
24
|
+
# @return [String] the text with known secret shapes replaced
|
|
25
|
+
def redact(text)
|
|
26
|
+
text.to_s
|
|
27
|
+
.gsub(SECRET_PARAM, "\\1#{REPLACEMENT}")
|
|
28
|
+
.gsub(BEARER, "\\1#{REPLACEMENT}")
|
|
29
|
+
.gsub(GOOGLE_KEY, REPLACEMENT)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# File: lib/legate/session.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'concurrent'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'json'
|
|
7
|
+
require_relative 'event' # Require the new Event class
|
|
8
|
+
require_relative 'errors'
|
|
9
|
+
|
|
10
|
+
module Legate
|
|
11
|
+
# Represents a single, ongoing conversation thread between a user and an agent system.
|
|
12
|
+
# It holds the history of interactions (Events) and temporary session-specific data (State).
|
|
13
|
+
class Session
|
|
14
|
+
VALID_PREFIXES = %w[user app temp].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :id, :app_name, :user_id, :created_at
|
|
17
|
+
attr_accessor :updated_at, :session_service
|
|
18
|
+
|
|
19
|
+
# Initializes a new session. Typically called by a SessionService.
|
|
20
|
+
# @param id [String] A unique identifier for the session (defaults to UUID).
|
|
21
|
+
# @param app_name [String] Identifier for the agent application.
|
|
22
|
+
# @param user_id [String] Identifier for the user initiating the session.
|
|
23
|
+
# @param initial_state [Hash] Optional initial data for the session state. Keys are symbolized.
|
|
24
|
+
# @param events [Array<Legate::Event>] Optional initial list of events (for reloading).
|
|
25
|
+
# @param session_service [Legate::SessionService::Base] The session service to use for persistence
|
|
26
|
+
def initialize(app_name:, user_id:, id: nil, initial_state: {}, events: [], session_service: nil)
|
|
27
|
+
@id = id || SecureRandom.uuid
|
|
28
|
+
@app_name = app_name
|
|
29
|
+
@user_id = user_id
|
|
30
|
+
@created_at = Time.now.utc # Use UTC
|
|
31
|
+
@updated_at = @created_at
|
|
32
|
+
@session_service = session_service
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
# Use Concurrent::Map for thread-safe state storage within the session object itself
|
|
35
|
+
@state = Concurrent::Map.new
|
|
36
|
+
# Ensure initial_state keys are symbols and manually populate the map
|
|
37
|
+
initial_state = {} unless initial_state.is_a?(Hash) # Ensure it's a hash
|
|
38
|
+
symbolized_initial_state = initial_state.transform_keys { |k|
|
|
39
|
+
begin
|
|
40
|
+
k.to_sym
|
|
41
|
+
rescue StandardError
|
|
42
|
+
k
|
|
43
|
+
end
|
|
44
|
+
}
|
|
45
|
+
symbolized_initial_state.each_pair do |key, value|
|
|
46
|
+
@state[key] = value
|
|
47
|
+
end
|
|
48
|
+
# Events array stores the history, ensure it's mutable if passed and validate contents
|
|
49
|
+
@events = events.map do |e|
|
|
50
|
+
if e.is_a?(Legate::Event)
|
|
51
|
+
e
|
|
52
|
+
else
|
|
53
|
+
(Legate.logger.warn("Session Init: Invalid event data skipped: #{e.inspect}")
|
|
54
|
+
nil)
|
|
55
|
+
end
|
|
56
|
+
end.compact
|
|
57
|
+
Legate.logger.debug("Session initialized: id=#{@id}, app=#{@app_name}, user=#{@user_id}, event_count=#{@events.size}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Thread-safe accessor for session events.
|
|
61
|
+
# @return [Array<Legate::Event>] A frozen snapshot of the event history.
|
|
62
|
+
def events
|
|
63
|
+
@mutex.synchronize { @events.dup.freeze }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Provides access to the session's temporary state data.
|
|
67
|
+
# @return [Hash] The current session state (immutable view).
|
|
68
|
+
def state
|
|
69
|
+
# Ensure external modifications don't affect internal state directly.
|
|
70
|
+
@state.dup # Return a shallow copy
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Adds an event to the session's history and updates state if needed.
|
|
74
|
+
# @param event [Legate::Event] The event to add
|
|
75
|
+
# @return [Legate::Event, nil] The added event or nil if invalid
|
|
76
|
+
def add_event(event)
|
|
77
|
+
return nil unless event.is_a?(Legate::Event)
|
|
78
|
+
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@events << event
|
|
81
|
+
@updated_at = Time.now.utc
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Apply the event's state delta OUTSIDE the mutex: update_state may call the
|
|
85
|
+
# session service (save_scoped_state), and holding the non-reentrant @mutex
|
|
86
|
+
# across that external call risks deadlock if a service implementation calls
|
|
87
|
+
# back into the session (e.g. #events/#to_h). @state is a Concurrent::Map, so
|
|
88
|
+
# it is safe without the lock.
|
|
89
|
+
update_state(event.state_delta) if event.state_delta && !event.state_delta.empty?
|
|
90
|
+
|
|
91
|
+
Legate.logger.debug("Session #{@id}: Event added - Role: #{event.role}, Tool: #{event.tool_name || 'N/A'}")
|
|
92
|
+
event
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- State Management Methods ---
|
|
96
|
+
|
|
97
|
+
# Gets a value from the session state.
|
|
98
|
+
# @param key [Symbol, String] The key to retrieve.
|
|
99
|
+
# @return [Object, nil] The value associated with the key, or nil if not found.
|
|
100
|
+
def get_state(key)
|
|
101
|
+
prefix, real_key = parse_key(key)
|
|
102
|
+
validate_prefix!(prefix) if prefix # match set/update/delete: reads and writes agree on what a key means
|
|
103
|
+
if prefix
|
|
104
|
+
@session_service&.load_scoped_state(scoped_namespace(prefix), real_key)
|
|
105
|
+
else
|
|
106
|
+
@state[real_key.to_sym]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Sets a value in the session state.
|
|
111
|
+
# @param key [Symbol, String] The key to set.
|
|
112
|
+
# @param value [Object] The value to store.
|
|
113
|
+
# @raise [Legate::StateValidationError] If the value cannot be serialized
|
|
114
|
+
# @raise [Legate::InvalidPrefixError] If an invalid prefix is used
|
|
115
|
+
# @return [Object] The value that was set.
|
|
116
|
+
def set_state(key, value)
|
|
117
|
+
validate_serializable!(value)
|
|
118
|
+
prefix, real_key = parse_key(key)
|
|
119
|
+
validate_prefix!(prefix) if prefix
|
|
120
|
+
|
|
121
|
+
touch!
|
|
122
|
+
if prefix
|
|
123
|
+
@session_service&.save_scoped_state(scoped_namespace(prefix), real_key, value)
|
|
124
|
+
else
|
|
125
|
+
@state[real_key.to_sym] = value
|
|
126
|
+
end
|
|
127
|
+
value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Merges a hash into the session state.
|
|
131
|
+
# @param hash [Hash] The hash to merge into the state.
|
|
132
|
+
# @raise [Legate::StateValidationError] If any value cannot be serialized
|
|
133
|
+
# @raise [Legate::InvalidPrefixError] If any key has an invalid prefix
|
|
134
|
+
def update_state(hash)
|
|
135
|
+
return unless hash.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
touch!
|
|
138
|
+
hash.each do |k, v|
|
|
139
|
+
validate_serializable!(v)
|
|
140
|
+
prefix, real_key = parse_key(k)
|
|
141
|
+
validate_prefix!(prefix) if prefix
|
|
142
|
+
|
|
143
|
+
if prefix
|
|
144
|
+
@session_service&.save_scoped_state(scoped_namespace(prefix), real_key, v)
|
|
145
|
+
else
|
|
146
|
+
@state[real_key.to_sym] = v
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Deletes a key from the session state.
|
|
152
|
+
# @param key [Symbol, String] The key to delete.
|
|
153
|
+
# @return [Object, nil] The value of the deleted key, or nil if not found.
|
|
154
|
+
def delete_state(key)
|
|
155
|
+
prefix, real_key = parse_key(key)
|
|
156
|
+
validate_prefix!(prefix) if prefix
|
|
157
|
+
|
|
158
|
+
touch!
|
|
159
|
+
if prefix
|
|
160
|
+
@session_service&.clear_scoped_state(scoped_namespace(prefix), real_key)
|
|
161
|
+
else
|
|
162
|
+
@state.delete(real_key.to_sym)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Clears all key-value pairs from the session state.
|
|
167
|
+
def clear_state!
|
|
168
|
+
touch!
|
|
169
|
+
@state.clear
|
|
170
|
+
VALID_PREFIXES.each do |prefix|
|
|
171
|
+
@session_service&.clear_scoped_state(scoped_namespace(prefix), '*')
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Provides a plain Hash representation of the current state.
|
|
176
|
+
# @return [Hash] A copy of the session state.
|
|
177
|
+
def state_to_h
|
|
178
|
+
# Convert Concurrent::Map to a regular Hash
|
|
179
|
+
Hash[@state.to_enum(:each_pair).to_a]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- Serialization Helpers ---
|
|
183
|
+
|
|
184
|
+
# Serializes the entire session object to a Hash suitable for JSON conversion.
|
|
185
|
+
# @return [Hash] Hash representation of the session.
|
|
186
|
+
def to_h
|
|
187
|
+
serialized_events = @mutex.synchronize { @events.map(&:to_h) }
|
|
188
|
+
{
|
|
189
|
+
id: @id,
|
|
190
|
+
app_name: @app_name,
|
|
191
|
+
user_id: @user_id,
|
|
192
|
+
created_at: @created_at.iso8601(3),
|
|
193
|
+
updated_at: @updated_at.iso8601(3),
|
|
194
|
+
state: state_to_h,
|
|
195
|
+
events: serialized_events
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Deserializes session data from a hash into a Session object.
|
|
200
|
+
# @param hash [Hash] Hash containing session data (typically from JSON).
|
|
201
|
+
# @return [Legate::Session] A new Session object.
|
|
202
|
+
def self.from_h(hash)
|
|
203
|
+
sym_hash = hash.transform_keys(&:to_sym) # Ensure keys are symbols
|
|
204
|
+
events_data = sym_hash[:events] || []
|
|
205
|
+
events = events_data.map { |event_hash| Legate::Event.from_h(event_hash.transform_keys(&:to_sym)) }.compact
|
|
206
|
+
|
|
207
|
+
new(
|
|
208
|
+
id: sym_hash[:id],
|
|
209
|
+
app_name: sym_hash[:app_name],
|
|
210
|
+
user_id: sym_hash[:user_id],
|
|
211
|
+
initial_state: sym_hash[:state] || {},
|
|
212
|
+
events: events
|
|
213
|
+
).tap do |session|
|
|
214
|
+
# Set timestamps after initialization
|
|
215
|
+
session.instance_variable_set(:@created_at, Time.iso8601(sym_hash[:created_at])) if sym_hash[:created_at]
|
|
216
|
+
session.instance_variable_set(:@updated_at, Time.iso8601(sym_hash[:updated_at])) if sym_hash[:updated_at]
|
|
217
|
+
end
|
|
218
|
+
rescue ArgumentError, TypeError => e
|
|
219
|
+
Legate.logger.error("Session.from_h: Failed to deserialize session data. Error: #{e.message}. Data: #{hash.inspect}")
|
|
220
|
+
nil # Return nil on deserialization error
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Records a state mutation by bumping @updated_at under the mutex, so the
|
|
226
|
+
# timestamp write doesn't race add_event's. The external scoped-state I/O is
|
|
227
|
+
# intentionally left outside the lock (see add_event).
|
|
228
|
+
def touch!
|
|
229
|
+
@mutex.synchronize { @updated_at = Time.now.utc }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def parse_key(key)
|
|
233
|
+
key_str = key.to_s
|
|
234
|
+
if key_str.include?(':')
|
|
235
|
+
prefix, real_key = key_str.split(':', 2)
|
|
236
|
+
[prefix, real_key]
|
|
237
|
+
else
|
|
238
|
+
[nil, key_str]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Builds the identity-qualified namespace passed to the session service for a
|
|
243
|
+
# scoped-state prefix, so scoped state is isolated by owner instead of sharing
|
|
244
|
+
# one global slot per prefix:
|
|
245
|
+
# user → per (app, user), shared across that user's sessions
|
|
246
|
+
# app → per app, shared across the app's users
|
|
247
|
+
# temp → per session
|
|
248
|
+
# The session service treats this whole string as an opaque scope, so wildcard
|
|
249
|
+
# clears (clear_scoped_state(ns, '*')) only touch the caller's own subtree.
|
|
250
|
+
# @param prefix [String] One of VALID_PREFIXES.
|
|
251
|
+
# @return [String] The namespace string.
|
|
252
|
+
def scoped_namespace(prefix)
|
|
253
|
+
case prefix
|
|
254
|
+
when 'user' then "user:#{escape_ns(@app_name)}:#{escape_ns(@user_id)}"
|
|
255
|
+
when 'app' then "app:#{escape_ns(@app_name)}"
|
|
256
|
+
when 'temp' then "temp:#{escape_ns(@id)}"
|
|
257
|
+
else prefix
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Escapes ':' in identity components so a crafted app_name/user_id/id cannot
|
|
262
|
+
# traverse into another owner's namespace.
|
|
263
|
+
def escape_ns(component)
|
|
264
|
+
component.to_s.gsub(':', '%3A')
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def validate_prefix!(prefix)
|
|
268
|
+
return if prefix.nil?
|
|
269
|
+
return if VALID_PREFIXES.include?(prefix)
|
|
270
|
+
|
|
271
|
+
raise Legate::InvalidPrefixError, "Invalid state key prefix: #{prefix}. Valid prefixes: #{VALID_PREFIXES.join(', ')}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Recursively checks if a value is JSON-serializable (basic types, nil, nested Hashes/Arrays thereof)
|
|
275
|
+
def is_json_serializable?(value)
|
|
276
|
+
case value
|
|
277
|
+
when String, Integer, TrueClass, FalseClass, NilClass
|
|
278
|
+
true
|
|
279
|
+
when Float
|
|
280
|
+
value.finite? # NaN/Infinity are Floats but JSON.generate raises on them — reject up front
|
|
281
|
+
when Hash
|
|
282
|
+
# Ensure all keys are strings/symbols and all values are serializable
|
|
283
|
+
value.all? { |k, v| (k.is_a?(String) || k.is_a?(Symbol)) && is_json_serializable?(v) }
|
|
284
|
+
when Array
|
|
285
|
+
value.all? { |v| is_json_serializable?(v) }
|
|
286
|
+
else
|
|
287
|
+
false # Other types (Time, Set, Object, etc.) are not serializable
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Raises error if value is not serializable
|
|
292
|
+
def validate_serializable!(value)
|
|
293
|
+
return if is_json_serializable?(value)
|
|
294
|
+
|
|
295
|
+
raise Legate::SerializationError,
|
|
296
|
+
"Value must be JSON-serializable (basic types, nil, Hash, Array): #{value.inspect}"
|
|
297
|
+
end
|
|
298
|
+
end # End Session class
|
|
299
|
+
end # End Legate module
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# File: lib/legate/session_service/active_record.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Opt-in: this file is NOT loaded by `require 'legate'`. Require it explicitly
|
|
5
|
+
# (and have ActiveRecord available + a connection established) to use a durable,
|
|
6
|
+
# ActiveRecord-backed session store. In Rails, `require 'legate/rails'` wires
|
|
7
|
+
# this up and `rails g legate:install` creates the migration.
|
|
8
|
+
require 'active_record'
|
|
9
|
+
require 'json'
|
|
10
|
+
require_relative 'base'
|
|
11
|
+
require_relative 'event_broadcast'
|
|
12
|
+
require_relative '../session'
|
|
13
|
+
require_relative '../event'
|
|
14
|
+
|
|
15
|
+
module Legate
|
|
16
|
+
module SessionService
|
|
17
|
+
# Durable session store backed by ActiveRecord (subsumes R2 persistence).
|
|
18
|
+
#
|
|
19
|
+
# Semantics mirror InMemory — repeated get_session within a process returns
|
|
20
|
+
# the same Session object so a run's mutations accumulate — but every
|
|
21
|
+
# mutation is written through to the database, so a restart or another
|
|
22
|
+
# process re-hydrates the committed history and state from rows.
|
|
23
|
+
#
|
|
24
|
+
# The host application owns the AR connection (Rails does this for you;
|
|
25
|
+
# standalone users call ActiveRecord::Base.establish_connection). Tables are
|
|
26
|
+
# created by the generated migration, or ad hoc via {.create_tables!}.
|
|
27
|
+
class ActiveRecord < Base
|
|
28
|
+
include EventBroadcast
|
|
29
|
+
|
|
30
|
+
# Abstract base so every Legate table shares the host's connection.
|
|
31
|
+
class Record < ::ActiveRecord::Base
|
|
32
|
+
self.abstract_class = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A persisted conversation (id is the Session UUID).
|
|
36
|
+
class SessionRecord < Record
|
|
37
|
+
self.table_name = 'legate_sessions'
|
|
38
|
+
serialize :state, coder: JSON
|
|
39
|
+
has_many :event_records, -> { order(:position) },
|
|
40
|
+
class_name: 'Legate::SessionService::ActiveRecord::EventRecord',
|
|
41
|
+
foreign_key: :legate_session_id, inverse_of: :session_record
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One appended event, ordered within its session by `position`.
|
|
45
|
+
class EventRecord < Record
|
|
46
|
+
self.table_name = 'legate_events'
|
|
47
|
+
serialize :content, coder: JSON
|
|
48
|
+
serialize :state_delta, coder: JSON
|
|
49
|
+
belongs_to :session_record,
|
|
50
|
+
class_name: 'Legate::SessionService::ActiveRecord::SessionRecord',
|
|
51
|
+
foreign_key: :legate_session_id, inverse_of: :event_records
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Scoped (user:/app:/temp:) state, keyed by the namespaced scope + key.
|
|
55
|
+
class ScopedStateRecord < Record
|
|
56
|
+
self.table_name = 'legate_scoped_states'
|
|
57
|
+
serialize :value, coder: JSON
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Creates the three Legate tables if absent. Convenience for tests and
|
|
61
|
+
# standalone (non-migration) setups; Rails apps use the generated migration.
|
|
62
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
63
|
+
def self.create_tables!(connection: Record.connection)
|
|
64
|
+
unless connection.table_exists?(:legate_sessions)
|
|
65
|
+
connection.create_table :legate_sessions, id: :string do |t|
|
|
66
|
+
t.string :app_name
|
|
67
|
+
t.string :user_id
|
|
68
|
+
t.text :state
|
|
69
|
+
t.timestamps
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
unless connection.table_exists?(:legate_events)
|
|
74
|
+
connection.create_table :legate_events do |t|
|
|
75
|
+
t.string :legate_session_id, null: false, index: true
|
|
76
|
+
t.integer :position, null: false, default: 0
|
|
77
|
+
t.string :role
|
|
78
|
+
t.text :content
|
|
79
|
+
t.string :tool_name
|
|
80
|
+
t.text :state_delta
|
|
81
|
+
t.string :event_timestamp
|
|
82
|
+
t.string :event_id
|
|
83
|
+
t.timestamps
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
return if connection.table_exists?(:legate_scoped_states)
|
|
88
|
+
|
|
89
|
+
connection.create_table :legate_scoped_states do |t|
|
|
90
|
+
t.string :scope, null: false
|
|
91
|
+
t.string :state_key, null: false
|
|
92
|
+
t.text :value
|
|
93
|
+
t.timestamps
|
|
94
|
+
end
|
|
95
|
+
connection.add_index :legate_scoped_states, %i[scope state_key], unique: true,
|
|
96
|
+
name: 'index_legate_scoped_states_on_scope_and_key'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Cap on cached Session objects. The cache gives a run a stable Session
|
|
100
|
+
# identity and avoids re-hydrating on every get_session; because every
|
|
101
|
+
# mutation is written through, an evicted session re-hydrates correctly
|
|
102
|
+
# from the database. LRU eviction only ever reclaims idle sessions — an
|
|
103
|
+
# active run keeps touching (and so keeps) its own session.
|
|
104
|
+
DEFAULT_MAX_CACHED_SESSIONS = 1_000
|
|
105
|
+
|
|
106
|
+
# @param max_cached_sessions [Integer] LRU bound for the in-process cache
|
|
107
|
+
def initialize(max_cached_sessions: DEFAULT_MAX_CACHED_SESSIONS)
|
|
108
|
+
super()
|
|
109
|
+
@cache = {} # insertion order = LRU order; guarded by @cache_mutex
|
|
110
|
+
@cache_mutex = Mutex.new
|
|
111
|
+
@max_cached_sessions = max_cached_sessions
|
|
112
|
+
Legate.logger.info('ActiveRecord session service initialized.')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def persistent?
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def create_session(app_name:, user_id:, session_id: nil, initial_state: {})
|
|
120
|
+
session = Legate::Session.new(
|
|
121
|
+
app_name: app_name, user_id: user_id, id: session_id,
|
|
122
|
+
initial_state: symbolize(initial_state), session_service: self
|
|
123
|
+
)
|
|
124
|
+
SessionRecord.create!(
|
|
125
|
+
id: session.id, app_name: app_name, user_id: user_id, state: session.state_to_h
|
|
126
|
+
)
|
|
127
|
+
cache_put(session.id, session)
|
|
128
|
+
Legate.logger.info("Created persistent session: #{session.id} for app:#{app_name}, user:#{user_id}")
|
|
129
|
+
session
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def get_session(session_id:)
|
|
133
|
+
cached = cache_get(session_id)
|
|
134
|
+
return cached if cached
|
|
135
|
+
|
|
136
|
+
record = SessionRecord.find_by(id: session_id)
|
|
137
|
+
unless record
|
|
138
|
+
Legate.logger.warn("Session not found: #{session_id}")
|
|
139
|
+
return nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
session = hydrate(record)
|
|
143
|
+
cache_put(session_id, session)
|
|
144
|
+
session
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def append_event(session_id:, event:)
|
|
148
|
+
session = get_session(session_id: session_id)
|
|
149
|
+
return false unless session
|
|
150
|
+
|
|
151
|
+
session.add_event(event) # merges state_delta into the cached Session
|
|
152
|
+
# The event row and the merged session state must land together, or
|
|
153
|
+
# neither — otherwise a crash between them leaves history and state
|
|
154
|
+
# inconsistent.
|
|
155
|
+
SessionRecord.transaction do
|
|
156
|
+
insert_event(session_id, event)
|
|
157
|
+
write_session_state(session_id, session)
|
|
158
|
+
end
|
|
159
|
+
broadcast_event(session_id, event) # notify any streaming subscribers (R3)
|
|
160
|
+
true
|
|
161
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
162
|
+
Legate.logger.error("ActiveRecord session service: append_event failed for '#{session_id}': #{e.message}")
|
|
163
|
+
# The write rolled back but the cached Session already holds the event;
|
|
164
|
+
# drop it so the next get_session re-hydrates the committed truth.
|
|
165
|
+
cache_evict(session_id)
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete_session(session_id:)
|
|
170
|
+
cache_evict(session_id)
|
|
171
|
+
EventRecord.where(legate_session_id: session_id).delete_all
|
|
172
|
+
deleted = SessionRecord.where(id: session_id).delete_all
|
|
173
|
+
if deleted.positive?
|
|
174
|
+
Legate.logger.info("Deleted persistent session: #{session_id}")
|
|
175
|
+
true
|
|
176
|
+
else
|
|
177
|
+
Legate.logger.warn("Attempted to delete non-existent session: #{session_id}")
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def list_sessions(app_name: nil, user_id: nil)
|
|
183
|
+
scope = SessionRecord.all
|
|
184
|
+
scope = scope.where(app_name: app_name) if app_name
|
|
185
|
+
scope = scope.where(user_id: user_id) if user_id
|
|
186
|
+
scope.pluck(:id).filter_map { |id| get_session(session_id: id) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def save_scoped_state(scope, key, value)
|
|
190
|
+
record = ScopedStateRecord.find_or_initialize_by(scope: scope.to_s, state_key: key.to_s)
|
|
191
|
+
record.value = value
|
|
192
|
+
record.save!
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def load_scoped_state(scope, key)
|
|
196
|
+
ScopedStateRecord.find_by(scope: scope.to_s, state_key: key.to_s)&.value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def clear_scoped_state(scope, key)
|
|
200
|
+
relation = ScopedStateRecord.where(scope: scope.to_s)
|
|
201
|
+
relation = relation.where(state_key: key.to_s) unless key == '*'
|
|
202
|
+
relation.delete_all
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def set_state(session_id:, key:, value:)
|
|
206
|
+
session = get_session(session_id: session_id)
|
|
207
|
+
unless session
|
|
208
|
+
Legate.logger.warn("ActiveRecord session service: Session not found '#{session_id}' when setting state for '#{key}'.")
|
|
209
|
+
return nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
session.set_state(key, value)
|
|
213
|
+
# Scoped keys are already persisted via save_scoped_state; write through
|
|
214
|
+
# the plain-key state so it survives a restart.
|
|
215
|
+
write_session_state(session_id, session)
|
|
216
|
+
nil
|
|
217
|
+
rescue Legate::SerializationError => e
|
|
218
|
+
Legate.logger.error("ActiveRecord session service: Error setting state for '#{session_id}', key '#{key}': #{e.message}")
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def get_state(session_id:, key:)
|
|
223
|
+
session = get_session(session_id: session_id)
|
|
224
|
+
return session.get_state(key) if session
|
|
225
|
+
|
|
226
|
+
Legate.logger.warn("ActiveRecord session service: Session not found '#{session_id}' when getting state for '#{key}'.")
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
# --- Bounded LRU session cache (guarded by @cache_mutex) ---
|
|
233
|
+
|
|
234
|
+
# Return the cached session and mark it most-recently-used.
|
|
235
|
+
def cache_get(session_id)
|
|
236
|
+
@cache_mutex.synchronize do
|
|
237
|
+
session = @cache.delete(session_id)
|
|
238
|
+
@cache[session_id] = session if session # reinsert at the tail (MRU)
|
|
239
|
+
session
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Cache the session as most-recently-used, evicting the LRU entry if over cap.
|
|
244
|
+
def cache_put(session_id, session)
|
|
245
|
+
@cache_mutex.synchronize do
|
|
246
|
+
@cache.delete(session_id)
|
|
247
|
+
@cache[session_id] = session
|
|
248
|
+
@cache.delete(@cache.keys.first) if @cache.size > @max_cached_sessions
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def cache_evict(session_id)
|
|
253
|
+
@cache_mutex.synchronize { @cache.delete(session_id) }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def insert_event(session_id, event)
|
|
257
|
+
next_position = (EventRecord.where(legate_session_id: session_id).maximum(:position) || 0) + 1
|
|
258
|
+
EventRecord.create!(
|
|
259
|
+
legate_session_id: session_id, position: next_position,
|
|
260
|
+
role: event.role&.to_s, content: event.content, tool_name: event.tool_name&.to_s,
|
|
261
|
+
state_delta: event.state_delta, event_timestamp: event.timestamp&.iso8601(3), event_id: event.event_id
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Write-through the session's plain-key state (scoped state is persisted
|
|
266
|
+
# separately via save_scoped_state). Shared by append_event and set_state.
|
|
267
|
+
def write_session_state(session_id, session)
|
|
268
|
+
SessionRecord.find_by(id: session_id)&.update!(state: session.state_to_h, updated_at: session.updated_at)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def hydrate(record)
|
|
272
|
+
events = record.event_records.map { |er| hydrate_event(er) }.compact
|
|
273
|
+
session = Legate::Session.new(
|
|
274
|
+
id: record.id, app_name: record.app_name, user_id: record.user_id,
|
|
275
|
+
initial_state: record.state || {}, events: events, session_service: self
|
|
276
|
+
)
|
|
277
|
+
session.instance_variable_set(:@created_at, record.created_at) if record.created_at
|
|
278
|
+
session.updated_at = record.updated_at if record.updated_at
|
|
279
|
+
session
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def hydrate_event(record)
|
|
283
|
+
Legate::Event.from_h(
|
|
284
|
+
role: record.role, content: record.content, timestamp: record.event_timestamp,
|
|
285
|
+
tool_name: record.tool_name, state_delta: record.state_delta, event_id: record.event_id
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def symbolize(hash)
|
|
290
|
+
return {} unless hash.is_a?(Hash)
|
|
291
|
+
|
|
292
|
+
hash.transform_keys do |k|
|
|
293
|
+
k.to_sym
|
|
294
|
+
rescue StandardError
|
|
295
|
+
k
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|