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,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