rails-agent-stack 0.1.1

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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/README.md +16 -0
  3. data/.github/workflows/ci.yml +32 -0
  4. data/.gitignore +8 -0
  5. data/.rspec +3 -0
  6. data/AGENTS.md +18 -0
  7. data/CHANGELOG.md +40 -0
  8. data/Gemfile +15 -0
  9. data/MIT-LICENSE +21 -0
  10. data/README.md +228 -0
  11. data/Rakefile +8 -0
  12. data/app/assets/images/rails_agents/kip-logo.png +0 -0
  13. data/app/assets/images/rails_agents/kip-waving.png +0 -0
  14. data/app/assets/stylesheets/rails_agents/application.css +411 -0
  15. data/app/controllers/rails_agents/application_controller.rb +25 -0
  16. data/app/controllers/rails_agents/connect_controller.rb +36 -0
  17. data/app/controllers/rails_agents/dashboard_controller.rb +70 -0
  18. data/app/controllers/rails_agents/handshake_controller.rb +55 -0
  19. data/app/controllers/rails_agents/open_wire_inbound_controller.rb +153 -0
  20. data/app/controllers/rails_agents/pulls_controller.rb +42 -0
  21. data/app/controllers/rails_agents/registrations_controller.rb +96 -0
  22. data/app/controllers/rails_agents/schema_controller.rb +123 -0
  23. data/app/controllers/rails_agents/sessions_controller.rb +76 -0
  24. data/app/controllers/rails_agents/tool_bridge_controller.rb +67 -0
  25. data/app/views/layouts/rails_agents/application.html.erb +18 -0
  26. data/app/views/rails_agents/dashboard/show.html.erb +139 -0
  27. data/app/views/rails_agents/registrations/new.html.erb +83 -0
  28. data/app/views/rails_agents/sessions/new.html.erb +73 -0
  29. data/app/views/rails_agents/shared/_auth_brand.html.erb +20 -0
  30. data/config/routes.rb +34 -0
  31. data/docs/AGENTS.md +220 -0
  32. data/docs/PRD.md +89 -0
  33. data/docs/assets/hero-banner.png +0 -0
  34. data/examples/support/agent.rb +14 -0
  35. data/examples/support/channels/.keep +0 -0
  36. data/examples/support/evals/smoke.yml +4 -0
  37. data/examples/support/knowledge/shipping.md +1 -0
  38. data/examples/support/memory.rb +3 -0
  39. data/examples/support/prompt.md +9 -0
  40. data/examples/support/skills/.keep +0 -0
  41. data/examples/support/tools/.keep +0 -0
  42. data/exe/rails-agents +6 -0
  43. data/lib/generators/rails_agents/agent/agent_generator.rb +80 -0
  44. data/lib/generators/rails_agents/agent/templates/agent.rb.tt +14 -0
  45. data/lib/generators/rails_agents/agent/templates/agent_database.rb.tt +42 -0
  46. data/lib/generators/rails_agents/agent/templates/channels/slack.rb.tt +8 -0
  47. data/lib/generators/rails_agents/agent/templates/evals/smoke.yml.tt +4 -0
  48. data/lib/generators/rails_agents/agent/templates/imports.yml.tt +9 -0
  49. data/lib/generators/rails_agents/agent/templates/memory.rb.tt +6 -0
  50. data/lib/generators/rails_agents/agent/templates/prompt.md.tt +10 -0
  51. data/lib/generators/rails_agents/agent/templates/prompt_database.md.tt +14 -0
  52. data/lib/generators/rails_agents/install/INSTALL +10 -0
  53. data/lib/generators/rails_agents/install/install_generator.rb +72 -0
  54. data/lib/generators/rails_agents/install/templates/AGENTS.md.tt +46 -0
  55. data/lib/generators/rails_agents/install/templates/initializer.rb.tt +8 -0
  56. data/lib/generators/rails_agents/install/templates/library_README.md.tt +28 -0
  57. data/lib/generators/rails_agents/install/templates/library_manifest.yml.tt +5 -0
  58. data/lib/generators/rails_agents/install/templates/rails_agents_autoload.rb.tt +10 -0
  59. data/lib/rails-agent-stack.rb +4 -0
  60. data/lib/rails_agents/background_agent.rb +15 -0
  61. data/lib/rails_agents/base.rb +294 -0
  62. data/lib/rails_agents/chat_agent.rb +15 -0
  63. data/lib/rails_agents/cli.rb +245 -0
  64. data/lib/rails_agents/client.rb +216 -0
  65. data/lib/rails_agents/configuration.rb +41 -0
  66. data/lib/rails_agents/creation_agent.rb +10 -0
  67. data/lib/rails_agents/credentials_writer.rb +38 -0
  68. data/lib/rails_agents/engine.rb +19 -0
  69. data/lib/rails_agents/knowledge_agent.rb +10 -0
  70. data/lib/rails_agents/library_imports.rb +163 -0
  71. data/lib/rails_agents/local_auth.rb +56 -0
  72. data/lib/rails_agents/local_sync.rb +113 -0
  73. data/lib/rails_agents/monitoring_agent.rb +10 -0
  74. data/lib/rails_agents/open_wire_adapter.rb +31 -0
  75. data/lib/rails_agents/operations_agent.rb +10 -0
  76. data/lib/rails_agents/railtie.rb +14 -0
  77. data/lib/rails_agents/tasks.rake +8 -0
  78. data/lib/rails_agents/version.rb +5 -0
  79. data/lib/rails_agents/workflow_agent.rb +11 -0
  80. data/lib/rails_agents.rb +57 -0
  81. data/rails-agent-stack.gemspec +49 -0
  82. data/rails-agents.code-workspace +25 -0
  83. data/spec/generators/agent_generator_spec.rb +36 -0
  84. data/spec/rails_agents/base_spec.rb +202 -0
  85. data/spec/rails_agents/client_spec.rb +121 -0
  86. data/spec/rails_agents/configuration_spec.rb +55 -0
  87. data/spec/rails_agents/library_imports_spec.rb +80 -0
  88. data/spec/rails_agents/local_sync_spec.rb +63 -0
  89. data/spec/rails_agents/taxonomy_spec.rb +52 -0
  90. data/spec/spec_helper.rb +27 -0
  91. metadata +229 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAgents
