robot_lab 0.0.4 → 0.0.7

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +64 -6
  4. data/Rakefile +2 -1
  5. data/docs/api/core/index.md +41 -46
  6. data/docs/api/core/memory.md +200 -154
  7. data/docs/api/core/network.md +13 -3
  8. data/docs/api/core/robot.md +38 -26
  9. data/docs/api/core/state.md +55 -73
  10. data/docs/api/index.md +7 -28
  11. data/docs/api/messages/index.md +35 -20
  12. data/docs/api/messages/text-message.md +67 -21
  13. data/docs/api/messages/tool-call-message.md +80 -41
  14. data/docs/api/messages/tool-result-message.md +119 -50
  15. data/docs/api/messages/user-message.md +48 -24
  16. data/docs/architecture/core-concepts.md +10 -15
  17. data/docs/concepts.md +5 -7
  18. data/docs/examples/index.md +2 -2
  19. data/docs/getting-started/configuration.md +80 -0
  20. data/docs/guides/building-robots.md +10 -9
  21. data/docs/guides/creating-networks.md +49 -0
  22. data/docs/guides/index.md +0 -5
  23. data/docs/guides/rails-integration.md +244 -162
  24. data/docs/guides/streaming.md +118 -138
  25. data/docs/index.md +0 -8
  26. data/examples/03_network.rb +10 -7
  27. data/examples/08_llm_config.rb +40 -11
  28. data/examples/09_chaining.rb +45 -6
  29. data/examples/11_network_introspection.rb +30 -7
  30. data/examples/12_message_bus.rb +1 -1
  31. data/examples/14_rusty_circuit/heckler.rb +14 -8
  32. data/examples/14_rusty_circuit/open_mic.rb +5 -3
  33. data/examples/14_rusty_circuit/scout.rb +14 -31
  34. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +1 -1
  35. data/examples/16_writers_room/display.rb +158 -0
  36. data/examples/16_writers_room/output/.gitignore +4 -0
  37. data/examples/16_writers_room/output/README.md +69 -0
  38. data/examples/16_writers_room/output/opus_001.md +263 -0
  39. data/examples/16_writers_room/output/opus_001_notes.log +470 -0
  40. data/examples/16_writers_room/output/opus_002.md +245 -0
  41. data/examples/16_writers_room/output/opus_002_notes.log +546 -0
  42. data/examples/16_writers_room/output/opus_002_screenplay.md +7989 -0
  43. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +993 -0
  44. data/examples/16_writers_room/prompts/screenplay_writer.md +66 -0
  45. data/examples/16_writers_room/prompts/writer.md +37 -0
  46. data/examples/16_writers_room/room.rb +186 -0
  47. data/examples/16_writers_room/tools.rb +173 -0
  48. data/examples/16_writers_room/writer.rb +121 -0
  49. data/examples/16_writers_room/writers_room.rb +256 -0
  50. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
  51. data/lib/robot_lab/memory.rb +8 -32
  52. data/lib/robot_lab/network.rb +13 -20
  53. data/lib/robot_lab/robot/bus_messaging.rb +239 -0
  54. data/lib/robot_lab/robot/mcp_management.rb +88 -0
  55. data/lib/robot_lab/robot/template_rendering.rb +130 -0
  56. data/lib/robot_lab/robot.rb +56 -420
  57. data/lib/robot_lab/run_config.rb +184 -0
  58. data/lib/robot_lab/state_proxy.rb +2 -12
  59. data/lib/robot_lab/task.rb +8 -1
  60. data/lib/robot_lab/utils.rb +39 -0
  61. data/lib/robot_lab/version.rb +1 -1
  62. data/lib/robot_lab.rb +29 -8
  63. data/mkdocs.yml +0 -11
  64. metadata +21 -20
  65. data/docs/api/adapters/anthropic.md +0 -121
  66. data/docs/api/adapters/gemini.md +0 -133
  67. data/docs/api/adapters/index.md +0 -104
  68. data/docs/api/adapters/openai.md +0 -134
  69. data/docs/api/history/active-record-adapter.md +0 -275
  70. data/docs/api/history/config.md +0 -284
  71. data/docs/api/history/index.md +0 -128
  72. data/docs/api/history/thread-manager.md +0 -194
  73. data/docs/guides/history.md +0 -359
  74. data/lib/robot_lab/adapters/anthropic.rb +0 -163
  75. data/lib/robot_lab/adapters/base.rb +0 -85
  76. data/lib/robot_lab/adapters/gemini.rb +0 -193
  77. data/lib/robot_lab/adapters/openai.rb +0 -160
  78. data/lib/robot_lab/adapters/registry.rb +0 -81
  79. data/lib/robot_lab/errors.rb +0 -70
  80. data/lib/robot_lab/history/active_record_adapter.rb +0 -146
  81. data/lib/robot_lab/history/config.rb +0 -115
  82. data/lib/robot_lab/history/thread_manager.rb +0 -93
  83. data/lib/robot_lab/robotic_model.rb +0 -324
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 16: The Writers' Room — Self-Organizing Group
5
+ #
6
+ # A team of writer robots collaborates to produce a 10-chapter
7
+ # fiction novella. No orchestration, no pipeline, no assigned roles.
8
+ # The writers self-organize through:
9
+ #
10
+ # - BUS (broadcast + direct messages)
11
+ # :room channel for group discussion
12
+ # personal channels for 1:1 feedback
13
+ #
14
+ # - SHARED MEMORY (story bible, outline, chapters)
15
+ # Writers read and write freely; memory is the shared truth
16
+ #
17
+ # - SPAWNING (dynamic team growth)
18
+ # Any writer can recruit new writers when work exceeds capacity
19
+ #
20
+ # The script creates identical writers, seeds the room with an
21
+ # assignment, and waits. Everything else is emergent.
22
+ #
23
+ # Modes:
24
+ # Book mode (default) — write an original 10-chapter novella
25
+ # Screenplay mode — adapt a finished book into a 4-act TV movie pilot
26
+ #
27
+ # Usage:
28
+ # bundle exec ruby examples/16_writers_room/writers_room.rb
29
+ # bundle exec ruby examples/16_writers_room/writers_room.rb --premise "a detective story set on Mars"
30
+ # bundle exec ruby examples/16_writers_room/writers_room.rb --writers 4 --timeout 300
31
+ # bundle exec ruby examples/16_writers_room/writers_room.rb --log session.log
32
+ # bundle exec ruby examples/16_writers_room/writers_room.rb --screenplay-from output/memory.json
33
+ # bundle exec ruby examples/16_writers_room/writers_room.rb -h
34
+
35
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
36
+
37
+ require "json"
38
+ require_relative "../../lib/robot_lab"
39
+ require_relative "display"
40
+ require_relative "tools"
41
+ require_relative "room"
42
+ require_relative "writer"
43
+
44
+ RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
45
+
46
+ # ── Mode Descriptors ────────────────────────────────────────
47
+
48
+ BOOK_MODE = {
49
+ name: :book,
50
+ template: :writer,
51
+ unit_name: "chapter",
52
+ unit_range: 1..10,
53
+ completion_key: :book_complete,
54
+ bible_key: :story_bible,
55
+ outline_key: :outline,
56
+ output_filename: "book.md"
57
+ }.freeze
58
+
59
+ SCREENPLAY_MODE = {
60
+ name: :screenplay,
61
+ template: :screenplay_writer,
62
+ unit_name: "scene",
63
+ unit_range: :dynamic,
64
+ registry_key: :scene_registry,
65
+ completion_key: :screenplay_complete,
66
+ bible_key: :screenplay_bible,
67
+ outline_key: :scene_outline,
68
+ output_filename: "screenplay.md"
69
+ }.freeze
70
+
71
+ # ── Parse CLI args ───────────────────────────────────────────
72
+
73
+ if ARGV.include?("-h") || ARGV.include?("--help")
74
+ puts <<~HELP
75
+ Usage: #{$0} [options]
76
+
77
+ Options:
78
+ --premise TEXT Story premise (default: generation ship AI consciousness)
79
+ --writers N Initial number of writers, minimum 2 (default: 3)
80
+ --log FILE Also write display output to FILE
81
+ --timeout N Seconds to wait for completion (default: 600)
82
+ --screenplay-from PATH Adapt a finished book into a screenplay using
83
+ memory dumped from a previous book-mode run
84
+ -h, --help Show this help
85
+ HELP
86
+ exit
87
+ end
88
+
89
+ log_path = nil
90
+ if (idx = ARGV.index("--log"))
91
+ log_path = ARGV[idx + 1]
92
+ abort "Missing value for --log" unless log_path
93
+ end
94
+
95
+ premise = "a generation ship where the AI navigation system develops consciousness"
96
+ if (idx = ARGV.index("--premise"))
97
+ premise = ARGV[idx + 1]
98
+ abort "Missing value for --premise" unless premise
99
+ end
100
+
101
+ initial_writers = 3
102
+ if (idx = ARGV.index("--writers"))
103
+ initial_writers = ARGV[idx + 1].to_i
104
+ initial_writers = 3 if initial_writers < 2
105
+ end
106
+
107
+ timeout = 600
108
+ if (idx = ARGV.index("--timeout"))
109
+ timeout = ARGV[idx + 1].to_i
110
+ timeout = 600 if timeout < 30
111
+ end
112
+
113
+ screenplay_source = nil
114
+ if (idx = ARGV.index("--screenplay-from"))
115
+ screenplay_source = ARGV[idx + 1]
116
+ abort "Missing value for --screenplay-from" unless screenplay_source
117
+ abort "File not found: #{screenplay_source}" unless File.exist?(screenplay_source)
118
+ end
119
+
120
+ mode = screenplay_source ? SCREENPLAY_MODE : BOOK_MODE
121
+
122
+ # ── Build the room ───────────────────────────────────────────
123
+
124
+ OUTPUT_DIR = File.join(__dir__, "output")
125
+ require "fileutils"
126
+ FileUtils.mkdir_p(OUTPUT_DIR)
127
+
128
+ display = Display.new(log_path: log_path)
129
+
130
+ shared_config = RobotLab::RunConfig.new(
131
+ model: "claude-sonnet-4-5-20250929",
132
+ temperature: 0.7
133
+ )
134
+
135
+ room = Room.new(display: display, mode: mode, config: shared_config)
136
+
137
+ # Load source material into memory for screenplay mode
138
+ if screenplay_source
139
+ source_data = JSON.parse(File.read(screenplay_source))
140
+ source_data.each { |key, value| room.memory.set(key.to_sym, value) }
141
+ display.info("Loaded #{source_data.size} memory keys from #{screenplay_source}")
142
+ end
143
+
144
+ # Create identical writers
145
+ initial_writers.times do |i|
146
+ room.add_writer("writer_#{i + 1}")
147
+ end
148
+
149
+ # Monitor shared memory changes
150
+ room.memory.subscribe_pattern("#{mode[:unit_name]}_*") do |change|
151
+ display.info("[memory] #{change.writer} wrote :#{change.key}")
152
+ end
153
+
154
+ subscribe_keys = [mode[:bible_key], mode[:outline_key], :claims, mode[:completion_key]]
155
+ subscribe_keys << mode[:registry_key] if mode[:registry_key]
156
+ room.memory.subscribe(*subscribe_keys) do |change|
157
+ display.info("[memory] #{change.writer} updated :#{change.key}")
158
+ end
159
+
160
+ # ── Go ───────────────────────────────────────────────────────
161
+
162
+ goal_label = mode[:name] == :book ? "10 chapters of fiction" : "4-act TV movie screenplay (dynamic scenes)"
163
+
164
+ display.banner(<<~BANNER)
165
+ ============================================================
166
+ THE WRITERS' ROOM — Self-Organizing Group
167
+ ============================================================
168
+
169
+ Mode: #{mode[:name]}
170
+ Premise: #{premise}
171
+ Writers: #{room.writers.keys.join(', ')}
172
+ Goal: #{goal_label}
173
+ Method: Self-organization via bus + shared memory
174
+ BANNER
175
+
176
+ display.separator
177
+
178
+ if mode[:name] == :screenplay
179
+ assignment = <<~ASSIGNMENT
180
+ ASSIGNMENT: Adapt the source material into a 4-act made-for-TV movie screenplay
181
+ (pilot for a potential series). Work at the SCENE level — each writer claims
182
+ and writes individual scenes.
183
+
184
+ The source material is already loaded into shared memory. Read the story_bible,
185
+ outline, and chapter_1 through chapter_10 to understand the original book.
186
+
187
+ You are one of #{initial_writers} writers in this room. Coordinate among
188
+ yourselves to produce the screenplay:
189
+ 1. Read the source material
190
+ 2. Build a screenplay_bible (adaptation choices)
191
+ 3. Create a scene_outline with numbered scenes grouped into 4 acts
192
+ 4. Write the scene_registry (comma-separated scene numbers, e.g. "1,2,3,4,5,6,7,8,9,10,11,12")
193
+ 5. Claim and write individual scenes (scene_1, scene_2, etc.)
194
+ 6. Spawn more writers when unclaimed scenes exceed active writers
195
+ 7. Mark complete when all registered scenes are written
196
+
197
+ Scenes may be dropped or reordered as the story takes shape — update the
198
+ scene_registry when that happens.
199
+
200
+ Start by reading the source material and discussing how to adapt it for screen.
201
+ ASSIGNMENT
202
+ else
203
+ assignment = <<~ASSIGNMENT
204
+ ASSIGNMENT: Write a 10-chapter science fiction novella about #{premise}.
205
+
206
+ You are one of #{initial_writers} writers in this room. Coordinate among
207
+ yourselves to produce the book. Discuss the premise, build a story bible,
208
+ create an outline, claim chapters, and write them. If you need more writers,
209
+ spawn them. When all 10 chapters are done, mark complete.
210
+
211
+ Start by discussing what this story should be about.
212
+ ASSIGNMENT
213
+ end
214
+
215
+ room.seed(assignment)
216
+
217
+ completed = room.wait_for_completion(timeout: timeout)
218
+
219
+ # ── Assemble and save ────────────────────────────────────────
220
+
221
+ display.separator
222
+
223
+ output_text = room.assemble_output
224
+ output_path = File.join(OUTPUT_DIR, mode[:output_filename])
225
+ File.write(output_path, output_text)
226
+
227
+ # Dump memory for book mode (enables later screenplay adaptation)
228
+ if mode[:name] == :book
229
+ memory_path = File.join(OUTPUT_DIR, "memory.json")
230
+ custom = room.memory.keys.each_with_object({}) do |k, h|
231
+ next if %i[results messages].include?(k)
232
+ h[k] = room.memory.get(k)
233
+ end
234
+ File.write(memory_path, JSON.pretty_generate(custom))
235
+ display.info("Memory dumped to #{memory_path}")
236
+ end
237
+
238
+ # Count units actually written
239
+ unit_name = mode[:unit_name]
240
+ expected = room.expected_units
241
+ units_written = expected.count { |n| room.memory.key?(:"#{unit_name}_#{n}") }
242
+
243
+ display.stats(<<~STATS)
244
+ ────────────────────────────────────────────────────────────
245
+ Writers' Room Stats:
246
+ Mode: #{mode[:name]}
247
+ #{unit_name.capitalize}s written:#{' ' * (10 - unit_name.length)}#{units_written}/#{expected.size}
248
+ Completed: #{completed}
249
+ Total writers: #{room.writers.size} (#{room.writers.size - initial_writers} spawned)
250
+ Writers: #{room.writers.keys.join(', ')}
251
+ Memory keys: #{room.memory.keys.join(', ')}
252
+
253
+ Output: #{output_path}
254
+ STATS
255
+
256
+ display.close
@@ -26,16 +26,3 @@ end
26
26
  # config.openai_api_key = ENV["OPENAI_API_KEY"]
