robot_lab 0.0.1 → 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/deploy-github-pages.yml +9 -9
  3. data/.irbrc +6 -0
  4. data/CHANGELOG.md +140 -0
  5. data/README.md +263 -48
  6. data/Rakefile +71 -1
  7. data/docs/api/core/index.md +53 -46
  8. data/docs/api/core/memory.md +200 -154
  9. data/docs/api/core/network.md +13 -3
  10. data/docs/api/core/robot.md +490 -130
  11. data/docs/api/core/state.md +55 -73
  12. data/docs/api/core/tool.md +205 -209
  13. data/docs/api/index.md +7 -28
  14. data/docs/api/mcp/client.md +119 -48
  15. data/docs/api/mcp/index.md +75 -60
  16. data/docs/api/mcp/server.md +120 -136
  17. data/docs/api/mcp/transports.md +172 -184
  18. data/docs/api/messages/index.md +35 -20
  19. data/docs/api/messages/text-message.md +67 -21
  20. data/docs/api/messages/tool-call-message.md +80 -41
  21. data/docs/api/messages/tool-result-message.md +119 -50
  22. data/docs/api/messages/user-message.md +48 -24
  23. data/docs/api/streaming/context.md +157 -74
  24. data/docs/api/streaming/events.md +114 -166
  25. data/docs/api/streaming/index.md +74 -72
  26. data/docs/architecture/core-concepts.md +360 -116
  27. data/docs/architecture/index.md +97 -59
  28. data/docs/architecture/message-flow.md +138 -129
  29. data/docs/architecture/network-orchestration.md +197 -50
  30. data/docs/architecture/robot-execution.md +199 -146
  31. data/docs/architecture/state-management.md +255 -187
  32. data/docs/concepts.md +311 -49
  33. data/docs/examples/basic-chat.md +89 -77
  34. data/docs/examples/index.md +222 -47
  35. data/docs/examples/mcp-server.md +207 -203
  36. data/docs/examples/multi-robot-network.md +129 -35
  37. data/docs/examples/rails-application.md +159 -160
  38. data/docs/examples/tool-usage.md +295 -204
  39. data/docs/getting-started/configuration.md +347 -154
  40. data/docs/getting-started/index.md +1 -1
  41. data/docs/getting-started/installation.md +22 -13
  42. data/docs/getting-started/quick-start.md +166 -121
  43. data/docs/guides/building-robots.md +418 -212
  44. data/docs/guides/creating-networks.md +143 -24
  45. data/docs/guides/index.md +0 -5
  46. data/docs/guides/mcp-integration.md +152 -113
  47. data/docs/guides/memory.md +220 -164
  48. data/docs/guides/rails-integration.md +244 -162
  49. data/docs/guides/streaming.md +137 -187
  50. data/docs/guides/using-tools.md +259 -212
  51. data/docs/index.md +46 -41
  52. data/examples/01_simple_robot.rb +6 -9
  53. data/examples/02_tools.rb +6 -9
  54. data/examples/03_network.rb +19 -17
  55. data/examples/04_mcp.rb +5 -8
  56. data/examples/05_streaming.rb +5 -8
  57. data/examples/06_prompt_templates.rb +42 -37
  58. data/examples/07_network_memory.rb +13 -14
  59. data/examples/08_llm_config.rb +169 -0
  60. data/examples/09_chaining.rb +262 -0
  61. data/examples/10_memory.rb +331 -0
  62. data/examples/11_network_introspection.rb +253 -0
  63. data/examples/12_message_bus.rb +74 -0
  64. data/examples/13_spawn.rb +90 -0
  65. data/examples/14_rusty_circuit/comic.rb +143 -0
  66. data/examples/14_rusty_circuit/display.rb +203 -0
  67. data/examples/14_rusty_circuit/heckler.rb +63 -0
  68. data/examples/14_rusty_circuit/open_mic.rb +123 -0
  69. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
  70. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
  71. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
  72. data/examples/14_rusty_circuit/scout.rb +156 -0
  73. data/examples/14_rusty_circuit/scout_notes.md +89 -0
  74. data/examples/14_rusty_circuit/show.log +234 -0
  75. data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
  78. data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
  79. data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
  80. data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
  81. data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
  82. data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
  83. data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
  84. data/examples/15_memory_network_and_bus/output/memory.json +13 -0
  85. data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
  86. data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
  87. data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
  88. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
  89. data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
  90. data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
  91. data/examples/16_writers_room/display.rb +158 -0
  92. data/examples/16_writers_room/output/.gitignore +2 -0
  93. data/examples/16_writers_room/output/opus_001.md +263 -0
  94. data/examples/16_writers_room/output/opus_001_notes.log +470 -0
  95. data/examples/16_writers_room/prompts/writer.md +37 -0
  96. data/examples/16_writers_room/room.rb +150 -0
  97. data/examples/16_writers_room/tools.rb +162 -0
  98. data/examples/16_writers_room/writer.rb +121 -0
  99. data/examples/16_writers_room/writers_room.rb +162 -0
  100. data/examples/README.md +197 -0
  101. data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
  102. data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
  103. data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
  104. data/examples/prompts/comedian.md +6 -0
  105. data/examples/prompts/comedy_critic.md +10 -0
  106. data/examples/prompts/configurable.md +9 -0
  107. data/examples/prompts/dispatcher.md +12 -0
  108. data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
  109. data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
  110. data/examples/prompts/frontmatter_mcp_test.md +9 -0
  111. data/examples/prompts/frontmatter_named_test.md +5 -0
  112. data/examples/prompts/frontmatter_tools_test.md +6 -0
  113. data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
  114. data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
  115. data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
  116. data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
  117. data/examples/prompts/llm_config_demo.md +20 -0
  118. data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
  119. data/examples/prompts/os_advocate.md +13 -0
  120. data/examples/prompts/os_chief.md +13 -0
  121. data/examples/prompts/os_editor.md +13 -0
  122. data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
  123. data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
  124. data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
  125. data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
  126. data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
  127. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
  128. data/lib/robot_lab/ask_user.rb +75 -0
  129. data/lib/robot_lab/config/defaults.yml +121 -0
  130. data/lib/robot_lab/config.rb +183 -0
  131. data/lib/robot_lab/error.rb +6 -0
  132. data/lib/robot_lab/mcp/client.rb +1 -1
  133. data/lib/robot_lab/memory.rb +10 -34
  134. data/lib/robot_lab/network.rb +13 -20
  135. data/lib/robot_lab/robot/bus_messaging.rb +239 -0
  136. data/lib/robot_lab/robot/mcp_management.rb +88 -0
  137. data/lib/robot_lab/robot/template_rendering.rb +130 -0
  138. data/lib/robot_lab/robot.rb +240 -330
  139. data/lib/robot_lab/robot_message.rb +44 -0
  140. data/lib/robot_lab/robot_result.rb +1 -0
  141. data/lib/robot_lab/run_config.rb +184 -0
  142. data/lib/robot_lab/state_proxy.rb +2 -12
  143. data/lib/robot_lab/streaming/context.rb +1 -1
  144. data/lib/robot_lab/task.rb +8 -1
  145. data/lib/robot_lab/tool.rb +108 -172
  146. data/lib/robot_lab/tool_config.rb +1 -1
  147. data/lib/robot_lab/tool_manifest.rb +2 -18
  148. data/lib/robot_lab/utils.rb +39 -0
  149. data/lib/robot_lab/version.rb +1 -1
  150. data/lib/robot_lab.rb +89 -57
  151. data/mkdocs.yml +0 -11
  152. metadata +121 -135
  153. data/docs/api/adapters/anthropic.md +0 -121
  154. data/docs/api/adapters/gemini.md +0 -133
  155. data/docs/api/adapters/index.md +0 -104
  156. data/docs/api/adapters/openai.md +0 -134
  157. data/docs/api/history/active-record-adapter.md +0 -195
  158. data/docs/api/history/config.md +0 -191
  159. data/docs/api/history/index.md +0 -132
  160. data/docs/api/history/thread-manager.md +0 -144
  161. data/docs/guides/history.md +0 -359
  162. data/examples/prompts/assistant/user.txt.erb +0 -1
  163. data/examples/prompts/billing/user.txt.erb +0 -1
  164. data/examples/prompts/classifier/user.txt.erb +0 -1
  165. data/examples/prompts/entity_extractor/user.txt.erb +0 -3
  166. data/examples/prompts/escalation/user.txt.erb +0 -34
  167. data/examples/prompts/general/user.txt.erb +0 -1
  168. data/examples/prompts/github_assistant/user.txt.erb +0 -1
  169. data/examples/prompts/helper/user.txt.erb +0 -1
  170. data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
  171. data/examples/prompts/order_support/user.txt.erb +0 -22
  172. data/examples/prompts/product_support/user.txt.erb +0 -32
  173. data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
  174. data/examples/prompts/synthesizer/user.txt.erb +0 -15
  175. data/examples/prompts/technical/user.txt.erb +0 -1
  176. data/examples/prompts/triage/user.txt.erb +0 -17
  177. data/lib/robot_lab/adapters/anthropic.rb +0 -163
  178. data/lib/robot_lab/adapters/base.rb +0 -85
  179. data/lib/robot_lab/adapters/gemini.rb +0 -193
  180. data/lib/robot_lab/adapters/openai.rb +0 -159
  181. data/lib/robot_lab/adapters/registry.rb +0 -81
  182. data/lib/robot_lab/configuration.rb +0 -143
  183. data/lib/robot_lab/errors.rb +0 -70
  184. data/lib/robot_lab/history/active_record_adapter.rb +0 -146
  185. data/lib/robot_lab/history/config.rb +0 -115
  186. data/lib/robot_lab/history/thread_manager.rb +0 -93
  187. data/lib/robot_lab/robotic_model.rb +0 -324
