robot_lab 0.0.11 → 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.
@@ -1,92 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Background job for executing robot runs asynchronously.
3
+ # Generic background job for executing any robot asynchronously.
4
4
  #
5
- # Resolves a robot class, optionally wires Turbo Stream callbacks for
6
- # real-time token streaming, persists the result, and broadcasts
7
- # completion/error events.
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.
8
11
  #
9
12
  # @example Enqueue from a controller
10
13
  # RobotRunJob.perform_later(
11
14
  # robot_class: "SupportRobot",
12
- # message: params[:message],
13
- # thread_id: session_id
15
+ # message: params[:message],
16
+ # thread_id: session_id
14
17
  # )
15
18
  #
16
- class RobotRunJob < ApplicationJob
19
+ class RobotRunJob < RobotLab::Job
17
20
  queue_as :default
18
-
19
- retry_on StandardError, wait: 5.seconds, attempts: 3
20
- discard_on ActiveJob::DeserializationError
21
-
22
- def perform(robot_class:, message:, thread_id:, **context)
23
- thread = RobotLabThread.find_or_create_by_session_id(thread_id)
24
- thread.update!(last_user_message: message, last_user_message_at: Time.current)
25
-
26
- robot = resolve_robot(robot_class, thread_id)
27
- result = robot.run(message, **context)
28
-
29
- persist_result(thread, result)
30
- broadcast_completion(thread_id)
31
- rescue StandardError => e
32
- broadcast_error(thread_id, e)
33
- raise
34
- end
35
-
36
- private
37
-
38
- def resolve_robot(robot_class, thread_id)
39
- klass = robot_class.to_s.constantize
40
- stream_name = "robot_lab_thread_#{thread_id}"
41
-
42
- if turbo_available?
43
- on_content = RobotLab::RailsIntegration::TurboStreamCallbacks.build_content_callback(
44
- stream_name: stream_name
45
- )
46
- on_tool_call = RobotLab::RailsIntegration::TurboStreamCallbacks.build_tool_call_callback(
47
- stream_name: stream_name
48
- )
49
- klass.build(on_content: on_content, on_tool_call: on_tool_call)
50
- else
51
- klass.build
52
- end
53
- end
54
-
55
- def persist_result(thread, result)
56
- sequence = thread.results.maximum(:sequence_number).to_i + 1
57
- exported = result.export
58
-
59
- thread.results.create!(
60
- robot_name: result.robot_name,
61
- sequence_number: sequence,
62
- output_data: exported[:output],
63
- tool_calls_data: exported[:tool_calls],
64
- stop_reason: result.stop_reason,
65
- checksum: result.checksum
66
- )
67
- end
68
-
69
- def broadcast_completion(thread_id)
70
- return unless turbo_available?
71
-
72
- Turbo::StreamsChannel.broadcast_replace_to(
73
- "robot_lab_thread_#{thread_id}",
74
- target: "robot_status",
75
- html: "<span class=\"robot-status-complete\">Complete</span>"
76
- )
77
- end
78
-
79
- def broadcast_error(thread_id, error)
80
- return unless turbo_available?
81
-
82
- Turbo::StreamsChannel.broadcast_append_to(
83
- "robot_lab_thread_#{thread_id}",
84
- target: "robot_errors",
85
- html: "<div class=\"robot-error\">#{ERB::Util.html_escape(error.message)}</div>"
86
- )
87
- end
88
-
89
- def turbo_available?
90
- defined?(Turbo::StreamsChannel)
91
- end
92
21
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Background job that runs <%= robot_class_name %> 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
+ # @example Enqueue from a controller
9
+ # <%= class_name %>Job.perform_later(
10
+ # message: params[:message],
11
+ # thread_id: session_id
12
+ # )
13
+ #
14
+ class <%= class_name %>Job < RobotLab::Job
15
+ queue_as :<%= queue_name %>
16
+
17
+ robot_class <%= robot_class_name %>
18
+ end
@@ -92,7 +92,7 @@ module RobotLab
92
92
  def self.from_hash(hash)
93
93
  hash = hash.transform_keys(&:to_sym)
94
94
 
95
- case hash[:type]&.to_s
95
+ case (hash[:type] || "text").to_s
96
96
  when "text"
97
97
  TextMessage.new(**hash.slice(:role, :content, :stop_reason))
98
98
  when "tool_call"
@@ -190,7 +190,7 @@ module RobotLab
190
190
  run_context,
