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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/README.md +64 -6
- data/Rakefile +2 -1
- data/docs/api/core/index.md +41 -46
- data/docs/api/core/memory.md +200 -154
- data/docs/api/core/network.md +13 -3
- data/docs/api/core/robot.md +38 -26
- data/docs/api/core/state.md +55 -73
- data/docs/api/index.md +7 -28
- data/docs/api/messages/index.md +35 -20
- data/docs/api/messages/text-message.md +67 -21
- data/docs/api/messages/tool-call-message.md +80 -41
- data/docs/api/messages/tool-result-message.md +119 -50
- data/docs/api/messages/user-message.md +48 -24
- data/docs/architecture/core-concepts.md +10 -15
- data/docs/concepts.md +5 -7
- data/docs/examples/index.md +2 -2
- data/docs/getting-started/configuration.md +80 -0
- data/docs/guides/building-robots.md +10 -9
- data/docs/guides/creating-networks.md +49 -0
- data/docs/guides/index.md +0 -5
- data/docs/guides/rails-integration.md +244 -162
- data/docs/guides/streaming.md +118 -138
- data/docs/index.md +0 -8
- data/examples/03_network.rb +10 -7
- data/examples/08_llm_config.rb +40 -11
- data/examples/09_chaining.rb +45 -6
- data/examples/11_network_introspection.rb +30 -7
- data/examples/12_message_bus.rb +1 -1
- data/examples/14_rusty_circuit/heckler.rb +14 -8
- data/examples/14_rusty_circuit/open_mic.rb +5 -3
- data/examples/14_rusty_circuit/scout.rb +14 -31
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +1 -1
- data/examples/16_writers_room/display.rb +158 -0
- data/examples/16_writers_room/output/.gitignore +4 -0
- data/examples/16_writers_room/output/README.md +69 -0
- data/examples/16_writers_room/output/opus_001.md +263 -0
- data/examples/16_writers_room/output/opus_001_notes.log +470 -0
- data/examples/16_writers_room/output/opus_002.md +245 -0
- data/examples/16_writers_room/output/opus_002_notes.log +546 -0
- data/examples/16_writers_room/output/opus_002_screenplay.md +7989 -0
- data/examples/16_writers_room/output/opus_002_screenplay_notes.md +993 -0
- data/examples/16_writers_room/prompts/screenplay_writer.md +66 -0
- data/examples/16_writers_room/prompts/writer.md +37 -0
- data/examples/16_writers_room/room.rb +186 -0
- data/examples/16_writers_room/tools.rb +173 -0
- data/examples/16_writers_room/writer.rb +121 -0
- data/examples/16_writers_room/writers_room.rb +256 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
- data/lib/robot_lab/memory.rb +8 -32
- data/lib/robot_lab/network.rb +13 -20
- data/lib/robot_lab/robot/bus_messaging.rb +239 -0
- data/lib/robot_lab/robot/mcp_management.rb +88 -0
- data/lib/robot_lab/robot/template_rendering.rb +130 -0
- data/lib/robot_lab/robot.rb +56 -420
- data/lib/robot_lab/run_config.rb +184 -0
- data/lib/robot_lab/state_proxy.rb +2 -12
- data/lib/robot_lab/task.rb +8 -1
- data/lib/robot_lab/utils.rb +39 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +29 -8
- data/mkdocs.yml +0 -11
- metadata +21 -20
- data/docs/api/adapters/anthropic.md +0 -121
- data/docs/api/adapters/gemini.md +0 -133
- data/docs/api/adapters/index.md +0 -104
- data/docs/api/adapters/openai.md +0 -134
- data/docs/api/history/active-record-adapter.md +0 -275
- data/docs/api/history/config.md +0 -284
- data/docs/api/history/index.md +0 -128
- data/docs/api/history/thread-manager.md +0 -194
- data/docs/guides/history.md +0 -359
- data/lib/robot_lab/adapters/anthropic.rb +0 -163
- data/lib/robot_lab/adapters/base.rb +0 -85
- data/lib/robot_lab/adapters/gemini.rb +0 -193
- data/lib/robot_lab/adapters/openai.rb +0 -160
- data/lib/robot_lab/adapters/registry.rb +0 -81
- data/lib/robot_lab/errors.rb +0 -70
- data/lib/robot_lab/history/active_record_adapter.rb +0 -146
- data/lib/robot_lab/history/config.rb +0 -115
- data/lib/robot_lab/history/thread_manager.rb +0 -93
- 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
|
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -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
|
|
557
|
-
messages: messages
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
data/lib/robot_lab/network.rb
CHANGED
|
@@ -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
|