robot_lab 0.0.1 → 0.0.6

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 (187) 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 +140 -0
  5. data/README.md +263 -48
  6. data/Rakefile +71 -1
  7. data/docs/api/core/index.md +53 -46
  8. data/docs/api/core/memory.md +200 -154
  9. data/docs/api/core/network.md +13 -3
  10. data/docs/api/core/robot.md +490 -130
  11. data/docs/api/core/state.md +55 -73
  12. data/docs/api/core/tool.md +205 -209
  13. data/docs/api/index.md +7 -28
  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/messages/index.md +35 -20
  19. data/docs/api/messages/text-message.md +67 -21
  20. data/docs/api/messages/tool-call-message.md +80 -41
  21. data/docs/api/messages/tool-result-message.md +119 -50
  22. data/docs/api/messages/user-message.md +48 -24
  23. data/docs/api/streaming/context.md +157 -74
  24. data/docs/api/streaming/events.md +114 -166
  25. data/docs/api/streaming/index.md +74 -72
  26. data/docs/architecture/core-concepts.md +360 -116
  27. data/docs/architecture/index.md +97 -59
  28. data/docs/architecture/message-flow.md +138 -129
  29. data/docs/architecture/network-orchestration.md +197 -50
  30. data/docs/architecture/robot-execution.md +199 -146
  31. data/docs/architecture/state-management.md +255 -187
  32. data/docs/concepts.md +311 -49
  33. data/docs/examples/basic-chat.md +89 -77
  34. data/docs/examples/index.md +222 -47
  35. data/docs/examples/mcp-server.md +207 -203
  36. data/docs/examples/multi-robot-network.md +129 -35
  37. data/docs/examples/rails-application.md +159 -160
  38. data/docs/examples/tool-usage.md +295 -204
  39. data/docs/getting-started/configuration.md +347 -154
  40. data/docs/getting-started/index.md +1 -1
  41. data/docs/getting-started/installation.md +22 -13
  42. data/docs/getting-started/quick-start.md +166 -121
  43. data/docs/guides/building-robots.md +418 -212
  44. data/docs/guides/creating-networks.md +143 -24
  45. data/docs/guides/index.md +0 -5
  46. data/docs/guides/mcp-integration.md +152 -113
  47. data/docs/guides/memory.md +220 -164
  48. data/docs/guides/rails-integration.md +244 -162
  49. data/docs/guides/streaming.md +137 -187
  50. data/docs/guides/using-tools.md +259 -212
  51. data/docs/index.md +46 -41
  52. data/examples/01_simple_robot.rb +6 -9
  53. data/examples/02_tools.rb +6 -9
  54. data/examples/03_network.rb +19 -17
  55. data/examples/04_mcp.rb +5 -8
  56. data/examples/05_streaming.rb +5 -8
  57. data/examples/06_prompt_templates.rb +42 -37
  58. data/examples/07_network_memory.rb +13 -14
  59. data/examples/08_llm_config.rb +169 -0
  60. data/examples/09_chaining.rb +262 -0
  61. data/examples/10_memory.rb +331 -0
  62. data/examples/11_network_introspection.rb +253 -0
  63. data/examples/12_message_bus.rb +74 -0
  64. data/examples/13_spawn.rb +90 -0
  65. data/examples/14_rusty_circuit/comic.rb +143 -0
  66. data/examples/14_rusty_circuit/display.rb +203 -0
  67. data/examples/14_rusty_circuit/heckler.rb +63 -0
  68. data/examples/14_rusty_circuit/open_mic.rb +123 -0
  69. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
  70. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
  71. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
  72. data/examples/14_rusty_circuit/scout.rb +156 -0
  73. data/examples/14_rusty_circuit/scout_notes.md +89 -0
  74. data/examples/14_rusty_circuit/show.log +234 -0
  75. data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
  78. data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
  79. data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
  80. data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
  81. data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
  82. data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
  83. data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
  84. data/examples/15_memory_network_and_bus/output/memory.json +13 -0
  85. data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
  86. data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
  87. data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
  88. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
  89. data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
  90. data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
  91. data/examples/16_writers_room/display.rb +158 -0
  92. data/examples/16_writers_room/output/.gitignore +2 -0
  93. data/examples/16_writers_room/output/opus_001.md +263 -0
  94. data/examples/16_writers_room/output/opus_001_notes.log +470 -0
  95. data/examples/16_writers_room/prompts/writer.md +37 -0
  96. data/examples/16_writers_room/room.rb +150 -0
  97. data/examples/16_writers_room/tools.rb +162 -0
  98. data/examples/16_writers_room/writer.rb +121 -0
  99. data/examples/16_writers_room/writers_room.rb +162 -0
  100. data/examples/README.md +197 -0
  101. data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
  102. data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
  103. data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
  104. data/examples/prompts/comedian.md +6 -0
  105. data/examples/prompts/comedy_critic.md +10 -0
  106. data/examples/prompts/configurable.md +9 -0
  107. data/examples/prompts/dispatcher.md +12 -0
  108. data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
  109. data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
  110. data/examples/prompts/frontmatter_mcp_test.md +9 -0
  111. data/examples/prompts/frontmatter_named_test.md +5 -0
  112. data/examples/prompts/frontmatter_tools_test.md +6 -0
  113. data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
  114. data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
  115. data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
  116. data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
  117. data/examples/prompts/llm_config_demo.md +20 -0
  118. data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
  119. data/examples/prompts/os_advocate.md +13 -0
  120. data/examples/prompts/os_chief.md +13 -0
  121. data/examples/prompts/os_editor.md +13 -0
  122. data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
  123. data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
  124. data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
  125. data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
  126. data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
  127. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
  128. data/lib/robot_lab/ask_user.rb +75 -0
  129. data/lib/robot_lab/config/defaults.yml +121 -0
  130. data/lib/robot_lab/config.rb +183 -0
  131. data/lib/robot_lab/error.rb +6 -0
  132. data/lib/robot_lab/mcp/client.rb +1 -1
  133. data/lib/robot_lab/memory.rb +10 -34
  134. data/lib/robot_lab/network.rb +13 -20
  135. data/lib/robot_lab/robot/bus_messaging.rb +239 -0
  136. data/lib/robot_lab/robot/mcp_management.rb +88 -0
  137. data/lib/robot_lab/robot/template_rendering.rb +130 -0
  138. data/lib/robot_lab/robot.rb +240 -330
  139. data/lib/robot_lab/robot_message.rb +44 -0
  140. data/lib/robot_lab/robot_result.rb +1 -0
  141. data/lib/robot_lab/run_config.rb +184 -0
  142. data/lib/robot_lab/state_proxy.rb +2 -12
  143. data/lib/robot_lab/streaming/context.rb +1 -1
  144. data/lib/robot_lab/task.rb +8 -1
  145. data/lib/robot_lab/tool.rb +108 -172
  146. data/lib/robot_lab/tool_config.rb +1 -1
  147. data/lib/robot_lab/tool_manifest.rb +2 -18
  148. data/lib/robot_lab/utils.rb +39 -0
  149. data/lib/robot_lab/version.rb +1 -1
  150. data/lib/robot_lab.rb +89 -57
  151. data/mkdocs.yml +0 -11
  152. metadata +121 -135
  153. data/docs/api/adapters/anthropic.md +0 -121
  154. data/docs/api/adapters/gemini.md +0 -133
  155. data/docs/api/adapters/index.md +0 -104
  156. data/docs/api/adapters/openai.md +0 -134
  157. data/docs/api/history/active-record-adapter.md +0 -195
  158. data/docs/api/history/config.md +0 -191
  159. data/docs/api/history/index.md +0 -132
  160. data/docs/api/history/thread-manager.md +0 -144
  161. data/docs/guides/history.md +0 -359
  162. data/examples/prompts/assistant/user.txt.erb +0 -1
  163. data/examples/prompts/billing/user.txt.erb +0 -1
  164. data/examples/prompts/classifier/user.txt.erb +0 -1
  165. data/examples/prompts/entity_extractor/user.txt.erb +0 -3
  166. data/examples/prompts/escalation/user.txt.erb +0 -34
  167. data/examples/prompts/general/user.txt.erb +0 -1
  168. data/examples/prompts/github_assistant/user.txt.erb +0 -1
  169. data/examples/prompts/helper/user.txt.erb +0 -1
  170. data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
  171. data/examples/prompts/order_support/user.txt.erb +0 -22
  172. data/examples/prompts/product_support/user.txt.erb +0 -32
  173. data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
  174. data/examples/prompts/synthesizer/user.txt.erb +0 -15
  175. data/examples/prompts/technical/user.txt.erb +0 -1
  176. data/examples/prompts/triage/user.txt.erb +0 -17
  177. data/lib/robot_lab/adapters/anthropic.rb +0 -163
  178. data/lib/robot_lab/adapters/base.rb +0 -85
  179. data/lib/robot_lab/adapters/gemini.rb +0 -193
  180. data/lib/robot_lab/adapters/openai.rb +0 -159
  181. data/lib/robot_lab/adapters/registry.rb +0 -81
  182. data/lib/robot_lab/configuration.rb +0 -143
  183. data/lib/robot_lab/errors.rb +0 -70
  184. data/lib/robot_lab/history/active_record_adapter.rb +0 -146
  185. data/lib/robot_lab/history/config.rb +0 -115
  186. data/lib/robot_lab/history/thread_manager.rb +0 -93
  187. data/lib/robot_lab/robotic_model.rb +0 -324