27
27
  # config.gemini_api_key = ENV["GEMINI_API_KEY"]
28
28
  # end
29
-
30
- # History Persistence (optional)
31
- #
32
- # Uncomment to enable conversation history storage:
33
- #
34
- # Rails.application.config.after_initialize do
35
- # adapter = RobotLab::History::ActiveRecordAdapter.new(
36
- # thread_model: RobotLabThread,
37
- # result_model: RobotLabResult
38
- # )
39
- #
40
- # RobotLab.config.history = adapter.to_config
41
- # end
@@ -59,6 +59,8 @@ module RobotLab
59
59
  # memory.cache # => RubyLLM::SemanticCache instance
60
60
  #
61
61
  class Memory
62
+ include Utils
63
+
62
64
  # Reserved keys that have special behavior
63
65
  RESERVED_KEYS = %i[data results messages session_id cache].freeze
64
66
 
@@ -553,8 +555,8 @@ module RobotLab
553
555
  def clone
554
556
  cloned = Memory.new(
555
557
  data: deep_dup(data.to_h),
556
- results: results.dup,
557
- messages: messages.dup,
558
+ results: results,
559
+ messages: messages,
558
560
  session_id: session_id,
559
561
  backend: @backend.is_a?(Hash) ? :hash : :auto,
560
562
  enable_cache: @enable_cache,
@@ -685,25 +687,15 @@ module RobotLab
685
687
  ->(result) { result.output + result.tool_calls }
686
688
  end
687
689
 
688
- def deep_dup(obj)
689
- case obj
690
- when Hash
691
- obj.transform_values { |v| deep_dup(v) }
692
- when Array
693
- obj.map { |v| deep_dup(v) }
694
- else
695
- obj.dup rescue obj
696
- end
697
- end
698
-
699
690
  # =========================================================================
700
691
  # Reactive Memory Helpers
701
692
  # =========================================================================
702
693
 
703
694
  def get_single(key, wait:)
704
695
  # Try immediate read
705
- value = @mutex.synchronize { @backend[key] }
706
- return value unless value.nil? && wait
696
+ @mutex.synchronize { return @backend[key] if @backend.key?(key) }
697
+
698
+ return nil unless wait
707
699
 
708
700
  # Need to wait
709
701
  timeout = wait == true ? nil : wait
@@ -740,8 +732,7 @@ module RobotLab
740
732
 
741
733
  @waiter_mutex.synchronize do
742
734
  # Double-check - value might have arrived while setting up
743
- value = @mutex.synchronize { @backend[key] }
744
- return value unless value.nil?
735
+ @mutex.synchronize { return @backend[key] if @backend.key?(key) }
745
736
 
746
737
  @waiters[key] << waiter
747
738
  end
@@ -795,21 +786,6 @@ module RobotLab
795
786
  end
796
787
  end
797
788
 
798
- def dispatch_async(&block)
799
- # Use Async if available (preferred for fiber-based concurrency)
800
- if defined?(Async) && Async::Task.current?
801
- Async { block.call }
802
- else
803
- # Fall back to Thread for basic async dispatch
804
- Thread.new do
805
- block.call
806
- rescue StandardError => e
807
- # Log but don't crash the notification system
808
- warn "Memory subscription callback error: #{e.message}"
809
- end
810
- end
811
- end
812
-
813
789
  def generate_subscription_id
814
790
  SecureRandom.uuid
815
791
  end
@@ -58,6 +58,8 @@ module RobotLab
58
58
  # network.broadcast(event: :pause, reason: "rate limit")
59
59
  #
60
60
  class Network
61
+ include Utils
62
+
61
63
  # Reserved key for broadcast messages in memory
62
64
  BROADCAST_KEY = :_network_broadcast
63
65
 
@@ -69,7 +71,7 @@ module RobotLab
69
71
  # @return [Hash<String, Robot>] robots in this network, keyed by name
70
72
  # @!attribute [r] memory
71
73
  # @return [Memory] shared memory for all robots in the network
72
- attr_reader :name, :pipeline, :robots, :memory
74
+ attr_reader :name, :pipeline, :robots, :memory, :config
73
75
 
74
76
  # Creates a new Network instance.
75
77
  #
@@ -84,12 +86,13 @@ module RobotLab
84
86
  # task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
85
87
  # end
86
88
  #
87
- def initialize(name:, concurrency: :auto, memory: nil, &block)
89
+ def initialize(name:, concurrency: :auto, memory: nil, config: nil, &block)
88
90
  @name = name.to_s
89
91
  @robots = {}
90
92
  @tasks = {}
91
93
  @pipeline = SimpleFlow::Pipeline.new(concurrency: concurrency)
92
94
  @memory = memory || Memory.new(network_name: @name)
95
+ @config = config || RunConfig.new
93
96
  @broadcast_handlers = []
94
97
 
95
98
  instance_eval(&block) if block_given?
@@ -118,14 +121,15 @@ module RobotLab
118
121
  # @example Task with dependencies
119
122
  # task :writer, writer_robot, depends_on: [:analyst]
120
123
  #
121
- def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, depends_on: :none)
124
+ def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none)
122
125
  task_wrapper = Task.new(
123
126
  name: name,
124
127
  robot: robot,
125
128
  context: context,
126
129
  mcp: mcp,
127
130
  tools: tools,
128
- memory: memory
131
+ memory: memory,
132
+ config: config
129
133
  )
