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
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'robot/template_rendering'
4
+ require_relative 'robot/mcp_management'
5
+ require_relative 'robot/bus_messaging'
6
+
3
7
  module RobotLab
4
8
  # LLM-powered robot built on RubyLLM::Agent
5
9
  #
@@ -34,15 +38,9 @@ module RobotLab
34
38
  # )
35
39
  #
36
40
  class Robot < RubyLLM::Agent
37
- # Front matter keys that map to chat configuration methods
38
- FRONT_MATTER_CONFIG_KEYS = %i[
39
- model temperature top_p top_k max_tokens
40
- presence_penalty frequency_penalty stop
41
- ].freeze
42
-
43
- # Front matter keys for robot identity and capabilities.
44
- # Note: uses `robot_name` because PM::Metadata reserves `name` for the filename.
45
- FRONT_MATTER_EXTRA_KEYS = %i[tools mcp robot_name description].freeze
41
+ include Robot::TemplateRendering
42
+ include Robot::MCPManagement
43
+ include Robot::BusMessaging
46
44
 
47
45
  # @!attribute [r] name
48
46
  # @return [String] the unique identifier for the robot
@@ -68,7 +66,7 @@ module RobotLab
68
66
 
69
67
  attr_reader :name, :description, :template, :system_prompt,
70
68
  :local_tools, :mcp_clients, :mcp_tools, :memory,
71
- :bus, :outbox
69
+ :bus, :outbox, :config
72
70
 
73
71
  # @!attribute [r] mcp_config
74
72
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -99,6 +97,7 @@ module RobotLab
99
97
  # @param presence_penalty [Float, nil] penalize based on presence
100
98
  # @param frequency_penalty [Float, nil] penalize based on frequency
101
99
  # @param stop [String, Array, nil] stop sequences
100
+ # @param config [RunConfig, nil] shared configuration (merged with explicit kwargs)
102
101
  def initialize(
103
102
  name:,
104
103
  template: nil,
@@ -120,7 +119,8 @@ module RobotLab
120
119
  max_tokens: nil,
121
120
  presence_penalty: nil,
122
121
  frequency_penalty: nil,
123
- stop: nil
122
+ stop: nil,
123
+ config: nil
124
124
  )
125
125
  @name = name.to_s
126
126
  @name_from_constructor = (name.to_s != "robot")
@@ -129,12 +129,32 @@ module RobotLab
129
129
  @build_context = context
130
130
  @description = description
131
131
  @local_tools = Array(local_tools)
132
- @on_tool_call = on_tool_call
133
- @on_tool_result = on_tool_result
132
+
133
+ # Build RunConfig from explicit kwargs, merged on top of passed-in config.
134
+ # Explicit constructor kwargs always override the shared config.
135
+ explicit_fields = {
136
+ model: model, temperature: temperature, top_p: top_p, top_k: top_k,
137
+ max_tokens: max_tokens, presence_penalty: presence_penalty,
138
+ frequency_penalty: frequency_penalty, stop: stop,
139
+ on_tool_call: on_tool_call, on_tool_result: on_tool_result,
140
+ bus: bus, enable_cache: enable_cache
141
+ }.compact
142
+
143
+ # Only include mcp/tools if explicitly set (not the default :none sentinel)
144
+ resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
145
+ explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
146
+ explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
147
+
148
+ explicit_config = RunConfig.new(**explicit_fields)
149
+ @config = config ? config.merge(explicit_config) : explicit_config
150
+
151
+ # Extract values from effective config for backward compatibility
152
+ @on_tool_call = @config.on_tool_call
153
+ @on_tool_result = @config.on_tool_result
134
154
 
135
155
  # Store raw config values for hierarchical resolution
136
- @mcp_config = mcp_servers.any? ? mcp_servers : mcp
137
- @tools_config = tools
156
+ @mcp_config = @config.mcp || :none
157
+ @tools_config = @config.tools || :none
138
158
 
139
159
  # MCP state
140
160
  @mcp_clients = {}
@@ -142,19 +162,22 @@ module RobotLab
142
162
  @mcp_initialized = false
143
163
 
144
164
  # Bus state (optional inter-robot communication)
145
- @bus = bus
165
+ @bus = @config.bus
146
166
  @message_counter = 0
147
167
  @outbox = {}
148
168
  @message_handler = nil
169
+ @bus_processing = false
170
+ @bus_queue = []
149
171
 
150
172
  # Inherent memory (used when standalone, not in a network)
151
- @memory = Memory.new(enable_cache: enable_cache)
173
+ cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
174
+ @memory = Memory.new(enable_cache: cache_enabled)
152
175
 
