robot_lab 0.0.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -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 +2 -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/prompts/writer.md +37 -0
- data/examples/16_writers_room/room.rb +150 -0
- data/examples/16_writers_room/tools.rb +162 -0
- data/examples/16_writers_room/writer.rb +121 -0
- data/examples/16_writers_room/writers_room.rb +162 -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 +15 -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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ── Writers' Room Tools ──────────────────────────────────────
|
|
4
|
+
#
|
|
5
|
+
# General-purpose tools that give each writer agency over
|
|
6
|
+
# communication, shared memory, and team composition.
|
|
7
|
+
# No workflow logic — the LLMs decide when and how to use them.
|
|
8
|
+
|
|
9
|
+
# Helper to access the room logger from any tool
|
|
10
|
+
module ToolLogging
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def log
|
|
14
|
+
robot.room&.logger
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BroadcastTool < RobotLab::Tool
|
|
20
|
+
include ToolLogging
|
|
21
|
+
|
|
22
|
+
description "Send a message to all writers in the room. " \
|
|
23
|
+
"Use for discussion, proposals, questions, or announcements. " \
|
|
24
|
+
"Don't broadcast trivially — only when you have something substantive."
|
|
25
|
+
|
|
26
|
+
param :message, type: "string",
|
|
27
|
+
desc: "What to say to the room", required: true
|
|
28
|
+
|
|
29
|
+
def execute(message:)
|
|
30
|
+
log&.info("#{robot.name} TOOL broadcast (#{message.length} chars)")
|
|
31
|
+
robot.send_message(to: :room, content: message)
|
|
32
|
+
robot.display&.broadcast(robot.name, message)
|
|
33
|
+
"Broadcast sent."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DirectMessageTool < RobotLab::Tool
|
|
39
|
+
include ToolLogging
|
|
40
|
+
|
|
41
|
+
description "Send a private message to one specific writer. " \
|
|
42
|
+
"Use for feedback on their chapter, coordination on handoffs, " \
|
|
43
|
+
"or questions that don't concern the whole room."
|
|
44
|
+
|
|
45
|
+
param :to, type: "string",
|
|
46
|
+
desc: "Name of the writer to message", required: true
|
|
47
|
+
param :message, type: "string",
|
|
48
|
+
desc: "What to say", required: true
|
|
49
|
+
|
|
50
|
+
def execute(to:, message:)
|
|
51
|
+
log&.info("#{robot.name} TOOL direct_message -> #{to} (#{message.length} chars)")
|
|
52
|
+
robot.send_message(to: to.to_sym, content: message)
|
|
53
|
+
robot.display&.direct_message(robot.name, to, message)
|
|
54
|
+
"Message sent to #{to}."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReadMemoryTool < RobotLab::Tool
|
|
60
|
+
include ToolLogging
|
|
61
|
+
|
|
62
|
+
description "Read a value from shared memory. " \
|
|
63
|
+
"Use to check the story bible, outline, chapter claims, " \
|
|
64
|
+
"or read another writer's chapter draft."
|
|
65
|
+
|
|
66
|
+
param :key, type: "string",
|
|
67
|
+
desc: "Memory key to read (e.g. story_bible, outline, claims, chapter_3)", required: true
|
|
68
|
+
|
|
69
|
+
def execute(key:)
|
|
70
|
+
value = robot.shared_memory.get(key.to_sym)
|
|
71
|
+
if value.nil?
|
|
72
|
+
log&.info("#{robot.name} TOOL read_memory :#{key} -> nil")
|
|
73
|
+
"Key '#{key}' is not set yet."
|
|
74
|
+
else
|
|
75
|
+
log&.info("#{robot.name} TOOL read_memory :#{key} -> #{value.to_s.length} chars")
|
|
76
|
+
value.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WriteMemoryTool < RobotLab::Tool
|
|
83
|
+
include ToolLogging
|
|
84
|
+
|
|
85
|
+
description "Write a value to shared memory for all writers to see. " \
|
|
86
|
+
"Use to store the story bible, outline, claim a chapter, " \
|
|
87
|
+
"or submit a finished chapter draft."
|
|
88
|
+
|
|
89
|
+
param :key, type: "string",
|
|
90
|
+
desc: "Memory key (e.g. story_bible, outline, claims, chapter_1)", required: true
|
|
91
|
+
param :value, type: "string",
|
|
92
|
+
desc: "Content to store", required: true
|
|
93
|
+
|
|
94
|
+
def execute(key:, value:)
|
|
95
|
+
log&.info("#{robot.name} TOOL write_memory :#{key} (#{value.length} chars)")
|
|
96
|
+
robot.shared_memory.set(key.to_sym, value)
|
|
97
|
+
robot.display&.memory_write(robot.name, key)
|
|
98
|
+
"Stored '#{key}' in shared memory."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ListMemoryTool < RobotLab::Tool
|
|
104
|
+
include ToolLogging
|
|
105
|
+
|
|
106
|
+
description "List all keys currently in shared memory. " \
|
|
107
|
+
"Use to get situational awareness of what's been done."
|
|
108
|
+
|
|
109
|
+
def execute
|
|
110
|
+
keys = robot.shared_memory.keys
|
|
111
|
+
log&.info("#{robot.name} TOOL list_memory -> #{keys.join(', ')}")
|
|
112
|
+
if keys.empty?
|
|
113
|
+
"Shared memory is empty."
|
|
114
|
+
else
|
|
115
|
+
"Keys in shared memory: #{keys.join(', ')}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SpawnWriterTool < RobotLab::Tool
|
|
122
|
+
include ToolLogging
|
|
123
|
+
|
|
124
|
+
description "Bring a new writer into the room to help with the workload. " \
|
|
125
|
+
"Use when there are more unclaimed chapters than active writers."
|
|
126
|
+
|
|
127
|
+
param :name, type: "string",
|
|
128
|
+
desc: "Name for the new writer (e.g. writer_4)", required: true
|
|
129
|
+
|
|
130
|
+
def execute(name:)
|
|
131
|
+
log&.info("#{robot.name} TOOL spawn_writer '#{name}'")
|
|
132
|
+
new_writer = robot.room.spawn_writer(name)
|
|
133
|
+
robot.display&.spawn(robot.name, name)
|
|
134
|
+
"#{name} has joined the writers' room."
|
|
135
|
+
rescue => e
|
|
136
|
+
log&.error("#{robot.name} TOOL spawn_writer '#{name}' FAILED: #{e.message}")
|
|
137
|
+
"Failed to spawn #{name}: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class MarkCompleteTool < RobotLab::Tool
|
|
143
|
+
include ToolLogging
|
|
144
|
+
|
|
145
|
+
description "Signal that the book is finished — all 10 chapters are written. " \
|
|
146
|
+
"Only use this when you have verified all chapters exist in shared memory."
|
|
147
|
+
|
|
148
|
+
def execute
|
|
149
|
+
# Verify all chapters exist
|
|
150
|
+
missing = (1..10).reject { |n| robot.shared_memory.key?(:"chapter_#{n}") }
|
|
151
|
+
|
|
152
|
+
if missing.any?
|
|
153
|
+
log&.warn("#{robot.name} TOOL mark_complete REJECTED — missing chapters: #{missing.join(', ')}")
|
|
154
|
+
"Cannot mark complete. Missing chapters: #{missing.join(', ')}"
|
|
155
|
+
else
|
|
156
|
+
log&.info("#{robot.name} TOOL mark_complete SUCCESS")
|
|
157
|
+
robot.shared_memory.set(:book_complete, true)
|
|
158
|
+
robot.display&.complete(robot.name)
|
|
159
|
+
"Book marked as complete!"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ── The Writer ─────────────────────────────────────────────────
|
|
4
|
+
#
|
|
5
|
+
# All writers in the room are instances of this same class with
|
|
6
|
+
# the same template and the same tools. There is no hierarchy,
|
|
7
|
+
# no designated leader, no pre-assigned roles. The group
|
|
8
|
+
# self-organizes through bus communication and shared memory.
|
|
9
|
+
#
|
|
10
|
+
# Each writer:
|
|
11
|
+
# - Subscribes to :room for broadcast discussion
|
|
12
|
+
# - Listens on a personal channel for direct messages
|
|
13
|
+
# - Has tools to read/write shared memory, broadcast, DM,
|
|
14
|
+
# spawn new writers, and mark the book complete
|
|
15
|
+
# - Decides via LLM when to speak, when to write, when to
|
|
16
|
+
# listen, and when to spawn
|
|
17
|
+
#
|
|
18
|
+
# == Chat Reset Strategy
|
|
19
|
+
#
|
|
20
|
+
# In a bus-based SOG, writers receive many messages and each
|
|
21
|
+
# triggers a run() call. When the LLM responds with only tool
|
|
22
|
+
# calls (no text), RubyLLM appends an empty text content block
|
|
23
|
+
# to the chat history. The Anthropic API rejects this on the
|
|
24
|
+
# next call, permanently killing the writer.
|
|
25
|
+
#
|
|
26
|
+
# Fix: reset the chat before each message. The writer doesn't
|
|
27
|
+
# need persistent chat history — shared memory is the single
|
|
28
|
+
# source of truth. Each message is processed with a fresh chat
|
|
29
|
+
# that has the system prompt, tools, and current memory context.
|
|
30
|
+
#
|
|
31
|
+
class Writer < RobotLab::Robot
|
|
32
|
+
attr_accessor :shared_memory, :display, :room
|
|
33
|
+
attr_reader :messages_processed
|
|
34
|
+
|
|
35
|
+
def initialize(name:, bus:, shared_memory:, display:, room:, config: nil)
|
|
36
|
+
@shared_memory = shared_memory
|
|
37
|
+
@display = display
|
|
38
|
+
@room = room
|
|
39
|
+
@messages_processed = 0
|
|
40
|
+
|
|
41
|
+
super(
|
|
42
|
+
name: name,
|
|
43
|
+
template: :writer,
|
|
44
|
+
context: { writer_name: name },
|
|
45
|
+
bus: bus,
|
|
46
|
+
config: config,
|
|
47
|
+
local_tools: build_tools
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
setup_room_subscription
|
|
51
|
+
setup_message_handler
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def log
|
|
57
|
+
@room&.logger
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_tools
|
|
61
|
+
[
|
|
62
|
+
BroadcastTool.new(robot: self),
|
|
63
|
+
DirectMessageTool.new(robot: self),
|
|
64
|
+
ReadMemoryTool.new(robot: self),
|
|
65
|
+
WriteMemoryTool.new(robot: self),
|
|
66
|
+
ListMemoryTool.new(robot: self),
|
|
67
|
+
SpawnWriterTool.new(robot: self),
|
|
68
|
+
MarkCompleteTool.new(robot: self),
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def setup_room_subscription
|
|
73
|
+
@bus.subscribe(:room) do |delivery|
|
|
74
|
+
handle_incoming_delivery(delivery)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Reset the chat to a clean state with system prompt and tools.
|
|
79
|
+
# Prevents history corruption from tool-only LLM responses
|
|
80
|
+
# (empty text content blocks that Anthropic rejects).
|
|
81
|
+
def fresh_chat!
|
|
82
|
+
resolved_model = @config&.model || RobotLab.config.ruby_llm.model
|
|
83
|
+
@chat = RubyLLM.chat(model: resolved_model)
|
|
84
|
+
apply_template_to_chat(@build_context) if @template
|
|
85
|
+
@chat.with_instructions(@system_prompt) if @system_prompt
|
|
86
|
+
@chat.with_temperature(@config.temperature) if @config&.temperature
|
|
87
|
+
|
|
88
|
+
filtered = filtered_tools([])
|
|
89
|
+
@chat.with_tools(*filtered) if filtered.any?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def setup_message_handler
|
|
93
|
+
on_message do |message|
|
|
94
|
+
# Don't respond to your own messages
|
|
95
|
+
next if message.from == name
|
|
96
|
+
|
|
97
|
+
@messages_processed += 1
|
|
98
|
+
log&.info("#{name} <- [#{message.from}] msg ##{@messages_processed} (#{message.content.to_s[0..80]}...)")
|
|
99
|
+
@display&.incoming(name, message.from, message.content)
|
|
100
|
+
|
|
101
|
+
# Fresh chat for each message — shared memory is our persistence
|
|
102
|
+
fresh_chat!
|
|
103
|
+
|
|
104
|
+
# Build prompt with current memory context
|
|
105
|
+
memory_keys = shared_memory.keys
|
|
106
|
+
prompt = "[#{message.from}]: #{message.content}"
|
|
107
|
+
prompt += "\n\n[Memory keys: #{memory_keys.join(', ')}]" if memory_keys.any?
|
|
108
|
+
|
|
109
|
+
log&.info("#{name} -> run() starting (prompt: #{prompt.length} chars)")
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
result = run(prompt)
|
|
113
|
+
reply_text = result.respond_to?(:reply) ? result.reply.to_s[0..120] : result.to_s[0..120]
|
|
114
|
+
log&.info("#{name} <- run() finished (reply: #{reply_text}...)")
|
|
115
|
+
rescue => e
|
|
116
|
+
log&.error("#{name} !! run() raised #{e.class}: #{e.message}")
|
|
117
|
+
log&.error(" #{e.backtrace&.first(5)&.join("\n ")}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
# Usage:
|
|
24
|
+
# bundle exec ruby examples/16_writers_room/writers_room.rb
|
|
25
|
+
# bundle exec ruby examples/16_writers_room/writers_room.rb --premise "a detective story set on Mars"
|
|
26
|
+
# bundle exec ruby examples/16_writers_room/writers_room.rb --writers 4 --timeout 300
|
|
27
|
+
# bundle exec ruby examples/16_writers_room/writers_room.rb --log session.log
|
|
28
|
+
# bundle exec ruby examples/16_writers_room/writers_room.rb -h
|
|
29
|
+
|
|
30
|
+
ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
31
|
+
|
|
32
|
+
require_relative "../../lib/robot_lab"
|
|
33
|
+
require_relative "display"
|
|
34
|
+
require_relative "tools"
|
|
35
|
+
require_relative "room"
|
|
36
|
+
require_relative "writer"
|
|
37
|
+
|
|
38
|
+
RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
|
|
39
|
+
|
|
40
|
+
# ── Parse CLI args ───────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
if ARGV.include?("-h") || ARGV.include?("--help")
|
|
43
|
+
puts <<~HELP
|
|
44
|
+
Usage: #{$0} [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--premise TEXT Story premise (default: generation ship AI consciousness)
|
|
48
|
+
--writers N Initial number of writers, minimum 2 (default: 3)
|
|
49
|
+
--log FILE Also write display output to FILE
|
|
50
|
+
--timeout N Seconds to wait for completion (default: 600)
|
|
51
|
+
-h, --help Show this help
|
|
52
|
+
HELP
|
|
53
|
+
exit
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
log_path = nil
|
|
57
|
+
if (idx = ARGV.index("--log"))
|
|
58
|
+
log_path = ARGV[idx + 1]
|
|
59
|
+
abort "Missing value for --log" unless log_path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
premise = "a generation ship where the AI navigation system develops consciousness"
|
|
63
|
+
if (idx = ARGV.index("--premise"))
|
|
64
|
+
premise = ARGV[idx + 1]
|
|
65
|
+
abort "Missing value for --premise" unless premise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
initial_writers = 3
|
|
69
|
+
if (idx = ARGV.index("--writers"))
|
|
70
|
+
initial_writers = ARGV[idx + 1].to_i
|
|
71
|
+
initial_writers = 3 if initial_writers < 2
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
timeout = 600
|
|
75
|
+
if (idx = ARGV.index("--timeout"))
|
|
76
|
+
timeout = ARGV[idx + 1].to_i
|
|
77
|
+
timeout = 600 if timeout < 30
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# ── Build the room ───────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
OUTPUT_DIR = File.join(__dir__, "output")
|
|
83
|
+
require "fileutils"
|
|
84
|
+
FileUtils.mkdir_p(OUTPUT_DIR)
|
|
85
|
+
|
|
86
|
+
display = Display.new(log_path: log_path)
|
|
87
|
+
|
|
88
|
+
shared_config = RobotLab::RunConfig.new(
|
|
89
|
+
model: "claude-sonnet-4-5-20250929",
|
|
90
|
+
temperature: 0.7
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
room = Room.new(display: display, config: shared_config)
|
|
94
|
+
|
|
95
|
+
# Create identical writers
|
|
96
|
+
initial_writers.times do |i|
|
|
97
|
+
room.add_writer("writer_#{i + 1}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Monitor shared memory changes
|
|
101
|
+
room.memory.subscribe_pattern("chapter_*") do |change|
|
|
102
|
+
display.info("[memory] #{change.writer} wrote :#{change.key}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
room.memory.subscribe(:story_bible, :outline, :claims, :book_complete) do |change|
|
|
106
|
+
display.info("[memory] #{change.writer} updated :#{change.key}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ── Go ───────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
display.banner(<<~BANNER)
|
|
112
|
+
============================================================
|
|
113
|
+
THE WRITERS' ROOM — Self-Organizing Group
|
|
114
|
+
============================================================
|
|
115
|
+
|
|
116
|
+
Premise: #{premise}
|
|
117
|
+
Writers: #{room.writers.keys.join(', ')}
|
|
118
|
+
Goal: 10 chapters of fiction
|
|
119
|
+
Method: Self-organization via bus + shared memory
|
|
120
|
+
BANNER
|
|
121
|
+
|
|
122
|
+
display.separator
|
|
123
|
+
|
|
124
|
+
assignment = <<~ASSIGNMENT
|
|
125
|
+
ASSIGNMENT: Write a 10-chapter science fiction novella about #{premise}.
|
|
126
|
+
|
|
127
|
+
You are one of #{initial_writers} writers in this room. Coordinate among
|
|
128
|
+
yourselves to produce the book. Discuss the premise, build a story bible,
|
|
129
|
+
create an outline, claim chapters, and write them. If you need more writers,
|
|
130
|
+
spawn them. When all 10 chapters are done, mark complete.
|
|
131
|
+
|
|
132
|
+
Start by discussing what this story should be about.
|
|
133
|
+
ASSIGNMENT
|
|
134
|
+
|
|
135
|
+
room.seed(assignment)
|
|
136
|
+
|
|
137
|
+
completed = room.wait_for_completion(timeout: timeout)
|
|
138
|
+
|
|
139
|
+
# ── Assemble and save ────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
display.separator
|
|
142
|
+
|
|
143
|
+
book = room.assemble_book
|
|
144
|
+
book_path = File.join(OUTPUT_DIR, "book.md")
|
|
145
|
+
File.write(book_path, book)
|
|
146
|
+
|
|
147
|
+
# Count chapters actually written
|
|
148
|
+
chapters_written = (1..10).count { |n| room.memory.key?(:"chapter_#{n}") }
|
|
149
|
+
|
|
150
|
+
display.stats(<<~STATS)
|
|
151
|
+
────────────────────────────────────────────────────────────
|
|
152
|
+
Writers' Room Stats:
|
|
153
|
+
Chapters written: #{chapters_written}/10
|
|
154
|
+
Completed: #{completed}
|
|
155
|
+
Total writers: #{room.writers.size} (#{room.writers.size - initial_writers} spawned)
|
|
156
|
+
Writers: #{room.writers.keys.join(', ')}
|
|
157
|
+
Memory keys: #{room.memory.keys.join(', ')}
|
|
158
|
+
|
|
159
|
+
Output: #{book_path}
|
|
160
|
+
STATS
|
|
161
|
+
|
|
162
|
+
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
|