legate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +345 -0
  4. data/bin/legate +13 -0
  5. data/examples/00_quickstart.rb +51 -0
  6. data/examples/01_simple_agent.rb +105 -0
  7. data/examples/02_multi_tool_agent.rb +140 -0
  8. data/examples/03_custom_tool.rb +93 -0
  9. data/examples/04_agent_instructions.rb +84 -0
  10. data/examples/05_state_and_sessions.rb +91 -0
  11. data/examples/06_callbacks.rb +186 -0
  12. data/examples/07_async_jobs.rb +112 -0
  13. data/examples/08_loop_agent.rb +197 -0
  14. data/examples/09_sequential_workflow.rb +40 -0
  15. data/examples/10_parallel_workflow.rb +34 -0
  16. data/examples/11_agent_delegation.rb +24 -0
  17. data/examples/12_http_client_tool.rb +156 -0
  18. data/examples/13_authentication.rb +220 -0
  19. data/examples/14_mcp_client.rb +154 -0
  20. data/examples/15_mcp_server.rb +79 -0
  21. data/examples/16_webhooks.rb +91 -0
  22. data/examples/README_sequential_agents.md +164 -0
  23. data/examples/advanced/auth/cookie_auth_tool.rb +146 -0
  24. data/examples/advanced/auth/custom_auth_flows_example.rb +626 -0
  25. data/examples/advanced/auth/excon_middleware.rb +317 -0
  26. data/examples/advanced/auth/excon_middleware_auth.rb +399 -0
  27. data/examples/advanced/auth/fiber_auth_example.rb +281 -0
  28. data/examples/advanced/auth/fiber_oidc_example.rb +403 -0
  29. data/examples/advanced/auth/httpbin_bearer_tool.rb +159 -0
  30. data/examples/advanced/auth/oauth2_auth.rb +419 -0
  31. data/examples/advanced/auth/oidc_auth.rb +514 -0
  32. data/examples/advanced/auth/openweather_api.rb +251 -0
  33. data/examples/advanced/auth/openweather_tool.rb +153 -0
  34. data/examples/advanced/auth/query_param_middleware_test.rb +138 -0
  35. data/examples/advanced/auth/service_account.rb +135 -0
  36. data/examples/advanced/auth/test_with_httpbin.rb +202 -0
  37. data/examples/advanced/auth/token_lifecycle_example.rb +428 -0
  38. data/examples/advanced/callback_monitoring.rb +679 -0
  39. data/examples/advanced/mas/fixed_delegation_example.rb +191 -0
  40. data/examples/advanced/mas/loop_workflow.rb +28 -0
  41. data/examples/advanced/mas/mock_planner.rb +77 -0
  42. data/examples/advanced/mas/proper_delegation_example.rb +276 -0
  43. data/examples/advanced/mcp/legate_mcp_server_resource_example.rb +182 -0
  44. data/examples/advanced/mcp/mcp_resource_server_example.rb +309 -0
  45. data/examples/advanced/mcp/mcp_server_async.rb +76 -0
  46. data/examples/advanced/mcp/mcp_server_async_tools.rb +122 -0
  47. data/examples/advanced/mcp/mcp_server_legate_agent.rb +95 -0
  48. data/examples/advanced/mcp/mcp_server_rack.rb +89 -0
  49. data/examples/advanced/random_calculator.rb +104 -0
  50. data/examples/advanced/sleep_agent.rb +153 -0
  51. data/examples/advanced/webhooks/webhook_e2e_runner.rb +110 -0
  52. data/examples/advanced/webhooks/webhook_receiver_agent.rb +58 -0
  53. data/examples/advanced/workflows/task_refinement_loop_agent.rb +278 -0
  54. data/examples/advanced/workflows/travel_planner_auto_sequential.rb +444 -0
  55. data/examples/advanced/workflows/travel_planner_parallel.rb +656 -0
  56. data/examples/advanced/workflows/travel_planner_sequential.rb +512 -0
  57. data/examples/tools/oauth2_example.rb +136 -0
  58. data/examples/tools/sleepy_tool.rb +42 -0
  59. data/lib/legate/activity_log.rb +71 -0
  60. data/lib/legate/agent.rb +959 -0
  61. data/lib/legate/agent_code_generator.rb +185 -0
  62. data/lib/legate/agent_definition.rb +812 -0
  63. data/lib/legate/agentic/decision.rb +49 -0
  64. data/lib/legate/agentic/loop.rb +134 -0
  65. data/lib/legate/agentic.rb +5 -0
  66. data/lib/legate/agents/loop_agent.rb +248 -0
  67. data/lib/legate/agents/parallel_agent.rb +163 -0
  68. data/lib/legate/agents/sequential_agent.rb +190 -0
  69. data/lib/legate/agents.rb +14 -0
  70. data/lib/legate/auth/config.rb +148 -0
  71. data/lib/legate/auth/coordinator.rb +218 -0
  72. data/lib/legate/auth/coordinators/oauth2_coordinator.rb +99 -0
  73. data/lib/legate/auth/coordinators/oidc_coordinator.rb +68 -0
  74. data/lib/legate/auth/coordinators/service_account_coordinator.rb +122 -0
  75. data/lib/legate/auth/credential.rb +157 -0
  76. data/lib/legate/auth/encryption.rb +108 -0
  77. data/lib/legate/auth/error.rb +94 -0
  78. data/lib/legate/auth/exchanged_credential.rb +180 -0
  79. data/lib/legate/auth/excon_middleware.rb +285 -0
  80. data/lib/legate/auth/http_client_utils.rb +364 -0
  81. data/lib/legate/auth/manager.rb +531 -0
  82. data/lib/legate/auth/manager_store.rb +394 -0
  83. data/lib/legate/auth/middleware_factory.rb +290 -0
  84. data/lib/legate/auth/runner.rb +279 -0
  85. data/lib/legate/auth/scheme.rb +125 -0
  86. data/lib/legate/auth/schemes/api_key.rb +212 -0
  87. data/lib/legate/auth/schemes/google_service_account.rb +108 -0
  88. data/lib/legate/auth/schemes/http_bearer.rb +98 -0
  89. data/lib/legate/auth/schemes/oauth2.rb +396 -0
  90. data/lib/legate/auth/schemes/openid_connect.rb +346 -0
  91. data/lib/legate/auth/schemes/service_account.rb +388 -0
  92. data/lib/legate/auth/schemes.rb +40 -0
  93. data/lib/legate/auth/token_manager.rb +362 -0
  94. data/lib/legate/auth/token_store.rb +86 -0
  95. data/lib/legate/auth/tool_context_extension.rb +97 -0
  96. data/lib/legate/auth/tool_integration.rb +188 -0
  97. data/lib/legate/auth/url_guard.rb +81 -0
  98. data/lib/legate/auth.rb +453 -0
  99. data/lib/legate/callbacks/callback_context.rb +71 -0
  100. data/lib/legate/cli/agent_commands.rb +950 -0
  101. data/lib/legate/cli/auth_commands.rb +520 -0
  102. data/lib/legate/cli/base_command.rb +24 -0
  103. data/lib/legate/cli/deployment_commands.rb +934 -0
  104. data/lib/legate/cli/output_helper.rb +108 -0
  105. data/lib/legate/cli/session_commands.rb +138 -0
  106. data/lib/legate/cli/skaffold_commands.rb +223 -0
  107. data/lib/legate/cli/tool_commands.rb +261 -0
  108. data/lib/legate/cli/web_commands.rb +182 -0
  109. data/lib/legate/cli.rb +40 -0
  110. data/lib/legate/configuration/webhooks.rb +113 -0
  111. data/lib/legate/configuration.rb +39 -0
  112. data/lib/legate/definition_store.rb +23 -0
  113. data/lib/legate/errors.rb +118 -0
  114. data/lib/legate/event.rb +161 -0
  115. data/lib/legate/gemini_ai_beta_patch.rb +39 -0
  116. data/lib/legate/generators/agent_generator.rb +412 -0
  117. data/lib/legate/generators/code_validator.rb +48 -0
  118. data/lib/legate/generators/legate/install_generator.rb +35 -0
  119. data/lib/legate/generators/legate/templates/create_legate_tables.rb.tt +36 -0
  120. data/lib/legate/generators/legate/templates/initializer.rb +18 -0
  121. data/lib/legate/generators/runtime_tool_loader.rb +76 -0
  122. data/lib/legate/generators/tool_generator.rb +408 -0
  123. data/lib/legate/generators.rb +11 -0
  124. data/lib/legate/global_definition_registry.rb +506 -0
  125. data/lib/legate/global_tool_manager.rb +135 -0
  126. data/lib/legate/llm/adapter.rb +69 -0
  127. data/lib/legate/llm/gemini.rb +172 -0
  128. data/lib/legate/llm/ollama.rb +80 -0
  129. data/lib/legate/llm.rb +34 -0
  130. data/lib/legate/mcp/client.rb +320 -0
  131. data/lib/legate/mcp/connection/sse.rb +292 -0
  132. data/lib/legate/mcp/connection/stdio.rb +273 -0
  133. data/lib/legate/mcp/connection_manager.rb +103 -0
  134. data/lib/legate/mcp/server/legate_agent_adapter.rb +170 -0
  135. data/lib/legate/mcp/server/legate_direct_agent_adapter.rb +140 -0
  136. data/lib/legate/mcp/server/legate_tool_adapter.rb +119 -0
  137. data/lib/legate/mcp/tool_wrapper.rb +138 -0
  138. data/lib/legate/mcp/util/schema_converter.rb +134 -0
  139. data/lib/legate/mcp.rb +23 -0
  140. data/lib/legate/plan_executor.rb +375 -0
  141. data/lib/legate/planner.rb +839 -0
  142. data/lib/legate/rails/railtie.rb +43 -0
  143. data/lib/legate/rails.rb +9 -0
  144. data/lib/legate/redaction.rb +32 -0
  145. data/lib/legate/session.rb +299 -0
  146. data/lib/legate/session_service/active_record.rb +300 -0
  147. data/lib/legate/session_service/base.rb +68 -0
  148. data/lib/legate/session_service/event_broadcast.rb +74 -0
  149. data/lib/legate/session_service/in_memory.rb +188 -0
  150. data/lib/legate/tool/metadata_dsl.rb +122 -0
  151. data/lib/legate/tool.rb +276 -0
  152. data/lib/legate/tool_code_generator.rb +103 -0
  153. data/lib/legate/tool_context.rb +350 -0
  154. data/lib/legate/tool_loader.rb +39 -0
  155. data/lib/legate/tool_registry.rb +73 -0
  156. data/lib/legate/tool_result.rb +61 -0
  157. data/lib/legate/tools/agent_tool.rb +187 -0
  158. data/lib/legate/tools/base/http_client.rb +319 -0
  159. data/lib/legate/tools/base/safe_url.rb +56 -0
  160. data/lib/legate/tools/base_async_job_tool.rb +91 -0
  161. data/lib/legate/tools/calculator.rb +89 -0
  162. data/lib/legate/tools/cat_facts.rb +81 -0
  163. data/lib/legate/tools/check_job_status_tool.rb +48 -0
  164. data/lib/legate/tools/current_time_tool.rb +64 -0
  165. data/lib/legate/tools/echo.rb +43 -0
  166. data/lib/legate/tools/http_request_tool.rb +105 -0
  167. data/lib/legate/tools/random_number_tool.rb +64 -0
  168. data/lib/legate/tools/read_webpage_tool.rb +92 -0
  169. data/lib/legate/tools/sleepy_tool.rb +74 -0
  170. data/lib/legate/tools/webhook_tool.rb +146 -0
  171. data/lib/legate/version.rb +5 -0
  172. data/lib/legate/web/app.rb +984 -0
  173. data/lib/legate/web/public/css/main.css +4980 -0
  174. data/lib/legate/web/public/images/favicon-256.png +0 -0
  175. data/lib/legate/web/public/images/favicon-32.png +0 -0
  176. data/lib/legate/web/public/images/legate-logo-dark.png +0 -0
  177. data/lib/legate/web/public/images/legate-logo-light.png +0 -0
  178. data/lib/legate/web/public/js/legate.js +616 -0
  179. data/lib/legate/web/public/styles/main.scss +4402 -0
  180. data/lib/legate/web/routes/agent_authentication_routes.rb +530 -0
  181. data/lib/legate/web/routes/agent_definition_routes.rb +803 -0
  182. data/lib/legate/web/routes/agent_generator_routes.rb +80 -0
  183. data/lib/legate/web/routes/agent_interaction_routes.rb +734 -0
  184. data/lib/legate/web/routes/agent_runtime_routes.rb +323 -0
  185. data/lib/legate/web/routes/api_routes.rb +56 -0
  186. data/lib/legate/web/routes/authentication_routes.rb +1541 -0
  187. data/lib/legate/web/routes/core_routes.rb +111 -0
  188. data/lib/legate/web/routes/documentation_routes.rb +220 -0
  189. data/lib/legate/web/routes/tool_generator_routes.rb +81 -0
  190. data/lib/legate/web/routes/tools_ui_routes.rb +207 -0
  191. data/lib/legate/web/sass_compiler.rb +73 -0
  192. data/lib/legate/web/views/_active_session_info.slim +25 -0
  193. data/lib/legate/web/views/_activity_list.slim +55 -0
  194. data/lib/legate/web/views/_agent_card.slim +56 -0
  195. data/lib/legate/web/views/_agent_generator_modal.slim +382 -0
  196. data/lib/legate/web/views/_agent_status_controls.slim +71 -0
  197. data/lib/legate/web/views/_agent_tool_table.slim +74 -0
  198. data/lib/legate/web/views/_chat_message.slim +95 -0
  199. data/lib/legate/web/views/_display_agent_configuration.slim +26 -0
  200. data/lib/legate/web/views/_display_agent_description.slim +11 -0
  201. data/lib/legate/web/views/_display_agent_fallback.slim +15 -0
  202. data/lib/legate/web/views/_display_agent_hierarchy.slim +93 -0
  203. data/lib/legate/web/views/_display_agent_instruction.slim +17 -0
  204. data/lib/legate/web/views/_display_agent_mcp.slim +13 -0
  205. data/lib/legate/web/views/_display_agent_model.slim +17 -0
  206. data/lib/legate/web/views/_display_agent_name.slim +42 -0
  207. data/lib/legate/web/views/_display_agent_output_key.slim +26 -0
  208. data/lib/legate/web/views/_display_agent_type.slim +65 -0
  209. data/lib/legate/web/views/_edit_agent_configuration.slim +74 -0
  210. data/lib/legate/web/views/_edit_agent_description.slim +16 -0
  211. data/lib/legate/web/views/_edit_agent_fallback.slim +25 -0
  212. data/lib/legate/web/views/_edit_agent_hierarchy.slim +98 -0
  213. data/lib/legate/web/views/_edit_agent_instruction.slim +49 -0
  214. data/lib/legate/web/views/_edit_agent_mcp.slim +33 -0
  215. data/lib/legate/web/views/_edit_agent_model.slim +23 -0
  216. data/lib/legate/web/views/_edit_agent_output_key.slim +36 -0
  217. data/lib/legate/web/views/_edit_agent_tools.slim +40 -0
  218. data/lib/legate/web/views/_edit_agent_type.slim +67 -0
  219. data/lib/legate/web/views/_session_error.slim +4 -0
  220. data/lib/legate/web/views/_skeleton.slim +69 -0
  221. data/lib/legate/web/views/_tool_card.slim +9 -0
  222. data/lib/legate/web/views/_tool_generator_modal.slim +311 -0
  223. data/lib/legate/web/views/agent.slim +436 -0
  224. data/lib/legate/web/views/agent_auth.slim +562 -0
  225. data/lib/legate/web/views/agents.slim +369 -0
  226. data/lib/legate/web/views/auth.slim +112 -0
  227. data/lib/legate/web/views/auth_credential_detail.slim +327 -0
  228. data/lib/legate/web/views/auth_credentials.slim +261 -0
  229. data/lib/legate/web/views/auth_debug.slim +94 -0
  230. data/lib/legate/web/views/auth_mapping_detail.slim +151 -0
  231. data/lib/legate/web/views/auth_mapping_new.slim +123 -0
  232. data/lib/legate/web/views/auth_mappings.slim +120 -0
  233. data/lib/legate/web/views/auth_scheme_detail.slim +274 -0
  234. data/lib/legate/web/views/auth_schemes.slim +259 -0
  235. data/lib/legate/web/views/auth_test.slim +418 -0
  236. data/lib/legate/web/views/chat.slim +192 -0
  237. data/lib/legate/web/views/docs_index.slim +105 -0
  238. data/lib/legate/web/views/docs_show.slim +105 -0
  239. data/lib/legate/web/views/error_404.slim +5 -0
  240. data/lib/legate/web/views/index.slim +148 -0
  241. data/lib/legate/web/views/layout.slim +144 -0
  242. data/lib/legate/web/views/tool_detail.slim +87 -0
  243. data/lib/legate/web/views/tools.slim +50 -0
  244. data/lib/legate/web/webhook_listener.rb +367 -0
  245. data/lib/legate/web.rb +9 -0
  246. data/lib/legate.rb +220 -0
  247. data/public/docs/advanced/callbacks.md +828 -0
  248. data/public/docs/advanced/mcp_schema_conversion.md +59 -0
  249. data/public/docs/authentication/api_reference/config.md +210 -0
  250. data/public/docs/authentication/api_reference/credential.md +246 -0
  251. data/public/docs/authentication/api_reference/encryption.md +218 -0
  252. data/public/docs/authentication/api_reference/exchanged_credential.md +271 -0
  253. data/public/docs/authentication/api_reference/excon_middleware.md +175 -0
  254. data/public/docs/authentication/api_reference/index.md +30 -0
  255. data/public/docs/authentication/api_reference/scheme.md +250 -0
  256. data/public/docs/authentication/api_reference/schemes/api_key.md +175 -0
  257. data/public/docs/authentication/api_reference/schemes/google_service_account.md +221 -0
  258. data/public/docs/authentication/api_reference/schemes/http_bearer.md +169 -0
  259. data/public/docs/authentication/api_reference/schemes/oauth2.md +343 -0
  260. data/public/docs/authentication/api_reference/schemes/oidc.md +73 -0
  261. data/public/docs/authentication/api_reference/schemes/openid_connect.md +311 -0
  262. data/public/docs/authentication/api_reference/schemes/service_account.md +287 -0
  263. data/public/docs/authentication/api_reference/token_manager.md +221 -0
  264. data/public/docs/authentication/api_reference/token_store.md +146 -0
  265. data/public/docs/authentication/api_reference/tool_context_extension.md +166 -0
  266. data/public/docs/authentication/guides/api_key.md +190 -0
  267. data/public/docs/authentication/guides/bearer.md +172 -0
  268. data/public/docs/authentication/guides/configuration.md +255 -0
  269. data/public/docs/authentication/guides/custom_flow.md +523 -0
  270. data/public/docs/authentication/guides/index.md +24 -0
  271. data/public/docs/authentication/guides/migration.md +435 -0
  272. data/public/docs/authentication/guides/oauth2.md +252 -0
  273. data/public/docs/authentication/guides/oidc.md +241 -0
  274. data/public/docs/authentication/guides/overview.md +155 -0
  275. data/public/docs/authentication/guides/secure_storage.md +301 -0
  276. data/public/docs/authentication/guides/service_account.md +228 -0
  277. data/public/docs/authentication/guides/token_lifecycle.md +295 -0
  278. data/public/docs/authentication/guides/web_ui_integration.md +504 -0
  279. data/public/docs/authentication/index.md +58 -0
  280. data/public/docs/authentication/troubleshooting/credential_storage.md +550 -0
  281. data/public/docs/authentication/troubleshooting/environment_variables.md +540 -0
  282. data/public/docs/authentication/troubleshooting/index.md +11 -0
  283. data/public/docs/authentication/troubleshooting/oauth2_issues.md +220 -0
  284. data/public/docs/authentication/troubleshooting/oidc_issues.md +412 -0
  285. data/public/docs/authentication/troubleshooting/token_refresh.md +338 -0
  286. data/public/docs/cli/legate_cli_usage.md +363 -0
  287. data/public/docs/core_concepts/legate_agent_lifecycle.md +124 -0
  288. data/public/docs/core_concepts/legate_architecture_overview.md +110 -0
  289. data/public/docs/core_concepts/legate_configuration.md +116 -0
  290. data/public/docs/core_concepts/legate_definition_store.md +102 -0
  291. data/public/docs/core_concepts/legate_planner.md +94 -0
  292. data/public/docs/core_concepts/legate_session_service.md +104 -0
  293. data/public/docs/error_handling/legate_error_handling.md +122 -0
  294. data/public/docs/examples.md +199 -0
  295. data/public/docs/getting_started.md +111 -0
  296. data/public/docs/guides/agentic_agents.md +137 -0
  297. data/public/docs/guides/ai_code_generators.md +437 -0
  298. data/public/docs/guides/auto_loading.md +326 -0
  299. data/public/docs/guides/configuring_agent_webhooks.md +219 -0
  300. data/public/docs/guides/http_client_usage.md +264 -0
  301. data/public/docs/guides/llm_providers.md +137 -0
  302. data/public/docs/guides/mcp_client_integration.md +232 -0
  303. data/public/docs/guides/mcp_server_exposure.md +206 -0
  304. data/public/docs/guides/rails_integration.md +128 -0
  305. data/public/docs/guides/sending_outbound_webhooks.md +227 -0
  306. data/public/docs/guides/streaming.md +112 -0
  307. data/public/docs/guides/webhooks.md +288 -0
  308. data/public/docs/introduction.md +51 -0
  309. data/public/docs/multi_agent_systems/advanced_features.md +57 -0
  310. data/public/docs/multi_agent_systems/agent_delegation.md +190 -0
  311. data/public/docs/multi_agent_systems/agent_hierarchy.md +49 -0
  312. data/public/docs/multi_agent_systems/state_management.md +47 -0
  313. data/public/docs/multi_agent_systems/workflow_agents.md +72 -0
  314. data/public/docs/tools/legate_built_in_tools.md +332 -0
  315. data/public/docs/tools/legate_tools_and_registry.md +263 -0
  316. data/public/docs/web_ui/legate_web_ui.md +137 -0
  317. metadata +823 -0