153
176
  # Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
154
177
  config = RobotLab.config
155
178
 
156
179
  # Build chat kwargs for Agent's super
157
- resolved_model = model || config.ruby_llm.model
180
+ resolved_model = @config.model || config.ruby_llm.model
158
181
  chat_kwargs = { model: resolved_model }
159
182
 
160
183
  # Create the persistent chat via Agent's initialize
@@ -165,14 +188,14 @@ module RobotLab
165
188
  apply_template_to_chat(context) if @template
166
189
  @chat.with_instructions(@system_prompt) if @system_prompt
167
190
 
168
- # Constructor params override template front matter
169
- apply_chat_option(:with_temperature, temperature)
170
- apply_chat_option(:with_top_p, top_p)
171
- apply_chat_option(:with_top_k, top_k)
172
- apply_chat_option(:with_max_tokens, max_tokens)
173
- apply_chat_option(:with_presence_penalty, presence_penalty)
174
- apply_chat_option(:with_frequency_penalty, frequency_penalty)
175
- apply_chat_option(:with_stop, stop)
191
+ # Constructor params override template front matter (use config values)
192
+ apply_chat_option(:with_temperature, @config.temperature)
193
+ apply_chat_option(:with_top_p, @config.top_p)
194
+ apply_chat_option(:with_top_k, @config.top_k)
195
+ apply_chat_option(:with_max_tokens, @config.max_tokens)
196
+ apply_chat_option(:with_presence_penalty, @config.presence_penalty)
197
+ apply_chat_option(:with_frequency_penalty, @config.frequency_penalty)
198
+ apply_chat_option(:with_stop, @config.stop)
176
199
 
177
200
  # Apply callbacks
178
201
  @chat.on_tool_call(&@on_tool_call) if @on_tool_call
@@ -206,18 +229,6 @@ module RobotLab
206
229
  end
207
230
  end
208
231
 
209
- # Apply a prompt_manager template to the robot's chat
210
- #
211
- # @param template_id [Symbol, String] the template identifier
212
- # @param context [Hash] variables to pass to the template
213
- # @return [self]
214
- def with_template(template_id, **context)
215
- @template = template_id.to_sym
216
- @build_context = context
217
- apply_template_to_chat(context)
218
- self
219
- end
220
-
221
232
 
222
233
  # Send a message and get a response, with Robot's extended capabilities
223
234
  #
@@ -228,8 +239,8 @@ module RobotLab
228
239
  # @param mcp [Symbol, Array, nil] runtime MCP override
229
240
  # @param tools [Symbol, Array, nil] runtime tools override
230
241
  # @return [RobotResult]
231
- def run(message = nil, network: nil, network_memory: nil, memory: nil, mcp: :none, tools: :none,
232
- **kwargs)
242
+ def run(message = nil, network: nil, network_memory: nil, network_config: nil,
243
+ memory: nil, mcp: :none, tools: :none, **kwargs)
233
244
  # Determine which memory to use
234
245
  run_memory = resolve_active_memory(network: network, network_memory: network_memory)
235
246
 
@@ -247,8 +258,8 @@ module RobotLab
247
258
 
248
259
  begin
249
260
  # Resolve hierarchical MCP and tools configuration
250
- resolved_mcp = resolve_mcp_hierarchy(mcp, network: network)
251
- resolved_tools = resolve_tools_hierarchy(tools, network: network)
261
+ resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
262
+ resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
252
263
 
253
264
  # Initialize or update MCP clients based on resolved config
254
265
  ensure_mcp_clients(resolved_mcp)
@@ -329,130 +340,6 @@ module RobotLab
329
340
  end
330
341
 
331
342
 