@@ -59,6 +59,8 @@ module RobotLab
59
59
  # memory.cache # => RubyLLM::SemanticCache instance
60
60
  #
61
61
  class Memory
62
+ include Utils
63
+
62
64
  # Reserved keys that have special behavior
63
65
  RESERVED_KEYS = %i[data results messages session_id cache].freeze
64
66
 
@@ -553,8 +555,8 @@ module RobotLab
553
555
  def clone
554
556
  cloned = Memory.new(
555
557
  data: deep_dup(data.to_h),
556
- results: results.dup,
557
- messages: messages.dup,
558
+ results: results,
559
+ messages: messages,
558
560
  session_id: session_id,
559
561
  backend: @backend.is_a?(Hash) ? :hash : :auto,
560
562
  enable_cache: @enable_cache,
@@ -654,7 +656,7 @@ module RobotLab
654
656
  return false unless defined?(Redis)
655
657
 
656
658
  # Check if Redis is configured in RobotLab
657
- redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
659
+ redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
658
660
  redis_config || ENV["REDIS_URL"]
659
661
  end
660
662
 
@@ -685,25 +687,15 @@ module RobotLab
685
687
  ->(result) { result.output + result.tool_calls }
686
688
  end
687
689
 
688
- def deep_dup(obj)
689
- case obj
690
- when Hash
691
- obj.transform_values { |v| deep_dup(v) }
692
- when Array
693
- obj.map { |v| deep_dup(v) }
694
- else
695
- obj.dup rescue obj
696
- end
697
- end
698
-
699
690
  # =========================================================================
700
691
  # Reactive Memory Helpers
701
692
  # =========================================================================
702
693
 
703
694
  def get_single(key, wait:)
704
695
  # Try immediate read
705
- value = @mutex.synchronize { @backend[key] }
706
- return value unless value.nil? && wait
696
+ @mutex.synchronize { return @backend[key] if @backend.key?(key) }
697
+
698
+ return nil unless wait
707
699
 
708
700
  # Need to wait
709
701
  timeout = wait == true ? nil : wait
@@ -740,8 +732,7 @@ module RobotLab
740
732
 
741
733
  @waiter_mutex.synchronize do
742
734
  # Double-check - value might have arrived while setting up
743
- value = @mutex.synchronize { @backend[key] }
744
- return value unless value.nil?
735
+ @mutex.synchronize { return @backend[key] if @backend.key?(key) }
745
736
 
746
737
  @waiters[key] << waiter
747
738
  end
@@ -795,21 +786,6 @@ module RobotLab
795
786
  end
796
787
  end
797
788
 
798
- def dispatch_async(&block)
799
- # Use Async if available (preferred for fiber-based concurrency)
800
- if defined?(Async) && Async::Task.current?
801
- Async { block.call }
802
- else
803
- # Fall back to Thread for basic async dispatch
804
- Thread.new do
805
- block.call
806
- rescue StandardError => e
807
- # Log but don't crash the notification system
808
- warn "Memory subscription callback error: #{e.message}"
809
- end
810
- end
811
- end
812
-
813
789
  def generate_subscription_id
814
790
  SecureRandom.uuid
815
791
  end
@@ -868,7 +844,7 @@ module RobotLab
868
844
  private
869
845
 
870
846
  def create_redis_connection
871
- redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
847
+ redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
872
848
 
873
849
  if redis_config.is_a?(Hash)
874
850
  Redis.new(**redis_config)
@@ -58,6 +58,8 @@ module RobotLab
58
58
  # network.broadcast(event: :pause, reason: "rate limit")
59
59
  #
60
60
  class Network
61
+ include Utils
62
+
61
63
  # Reserved key for broadcast messages in memory
62
64
  BROADCAST_KEY = :_network_broadcast
63
65
 
@@ -69,7 +71,7 @@ module RobotLab
69
71
  # @return [Hash<String, Robot>] robots in this network, keyed by name
70
72
  # @!attribute [r] memory
71
73
  # @return [Memory] shared memory for all robots in the network
72
- attr_reader :name, :pipeline, :robots, :memory
74
+ attr_reader :name, :pipeline, :robots, :memory, :config
73
75
 
74
76
  # Creates a new Network instance.
75
77
  #
@@ -84,12 +86,13 @@ module RobotLab
84
86
  # task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
85
87
  # end
86
88
  #
87
- def initialize(name:, concurrency: :auto, memory: nil, &block)
89
+ def initialize(name:, concurrency: :auto, memory: nil, config: nil, &block)
88
90
  @name = name.to_s
89
91
  @robots = {}
90
92
  @tasks = {}
91
93
  @pipeline = SimpleFlow::Pipeline.new(concurrency: concurrency)
92
94
  @memory = memory || Memory.new(network_name: @name)
95
+ @config = config || RunConfig.new
93
96
  @broadcast_handlers = []
94
97
 
95
98
  instance_eval(&block) if block_given?
@@ -118,14 +121,15 @@ module RobotLab
118
121
  # @example Task with dependencies
119
122
  # task :writer, writer_robot, depends_on: [:analyst]
120
123
  #
121
- def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, depends_on: :none)
124
+ def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none)
122
125
  task_wrapper = Task.new(
123
126
  name: name,
124
127
  robot: robot,
125
128
  context: context,
126
129
  mcp: mcp,
127
130
  tools: tools,
128
- memory: memory
131
+ memory: memory,
132
+ config: config
129
133
  )