191
191
  context: { run_params: run_context }
192
192
  )
193
- @pipeline.call_parallel(initial_result)
193
+ @pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
194
194
  end
195
195
  end
196
196
 
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module RailsIntegration
5
+ # Base class for RobotLab background jobs.
6
+ #
7
+ # Encapsulates the full robot-run lifecycle: robot class resolution,
8
+ # Turbo Stream callback wiring, thread-record persistence, and
9
+ # completion/error broadcasting. Suitable for fiber-safe execution
10
+ # under Solid Queue's fiber mode (see issue #20 for SQ version details).
11
+ #
12
+ # @example Minimal subclass using the robot_class DSL
13
+ # class SupportRobotJob < RobotLab::Job
14
+ # queue_as :default
15
+ # robot_class SupportRobot
16
+ # end
17
+ #
18
+ # # Enqueue (no robot_class: needed — taken from DSL):
19
+ # SupportRobotJob.perform_later(message: "Hello", thread_id: session_id)
20
+ #
21
+ # @example Generic job accepting robot_class at enqueue time
22
+ # class RobotRunJob < RobotLab::Job
23
+ # queue_as :default
24
+ # end
25
+ #
26
+ # RobotRunJob.perform_later(
27
+ # robot_class: "SupportRobot",
28
+ # message: params[:message],
29
+ # thread_id: session_id
30
+ # )
31
+ #
32
+ class Job < ActiveJob::Base
33
+ # Set or get the default robot class for this job subclass.
34
+ #
35
+ # @overload robot_class
36
+ # @return [Class, nil] the configured robot class
37
+ # @overload robot_class(klass)
38
+ # @param klass [Class] the robot class (must respond to .build)
39
+ # @return [Class]
40
+ def self.robot_class(klass = nil)
41
+ klass ? @robot_class = klass : @robot_class
42
+ end
43
+
44
+ retry_on StandardError, wait: 5.seconds, attempts: 3
45
+ discard_on ActiveJob::DeserializationError
46
+
47
+ # Run a robot as a background job.
48
+ #
49
+ # When +thread_id+ is provided the job:
50
+ # - Finds or creates a +RobotLabThread+ record and updates its last-message fields
51
+ # - Wires +TurboStreamCallbacks+ onto the robot (when turbo-rails is present)
52
+ # - Persists the +RobotResult+ to +RobotLabResult+
53
+ # - Broadcasts a completion or error event via Turbo Streams
54
+ #
55
+ # When +thread_id+ is omitted the robot runs in a fire-and-forget mode —
56
+ # no persistence, no broadcasting, result returned directly.
57
+ #
58
+ # @param message [String] the user message forwarded to robot.run
59
+ # @param robot_class [String, Class, nil] override; falls back to the class-level DSL
60
+ # @param thread_id [String, nil] session key for persistence and Turbo broadcasting
61
+ # @param context [Hash] additional keyword args forwarded to robot.run
62
+ # @return [RobotResult]
63
+ def perform(message:, robot_class: nil, thread_id: nil, **context)
64
+ klass = resolve_robot_class(robot_class)
65
+ thread = setup_thread(thread_id, message)
66
+ robot = build_robot(klass, thread_id)
67
+ result = robot.run(message, **context)
68
+
69
+ if thread
70
+ persist_result(thread, result)
71
+ broadcast_completion(thread_id)
72
+ end
73
+
74
+ result
75
+ rescue StandardError => e
76
+ broadcast_error(thread_id, e) if thread_id
77
+ raise
78
+ end
79
+
80
+ private
81
+
82
+ # Resolve the robot class from the runtime arg or the class-level DSL.
83
+ def resolve_robot_class(runtime_class)
84
+ klass = runtime_class || self.class.robot_class
85
+ raise ArgumentError,
86
+ "No robot class specified. Pass robot_class: to perform or set robot_class on the job class." \
87
+ unless klass
88
+
89
+ return klass if klass.is_a?(Class)
90
+
91
+ klass.to_s.constantize
92
+ end
93
+
94
+ # Find or create the thread record and stamp the incoming message.
95
+ # Returns nil when thread_id is absent (fire-and-forget mode).
96
+ def setup_thread(thread_id, message)
97
+ return nil unless thread_id
98
+
99
+ thread = "RobotLabThread".constantize.find_or_create_by_session_id(thread_id)
100
+ thread.update!(last_user_message: message, last_user_message_at: Time.current)
101
+ thread
102
+ end
103
+
104
+ # Build the robot, wiring Turbo callbacks when thread_id + turbo-rails are present.
105
+ def build_robot(klass, thread_id)
106
+ if thread_id && turbo_available?
107
+ stream_name = "robot_lab_thread_#{thread_id}"
108
+ on_content = TurboStreamCallbacks.build_content_callback(stream_name: stream_name)
109
+ on_tool_call = TurboStreamCallbacks.build_tool_call_callback(stream_name: stream_name)
110
+ klass.build(on_content: on_content, on_tool_call: on_tool_call)
111
+ else
112
+ klass.build
113
+ end
114
+ end
115
+
116
+ # Append a RobotLabResult record to the thread.
117
+ def persist_result(thread, result)
118
+ sequence = thread.results.maximum(:sequence_number).to_i + 1
119
+ exported = result.export
120
+
121
+ thread.results.create!(
122
+ robot_name: result.robot_name,
123
+ sequence_number: sequence,
124
+ output_data: exported[:output],
125
+ tool_calls_data: exported[:tool_calls],
126
+ stop_reason: result.stop_reason,
127
+ checksum: result.checksum
128
+ )
129
+ end
130
+
131
+ # Broadcast a "Complete" badge to the Turbo Stream.
132
+ def broadcast_completion(thread_id)
133
+ return unless turbo_available?
134
+
135
+ Turbo::StreamsChannel.broadcast_replace_to(
136
+ "robot_lab_thread_#{thread_id}",
137
+ target: "robot_status",
138
+ html: "<div id=\"robot_status\"><span class=\"complete\">Complete</span></div>"
139
+ )
140
+ end
141
+
142
+ # Broadcast an HTML-escaped error message to the Turbo Stream.
143
+ def broadcast_error(thread_id, error)
144
+ return unless turbo_available?
145
+
146
+ Turbo::StreamsChannel.broadcast_append_to(
147
+ "robot_lab_thread_#{thread_id}",
148
+ target: "robot_errors",
149
+ html: "<div class=\"error\">#{ERB::Util.html_escape(error.message)}</div>"
150
+ )
151
+ end
152
+
153
+ def turbo_available?
154
+ defined?(Turbo::StreamsChannel)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -33,9 +33,18 @@ module RobotLab
33
33
  Dir.glob("#{path}/**/*.rake").each { |f| load f }