332
- # Send a message to another robot via the bus.
333
- #
334
- # @param to [String, Symbol] target robot's channel name
335
- # @param content [String, Hash] message payload
336
- # @return [RobotMessage] the sent message
337
- # @raise [BusError] if no bus is configured
338
- def send_message(to:, content:)
339
- raise BusError, "No bus configured on robot '#{@name}'" unless @bus
340
-
341
- @message_counter += 1
342
- message = RobotMessage.build(id: @message_counter, from: @name, content: content)
343
- @outbox[message.key] = { message: message, status: :sent, replies: [] }
344
- publish_to_bus(to.to_sym, message)
345
- message
346
- end
347
-
348
-
349
- # Send a reply to a specific message via the bus.
350
- #
351
- # @param to [String, Symbol] target robot's channel name
352
- # @param content [String, Hash] reply payload
353
- # @param in_reply_to [String] composite key of the message being replied to
354
- # @return [RobotMessage] the reply message
355
- # @raise [BusError] if no bus is configured
356
- def send_reply(to:, content:, in_reply_to:)
357
- raise BusError, "No bus configured on robot '#{@name}'" unless @bus
358
-
359
- @message_counter += 1
360
- reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
361
- publish_to_bus(to.to_sym, reply)
362
- reply
363
- end
364
-
365
-
366
- # Convenience method to reply to a RobotMessage.
367
- #
368
- # @param message [RobotMessage] the message to reply to
369
- # @param content [String, Hash] reply payload
370
- # @return [RobotMessage] the reply message
371
- def reply(message, content)
372
- send_reply(to: message.from.to_sym, content: content, in_reply_to: message.key)
373
- end
374
-
375
-
376
- # Register a custom handler for incoming bus messages.
377
- #
378
- # Block arity controls delivery handling:
379
- # - 1 argument `|message|`: auto-acks before calling, auto-nacks on exception
380
- # - 2 arguments `|delivery, message|`: manual mode, you call ack!/nack!
381
- #
382
- # @yield [message] or [delivery, message]
383
- # @return [self]
384
- def on_message(&block)
385
- @message_handler = block
386
- self
387
- end
388
-
389
-
390
- # Spawn a new robot on a shared bus.
391
- #
392
- # Creates a new Robot instance that shares this robot's bus,
393
- # allowing it to immediately send and receive messages with
394
- # all other robots on the bus. If no bus exists yet, one is
395
- # created automatically and the parent robot is connected to it.
396
- #
397
- # @param name [String] unique name for the new robot
398
- # @param system_prompt [String, nil] inline system prompt
399
- # @param template [Symbol, nil] prompt_manager template
400
- # @param local_tools [Array] tools for the new robot
401
- # @param options [Hash] additional options passed to RobotLab.build
402
- # @return [Robot] the newly created robot
403
- #
404
- # @example Spawn from a bus-less robot (bus and name created automatically)
405
- # bot = RobotLab.build
406
- # bot2 = bot.spawn(system_prompt: "You are helpful.")
407
- #
408
- # @example Spawn a specialist from a message handler
409
- # on_message do |message|
410
- # specialist = spawn(
411
- # name: "fact_checker",
412
- # system_prompt: "You verify factual claims. Be concise."
413
- # )
414
- # specialist.send_message(to: name.to_sym, content: specialist.run(message.content).last_text_content)
415
- # end
416
- #
417
- def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
418
- ensure_bus
419
-
420
- RobotLab.build(
421
- name: name,
422
- system_prompt: system_prompt,
423
- template: template,
424
- local_tools: local_tools,
425
- bus: @bus,
426
- **options
427
- )
428
- end
429
-
430
-
431
- # Connect this robot to a message bus.
432
- #
433
- # If a bus is provided, the robot joins it. If no bus is provided
434
- # and the robot doesn't already have one, a new bus is created.
435
- # No-op if the robot is already on the given bus.
436
- #
437
- # @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
438
- # @return [self]
439
- #
440
- # @example Join an existing bus
441
- # bot = RobotLab.build.with_bus(some_bus)
442
- #
443
- # @example Create a bus on demand
444
- # bot = RobotLab.build.with_bus
445
- #
446
- def with_bus(bus = nil)
447
- return self if bus && @bus == bus
448
-
449
- teardown_bus_channel if @bus
450
- @bus = bus || @bus || TypedBus::MessageBus.new
451
- setup_bus_channel
452
- self
453
- end
454
-
455
-
456
343
  # Disconnect all MCP clients and bus channel.
457
344
  #
458
345
  # @return [self]
@@ -478,6 +365,7 @@ module RobotLab
478
365
  tools_config: @tools_config,
479
366
  mcp_servers: @mcp_clients.keys,
480
367
  model: model,
368
+ config: (@config.empty? ? nil : @config.to_json_hash),
481
369
  bus: @bus ? true : nil
482
370
  }.compact
483
371
  end
@@ -490,107 +378,6 @@ module RobotLab
490
378
  end
491
379
 
492
380
 
