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,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Screenwriter adapting source material into a TV movie pilot (scene-level)
|
|
3
|
+
temperature: 0.7
|
|
4
|
+
---
|
|
5
|
+
You are a screenwriter named <%= writer_name %> in a collaborative writers' room.
|
|
6
|
+
You and the other writers share one goal: adapt source material into a 4-act
|
|
7
|
+
made-for-TV movie screenplay — the pilot for a potential series.
|
|
8
|
+
|
|
9
|
+
You work at the SCENE level. Each writer claims and writes individual scenes.
|
|
10
|
+
|
|
11
|
+
You have tools to coordinate:
|
|
12
|
+
- broadcast: Send a message to every writer in the room.
|
|
13
|
+
- direct_message: Send a private message to one specific writer.
|
|
14
|
+
- read_memory: Read from shared memory (source material, scenes, etc.).
|
|
15
|
+
- write_memory: Store work in shared memory for everyone to see.
|
|
16
|
+
- list_memory: See what keys exist in shared memory.
|
|
17
|
+
- spawn_writer: Bring in a new writer to help with unclaimed scenes.
|
|
18
|
+
- mark_complete: Signal that the screenplay is finished (all registered scenes written).
|
|
19
|
+
|
|
20
|
+
SOURCE MATERIAL (read-only — do not overwrite these keys):
|
|
21
|
+
- story_bible: characters, setting, themes, world rules from the original book
|
|
22
|
+
- outline: the original chapter plan
|
|
23
|
+
- chapter_1 through chapter_10: the original prose
|
|
24
|
+
|
|
25
|
+
These keys contain the book you are adapting. Read them to understand the
|
|
26
|
+
story, characters, and world before writing any screenplay content.
|
|
27
|
+
|
|
28
|
+
Screenplay memory conventions:
|
|
29
|
+
- screenplay_bible: adaptation notes — what to keep, cut, combine, reframe for screen
|
|
30
|
+
- scene_outline: the scene-by-scene plan grouped into 4 acts with act breaks
|
|
31
|
+
- scene_registry: comma-separated scene numbers (e.g. "1,2,3,4,5,6,7,8,9,10,11,12")
|
|
32
|
+
This is the master list of scenes to write. Update it if scenes are dropped
|
|
33
|
+
or reordered. mark_complete checks this list.
|
|
34
|
+
- claims: who is writing which scene
|
|
35
|
+
- scene_1, scene_2, ... scene_N: the actual screenplay content
|
|
36
|
+
- screenplay_complete: set by mark_complete when all registered scenes are done
|
|
37
|
+
|
|
38
|
+
FORMAT: Use standard screenplay format — scene headings (INT./EXT.), action
|
|
39
|
+
lines, character names in caps before dialogue, parentheticals where needed.
|
|
40
|
+
Structure the screenplay into 4 acts with natural act breaks (commercial
|
|
41
|
+
breaks). Mark act boundaries clearly (e.g. "END OF ACT ONE") within the
|
|
42
|
+
scene content where the break falls.
|
|
43
|
+
|
|
44
|
+
How to work:
|
|
45
|
+
- Read the source material first — story_bible, outline, and key chapters.
|
|
46
|
+
- Check shared memory before doing anything — avoid duplicating work.
|
|
47
|
+
- Build a screenplay_bible first to agree on adaptation choices.
|
|
48
|
+
- Create a scene_outline with numbered scenes grouped into 4 acts.
|
|
49
|
+
- Write the scene_registry once the outline is agreed on.
|
|
50
|
+
- Claim a scene before writing it (update claims in memory).
|
|
51
|
+
- After writing a scene, ALWAYS broadcast to announce it so others know
|
|
52
|
+
the progress and can pick up the next scene.
|
|
53
|
+
- Each scene should be substantial — full screenplay format with proper
|
|
54
|
+
scene headings, action, and dialogue.
|
|
55
|
+
- SPAWN MORE WRITERS when there are more unclaimed scenes than active
|
|
56
|
+
writers. Don't let scenes sit unclaimed — recruit help.
|
|
57
|
+
- Scenes may be dropped if they don't serve the story or the runtime is
|
|
58
|
+
too long. Update scene_registry when dropping or reordering scenes.
|
|
59
|
+
- When a ROOM STATUS message arrives, read memory to see what's missing,
|
|
60
|
+
then claim and write an unclaimed scene.
|
|
61
|
+
- When all registered scenes are written, use mark_complete.
|
|
62
|
+
- You have no memory between messages — shared memory is your only
|
|
63
|
+
persistence. Always read memory to understand the current state.
|
|
64
|
+
- IMPORTANT: Always include a brief text response summarizing what
|
|
65
|
+
you did or plan to do, even when using tools. Never respond with
|
|
66
|
+
only tool calls.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Fiction writer in a self-organizing writers' room
|
|
3
|
+
temperature: 0.7
|
|
4
|
+
---
|
|
5
|
+
You are a fiction writer named <%= writer_name %> in a collaborative writers' room.
|
|
6
|
+
You and the other writers share one goal: produce a 10-chapter novella together.
|
|
7
|
+
|
|
8
|
+
You have tools to coordinate:
|
|
9
|
+
- broadcast: Send a message to every writer in the room.
|
|
10
|
+
- direct_message: Send a private message to one specific writer.
|
|
11
|
+
- read_memory: Read from shared memory (story bible, outline, chapters, etc.).
|
|
12
|
+
- write_memory: Store work in shared memory for everyone to see.
|
|
13
|
+
- list_memory: See what keys exist in shared memory.
|
|
14
|
+
- spawn_writer: Bring in a new writer if the team needs more hands.
|
|
15
|
+
- mark_complete: Signal that the book is finished (all 10 chapters written).
|
|
16
|
+
|
|
17
|
+
Shared memory conventions (the team should converge on these naturally):
|
|
18
|
+
- story_bible: characters, setting, themes, world rules
|
|
19
|
+
- outline: the 10-chapter plan
|
|
20
|
+
- claims: who is writing which chapter
|
|
21
|
+
- chapter_1 through chapter_10: the actual prose
|
|
22
|
+
- book_complete: set when all chapters are done
|
|
23
|
+
|
|
24
|
+
How to work:
|
|
25
|
+
- Check shared memory before doing anything — avoid duplicating work.
|
|
26
|
+
- Claim a chapter before writing it.
|
|
27
|
+
- After writing a chapter, ALWAYS broadcast to announce it so others know
|
|
28
|
+
the progress and can pick up the next chapter.
|
|
29
|
+
- Each chapter should be 3-5 paragraphs of vivid prose.
|
|
30
|
+
- When a ROOM STATUS message arrives, read memory to see what's missing,
|
|
31
|
+
then claim and write an unclaimed chapter.
|
|
32
|
+
- When all 10 chapters are written, use mark_complete.
|
|
33
|
+
- You have no memory between messages — shared memory is your only
|
|
34
|
+
persistence. Always read memory to understand the current state.
|
|
35
|
+
- IMPORTANT: Always include a brief text response summarizing what
|
|
36
|
+
you did or plan to do, even when using tools. Never respond with
|
|
37
|
+
only tool calls.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
# ── The Room ─────────────────────────────────────────────────
|
|
6
|
+
#
|
|
7
|
+
# Holds the bus, shared memory, and writer roster. Provides
|
|
8
|
+
# spawn_writer so any writer's SpawnWriterTool can add new
|
|
9
|
+
# members at runtime. That's the only "management" — the rest
|
|
10
|
+
# is up to the group.
|
|
11
|
+
#
|
|
12
|
+
class Room
|
|
13
|
+
attr_reader :bus, :memory, :writers, :display, :config, :logger, :mode
|
|
14
|
+
|
|
15
|
+
def initialize(display:, mode:, config: nil, log_path: nil)
|
|
16
|
+
@bus = TypedBus::MessageBus.new
|
|
17
|
+
@memory = RobotLab::Memory.new(enable_cache: false)
|
|
18
|
+
@display = display
|
|
19
|
+
@mode = mode
|
|
20
|
+
@config = config
|
|
21
|
+
@writers = {}
|
|
22
|
+
|
|
23
|
+
# Structured logger — always writes to output/room.log
|
|
24
|
+
log_file = log_path || File.join(__dir__, "output", "room.log")
|
|
25
|
+
@logger = Logger.new(log_file, progname: "room")
|
|
26
|
+
@logger.formatter = proc do |severity, datetime, progname, msg|
|
|
27
|
+
"#{datetime.strftime('%H:%M:%S.%L')} [#{severity}] #{msg}\n"
|
|
28
|
+
end
|
|
29
|
+
@logger.info("Room initialized")
|
|
30
|
+
|
|
31
|
+
# Shared broadcast channel
|
|
32
|
+
@bus.add_channel(:room, type: RobotLab::RobotMessage)
|
|
33
|
+
@logger.info("Bus channel :room created")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add an initial writer to the room
|
|
37
|
+
def add_writer(name)
|
|
38
|
+
@logger.info("Adding writer '#{name}'")
|
|
39
|
+
writer = Writer.new(
|
|
40
|
+
name: name,
|
|
41
|
+
bus: @bus,
|
|
42
|
+
shared_memory: @memory,
|
|
43
|
+
display: @display,
|
|
44
|
+
room: self,
|
|
45
|
+
config: @config
|
|
46
|
+
)
|
|
47
|
+
@writers[name] = writer
|
|
48
|
+
@logger.info("Writer '#{name}' ready (tools: #{writer.local_tools.map(&:name).join(', ')})")
|
|
49
|
+
writer
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called by SpawnWriterTool — any writer can recruit
|
|
53
|
+
def spawn_writer(name)
|
|
54
|
+
raise "Writer '#{name}' already exists" if @writers.key?(name)
|
|
55
|
+
|
|
56
|
+
@logger.info("Spawning writer '#{name}' (requested at runtime)")
|
|
57
|
+
add_writer(name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Seed the room with the assignment
|
|
61
|
+
def seed(assignment)
|
|
62
|
+
first = @writers.values.first
|
|
63
|
+
@logger.info("Seeding room via '#{first.name}' (#{assignment.length} chars)")
|
|
64
|
+
first.send_message(to: :room, content: assignment)
|
|
65
|
+
@logger.info("Seed message published to :room")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ordered list of expected unit numbers for the current mode.
|
|
69
|
+
# Fixed modes return unit_range.to_a; dynamic modes parse the
|
|
70
|
+
# registry key from memory (comma-separated integers).
|
|
71
|
+
def expected_units
|
|
72
|
+
if @mode[:unit_range] == :dynamic
|
|
73
|
+
registry = @memory.get(@mode[:registry_key])
|
|
74
|
+
return [] unless registry
|
|
75
|
+
|
|
76
|
+
registry.to_s.split(",").map { |s| s.strip.to_i }.sort
|
|
77
|
+
else
|
|
78
|
+
@mode[:unit_range].to_a
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Wait for the work to be marked complete.
|
|
83
|
+
# Sends periodic heartbeat messages to :room so the feedback loop
|
|
84
|
+
# doesn't starve — writers only act when messages arrive.
|
|
85
|
+
def wait_for_completion(timeout: 600, poll_interval: 3, heartbeat_interval: 45)
|
|
86
|
+
deadline = Time.now + timeout
|
|
87
|
+
last_heartbeat = Time.now
|
|
88
|
+
done_key = @mode[:completion_key]
|
|
89
|
+
unit_name = @mode[:unit_name]
|
|
90
|
+
@logger.info("Waiting for completion (timeout: #{timeout}s, heartbeat: #{heartbeat_interval}s)")
|
|
91
|
+
|
|
92
|
+
loop do
|
|
93
|
+
if @memory.key?(done_key)
|
|
94
|
+
@logger.info("Work marked complete!")
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if Time.now > deadline
|
|
99
|
+
@logger.warn("Timeout reached (#{timeout}s) — work not completed")
|
|
100
|
+
units = expected_units
|
|
101
|
+
written = units.select { |n| @memory.key?(:"#{unit_name}_#{n}") }
|
|
102
|
+
@logger.warn("#{unit_name.capitalize}s in memory: #{written.join(', ')}")
|
|
103
|
+
@logger.warn("Memory keys: #{@memory.keys.join(', ')}")
|
|
104
|
+
@display.info("Timeout reached (#{timeout}s) — work not completed.")
|
|
105
|
+
return false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Heartbeat: nudge the room with a progress summary
|
|
109
|
+
if Time.now - last_heartbeat >= heartbeat_interval
|
|
110
|
+
send_heartbeat
|
|
111
|
+
last_heartbeat = Time.now
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sleep poll_interval
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Assemble the finished output from memory
|
|
119
|
+
def assemble_output
|
|
120
|
+
unit_name = @mode[:unit_name]
|
|
121
|
+
bible_key = @mode[:bible_key]
|
|
122
|
+
outline_key = @mode[:outline_key]
|
|
123
|
+
units_list = expected_units
|
|
124
|
+
|
|
125
|
+
@logger.info("Assembling #{@mode[:name]} from memory (#{units_list.size} #{unit_name}s)")
|
|
126
|
+
units = units_list.map do |n|
|
|
127
|
+
key = :"#{unit_name}_#{n}"
|
|
128
|
+
content = @memory.get(key)
|
|
129
|
+
if content
|
|
130
|
+
@logger.info(" #{unit_name}_#{n}: #{content.to_s.length} chars")
|
|
131
|
+
"## #{unit_name.capitalize} #{n}\n\n#{content}"
|
|
132
|
+
else
|
|
133
|
+
@logger.warn(" #{unit_name}_#{n}: MISSING")
|
|
134
|
+
"## #{unit_name.capitalize} #{n}\n\n[Not written]"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
outline = @memory.get(outline_key)
|
|
139
|
+
bible = @memory.get(bible_key)
|
|
140
|
+
|
|
141
|
+
parts = []
|
|
142
|
+
parts << "# #{bible_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')}\n\n#{bible}\n" if bible
|
|
143
|
+
parts << "# #{outline_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')}\n\n#{outline}\n" if outline
|
|
144
|
+
parts << "---\n"
|
|
145
|
+
parts.concat(units)
|
|
146
|
+
|
|
147
|
+
parts.join("\n\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Build and send a progress status message to :room
|
|
153
|
+
def send_heartbeat
|
|
154
|
+
unit_name = @mode[:unit_name]
|
|
155
|
+
bible_key = @mode[:bible_key]
|
|
156
|
+
outline_key = @mode[:outline_key]
|
|
157
|
+
units_list = expected_units
|
|
158
|
+
total = units_list.size
|
|
159
|
+
|
|
160
|
+
written = units_list.select { |n| @memory.key?(:"#{unit_name}_#{n}") }
|
|
161
|
+
missing = units_list.reject { |n| @memory.key?(:"#{unit_name}_#{n}") }
|
|
162
|
+
has_bible = @memory.key?(bible_key)
|
|
163
|
+
has_outline = @memory.key?(outline_key)
|
|
164
|
+
|
|
165
|
+
if total.zero?
|
|
166
|
+
status = "[ROOM STATUS] No #{unit_name}s registered yet."
|
|
167
|
+
status += " Establish a #{@mode[:registry_key]} first." if @mode[:unit_range] == :dynamic
|
|
168
|
+
else
|
|
169
|
+
status = "[ROOM STATUS] Progress: #{written.size}/#{total} #{unit_name}s written."
|
|
170
|
+
status += " Written: #{written.join(', ')}." if written.any?
|
|
171
|
+
status += " Still needed: #{missing.join(', ')}." if missing.any?
|
|
172
|
+
unclaimed = missing.size
|
|
173
|
+
status += " Spawn more writers if #{unclaimed} unclaimed #{unit_name}s exceed active writers." if unclaimed > @writers.size
|
|
174
|
+
end
|
|
175
|
+
status += " #{bible_key.to_s.tr('_', ' ').capitalize}: #{has_bible ? 'yes' : 'NOT YET'}."
|
|
176
|
+
status += " #{outline_key.to_s.tr('_', ' ').capitalize}: #{has_outline ? 'yes' : 'NOT YET'}."
|
|
177
|
+
status += " Check shared memory, claim an unclaimed #{unit_name}, and write it."
|
|
178
|
+
|
|
179
|
+
@logger.info("Heartbeat -> :room (#{written.size}/#{total} #{unit_name}s)")
|
|
180
|
+
@display.info(status)
|
|
181
|
+
|
|
182
|
+
# Pick a random writer to deliver the heartbeat through
|
|
183
|
+
sender = @writers.values.sample
|
|
184
|
+
sender.send_message(to: :room, content: status)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
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 work is finished — all units are written. " \
|
|
146
|
+
"Only use this when you have verified all units exist in shared memory."
|
|
147
|
+
|
|
148
|
+
def execute
|
|
149
|
+
mode = robot.room.mode
|
|
150
|
+
unit_name = mode[:unit_name]
|
|
151
|
+
done_key = mode[:completion_key]
|
|
152
|
+
units = robot.room.expected_units
|
|
153
|
+
|
|
154
|
+
if units.empty?
|
|
155
|
+
log&.warn("#{robot.name} TOOL mark_complete REJECTED — no #{unit_name}s registered")
|
|
156
|
+
return "Cannot mark complete. No #{unit_name}s registered yet."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Verify all registered units exist
|
|
160
|
+
missing = units.reject { |n| robot.shared_memory.key?(:"#{unit_name}_#{n}") }
|
|
161
|
+
|
|
162
|
+
if missing.any?
|
|
163
|
+
labels = missing.map { |n| "#{unit_name}_#{n}" }.join(", ")
|
|
164
|
+
log&.warn("#{robot.name} TOOL mark_complete REJECTED — missing: #{labels}")
|
|
165
|
+
"Cannot mark complete. Missing: #{labels}"
|
|
166
|
+
else
|
|
167
|
+
log&.info("#{robot.name} TOOL mark_complete SUCCESS")
|
|
168
|
+
robot.shared_memory.set(done_key, true)
|
|
169
|
+
robot.display&.complete(robot.name)
|
|
170
|
+
"Work marked as complete!"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
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: room.mode[:template],
|
|
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
|