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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -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 +2 -0
  37. data/examples/16_writers_room/output/opus_001.md +263 -0
  38. data/examples/16_writers_room/output/opus_001_notes.log +470 -0
  39. data/examples/16_writers_room/prompts/writer.md +37 -0
  40. data/examples/16_writers_room/room.rb +150 -0
  41. data/examples/16_writers_room/tools.rb +162 -0
  42. data/examples/16_writers_room/writer.rb +121 -0
  43. data/examples/16_writers_room/writers_room.rb +162 -0
  44. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
  45. data/lib/robot_lab/memory.rb +8 -32
  46. data/lib/robot_lab/network.rb +13 -20
  47. data/lib/robot_lab/robot/bus_messaging.rb +239 -0
  48. data/lib/robot_lab/robot/mcp_management.rb +88 -0
  49. data/lib/robot_lab/robot/template_rendering.rb +130 -0
  50. data/lib/robot_lab/robot.rb +56 -420
  51. data/lib/robot_lab/run_config.rb +184 -0
  52. data/lib/robot_lab/state_proxy.rb +2 -12
  53. data/lib/robot_lab/task.rb +8 -1
  54. data/lib/robot_lab/utils.rb +39 -0
  55. data/lib/robot_lab/version.rb +1 -1
  56. data/lib/robot_lab.rb +29 -8
  57. data/mkdocs.yml +0 -11
  58. metadata +15 -20
  59. data/docs/api/adapters/anthropic.md +0 -121
  60. data/docs/api/adapters/gemini.md +0 -133
  61. data/docs/api/adapters/index.md +0 -104
  62. data/docs/api/adapters/openai.md +0 -134
  63. data/docs/api/history/active-record-adapter.md +0 -275
  64. data/docs/api/history/config.md +0 -284
  65. data/docs/api/history/index.md +0 -128
  66. data/docs/api/history/thread-manager.md +0 -194
  67. data/docs/guides/history.md +0 -359
  68. data/lib/robot_lab/adapters/anthropic.rb +0 -163
  69. data/lib/robot_lab/adapters/base.rb +0 -85
  70. data/lib/robot_lab/adapters/gemini.rb +0 -193
  71. data/lib/robot_lab/adapters/openai.rb +0 -160
  72. data/lib/robot_lab/adapters/registry.rb +0 -81
  73. data/lib/robot_lab/errors.rb +0 -70
  74. data/lib/robot_lab/history/active_record_adapter.rb +0 -146
  75. data/lib/robot_lab/history/config.rb +0 -115
  76. data/lib/robot_lab/history/thread_manager.rb +0 -93
  77. data/lib/robot_lab/robotic_model.rb +0 -324