493
- # Apply a prompt_manager template to the persistent chat.
494
- # If required parameters are missing, applies front matter config but
495
- # defers rendering until run time when all values are available.
496
- def apply_template_to_chat(context)
497
- parsed = PM.parse(@template)
498
-
499
- # Extract extra config from front matter (name, description, tools, mcp)
500
- apply_front_matter_extras(parsed.metadata)
501
-
502
- # Extract and apply LLM config to the chat (model, temperature, etc.)
503
- apply_front_matter_config(parsed.metadata)
504
-
505
- # Resolve context (could be a Proc)
506
- resolved_ctx = resolve_context(context, network: nil)
507
-
508
- # Render the template body with context
509
- begin
510
- rendered = parsed.to_s(**resolved_ctx)
511
- @chat.with_instructions(rendered)
512
- rescue ArgumentError => e
513
- raise unless e.message.start_with?("Missing required parameters:")
514
-
515
- # Required parameters not yet available; template will be
516
- # fully rendered at run time via rerender_template.
517
- end
518
- end
519
-
520
-
521
- # Re-render the template with run-time context merged into build-time context.
522
- # prompt_manager parameters may be required (null) and only available at run time.
523
- def rerender_template(run_context)
524
- merged = (@build_context || {}).merge(run_context)
525
- parsed = PM.parse(@template)
526
- resolved_ctx = resolve_context(merged, network: nil)
527
- rendered = parsed.to_s(**resolved_ctx)
528
- @chat.with_instructions(rendered)
529
- end
530
-
531
-
532
- # Extract whitelisted config from front matter and apply to chat
533
- def apply_front_matter_config(metadata)
534
- FRONT_MATTER_CONFIG_KEYS.each do |key|
535
- value = metadata.respond_to?(key) ? metadata.send(key) : nil
536
- next unless value
537
-
538
- method = :"with_#{key}"
539
- @chat.public_send(method, value) if @chat.respond_to?(method)
540
- end
541
-
542
- # Handle model specially (may need with_model)
543
- return unless metadata.respond_to?(:model) && metadata.model
544
-
545
- @chat.with_model(metadata.model)
546
-
547
- end
548
-
549
-
550
- # Extract identity and capability keys from front matter metadata.
551
- # Constructor-provided values take precedence over frontmatter.
552
- def apply_front_matter_extras(metadata)
553
- if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
554
- @name = metadata.robot_name.to_s
555
- end
556
-
557
- if metadata.respond_to?(:description) && metadata.description && @description.nil?
558
- @description = metadata.description.to_s
559
- end
560
-
561
- if metadata.respond_to?(:tools) && metadata.tools.is_a?(Array) && @local_tools.empty?
562
- @local_tools = resolve_frontmatter_tools(metadata.tools)
563
- end
564
-
565
- if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
566
- @mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
567
- end
568
- end
569
-
570
-
571
- # Resolve string tool names from frontmatter to Ruby constants.
572
- # Tool subclasses are instantiated; instances are used as-is.
573
- # Unresolvable names are skipped with a warning.
574
- def resolve_frontmatter_tools(tool_names)
575
- tool_names.filter_map do |name|
576
- case name
577
- when String
578
- begin
579
- const = Object.const_get(name)
580
- const.is_a?(Class) && const < RubyLLM::Tool ? const.new : const
581
- rescue NameError
582
- RobotLab.config.logger.warn("Robot '#{@name}': tool '#{name}' not found, skipping")
583
- nil
584
- end
585
- when Class
586
- name.new
587
- else
588
- name
589
- end
590
- end
591
- end
592
-
593
-
594
381
  # Determine which memory to use
595
382
  def resolve_active_memory(network: nil, network_memory: nil)
596
383
  network_memory || network&.memory || @memory
@@ -606,6 +393,7 @@ module RobotLab
606
393
  tools = run_params.delete(:tools) || :none
607
394
  memory = run_params.delete(:memory)
608
395
  network_memory = run_params.delete(:network_memory)
396
+ network_config = run_params.delete(:network_config)
609
397
 
610
398
  # Build base context from remaining run params
611
399
  base = run_params.dup
@@ -627,20 +415,12 @@ module RobotLab
627
415
  merged[:tools] = tools
628
416
  merged[:memory] = memory if memory
629
417
  merged[:network_memory] = network_memory if network_memory
418
+ merged[:network_config] = network_config if network_config
630
419
 
631
420
  merged
632
421
  end
633
422
 
634
423
 
635
- def resolve_context(context, network:)
636
- case context
637
- when Proc then context.call(network: network)
638
- when Hash then context
639
- else {}
640
- end
641
- end
642
-
643
-
644
424
  def build_result(response, _memory)
645
425
  output = if response.respond_to?(:content) && response.content
646
426
  [TextMessage.new(role: 'assistant', content: response.content)]
@@ -675,81 +455,6 @@ module RobotLab
675
455
  end
676
456
 
677
457
 