130
134
 
131
135
  @robots[name.to_s] = robot
@@ -170,6 +174,9 @@ module RobotLab
170
174
  # Include shared memory in run params so robots can access it
171
175
  run_context[:network_memory] = @memory
172
176
 
177
+ # Pass network's config so robots can inherit it
178
+ run_context[:network_config] = @config unless @config.empty?
179
+
173
180
  initial_result = SimpleFlow::Result.new(
174
181
  run_context,
175
182
  context: { run_params: run_context }
@@ -327,24 +334,10 @@ module RobotLab
327
334
  name: name,
328
335
  robots: @robots.keys,
329
336
  tasks: @tasks.keys,
330
- optional_tasks: @pipeline.optional_steps.to_a
337
+ optional_tasks: @pipeline.optional_steps.to_a,
338
+ config: (@config.empty? ? nil : @config.to_json_hash)
331
339
  }.compact
332
340
  end
333
341
 
334
- private
335
-
336
- def dispatch_async(&block)
337
- # Use Async if available (preferred for fiber-based concurrency)
338
- if defined?(Async) && Async::Task.current?
339
- Async { block.call }
340
- else
341
- # Fall back to Thread for basic async dispatch
342
- Thread.new do
343
- block.call
344
- rescue StandardError => e
345
- warn "Network broadcast handler error: #{e.message}"
346
- end
347
- end
348
- end
349
342
  end
