robot_lab-rails 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/CHANGELOG.md +5 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +110 -0
  7. data/Rakefile +8 -0
  8. data/docs/examples/rails-application.md +419 -0
  9. data/docs/guides/rails-integration.md +681 -0
  10. data/docs/index.md +75 -0
  11. data/examples/18_rails/.envrc +3 -0
  12. data/examples/18_rails/.gitignore +5 -0
  13. data/examples/18_rails/Gemfile +11 -0
  14. data/examples/18_rails/README.md +48 -0
  15. data/examples/18_rails/Rakefile +4 -0
  16. data/examples/18_rails/app/controllers/application_controller.rb +4 -0
  17. data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
  18. data/examples/18_rails/app/jobs/application_job.rb +4 -0
  19. data/examples/18_rails/app/jobs/robot_run_job.rb +19 -0
  20. data/examples/18_rails/app/models/application_record.rb +5 -0
  21. data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
  22. data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
  23. data/examples/18_rails/app/robots/chat_robot.rb +14 -0
  24. data/examples/18_rails/app/tools/time_tool.rb +9 -0
  25. data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
  26. data/examples/18_rails/app/views/chat/index.html.erb +67 -0
  27. data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
  28. data/examples/18_rails/bin/dev +7 -0
  29. data/examples/18_rails/bin/rails +6 -0
  30. data/examples/18_rails/bin/setup +15 -0
  31. data/examples/18_rails/config/application.rb +33 -0
  32. data/examples/18_rails/config/cable.yml +2 -0
  33. data/examples/18_rails/config/database.yml +5 -0
  34. data/examples/18_rails/config/environment.rb +4 -0
  35. data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
  36. data/examples/18_rails/config/routes.rb +6 -0
  37. data/examples/18_rails/config.ru +4 -0
  38. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
  39. data/lib/generators/robot_lab/install_generator.rb +63 -0
  40. data/lib/generators/robot_lab/job_generator.rb +28 -0
  41. data/lib/generators/robot_lab/robot_generator.rb +40 -0
  42. data/lib/generators/robot_lab/templates/initializer.rb.tt +39 -0
  43. data/lib/generators/robot_lab/templates/job.rb.tt +21 -0
  44. data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
  45. data/lib/generators/robot_lab/templates/result_model.rb.tt +40 -0
  46. data/lib/generators/robot_lab/templates/robot.rb.tt +31 -0
  47. data/lib/generators/robot_lab/templates/robot_job.rb.tt +15 -0
  48. data/lib/generators/robot_lab/templates/robot_test.rb.tt +21 -0
  49. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +42 -0
  50. data/lib/generators/robot_lab/templates/thread_model.rb.tt +27 -0
  51. data/lib/robot_lab/rails/version.rb +7 -0
  52. data/lib/robot_lab/rails.rb +10 -0
  53. data/lib/robot_lab/rails_integration/engine.rb +23 -0
  54. data/lib/robot_lab/rails_integration/job.rb +109 -0
  55. data/lib/robot_lab/rails_integration/railtie.rb +36 -0
  56. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +42 -0
  57. data/mkdocs.yml +117 -0
  58. metadata +158 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RobotLab