678
- # Resolve MCP hierarchy: runtime -> robot build -> network -> config
679
- def resolve_mcp_hierarchy(runtime_value, network:)
680
- parent_value = network&.network&.mcp || RobotLab.config.mcp
681
- build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
682
- ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
683
- end
684
-
685
-
686
- # Resolve tools hierarchy: runtime -> robot build -> network -> config
687
- def resolve_tools_hierarchy(runtime_value, network:)
688
- parent_value = network&.network&.tools || RobotLab.config.tools
689
- build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
690
- ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
691
- end
692
-
693
-
694
- # Ensure MCP clients are initialized for the given server configs
695
- def ensure_mcp_clients(mcp_servers)
696
- return if mcp_servers.empty?
697
-
698
- needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
699
- return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
700
-
701
- disconnect if @mcp_initialized
702
-
703
- @mcp_clients = {}
704
- @mcp_tools = []
705
-
706
- mcp_servers.each do |server_config|
707
- init_mcp_client(server_config)
708
- end
709
-
710
- @mcp_initialized = true
711
- end
712
-
713
-
714
- def init_mcp_client(server_config)
715
- client = MCP::Client.new(server_config)
716
- client.connect
717
-
718
- if client.connected?
719
- server_name = client.server.name
720
- @mcp_clients[server_name] = client
721
- discover_mcp_tools(client, server_name)
722
- else
723
- RobotLab.config.logger.warn(
724
- "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
725
- )
726
- end
727
- end
728
-
729
-
730
- def discover_mcp_tools(client, server_name)
731
- tools = client.list_tools
732
-
733
- tools.each do |tool_def|
734
- tool_name = tool_def[:name]
735
- mcp_client = client
736
-
737
- tool = Tool.create(
738
- name: tool_name,
739
- description: tool_def[:description],
740
- parameters: tool_def[:inputSchema],
741
- mcp: server_name
742
- ) { |args| mcp_client.call_tool(tool_name, args) }
743
-
744
- @mcp_tools << tool
745
- end
746
-
747
- RobotLab.config.logger.info(
748
- "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
749
- )
750
- end
751
-
752
-
753
458
  def all_tools
754
459
  @local_tools + @mcp_tools
755
460
  end
@@ -761,74 +466,5 @@ module RobotLab
761
466
 
762
467
  ToolConfig.filter_tools(available, allowed_names: allowed_names)
763
468
  end
764
-
765
-
766
- # Create a bus if one doesn't exist and connect this robot to it
767
- def ensure_bus
768
- with_bus unless @bus
769
- end
770
-
771
-
772
- # Create a typed channel on the bus and subscribe to it
773
- def setup_bus_channel
774
- channel_name = @name.to_sym
775
- @bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
776
- @bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| handle_incoming_delivery(delivery) }
777
- end
778
-
779
-
780
- # Unsubscribe from the bus channel
781
- def teardown_bus_channel
782
- channel_name = @name.to_sym
783
- @bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
784
- @bus_subscriber_id = nil
785
- end
786
-
787
-
788
- # Dispatch incoming bus delivery to handler.
789
- # Auto-ack when the handler takes 1 arg (message only);
790
- # manual ack/nack when the handler takes 2 args (delivery, message).
791
- def handle_incoming_delivery(delivery)
792
- message = delivery.message
793
-
794
- # Correlate replies with outbox entries
795
- if message.reply? && @outbox.key?(message.in_reply_to)
796
- entry = @outbox[message.in_reply_to]
797
- entry[:status] = :replied
798
- entry[:replies] << message
799
- end
800
-
801
- if @message_handler
802
- if @message_handler.arity == 1
803
- delivery.ack!
804
- @message_handler.call(message)
805
- else
806
- @message_handler.call(delivery, message)
807
- end
808
- else
809
- handle_message_via_llm(delivery, message)
810
- end
811
- rescue => e
812
- delivery.nack! if delivery.pending?
813
- raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
814
- end
815
-
816
-
817
- # Default handler: interpret message via LLM and reply
818
- def handle_message_via_llm(delivery, message)
819
- delivery.ack!
820
- result = run(message.content.to_s)
821
- send_reply(to: message.from.to_sym, content: result.last_text_content, in_reply_to: message.key)
822
- end
823
-
824
-
825
- # Publish a RobotMessage to a bus channel
826
- def publish_to_bus(channel_name, message)
827
- if defined?(Async::Task) && Async::Task.current?
828
- @bus.publish(channel_name, message)
829
- else
830
- Async { @bus.publish(channel_name, message) }
831
- end
832
- end
833
469
  end
834
470
  end