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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/deploy-github-pages.yml +9 -9
  3. data/.irbrc +6 -0
  4. data/CHANGELOG.md +90 -0
  5. data/README.md +203 -46
  6. data/Rakefile +70 -1
  7. data/docs/api/core/index.md +12 -0
  8. data/docs/api/core/robot.md +478 -130
  9. data/docs/api/core/tool.md +205 -209
  10. data/docs/api/history/active-record-adapter.md +174 -94
  11. data/docs/api/history/config.md +186 -93
  12. data/docs/api/history/index.md +57 -61
  13. data/docs/api/history/thread-manager.md +123 -73
  14. data/docs/api/mcp/client.md +119 -48
  15. data/docs/api/mcp/index.md +75 -60
  16. data/docs/api/mcp/server.md +120 -136
  17. data/docs/api/mcp/transports.md +172 -184
  18. data/docs/api/streaming/context.md +157 -74
  19. data/docs/api/streaming/events.md +114 -166
  20. data/docs/api/streaming/index.md +74 -72
  21. data/docs/architecture/core-concepts.md +361 -112
  22. data/docs/architecture/index.md +97 -59
  23. data/docs/architecture/message-flow.md +138 -129
  24. data/docs/architecture/network-orchestration.md +197 -50
  25. data/docs/architecture/robot-execution.md +199 -146
  26. data/docs/architecture/state-management.md +255 -187
  27. data/docs/concepts.md +312 -48
  28. data/docs/examples/basic-chat.md +89 -77
  29. data/docs/examples/index.md +222 -47
  30. data/docs/examples/mcp-server.md +207 -203
  31. data/docs/examples/multi-robot-network.md +129 -35
  32. data/docs/examples/rails-application.md +159 -160
  33. data/docs/examples/tool-usage.md +295 -204
  34. data/docs/getting-started/configuration.md +275 -162
  35. data/docs/getting-started/index.md +1 -1
  36. data/docs/getting-started/installation.md +22 -13
  37. data/docs/getting-started/quick-start.md +166 -121
  38. data/docs/guides/building-robots.md +417 -212
  39. data/docs/guides/creating-networks.md +94 -24
  40. data/docs/guides/mcp-integration.md +152 -113
  41. data/docs/guides/memory.md +220 -164
  42. data/docs/guides/streaming.md +80 -110
  43. data/docs/guides/using-tools.md +259 -212
  44. data/docs/index.md +50 -37
  45. data/examples/01_simple_robot.rb +6 -9
  46. data/examples/02_tools.rb +6 -9
  47. data/examples/03_network.rb +13 -14
  48. data/examples/04_mcp.rb +5 -8
  49. data/examples/05_streaming.rb +5 -8
  50. data/examples/06_prompt_templates.rb +42 -37
  51. data/examples/07_network_memory.rb +13 -14
  52. data/examples/08_llm_config.rb +140 -0
  53. data/examples/09_chaining.rb +223 -0
  54. data/examples/10_memory.rb +331 -0
  55. data/examples/11_network_introspection.rb +230 -0
  56. data/examples/12_message_bus.rb +74 -0
  57. data/examples/13_spawn.rb +90 -0
  58. data/examples/14_rusty_circuit/comic.rb +143 -0
  59. data/examples/14_rusty_circuit/display.rb +203 -0
  60. data/examples/14_rusty_circuit/heckler.rb +57 -0
  61. data/examples/14_rusty_circuit/open_mic.rb +121 -0
  62. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
  63. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
  64. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
  65. data/examples/14_rusty_circuit/scout.rb +173 -0
  66. data/examples/14_rusty_circuit/scout_notes.md +89 -0
  67. data/examples/14_rusty_circuit/show.log +234 -0
  68. data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
  71. data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
  72. data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
  73. data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
  74. data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
  75. data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
  76. data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
  77. data/examples/15_memory_network_and_bus/output/memory.json +13 -0
  78. data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
  79. data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
  80. data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
  81. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
  82. data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
  83. data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
  84. data/examples/README.md +197 -0
  85. data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
  86. data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
  87. data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
  88. data/examples/prompts/comedian.md +6 -0
  89. data/examples/prompts/comedy_critic.md +10 -0
  90. data/examples/prompts/configurable.md +9 -0
  91. data/examples/prompts/dispatcher.md +12 -0
  92. data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
  93. data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
  94. data/examples/prompts/frontmatter_mcp_test.md +9 -0
  95. data/examples/prompts/frontmatter_named_test.md +5 -0
  96. data/examples/prompts/frontmatter_tools_test.md +6 -0
  97. data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
  98. data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
  99. data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
  100. data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
  101. data/examples/prompts/llm_config_demo.md +20 -0
  102. data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
  103. data/examples/prompts/os_advocate.md +13 -0
  104. data/examples/prompts/os_chief.md +13 -0
  105. data/examples/prompts/os_editor.md +13 -0
  106. data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
  107. data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
  108. data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
  109. data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
  110. data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
  111. data/lib/generators/robot_lab/templates/initializer.rb.tt +1 -1
  112. data/lib/robot_lab/adapters/openai.rb +2 -1
  113. data/lib/robot_lab/ask_user.rb +75 -0
  114. data/lib/robot_lab/config/defaults.yml +121 -0
  115. data/lib/robot_lab/config.rb +183 -0
  116. data/lib/robot_lab/error.rb +6 -0
  117. data/lib/robot_lab/mcp/client.rb +1 -1
  118. data/lib/robot_lab/memory.rb +2 -2
  119. data/lib/robot_lab/robot.rb +523 -249
  120. data/lib/robot_lab/robot_message.rb +44 -0
  121. data/lib/robot_lab/robot_result.rb +1 -0
  122. data/lib/robot_lab/robotic_model.rb +1 -1
  123. data/lib/robot_lab/streaming/context.rb +1 -1
  124. data/lib/robot_lab/tool.rb +108 -172
  125. data/lib/robot_lab/tool_config.rb +1 -1
  126. data/lib/robot_lab/tool_manifest.rb +2 -18
  127. data/lib/robot_lab/version.rb +1 -1
  128. data/lib/robot_lab.rb +66 -55
  129. metadata +107 -116
  130. data/examples/prompts/assistant/user.txt.erb +0 -1
  131. data/examples/prompts/billing/user.txt.erb +0 -1
  132. data/examples/prompts/classifier/user.txt.erb +0 -1
  133. data/examples/prompts/entity_extractor/user.txt.erb +0 -3
  134. data/examples/prompts/escalation/user.txt.erb +0 -34
  135. data/examples/prompts/general/user.txt.erb +0 -1
  136. data/examples/prompts/github_assistant/user.txt.erb +0 -1
  137. data/examples/prompts/helper/user.txt.erb +0 -1
  138. data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
  139. data/examples/prompts/order_support/user.txt.erb +0 -22
  140. data/examples/prompts/product_support/user.txt.erb +0 -32
  141. data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
  142. data/examples/prompts/synthesizer/user.txt.erb +0 -15
  143. data/examples/prompts/technical/user.txt.erb +0 -1
  144. data/examples/prompts/triage/user.txt.erb +0 -17
  145. 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