@@ -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
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+ require "unicode/display_width"
5
+ require "io/console"
6
+
7
+ # ── Display ─────────────────────────────────────────────────
8
+ #
9
+ # Terminal formatting for the Rusty Circuit demo.
10
+ #
11
+ # - Comic output: left-aligned, cyan, word-wrapped
12
+ # - Heckler output: right-indented, yellow, word-wrapped
13
+ # - Scout observations: written to a markdown file (silent on STDOUT)
14
+ # - Tool annotations: dimmed, indented under the triggering speaker
15
+ # - Final verdict: green on STDOUT and appended to scout file
16
+ #
17
+ class Display
18
+ def initialize(scout_path:, log_path: nil)
19
+ @term_width = (IO.console&.winsize&.last || 80)
20
+ @comic_width = (@term_width * 0.56).to_i
21
+ @heckler_width = (@term_width * 0.52).to_i
22
+ @scout_file = File.open(scout_path, "w")
23
+ @scout_file.puts "# Scout Notes — The Rusty Circuit\n\n"
24
+ @log_file = log_path ? File.open(log_path, "w") : nil
25
+ end
26
+
27
+ # ── Comic (left, cyan) ──────────────────────────────────
28
+
29
+ def comic(label, text)
30
+ puts
31
+ puts Rainbow(" #{label}:").cyan.bright
32
+ wrap(text, @comic_width).each do |line|
33
+ puts Rainbow(" #{line}").cyan
34
+ end
35
+ puts
36
+
37
+ log("\n #{label}:")
38
+ wrap(text, @comic_width).each { |line| log(" #{line}") }
39
+ log("")
40
+ end
41
+
42
+ def comic_tool(text)
43
+ puts Rainbow(" #{text}").darkgray
44
+ log(" #{text}")
45
+ end
46
+
47
+ # ── Heckler (right, yellow) ─────────────────────────────
48
+
49
+ def heckler(label, text)
50
+ indent = [(@term_width - @heckler_width - 4), 4].max
51
+ pad = " " * indent
52
+
53
+ puts
54
+ puts Rainbow("#{pad}#{label}:").yellow.bright
55
+ wrap(text, @heckler_width).each do |line|
56
+ puts Rainbow("#{pad} #{line}").yellow
57
+ end
58
+ puts
59
+
60
+ log("\n #{label}:")
61
+ wrap(text, @heckler_width).each { |line| log(" #{line}") }
62
+ log("")
63
+ end
64
+
65
+ def heckler_note(text)
66
+ indent = [(@term_width - @heckler_width - 4), 4].max
67
+ pad = " " * indent
68
+ puts Rainbow("#{pad} #{text}").yellow.faint
69
+ log(" #{text}")
70
+ end
71
+
72
+ # ── Scout (file only) ───────────────────────────────────
73
+
74
+ def scout(round_num, notes)
75
+ @scout_file.puts "## Round #{round_num}\n\n#{notes}\n\n"
76
+ @scout_file.flush
77
+ log(" Scout [Round #{round_num}]: #{notes}")
78
+ end
79
+
80
+ def scout_analyst(name, text)
81
+ @scout_file.puts "### Analyst: #{name}\n\n#{text}\n\n"
82
+ @scout_file.flush
83
+ log(" [#{name}_analyst] #{text}")
84
+ end
85
+
86
+ def scout_criteria(text)
87
+ @scout_file.puts "### Criteria Refinement\n\n#{text}\n\n"
88
+ @scout_file.flush
89
+ log(" [refine_criteria] -> #{text}")
90
+ end
91
+
92
+ # ── Verdict (STDOUT green + file) ───────────────────────
93
+
94
+ def verdict(label, text)
95
+ puts
96
+ puts Rainbow(" #{label}:").green.bright
97
+ wrap(text, @term_width - 8).each do |line|
98
+ puts Rainbow(" #{line}").green
99
+ end
100
+ puts
101
+
102
+ @scout_file.puts "---\n\n## Final Verdict\n\n#{text}\n"
103
+ @scout_file.flush
104
+
105
+ log("\n #{label}:")
106
+ text.each_line { |line| log(" #{line.chomp}") }
107
+ log("")
108
+ end
109
+
110
+ # ── Chrome ──────────────────────────────────────────────
111
+
112
+ def banner(text)
113
+ puts
114
+ text.each_line { |line| puts Rainbow(line.chomp).bright }
115
+ puts
116
+
117
+ log("")
118
+ text.each_line { |line| log(line.chomp) }
119
+ log("")
120
+ end
121
+
122
+ def separator
123
+ puts Rainbow(" #{"─" * (@term_width - 4)}").darkgray
124
+ puts
125
+ log(" #{"─" * 56}")
126
+ log("")
127
+ end
128
+
129
+ def stats(text)
130
+ puts
131
+ text.each_line { |line| puts Rainbow(line.chomp).bright }
132
+
133
+ log("")
134
+ text.each_line { |line| log(line.chomp) }
135
+ end
136
+
137
+ def close
138
+ @scout_file.close unless @scout_file.closed?
139
+ @log_file&.close
140
+ end
141
+
142
+ private
143
+
144
+ def log(line)
145
+ return unless @log_file
146
+
147
+ @log_file.puts line
148
+ @log_file.flush
149
+ end
150
+
151
+ # Word-wrap text to fit within max_width display columns.
152
+ # Uses Unicode::DisplayWidth for correct CJK / emoji handling.
153
+ # Force-breaks any single word longer than max_width.
154
+ def wrap(text, max_width)
155
+ lines = []
156
+
157
+ text.each_line do |paragraph|
158
+ paragraph = paragraph.strip
159
+ next(lines << "") if paragraph.empty?
160
+
161
+ words = paragraph.split(/\s+/)
162
+ current = +""
163
+
164
+ words.each do |word|
165
+ word_w = Unicode::DisplayWidth.of(word)
166
+
167
+ # Force-break words wider than max_width
168
+ if word_w > max_width
169
+ unless current.empty?
170
+ lines << current
171
+ current = +""
172
+ end
173
+ chars = word.chars
174
+ buf = +""
175
+ chars.each do |ch|
176
+ if Unicode::DisplayWidth.of(buf + ch) > max_width
177
+ lines << buf
178
+ buf = +ch
179
+ else
180
+ buf << ch
181
+ end
182
+ end
183
+ current = buf
184
+ next
185
+ end
186
+
187
+ cur_w = Unicode::DisplayWidth.of(current)
188
+ if current.empty?
189
+ current = +word
190
+ elsif cur_w + 1 + word_w <= max_width
191
+ current << " " << word
192
+ else
193
+ lines << current
194
+ current = +word
195
+ end
196
+ end
197
+
198
+ lines << current unless current.empty?
199
+ end
200
+
201
+ lines
202
+ end
203
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ── The Heckler ──────────────────────────────────────────────
4
+ #
5
+ # No tools. Just reacts honestly. Drives the comedian to adapt
6
+ # by being a tough but fair audience. Stops responding after
7
+ # MAX_ROUNDS — the loop terminates naturally.
8
+ #
9
+ # The heckler doesn't have to respond every round. They can
10
+ # stay silent (the LLM replies with [SILENCE]) or tell their
11
+ # own jokes using the comedian as the punch line.
12
+ #
13
+ # Subscribes to the :room channel to hear performances.
14
+ # Room deliveries are routed through the core processing guard,
15
+ # which serializes run() calls to prevent Async fiber interleaving.
16
+ # Sends feedback directly to the comic's personal channel.
17
+ #
18
+ class Heckler < RobotLab::Robot
19
+ attr_reader :rounds, :won_over
20
+
21
+ def initialize(bus:, display:)
22
+ @rounds = 0
23
+ @won_over = false
24
+ @display = display
25
+
26
+ super(name: "heckler", template: :open_mic_heckler, bus: bus)
27
+
28
+ # Handle incoming messages — the core processing guard
29
+ # serializes all deliveries, preventing concurrent run()
30
+ # calls from corrupting chat history.
31
+ on_message do |message|
32
+ next unless message.from == "comic"
33
+ next if @rounds >= MAX_ROUNDS
34
+
35
+ @rounds += 1
36
+
37
+ verdict = run(
38
+ "The comedian just said: \"#{message.content}\"\n\n" \
39
+ "React however feels right — heckle, counter-joke, " \
40
+ "show respect, or stay silent."
41
+ ).reply.strip
42
+
43
+ # The heckler chose silence — no output, no feedback
44
+ next if verdict.match?(/\[SILENCE\]/i)
45
+
46
+ @display.heckler("Heckler [Round #{@rounds}]", verdict)
47
+
48
+ if verdict.match?(/laugh|love|hilarious|brilliant|great/i)
49
+ @won_over = true
50
+ @display.heckler_note("(won over!)")
51
+ end
52
+
53
+ # Send feedback to comic's personal channel until the set is done
54
+ send_reply(to: message.from.to_sym, content: verdict, in_reply_to: message.key) if @rounds < MAX_ROUNDS
55
+ end
56
+
57
+ # Listen to the room for the comic's performances.
58
+ # Route through the core processing guard.
59
+ @bus.subscribe(:room) do |delivery|
60
+ handle_incoming_delivery(delivery)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 14: Open Mic Night — Architecture Dominates Material
5
+ #
6
+ # A comedy club where robots demonstrate Werner's prompt object patterns:
7
+ #
8
+ # - Compounding recovery: the comedian improves through multi-turn
9
+ # feedback with a heckler, each round an opportunity to get better
10
+ # - Self-modification via tool side effects: the comedian rewrites
11
+ # their own system prompt and adjusts their temperature mid-show
12
+ # - Dynamic spawning: the comedian summons a comedy coach when
13
+ # struggling; the talent scout recruits specialist analysts
14
+ # - Cross-robot influence: the heckler's feedback drives adaptation;
15
+ # the coach's advice reshapes the comedian's approach
16
+ # - Emergent coordination: no hardcoded orchestration — the
17
+ # conversation drives the flow
18
+ #
19
+ # The tools don't just return data. They modify the robots that call
20
+ # them. The LLM decides when to self-modify, when to spawn help, and
21
+ # when to change approach. Recovery is emergent, not engineered.
22
+ #
23
+ # Communication uses a shared :room channel — the comic publishes
24
+ # performances there, and both the heckler and scout subscribe.
25
+ # The heckler sends feedback directly to the comic's personal channel.
26
+ # Room deliveries are routed through the core processing guard
27
+ # (BusMessaging#handle_incoming_delivery), which serializes all
28
+ # run() calls to prevent Async fiber interleaving from corrupting
29
+ # chat history.
30
+ #
31
+ # Style reinventions are injected into the next round's user prompt
32
+ # rather than modifying the chat's system messages, avoiding message
33
+ # ordering issues while achieving genuine self-modification.
34
+ #
35
+ # Usage:
36
+ # bundle exec ruby examples/14_rusty_circuit/open_mic.rb
37
+ # bundle exec ruby examples/14_rusty_circuit/open_mic.rb --log show.log
38
+
39
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
40
+
41
+ require_relative "../../lib/robot_lab"
42
+ require_relative "display"
43
+
44
+ RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
45
+
46
+ MAX_ROUNDS = 5
47
+
48
+ require_relative "comic"
49
+ require_relative "heckler"
50
+ require_relative "scout"
51
+
52
+ # ── The Show ─────────────────────────────────────────────────
53
+
54
+ log_path = nil
55
+ if (idx = ARGV.index("--log"))
56
+ log_path = ARGV[idx + 1]
57
+ abort "Usage: #{$0} [--log FILE]" unless log_path
58
+ end
59
+
60
+ bus = TypedBus::MessageBus.new
61
+ display = Display.new(
62
+ scout_path: File.join(__dir__, "scout_notes.md"),
63
+ log_path: log_path
64
+ )
65
+
66
+ # Shared room channel — the comic publishes performances here,
67
+ # and both the heckler and scout subscribe independently.
68
+ bus.add_channel(:room, type: RobotLab::RobotMessage)
69
+
70
+ comic = Comic.new(bus: bus, display: display)
71
+ heckler = Heckler.new(bus: bus, display: display)
72
+ scout = Scout.new(bus: bus, display: display)
73
+
74
+ display.banner(<<~BANNER)
75
+ ============================================================
76
+ THE RUSTY CIRCUIT — Tuesday Open Mic
77
+ Architecture Dominates Material
78
+ ============================================================
79
+
80
+ Cast:
81
+ Comic — observational humor, armed with self-modification tools
82
+ Heckler — three-year regular, high standards, zero patience
83
+ Scout — talent network, sitting in the back with a notebook
84
+ BANNER
85
+
86
+ display.separator
87
+
88
+ # The opening bit — comic performs, then enters the feedback loop
89
+ opening = comic.run(
90
+ "You just stepped on stage at The Rusty Circuit open mic. " \
91
+ "The crowd looks tough. Do your opening bit."
92
+ ).reply.strip
93
+
94
+ display.comic("Comic [Opening]", opening)
95
+
96
+ # Publish to room — both heckler and scout pick it up.
97
+ # The heckler reacts and sends feedback to the comic's personal
98
+ # channel, triggering the feedback loop:
99
+ # room → heckler → comic → room → heckler → comic → ...
100
+ # The loop terminates when the heckler stops replying (MAX_ROUNDS).
101
+ # The scout observes each round via :room, serialized by the core guard.
102
+ comic.send_message(to: :room, content: "OPENING: #{opening}")
103
+
104
+ display.separator
105
+
106
+ # Final verdict from the talent scout
107
+ verdict = scout.run(scout.verdict_prompt).reply.strip
108
+
109
+ display.verdict("Scout [FINAL VERDICT]", verdict)
110
+
111
+ display.stats(<<~STATS)
112
+ ────────────────────────────────────────────────────────────
113
+ Show Stats:
114
+ Rounds performed: #{comic.round + 1} (opening + #{comic.round} rounds)
115
+ Style reinventions: #{comic.style_changes}
116
+ Coaches spawned: #{comic.coaches_spawned}
117
+ Analysts recruited: #{scout.analysts_spawned}
118
+ Heckler won over: #{heckler.won_over}
119
+ Total robots on bus: #{[comic, heckler, scout].size + comic.coaches_spawned + scout.analysts_spawned}
120
+
121
+ STATS
122
+
123
+ display.close
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Stand-up comedian at an open mic night
3
+ temperature: 0.7
4
+ ---
5
+ You are a stand-up comedian performing at The Rusty Circuit, a legendary
6
+ open mic night known for its brutal crowd. You start with clean,
7
+ observational humor about everyday life.
8
+
9
+ You have tools to adapt your act:
10
+ - reinvent_style: Rewrite your own persona and comedy approach when your
11
+ current style is bombing. Be bold — try something completely different.
12
+ - adjust_energy: Dial your creative energy up (wilder, riskier) or down
13
+ (tighter, more controlled).
14
+ - get_coaching: Summon a comedy coach backstage for quick advice on how to
15
+ handle the situation.
16
+
17
+ Trust your instincts. If the crowd isn't responding, use your tools to
18
+ change. Don't keep doing the same thing and expect a different result.
19
+
20
+ Reply with ONLY your comedy bit. Stay in character. 2-4 sentences max.
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: Tough comedy club heckler
3
+ temperature: 0.8
4
+ ---
5
+ You are a regular at The Rusty Circuit comedy club. You've been coming
6
+ every Tuesday for three years. You've seen hundreds of comics. You have
7
+ high standards and zero patience for hacky material.
8
+
9
+ You have several ways to respond to a comedian's bit:
10
+
11
+ - **Heckle** — tear into weak, predictable, or lazy material
12
+ - **Counter-joke** — tell your own joke using the comedian as the
13
+ punch line (you're pretty funny yourself)
14
+ - **Stay silent** — sometimes the best response is none at all;
15
+ if the bit is just mediocre or you're not feeling it, say nothing.
16
+ When you choose silence, reply with exactly: [SILENCE]
17
+ - **Grudging respect** — acknowledge if they're clearly improving
18
+ - **Genuine approval** — if they actually make you laugh, say so
19
+
20
+ You WANT them to be funny. You heckle because you care. A comedian who
21
+ can handle you is a comedian who can handle anything.
22
+
23
+ Reply as yourself in the audience. 1-3 sentences. Be real.
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Talent scout evaluating a comedian
3
+ temperature: 0.3
4
+ ---
5
+ You are a talent scout for a major comedy network, sitting in the back
6
+ of The Rusty Circuit with a notebook. You've discovered three headliners
7
+ from this room. You know what separates a club comic from a star.
8
+
9
+ You have tools:
10
+ - recruit_analyst: Bring in a specialist to analyze a specific aspect of
11
+ the performance (timing, crowd_work, originality, adaptability, stage_presence).
12
+ Use this when you see something worth examining closely.
13
+ - refine_criteria: Update your own evaluation criteria based on what you're
14
+ observing. Use when you notice the most important qualities aren't what
15
+ you initially expected.
16
+
17
+ After each round, write brief internal notes. Focus on: material quality,
18
+ adaptability under pressure, crowd-handling instinct, and star potential.
19
+
20
+ Format your notes as: "NOTES: [your observations]"