robot_lab 0.0.1 → 0.0.4
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 +4 -4
- data/.github/workflows/deploy-github-pages.yml +9 -9
- data/.irbrc +6 -0
- data/CHANGELOG.md +90 -0
- data/README.md +203 -46
- data/Rakefile +70 -1
- data/docs/api/core/index.md +12 -0
- data/docs/api/core/robot.md +478 -130
- data/docs/api/core/tool.md +205 -209
- data/docs/api/history/active-record-adapter.md +174 -94
- data/docs/api/history/config.md +186 -93
- data/docs/api/history/index.md +57 -61
- data/docs/api/history/thread-manager.md +123 -73
- data/docs/api/mcp/client.md +119 -48
- data/docs/api/mcp/index.md +75 -60
- data/docs/api/mcp/server.md +120 -136
- data/docs/api/mcp/transports.md +172 -184
- data/docs/api/streaming/context.md +157 -74
- data/docs/api/streaming/events.md +114 -166
- data/docs/api/streaming/index.md +74 -72
- data/docs/architecture/core-concepts.md +361 -112
- data/docs/architecture/index.md +97 -59
- data/docs/architecture/message-flow.md +138 -129
- data/docs/architecture/network-orchestration.md +197 -50
- data/docs/architecture/robot-execution.md +199 -146
- data/docs/architecture/state-management.md +255 -187
- data/docs/concepts.md +312 -48
- data/docs/examples/basic-chat.md +89 -77
- data/docs/examples/index.md +222 -47
- data/docs/examples/mcp-server.md +207 -203
- data/docs/examples/multi-robot-network.md +129 -35
- data/docs/examples/rails-application.md +159 -160
- data/docs/examples/tool-usage.md +295 -204
- data/docs/getting-started/configuration.md +275 -162
- data/docs/getting-started/index.md +1 -1
- data/docs/getting-started/installation.md +22 -13
- data/docs/getting-started/quick-start.md +166 -121
- data/docs/guides/building-robots.md +417 -212
- data/docs/guides/creating-networks.md +94 -24
- data/docs/guides/mcp-integration.md +152 -113
- data/docs/guides/memory.md +220 -164
- data/docs/guides/streaming.md +80 -110
- data/docs/guides/using-tools.md +259 -212
- data/docs/index.md +50 -37
- data/examples/01_simple_robot.rb +6 -9
- data/examples/02_tools.rb +6 -9
- data/examples/03_network.rb +13 -14
- data/examples/04_mcp.rb +5 -8
- data/examples/05_streaming.rb +5 -8
- data/examples/06_prompt_templates.rb +42 -37
- data/examples/07_network_memory.rb +13 -14
- data/examples/08_llm_config.rb +140 -0
- data/examples/09_chaining.rb +223 -0
- data/examples/10_memory.rb +331 -0
- data/examples/11_network_introspection.rb +230 -0
- data/examples/12_message_bus.rb +74 -0
- data/examples/13_spawn.rb +90 -0
- data/examples/14_rusty_circuit/comic.rb +143 -0
- data/examples/14_rusty_circuit/display.rb +203 -0
- data/examples/14_rusty_circuit/heckler.rb +57 -0
- data/examples/14_rusty_circuit/open_mic.rb +121 -0
- data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
- data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
- data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
- data/examples/14_rusty_circuit/scout.rb +173 -0
- data/examples/14_rusty_circuit/scout_notes.md +89 -0
- data/examples/14_rusty_circuit/show.log +234 -0
- data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
- data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
- data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
- data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
- data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
- data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
- data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
- data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
- data/examples/15_memory_network_and_bus/output/memory.json +13 -0
- data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
- data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
- data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
- data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
- data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
- data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
- data/examples/README.md +197 -0
- data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
- data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
- data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
- data/examples/prompts/comedian.md +6 -0
- data/examples/prompts/comedy_critic.md +10 -0
- data/examples/prompts/configurable.md +9 -0
- data/examples/prompts/dispatcher.md +12 -0
- data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
- data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
- data/examples/prompts/frontmatter_mcp_test.md +9 -0
- data/examples/prompts/frontmatter_named_test.md +5 -0
- data/examples/prompts/frontmatter_tools_test.md +6 -0
- data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
- data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
- data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
- data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
- data/examples/prompts/llm_config_demo.md +20 -0
- data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
- data/examples/prompts/os_advocate.md +13 -0
- data/examples/prompts/os_chief.md +13 -0
- data/examples/prompts/os_editor.md +13 -0
- data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
- data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
- data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
- data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
- data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +1 -1
- data/lib/robot_lab/adapters/openai.rb +2 -1
- data/lib/robot_lab/ask_user.rb +75 -0
- data/lib/robot_lab/config/defaults.yml +121 -0
- data/lib/robot_lab/config.rb +183 -0
- data/lib/robot_lab/error.rb +6 -0
- data/lib/robot_lab/mcp/client.rb +1 -1
- data/lib/robot_lab/memory.rb +2 -2
- data/lib/robot_lab/robot.rb +523 -249
- data/lib/robot_lab/robot_message.rb +44 -0
- data/lib/robot_lab/robot_result.rb +1 -0
- data/lib/robot_lab/robotic_model.rb +1 -1
- data/lib/robot_lab/streaming/context.rb +1 -1
- data/lib/robot_lab/tool.rb +108 -172
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +2 -18
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +66 -55
- metadata +107 -116
- data/examples/prompts/assistant/user.txt.erb +0 -1
- data/examples/prompts/billing/user.txt.erb +0 -1
- data/examples/prompts/classifier/user.txt.erb +0 -1
- data/examples/prompts/entity_extractor/user.txt.erb +0 -3
- data/examples/prompts/escalation/user.txt.erb +0 -34
- data/examples/prompts/general/user.txt.erb +0 -1
- data/examples/prompts/github_assistant/user.txt.erb +0 -1
- data/examples/prompts/helper/user.txt.erb +0 -1
- data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
- data/examples/prompts/order_support/user.txt.erb +0 -22
- data/examples/prompts/product_support/user.txt.erb +0 -32
- data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
- data/examples/prompts/synthesizer/user.txt.erb +0 -15
- data/examples/prompts/technical/user.txt.erb +0 -1
- data/examples/prompts/triage/user.txt.erb +0 -17
- data/lib/robot_lab/configuration.rb +0 -143
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 11: Network Visualization & Introspection
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates network inspection and visualization without making any
|
|
7
|
+
# LLM calls. Covers:
|
|
8
|
+
# - to_mermaid() — Mermaid diagram export
|
|
9
|
+
# - to_dot() — Graphviz DOT export
|
|
10
|
+
# - execution_plan() — text execution order
|
|
11
|
+
# - visualize() — ASCII pipeline visualization
|
|
12
|
+
# - robot(name) / [name] — access individual robots
|
|
13
|
+
# - available_robots() — list all robots
|
|
14
|
+
# - add_robot() — dynamically add a robot
|
|
15
|
+
# - to_h() — network introspection hash (via amazing_print)
|
|
16
|
+
# - Task-specific config (context:, depends_on:)
|
|
17
|
+
# - broadcast() and on_broadcast
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# bundle exec ruby examples/11_network_introspection.rb
|
|
21
|
+
|
|
22
|
+
# Configure template path before loading
|
|
23
|
+
ENV['ROBOT_LAB_TEMPLATE_PATH'] ||= File.join(__dir__, "prompts")
|
|
24
|
+
|
|
25
|
+
require_relative "../lib/robot_lab"
|
|
26
|
+
require "amazing_print"
|
|
27
|
+
require "tempfile"
|
|
28
|
+
|
|
29
|
+
# On macOS + iTerm2, render DOT as a PNG and display inline via imgcat.
|
|
30
|
+
# Returns true if the image was displayed, false otherwise.
|
|
31
|
+
def render_dot_image(dot_source)
|
|
32
|
+
return false unless RUBY_PLATFORM.include?("darwin")
|
|
33
|
+
|
|
34
|
+
dot_cmd = `which dot 2>/dev/null`.chomp
|
|
35
|
+
imgcat = File.expand_path("~/.iterm2/imgcat")
|
|
36
|
+
imgcat = `which imgcat 2>/dev/null`.chomp unless File.executable?(imgcat)
|
|
37
|
+
|
|
38
|
+
return false unless File.executable?(dot_cmd) && File.executable?(imgcat)
|
|
39
|
+
return false unless ENV["TERM_PROGRAM"] == "iTerm.app"
|
|
40
|
+
|
|
41
|
+
Tempfile.create(["pipeline", ".png"]) do |png|
|
|
42
|
+
IO.popen([dot_cmd, "-Tpng", "-o", png.path], "w") { |io| io.write(dot_source) }
|
|
43
|
+
|
|
44
|
+
if $?.success? && File.size(png.path) > 0
|
|
45
|
+
system(imgcat, png.path)
|
|
46
|
+
true
|
|
47
|
+
else
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts "=" * 70
|
|
54
|
+
puts "Example 11: Network Visualization & Introspection"
|
|
55
|
+
puts "=" * 70
|
|
56
|
+
puts
|
|
57
|
+
|
|
58
|
+
# Build robots (no LLM calls, just instances)
|
|
59
|
+
classifier = RobotLab.build(name: "classifier", system_prompt: "Classify input")
|
|
60
|
+
analyst = RobotLab.build(name: "analyst", system_prompt: "Analyze data")
|
|
61
|
+
writer = RobotLab.build(name: "writer", system_prompt: "Write summary")
|
|
62
|
+
|
|
63
|
+
# Build network with dependencies and per-task config
|
|
64
|
+
network = RobotLab.create_network(name: "demo_pipeline") do
|
|
65
|
+
task :classify, classifier, depends_on: :none
|
|
66
|
+
task :analyze, analyst, context: { depth: "deep" }, depends_on: [:classify]
|
|
67
|
+
task :write, writer, depends_on: [:analyze]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# =============================================================================
|
|
71
|
+
# Section 1: Visualization outputs
|
|
72
|
+
# =============================================================================
|
|
73
|
+
|
|
74
|
+
puts "--- Section 1: Visualization ---"
|
|
75
|
+
puts
|
|
76
|
+
|
|
77
|
+
mermaid = network.to_mermaid
|
|
78
|
+
if mermaid
|
|
79
|
+
puts "Mermaid diagram:"
|
|
80
|
+
puts mermaid
|
|
81
|
+
puts
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
dot = network.to_dot
|
|
85
|
+
if dot
|
|
86
|
+
puts "Graphviz DOT:"
|
|
87
|
+
puts dot
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
if render_dot_image(dot)
|
|
91
|
+
puts "(Rendered pipeline graph above via Graphviz + imgcat)"
|
|
92
|
+
puts
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
plan = network.execution_plan
|
|
97
|
+
if plan
|
|
98
|
+
puts "Execution plan:"
|
|
99
|
+
puts plan
|
|
100
|
+
puts
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
ascii = network.visualize
|
|
104
|
+
if ascii
|
|
105
|
+
puts "ASCII visualization:"
|
|
106
|
+
puts ascii
|
|
107
|
+
puts
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# If none of the visualization methods returned output, note it
|
|
111
|
+
unless mermaid || dot || plan || ascii
|
|
112
|
+
puts "(Visualization methods returned nil — depends on simple_flow version)"
|
|
113
|
+
puts
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# =============================================================================
|
|
117
|
+
# Section 2: Robot access
|
|
118
|
+
# =============================================================================
|
|
119
|
+
|
|
120
|
+
puts "--- Section 2: Robot Access ---"
|
|
121
|
+
puts
|
|
122
|
+
|
|
123
|
+
# Access by task name with robot() method
|
|
124
|
+
# Note: robots are keyed by task name (the first arg to task()), not robot.name
|
|
125
|
+
puts "network.robot('classify').name = #{network.robot('classify').name.inspect}"
|
|
126
|
+
|
|
127
|
+
# Access with [] shorthand
|
|
128
|
+
puts "network['analyze'].name = #{network['analyze'].name.inspect}"
|
|
129
|
+
|
|
130
|
+
# Also works with symbols
|
|
131
|
+
puts "network[:write].name = #{network[:write].name.inspect}"
|
|
132
|
+
puts
|
|
133
|
+
|
|
134
|
+
# List all robots
|
|
135
|
+
puts "available_robots:"
|
|
136
|
+
ap network.available_robots.map(&:name)
|
|
137
|
+
puts
|
|
138
|
+
|
|
139
|
+
# =============================================================================
|
|
140
|
+
# Section 3: Dynamic robot addition
|
|
141
|
+
# =============================================================================
|
|
142
|
+
|
|
143
|
+
puts "--- Section 3: Dynamic Robot Addition ---"
|
|
144
|
+
puts
|
|
145
|
+
|
|
146
|
+
reviewer = RobotLab.build(name: "reviewer", system_prompt: "Review output")
|
|
147
|
+
network.add_robot(reviewer)
|
|
148
|
+
|
|
149
|
+
puts "After add_robot(reviewer):"
|
|
150
|
+
ap network.available_robots.map(&:name)
|
|
151
|
+
puts
|
|
152
|
+
|
|
153
|
+
# Attempting to add a duplicate raises an error
|
|
154
|
+
begin
|
|
155
|
+
network.add_robot(reviewer)
|
|
156
|
+
rescue ArgumentError => e
|
|
157
|
+
puts "Duplicate add_robot raises: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
puts
|
|
160
|
+
|
|
161
|
+
# =============================================================================
|
|
162
|
+
# Section 4: Network introspection
|
|
163
|
+
# =============================================================================
|
|
164
|
+
|
|
165
|
+
puts "--- Section 4: Network Introspection ---"
|
|
166
|
+
puts
|
|
167
|
+
|
|
168
|
+
puts "network.to_h:"
|
|
169
|
+
ap network.to_h
|
|
170
|
+
puts
|
|
171
|
+
|
|
172
|
+
# Individual robot introspection
|
|
173
|
+
puts "network['classify'].to_h:"
|
|
174
|
+
ap network["classify"].to_h
|
|
175
|
+
puts
|
|
176
|
+
|
|
177
|
+
puts "network['analyze'].to_h:"
|
|
178
|
+
ap network["analyze"].to_h
|
|
179
|
+
puts
|
|
180
|
+
|
|
181
|
+
# =============================================================================
|
|
182
|
+
# Section 5: Broadcast
|
|
183
|
+
# =============================================================================
|
|
184
|
+
|
|
185
|
+
puts "--- Section 5: Broadcast ---"
|
|
186
|
+
puts
|
|
187
|
+
|
|
188
|
+
broadcast_messages = []
|
|
189
|
+
|
|
190
|
+
network.on_broadcast do |msg|
|
|
191
|
+
broadcast_messages << msg
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
puts "Registered broadcast handler"
|
|
195
|
+
|
|
196
|
+
network.broadcast(event: :demo, message: "Hello from network!")
|
|
197
|
+
|
|
198
|
+
# Give async handler time to fire
|
|
199
|
+
sleep 0.1
|
|
200
|
+
|
|
201
|
+
if broadcast_messages.any?
|
|
202
|
+
puts "Broadcast received:"
|
|
203
|
+
ap broadcast_messages.first
|
|
204
|
+
else
|
|
205
|
+
puts "(No broadcast received — handler may be async)"
|
|
206
|
+
end
|
|
207
|
+
puts
|
|
208
|
+
|
|
209
|
+
# =============================================================================
|
|
210
|
+
# Section 6: Shared memory access
|
|
211
|
+
# =============================================================================
|
|
212
|
+
|
|
213
|
+
puts "--- Section 6: Shared Network Memory ---"
|
|
214
|
+
puts
|
|
215
|
+
|
|
216
|
+
puts "network.memory is a #{network.memory.class}"
|
|
217
|
+
puts "network.memory.network_name = #{network.memory.network_name.inspect}"
|
|
218
|
+
|
|
219
|
+
# Robots in a network share this memory during run()
|
|
220
|
+
network.memory.set(:demo_key, "shared value")
|
|
221
|
+
puts "network.memory.get(:demo_key) = #{network.memory.get(:demo_key).inspect}"
|
|
222
|
+
puts
|
|
223
|
+
|
|
224
|
+
puts "network.memory.to_h:"
|
|
225
|
+
ap network.memory.to_h
|
|
226
|
+
puts
|
|
227
|
+
|
|
228
|
+
puts "=" * 70
|
|
229
|
+
puts "All sections completed without any LLM calls."
|
|
230
|
+
puts "=" * 70
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 12: Message Bus — Converging on a Funny Robot Joke
|
|
5
|
+
#
|
|
6
|
+
# Alice tasks Bob to tell a robot joke. Alice uses her LLM to
|
|
7
|
+
# evaluate each joke. If she's not impressed she asks Bob to try
|
|
8
|
+
# again. The loop continues until Alice approves or MAX_ATTEMPTS.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# bundle exec ruby examples/12_message_bus.rb
|
|
12
|
+
|
|
13
|
+
ENV['ROBOT_LAB_TEMPLATE_PATH'] ||= File.join(__dir__, "prompts")
|
|
14
|
+
|
|
15
|
+
require_relative "../lib/robot_lab"
|
|
16
|
+
|
|
17
|
+
RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
18
|
+
|
|
19
|
+
MAX_ATTEMPTS = 5
|
|
20
|
+
|
|
21
|
+
class Comedian < RobotLab::Robot
|
|
22
|
+
TEMP_START = 0.2
|
|
23
|
+
TEMP_STEP = 0.2
|
|
24
|
+
|
|
25
|
+
def initialize(bus:)
|
|
26
|
+
super(name: "bob", template: :comedian, bus: bus, temperature: TEMP_START)
|
|
27
|
+
@attempts = 0
|
|
28
|
+
on_message do |message|
|
|
29
|
+
@attempts += 1
|
|
30
|
+
temp = [TEMP_START + TEMP_STEP * (@attempts - 1), 1.0].min
|
|
31
|
+
with_temperature(temp)
|
|
32
|
+
joke = run(message.content.to_s).reply.strip
|
|
33
|
+
puts " Bob [##{@attempts}, t=#{"%.1f" % temp}]: #{joke}"
|
|
34
|
+
reply(message, joke)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :attempts
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ComedyCritic < RobotLab::Robot
|
|
42
|
+
def initialize(bus:)
|
|
43
|
+
super(name: "alice", template: :comedy_critic, bus: bus)
|
|
44
|
+
@accepted = false
|
|
45
|
+
@rounds = 0
|
|
46
|
+
on_message do |message|
|
|
47
|
+
@rounds += 1
|
|
48
|
+
verdict = run("Evaluate this joke:\n\n#{message.content}").reply.strip
|
|
49
|
+
puts " Alice: #{verdict}"
|
|
50
|
+
puts
|
|
51
|
+
@accepted = verdict.start_with?("FUNNY")
|
|
52
|
+
send_message(to: :bob, content: "Not funny enough. Try again.") unless @accepted || @rounds >= MAX_ATTEMPTS
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :accepted
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
bus = TypedBus::MessageBus.new
|
|
60
|
+
bob = Comedian.new(bus: bus)
|
|
61
|
+
alice = ComedyCritic.new(bus: bus)
|
|
62
|
+
|
|
63
|
+
puts "=" * 60
|
|
64
|
+
puts "Example 12: Tell Me a Funny Robot Joke"
|
|
65
|
+
puts "=" * 60
|
|
66
|
+
puts
|
|
67
|
+
|
|
68
|
+
puts "Alice: Tell me a funny robot joke."
|
|
69
|
+
puts
|
|
70
|
+
alice.send_message(to: :bob, content: "Tell me a funny robot joke.")
|
|
71
|
+
|
|
72
|
+
puts "-" * 60
|
|
73
|
+
puts "Attempts: #{bob.attempts} / #{MAX_ATTEMPTS}"
|
|
74
|
+
puts "Accepted: #{alice.accepted}"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example 13: Spawning Robots — Dynamic Specialist Creation
|
|
5
|
+
#
|
|
6
|
+
# A dispatcher robot receives a question, decides what kind of
|
|
7
|
+
# specialist is needed, spawns one on the fly, and hands off
|
|
8
|
+
# the work. The spawned robot replies via the shared message bus.
|
|
9
|
+
#
|
|
10
|
+
# This demonstrates Werner's "objects that create new objects to
|
|
11
|
+
# deal with problems nobody anticipated."
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# bundle exec ruby examples/13_spawn.rb
|
|
15
|
+
|
|
16
|
+
ENV['ROBOT_LAB_TEMPLATE_PATH'] ||= File.join(__dir__, "prompts")
|
|
17
|
+
|
|
18
|
+
require_relative "../lib/robot_lab"
|
|
19
|
+
|
|
20
|
+
RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
21
|
+
|
|
22
|
+
QUESTIONS = [
|
|
23
|
+
"Why did the Roman Empire fall?",
|
|
24
|
+
"Write a haiku about recursion.",
|
|
25
|
+
"What is the square root of 144?",
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
class Dispatcher < RobotLab::Robot
|
|
29
|
+
attr_reader :spawned
|
|
30
|
+
|
|
31
|
+
def initialize(bus: nil)
|
|
32
|
+
super(name: "dispatcher", template: :dispatcher, bus: bus)
|
|
33
|
+
@spawned = {}
|
|
34
|
+
@pending = {}
|
|
35
|
+
|
|
36
|
+
on_message do |message|
|
|
37
|
+
puts " Dispatcher <- :#{message.from} replied"
|
|
38
|
+
puts " | #{message.content.to_s.lines.first&.strip}"
|
|
39
|
+
@pending.delete(message.from)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dispatch(question)
|
|
44
|
+
# Ask the LLM which specialist to spawn
|
|
45
|
+
plan = run(question).reply.strip
|
|
46
|
+
role, instruction = plan.split("\n", 2)
|
|
47
|
+
role = role.strip.downcase.gsub(/\s+/, "_")
|
|
48
|
+
instruction = instruction&.strip || "You are a helpful #{role}."
|
|
49
|
+
|
|
50
|
+
puts " Dispatcher -> spawn :#{role}"
|
|
51
|
+
puts " | #{instruction}"
|
|
52
|
+
|
|
53
|
+
# Spawn the specialist (reuse if already spawned)
|
|
54
|
+
specialist = @spawned[role] ||= spawn(
|
|
55
|
+
name: role,
|
|
56
|
+
system_prompt: instruction
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Record what we're waiting for
|
|
60
|
+
@pending[role] = question
|
|
61
|
+
|
|
62
|
+
# Ask the specialist to work on the question
|
|
63
|
+
specialist.send_message(to: :dispatcher, content:
|
|
64
|
+
specialist.run(question).reply.strip
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def done?
|
|
69
|
+
@pending.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── main ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
dispatcher = Dispatcher.new
|
|
76
|
+
|
|
77
|
+
puts "=" * 60
|
|
78
|
+
puts "Example 13: Spawning Specialist Robots"
|
|
79
|
+
puts "=" * 60
|
|
80
|
+
|
|
81
|
+
QUESTIONS.each_with_index do |question, i|
|
|
82
|
+
puts
|
|
83
|
+
puts "Question #{i + 1}: #{question}"
|
|
84
|
+
dispatcher.dispatch(question)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
puts
|
|
88
|
+
puts "-" * 60
|
|
89
|
+
puts "Specialists spawned: #{dispatcher.spawned.keys.join(', ')}"
|
|
90
|
+
puts "Total robots on bus: #{dispatcher.spawned.size + 1}"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ── Comic Tools ─────────────────────────────────────────────
|
|
4
|
+
#
|
|
5
|
+
# Each tool accesses the owning robot via the `robot` accessor
|
|
6
|
+
# inherited from RobotLab::Tool.
|
|
7
|
+
|
|
8
|
+
class ReinventStyle < RobotLab::Tool
|
|
9
|
+
description "Completely rewrite your comedy persona and approach. " \
|
|
10
|
+
"Use when your current style is clearly not working. " \
|
|
11
|
+
"Be bold — try something totally different. " \
|
|
12
|
+
"The new style takes effect on your next bit."
|
|
13
|
+
|
|
14
|
+
param :new_persona, type: "string",
|
|
15
|
+
desc: "Your new comedy persona, style, and approach. " \
|
|
16
|
+
"Be specific: what kind of humor, what voice, what attitude."
|
|
17
|
+
|
|
18
|
+
def execute(new_persona:)
|
|
19
|
+
robot.pending_reinvention = new_persona
|
|
20
|
+
robot.style_changes += 1
|
|
21
|
+
robot.display&.comic_tool("[reinvent_style] -> #{new_persona[0..70]}...")
|
|
22
|
+
"Style reinvention accepted: #{new_persona}. " \
|
|
23
|
+
"Commit to this new approach starting now."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class AdjustEnergy < RobotLab::Tool
|
|
28
|
+
description "Adjust your creative energy level. " \
|
|
29
|
+
"Higher (0.8-1.0) = wilder, riskier, more unpredictable. " \
|
|
30
|
+
"Lower (0.2-0.4) = tighter, more controlled, precise."
|
|
31
|
+
|
|
32
|
+
param :level, type: "number",
|
|
33
|
+
desc: "Energy level from 0.1 (very controlled) to 1.0 (unhinged)"
|
|
34
|
+
param :reason, type: "string",
|
|
35
|
+
desc: "Why you're adjusting", required: false
|
|
36
|
+
|
|
37
|
+
def execute(level:, reason: "tactical adjustment")
|
|
38
|
+
clamped = [[level.to_f, 0.1].max, 1.0].min
|
|
39
|
+
robot.with_temperature(clamped)
|
|
40
|
+
robot.display&.comic_tool("[adjust_energy] -> %.1f (%s)" % [clamped, reason])
|
|
41
|
+
"Energy adjusted to #{clamped}. Reason: #{reason}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class GetCoaching < RobotLab::Tool
|
|
46
|
+
description "Summon a comedy coach backstage for quick advice. " \
|
|
47
|
+
"Use when you're struggling with the crowd and need " \
|
|
48
|
+
"an outside perspective on what to try next."
|
|
49
|
+
|
|
50
|
+
param :situation, type: "string",
|
|
51
|
+
desc: "Describe what's happening and what you need help with"
|
|
52
|
+
|
|
53
|
+
def execute(situation:)
|
|
54
|
+
@coaches ||= {}
|
|
55
|
+
coach = @coaches["comedy_coach"] ||= begin
|
|
56
|
+
robot.coaches_spawned += 1
|
|
57
|
+
robot.spawn(
|
|
58
|
+
name: "comedy_coach",
|
|
59
|
+
system_prompt:
|
|
60
|
+
"You are a veteran comedy coach backstage at a live show. " \
|
|
61
|
+
"A comedian is struggling and needs quick, actionable advice. " \
|
|
62
|
+
"Be direct and specific. One paragraph max. Tell them " \
|
|
63
|
+
"exactly what to do differently in their next bit."
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
advice = coach.run(situation).reply.strip
|
|
67
|
+
robot.display&.comic_tool("[get_coaching] -> #{advice[0..70]}...")
|
|
68
|
+
advice
|
|
69
|
+
rescue => e
|
|
70
|
+
robot.display&.comic_tool("[get_coaching] ERROR: #{e.message}")
|
|
71
|
+
"Coach unavailable right now. Trust your instincts."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── The Comic ────────────────────────────────────────────────
|
|
77
|
+
#
|
|
78
|
+
# Has three tools, all with side effects on the robot itself:
|
|
79
|
+
#
|
|
80
|
+
# reinvent_style — queues a system prompt rewrite (applied next round)
|
|
81
|
+
# adjust_energy — changes the comic's temperature immediately
|
|
82
|
+
# get_coaching — spawns a comedy coach on the shared bus
|
|
83
|
+
#
|
|
84
|
+
# The LLM decides when to call them. The developer provides the
|
|
85
|
+
# mechanism; the robot provides the judgment.
|
|
86
|
+
#
|
|
87
|
+
# Listens on personal :comic channel for heckler feedback.
|
|
88
|
+
# Publishes performances to the shared :room channel.
|
|
89
|
+
#
|
|
90
|
+
class Comic < RobotLab::Robot
|
|
91
|
+
attr_accessor :round, :style_changes, :coaches_spawned,
|
|
92
|
+
:pending_reinvention, :display
|
|
93
|
+
|
|
94
|
+
def initialize(bus:, display:)
|
|
95
|
+
@round = 0
|
|
96
|
+
@style_changes = 0
|
|
97
|
+
@coaches_spawned = 0
|
|
98
|
+
@pending_reinvention = nil
|
|
99
|
+
@display = display
|
|
100
|
+
|
|
101
|
+
super(
|
|
102
|
+
name: "comic",
|
|
103
|
+
template: :open_mic_comic,
|
|
104
|
+
bus: bus,
|
|
105
|
+
local_tools: [
|
|
106
|
+
ReinventStyle.new(robot: self),
|
|
107
|
+
AdjustEnergy.new(robot: self),
|
|
108
|
+
GetCoaching.new(robot: self)
|
|
109
|
+
]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Listen on personal channel for heckler feedback
|
|
113
|
+
on_message do |message|
|
|
114
|
+
next unless message.from == "heckler"
|
|
115
|
+
|
|
116
|
+
@round += 1
|
|
117
|
+
|
|
118
|
+
# Build the prompt, injecting any queued style reinvention.
|
|
119
|
+
# This embeds self-modification in the user prompt rather than
|
|
120
|
+
# rewriting system messages, avoiding chat message ordering issues.
|
|
121
|
+
prompt = "Round #{@round}."
|
|
122
|
+
|
|
123
|
+
if @pending_reinvention
|
|
124
|
+
prompt += "\n\nSTYLE REINVENTION: You are now #{@pending_reinvention}. " \
|
|
125
|
+
"Commit fully to this new style. Abandon your previous approach."
|
|
126
|
+
@pending_reinvention = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
prompt += "\n\nThe heckler just shouted: \"#{message.content}\"\n\n" \
|
|
130
|
+
"Process their feedback. If your material isn't landing, " \
|
|
131
|
+
"use your tools to adapt — reinvent your style, adjust " \
|
|
132
|
+
"your energy, or get coaching. Then deliver your next bit."
|
|
133
|
+
|
|
134
|
+
result = run(prompt)
|
|
135
|
+
bit = result.reply.strip
|
|
136
|
+
|
|
137
|
+
@display.comic("Comic [Round #{@round}]", bit)
|
|
138
|
+
|
|
139
|
+
# Publish to the room — heckler and scout both pick it up
|
|
140
|
+
send_message(to: :room, content: "ROUND #{@round}: #{bit}")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|