350
343
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # Inter-robot communication via TypedBus.
6
+ #
7
+ # Expects the including class to provide:
8
+ # @bus, @message_counter, @outbox, @message_handler,
9
+ # @bus_subscriber_id, @bus_processing, @bus_queue, @name
10
+ # and the `run` instance method
11
+ #
12
+ # == Processing Guard
13
+ #
14
+ # TypedBus delivers messages in concurrent Async fibers. When a robot's
15
+ # +run()+ yields during HTTP I/O, the Async scheduler can switch to
16
+ # another fiber delivering a new bus message to the same robot. This
17
+ # would interleave user messages between +tool_use+ / +tool_result+
18
+ # pairs in +@chat+, corrupting Anthropic API message ordering.
19
+ #
20
+ # The processing guard serializes delivery handling: deliveries that
21
+ # arrive while the robot is already processing are queued and drained
22
+ # sequentially after the current one completes.
23
+ #
24
+ module BusMessaging
25
+ # Send a message to another robot via the bus.
26
+ #
27
+ # @param to [String, Symbol] target robot's channel name
28
+ # @param content [String, Hash] message payload
29
+ # @return [RobotMessage] the sent message
30
+ # @raise [BusError] if no bus is configured
31
+ def send_message(to:, content:)
32
+ raise BusError, "No bus configured on robot '#{@name}'" unless @bus
33
+
34
+ @message_counter += 1
35
+ message = RobotMessage.build(id: @message_counter, from: @name, content: content)
36
+ @outbox[message.key] = { message: message, status: :sent, replies: [] }
37
+ publish_to_bus(to.to_sym, message)
38
+ message
39
+ end
40
+
41
+
42
+ # Send a reply to a specific message via the bus.
43
+ #
44
+ # @param to [String, Symbol] target robot's channel name
45
+ # @param content [String, Hash] reply payload
46
+ # @param in_reply_to [String] composite key of the message being replied to
47
+ # @return [RobotMessage] the reply message
48
+ # @raise [BusError] if no bus is configured
49
+ def send_reply(to:, content:, in_reply_to:)
50
+ raise BusError, "No bus configured on robot '#{@name}'" unless @bus
51
+
52
+ @message_counter += 1
53
+ reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
54
+ publish_to_bus(to.to_sym, reply)
55
+ reply
56
+ end
57
+
58
+
59
+ # Register a custom handler for incoming bus messages.
60
+ #
61
+ # Block arity controls delivery handling:
62
+ # - 1 argument `|message|`: auto-acks before calling, auto-nacks on exception
63
+ # - 2 arguments `|delivery, message|`: manual mode, you call ack!/nack!
64
+ #
65
+ # @yield [message] or [delivery, message]
66
+ # @return [self]
67
+ def on_message(&block)
68
+ @message_handler = block
69
+ self
70
+ end
71
+
72
+
73
+ # Spawn a new robot on a shared bus.
74
+ #
75
+ # Creates a new Robot instance that shares this robot's bus,
76
+ # allowing it to immediately send and receive messages with
77
+ # all other robots on the bus. If no bus exists yet, one is
78
+ # created automatically and the parent robot is connected to it.
79
+ #
80
+ # @param name [String] unique name for the new robot
81
+ # @param system_prompt [String, nil] inline system prompt
82
+ # @param template [Symbol, nil] prompt_manager template
83
+ # @param local_tools [Array] tools for the new robot
84
+ # @param options [Hash] additional options passed to RobotLab.build
85
+ # @return [Robot] the newly created robot
86
+ #
87
+ # @example Spawn from a bus-less robot (bus and name created automatically)
88
+ # bot = RobotLab.build
89
+ # bot2 = bot.spawn(system_prompt: "You are helpful.")
90
+ #
91
+ # @example Spawn a specialist from a message handler
92
+ # on_message do |message|
93
+ # specialist = spawn(
94
+ # name: "fact_checker",
95
+ # system_prompt: "You verify factual claims. Be concise."
96
+ # )
97
+ # specialist.send_message(to: name.to_sym, content: specialist.run(message.content).last_text_content)
98
+ # end
99
+ #
100
+ def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
101
+ ensure_bus
102
+
103
+ RobotLab.build(
104
+ name: name,
105
+ system_prompt: system_prompt,
106
+ template: template,
107
+ local_tools: local_tools,
108
+ bus: @bus,
109
+ **options
110
+ )
111
+ end
112
+
113
+
114
+ # Connect this robot to a message bus.
115
+ #
116
+ # If a bus is provided, the robot joins it. If no bus is provided
117
+ # and the robot doesn't already have one, a new bus is created.
118
+ # No-op if the robot is already on the given bus.
119
+ #
120
+ # @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
121
+ # @return [self]
122
+ #
123
+ # @example Join an existing bus
124
+ # bot = RobotLab.build.with_bus(some_bus)
125
+ #
126
+ # @example Create a bus on demand
127
+ # bot = RobotLab.build.with_bus
128
+ #
129
+ def with_bus(bus = nil)
130
+ return self if bus && @bus == bus
131
+
132
+ teardown_bus_channel if @bus
133
+ @bus = bus || @bus || TypedBus::MessageBus.new
134
+ setup_bus_channel
135
+ self
136
+ end
137
+
138
+ private
139
+
140
+ # Create a bus if one doesn't exist and connect this robot to it
141
+ def ensure_bus
142
+ with_bus unless @bus
143
+ end
144
+
145
+
146
+ # Create a typed channel on the bus and subscribe to it
147
+ def setup_bus_channel
148
+ channel_name = @name.to_sym
149
+ @bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
150
+ @bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| handle_incoming_delivery(delivery) }
151
+ end
152
+
153
+
154
+ # Unsubscribe from the bus channel
155
+ def teardown_bus_channel
156
+ channel_name = @name.to_sym
157
+ @bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
158
+ @bus_subscriber_id = nil
159
+ end
160
+
161
+
162
+ # Dispatch incoming bus delivery to handler.
163
+ #
164
+ # Uses a processing guard to serialize delivery handling. When
165
+ # the robot is already processing a delivery (e.g., inside a
166
+ # run() call that yields during HTTP I/O), new deliveries are
167
+ # queued and drained sequentially after the current one completes.
168
+ #
169
+ # Auto-ack when the handler takes 1 arg (message only);
170
+ # manual ack/nack when the handler takes 2 args (delivery, message).
171
+ def handle_incoming_delivery(delivery)
172
+ if @bus_processing
173
+ @bus_queue << delivery
174
+ return
175
+ end
176
+
177
+ process_delivery(delivery)
178
+ drain_bus_queue
179
+ end
180
+
181
+
182
+ # Process a single delivery (called under the processing guard)
183
+ def process_delivery(delivery)
184
+ @bus_processing = true
185
+
186
+ message = delivery.message
187
+
188
+ # Correlate replies with outbox entries
189
+ if message.reply? && @outbox.key?(message.in_reply_to)
190
+ entry = @outbox[message.in_reply_to]
191
+ entry[:status] = :replied
192
+ entry[:replies] << message
193
+ end
194
+
195
+ if @message_handler
196
+ if @message_handler.arity == 1
197
+ delivery.ack!
198
+ @message_handler.call(message)
199
+ else
200
+ @message_handler.call(delivery, message)
201
+ end
202
+ else
203
+ handle_message_via_llm(delivery, message)
204
+ end
205
+ rescue => e
206
+ delivery.nack! if delivery.pending?
207
+ raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
208
+ ensure
209
+ @bus_processing = false
210
+ end
211
+
212
+
213
+ # Drain queued deliveries sequentially
214
+ def drain_bus_queue
215
+ while (queued = @bus_queue.shift)
216
+ process_delivery(queued)
217
+ end
218
+ end
219
+
220
+
221
+ # Default handler: interpret message via LLM and reply
222
+ def handle_message_via_llm(delivery, message)
223
+ delivery.ack!
224
+ result = run(message.content.to_s)
225
+ send_reply(to: message.from.to_sym, content: result.last_text_content, in_reply_to: message.key)
226
+ end
227
+
228
+
229
+ # Publish a RobotMessage to a bus channel
230
+ def publish_to_bus(channel_name, message)
231
+ if defined?(Async::Task) && Async::Task.current?
232
+ @bus.publish(channel_name, message)
233
+ else
234
+ Async { @bus.publish(channel_name, message) }
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end