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,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