6
+ module Generators
7
+ class JobGenerator < ::Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :queue, type: :string, default: "default",
11
+ desc: "ActiveJob queue name"
12
+
13
+ def create_job_file
14
+ template "robot_job.rb.tt", "app/jobs/#{file_name}_job.rb"
15
+ end
16
+
17
+ private
18
+
19
+ def queue_name
20
+ options[:queue]
21
+ end
22
+
23
+ def robot_class_name
24
+ "#{class_name}Robot"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RobotLab
6
+ module Generators
7
+ class RobotGenerator < ::Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :description, type: :string, default: nil,
11
+ desc: "Robot description"
12
+ class_option :routing, type: :boolean, default: false,
13
+ desc: "Generate a routing robot"
14
+ class_option :tools, type: :array, default: [],
15
+ desc: "List of tools to include"
16
+
17
+ def create_robot_file
18
+ if options[:routing]
19
+ template "routing_robot.rb.tt", "app/robots/#{file_name}_robot.rb"
20
+ else
21
+ template "robot.rb.tt", "app/robots/#{file_name}_robot.rb"
22
+ end
23
+ end
24
+
25
+ def create_test_file
26
+ template "robot_test.rb.tt", "test/robots/#{file_name}_robot_test.rb"
27
+ end
28
+
29
+ private
30
+
31
+ def robot_description
32
+ options[:description] || "A helpful #{class_name.titleize} robot"
33
+ end
34
+
35
+ def robot_tools
36
+ options[:tools]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RobotLab Configuration
4
+ #
5
+ # RobotLab uses MywayConfig for configuration. Settings are loaded from:
6
+ #
7
+ # 1. Bundled defaults (lib/robot_lab/config/defaults.yml)
8
+ # 2. Environment-specific overrides (development, test, production)
9
+ # 3. XDG user config (~/.config/robot_lab/config.yml)
10
+ # 4. Project config (./config/robot_lab.yml)
11
+ # 5. Environment variables (ROBOT_LAB_* prefix)
12
+ #
13
+ # Create config/robot_lab.yml for project-specific settings:
14
+ #
15
+ # defaults:
16
+ # ruby_llm:
17
+ # anthropic_api_key: <%%= ENV['ANTHROPIC_API_KEY'] %>
18
+ # openai_api_key: <%%= ENV['OPENAI_API_KEY'] %>
19
+ # model: claude-sonnet-4
20
+ # request_timeout: 180
21
+ #
22
+ # development:
23
+ # ruby_llm:
24
+ # log_level: :debug
25
+ #
26
+ # test:
27
+ # streaming_enabled: false
28
+ # ruby_llm:
29
+ # model: claude-3-haiku-20240307
30
+ # request_timeout: 30
31
+ #
32
+ # Or use environment variables with double underscores for nested keys:
33
+ #
34
+ # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
35
+ # ROBOT_LAB_RUBY_LLM__MODEL=claude-sonnet-4
36
+ #
37
+
38
+ # Set the RobotLab logger to use Rails.logger
39
+ RobotLab.config.logger = Rails.logger
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generic background job for executing any robot asynchronously.
4
+ #
5
+ # Inherits from RobotLab::Job — Turbo Stream wiring, thread persistence,
6
+ # and completion/error broadcasting are all handled by the base class.
7
+ #
8
+ # Pass robot_class: at enqueue time to select which robot to run, or
9
+ # generate a dedicated job with `rails generate robot_lab:job NAME` to
10
+ # bind a job class to a specific robot via the robot_class DSL.
11
+ #
12
+ # @example Enqueue from a controller
13
+ # RobotRunJob.perform_later(
14
+ # robot_class: "SupportRobot",
15
+ # message: params[:message],
16
+ # thread_id: session_id
17
+ # )
18
+ #
19
+ class RobotRunJob < RobotLab::Job
20
+ queue_as :default
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRobotLabTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :robot_lab_threads do |t|
6
+ t.string :session_id, null: false, index: { unique: true }
7
+ t.text :initial_input
8
+ t.json :input_metadata, default: {}
9
+ t.json :state_data, default: {}
10
+ t.text :last_user_message
11
+ t.datetime :last_user_message_at
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ create_table :robot_lab_results do |t|
17
+ t.string :session_id, null: false, index: true
18
+ t.string :robot_name, null: false
19
+ t.integer :sequence_number, null: false, default: 0
20
+ t.json :output_data, default: []
21
+ t.json :tool_calls_data, default: []
22
+ t.string :stop_reason
23
+ t.string :checksum
24
+
25
+ t.timestamps
26
+ end
27
+
28
+ add_index :robot_lab_results, [:session_id, :sequence_number]
29
+ add_foreign_key :robot_lab_results, :robot_lab_threads,
30
+ column: :session_id, primary_key: :session_id
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RobotLab Result Model
4
+ #
5
+ # Stores robot execution results for history persistence.
6
+ #
7
+ class RobotLabResult < ApplicationRecord
8
+ belongs_to :thread,
9
+ class_name: "RobotLabThread",
10
+ foreign_key: :session_id,
11
+ primary_key: :session_id
12
+
13
+ validates :session_id, presence: true
14
+ validates :robot_name, presence: true
15
+ validates :sequence_number, presence: true,
16
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
17
+
18
+ default_scope { order(sequence_number: :asc) }
19
+
20
+ def output_messages
21
+ (output_data || []).map do |data|
22
+ RobotLab::Message.from_hash(data.symbolize_keys)
23
+ end
24
+ end
25
+
26
+ def tool_call_messages
27
+ (tool_calls_data || []).map do |data|
28
+ RobotLab::Message.from_hash(data.symbolize_keys)
29
+ end
30
+ end
31
+
32
+ def to_robot_result
33
+ RobotLab::RobotResult.new(
34
+ robot_name: robot_name,
35
+ output: output_messages,
36
+ tool_calls: tool_call_messages,
37
+ stop_reason: stop_reason
38
+ )
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> Robot
4
+ #
5
+ # <%= robot_description %>
6
+ #
7
+ class <%= class_name %>Robot
8
+ SYSTEM_PROMPT = <<~PROMPT
9
+ You are a helpful <%= class_name.titleize %> assistant.
10
+
11
+ Your role is to assist users with their requests in a friendly and efficient manner.
12
+ PROMPT
13
+
14
+ def self.build(**options)
15
+ RobotLab.build(
16
+ name: "<%= file_name %>",
17
+ description: "<%= robot_description %>",
18
+ system_prompt: SYSTEM_PROMPT,
19
+ local_tools: tools,
20
+ **options
21
+ )
22
+ end
23
+
24
+ def self.tools
25
+ [
26
+ <%- robot_tools.each do |tool| -%>
27
+ # <%= tool.camelize %>,
28
+ <%- end -%>
29
+ ]
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Background job that runs <%= robot_class_name %> asynchronously.
4
+ #
5
+ # @example Enqueue from a controller
6
+ # <%= class_name %>Job.perform_later(
7
+ # message: params[:message],
8
+ # thread_id: session_id
9
+ # )
10
+ #
11
+ class <%= class_name %>Job < RobotLab::Job
12
+ queue_as :<%= queue_name %>
13
+
14
+ robot_class <%= robot_class_name %>
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class <%= class_name %>RobotTest < ActiveSupport::TestCase
6
+ def setup
7
+ @robot = <%= class_name %>Robot.build
8
+ end
9
+
10
+ test "robot has correct name" do
11
+ assert_equal "<%= file_name %>", @robot.name
12
+ end
13
+
14
+ test "robot has description" do
15
+ assert_not_nil @robot.description
16
+ end
17
+
18
+ test "robot has system prompt" do
19
+ assert_not_nil @robot.system_prompt
20
+ end
21
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> Routing Robot
4
+ #
5
+ # <%= robot_description %>
6
+ #
7
+ class <%= class_name %>Robot < RobotLab::Robot
8
+ SYSTEM_PROMPT = <<~PROMPT
9
+ You are a routing robot that classifies user requests.
10
+
11
+ Analyze the user's request and respond with ONLY the category name.
12
+ Valid categories: billing, technical, general
13
+ PROMPT
14
+
15
+ def self.build(**options)
16
+ new(
17
+ name: "<%= file_name %>",
18
+ description: "<%= robot_description %>",
19
+ system_prompt: SYSTEM_PROMPT,
20
+ **options
21
+ )
22
+ end
23
+
24
+ def call(result)
25
+ context = extract_run_context(result)
26
+ message = context.delete(:message)
27
+
28
+ robot_result = run(message, **context)
29
+
30
+ new_result = result
31
+ .with_context(@name.to_sym, robot_result)
32
+ .continue(robot_result)
33
+
34
+ category = robot_result.last_text_content.to_s.strip.downcase
35
+
36
+ case category
37
+ when /billing/ then new_result.activate(:billing)
38
+ when /technical/ then new_result.activate(:technical)
39
+ else new_result.activate(:general)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RobotLab Thread Model
4
+ #
5
+ # Stores conversation threads for history persistence.
6
+ #
7
+ class RobotLabThread < ApplicationRecord
8
+ has_many :results,
9
+ class_name: "RobotLabResult",
10
+ foreign_key: :session_id,
11
+ primary_key: :session_id,
12
+ dependent: :destroy
13
+
14
+ validates :session_id, presence: true, uniqueness: true
15
+
16
+ def self.find_or_create_by_session_id(id)
17
+ find_or_create_by(session_id: id)
18
+ end
19
+
20
+ def last_result
21
+ results.order(sequence_number: :desc).first
22
+ end
23
+
24
+ def message_count
25
+ results.count
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails/version"
4
+ require_relative "rails_integration/turbo_stream_callbacks"
5
+
6
+ if defined?(::Rails)
7
+ require_relative "rails_integration/engine"
8
+ require_relative "rails_integration/railtie"
9
+ require_relative "rails_integration/job"
10
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RobotLab
7
+
8
+ initializer "robot_lab.configure" do |app|
9
+ app.config.robot_lab ||= ActiveSupport::OrderedOptions.new
10
+ end
11
+
12
+ initializer "robot_lab.add_autoload_paths", before: :set_autoload_paths do |app|
13
+ app.config.autoload_paths << root.join("app", "robots")
14
+ app.config.autoload_paths << root.join("app", "tools")
15
+ end
16
+
17
+ config.generators do |g|
18
+ g.test_framework :minitest, fixture: false
19
+ g.fixture_replacement nil
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ # Base class for RobotLab background jobs.
6
+ #
7
+ # @example Minimal subclass using the robot_class DSL
8
+ # class SupportRobotJob < RobotLab::Job
9
+ # queue_as :default
10
+ # robot_class SupportRobot
11
+ # end
12
+ #
13
+ class Job < ActiveJob::Base
14
+ def self.robot_class(klass = nil)
15
+ klass ? @robot_class = klass : @robot_class
16
+ end
17
+
18
+ retry_on StandardError, wait: 5.seconds, attempts: 3
19
+ discard_on ActiveJob::DeserializationError
20
+
21
+ def perform(message:, robot_class: nil, thread_id: nil, **context)
22
+ klass = resolve_robot_class(robot_class)
23
+ thread = setup_thread(thread_id, message)
24
+ robot = build_robot(klass, thread_id)
25
+ result = robot.run(message, **context)
26
+
27
+ if thread
28
+ persist_result(thread, result)
29
+ broadcast_completion(thread_id)
30
+ end
31
+
32
+ result
33
+ rescue StandardError => e
34
+ broadcast_error(thread_id, e) if thread_id
35
+ raise
36
+ end
37
+
38
+ private
39
+
40
+ def resolve_robot_class(runtime_class)
41
+ klass = runtime_class || self.class.robot_class
42
+ raise ArgumentError,
43
+ "No robot class specified. Pass robot_class: to perform or set robot_class on the job class." \
44
+ unless klass
45
+
46
+ return klass if klass.is_a?(Class)
47
+
48
+ klass.to_s.constantize
49
+ end
50
+
51
+ def setup_thread(thread_id, message)
52
+ return nil unless thread_id
53
+
54
+ thread = "RobotLabThread".constantize.find_or_create_by_session_id(thread_id)
55
+ thread.update!(last_user_message: message, last_user_message_at: Time.current)
56
+ thread
57
+ end
58
+
59
+ def build_robot(klass, thread_id)
60
+ if thread_id && turbo_available?
61
+ stream_name = "robot_lab_thread_#{thread_id}"
62
+ on_content = TurboStreamCallbacks.build_content_callback(stream_name: stream_name)
63
+ on_tool_call = TurboStreamCallbacks.build_tool_call_callback(stream_name: stream_name)
64
+ klass.build(on_content: on_content, on_tool_call: on_tool_call)
65
+ else
66
+ klass.build
67
+ end
68
+ end
69
+
70
+ def persist_result(thread, result)
71
+ sequence = thread.results.maximum(:sequence_number).to_i + 1
72
+ exported = result.export
73
+
74
+ thread.results.create!(
75
+ robot_name: result.robot_name,
76
+ sequence_number: sequence,
77
+ output_data: exported[:output],
78
+ tool_calls_data: exported[:tool_calls],
79
+ stop_reason: result.stop_reason,
80
+ checksum: result.checksum
81
+ )
82
+ end
83
+
84
+ def broadcast_completion(thread_id)
85
+ return unless turbo_available?
86
+
87
+ Turbo::StreamsChannel.broadcast_replace_to(
88
+ "robot_lab_thread_#{thread_id}",
89
+ target: "robot_status",
90
+ html: "<div id=\"robot_status\"><span class=\"complete\">Complete</span></div>"
91
+ )
92
+ end
93
+
94
+ def broadcast_error(thread_id, error)
95
+ return unless turbo_available?
96
+
97
+ Turbo::StreamsChannel.broadcast_append_to(
98
+ "robot_lab_thread_#{thread_id}",
99
+ target: "robot_errors",
100
+ html: "<div class=\"error\">#{ERB::Util.html_escape(error.message)}</div>"
101
+ )
102
+ end
103
+
104
+ def turbo_available?
105
+ defined?(Turbo::StreamsChannel)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ class Railtie < ::Rails::Railtie
6
+ config.robot_lab = ActiveSupport::OrderedOptions.new
7
+
8
+ initializer "robot_lab.configuration" do |app|
9
+ RobotLab.configure do |config|
10
+ rails_config = app.config.robot_lab
11
+
12
+ config.default_model = rails_config.default_model if rails_config.default_model
13
+ config.default_provider = rails_config.default_provider if rails_config.default_provider
14
+ config.logger = ::Rails.logger
15
+ end
16
+ end
17
+
18
+ initializer "robot_lab.active_record" do
19
+ ActiveSupport.on_load(:active_record) do
20
+ # Extend ActiveRecord with RobotLab concerns if needed
21
+ end
22
+ end
23
+
24
+ rake_tasks do
25
+ path = File.expand_path("../tasks", __dir__)
26
+ Dir.glob("#{path}/**/*.rake").each { |f| load f }
27
+ end
28
+
29
+ generators do
30
+ require "generators/robot_lab/install_generator"
31
+ require "generators/robot_lab/robot_generator"
32
+ require "generators/robot_lab/job_generator"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ # Stateless utility module that builds callback Procs for Turbo Stream broadcasting.
6
+ #
7
+ # Safe to require even without turbo-rails installed — checks at call time
8
+ # via `defined?(Turbo::StreamsChannel)`.
9
+ #
10
+ module TurboStreamCallbacks
11
+ def self.available?
12
+ defined?(Turbo::StreamsChannel) ? true : false
13
+ end
14
+
15
+ def self.build_content_callback(stream_name:, target: "robot_response")
16
+ ->(chunk) {
17
+ content = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
18
+ return unless content && TurboStreamCallbacks.available?
19
+
20
+ Turbo::StreamsChannel.broadcast_append_to(
21
+ stream_name,
22
+ target: target,
23
+ html: ERB::Util.html_escape(content)
24
+ )
25
+ }
26
+ end
27
+
28
+ def self.build_tool_call_callback(stream_name:, target: "robot_tools")
29
+ ->(tool_call) {
30
+ return unless TurboStreamCallbacks.available?
31
+
32
+ name = tool_call.respond_to?(:name) ? tool_call.name : tool_call.to_s
33
+ Turbo::StreamsChannel.broadcast_append_to(
34
+ stream_name,
35
+ target: target,
36
+ html: "<span class=\"tool-badge\">Using: #{ERB::Util.html_escape(name)}</span>"
37
+ )
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end