@@ -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
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # MCP client lifecycle and hierarchical tool/MCP resolution.
6
+ #
7
+ # Expects the including class to provide:
8
+ # @mcp_config, @tools_config, @mcp_clients, @mcp_tools,
9
+ # @mcp_initialized, @name, @chat, @local_tools
10
+ module MCPManagement
11
+ private
12
+
13
+ # Resolve MCP hierarchy: runtime -> robot build -> network -> config
14
+ def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
15
+ parent_value = network_config&.mcp || network&.network&.mcp || RobotLab.config.mcp
16
+ build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
17
+ ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
18
+ end
19
+
20
+
21
+ # Resolve tools hierarchy: runtime -> robot build -> network -> config
22
+ def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
23
+ parent_value = network_config&.tools || network&.network&.tools || RobotLab.config.tools
24
+ build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
25
+ ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
26
+ end
27
+
28
+
29
+ # Ensure MCP clients are initialized for the given server configs
30
+ def ensure_mcp_clients(mcp_servers)
31
+ return if mcp_servers.empty?
32
+
33
+ needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
34
+ return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
35
+
36
+ disconnect if @mcp_initialized
37
+
38
+ @mcp_clients = {}
39
+ @mcp_tools = []
40
+
41
+ mcp_servers.each do |server_config|
42
+ init_mcp_client(server_config)
43
+ end
44
+
45
+ @mcp_initialized = true
46
+ end
47
+
48
+
49
+ def init_mcp_client(server_config)
50
+ client = MCP::Client.new(server_config)
51
+ client.connect
52
+
53
+ if client.connected?
54
+ server_name = client.server.name
55
+ @mcp_clients[server_name] = client
56
+ discover_mcp_tools(client, server_name)
57
+ else
58
+ RobotLab.config.logger.warn(
59
+ "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
60
+ )
61
+ end
62
+ end
63
+
64
+
65
+ def discover_mcp_tools(client, server_name)
66
+ tools = client.list_tools
67
+
68
+ tools.each do |tool_def|
69
+ tool_name = tool_def[:name]
70
+ mcp_client = client
71
+
72
+ tool = Tool.create(
73
+ name: tool_name,
74
+ description: tool_def[:description],
75
+ parameters: tool_def[:inputSchema],
76
+ mcp: server_name
77
+ ) { |args| mcp_client.call_tool(tool_name, args) }
78
+
79
+ @mcp_tools << tool
80
+ end
81
+
82
+ RobotLab.config.logger.info(
83
+ "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # Template loading, rendering, and front-matter extraction.
6
+ #
7
+ # Expects the including class to provide:
8
+ # @chat, @template, @build_context, @name, @name_from_constructor,
9
+ # @description, @local_tools, @mcp_config
10
+ module TemplateRendering
11
+ # Front matter keys that map to chat configuration methods
12
+ FRONT_MATTER_CONFIG_KEYS = %i[
13
+ model temperature top_p top_k max_tokens
14
+ presence_penalty frequency_penalty stop
15
+ ].freeze
16
+
17
+ # Front matter keys for robot identity and capabilities.
18
+ # Note: uses `robot_name` because PM::Metadata reserves `name` for the filename.
19
+ FRONT_MATTER_EXTRA_KEYS = %i[tools mcp robot_name description].freeze
20
+
21
+ # Apply a prompt_manager template to the robot's chat
22
+ #
23
+ # @param template_id [Symbol, String] the template identifier
24
+ # @param context [Hash] variables to pass to the template
25
+ # @return [self]
26
+ def with_template(template_id, **context)
27
+ @template = template_id.to_sym
28
+ @build_context = context
29
+ apply_template_to_chat(context)
30
+ self
31
+ end
32
+
33
+ private
34
+
35
+ # Apply a prompt_manager template to the persistent chat.
36
+ # If required parameters are missing, applies front matter config but
37
+ # defers rendering until run time when all values are available.
38
+ def apply_template_to_chat(context)
39
+ parsed = PM.parse(@template)
40
+
41
+ # Extract extra config from front matter (name, description, tools, mcp)
42
+ apply_front_matter_extras(parsed.metadata)
43
+
44
+ # Extract LLM config from front matter and apply to chat.
45
+ # Front matter is the base; @config (from constructor kwargs) overrides.
46
+ fm_config = RunConfig.from_front_matter(parsed.metadata)
47
+ effective = fm_config.merge(@config)
48
+ effective.apply_to(@chat)
49
+
50
+ # Resolve context (could be a Proc)
51
+ resolved_ctx = resolve_context(context, network: nil)
52
+
53
+ # Render the template body with context
54
+ begin
55
+ rendered = parsed.to_s(**resolved_ctx)
56
+ @chat.with_instructions(rendered)
57
+ rescue ArgumentError => e
58
+ raise unless e.message.start_with?("Missing required parameters:")
59
+
60
+ # Required parameters not yet available; template will be
61
+ # fully rendered at run time via rerender_template.
62
+ end
63
+ end
64
+
65
+
66
+ # Re-render the template with run-time context merged into build-time context.
67
+ # prompt_manager parameters may be required (null) and only available at run time.
68
+ def rerender_template(run_context)
69
+ merged = (@build_context || {}).merge(run_context)
70
+ parsed = PM.parse(@template)
71
+ resolved_ctx = resolve_context(merged, network: nil)
72
+ rendered = parsed.to_s(**resolved_ctx)
73
+ @chat.with_instructions(rendered)
74
+ end
75
+
76
+
77
+ # Extract identity and capability keys from front matter metadata.
78
+ # Constructor-provided values take precedence over frontmatter.
79
+ def apply_front_matter_extras(metadata)
80
+ if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
81
+ @name = metadata.robot_name.to_s
82
+ end
83
+
84
+ if metadata.respond_to?(:description) && metadata.description && @description.nil?
85
+ @description = metadata.description.to_s
86
+ end
87
+
88
+ if metadata.respond_to?(:tools) && metadata.tools.is_a?(Array) && @local_tools.empty?
89
+ @local_tools = resolve_frontmatter_tools(metadata.tools)
90
+ end
91
+
92
+ if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
93
+ @mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
94
+ end
95
+ end
96
+
97
+
98
+ # Resolve string tool names from frontmatter to Ruby constants.
99
+ # Tool subclasses are instantiated; instances are used as-is.
100
+ # Unresolvable names are skipped with a warning.
101
+ def resolve_frontmatter_tools(tool_names)
102
+ tool_names.filter_map do |name|
103
+ case name
104
+ when String
105
+ begin
106
+ const = Object.const_get(name)
107
+ const.is_a?(Class) && const < RubyLLM::Tool ? const.new : const
108
+ rescue NameError
109
+ RobotLab.config.logger.warn("Robot '#{@name}': tool '#{name}' not found, skipping")
110
+ nil
111
+ end
112
+ when Class
113
+ name.new
114
+ else
115
+ name
116
+ end
117
+ end
118
+ end
119
+
120
+
121
+ def resolve_context(context, network:)
122
+ case context
123
+ when Proc then context.call(network: network)
124
+ when Hash then context
125
+ else {}
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end