robot_lab 0.0.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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.github/workflows/deploy-yard-docs.yml +52 -0
- data/CHANGELOG.md +55 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +332 -0
- data/Rakefile +67 -0
- data/docs/api/adapters/anthropic.md +121 -0
- data/docs/api/adapters/gemini.md +133 -0
- data/docs/api/adapters/index.md +104 -0
- data/docs/api/adapters/openai.md +134 -0
- data/docs/api/core/index.md +113 -0
- data/docs/api/core/memory.md +314 -0
- data/docs/api/core/network.md +291 -0
- data/docs/api/core/robot.md +273 -0
- data/docs/api/core/state.md +273 -0
- data/docs/api/core/tool.md +353 -0
- data/docs/api/history/active-record-adapter.md +195 -0
- data/docs/api/history/config.md +191 -0
- data/docs/api/history/index.md +132 -0
- data/docs/api/history/thread-manager.md +144 -0
- data/docs/api/index.md +82 -0
- data/docs/api/mcp/client.md +221 -0
- data/docs/api/mcp/index.md +111 -0
- data/docs/api/mcp/server.md +225 -0
- data/docs/api/mcp/transports.md +264 -0
- data/docs/api/messages/index.md +67 -0
- data/docs/api/messages/text-message.md +102 -0
- data/docs/api/messages/tool-call-message.md +144 -0
- data/docs/api/messages/tool-result-message.md +154 -0
- data/docs/api/messages/user-message.md +171 -0
- data/docs/api/streaming/context.md +174 -0
- data/docs/api/streaming/events.md +237 -0
- data/docs/api/streaming/index.md +108 -0
- data/docs/architecture/core-concepts.md +243 -0
- data/docs/architecture/index.md +138 -0
- data/docs/architecture/message-flow.md +320 -0
- data/docs/architecture/network-orchestration.md +216 -0
- data/docs/architecture/robot-execution.md +243 -0
- data/docs/architecture/state-management.md +323 -0
- data/docs/assets/css/custom.css +56 -0
- data/docs/assets/images/robot_lab.jpg +0 -0
- data/docs/concepts.md +216 -0
- data/docs/examples/basic-chat.md +193 -0
- data/docs/examples/index.md +129 -0
- data/docs/examples/mcp-server.md +290 -0
- data/docs/examples/multi-robot-network.md +312 -0
- data/docs/examples/rails-application.md +420 -0
- data/docs/examples/tool-usage.md +310 -0
- data/docs/getting-started/configuration.md +230 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +179 -0
- data/docs/getting-started/quick-start.md +203 -0
- data/docs/guides/building-robots.md +376 -0
- data/docs/guides/creating-networks.md +366 -0
- data/docs/guides/history.md +359 -0
- data/docs/guides/index.md +68 -0
- data/docs/guides/mcp-integration.md +356 -0
- data/docs/guides/memory.md +309 -0
- data/docs/guides/rails-integration.md +432 -0
- data/docs/guides/streaming.md +314 -0
- data/docs/guides/using-tools.md +394 -0
- data/docs/index.md +160 -0
- data/examples/01_simple_robot.rb +38 -0
- data/examples/02_tools.rb +106 -0
- data/examples/03_network.rb +103 -0
- data/examples/04_mcp.rb +219 -0
- data/examples/05_streaming.rb +124 -0
- data/examples/06_prompt_templates.rb +324 -0
- data/examples/07_network_memory.rb +329 -0
- data/examples/prompts/assistant/system.txt.erb +2 -0
- data/examples/prompts/assistant/user.txt.erb +1 -0
- data/examples/prompts/billing/system.txt.erb +7 -0
- data/examples/prompts/billing/user.txt.erb +1 -0
- data/examples/prompts/classifier/system.txt.erb +4 -0
- data/examples/prompts/classifier/user.txt.erb +1 -0
- data/examples/prompts/entity_extractor/system.txt.erb +11 -0
- data/examples/prompts/entity_extractor/user.txt.erb +3 -0
- data/examples/prompts/escalation/system.txt.erb +35 -0
- data/examples/prompts/escalation/user.txt.erb +34 -0
- data/examples/prompts/general/system.txt.erb +4 -0
- data/examples/prompts/general/user.txt.erb +1 -0
- data/examples/prompts/github_assistant/system.txt.erb +6 -0
- data/examples/prompts/github_assistant/user.txt.erb +1 -0
- data/examples/prompts/helper/system.txt.erb +1 -0
- data/examples/prompts/helper/user.txt.erb +1 -0
- data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
- data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
- data/examples/prompts/order_support/system.txt.erb +27 -0
- data/examples/prompts/order_support/user.txt.erb +22 -0
- data/examples/prompts/product_support/system.txt.erb +30 -0
- data/examples/prompts/product_support/user.txt.erb +32 -0
- data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
- data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
- data/examples/prompts/synthesizer/system.txt.erb +14 -0
- data/examples/prompts/synthesizer/user.txt.erb +15 -0
- data/examples/prompts/technical/system.txt.erb +7 -0
- data/examples/prompts/technical/user.txt.erb +1 -0
- data/examples/prompts/triage/system.txt.erb +16 -0
- data/examples/prompts/triage/user.txt.erb +17 -0
- data/lib/generators/robot_lab/install_generator.rb +78 -0
- data/lib/generators/robot_lab/robot_generator.rb +55 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
- data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
- data/lib/robot_lab/adapters/anthropic.rb +163 -0
- data/lib/robot_lab/adapters/base.rb +85 -0
- data/lib/robot_lab/adapters/gemini.rb +193 -0
- data/lib/robot_lab/adapters/openai.rb +159 -0
- data/lib/robot_lab/adapters/registry.rb +81 -0
- data/lib/robot_lab/configuration.rb +143 -0
- data/lib/robot_lab/error.rb +32 -0
- data/lib/robot_lab/errors.rb +70 -0
- data/lib/robot_lab/history/active_record_adapter.rb +146 -0
- data/lib/robot_lab/history/config.rb +115 -0
- data/lib/robot_lab/history/thread_manager.rb +93 -0
- data/lib/robot_lab/mcp/client.rb +210 -0
- data/lib/robot_lab/mcp/server.rb +84 -0
- data/lib/robot_lab/mcp/transports/base.rb +56 -0
- data/lib/robot_lab/mcp/transports/sse.rb +117 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
- data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
- data/lib/robot_lab/memory.rb +882 -0
- data/lib/robot_lab/memory_change.rb +123 -0
- data/lib/robot_lab/message.rb +357 -0
- data/lib/robot_lab/network.rb +350 -0
- data/lib/robot_lab/rails/engine.rb +29 -0
- data/lib/robot_lab/rails/railtie.rb +42 -0
- data/lib/robot_lab/robot.rb +560 -0
- data/lib/robot_lab/robot_result.rb +205 -0
- data/lib/robot_lab/robotic_model.rb +324 -0
- data/lib/robot_lab/state_proxy.rb +188 -0
- data/lib/robot_lab/streaming/context.rb +144 -0
- data/lib/robot_lab/streaming/events.rb +95 -0
- data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
- data/lib/robot_lab/task.rb +117 -0
- data/lib/robot_lab/tool.rb +223 -0
- data/lib/robot_lab/tool_config.rb +112 -0
- data/lib/robot_lab/tool_manifest.rb +234 -0
- data/lib/robot_lab/user_message.rb +118 -0
- data/lib/robot_lab/version.rb +5 -0
- data/lib/robot_lab/waiter.rb +73 -0
- data/lib/robot_lab.rb +195 -0
- data/mkdocs.yml +214 -0
- data/sig/robot_lab.rbs +4 -0
- metadata +442 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Multi-Robot Network
|
|
2
|
+
|
|
3
|
+
Customer service system with intelligent routing using SimpleFlow pipelines.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This example demonstrates a multi-robot network where a classifier routes customer inquiries to specialized support robots using optional task activation.
|
|
8
|
+
|
|
9
|
+
## Complete Example
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
#!/usr/bin/env ruby
|
|
13
|
+
# examples/customer_service.rb
|
|
14
|
+
|
|
15
|
+
require "bundler/setup"
|
|
16
|
+
require "robot_lab"
|
|
17
|
+
|
|
18
|
+
RobotLab.configure do |config|
|
|
19
|
+
config.default_model = "claude-sonnet-4"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Custom classifier that routes to specialists
|
|
23
|
+
class ClassifierRobot < RobotLab::Robot
|
|
24
|
+
def call(result)
|
|
25
|
+
robot_result = run(**extract_run_context(result))
|
|
26
|
+
|
|
27
|
+
new_result = result
|
|
28
|
+
.with_context(@name.to_sym, robot_result)
|
|
29
|
+
.continue(robot_result)
|
|
30
|
+
|
|
31
|
+
# Route based on classification
|
|
32
|
+
category = robot_result.last_text_content.to_s.strip.downcase
|
|
33
|
+
|
|
34
|
+
case category
|
|
35
|
+
when /billing/ then new_result.activate(:billing_agent)
|
|
36
|
+
when /technical/ then new_result.activate(:tech_agent)
|
|
37
|
+
when /account/ then new_result.activate(:account_agent)
|
|
38
|
+
else new_result.activate(:general_agent)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Classifier robot
|
|
44
|
+
classifier = ClassifierRobot.new(
|
|
45
|
+
name: "classifier",
|
|
46
|
+
description: "Classifies customer inquiries",
|
|
47
|
+
system_prompt: <<~PROMPT
|
|
48
|
+
You are a customer inquiry classifier. Analyze the customer's message
|
|
49
|
+
and respond with exactly ONE of these categories:
|
|
50
|
+
|
|
51
|
+
- BILLING (payment issues, invoices, refunds, subscriptions)
|
|
52
|
+
- TECHNICAL (bugs, errors, how-to questions, feature requests)
|
|
53
|
+
- ACCOUNT (login issues, profile changes, security concerns)
|
|
54
|
+
- GENERAL (everything else)
|
|
55
|
+
|
|
56
|
+
Respond with ONLY the category name, nothing else.
|
|
57
|
+
PROMPT
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Billing specialist
|
|
61
|
+
billing_agent = RobotLab.build(
|
|
62
|
+
name: "billing_agent",
|
|
63
|
+
description: "Handles billing inquiries",
|
|
64
|
+
system_prompt: <<~PROMPT
|
|
65
|
+
You are a billing support specialist. You help customers with:
|
|
66
|
+
- Payment issues and refunds
|
|
67
|
+
- Invoice questions
|
|
68
|
+
- Subscription management
|
|
69
|
+
- Pricing inquiries
|
|
70
|
+
|
|
71
|
+
Be helpful, empathetic, and provide clear next steps.
|
|
72
|
+
PROMPT
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Technical support
|
|
76
|
+
tech_agent = RobotLab.build(
|
|
77
|
+
name: "tech_agent",
|
|
78
|
+
description: "Handles technical issues",
|
|
79
|
+
system_prompt: <<~PROMPT
|
|
80
|
+
You are a technical support specialist. You help customers with:
|
|
81
|
+
- Bug reports and troubleshooting
|
|
82
|
+
- Feature explanations
|
|
83
|
+
- Integration questions
|
|
84
|
+
- Best practices
|
|
85
|
+
|
|
86
|
+
Ask clarifying questions when needed. Provide step-by-step solutions.
|
|
87
|
+
PROMPT
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Account specialist
|
|
91
|
+
account_agent = RobotLab.build(
|
|
92
|
+
name: "account_agent",
|
|
93
|
+
description: "Handles account issues",
|
|
94
|
+
system_prompt: <<~PROMPT
|
|
95
|
+
You are an account support specialist. You help customers with:
|
|
96
|
+
- Login and authentication issues
|
|
97
|
+
- Profile and settings changes
|
|
98
|
+
- Security concerns
|
|
99
|
+
- Account recovery
|
|
100
|
+
|
|
101
|
+
Prioritize security while being helpful.
|
|
102
|
+
PROMPT
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# General support
|
|
106
|
+
general_agent = RobotLab.build(
|
|
107
|
+
name: "general_agent",
|
|
108
|
+
description: "Handles general inquiries",
|
|
109
|
+
system_prompt: <<~PROMPT
|
|
110
|
+
You are a general support agent. You help customers with:
|
|
111
|
+
- Product information
|
|
112
|
+
- General questions
|
|
113
|
+
- Feedback collection
|
|
114
|
+
- Routing to appropriate departments
|
|
115
|
+
|
|
116
|
+
Be friendly and informative.
|
|
117
|
+
PROMPT
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Create the network with optional task routing
|
|
121
|
+
network = RobotLab.create_network(name: "customer_service") do
|
|
122
|
+
task :classifier, classifier, depends_on: :none
|
|
123
|
+
task :billing_agent, billing_agent, depends_on: :optional
|
|
124
|
+
task :tech_agent, tech_agent, depends_on: :optional
|
|
125
|
+
task :account_agent, account_agent, depends_on: :optional
|
|
126
|
+
task :general_agent, general_agent, depends_on: :optional
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Run the support system
|
|
130
|
+
puts "Customer Service System"
|
|
131
|
+
puts "=" * 50
|
|
132
|
+
puts
|
|
133
|
+
|
|
134
|
+
test_inquiries = [
|
|
135
|
+
"I was charged twice for my subscription last month",
|
|
136
|
+
"How do I reset my password?",
|
|
137
|
+
"The app crashes when I try to upload photos",
|
|
138
|
+
"What features are included in the pro plan?"
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
test_inquiries.each do |inquiry|
|
|
142
|
+
puts "Customer: #{inquiry}"
|
|
143
|
+
puts "-" * 50
|
|
144
|
+
|
|
145
|
+
result = network.run(message: inquiry)
|
|
146
|
+
|
|
147
|
+
# Show classification
|
|
148
|
+
if result.context[:classifier]
|
|
149
|
+
puts "Classification: #{result.context[:classifier].last_text_content}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Show specialist response
|
|
153
|
+
if result.value.is_a?(RobotLab::RobotResult)
|
|
154
|
+
puts "Handled by: #{result.value.robot_name}"
|
|
155
|
+
puts "Response: #{result.value.last_text_content[0..200]}..."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
puts
|
|
159
|
+
puts "=" * 50
|
|
160
|
+
puts
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## With Context Passing
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Enhanced version with additional context
|
|
168
|
+
|
|
169
|
+
class ContextAwareClassifier < RobotLab::Robot
|
|
170
|
+
def call(result)
|
|
171
|
+
robot_result = run(**extract_run_context(result))
|
|
172
|
+
|
|
173
|
+
# Store classification in context for specialist
|
|
174
|
+
new_result = result
|
|
175
|
+
.with_context(@name.to_sym, robot_result)
|
|
176
|
+
.with_context(:classification, robot_result.last_text_content.strip)
|
|
177
|
+
.with_context(:original_message, result.context[:run_params][:message])
|
|
178
|
+
.continue(robot_result)
|
|
179
|
+
|
|
180
|
+
category = robot_result.last_text_content.to_s.downcase
|
|
181
|
+
case category
|
|
182
|
+
when /billing/ then new_result.activate(:billing_agent)
|
|
183
|
+
when /technical/ then new_result.activate(:tech_agent)
|
|
184
|
+
else new_result.activate(:general_agent)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Specialist can access shared context
|
|
190
|
+
class BillingAgent < RobotLab::Robot
|
|
191
|
+
def call(result)
|
|
192
|
+
# Access context from classifier
|
|
193
|
+
classification = result.context[:classification]
|
|
194
|
+
original_message = result.context[:original_message]
|
|
195
|
+
|
|
196
|
+
robot_result = run(
|
|
197
|
+
**extract_run_context(result),
|
|
198
|
+
classification: classification,
|
|
199
|
+
customer_message: original_message
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
result.with_context(@name.to_sym, robot_result).continue(robot_result)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Per-Task Configuration
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# Tasks with individual context and tools
|
|
211
|
+
network = RobotLab.create_network(name: "support") do
|
|
212
|
+
task :classifier, classifier, depends_on: :none
|
|
213
|
+
task :billing_agent, billing_agent,
|
|
214
|
+
context: { department: "billing", escalation_level: 2 },
|
|
215
|
+
tools: [RefundTool, InvoiceTool],
|
|
216
|
+
depends_on: :optional
|
|
217
|
+
task :tech_agent, tech_agent,
|
|
218
|
+
context: { department: "technical" },
|
|
219
|
+
mcp: [FilesystemServer],
|
|
220
|
+
depends_on: :optional
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Pipeline Pattern
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
# Sequential processing pipeline
|
|
228
|
+
network = RobotLab.create_network(name: "document_processor") do
|
|
229
|
+
task :extract, extractor, depends_on: :none
|
|
230
|
+
task :analyze, analyzer, depends_on: [:extract]
|
|
231
|
+
task :format, formatter, depends_on: [:analyze]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
result = network.run(message: "Process this document")
|
|
235
|
+
puts result.value.last_text_content
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Parallel Analysis Pattern
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# Fan-out / fan-in pattern
|
|
242
|
+
network = RobotLab.create_network(name: "multi_analysis", concurrency: :threads) do
|
|
243
|
+
task :prepare, preparer, depends_on: :none
|
|
244
|
+
|
|
245
|
+
# These run in parallel
|
|
246
|
+
task :sentiment, sentiment_analyzer, depends_on: [:prepare]
|
|
247
|
+
task :entities, entity_extractor, depends_on: [:prepare]
|
|
248
|
+
task :keywords, keyword_extractor, depends_on: [:prepare]
|
|
249
|
+
|
|
250
|
+
# Waits for all three
|
|
251
|
+
task :summarize, summarizer, depends_on: [:sentiment, :entities, :keywords]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
result = network.run(message: "Analyze this text")
|
|
255
|
+
|
|
256
|
+
# Access parallel results
|
|
257
|
+
puts "Sentiment: #{result.context[:sentiment].last_text_content}"
|
|
258
|
+
puts "Entities: #{result.context[:entities].last_text_content}"
|
|
259
|
+
puts "Keywords: #{result.context[:keywords].last_text_content}"
|
|
260
|
+
puts "Summary: #{result.value.last_text_content}"
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Conditional Halting
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class ValidatorRobot < RobotLab::Robot
|
|
267
|
+
def call(result)
|
|
268
|
+
robot_result = run(**extract_run_context(result))
|
|
269
|
+
|
|
270
|
+
if robot_result.last_text_content.include?("INVALID")
|
|
271
|
+
# Halt the pipeline early
|
|
272
|
+
result.halt(robot_result)
|
|
273
|
+
else
|
|
274
|
+
result.with_context(@name.to_sym, robot_result).continue(robot_result)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
network = RobotLab.create_network(name: "validated_pipeline") do
|
|
280
|
+
task :validate, validator, depends_on: :none
|
|
281
|
+
task :process, processor, depends_on: [:validate] # Only runs if not halted
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
result = network.run(message: "Process this")
|
|
285
|
+
if result.halted?
|
|
286
|
+
puts "Validation failed: #{result.value.last_text_content}"
|
|
287
|
+
else
|
|
288
|
+
puts "Processing complete: #{result.value.last_text_content}"
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Running
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
export ANTHROPIC_API_KEY="your-key"
|
|
296
|
+
ruby examples/customer_service.rb
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Key Concepts
|
|
300
|
+
|
|
301
|
+
1. **SimpleFlow Pipeline**: DAG-based execution with dependency management
|
|
302
|
+
2. **Optional Tasks**: Activated dynamically based on classification
|
|
303
|
+
3. **Robot#call**: Custom routing logic in classifier robots
|
|
304
|
+
4. **Context Flow**: Data passed through `result.context`
|
|
305
|
+
5. **Parallel Execution**: Tasks with same dependencies run concurrently
|
|
306
|
+
6. **Per-Task Configuration**: Each task can have its own context, tools, and MCP servers
|
|
307
|
+
|
|
308
|
+
## See Also
|
|
309
|
+
|
|
310
|
+
- [Creating Networks Guide](../guides/creating-networks.md)
|
|
311
|
+
- [Network Orchestration](../architecture/network-orchestration.md)
|
|
312
|
+
- [API Reference: Network](../api/core/network.md)
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Rails Application
|
|
2
|
+
|
|
3
|
+
Full Rails integration with Action Cable and background jobs.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This example demonstrates integrating RobotLab into a Rails application with real-time streaming via Action Cable, background job processing, and persistent conversation history.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
### 1. Add to Gemfile
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem "robot_lab"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 2. Run Generator
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
rails generate robot_lab:install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This creates:
|
|
25
|
+
|
|
26
|
+
- `config/initializers/robot_lab.rb`
|
|
27
|
+
- `app/robots/` directory
|
|
28
|
+
- Database migrations for history
|
|
29
|
+
|
|
30
|
+
### 3. Run Migrations
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
rails db:migrate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# config/initializers/robot_lab.rb
|
|
40
|
+
|
|
41
|
+
RobotLab.configure do |config|
|
|
42
|
+
config.default_model = ENV.fetch("LLM_MODEL", "claude-sonnet-4")
|
|
43
|
+
|
|
44
|
+
# Enable logging in development
|
|
45
|
+
config.logger = Rails.logger if Rails.env.development?
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Models
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# app/models/conversation_thread.rb
|
|
53
|
+
class ConversationThread < ApplicationRecord
|
|
54
|
+
belongs_to :user
|
|
55
|
+
has_many :messages, class_name: "ConversationMessage", dependent: :destroy
|
|
56
|
+
|
|
57
|
+
validates :external_id, presence: true, uniqueness: true
|
|
58
|
+
|
|
59
|
+
def self.find_or_create_for(user:, external_id: nil)
|
|
60
|
+
external_id ||= SecureRandom.uuid
|
|
61
|
+
find_or_create_by!(user: user, external_id: external_id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# app/models/conversation_message.rb
|
|
66
|
+
class ConversationMessage < ApplicationRecord
|
|
67
|
+
belongs_to :thread, class_name: "ConversationThread"
|
|
68
|
+
|
|
69
|
+
validates :role, presence: true
|
|
70
|
+
validates :content, presence: true
|
|
71
|
+
|
|
72
|
+
scope :ordered, -> { order(:position) }
|
|
73
|
+
|
|
74
|
+
def to_robot_result
|
|
75
|
+
RobotLab::RobotResult.from_hash(
|
|
76
|
+
robot_name: robot_name,
|
|
77
|
+
input: input,
|
|
78
|
+
output: output
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Robot Definitions
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# app/robots/support_robot.rb
|
|
88
|
+
class SupportRobot
|
|
89
|
+
def self.build
|
|
90
|
+
RobotLab.build do
|
|
91
|
+
name "support"
|
|
92
|
+
description "Customer support assistant"
|
|
93
|
+
|
|
94
|
+
template <<~PROMPT
|
|
95
|
+
You are a helpful customer support assistant for our company.
|
|
96
|
+
Be friendly, professional, and thorough in your responses.
|
|
97
|
+
If you need to look up information, use the available tools.
|
|
98
|
+
PROMPT
|
|
99
|
+
|
|
100
|
+
tool :get_user_info do
|
|
101
|
+
description "Get information about the current user"
|
|
102
|
+
|
|
103
|
+
handler do |state:, **_|
|
|
104
|
+
user_id = state.data[:user_id]
|
|
105
|
+
user = User.find(user_id)
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
name: user.name,
|
|
109
|
+
email: user.email,
|
|
110
|
+
plan: user.subscription&.plan || "free",
|
|
111
|
+
member_since: user.created_at.to_date.to_s
|
|
112
|
+
}
|
|
113
|
+
rescue ActiveRecord::RecordNotFound
|
|
114
|
+
{ error: "User not found" }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
tool :get_orders do
|
|
119
|
+
description "Get user's recent orders"
|
|
120
|
+
parameter :limit, type: :integer, default: 5
|
|
121
|
+
|
|
122
|
+
handler do |limit:, state:, **_|
|
|
123
|
+
user_id = state.data[:user_id]
|
|
124
|
+
orders = Order.where(user_id: user_id)
|
|
125
|
+
.order(created_at: :desc)
|
|
126
|
+
.limit(limit)
|
|
127
|
+
|
|
128
|
+
orders.map do |order|
|
|
129
|
+
{
|
|
130
|
+
id: order.external_id,
|
|
131
|
+
status: order.status,
|
|
132
|
+
total: order.total.to_f,
|
|
133
|
+
created_at: order.created_at.iso8601
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
tool :create_ticket do
|
|
140
|
+
description "Create a support ticket"
|
|
141
|
+
parameter :subject, type: :string, required: true
|
|
142
|
+
parameter :description, type: :string, required: true
|
|
143
|
+
parameter :priority, type: :string, enum: %w[low medium high], default: "medium"
|
|
144
|
+
|
|
145
|
+
handler do |subject:, description:, priority:, state:, **_|
|
|
146
|
+
ticket = SupportTicket.create!(
|
|
147
|
+
user_id: state.data[:user_id],
|
|
148
|
+
subject: subject,
|
|
149
|
+
description: description,
|
|
150
|
+
priority: priority
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
success: true,
|
|
155
|
+
ticket_id: ticket.external_id,
|
|
156
|
+
message: "Ticket created successfully"
|
|
157
|
+
}
|
|
158
|
+
rescue => e
|
|
159
|
+
{ success: false, error: e.message }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Network Configuration
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# app/robots/support_network.rb
|
|
171
|
+
class SupportNetwork
|
|
172
|
+
def self.build
|
|
173
|
+
RobotLab.create_network do
|
|
174
|
+
name "support_network"
|
|
175
|
+
default_model "claude-sonnet-4"
|
|
176
|
+
|
|
177
|
+
history RobotLab::History::ActiveRecordAdapter.new(
|
|
178
|
+
thread_model: ConversationThread,
|
|
179
|
+
result_model: ConversationMessage
|
|
180
|
+
).to_config
|
|
181
|
+
|
|
182
|
+
add_robot SupportRobot.build
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Service Object
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# app/services/chat_service.rb
|
|
192
|
+
class ChatService
|
|
193
|
+
def initialize(user:, thread_id: nil)
|
|
194
|
+
@user = user
|
|
195
|
+
@thread_id = thread_id
|
|
196
|
+
@network = SupportNetwork.build
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def call(message:, &streaming_callback)
|
|
200
|
+
user_message = build_message(message)
|
|
201
|
+
state = build_state(user_message)
|
|
202
|
+
|
|
203
|
+
result = @network.run(state: state, user_id: @user.id) do |event|
|
|
204
|
+
streaming_callback&.call(event)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
{
|
|
208
|
+
thread_id: result.state.thread_id,
|
|
209
|
+
response: extract_response(result),
|
|
210
|
+
messages: result.new_results
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def build_message(content)
|
|
217
|
+
if @thread_id
|
|
218
|
+
RobotLab::UserMessage.new(content, thread_id: @thread_id)
|
|
219
|
+
else
|
|
220
|
+
content
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def build_state(message)
|
|
225
|
+
RobotLab.create_state(
|
|
226
|
+
message: message,
|
|
227
|
+
data: { user_id: @user.id }
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def extract_response(result)
|
|
232
|
+
result.last_result&.output&.find { |m| m.is_a?(RobotLab::TextMessage) }&.content
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Controller
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# app/controllers/api/chats_controller.rb
|
|
241
|
+
module Api
|
|
242
|
+
class ChatsController < ApplicationController
|
|
243
|
+
before_action :authenticate_user!
|
|
244
|
+
|
|
245
|
+
def create
|
|
246
|
+
service = ChatService.new(
|
|
247
|
+
user: current_user,
|
|
248
|
+
thread_id: params[:thread_id]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
result = service.call(message: params[:message])
|
|
252
|
+
|
|
253
|
+
render json: {
|
|
254
|
+
thread_id: result[:thread_id],
|
|
255
|
+
response: result[:response]
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Action Cable Integration
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# app/channels/chat_channel.rb
|
|
266
|
+
class ChatChannel < ApplicationCable::Channel
|
|
267
|
+
def subscribed
|
|
268
|
+
stream_for current_user
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def receive(data)
|
|
272
|
+
ChatJob.perform_later(
|
|
273
|
+
user_id: current_user.id,
|
|
274
|
+
thread_id: data["thread_id"],
|
|
275
|
+
message: data["message"]
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# app/jobs/chat_job.rb
|
|
281
|
+
class ChatJob < ApplicationJob
|
|
282
|
+
queue_as :default
|
|
283
|
+
|
|
284
|
+
def perform(user_id:, thread_id:, message:)
|
|
285
|
+
user = User.find(user_id)
|
|
286
|
+
|
|
287
|
+
service = ChatService.new(user: user, thread_id: thread_id)
|
|
288
|
+
|
|
289
|
+
service.call(message: message) do |event|
|
|
290
|
+
case event.type
|
|
291
|
+
when :text_delta
|
|
292
|
+
broadcast_to_user(user, type: "text", content: event.text)
|
|
293
|
+
when :tool_call
|
|
294
|
+
broadcast_to_user(user, type: "tool", name: event.name)
|
|
295
|
+
when :complete
|
|
296
|
+
broadcast_to_user(user, type: "complete")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def broadcast_to_user(user, data)
|
|
304
|
+
ChatChannel.broadcast_to(user, data)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Frontend (Stimulus)
|
|
310
|
+
|
|
311
|
+
```javascript
|
|
312
|
+
// app/javascript/controllers/chat_controller.js
|
|
313
|
+
import { Controller } from "@hotwired/stimulus"
|
|
314
|
+
import { createConsumer } from "@rails/actioncable"
|
|
315
|
+
|
|
316
|
+
export default class extends Controller {
|
|
317
|
+
static targets = ["messages", "input", "response"]
|
|
318
|
+
|
|
319
|
+
connect() {
|
|
320
|
+
this.consumer = createConsumer()
|
|
321
|
+
this.channel = this.consumer.subscriptions.create("ChatChannel", {
|
|
322
|
+
received: (data) => this.handleMessage(data)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
disconnect() {
|
|
327
|
+
this.channel?.unsubscribe()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
send() {
|
|
331
|
+
const message = this.inputTarget.value.trim()
|
|
332
|
+
if (!message) return
|
|
333
|
+
|
|
334
|
+
this.appendMessage("user", message)
|
|
335
|
+
this.inputTarget.value = ""
|
|
336
|
+
|
|
337
|
+
// Create response container
|
|
338
|
+
this.currentResponse = document.createElement("div")
|
|
339
|
+
this.currentResponse.className = "message assistant"
|
|
340
|
+
this.messagesTarget.appendChild(this.currentResponse)
|
|
341
|
+
|
|
342
|
+
this.channel.send({
|
|
343
|
+
message: message,
|
|
344
|
+
thread_id: this.threadId
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
handleMessage(data) {
|
|
349
|
+
switch (data.type) {
|
|
350
|
+
case "text":
|
|
351
|
+
this.currentResponse.textContent += data.content
|
|
352
|
+
break
|
|
353
|
+
case "tool":
|
|
354
|
+
// Show tool indicator
|
|
355
|
+
break
|
|
356
|
+
case "complete":
|
|
357
|
+
this.threadId = data.thread_id
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
appendMessage(role, content) {
|
|
363
|
+
const div = document.createElement("div")
|
|
364
|
+
div.className = `message ${role}`
|
|
365
|
+
div.textContent = content
|
|
366
|
+
this.messagesTarget.appendChild(div)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## View
|
|
372
|
+
|
|
373
|
+
```erb
|
|
374
|
+
<!-- app/views/chats/show.html.erb -->
|
|
375
|
+
<div data-controller="chat">
|
|
376
|
+
<div class="messages" data-chat-target="messages">
|
|
377
|
+
<!-- Messages appear here -->
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<form data-action="submit->chat#send">
|
|
381
|
+
<input type="text"
|
|
382
|
+
data-chat-target="input"
|
|
383
|
+
placeholder="Type a message..."
|
|
384
|
+
autocomplete="off">
|
|
385
|
+
<button type="submit">Send</button>
|
|
386
|
+
</form>
|
|
387
|
+
</div>
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Running
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
# Install dependencies
|
|
394
|
+
bundle install
|
|
395
|
+
yarn install
|
|
396
|
+
|
|
397
|
+
# Setup database
|
|
398
|
+
rails db:migrate
|
|
399
|
+
|
|
400
|
+
# Set API key
|
|
401
|
+
export ANTHROPIC_API_KEY="your-key"
|
|
402
|
+
|
|
403
|
+
# Start server
|
|
404
|
+
bin/dev
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Key Concepts
|
|
408
|
+
|
|
409
|
+
1. **Robot Classes**: Encapsulate robot definitions
|
|
410
|
+
2. **Network Classes**: Configure multi-robot networks
|
|
411
|
+
3. **Service Objects**: Handle business logic
|
|
412
|
+
4. **Action Cable**: Real-time streaming to browser
|
|
413
|
+
5. **Background Jobs**: Non-blocking processing
|
|
414
|
+
6. **History Persistence**: ActiveRecord integration
|
|
415
|
+
|
|
416
|
+
## See Also
|
|
417
|
+
|
|
418
|
+
- [Rails Integration Guide](../guides/rails-integration.md)
|
|
419
|
+
- [Streaming Guide](../guides/streaming.md)
|
|
420
|
+
- [History Guide](../guides/history.md)
|