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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +110 -0
- data/Rakefile +8 -0
- data/docs/examples/rails-application.md +419 -0
- data/docs/guides/rails-integration.md +681 -0
- data/docs/index.md +75 -0
- data/examples/18_rails/.envrc +3 -0
- data/examples/18_rails/.gitignore +5 -0
- data/examples/18_rails/Gemfile +11 -0
- data/examples/18_rails/README.md +48 -0
- data/examples/18_rails/Rakefile +4 -0
- data/examples/18_rails/app/controllers/application_controller.rb +4 -0
- data/examples/18_rails/app/controllers/chat_controller.rb +46 -0
- data/examples/18_rails/app/jobs/application_job.rb +4 -0
- data/examples/18_rails/app/jobs/robot_run_job.rb +19 -0
- data/examples/18_rails/app/models/application_record.rb +5 -0
- data/examples/18_rails/app/models/robot_lab_result.rb +36 -0
- data/examples/18_rails/app/models/robot_lab_thread.rb +23 -0
- data/examples/18_rails/app/robots/chat_robot.rb +14 -0
- data/examples/18_rails/app/tools/time_tool.rb +9 -0
- data/examples/18_rails/app/views/chat/_user_message.html.erb +1 -0
- data/examples/18_rails/app/views/chat/index.html.erb +67 -0
- data/examples/18_rails/app/views/layouts/application.html.erb +49 -0
- data/examples/18_rails/bin/dev +7 -0
- data/examples/18_rails/bin/rails +6 -0
- data/examples/18_rails/bin/setup +15 -0
- data/examples/18_rails/config/application.rb +33 -0
- data/examples/18_rails/config/cable.yml +2 -0
- data/examples/18_rails/config/database.yml +5 -0
- data/examples/18_rails/config/environment.rb +4 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +3 -0
- data/examples/18_rails/config/routes.rb +6 -0
- data/examples/18_rails/config.ru +4 -0
- data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +32 -0
- data/lib/generators/robot_lab/install_generator.rb +63 -0
- data/lib/generators/robot_lab/job_generator.rb +28 -0
- data/lib/generators/robot_lab/robot_generator.rb +40 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +39 -0
- data/lib/generators/robot_lab/templates/job.rb.tt +21 -0
- data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/result_model.rb.tt +40 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +31 -0
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +15 -0
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +21 -0
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +42 -0
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +27 -0
- data/lib/robot_lab/rails/version.rb +7 -0
- data/lib/robot_lab/rails.rb +10 -0
- data/lib/robot_lab/rails_integration/engine.rb +23 -0
- data/lib/robot_lab/rails_integration/job.rb +109 -0
- data/lib/robot_lab/rails_integration/railtie.rb +36 -0
- data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +42 -0
- data/mkdocs.yml +117 -0
- metadata +158 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
# Rails Integration
|
|
2
|
+
|
|
3
|
+
RobotLab integrates seamlessly with Ruby on Rails applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Generate Files
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
rails generate robot_lab:install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This creates:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
config/initializers/robot_lab.rb # Logger setup
|
|
17
|
+
db/migrate/*_create_robot_lab_tables.rb # Database tables
|
|
18
|
+
app/models/robot_lab_thread.rb # Thread model
|
|
19
|
+
app/models/robot_lab_result.rb # Result model
|
|
20
|
+
app/jobs/robot_run_job.rb # Background job for robot execution
|
|
21
|
+
app/robots/ # Directory for robots
|
|
22
|
+
app/tools/ # Directory for tools
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
|
|
27
|
+
- `--skip-migration` — Skip database migration generation
|
|
28
|
+
- `--skip-job` — Skip background job generation
|
|
29
|
+
|
|
30
|
+
### Run Migrations
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
rails db:migrate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
RobotLab uses [MywayConfig](https://github.com/madbomber/myway_config) for configuration. There is no `RobotLab.configure` block. Instead, settings are loaded from YAML files and environment variables in the following priority order:
|
|
39
|
+
|
|
40
|
+
1. **Bundled defaults** (`lib/robot_lab/config/defaults.yml`)
|
|
41
|
+
2. **Environment-specific overrides** (development, test, production sections)
|
|
42
|
+
3. **XDG user config** (`~/.config/robot_lab/config.yml`)
|
|
43
|
+
4. **Project config** (`./config/robot_lab.yml`)
|
|
44
|
+
5. **Environment variables** (`ROBOT_LAB_*` prefix)
|
|
45
|
+
|
|
46
|
+
### Project Config File
|
|
47
|
+
|
|
48
|
+
```yaml title="config/robot_lab.yml"
|
|
49
|
+
defaults:
|
|
50
|
+
ruby_llm:
|
|
51
|
+
anthropic_api_key: <%= ENV['ANTHROPIC_API_KEY'] %>
|
|
52
|
+
openai_api_key: <%= ENV['OPENAI_API_KEY'] %>
|
|
53
|
+
model: claude-sonnet-4
|
|
54
|
+
request_timeout: 180
|
|
55
|
+
|
|
56
|
+
# Template path auto-detected as app/prompts in Rails
|
|
57
|
+
# template_path: app/prompts
|
|
58
|
+
|
|
59
|
+
development:
|
|
60
|
+
ruby_llm:
|
|
61
|
+
model: claude-haiku-3
|
|
62
|
+
log_level: :debug
|
|
63
|
+
|
|
64
|
+
test:
|
|
65
|
+
streaming_enabled: false
|
|
66
|
+
ruby_llm:
|
|
67
|
+
model: claude-3-haiku-20240307
|
|
68
|
+
request_timeout: 30
|
|
69
|
+
|
|
70
|
+
production:
|
|
71
|
+
ruby_llm:
|
|
72
|
+
request_timeout: 180
|
|
73
|
+
max_retries: 5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Environment Variables
|
|
77
|
+
|
|
78
|
+
Environment variables use the `ROBOT_LAB_` prefix with double underscores for nested keys:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
|
|
82
|
+
ROBOT_LAB_RUBY_LLM__MODEL=claude-sonnet-4
|
|
83
|
+
ROBOT_LAB_RUBY_LLM__REQUEST_TIMEOUT=180
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
RobotLab also falls back to standard provider environment variables (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) when the prefixed versions are not set.
|
|
87
|
+
|
|
88
|
+
### Initializer (Logger Only)
|
|
89
|
+
|
|
90
|
+
The only runtime-writable config attribute is the logger. The generated initializer sets it to the Rails logger:
|
|
91
|
+
|
|
92
|
+
```ruby title="config/initializers/robot_lab.rb"
|
|
93
|
+
# frozen_string_literal: true
|
|
94
|
+
|
|
95
|
+
# Set the RobotLab logger to use Rails.logger
|
|
96
|
+
RobotLab.config.logger = Rails.logger
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Accessing Configuration
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Read configuration values
|
|
103
|
+
RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
|
|
104
|
+
RobotLab.config.ruby_llm.anthropic_api_key #=> "sk-ant-..."
|
|
105
|
+
RobotLab.config.ruby_llm.request_timeout #=> 120
|
|
106
|
+
RobotLab.config.streaming_enabled #=> true
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Creating Robots
|
|
110
|
+
|
|
111
|
+
### Robot Generator
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
rails generate robot_lab:robot Support
|
|
115
|
+
rails generate robot_lab:robot Billing --description="Handles billing inquiries"
|
|
116
|
+
rails generate robot_lab:robot Router --routing
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Robot Class
|
|
120
|
+
|
|
121
|
+
Robots are plain Ruby classes with a `.build` factory method that calls `RobotLab.build` with keyword arguments:
|
|
122
|
+
|
|
123
|
+
```ruby title="app/robots/support_robot.rb"
|
|
124
|
+
# frozen_string_literal: true
|
|
125
|
+
|
|
126
|
+
class SupportRobot
|
|
127
|
+
def self.build(**options)
|
|
128
|
+
RobotLab.build(
|
|
129
|
+
name: "support",
|
|
130
|
+
description: "Handles customer support inquiries",
|
|
131
|
+
system_prompt: "You are a helpful support assistant.",
|
|
132
|
+
model: "claude-sonnet-4",
|
|
133
|
+
local_tools: [OrderLookup],
|
|
134
|
+
**options
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Routing Robot Class
|
|
141
|
+
|
|
142
|
+
A routing robot classifies requests and activates optional tasks in a Network. It subclasses `RobotLab::Robot` and overrides `call(result)`:
|
|
143
|
+
|
|
144
|
+
```ruby title="app/robots/classifier_robot.rb"
|
|
145
|
+
# frozen_string_literal: true
|
|
146
|
+
|
|
147
|
+
class ClassifierRobot < RobotLab::Robot
|
|
148
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
149
|
+
You are a routing robot that classifies user requests.
|
|
150
|
+
|
|
151
|
+
Analyze the user's request and respond with ONLY the category name.
|
|
152
|
+
Valid categories: billing, technical, general
|
|
153
|
+
PROMPT
|
|
154
|
+
|
|
155
|
+
def self.build(**options)
|
|
156
|
+
new(
|
|
157
|
+
name: "classifier",
|
|
158
|
+
description: "Classifies support requests",
|
|
159
|
+
system_prompt: SYSTEM_PROMPT,
|
|
160
|
+
**options
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def call(result)
|
|
165
|
+
context = extract_run_context(result)
|
|
166
|
+
message = context.delete(:message)
|
|
167
|
+
|
|
168
|
+
robot_result = run(message, **context)
|
|
169
|
+
|
|
170
|
+
new_result = result
|
|
171
|
+
.with_context(@name.to_sym, robot_result)
|
|
172
|
+
.continue(robot_result)
|
|
173
|
+
|
|
174
|
+
category = robot_result.last_text_content.to_s.strip.downcase
|
|
175
|
+
|
|
176
|
+
case category
|
|
177
|
+
when /billing/ then new_result.activate(:billing)
|
|
178
|
+
when /technical/ then new_result.activate(:technical)
|
|
179
|
+
else new_result.activate(:general)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Use the routing robot as the first task in a network:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
classifier = ClassifierRobot.build
|
|
189
|
+
billing = BillingRobot.build
|
|
190
|
+
technical = TechnicalRobot.build
|
|
191
|
+
|
|
192
|
+
network = RobotLab.create_network(name: "support") do
|
|
193
|
+
task :classifier, classifier, depends_on: :none
|
|
194
|
+
task :billing, billing, depends_on: :optional
|
|
195
|
+
task :technical, technical, depends_on: :optional
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
result = network.run(message: "I was charged twice")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Custom Tool
|
|
202
|
+
|
|
203
|
+
Tools subclass `RobotLab::Tool` (which extends `RubyLLM::Tool`):
|
|
204
|
+
|
|
205
|
+
```ruby title="app/tools/order_lookup.rb"
|
|
206
|
+
# frozen_string_literal: true
|
|
207
|
+
|
|
208
|
+
class OrderLookup < RobotLab::Tool
|
|
209
|
+
description "Look up an order by ID"
|
|
210
|
+
param :order_id, type: "string", desc: "The order ID to look up"
|
|
211
|
+
|
|
212
|
+
def execute(order_id:)
|
|
213
|
+
order = Order.find_by(id: order_id)
|
|
214
|
+
return "Order not found" unless order
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
id: order.id,
|
|
218
|
+
status: order.status,
|
|
219
|
+
total: order.total.to_s,
|
|
220
|
+
created_at: order.created_at.iso8601
|
|
221
|
+
}.to_json
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Using in Controllers
|
|
227
|
+
|
|
228
|
+
```ruby title="app/controllers/chat_controller.rb"
|
|
229
|
+
class ChatController < ApplicationController
|
|
230
|
+
def create
|
|
231
|
+
robot = SupportRobot.build
|
|
232
|
+
result = robot.run(params[:message])
|
|
233
|
+
|
|
234
|
+
render json: {
|
|
235
|
+
response: result.last_text_content,
|
|
236
|
+
robot_name: result.robot_name
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Using a Network in Controllers
|
|
243
|
+
|
|
244
|
+
Networks use `RobotLab.create_network` with a block DSL that defines tasks. Each task wraps a robot with dependency declarations:
|
|
245
|
+
|
|
246
|
+
```ruby title="app/controllers/chat_controller.rb"
|
|
247
|
+
class ChatController < ApplicationController
|
|
248
|
+
def create
|
|
249
|
+
support_robot = SupportRobot.build
|
|
250
|
+
billing_robot = BillingRobot.build
|
|
251
|
+
|
|
252
|
+
network = RobotLab.create_network(name: "customer_service") do
|
|
253
|
+
task :support, support_robot, depends_on: :none
|
|
254
|
+
task :billing, billing_robot, depends_on: :optional
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
result = network.run(message: params[:message], user_id: current_user.id)
|
|
258
|
+
|
|
259
|
+
# result is a SimpleFlow::Result
|
|
260
|
+
# result.value is a RobotResult from the last robot
|
|
261
|
+
render json: {
|
|
262
|
+
response: result.value.last_text_content,
|
|
263
|
+
robot_name: result.value.robot_name
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Prompt Templates
|
|
270
|
+
|
|
271
|
+
### Template Location
|
|
272
|
+
|
|
273
|
+
Templates are `.md` files with YAML front matter, stored in `app/prompts/` (auto-configured for Rails):
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
app/prompts/
|
|
277
|
+
├── support.md
|
|
278
|
+
├── billing.md
|
|
279
|
+
└── router.md
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Template Format
|
|
283
|
+
|
|
284
|
+
```markdown title="app/prompts/support.md"
|
|
285
|
+
---
|
|
286
|
+
description: Customer support assistant
|
|
287
|
+
parameters:
|
|
288
|
+
company_name: null
|
|
289
|
+
tone: friendly
|
|
290
|
+
model: claude-sonnet-4
|
|
291
|
+
temperature: 0.7
|
|
292
|
+
---
|
|
293
|
+
You are a support agent for <%= company_name %>.
|
|
294
|
+
Respond in a <%= tone %> manner.
|
|
295
|
+
|
|
296
|
+
Your responsibilities:
|
|
297
|
+
- Answer product questions
|
|
298
|
+
- Help with order issues
|
|
299
|
+
- Provide friendly assistance
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Template Usage
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
# Pass context to fill template parameters
|
|
306
|
+
robot = RobotLab.build(
|
|
307
|
+
name: "support",
|
|
308
|
+
template: :support,
|
|
309
|
+
context: { company_name: "Acme Corp" }
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Parameters with defaults (like `tone: friendly`) are optional.
|
|
313
|
+
# Parameters set to null are required and must be provided via context.
|
|
314
|
+
result = robot.run("I need help with my order")
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Action Cable Integration
|
|
318
|
+
|
|
319
|
+
### Channel
|
|
320
|
+
|
|
321
|
+
```ruby title="app/channels/chat_channel.rb"
|
|
322
|
+
class ChatChannel < ApplicationCable::Channel
|
|
323
|
+
def subscribed
|
|
324
|
+
stream_from "chat_#{params[:session_id]}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def receive(data)
|
|
328
|
+
message = data["message"]
|
|
329
|
+
session_id = data["session_id"]
|
|
330
|
+
|
|
331
|
+
robot = SupportRobot.build
|
|
332
|
+
result = robot.run(message)
|
|
333
|
+
|
|
334
|
+
ActionCable.server.broadcast(
|
|
335
|
+
"chat_#{session_id}",
|
|
336
|
+
{
|
|
337
|
+
event: "complete",
|
|
338
|
+
response: result.last_text_content,
|
|
339
|
+
robot_name: result.robot_name
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### JavaScript Client
|
|
347
|
+
|
|
348
|
+
```javascript
|
|
349
|
+
const channel = consumer.subscriptions.create(
|
|
350
|
+
{ channel: "ChatChannel", session_id: sessionId },
|
|
351
|
+
{
|
|
352
|
+
received(data) {
|
|
353
|
+
if (data.event === "complete") {
|
|
354
|
+
displayMessage(data.response);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
channel.send({ message: "Hello!", session_id: sessionId });
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Background Jobs
|
|
364
|
+
|
|
365
|
+
### RobotLab::Job Base Class
|
|
366
|
+
|
|
367
|
+
All RobotLab background jobs inherit from `RobotLab::Job` (`RobotLab::RailsIntegration::Job`), which handles the full robot-run lifecycle automatically:
|
|
368
|
+
|
|
369
|
+
1. Resolves the robot class (from the `robot_class` DSL or a `robot_class:` kwarg at enqueue time)
|
|
370
|
+
2. Finds or creates a `RobotLabThread` record and stamps the incoming message
|
|
371
|
+
3. Wires Turbo Stream callbacks when `turbo-rails` is available (graceful no-op otherwise)
|
|
372
|
+
4. Runs the robot and persists the `RobotResult` to `RobotLabResult`
|
|
373
|
+
5. Broadcasts a completion or error event via Turbo Streams
|
|
374
|
+
|
|
375
|
+
`retry_on StandardError` (3 attempts, 5 s wait) and `discard_on ActiveJob::DeserializationError` are configured by default.
|
|
376
|
+
|
|
377
|
+
### RobotRunJob (Generated)
|
|
378
|
+
|
|
379
|
+
The install generator creates a thin subclass you can enqueue with any robot class at runtime:
|
|
380
|
+
|
|
381
|
+
```ruby title="app/jobs/robot_run_job.rb"
|
|
382
|
+
class RobotRunJob < RobotLab::Job
|
|
383
|
+
queue_as :default
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
# Enqueue from a controller — pass robot_class: as a string
|
|
389
|
+
RobotRunJob.perform_later(
|
|
390
|
+
robot_class: "SupportRobot",
|
|
391
|
+
message: params[:message],
|
|
392
|
+
thread_id: session_id
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
render json: { status: "processing" }
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Dedicated Job (robot_class DSL)
|
|
399
|
+
|
|
400
|
+
Generate a job pre-bound to a specific robot class so callers never need to pass `robot_class:`:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
rails generate robot_lab:job Support # binds to SupportRobot, queue: default
|
|
404
|
+
rails generate robot_lab:job Support --queue ai # custom queue name
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
```ruby title="app/jobs/support_job.rb"
|
|
408
|
+
class SupportJob < RobotLab::Job
|
|
409
|
+
queue_as :default
|
|
410
|
+
robot_class SupportRobot
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# No robot_class: needed at enqueue time
|
|
416
|
+
SupportJob.perform_later(message: params[:message], thread_id: session_id)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The `robot_class` DSL is per-subclass and does not affect sibling job classes.
|
|
420
|
+
|
|
421
|
+
### Omitting thread_id (fire-and-forget)
|
|
422
|
+
|
|
423
|
+
When `thread_id` is omitted the job runs the robot and returns the result without any persistence or broadcasting:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
RobotRunJob.perform_later(robot_class: "ChatRobot", message: "ping")
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Turbo Stream Token Streaming
|
|
430
|
+
|
|
431
|
+
When `turbo-rails` is installed, `RobotLab::Job` automatically streams content tokens and tool call badges to the browser in real time.
|
|
432
|
+
|
|
433
|
+
#### View Setup
|
|
434
|
+
|
|
435
|
+
Subscribe to the thread's Turbo Stream channel in your view:
|
|
436
|
+
|
|
437
|
+
```erb
|
|
438
|
+
<%%= turbo_stream_from "robot_lab_thread_#{@thread_id}" %>
|
|
439
|
+
|
|
440
|
+
<div id="robot_response"></div>
|
|
441
|
+
<div id="robot_tools"></div>
|
|
442
|
+
<div id="robot_status">Processing...</div>
|
|
443
|
+
<div id="robot_errors"></div>
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
As the robot generates tokens, they are appended to `#robot_response`. Tool calls appear as badges in `#robot_tools`. On completion, `#robot_status` is replaced with "Complete".
|
|
447
|
+
|
|
448
|
+
#### TurboStreamCallbacks API
|
|
449
|
+
|
|
450
|
+
`RobotLab::RailsIntegration::TurboStreamCallbacks` is a stateless utility module for building callback Procs. Use it outside of `RobotRunJob` for custom streaming setups:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
# Check if Turbo Streams is available
|
|
454
|
+
RobotLab::RailsIntegration::TurboStreamCallbacks.available?
|
|
455
|
+
|
|
456
|
+
# Build a content streaming callback
|
|
457
|
+
on_content = RobotLab::RailsIntegration::TurboStreamCallbacks.build_content_callback(
|
|
458
|
+
stream_name: "robot_lab_thread_#{thread_id}",
|
|
459
|
+
target: "robot_response" # default
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Build a tool call badge callback
|
|
463
|
+
on_tool_call = RobotLab::RailsIntegration::TurboStreamCallbacks.build_tool_call_callback(
|
|
464
|
+
stream_name: "robot_lab_thread_#{thread_id}",
|
|
465
|
+
target: "robot_tools" # default
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Wire into a robot at build time
|
|
469
|
+
robot = SupportRobot.build(on_content: on_content, on_tool_call: on_tool_call)
|
|
470
|
+
robot.run(message)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The stream name convention is `"robot_lab_thread_#{thread_id}"`, matching the `RobotLabThread.session_id` pattern.
|
|
474
|
+
|
|
475
|
+
### Custom Background Job
|
|
476
|
+
|
|
477
|
+
For full control outside of the `RobotLab::Job` lifecycle (e.g. custom persistence or a different broadcasting strategy), inherit from `ApplicationJob` directly:
|
|
478
|
+
|
|
479
|
+
```ruby title="app/jobs/process_message_job.rb"
|
|
480
|
+
class ProcessMessageJob < ApplicationJob
|
|
481
|
+
queue_as :default
|
|
482
|
+
|
|
483
|
+
def perform(session_id:, message:, user_id:)
|
|
484
|
+
robot = SupportRobot.build
|
|
485
|
+
result = robot.run(message)
|
|
486
|
+
|
|
487
|
+
ActionCable.server.broadcast(
|
|
488
|
+
"chat_#{session_id}",
|
|
489
|
+
{
|
|
490
|
+
event: "complete",
|
|
491
|
+
response: result.last_text_content,
|
|
492
|
+
robot_name: result.robot_name
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## Testing
|
|
500
|
+
|
|
501
|
+
### Test Configuration
|
|
502
|
+
|
|
503
|
+
Use `config/robot_lab.yml` to configure the test environment with a faster, cheaper model:
|
|
504
|
+
|
|
505
|
+
```yaml title="config/robot_lab.yml"
|
|
506
|
+
test:
|
|
507
|
+
max_iterations: 3
|
|
508
|
+
streaming_enabled: false
|
|
509
|
+
ruby_llm:
|
|
510
|
+
model: claude-3-haiku-20240307
|
|
511
|
+
request_timeout: 30
|
|
512
|
+
max_retries: 1
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Robot Tests
|
|
516
|
+
|
|
517
|
+
```ruby title="test/robots/support_robot_test.rb"
|
|
518
|
+
require "test_helper"
|
|
519
|
+
|
|
520
|
+
class SupportRobotTest < ActiveSupport::TestCase
|
|
521
|
+
test "builds valid robot" do
|
|
522
|
+
robot = SupportRobot.build
|
|
523
|
+
assert_equal "support", robot.name
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
test "robot has correct model" do
|
|
527
|
+
robot = SupportRobot.build
|
|
528
|
+
assert_equal "claude-sonnet-4", robot.model
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
test "robot has local tools" do
|
|
532
|
+
robot = SupportRobot.build
|
|
533
|
+
tool_names = robot.local_tools.map(&:name)
|
|
534
|
+
assert_includes tool_names, "order_lookup"
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Integration Tests
|
|
540
|
+
|
|
541
|
+
```ruby title="test/integration/chat_test.rb"
|
|
542
|
+
require "test_helper"
|
|
543
|
+
|
|
544
|
+
class ChatTest < ActionDispatch::IntegrationTest
|
|
545
|
+
test "processes chat message" do
|
|
546
|
+
VCR.use_cassette("chat_response") do
|
|
547
|
+
post chat_path, params: { message: "Hello" }
|
|
548
|
+
assert_response :success
|
|
549
|
+
|
|
550
|
+
json = JSON.parse(response.body)
|
|
551
|
+
assert json["response"].present?
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## Models
|
|
558
|
+
|
|
559
|
+
### Thread Model
|
|
560
|
+
|
|
561
|
+
```ruby title="app/models/robot_lab_thread.rb"
|
|
562
|
+
class RobotLabThread < ApplicationRecord
|
|
563
|
+
has_many :results,
|
|
564
|
+
class_name: "RobotLabResult",
|
|
565
|
+
foreign_key: :session_id,
|
|
566
|
+
primary_key: :session_id,
|
|
567
|
+
dependent: :destroy
|
|
568
|
+
|
|
569
|
+
validates :session_id, presence: true, uniqueness: true
|
|
570
|
+
|
|
571
|
+
def self.find_or_create_by_session_id(id)
|
|
572
|
+
find_or_create_by(session_id: id)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def last_result
|
|
576
|
+
results.order(sequence_number: :desc).first
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Result Model
|
|
582
|
+
|
|
583
|
+
```ruby title="app/models/robot_lab_result.rb"
|
|
584
|
+
class RobotLabResult < ApplicationRecord
|
|
585
|
+
belongs_to :thread,
|
|
586
|
+
class_name: "RobotLabThread",
|
|
587
|
+
foreign_key: :session_id,
|
|
588
|
+
primary_key: :session_id
|
|
589
|
+
|
|
590
|
+
validates :session_id, presence: true
|
|
591
|
+
validates :robot_name, presence: true
|
|
592
|
+
|
|
593
|
+
default_scope { order(sequence_number: :asc) }
|
|
594
|
+
|
|
595
|
+
def to_robot_result
|
|
596
|
+
RobotLab::RobotResult.new(
|
|
597
|
+
robot_name: robot_name,
|
|
598
|
+
output: (output_data || []).map { |d| RobotLab::Message.from_hash(d.symbolize_keys) },
|
|
599
|
+
tool_calls: (tool_calls_data || []).map { |d| RobotLab::Message.from_hash(d.symbolize_keys) },
|
|
600
|
+
stop_reason: stop_reason
|
|
601
|
+
)
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
## Best Practices
|
|
607
|
+
|
|
608
|
+
### 1. Use Service Objects
|
|
609
|
+
|
|
610
|
+
```ruby title="app/services/chat_service.rb"
|
|
611
|
+
class ChatService
|
|
612
|
+
def initialize(user:)
|
|
613
|
+
@user = user
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def process(message)
|
|
617
|
+
robot = SupportRobot.build
|
|
618
|
+
result = robot.run(message)
|
|
619
|
+
|
|
620
|
+
{
|
|
621
|
+
response: result.last_text_content,
|
|
622
|
+
robot_name: result.robot_name
|
|
623
|
+
}
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def process_with_network(message)
|
|
627
|
+
support_robot = SupportRobot.build
|
|
628
|
+
billing_robot = BillingRobot.build
|
|
629
|
+
|
|
630
|
+
network = RobotLab.create_network(name: "customer_service") do
|
|
631
|
+
task :support, support_robot, depends_on: :none
|
|
632
|
+
task :billing, billing_robot, depends_on: :optional
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
result = network.run(message: message, user_id: @user.id)
|
|
636
|
+
|
|
637
|
+
{
|
|
638
|
+
response: result.value.last_text_content,
|
|
639
|
+
robot_name: result.value.robot_name
|
|
640
|
+
}
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### 2. Handle Errors
|
|
646
|
+
|
|
647
|
+
```ruby
|
|
648
|
+
def create
|
|
649
|
+
result = ChatService.new(user: current_user).process(params[:message])
|
|
650
|
+
render json: result
|
|
651
|
+
rescue RobotLab::Error => e
|
|
652
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
653
|
+
rescue StandardError => e
|
|
654
|
+
Rails.logger.error("Chat error: #{e.message}")
|
|
655
|
+
render json: { error: "An error occurred" }, status: :internal_server_error
|
|
656
|
+
end
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### 3. Rate Limiting
|
|
660
|
+
|
|
661
|
+
```ruby
|
|
662
|
+
class ChatController < ApplicationController
|
|
663
|
+
before_action :check_rate_limit
|
|
664
|
+
|
|
665
|
+
private
|
|
666
|
+
|
|
667
|
+
def check_rate_limit
|
|
668
|
+
key = "chat_rate:#{current_user.id}"
|
|
669
|
+
count = Rails.cache.increment(key, 1, expires_in: 1.minute)
|
|
670
|
+
|
|
671
|
+
if count > 10
|
|
672
|
+
render json: { error: "Rate limit exceeded" }, status: :too_many_requests
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
## Next Steps
|
|
679
|
+
|
|
680
|
+
- [Building Robots](building-robots.md) - Robot patterns
|
|
681
|
+
- [Creating Networks](creating-networks.md) - Network configuration
|