34
34
  end
35
35
 
36
+ # TODO: Add fiber isolation warning once Solid Queue fiber mode lands in a
37
+ # mainline release. PR rails/solid_queue#728 (branch crmne/solid_queue
38
+ # async-worker-execution-mode) introduces the `fibers:` worker key but has
39
+ # not yet been merged or released. When a released version is detectable,
40
+ # add an initializer here that warns when:
41
+ # defined?(SolidQueue) &&
42
+ # app.config.active_support.isolation_level != :fiber
43
+
36
44
  generators do
37
45
  require "generators/robot_lab/install_generator"
38
46
  require "generators/robot_lab/robot_generator"
47
+ require "generators/robot_lab/job_generator"
39
48
  end
40
49
  end
41
50
  end
@@ -41,7 +41,7 @@ module RobotLab
41
41
  CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
42
 
43
43
  # Infrastructure fields
44
- INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size].freeze
44
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size max_concurrent_robots].freeze
45
45
 
46
46
  # All recognized fields
47
47
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.0.11"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/robot_lab.rb CHANGED
@@ -251,4 +251,8 @@ if defined?(Rails::Engine)
251
251
  require 'robot_lab/rails_integration/engine'
252
252
  require 'robot_lab/rails_integration/railtie'
253
253
  require 'robot_lab/rails_integration/turbo_stream_callbacks'
254
+ require 'robot_lab/rails_integration/job'
255
+
256
+ # Convenience alias so job subclasses can inherit from RobotLab::Job
257
+ RobotLab::Job = RobotLab::RailsIntegration::Job
254
258
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -155,14 +155,14 @@ dependencies:
155
155
  requirements:
156
156
  - - "~>"
157
157
  - !ruby/object:Gem::Version
158
- version: 0.3.0
158
+ version: 0.4.0
159
159
  type: :runtime
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
163
  - - "~>"
164
164
  - !ruby/object:Gem::Version
165
- version: 0.3.0
165
+ version: 0.4.0
166
166
  - !ruby/object:Gem::Dependency
167
167
  name: ractor_queue