6
+ # Receives open-wire/1 message.inbound POSTs from the Open-Wire gateway.
7
+ # Mounted under the Rails Agents engine (no separate OpenWire::Engine).
8
+ #
9
+ # POST /agents/open_wire/inbound
10
+ # Headers: X-Open-Wire-Secret, X-Open-Wire-Protocol, X-Open-Wire-Installation
11
+ #
12
+ # Resolves agent via:
13
+ # ?agent=order_notification OR JSON meta / installation mapping in channels/*.yml
14
+ class OpenWireInboundController < ApplicationController
15
+ skip_forgery_protection
16
+
17
+ def create
18
+ slug = resolve_agent_slug
19
+ return render json: { error: "agent is required (?agent=slug)" }, status: :bad_request if slug.blank?
20
+
21
+ klass = load_agent_class(slug)
22
+ return render json: { error: "Agent not found" }, status: :not_found unless klass
23
+
24
+ secret = webhook_secret_for(slug)
25
+ return render json: { error: "Open-Wire webhook secret not configured for agent" }, status: :unauthorized if secret.blank?
26
+
27
+ message = RailsAgents::OpenWireAdapter.verify_inbound!(
28
+ secret: secret,
29
+ body: request.raw_post,
30
+ headers: request.headers
31
+ )
32
+
33
+ result = dispatch_to_agent(klass, message, slug)
34
+
35
+ render json: {
36
+ object: "open_wire_inbound_ack",
37
+ data: {
38
+ agent: slug,
39
+ message_id: message.id,
40
+ thread_id: message.thread_id,
41
+ result: result
42
+ }
43
+ }
44
+ rescue ::OpenWire::AuthError => e
45
+ render json: { error: e.message }, status: :unauthorized
46
+ rescue ::OpenWire::WebhookError => e
47
+ render json: { error: e.message }, status: :unprocessable_entity
48
+ rescue StandardError => e
49
+ Rails.logger.error("[RailsAgents::OpenWireInbound] #{e.class}: #{e.message}\n#{e.backtrace&.first(8)&.join("\n")}")
50
+ render json: { error: "Inbound handler failed" }, status: :internal_server_error
51
+ end
52
+
53
+ private
54
+
55
+ def resolve_agent_slug
56
+ params[:agent].presence ||
57
+ params[:agent_slug].presence ||
58
+ request.headers["X-Rails-Agent-Slug"].presence
59
+ end
60
+
61
+ def webhook_secret_for(slug)
62
+ yml = channel_yml(slug)
63
+ return ENV["OPEN_WIRE_WEBHOOK_SECRET"].presence unless yml
64
+
65
+ yml[/open_wire_webhook_secret:\s*["']?([^"'\s]+)/, 1].presence ||
66
+ ENV["OPEN_WIRE_WEBHOOK_SECRET"].presence
67
+ end
68
+
69
+ def installation_id_for(slug)
70
+ yml = channel_yml(slug)
71
+ return nil unless yml
72
+
73
+ yml[/open_wire_installation_id:\s*["']?([^"'\s]+)/, 1]
74
+ end
75
+
76
+ def channel_yml(slug)
77
+ %w[slack.yml slack.yaml].each do |name|
78
+ path = Rails.root.join("app", "agents", slug, "channels", name)
79
+ return path.read if path.file?
80
+ end
81
+ nil
82
+ end
83
+
84
+ def dispatch_to_agent(klass, message, slug)
85
+ # Prefer class-level on_channel_message callback when defined.
86
+ if klass.respond_to?(:channel_message_handler) && klass.channel_message_handler
87
+ return klass.channel_message_handler.call(message)
88
+ end
89
+
90
+ # Default: forward into agent run so Cloud/HITL loop can continue.
91
+ run_message = [
92
+ "open_wire_inbound:",
93
+ "channel: #{message.channel}",
94
+ "thread_id: #{message.thread_id}",
95
+ "from: #{message.from_id}",
96
+ "to: #{message.to_id}",
97
+ "kind: #{message.to_kind}",
98
+ "text: #{message.text}"
99
+ ].join("\n")
100
+
101
+ session_id = message.thread_id.present? ? "ow:#{message.thread_id}" : nil
102
+ result = klass.run(run_message, session_id: session_id)
103
+
104
+ maybe_auto_reply(slug, message, result)
105
+
106
+ {
107
+ mode: "agent_run",
108
+ run_id: (result.respond_to?(:id) ? result.id : nil),
109
+ output_preview: result.to_s.to_s[0, 240]
110
+ }
111
+ end
112
+
113
+ def maybe_auto_reply(slug, message, result)
114
+ installation_id = message.installation_id.presence || installation_id_for(slug)
115
+ return unless installation_id && message.to_id.present?
116
+
117
+ text =
118
+ if result.respond_to?(:output)
119
+ result.output.to_s
120
+ else
121
+ result.to_s
122
+ end
123
+ text = text.strip
124
+ return if text.empty?
125
+
126
+ # Cap auto-reply length for Slack
127
+ text = text[0, 2800]
128
+ RailsAgents::OpenWireAdapter.reply!(
129
+ installation_id: installation_id,
130
+ to: { id: message.to_id, kind: message.dm? ? "dm" : "channel" },
131
+ text: text,
132
+ thread_id: message.thread_id
133
+ )
134
+ rescue ::OpenWire::Error => e
135
+ Rails.logger.warn("[RailsAgents::OpenWireInbound] auto-reply skipped: #{e.message}")
136
+ end
137
+
138
+ def load_agent_class(slug)
139
+ safe_slug = slug.to_s
140
+ return nil unless safe_slug.match?(/\A[a-zA-Z0-9_-]+\z/)
141
+
142
+ path = Rails.root.join("app", "agents", safe_slug, "agent.rb")
143
+ return nil unless path.file?
144
+
145
+ source = path.read
146
+ class_name = source[/class\s+([A-Z][A-Za-z0-9_:]*)\s*<\s*RailsAgents::/, 1]
147
+ return nil unless class_name
148
+
149
+ require_dependency path.to_s
150
+ class_name.constantize
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RailsAgents
6
+ class PullsController < ApplicationController
7
+ # Deprecated: JSON pull from embed bridge. Prefer local `app/agents/` + `rails-agents sync`.
8
+ # Kept for CLI `rails-agents pull` and backward compatibility until cloud authoring is removed.
9
+ def create
10
+ load_local_credentials!
11
+
12
+ unless RailsAgents.config.configured?
13
+ return render json: { ok: false, error: "Not connected to Rails Agent Cloud" }, status: :unprocessable_entity
14
+ end
15
+
16
+ agent_id = params[:agent_id].presence || params[:agent].presence || params.dig(:pull, :agent_id)
17
+ if agent_id.blank?
18
+ return render json: { ok: false, error: "agent_id is required" }, status: :bad_request
19
+ end
20
+
21
+ result = LocalSync.new.pull!(agent_id)
22
+ render json: result
23
+ rescue LocalSync::Error, Client::Error => e
24
+ render json: { ok: false, error: e.message }, status: :unprocessable_entity
25
+ end
26
+
27
+ private
28
+
29
+ def load_local_credentials!
30
+ path = Rails.root.join("config/rails_agents_credentials.yml")
31
+ return unless path.exist?
32
+
33
+ data = YAML.safe_load(path.read) || {}
34
+ RailsAgents.configure do |config|
35
+ config.api_key = data["api_key"] if data["api_key"].present?
36
+ config.project_id = data["project_id"] if data["project_id"].present?
37
+ end
38
+ rescue Psych::SyntaxError
39
+ nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ class RegistrationsController < ApplicationController
5
+ include LocalAuth
6
+
7
+ before_action :redirect_if_configured!
8
+
9
+ def new
10
+ @github_url = github_oauth_url
11
+ pending = session[:ra_auth]
12
+ if pending.is_a?(Hash) && pending["email"].present? && pending["purpose"] == "signup"
13
+ @step = :code
14
+ @email = pending["email"]
15
+ @name = pending["name"]
16
+ @workspace = pending["workspace"].presence || default_workspace
17
+ @dev_code = pending["dev_code"]
18
+ else
19
+ @step = :email
20
+ @workspace = default_workspace
21
+ end
22
+ end
23
+
24
+ def create
25
+ @github_url = github_oauth_url
26
+ @workspace = params[:workspace].to_s.strip.presence || default_workspace
27
+ @name = params[:name].to_s.strip
28
+ @email = params[:email].to_s.strip.downcase
29
+
30
+ if @email.blank?
31
+ flash.now[:alert] = "Email is required."
32
+ @step = :email
33
+ render :new, status: :unprocessable_entity
34
+ return
35
+ end
36
+
37
+ response = Client.new.email_start(
38
+ email: @email,
39
+ purpose: "signup",
40
+ name: @name.presence,
41
+ company: @workspace
42
+ )
43
+ session[:ra_auth] = {
44
+ "purpose" => "signup",
45
+ "email" => @email,
46
+ "name" => @name,
47
+ "workspace" => @workspace,
48
+ "dev_code" => response["devCode"] || response["dev_code"]
49
+ }
50
+ @step = :code
51
+ @dev_code = session[:ra_auth]["dev_code"]
52
+ render :new
53
+ rescue Client::Error => e
54
+ flash.now[:alert] = e.message
55
+ @step = :email
56
+ render :new, status: :unprocessable_entity
57
+ end
58
+
59
+ def verify
60
+ pending = session[:ra_auth] || {}
61
+ @email = pending["email"].to_s
62
+ @name = pending["name"].to_s
63
+ @workspace = pending["workspace"].to_s
64
+ @github_url = github_oauth_url
65
+ code = params[:code].to_s.strip
66
+
67
+ if @email.blank? || code.blank?
68
+ flash.now[:alert] = "Enter the code we emailed you."
69
+ @step = :code
70
+ render :new, status: :unprocessable_entity
71
+ return
72
+ end
73
+
74
+ response = Client.new.email_verify(
75
+ email: @email,
76
+ code: code,
77
+ name: @name.presence,
78
+ company: @workspace.presence
79
+ )
80
+ session.delete(:ra_auth)
81
+ complete_cloud_auth!(response)
82
+ rescue Client::Error => e
83
+ flash.now[:alert] = e.message
84
+ @step = :code
85
+ @dev_code = pending["dev_code"]
86
+ @github_url = github_oauth_url
87
+ render :new, status: :unprocessable_entity
88
+ end
89
+
90
+ private
91
+
92
+ def default_workspace
93
+ (Rails.application.class.module_parent_name rescue "MyApp")
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ class SchemaController < ApplicationController
5
+ # GET /agents/schema — table list for Knowledge → Rails database connector
6
+ def show
7
+ databases = configured_databases
8
+ tables = active_record_tables
9
+ tables.concat(mongoid_collections) if tables.empty? || databases.any? { |db| db[:adapter] == "mongoid" }
10
+ primary = databases.find { |db| db[:name] == "primary" } || databases.first
11
+
12
+ render json: {
13
+ ok: true,
14
+ tables: tables,
15
+ adapter: primary&.dig(:adapter),
16
+ databases: databases,
17
+ config_files: detected_config_files
18
+ }
19
+ rescue StandardError => e
20
+ render json: { ok: false, error: e.message, tables: [] }, status: :unprocessable_entity
21
+ end
22
+
23
+ private
24
+
25
+ def configured_databases
26
+ configs = []
27
+ if defined?(ActiveRecord::Base)
28
+ ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db|
29
+ configs << {
30
+ name: db.name.to_s,
31
+ adapter: normalize_adapter(db.adapter),
32
+ raw_adapter: db.adapter.to_s,
33
+ source: "config/database.yml"
34
+ }
35
+ end
36
+ end
37
+ if Rails.root.join("config/mongoid.yml").exist?
38
+ configs << {
39
+ name: "mongoid",
40
+ adapter: "mongoid",
41
+ raw_adapter: "mongodb",
42
+ source: "config/mongoid.yml"
43
+ }
44
+ end
45
+ configs.uniq { |db| [db[:name], db[:adapter], db[:source]] }
46
+ rescue StandardError
47
+ []
48
+ end
49
+
50
+ def active_record_tables
51
+ return [] unless defined?(ActiveRecord::Base)
52
+
53
+ ActiveRecord::Base.connection.tables
54
+ .reject { |table| internal_table?(table) }
55
+ .sort
56
+ .map do |name|
57
+ columns =
58
+ begin
59
+ ActiveRecord::Base.connection.columns(name).map(&:name)
60
+ rescue StandardError
61
+ []
62
+ end
63
+ {
64
+ name: name,
65
+ columns: columns.first(40),
66
+ kind: "table",
67
+ samples: sample_rows(name, columns)
68
+ }
69
+ end
70
+ rescue StandardError
71
+ []
72
+ end
73
+
74
+ def sample_rows(_table, columns)
75
+ return [] if columns.blank?
76
+
77
+ quoted = ActiveRecord::Base.connection.quote_table_name(_table)
78
+ rows = ActiveRecord::Base.connection.exec_query("SELECT * FROM #{quoted} LIMIT 8").to_a
79
+ rows.map do |row|
80
+ row.transform_values { |value| truncate_sample(value) }
81
+ end
82
+ rescue StandardError
83
+ []
84
+ end
85
+
86
+ def truncate_sample(value)
87
+ text = value.is_a?(String) ? value : value.inspect
88
+ text.length > 120 ? "#{text[0, 117]}..." : text
89
+ end
90
+
91
+ def mongoid_collections
92
+ return [] unless defined?(Mongoid) && Mongoid.respond_to?(:default_client)
93
+
94
+ Mongoid.default_client.database.collection_names
95
+ .reject { |name| name.start_with?("system.") }
96
+ .sort
97
+ .map { |name| { name: name, columns: [], kind: "collection", samples: [] } }
98
+ rescue StandardError
99
+ []
100
+ end
101
+
102
+ def detected_config_files
103
+ %w[config/database.yml config/mongoid.yml]
104
+ .select { |path| Rails.root.join(path).exist? }
105
+ end
106
+
107
+ def normalize_adapter(adapter)
108
+ value = adapter.to_s.downcase
109
+ return "postgresql" if value.include?("postgres") || value.include?("postgis") || value.include?("cockroach")
110
+ return "mysql" if value.include?("mysql") || value.include?("trilogy") || value.include?("maria")
111
+ return "sqlite" if value.include?("sqlite")
112
+ return "sqlserver" if value.include?("sqlserver")
113
+ return "oracle" if value.include?("oracle")
114
+
115
+ value.presence || "active_record"
116
+ end
117
+
118
+ def internal_table?(table)
119
+ table.start_with?("ar_", "solid_", "rails_agents") ||
120
+ %w[schema_migrations ar_internal_metadata].include?(table)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ class SessionsController < ApplicationController
5
+ include LocalAuth
6
+
7
+ before_action :redirect_if_configured!, only: %i[new create verify]
8
+
9
+ def new
10
+ @github_url = github_oauth_url
11
+ pending = session[:ra_auth]
12
+ if pending.is_a?(Hash) && pending["email"].present? && pending["purpose"] == "signin"
13
+ @step = :code
14
+ @email = pending["email"]
15
+ @dev_code = pending["dev_code"]
16
+ else
17
+ @step = :email
18
+ end
19
+ end
20
+
21
+ def create
22
+ @github_url = github_oauth_url
23
+ @email = params[:email].to_s.strip.downcase
24
+
25
+ if @email.blank?
26
+ flash.now[:alert] = "Email is required."
27
+ @step = :email
28
+ render :new, status: :unprocessable_entity
29
+ return
30
+ end
31
+
32
+ response = Client.new.email_start(email: @email, purpose: "signin")
33
+ session[:ra_auth] = {
34
+ "purpose" => "signin",
35
+ "email" => @email,
36
+ "dev_code" => response["devCode"] || response["dev_code"]
37
+ }
38
+ @step = :code
39
+ @dev_code = session[:ra_auth]["dev_code"]
40
+ render :new
41
+ rescue Client::Error => e
42
+ flash.now[:alert] = e.message
43
+ @step = :email
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+
47
+ def verify
48
+ pending = session[:ra_auth] || {}
49
+ @email = pending["email"].to_s
50
+ @github_url = github_oauth_url
51
+ code = params[:code].to_s.strip
52
+
53
+ if @email.blank? || code.blank?
54
+ flash.now[:alert] = "Enter the code we emailed you."
55
+ @step = :code
56
+ render :new, status: :unprocessable_entity
57
+ return
58
+ end
59
+
60
+ response = Client.new.email_verify(email: @email, code: code)
61
+ session.delete(:ra_auth)
62
+ complete_cloud_auth!(response)
63
+ rescue Client::Error => e
64
+ flash.now[:alert] = e.message
65
+ @step = :code
66
+ @dev_code = pending["dev_code"]
67
+ render :new, status: :unprocessable_entity
68
+ end
69
+
70
+ def destroy
71
+ cloud = RailsAgents.config.dashboard_base.to_s.chomp("/")
72
+ cloud = "https://cloud.rails-agent.com" if cloud.blank?
73
+ redirect_to "#{cloud}/signin", allow_other_host: true
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module RailsAgents
9
+ class ToolBridgeController < ApplicationController
10
+ skip_forgery_protection
11
+
12
+ def create
13
+ return render json: { error: "Unauthorized" }, status: :unauthorized unless authorized_runtime?
14
+
15
+ klass = load_agent_class(params[:agent])
16
+ tool = klass&.tool_definitions&.fetch(params[:tool].to_sym, nil)
17
+ return render json: { error: "Tool not found" }, status: :not_found unless tool
18
+
19
+ arguments = request.request_parameters.presence || {}
20
+ result = tool.call(**arguments.deep_symbolize_keys)
21
+ render json: { ok: true, result: result }
22
+ rescue ArgumentError => e
23
+ render json: { error: e.message }, status: :unprocessable_entity
24
+ rescue StandardError => e
25
+ Rails.logger.error("[RailsAgents::ToolBridge] #{e.class}: #{e.message}")
26
+ render json: { error: "Tool execution failed" }, status: :internal_server_error
27
+ end
28
+
29
+ private
30
+
31
+ def authorized_runtime?
32
+ token = request.authorization.to_s.sub(/\ABearer\s+/i, "")
33
+ return false if token.empty?
34
+
35
+ uri = URI("#{RailsAgents.config.api_v1_base}/auth/introspect")
36
+ req = Net::HTTP::Get.new(uri)
37
+ req["Authorization"] = "Bearer #{token}"
38
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
39
+ http.open_timeout = 3
40
+ http.read_timeout = 5
41
+ http.request(req)
42
+ end
43
+ return false unless response.is_a?(Net::HTTPSuccess)
44
+
45
+ body = JSON.parse(response.body)
46
+ body.dig("data", "projectId").to_s == RailsAgents.config.project_id.to_s
47
+ rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error
48
+ false
49
+ end
50
+
51
+ def load_agent_class(slug)
52
+ safe_slug = slug.to_s
53
+ return nil unless safe_slug.match?(/\A[a-zA-Z0-9_-]+\z/)
54
+
55
+ path = Rails.root.join("app", "agents", safe_slug, "agent.rb")
56
+ return nil unless path.file?
57
+
58
+ source = path.read
59
+ class_name = source[/class\s+([A-Z][A-Za-z0-9_:]*)\s*<\s*RailsAgents::/, 1]
60
+ return nil unless class_name
61
+
62
+ require_dependency path.to_s
63
+ class_name.constantize
64
+ end
65
+ end
66
+ end
67
+
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Rails Agent</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,500;1,9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <%= csrf_meta_tags if respond_to?(:csrf_meta_tags) %>
11
+ <%= stylesheet_link_tag "rails_agents/application", media: "all" %>
12
+ </head>
13
+ <body class="rails-agents-body">
14
+ <main class="<%= configured? ? "rails-agents-main--embed" : nil %>">
15
+ <%= yield %>
16
+ </main>
17
+ </body>
18
+ </html>