@@ -0,0 +1,984 @@
1
+ # File: lib/legate/web/app.rb
2
+ # frozen_string_literal: true
3
+
4
+ # This file defines the main Sinatra application for the Legate Web UI.
5
+ # It handles agent definition management (via GlobalDefinitionRegistry), runtime management (in-memory),
6
+ # user interactions (chat, direct execution), tool discovery (native and MCP),
7
+ # and provides a dynamic web interface using HTMX.
8
+
9
+ # STDOUT.sync = true # Uncomment for immediate output flushing if needed
10
+ # --- Core Web Framework Dependencies ---
11
+ require 'openssl' # session-secret key derivation
12
+ require 'sinatra/base'
13
+ require 'sinatra/json'
14
+ require 'sinatra/custom_logger' # For using helpers Sinatra::CustomLogger
15
+ require 'sinatra/reloader'
16
+ require 'slim' # Templating engine
17
+ require 'json'
18
+ require_relative 'sass_compiler' # For compiling Sass/SCSS to CSS
19
+ require 'rack/utils' # For escape_html
20
+ require 'securerandom' # For session secret generation
21
+ require_relative '../mcp/util/schema_converter' # For converting MCP tool schemas
22
+ require_relative '../llm' # LLM provider adapters (example task generation, etc.)
23
+
24
+ # --- Load Legate Components ---
25
+ # Order matters: Load core concepts before components that depend on them.
26
+ require_relative '../event' # Core event structure used by sessions
27
+ require_relative '../session' # Session structure for conversation history
28
+ require_relative '../tool_context' # Context object passed to tools during execution
29
+ require_relative '../agent' # Core Agent class (defines DEFAULT_MODEL)
30
+ require_relative '../tool' # Base Tool class
31
+ require_relative '../tool_registry' # Manages tools within an agent instance
32
+ require_relative '../session_service/in_memory' # Default in-memory session storage
33
+ require_relative '../global_tool_manager' # Discovers and manages native tools available to the application
34
+ # --- Load Authentication System ---
35
+ require_relative '../auth/manager' # Authentication manager for handling authentication schemes and credentials
36
+ require_relative '../auth/manager_store' # Persistence for authentication configuration
37
+ # Explicitly require built-in native tools so GlobalToolManager can find them
38
+ require_relative '../tools/echo'
39
+ require_relative '../tools/calculator'
40
+ require_relative '../tools/cat_facts'
41
+ require_relative '../tools/random_number_tool'
42
+ require_relative '../tools/agent_tool' # Tool that allows an agent to call another agent
43
+ require_relative '../activity_log' # Activity logging for dashboard
44
+ require_relative '../tools/base_async_job_tool' # Base class for tools that run asynchronously
45
+ require_relative '../tools/check_job_status_tool' # Tool to check the status of async jobs
46
+ require_relative '../tools/sleepy_tool' # Example async tool
47
+ # --- Require GlobalDefinitionRegistry (replaces Redis-based DefinitionStore) ---
48
+ require_relative '../global_definition_registry'
49
+
50
+ # --- Route Modules ---
51
+ require_relative 'routes/core_routes'
52
+ require_relative 'routes/api_routes'
53
+ require_relative 'routes/tools_ui_routes'
54
+ require_relative 'routes/agent_runtime_routes'
55
+ require_relative 'routes/agent_definition_routes'
56
+ require_relative 'routes/agent_interaction_routes'
57
+ require_relative 'routes/documentation_routes'
58
+ require_relative 'routes/authentication_routes'
59
+ require_relative 'routes/agent_authentication_routes'
60
+ require_relative 'routes/agent_generator_routes'
61
+ require_relative 'routes/tool_generator_routes'
62
+
63
+ # Load dotenv for development environment variables
64
+ begin; require 'dotenv/load'; rescue LoadError; end if ENV['RACK_ENV'] == 'development' || Sinatra::Base.development?
65
+
66
+ module Legate
67
+ module Web
68
+ # Sinatra application providing a web UI for managing and interacting with Legate Agents.
69
+ # Uses GlobalDefinitionRegistry for agent definitions and an in-memory hash for running agent instances.
70
+ # Leverages HTMX for dynamic UI updates and communicates with external tools via MCP.
71
+ class App < Sinatra::Base
72
+ helpers Sinatra::CustomLogger # Integrate Sinatra logging with the central Legate logger
73
+
74
+ # Development-specific configurations
75
+ configure :development do
76
+ register Sinatra::Reloader # Enable automatic code reloading
77
+ # Optional: Increase logging level specifically for development web server
78
+ # Legate.logger.level = Logger::DEBUG if Legate.logger
79
+ end
80
+
81
+ # General configurations for all environments
82
+ configure do
83
+ set :logger, Legate.logger # Use the central Legate logger
84
+ # Sinatra 4 / rack-protection 4 enable Host authorization by default,
85
+ # which 403s any request whose Host header isn't explicitly permitted.
86
+ # This dev tool is reached via localhost, a LAN IP, or whatever host the
87
+ # operator binds, so permit all hosts (the prior Sinatra 3 posture). The
88
+ # web UI is not meant to face untrusted networks — see the security model.
89
+ set :host_authorization, { permitted_hosts: [] }
90
+ # Session cookie for storing the per-browser user id and CSRF token.
91
+ # httponly + SameSite blunt cookie theft/CSRF everywhere; Secure is only
92
+ # set in production (the dev UI is plain HTTP on localhost, where a Secure
93
+ # cookie would never be sent). Production deployments must terminate TLS.
94
+ set :sessions, httponly: true, same_site: :lax, secure: production?
95
+ # Derive a stable 64-hex-char key from whatever SESSION_SECRET is set.
96
+ # rack-protection's encrypted-cookie store 500s on a short/odd-length
97
+ # secret (it slices a fixed-length AES key off the raw string); hashing
98
+ # makes ANY value work, deterministically across restarts and Puma
99
+ # workers. SecureRandom default is per-process (dev only).
100
+ raw_session_secret = ENV['SESSION_SECRET'] || SecureRandom.hex(64)
101
+ set :session_secret, OpenSSL::Digest::SHA256.hexdigest(raw_session_secret)
102
+ end
103
+
104
+ configure :production do
105
+ unless ENV['SESSION_SECRET']
106
+ raise 'SESSION_SECRET must be set in production. It secures the session ' \
107
+ 'and CSRF cookies, and a per-process random fallback breaks both ' \
108
+ 'across restarts and Puma workers. Refusing to start.'
109
+ end
110
+ end
111
+
112
+ CSRF_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
113
+
114
+ before do
115
+ session[:web_user_id] ||= SecureRandom.uuid
116
+ session[:csrf] ||= SecureRandom.hex(32)
117
+
118
+ next if CSRF_SAFE_METHODS.include?(request.request_method)
119
+
120
+ provided = params['authenticity_token'] || request.env['HTTP_X_CSRF_TOKEN']
121
+ halt 403, { 'Content-Type' => 'text/plain' }, 'Forbidden: Invalid CSRF token' unless provided && Rack::Utils.secure_compare(provided, session[:csrf])
122
+ end
123
+
124
+ # --- Sinatra Settings ---
125
+ set :root, File.expand_path('../../..', __dir__) # Project root directory
126
+ set :views, File.expand_path('views', __dir__) # Views directory for Slim templates
127
+ set :public_folder, File.expand_path('public', __dir__) # Directory for static assets (CSS, JS, images)
128
+ set :slim, pretty: true # Configure Slim for readable HTML output
129
+
130
+ # --- Constants ---
131
+ # List of available Gemini models selectable in the UI.
132
+ # Now includes beta models since we're using v1beta API endpoint
133
+ AVAILABLE_MODELS = [
134
+ 'gemini-3.5-flash',
135
+ 'gemini-2.5-flash',
136
+ 'gemini-2.5-pro',
137
+ # Preview / Experimental
138
+ 'gemini-3-pro-preview'
139
+ ].freeze
140
+
141
+ # --- Register Route Modules ---
142
+ register Legate::Web::CoreRoutes
143
+ register Legate::Web::ApiRoutes
144
+ register Legate::Web::ToolsUIRoutes
145
+ # Generator routes must be registered BEFORE definition routes to avoid :name matching "generate"
146
+ register Legate::Web::AgentGeneratorRoutes
147
+ register Legate::Web::ToolGeneratorRoutes
148
+ register Legate::Web::AgentRuntimeRoutes
149
+ register Legate::Web::AgentDefinitionRoutes
150
+ register Legate::Web::AgentInteractionRoutes
151
+ register Legate::Web::DocumentationRoutes
152
+ register Legate::Web::AuthenticationRoutes
153
+ register Legate::Web::AgentAuthenticationRoutes
154
+
155
+ # --- Instance Variables ---
156
+ # Initializes application state, including connections and services.
157
+ def initialize
158
+ super
159
+ @logger = Legate.logger # Ensure logger is set early
160
+ # In-memory map of active/running Legate::Agent instances. Keyed by the
161
+ # agent's STRING name (the `/agents/:name/...` route-param form). Note that
162
+ # definition hashes store `:name` as a Symbol, so any running-state lookup
163
+ # that starts from a definition must `.to_s` the name before `@agents.key?`.
164
+ @agents = Concurrent::Map.new
165
+ # Service responsible for managing chat sessions (stores conversation history).
166
+ # Uses the global Legate.config.session_service for consistency with CLI.
167
+ @session_service = Legate.config.session_service
168
+ # Use GlobalDefinitionRegistry as the definition store (in-memory, no Redis)
169
+ @definition_store = Legate::GlobalDefinitionRegistry
170
+ @logger.info('Agent Definition Store initialized (using GlobalDefinitionRegistry).')
171
+
172
+ # Initialize Auth Manager Store (in-memory, no Redis)
173
+ initialize_auth_manager_store
174
+ # --- END MODIFICATION ---
175
+
176
+ # Compile SASS/SCSS files in public/styles to CSS in public/css on application startup.
177
+ # In production, we assume this was done during the build process.
178
+ SassCompiler.compile_all if ENV['RACK_ENV'] == 'development' || Sinatra::Base.development?
179
+ end
180
+
181
+ # --- Sinatra Helpers ---
182
+ # Utility methods accessible within route handlers and Slim templates.
183
+ helpers do
184
+ def csrf_token
185
+ session[:csrf]
186
+ end
187
+
188
+ # Format time as relative time ago string
189
+ # @param time [Time] The time to format
190
+ # @return [String] Relative time string (e.g., "2 minutes ago")
191
+ def time_ago_in_words(time)
192
+ return 'just now' unless time
193
+
194
+ seconds = (Time.now.utc - time.utc).to_i
195
+
196
+ case seconds
197
+ when 0..59
198
+ 'just now'
199
+ when 60..119
200
+ '1 minute ago'
201
+ when 120..3599
202
+ "#{seconds / 60} minutes ago"
203
+ when 3600..7199
204
+ '1 hour ago'
205
+ when 7200..86_399
206
+ "#{seconds / 3600} hours ago"
207
+ when 86_400..172_799
208
+ 'yesterday'
209
+ else
210
+ "#{seconds / 86_400} days ago"
211
+ end
212
+ end
213
+
214
+ # Fetches tool lists from one or more MCP (Multi-Capability Protocol) servers.
215
+ # Connects to each server defined in the mcp_configs array, lists its tools,
216
+ # and handles connection errors or timeouts.
217
+ # @param mcp_configs [Array<Hash>] Array of hashes, each defining an MCP server connection (e.g., {type: :stdio, command: "...", name: "..."}, {type: :tcp, url: "...", name: "..."}).
218
+ # @param timeout_seconds [Integer] Connection/fetch timeout per server.
219
+ # @return [Array<Hash>] An array of result hashes, one for each config.
220
+ # Success: { status: :success, server: String, config: Hash, tools: Array<Hash> }
221
+ # Error: { status: :error, server: String, config: Hash, message: String }
222
+ def fetch_mcp_tools(mcp_configs, timeout_seconds = 5)
223
+ # Ensure necessary Legate::Mcp classes are loaded (might be redundant if loaded globally, but safe)
224
+ require_relative '../mcp/client'
225
+ # MCP errors are defined in legate/errors.rb (loaded by lib/legate.rb)
226
+ require 'timeout'
227
+
228
+ aggregated_results = [] # Store results for each server config
229
+ return aggregated_results unless mcp_configs.is_a?(Array)
230
+
231
+ mcp_configs.each_with_index do |config, index|
232
+ server_label = config['name'] || config['command'] || config['url'] || "Server #{index + 1}" # Need string keys here
233
+ begin
234
+ logger.info("Attempting to fetch tools from MCP server: #{server_label}")
235
+ result = Timeout.timeout(timeout_seconds) do
236
+ client = nil
237
+ fetched_tools = []
238
+ begin
239
+ # Transform keys to symbols for the client
240
+ symbolized_config = config.transform_keys(&:to_sym)
241
+ # --- NEW: Explicitly convert string 'stdio' value to symbol :stdio ---
242
+ symbolized_config[:type] = :stdio if symbolized_config[:type] == 'stdio'
243
+ # Pass the modified hash with symbol keys and potentially symbolized type value
244
+ client = Legate::Mcp::Client.new(symbolized_config)
245
+ # Connect implicitly calls list_tools during handshake in current implementation
246
+ # If connect succeeds, tools should be available via client instance if needed
247
+ client.connect # Performs handshake
248
+ fetched_tools = client.list_tools # Explicitly list tools
249
+ logger.info("Successfully fetched #{fetched_tools.count} tools from #{server_label}.")
250
+ # Add server label/config for context in results
251
+ aggregated_results << { status: :success, server: server_label, config: config, tools: fetched_tools }
252
+ rescue Legate::Mcp::ConnectionError, Legate::Mcp::ProtocolError => e
253
+ logger.error("MCP Error fetching tools from #{server_label}: #{e.message}")
254
+ aggregated_results << { status: :error, server: server_label, config: config,
255
+ message: "MCP Connection/Protocol Error: #{e.message}" }
256
+ rescue StandardError => e
257
+ logger.error("Unexpected Error fetching tools from #{server_label}: #{e.class} - #{e.message}")
258
+ logger.error(e.backtrace.first(5).join("\n")) # Log backtrace for unexpected errors
259
+ aggregated_results << { status: :error, server: server_label, config: config,
260
+ message: "Internal Error: #{e.message}" }
261
+ ensure
262
+ # Ensure disconnect is always attempted if client was created
263
+ client&.disconnect
264
+ end
265
+ end # Timeout block
266
+ rescue Timeout::Error
267
+ logger.error("Timeout (#{timeout_seconds}s) fetching tools from MCP server: #{server_label}")
268
+ aggregated_results << { status: :error, server: server_label, config: config,
269
+ message: "Timeout after #{timeout_seconds} seconds" }
270
+ end # Begin/rescue Timeout
271
+ end # each_with_index
272
+
273
+ aggregated_results
274
+ end
275
+
276
+ # Builds the lookup map of all tools available to an agent (native +
277
+ # MCP), keyed by tool-name Symbol, used by the agent-definition routes to
278
+ # resolve a definition's configured tools for display. Callers pass their
279
+ # own native-tool metadata (the routes differ in how they shape it) and
280
+ # do their own configured-tool selection / check_job_status handling.
281
+ # @param mcp_servers_json [String, nil] the definition's MCP servers JSON
282
+ # @param native_tools_metadata [Array<Hash>] caller-shaped native tool metadata
283
+ # @param log_context [String] label for any MCP-JSON parse error log
284
+ # @return [Hash] { map: {Symbol => tool_hash}, mcp_results: Array<Hash> }
285
+ def resolve_available_tools(mcp_servers_json, native_tools_metadata, log_context: 'agent tools')
286
+ mcp_configs_list = []
287
+ begin
288
+ mcp_configs_list = JSON.parse(mcp_servers_json) if mcp_servers_json && !mcp_servers_json.empty? && mcp_servers_json != '[]'
289
+ rescue JSON::ParserError => e
290
+ logger.error("Invalid MCP JSON (#{log_context}): #{e.message}")
291
+ end
292
+ mcp_results = fetch_mcp_tools(mcp_configs_list)
293
+
294
+ fetched_mcp_tools_metadata = []
295
+ mcp_results.each do |result|
296
+ next unless result[:status] == :success && result[:tools]
297
+
298
+ result[:tools].each do |mcp_tool_schema|
299
+ parameters = Legate::Mcp::Util::SchemaConverter.json_to_legate(
300
+ mcp_tool_schema.dig(:inputSchema, 'properties') || {},
301
+ mcp_tool_schema.dig(:inputSchema, 'required') || []
302
+ )
303
+ fetched_mcp_tools_metadata << {
304
+ name: mcp_tool_schema[:name].to_sym,
305
+ description: mcp_tool_schema[:description] || '',
306
+ parameters: parameters, source: :mcp, source_detail: "MCP (#{result[:server]})"
307
+ }
308
+ end
309
+ end
310
+
311
+ map = (native_tools_metadata + fetched_mcp_tools_metadata).each_with_object({}) do |tool, acc|
312
+ acc[tool[:name]] ||= tool
313
+ end
314
+ { map: map, mcp_results: mcp_results }
315
+ end
316
+
317
+ # Helper for Agent Start/Stop button fragments (used in table view)
318
+ def agent_status_fragments(agent_data_or_obj)
319
+ agent_name = agent_data_or_obj.is_a?(Hash) ? agent_data_or_obj[:name] : agent_data_or_obj.name
320
+ safe_agent_id = agent_name.to_s.gsub(/[^a-zA-Z0-9_-]/, '-') # Sanitize for use in HTML IDs/CSS selectors
321
+ is_running = agent_data_or_obj.is_a?(Hash) ? agent_data_or_obj[:running] : agent_data_or_obj.running?
322
+
323
+ status_content_id = "agent-status-content-#{safe_agent_id}"
324
+ start_action_id = "agent-start-action-#{safe_agent_id}"
325
+ stop_action_id = "agent-stop-action-#{safe_agent_id}"
326
+ dropdown_id = "agent-actions-dropdown-#{safe_agent_id}"
327
+
328
+ status_html = <<~HTML
329
+ <span id="#{status_content_id}" hx-swap-oob="outerHTML">
330
+ <span class="tag is-medium #{is_running ? 'is-success' : 'is-danger'}">
331
+ <span class="icon is-small"><i class="fas #{is_running ? 'fa-check-circle' : 'fa-stop-circle'}"></i></span>
332
+ <span class="ml-1">#{is_running ? 'Running' : 'Stopped'}</span>
333
+ </span>
334
+ </span>
335
+ HTML
336
+ start_action_html = <<~HTML
337
+ <a class="dropdown-item #{is_running ? 'is-disabled' : ''}" href="#"#{' '}
338
+ id="#{start_action_id}"#{' '}
339
+ hx-post="/agents/#{agent_name}/start"#{' '}
340
+ hx-indicator="##{dropdown_id} .dropdown-trigger button"
341
+ hx-swap-oob="outerHTML"#{' '}
342
+ onclick="if(this.classList.contains('is-disabled')) event.preventDefault();">
343
+ <span class="icon has-text-success"><i class="fas fa-play"></i></span>
344
+ <span>Start</span>
345
+ </a>
346
+ HTML
347
+ stop_action_html = <<~HTML
348
+ <a class="dropdown-item #{!is_running ? 'is-disabled' : ''}" href="#"#{' '}
349
+ id="#{stop_action_id}"#{' '}
350
+ hx-post="/agents/#{agent_name}/stop"#{' '}
351
+ hx-indicator="##{dropdown_id} .dropdown-trigger button"
352
+ hx-swap-oob="outerHTML"#{' '}
353
+ onclick="if(this.classList.contains('is-disabled')) event.preventDefault();">
354
+ <span class="icon has-text-danger"><i class="fas fa-stop"></i></span>
355
+ <span>Stop</span>
356
+ </a>
357
+ HTML
358
+ status_html.strip + start_action_html.strip + stop_action_html.strip
359
+ end # end agent_status_fragments
360
+
361
+ # Helper for formatting tool/agent execution results into HTML
362
+ def format_execution_result_html(result_data)
363
+ html_parts = []
364
+ notification_class = 'is-info' # Default
365
+ overall_status = :unknown # Default
366
+
367
+ # --- Determine overall status ---
368
+ # Handle Legate::Event first
369
+ if result_data.is_a?(Legate::Event)
370
+ result_data = result_data.content # Extract content hash
371
+ end
372
+
373
+ # Now work with the hash
374
+ if result_data.is_a?(Hash) && result_data.key?(:status)
375
+ overall_status = result_data[:status]
376
+ elsif result_data.is_a?(Array) && result_data.all? { |h| h.is_a?(Hash) && h.key?(:status) }
377
+ # Multi-step array - determine overall status
378
+ overall_status = if result_data.any? { |h| h[:status] == :error }
379
+ :error
380
+ elsif result_data.any? { |h| h[:status] == :pending }
381
+ :pending
382
+ elsif result_data.empty? # Empty plan result
383
+ :warning # Or treat as error?
384
+ else # All success
385
+ :success
386
+ end
387
+ else # Unexpected format, treat as error
388
+ overall_status = :error
389
+ # Wrap the unexpected data into a standard error hash for consistent handling below
390
+ result_data = { status: :error, error_message: "Unexpected result format: #{result_data.inspect}" }
391
+ end
392
+ # --- End determine overall status ---
393
+
394
+ # Set notification class based on status
395
+ notification_class = case overall_status
396
+ when :success then 'is-success'
397
+ when :error then 'is-danger'
398
+ when :pending then 'is-warning' # Use warning for pending
399
+ else 'is-info' # includes :unknown, :warning (empty plan)
400
+ end
401
+
402
+ # --- Generate HTML content ---
403
+ if result_data.is_a?(Array) # Multi-step result array
404
+ html_parts << '<p><strong>Multi-step Result:</strong></p><ol>'
405
+ result_data.each_with_index do |step_hash, index|
406
+ html_parts << '<li>'
407
+ if step_hash.is_a?(Hash) # Ensure it's a hash before checking status
408
+ case step_hash[:status]
409
+ when :success
410
+ step_result_content = step_hash[:result]
411
+ # Handle potential nested result from AgentTool for display
412
+ if step_result_content.is_a?(Hash) && step_result_content.key?(:status)
413
+ html_parts << "<strong>Step #{index + 1} (Success - Delegated):</strong>"
414
+ html_parts << "<blockquote style='margin-left: 1em; border-left: 3px solid #dbdbdb; padding-left: 1em;'>"
415
+ html_parts << format_execution_result_html(step_result_content) # Recursive call
416
+ html_parts << '</blockquote>'
417
+ else
418
+ # Format as JSON if it's a hash or array, otherwise use to_s
419
+ formatted_step_content = if step_result_content.is_a?(Hash) || step_result_content.is_a?(Array)
420
+ JSON.pretty_generate(step_result_content)
421
+ else
422
+ step_result_content.to_s
423
+ end
424
+ html_parts << "<strong>Step #{index + 1} (Success):</strong> <pre>#{Rack::Utils.escape_html(formatted_step_content)}</pre>"
425
+ end
426
+ when :pending # <-- ADDED Pending Case for Multi-step
427
+ html_parts << "<strong>Step #{index + 1} (Pending):</strong>"
428
+ html_parts << "<pre>Job ID: #{Rack::Utils.escape_html(step_hash[:job_id].to_s)}" # Changed workflow_id to job_id
429
+ html_parts << "\nMessage: #{Rack::Utils.escape_html(step_hash[:message].to_s)}" if step_hash[:message]
430
+ html_parts << '</pre>'
431
+ when :error
432
+ html_parts << "<strong>Step #{index + 1} (Error):</strong> <pre class='has-text-danger'>#{Rack::Utils.escape_html(step_hash[:error_message].to_s)}</pre>"
433
+ else # Unknown status
434
+ html_parts << "<strong>Step #{index + 1} (Unknown Status):</strong> <pre>#{Rack::Utils.escape_html(step_hash.inspect)}</pre>"
435
+ end
436
+ else
437
+ # Handle case where an element in the array isn't a hash
438
+ html_parts << "<strong>Step #{index + 1} (Invalid format):</strong> <pre>#{Rack::Utils.escape_html(step_hash.inspect)}</pre>"
439
+ end
440
+ html_parts << '</li>'
441
+ end
442
+ html_parts << '</ol>'
443
+
444
+ elsif result_data.is_a?(Hash) # Single result/error/pending hash
445
+ case result_data[:status]
446
+ when :success
447
+ result_content = result_data[:result]
448
+ # Handle potential nested result from AgentTool
449
+ if result_content.is_a?(Hash) && result_content.key?(:status)
450
+ html_parts << '<p><strong>Result (from delegated agent):</strong></p>'
451
+ html_parts << "<blockquote style='margin-left: 1em; border-left: 3px solid #dbdbdb; padding-left: 1em;'>"
452
+ html_parts << format_execution_result_html(result_content) # Recursive call
453
+ html_parts << '</blockquote>'
454
+ else
455
+ # Format as JSON if it's a hash or array, otherwise use to_s
456
+ formatted_content = if result_content.is_a?(Hash) || result_content.is_a?(Array)
457
+ JSON.pretty_generate(result_content)
458
+ else
459
+ result_content.to_s
460
+ end
461
+ html_parts << "<p><strong>Result:</strong></p><pre>#{Rack::Utils.escape_html(formatted_content)}</pre>"
462
+ end
463
+ when :pending # <-- ADDED Pending Case for Single Step
464
+ html_parts << '<p><strong>Status: Pending</strong></p>'
465
+ html_parts << "<pre>Job ID: #{Rack::Utils.escape_html(result_data[:job_id].to_s)}" # Changed workflow_id to job_id
466
+ html_parts << "\nMessage: #{Rack::Utils.escape_html(result_data[:message].to_s)}" if result_data[:message]
467
+ html_parts << "\n(Use tool 'check_job_status' with this ID to get the final result)</pre>"
468
+ when :error
469
+ html_parts << "<p><strong>Error:</strong></p><pre class='has-text-danger'>#{Rack::Utils.escape_html(result_data[:error_message].to_s)}</pre>"
470
+ else # Unknown status within hash
471
+ html_parts << "<p><strong>Result (Unknown Status):</strong></p><pre>#{Rack::Utils.escape_html(result_data.inspect)}</pre>"
472
+ end
473
+ end # End if result_data.is_a?(Hash)
474
+ # --- End Generate HTML ---
475
+
476
+ # Return final HTML structure
477
+ "<div class='notification #{notification_class} mt-4'>#{html_parts.join}</div>"
478
+ end # end format_execution_result_html
479
+
480
+ def process_agent_response(agent_result)
481
+ response_data = {
482
+ msg_class: 'is-warning',
483
+ display_content: '',
484
+ raw_json_content: '',
485
+ event_id: SecureRandom.hex(4)
486
+ }
487
+ case agent_result
488
+ when Legate::Event
489
+ response_data[:event_id] = agent_result.event_id || response_data[:event_id]
490
+ if agent_result.role == :agent
491
+ content = agent_result.content
492
+ response_data[:raw_json_content] = content.inspect
493
+ if content.is_a?(Hash)
494
+ case content[:status]
495
+ when :success
496
+ response_data[:msg_class] = 'is-success'
497
+ response_data[:display_content] = content[:result].to_s
498
+ when :error
499
+ response_data[:msg_class] = 'is-danger'
500
+ original_error = content[:error_message] || 'Agent error (no message)'
501
+ response_data[:display_content] = if original_error == 'I cannot fulfill this request with the available tools (empty plan).'
502
+ "Sorry, I couldn't determine how to handle that request with the tools I have available."
503
+ else
504
+ original_error
505
+ end
506
+ when :pending
507
+ response_data[:msg_class] = 'is-warning'
508
+ response_data[:display_content] = "Task pending... Job ID: #{content[:job_id]}" # Changed workflow_id to job_id
509
+ response_data[:display_content] << " - #{content[:message]}" if content[:message]
510
+ else
511
+ response_data[:display_content] = "Agent response has unknown status: #{content[:status]}"
512
+ end
513
+ else
514
+ response_data[:display_content] = "Agent event content format unexpected: #{content.inspect}"
515
+ end
516
+ elsif agent_result.role == :tool_request
517
+ response_data[:msg_class] = 'is-info is-light'
518
+ content = agent_result.content
519
+ response_data[:raw_json_content] = content.inspect
520
+ if content.is_a?(Hash) && content[:tool_name]
521
+ tool_name = content[:tool_name]
522
+ params_preview = content[:params] && !content[:params].empty? ? ' with parameters' : ' (no parameters)'
523
+ response_data[:display_content] = "Tool Request: #{tool_name}#{params_preview}"
524
+ else
525
+ response_data[:display_content] = "Tool Request: #{content.inspect}"
526
+ end
527
+ elsif agent_result.role == :tool_result
528
+ content = agent_result.content
529
+ response_data[:raw_json_content] = content.inspect
530
+ if content.is_a?(Hash)
531
+ if content[:status] == :error || content[:error]
532
+ response_data[:msg_class] = 'is-danger is-light'
533
+ response_data[:display_content] =
534
+ "Tool Error: #{content[:error] || content[:error_message] || 'Unknown error'}"
535
+ else
536
+ response_data[:msg_class] = 'is-success is-light'
537
+ if content[:result]
538
+ result_str = content[:result].is_a?(String) ? content[:result] : content[:result].inspect
539
+ response_data[:display_content] = "Tool Result: #{result_str}"
540
+ else
541
+ response_data[:display_content] = "Tool Result: #{content.inspect}"
542
+ end
543
+ end
544
+ else
545
+ response_data[:display_content] = "Tool Result: #{content.inspect}"
546
+ response_data[:msg_class] = 'is-success is-light'
547
+ end
548
+ else
549
+ response_data[:display_content] = "Received event with unknown role: #{agent_result.role}"
550
+ response_data[:raw_json_content] = agent_result.inspect
551
+ end
552
+ when Hash
553
+ response_data[:raw_json_content] = agent_result.inspect
554
+ if agent_result[:status] == :error
555
+ response_data[:msg_class] = 'is-danger'
556
+ response_data[:display_content] = agent_result[:error_message] || 'An unspecified error occurred.'
557
+ else
558
+ response_data[:display_content] = "Unexpected hash format from server: #{agent_result.inspect}"
559
+ end
560
+ else
561
+ response_data[:raw_json_content] = agent_result.inspect
562
+ response_data[:display_content] = "Unexpected response type from server: #{agent_result.class}"
563
+ end
564
+ response_data
565
+ end
566
+
567
+ def format_historical_agent_content(content)
568
+ display_content = ''
569
+ if content.is_a?(Hash) && content.key?(:status)
570
+ case content[:status]
571
+ when :success
572
+ display_content = content[:result]
573
+ when :error
574
+ original_error = content[:error_message] || 'Agent error (no message)'
575
+ display_content = if original_error == 'I cannot fulfill this request with the available tools (empty plan).'
576
+ "Sorry, I couldn't determine how to handle that request with the tools I have available."
577
+ else
578
+ original_error
579
+ end
580
+ when :pending
581
+ display_content = "Task pending... Job ID: #{content[:job_id]}" # Changed workflow_id to job_id
582
+ display_content << " - #{content[:message]}" if content[:message]
583
+ else
584
+ display_content = "Agent response (unknown status): #{content.inspect}"
585
+ end
586
+ elsif content.is_a?(Hash) && content.key?(:tool_name)
587
+ display_content = "Tool request: #{content[:tool_name]}"
588
+ display_content += ' with parameters' if content[:params] && !content[:params].empty?
589
+ elsif content.is_a?(Hash) && (content.key?(:result) || content.key?(:error))
590
+ if content[:error]
591
+ display_content = "Tool error: #{content[:error]}"
592
+ elsif content[:result]
593
+ result_str = content[:result].is_a?(String) ? content[:result] : content[:result].inspect
594
+ display_content = "Tool result: #{result_str}"
595
+ else
596
+ display_content = "Tool response: #{content.inspect}"
597
+ end
598
+ elsif content.is_a?(Array)
599
+ display_content = "Agent response (array): #{content.inspect}"
600
+ else
601
+ display_content = content.to_s
602
+ end
603
+ display_content.to_s
604
+ end
605
+
606
+ def summarize_session(session_object)
607
+ return 'Invalid session object' unless session_object.is_a?(Legate::Session)
608
+
609
+ created_at_formatted = session_object.created_at.strftime('%b %d, %Y %H:%M')
610
+ updated_at_formatted = session_object.updated_at.strftime('%b %d, %Y %H:%M')
611
+ event_count = session_object.events&.count || 0
612
+ messages_text = event_count == 1 ? 'message' : 'messages'
613
+ preview_text = 'Session started'
614
+ if event_count.zero?
615
+ preview_text = "Empty session (created #{created_at_formatted})"
616
+ else
617
+ first_user_text_event = session_object.events.find do |event|
618
+ event.role == :user && event.content.is_a?(String) && !event.content.strip.empty?
619
+ end
620
+ if first_user_text_event
621
+ words = first_user_text_event.content.strip.split(/\s+/)
622
+ preview = words.take(10).join(' ')
623
+ preview_text = "#{preview}#{words.size > 10 ? '...' : ''}"
624
+ elsif session_object.events.any? { |e| e.role == :user }
625
+ preview_text = 'Contains non-text user messages'
626
+ else
627
+ preview_text = 'Agent-initiated session'
628
+ end
629
+ end
630
+ "Chat from #{created_at_formatted} (Last active: #{updated_at_formatted}) (#{event_count} #{messages_text}): #{preview_text}"
631
+ end
632
+
633
+ def pretty_json(object)
634
+ JSON.pretty_generate(object)
635
+ rescue StandardError => e
636
+ object.inspect
637
+ end
638
+
639
+ # --- START: MERMAID HELPERS (Corrected for delegate_task rich result) ---
640
+ def generate_mermaid_sequence_diagram(final_agent_event_content, original_user_input)
641
+ return '' unless final_agent_event_content.is_a?(Hash)
642
+
643
+ mermaid_def = ['sequenceDiagram']
644
+ participants = Set.new
645
+ # Initial call: current_agent_name is just "Agent"
646
+ collect_participants_recursive(final_agent_event_content, participants, 'Agent')
647
+
648
+ participants.each { |p| mermaid_def << " participant #{p}" }
649
+
650
+ mermaid_def << " User->>Agent: #{escape_mermaid_label(original_user_input)}"
651
+ # Initial call: current_agent_is "Agent", final_recipient_is "User"
652
+ append_plan_to_mermaid_recursive(final_agent_event_content, 'Agent', 'User', mermaid_def)
653
+
654
+ mermaid_def.join("\n")
655
+ end
656
+
657
+ def collect_participants_recursive(event_content, participants_set, current_agent_alias = 'Agent')
658
+ participants_set.add('User')
659
+ participants_set.add(current_agent_alias)
660
+
661
+ plan_details = event_content[:plan_details]
662
+ return unless plan_details.is_a?(Array)
663
+
664
+ plan_details.each do |step_in_plan|
665
+ tool_name_str = step_in_plan[:tool_name]&.to_s
666
+ participants_set.add("Tool(#{tool_name_str})") if tool_name_str && !tool_name_str.empty?
667
+
668
+ next unless step_in_plan[:tool_name]&.to_sym == :delegate_task &&
669
+ step_in_plan == plan_details.last &&
670
+ event_content.dig(:result, :status) == :success &&
671
+ event_content.dig(:result, :result).is_a?(Hash) &&
672
+ event_content.dig(:result, :result, :plan_details)
673
+
674
+ delegated_agent_full_content = event_content.dig(:result, :result)
675
+ target_agent_name_param = step_in_plan.dig(:params,
676
+ :target_agent_name) || step_in_plan.dig(:params,
677
+ 'target_agent_name')
678
+ delegated_agent_actual_name = delegated_agent_full_content.dig(:name)&.to_s || target_agent_name_param || 'DelegatedAgent'
679
+ delegated_agent_participant_alias = "Agent(#{delegated_agent_actual_name})"
680
+
681
+ participants_set.add(delegated_agent_participant_alias)
682
+ collect_participants_recursive(delegated_agent_full_content, participants_set,
683
+ delegated_agent_participant_alias)
684
+ end
685
+ end
686
+
687
+ def append_plan_to_mermaid_recursive(event_content, current_agent_participant_name, final_recipient_name,
688
+ mermaid_def_array)
689
+ plan_details = event_content[:plan_details]
690
+ return unless plan_details.is_a?(Array)
691
+
692
+ plan_details.each_with_index do |step_in_plan, index|
693
+ tool_name_str = step_in_plan[:tool_name]&.to_s || 'UnknownTool'
694
+ tool_participant = "Tool(#{tool_name_str})"
695
+
696
+ params_summary = summarize_for_mermaid(step_in_plan[:params]) # Removed max_length override
697
+ mermaid_def_array << " #{current_agent_participant_name}->>#{tool_participant}: Call #{tool_name_str} with #{params_summary}"
698
+
699
+ original_tool_output_for_this_step = step_in_plan[:result]
700
+
701
+ if step_in_plan == plan_details.last &&
702
+ step_in_plan[:tool_name]&.to_sym == :delegate_task &&
703
+ event_content.dig(:result, :status) == :success &&
704
+ event_content.dig(:result, :result).is_a?(Hash) &&
705
+ event_content.dig(:result, :result, :plan_details)
706
+
707
+ original_tool_output_for_this_step = event_content[:result][:result]
708
+ end
709
+
710
+ if original_tool_output_for_this_step.is_a?(Hash) &&
711
+ original_tool_output_for_this_step.key?(:plan_details) &&
712
+ step_in_plan[:tool_name]&.to_sym == :delegate_task
713
+
714
+ delegated_agent_content = original_tool_output_for_this_step
715
+ target_agent_name_param = step_in_plan.dig(:params,
716
+ :target_agent_name) || step_in_plan.dig(:params,
717
+ 'target_agent_name')
718
+ effective_delegated_name = delegated_agent_content[:name]&.to_s || target_agent_name_param&.to_s || 'DelegatedAgent'
719
+ delegated_agent_participant = "Agent(#{effective_delegated_name})"
720
+ task_for_delegated = summarize_for_mermaid(step_in_plan.dig(:params,
721
+ :task) || step_in_plan.dig(:params, 'task'))
722
+ mermaid_def_array << " #{tool_participant}->>#{delegated_agent_participant}: Run task: #{task_for_delegated || 'Delegated Task'}"
723
+ append_plan_to_mermaid_recursive(delegated_agent_content, delegated_agent_participant, tool_participant,
724
+ mermaid_def_array)
725
+ delegated_outcome_summary = if delegated_agent_content[:status] == :success
726
+ "Delegated success: #{summarize_for_mermaid(delegated_agent_content[:result])}"
727
+ elsif delegated_agent_content[:status] == :error
728
+ "Delegated error: #{summarize_for_mermaid(delegated_agent_content[:error_message])}"
729
+ else
730
+ "Delegated status: #{delegated_agent_content[:status]}"
731
+ end
732
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: #{delegated_outcome_summary}"
733
+ elsif original_tool_output_for_this_step.is_a?(Hash)
734
+ status = original_tool_output_for_this_step[:status]&.to_s || 'unknown'
735
+ case status.to_sym
736
+ when :success
737
+ result_value = original_tool_output_for_this_step[:result]
738
+ if result_value.is_a?(String) && result_value == '[Complex Result Structure]'
739
+ actual_result = event_content[:result][:result]
740
+ mermaid_def_array << if actual_result.is_a?(String)
741
+ " #{tool_participant}-->>#{current_agent_participant_name}: Result: \"#{actual_result}\""
742
+ else
743
+ " #{tool_participant}-->>#{current_agent_participant_name}: Result: [Complex Result Structure]"
744
+ end
745
+ else
746
+ result_summary = summarize_for_mermaid(original_tool_output_for_this_step[:result])
747
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: Result: #{result_summary}"
748
+ end
749
+ when :error
750
+ error_summary = summarize_for_mermaid(original_tool_output_for_this_step[:error_message] || 'Unknown Error')
751
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: Error: #{error_summary}"
752
+ when :pending
753
+ job_id_summary = summarize_for_mermaid(original_tool_output_for_this_step[:job_id] || 'N/A') # Changed from workflow_id
754
+ message_summary = original_tool_output_for_this_step[:message] ? " (#{summarize_for_mermaid(original_tool_output_for_this_step[:message])})" : ''
755
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: Pending (Job ID: #{job_id_summary})#{message_summary}"
756
+ else
757
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: Result (Status: #{status}): #{summarize_for_mermaid(original_tool_output_for_this_step)}"
758
+ end
759
+ else
760
+ mermaid_def_array << " #{tool_participant}-->>#{current_agent_participant_name}: Malformed Result: #{summarize_for_mermaid(original_tool_output_for_this_step)}"
761
+ end
762
+ end
763
+
764
+ final_response_summary = ''
765
+ if event_content[:status] == :success
766
+ core_result = if event_content[:result].is_a?(Hash) && event_content[:result][:status] == :success && event_content[:result].key?(:result)
767
+ event_content[:result][:result]
768
+ else
769
+ event_content[:result]
770
+ end
771
+ if core_result.is_a?(String) && core_result == '[Complex Result Structure]'
772
+ actual_result = event_content[:result][:result]
773
+ final_response_summary = if actual_result.is_a?(String)
774
+ "Final Result: \"#{actual_result}\""
775
+ else
776
+ 'Final Result: [Complex Result Structure]'
777
+ end
778
+ else
779
+ final_response_summary = "Final Result: #{summarize_for_mermaid(core_result)}"
780
+ end
781
+ elsif event_content[:status] == :error
782
+ final_response_summary = "Final Error: #{summarize_for_mermaid(event_content[:error_message])}"
783
+ elsif event_content[:status] == :pending
784
+ job_id_summary = summarize_for_mermaid(event_content[:job_id]) # Changed from workflow_id
785
+ message_summary = event_content[:message] ? " - #{summarize_for_mermaid(event_content[:message])}" : ''
786
+ final_response_summary = "Task Pending: Job ID #{job_id_summary}#{message_summary}"
787
+ else
788
+ final_response_summary = "Final Response (Status: #{event_content[:status]}): #{summarize_for_mermaid(event_content)}"
789
+ end
790
+ mermaid_def_array << " #{current_agent_participant_name}-->>#{final_recipient_name}: #{final_response_summary}"
791
+ end
792
+
793
+ def summarize_for_mermaid(data, max_length = 700)
794
+ return 'nil' if data.nil?
795
+
796
+ raw_summary_str = ''
797
+ if data.is_a?(Hash)
798
+ if data.key?(:result) && data[:result].is_a?(Hash) && data[:result].key?(:content)
799
+ content_str = data[:result][:content].to_s
800
+ content_preview = content_str.length > 50 ? "#{content_str[0..50]}..." : content_str
801
+ raw_summary_str = "{status: #{data[:status]}, result: {content: \"#{content_preview}\"}}"
802
+ else
803
+ items = data.map do |k, v_raw|
804
+ v_str = if v_raw.is_a?(Hash)
805
+ "{#{v_raw.keys.take(3).join(', ')}#{v_raw.keys.size > 3 ? ', ...' : ''}}"
806
+ elsif v_raw.is_a?(String) && v_raw.length <= 30 && !v_raw.match?(/[:;()`"'\n\\]/)
807
+ v_raw
808
+ elsif v_raw.is_a?(Array) && v_raw.size <= 3
809
+ v_raw.inspect
810
+ elsif v_raw.is_a?(Array)
811
+ "[#{v_raw.size} items]"
812
+ else
813
+ v_raw.inspect
814
+ end
815
+ "#{k}: #{v_str}"
816
+ end
817
+ raw_summary_str = "{#{items.join(', ')}}"
818
+ end
819
+ elsif data.is_a?(Array)
820
+ if data.size <= 5
821
+ items_str = data.map do |item|
822
+ if item.is_a?(Hash)
823
+ "{#{item.keys.take(2).join(', ')}#{item.keys.size > 2 ? ', ...' : ''}}"
824
+ else
825
+ item.inspect
826
+ end
827
+ end.join(', ')
828
+ raw_summary_str = "[#{items_str}]"
829
+ else
830
+ items_str = data.take(3).map do |item|
831
+ if item.is_a?(Hash)
832
+ "{#{item.keys.take(2).join(', ')}#{item.keys.size > 2 ? ', ...' : ''}}"
833
+ else
834
+ item.inspect
835
+ end
836
+ end.join(', ')
837
+ raw_summary_str = "[#{items_str}, ... (#{data.size} total items)]"
838
+ end
839
+ else
840
+ raw_summary_str = data.to_s
841
+ end
842
+ escaped_summary = escape_mermaid_label(raw_summary_str)
843
+ if escaped_summary.length > max_length
844
+ escaped_summary[0...(max_length - 3)] + '...'
845
+ else
846
+ escaped_summary
847
+ end
848
+ end
849
+
850
+ # MODIFIED: Simplified escape_mermaid_label
851
+ def escape_mermaid_label(text)
852
+ return '' if text.nil?
853
+
854
+ s = text.to_s
855
+ s = s.gsub(/#/, '#hash;') # Escape # to prevent it being a comment/directive
856
+ s = s.gsub(/"/, '#quot;') # For quoted strings within messages; Mermaid prefers this over "
857
+ s = s.gsub(/;/, '#semi;') # Semicolons can end Mermaid statements
858
+
859
+ # Replace newlines with <br> for explicit line breaks in Mermaid labels
860
+ s = s.gsub(/\n/, '<br>')
861
+
862
+ # Escape sequences that might be misinterpreted as Mermaid diagram arrows/lines
863
+ s = s.gsub(/->>/, '->>')
864
+ s = s.gsub(/-->>/, '-->>')
865
+ s = s.gsub(/->/, '->')
866
+ s.gsub(/--/, '- -') # also to prevent '--' being parsed as start of solid line in some contexts
867
+
868
+ # Parentheses and backticks are often fine in message text, remove aggressive escaping for them for now.
869
+ # Colons are fine.
870
+ end
871
+ # --- END MERMAID HELPERS ---
872
+ end # end helpers
873
+
874
+ # --- Private Helper Methods ---
875
+ private
876
+
877
+ # Initialize the authentication manager store (in-memory)
878
+ def initialize_auth_manager_store
879
+ # Create the in-memory store for authentication configuration
880
+ auth_store = Legate::Auth::ManagerStore::InMemoryStore.new
881
+
882
+ # Set the store on the singleton Auth::Manager
883
+ auth_manager = Legate::Auth::Manager.instance
884
+ auth_manager.set_store(auth_store, load_immediately: false)
885
+
886
+ @logger.info('Authentication Manager Store initialized (in-memory).')
887
+ rescue StandardError => e
888
+ @logger.error("Failed to initialize Auth Manager Store: #{e.class} - #{e.message}")
889
+ @logger.debug(e.backtrace.first(3).join("\n"))
890
+ end
891
+
892
+ def _stop_agent(name)
893
+ agent = @agents[name]
894
+ if agent
895
+ logger.info("Stopping agent '#{name}'...")
896
+ begin
897
+ agent.stop
898
+ @agents.delete(name)
899
+ logger.info("Agent '#{name}' stopped.")
900
+ Legate::ActivityLog.safe_log(:agent_stopped, { name: name })
901
+ true
902
+ rescue StandardError => e
903
+ logger.error("Error stopping agent '#{name}': #{e.message}")
904
+ false
905
+ end
906
+ else
907
+ logger.warn("Attempted to stop non-running agent: '#{name}'.")
908
+ true
909
+ end
910
+ end
911
+
912
+ def _start_agent(name)
913
+ return @agents[name] if @agents.key?(name)
914
+
915
+ # Use proper error handling instead of halt (which only works in request contexts)
916
+ unless @definition_store
917
+ logger.error("Definition Store unavailable, cannot start agent '#{name}'.")
918
+ return nil
919
+ end
920
+
921
+ agent_definition = nil
922
+ begin
923
+ agent_definition = @definition_store.get_definition(name)
924
+ rescue StandardError => e
925
+ logger.error("Store error fetching definition for starting agent '#{name}': #{e.message}")
926
+ return nil
927
+ end
928
+
929
+ unless agent_definition
930
+ logger.error("Agent definition not found for '#{name}', cannot start.")
931
+ return nil
932
+ end
933
+
934
+ agent_description = agent_definition[:description]
935
+ selected_tool_names = agent_definition[:tools].map(&:to_sym)
936
+ model_name = agent_definition[:model]
937
+ fallback_mode_sym = agent_definition[:fallback_mode]
938
+ mcp_servers_json = agent_definition[:mcp_servers_json]
939
+ agent_instruction = agent_definition[:instruction]
940
+
941
+ mcp_server_count = 0
942
+ begin
943
+ parsed_mcp = JSON.parse(mcp_servers_json)
944
+ mcp_server_count = parsed_mcp.is_a?(Array) ? parsed_mcp.count : 0
945
+ rescue JSON::ParserError
946
+ end
947
+ logger.info("Attempting to start agent '#{name}' (Model: #{model_name}, Fallback: #{fallback_mode_sym}, MCP: #{mcp_server_count} servers)... Selected Tools: #{selected_tool_names.inspect}")
948
+
949
+ # Convert hash to Legate::AgentDefinition object
950
+ definition_obj = Legate::AgentDefinition.from_hash(agent_definition)
951
+ unless definition_obj
952
+ logger.error("Failed to convert agent definition hash to Legate::AgentDefinition object for '#{name}'")
953
+ return nil
954
+ end
955
+
956
+ agent = Legate::Agent.new(
957
+ definition: definition_obj,
958
+ session_service: @session_service
959
+ )
960
+
961
+ selected_tool_names.each do |tn|
962
+ inst = Legate::GlobalToolManager.create_instance(tn)
963
+ if inst
964
+ logger.debug("Adding selected native tool: #{tn}")
965
+ agent.add_tool(inst)
966
+ else
967
+ logger.debug("Tool '#{tn}' selected but not found in GlobalToolManager (assuming MCP tool).")
968
+ end
969
+ end
970
+
971
+ agent.start
972
+ @agents[name] = agent
973
+ logger.info("Agent '#{name}' started successfully.")
974
+ Legate::ActivityLog.safe_log(:agent_started, { name: name })
975
+ agent
976
+ rescue StandardError => e
977
+ logger.error("Failed to start agent '#{name}': #{e.class} - #{e.message}")
978
+ logger.error(e.backtrace.join("\n"))
979
+ @agents.delete(name)
980
+ nil
981
+ end
982
+ end # End App class
983
+ end # End Web module
984
+ end # End Legate module