168
168
  requirement: !ruby/object:Gem::Requirement
@@ -234,7 +234,6 @@ extra_rdoc_files: []
234
234
  files:
235
235
  - ".envrc"
236
236
  - ".github/workflows/deploy-github-pages.yml"
237
- - ".github/workflows/deploy-yard-docs.yml"
238
237
  - ".irbrc"
239
238
  - CHANGELOG.md
240
239
  - COMMITS.md
@@ -244,9 +243,11 @@ files:
244
243
  - docs/api/core/index.md
245
244
  - docs/api/core/memory.md
246
245
  - docs/api/core/network.md
246
+ - docs/api/core/result.md
247
247
  - docs/api/core/robot.md
248
248
  - docs/api/core/state.md
249
249
  - docs/api/core/tool.md
250
+ - docs/api/errors.md
250
251
  - docs/api/index.md
251
252
  - docs/api/mcp/client.md
252
253
  - docs/api/mcp/index.md
@@ -394,6 +395,7 @@ files:
394
395
  - examples/28_mcp_discovery.rb
395
396
  - examples/29_ractor_tools.rb
396
397
  - examples/30_ractor_network.rb
398
+ - examples/31_launch_assessment.rb
397
399
  - examples/README.md
398
400
  - examples/prompts/assistant.md
399
401
  - examples/prompts/audit_trail.md
@@ -444,12 +446,14 @@ files:
444
446
  - examples/prompts/template_with_skills_test.md
445
447
  - examples/prompts/triage.md
446
448
  - lib/generators/robot_lab/install_generator.rb
449
+ - lib/generators/robot_lab/job_generator.rb
447
450
  - lib/generators/robot_lab/robot_generator.rb
448
451
  - lib/generators/robot_lab/templates/initializer.rb.tt
449
452
  - lib/generators/robot_lab/templates/job.rb.tt
450
453
  - lib/generators/robot_lab/templates/migration.rb.tt
451
454
  - lib/generators/robot_lab/templates/result_model.rb.tt
452
455
  - lib/generators/robot_lab/templates/robot.rb.tt
456
+ - lib/generators/robot_lab/templates/robot_job.rb.tt
453
457
  - lib/generators/robot_lab/templates/robot_test.rb.tt
454
458
  - lib/generators/robot_lab/templates/routing_robot.rb.tt
455
459
  - lib/generators/robot_lab/templates/thread_model.rb.tt
@@ -482,6 +486,7 @@ files:
482
486
  - lib/robot_lab/ractor_network_scheduler.rb
483
487
  - lib/robot_lab/ractor_worker_pool.rb
484
488
  - lib/robot_lab/rails_integration/engine.rb
489
+ - lib/robot_lab/rails_integration/job.rb
485
490
  - lib/robot_lab/rails_integration/railtie.rb
486
491
  - lib/robot_lab/rails_integration/turbo_stream_callbacks.rb
487
492
  - lib/robot_lab/robot.rb
@@ -1,52 +0,0 @@
1
- name: Deploy YARD Documentation to GitHub Pages
2
- on:
3
- push:
4
- branches:
5
- - main
6
- - develop
7
- paths:
8
- - "lib/**"
9
- - "docs/assets/**"
10
- - ".yardopts"
11
- - "*.gemspec"
12
- - ".github/workflows/deploy-yard-docs.yml"
13
- workflow_dispatch:
14
-
15
- permissions:
16
- contents: write
17
- pages: write
18
- id-token: write
19
-
20
- jobs:
21
- deploy:
22
- runs-on: ubuntu-latest
23
- steps:
24
- - name: Checkout code
25
- uses: actions/checkout@v4
26
- with:
27
- fetch-depth: 0
28
-
29
- - name: Setup Ruby
30
- uses: ruby/setup-ruby@v1
31
- with:
32
- ruby-version: "3.3"
33
- bundler-cache: true
34
-
35
- - name: Install YARD
36
- run: gem install yard
37
-
38
- - name: Build YARD documentation
39
- run: yard doc
40
-
41
- - name: Configure Git
42
- run: |
43
- git config --local user.email "action@github.com"
44
- git config --local user.name "GitHub Action"
45
-
46
- - name: Deploy to GitHub Pages
47
- uses: peaceiris/actions-gh-pages@v4
48
- with:
49
- github_token: ${{ secrets.GITHUB_TOKEN }}
50
- publish_dir: ./doc
51
- destination_dir: yard
52
- keep_files: true