130
134
 
131
135
  @robots[name.to_s] = robot
@@ -170,6 +174,9 @@ module RobotLab
170
174
  # Include shared memory in run params so robots can access it
171
175
  run_context[:network_memory] = @memory
172
176
 
177
+ # Pass network's config so robots can inherit it
178
+ run_context[:network_config] = @config unless @config.empty?
179
+
173
180
  initial_result = SimpleFlow::Result.new(
174
181
  run_context,
175
182
  context: { run_params: run_context }
@@ -327,24 +334,10 @@ module RobotLab
327
334
  name: name,
328
335
  robots: @robots.keys,
329
336
  tasks: @tasks.keys,
330
- optional_tasks: @pipeline.optional_steps.to_a
337
+ optional_tasks: @pipeline.optional_steps.to_a,
338
+ config: (@config.empty? ? nil : @config.to_json_hash)
331
339
  }.compact
332
340
  end
333
341
 
334
- private
335
-
336
- def dispatch_async(&block)
337
- # Use Async if available (preferred for fiber-based concurrency)
338
- if defined?(Async) && Async::Task.current?
339
- Async { block.call }
340
- else
341
- # Fall back to Thread for basic async dispatch
342
- Thread.new do
343
- block.call
344
- rescue StandardError => e
345
- warn "Network broadcast handler error: #{e.message}"
346
- end
347
- end
348
- end
349
342
  end
350
343
  end
@@ -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