robot_lab 0.0.1 → 0.0.4

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 (145) 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 +90 -0
  5. data/README.md +203 -46
  6. data/Rakefile +70 -1
  7. data/docs/api/core/index.md +12 -0
  8. data/docs/api/core/robot.md +478 -130
  9. data/docs/api/core/tool.md +205 -209
  10. data/docs/api/history/active-record-adapter.md +174 -94
  11. data/docs/api/history/config.md +186 -93
  12. data/docs/api/history/index.md +57 -61
  13. data/docs/api/history/thread-manager.md +123 -73
  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/streaming/context.md +157 -74
  19. data/docs/api/streaming/events.md +114 -166
  20. data/docs/api/streaming/index.md +74 -72
  21. data/docs/architecture/core-concepts.md +361 -112
  22. data/docs/architecture/index.md +97 -59
  23. data/docs/architecture/message-flow.md +138 -129
  24. data/docs/architecture/network-orchestration.md +197 -50
  25. data/docs/architecture/robot-execution.md +199 -146
  26. data/docs/architecture/state-management.md +255 -187
  27. data/docs/concepts.md +312 -48
  28. data/docs/examples/basic-chat.md +89 -77
  29. data/docs/examples/index.md +222 -47
  30. data/docs/examples/mcp-server.md +207 -203
  31. data/docs/examples/multi-robot-network.md +129 -35
  32. data/docs/examples/rails-application.md +159 -160
  33. data/docs/examples/tool-usage.md +295 -204
  34. data/docs/getting-started/configuration.md +275 -162
  35. data/docs/getting-started/index.md +1 -1
  36. data/docs/getting-started/installation.md +22 -13
  37. data/docs/getting-started/quick-start.md +166 -121
  38. data/docs/guides/building-robots.md +417 -212
  39. data/docs/guides/creating-networks.md +94 -24
  40. data/docs/guides/mcp-integration.md +152 -113
  41. data/docs/guides/memory.md +220 -164
  42. data/docs/guides/streaming.md +80 -110
  43. data/docs/guides/using-tools.md +259 -212
  44. data/docs/index.md +50 -37
  45. data/examples/01_simple_robot.rb +6 -9
  46. data/examples/02_tools.rb +6 -9
  47. data/examples/03_network.rb +13 -14
  48. data/examples/04_mcp.rb +5 -8
  49. data/examples/05_streaming.rb +5 -8
  50. data/examples/06_prompt_templates.rb +42 -37
  51. data/examples/07_network_memory.rb +13 -14
  52. data/examples/08_llm_config.rb +140 -0
  53. data/examples/09_chaining.rb +223 -0
  54. data/examples/10_memory.rb +331 -0
  55. data/examples/11_network_introspection.rb +230 -0
  56. data/examples/12_message_bus.rb +74 -0
  57. data/examples/13_spawn.rb +90 -0
  58. data/examples/14_rusty_circuit/comic.rb +143 -0
  59. data/examples/14_rusty_circuit/display.rb +203 -0
  60. data/examples/14_rusty_circuit/heckler.rb +57 -0
  61. data/examples/14_rusty_circuit/open_mic.rb +121 -0
  62. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
  63. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
  64. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
  65. data/examples/14_rusty_circuit/scout.rb +173 -0
  66. data/examples/14_rusty_circuit/scout_notes.md +89 -0
  67. data/examples/14_rusty_circuit/show.log +234 -0
  68. data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
  71. data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
  72. data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
  73. data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
  74. data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
  75. data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
  76. data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
  77. data/examples/15_memory_network_and_bus/output/memory.json +13 -0
  78. data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
  79. data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
  80. data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
  81. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
  82. data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
  83. data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
  84. data/examples/README.md +197 -0
  85. data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
  86. data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
  87. data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
  88. data/examples/prompts/comedian.md +6 -0
  89. data/examples/prompts/comedy_critic.md +10 -0
  90. data/examples/prompts/configurable.md +9 -0
  91. data/examples/prompts/dispatcher.md +12 -0
  92. data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
  93. data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
  94. data/examples/prompts/frontmatter_mcp_test.md +9 -0
  95. data/examples/prompts/frontmatter_named_test.md +5 -0
  96. data/examples/prompts/frontmatter_tools_test.md +6 -0
  97. data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
  98. data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
  99. data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
  100. data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
  101. data/examples/prompts/llm_config_demo.md +20 -0
  102. data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
  103. data/examples/prompts/os_advocate.md +13 -0
  104. data/examples/prompts/os_chief.md +13 -0
  105. data/examples/prompts/os_editor.md +13 -0
  106. data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
  107. data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
  108. data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
  109. data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
  110. data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
  111. data/lib/generators/robot_lab/templates/initializer.rb.tt +1 -1
  112. data/lib/robot_lab/adapters/openai.rb +2 -1
  113. data/lib/robot_lab/ask_user.rb +75 -0
  114. data/lib/robot_lab/config/defaults.yml +121 -0
  115. data/lib/robot_lab/config.rb +183 -0
  116. data/lib/robot_lab/error.rb +6 -0
  117. data/lib/robot_lab/mcp/client.rb +1 -1
  118. data/lib/robot_lab/memory.rb +2 -2
  119. data/lib/robot_lab/robot.rb +523 -249
  120. data/lib/robot_lab/robot_message.rb +44 -0
  121. data/lib/robot_lab/robot_result.rb +1 -0
  122. data/lib/robot_lab/robotic_model.rb +1 -1
  123. data/lib/robot_lab/streaming/context.rb +1 -1
  124. data/lib/robot_lab/tool.rb +108 -172
  125. data/lib/robot_lab/tool_config.rb +1 -1
  126. data/lib/robot_lab/tool_manifest.rb +2 -18
  127. data/lib/robot_lab/version.rb +1 -1
  128. data/lib/robot_lab.rb +66 -55
  129. metadata +107 -116
  130. data/examples/prompts/assistant/user.txt.erb +0 -1
  131. data/examples/prompts/billing/user.txt.erb +0 -1
  132. data/examples/prompts/classifier/user.txt.erb +0 -1
  133. data/examples/prompts/entity_extractor/user.txt.erb +0 -3
  134. data/examples/prompts/escalation/user.txt.erb +0 -34
  135. data/examples/prompts/general/user.txt.erb +0 -1
  136. data/examples/prompts/github_assistant/user.txt.erb +0 -1
  137. data/examples/prompts/helper/user.txt.erb +0 -1
  138. data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
  139. data/examples/prompts/order_support/user.txt.erb +0 -22
  140. data/examples/prompts/product_support/user.txt.erb +0 -32
  141. data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
  142. data/examples/prompts/synthesizer/user.txt.erb +0 -15
  143. data/examples/prompts/technical/user.txt.erb +0 -1
  144. data/examples/prompts/triage/user.txt.erb +0 -17
  145. data/lib/robot_lab/configuration.rb +0 -143
@@ -1,76 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- # LLM-powered robot using ruby_llm-template for prompts
4
+ # LLM-powered robot built on RubyLLM::Agent
5
5
  #
6
- # Robot is a thin wrapper around RubyLLM.chat that provides:
7
- # - Template-based prompts via ruby_llm-template
8
- # - Build-time context (static robot configuration)
9
- # - Run-time context (per-request dynamic data)
10
- # - Tool integration via RubyLLM::Tool
11
- # - Hierarchical MCP and tools configuration
6
+ # Robot is a subclass of RubyLLM::Agent that adds:
7
+ # - Template-based prompts via prompt_manager
8
+ # - Shared memory (standalone or network)
9
+ # - Tool integration with hierarchical MCP configuration
10
+ # - SimpleFlow pipeline integration
12
11
  #
13
12
  # == Memory Behavior
14
13
  #
15
- # Robots have two memory contexts depending on how they're used:
16
- #
17
14
  # *Standalone*: Robot uses its own inherent memory (`robot.memory`).
18
- # Use `robot.reset_memory` to clear it.
19
- #
20
- # *In a Network*: Robot uses the network's shared memory (`network.memory`).
21
- # The robot's inherent memory is ignored. Use `network.reset_memory` to clear it.
22
- #
23
- # This allows the same robot instance to work both standalone and as part
24
- # of a network, with appropriate memory isolation in each context.
15
+ # *In a Network*: Robot uses the network's shared memory.
25
16
  #
26
17
  # @example Simple robot with template
27
- # robot = Robot.new(
28
- # name: "helper",
29
- # template: :helper,
30
- # context: { company_name: "Acme Corp" }
31
- # )
32
- # result = robot.run(message: "Hello!", user_name: "Alice")
18
+ # robot = Robot.new(name: "helper", template: :helper)
19
+ # result = robot.run("Hello!")
33
20
  #
34
- # @example Robot with inline system prompt (no template file needed)
35
- # robot = Robot.new(
36
- # name: "quick_bot",
37
- # system_prompt: "You are a helpful assistant. Be concise."
38
- # )
21
+ # @example Robot with inline system prompt
22
+ # robot = Robot.new(name: "bot", system_prompt: "You are helpful.")
23
+ # result = robot.run("What is 2+2?")
39
24
  #
40
- # @example Robot with template and additional system prompt
41
- # robot = Robot.new(
42
- # name: "support",
43
- # template: :support_agent,
44
- # system_prompt: "Today is #{Date.today}. Current promotion: 20% off."
45
- # )
25
+ # @example Bare robot configured via chaining
26
+ # robot = Robot.new(name: "bot")
27
+ # robot.with_instructions("Be concise.").run("Hello")
46
28
  #
47
29
  # @example Robot with tools
48
30
  # robot = Robot.new(
49
31
  # name: "support",
50
32
  # template: :support,
51
- # context: { policies: POLICIES },
52
- # tools: [OrderLookup, RefundProcessor]
33
+ # local_tools: [OrderLookup, RefundProcessor]
53
34
  # )
54
35
  #
55
- # @example Robot with hierarchical MCP/tools config
56
- # robot = Robot.new(
57
- # name: "assistant",
58
- # template: :assistant,
59
- # mcp: :inherit, # Inherit from network/config
60
- # tools: %w[search_code] # Only allow search_code tool
61
- # )
62
- #
63
- class Robot
36
+ 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
46
+
64
47
  # @!attribute [r] name
65
48
  # @return [String] the unique identifier for the robot
66
49
  # @!attribute [r] description
67
50
  # @return [String, nil] an optional description of the robot's purpose
68
51
  # @!attribute [r] template
69
- # @return [Symbol, nil] the ERB template for the robot's prompt
52
+ # @return [Symbol, nil] the prompt_manager template for the robot's prompt
70
53
  # @!attribute [r] system_prompt
71
54
  # @return [String, nil] inline system prompt (used alone or appended to template)
72
- # @!attribute [r] model
73
- # @return [String, Object] the LLM model identifier or model object
74
55
  # @!attribute [r] local_tools
75
56
  # @return [Array] the locally defined tools for this robot
76
57
  # @!attribute [r] mcp_clients
@@ -79,7 +60,15 @@ module RobotLab
79
60
  # @return [Array<Tool>] tools discovered from MCP servers
80
61
  # @!attribute [r] memory
81
62
  # @return [Memory] the robot's inherent memory (used when standalone, not in network)
82
- attr_reader :name, :description, :template, :system_prompt, :model, :local_tools, :mcp_clients, :mcp_tools, :memory
63
+ # @!attribute [rw] input
64
+ # @return [IO] input stream for user interaction (default: $stdin)
65
+ # @!attribute [rw] output
66
+ # @return [IO] output stream for user interaction (default: $stdout)
67
+ attr_accessor :input, :output
68
+
69
+ attr_reader :name, :description, :template, :system_prompt,
70
+ :local_tools, :mcp_clients, :mcp_tools, :memory,
71
+ :bus, :outbox
83
72
 
84
73
  # @!attribute [r] mcp_config
85
74
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -90,37 +79,26 @@ module RobotLab
90
79
  # Creates a new Robot instance.
91
80
  #
92
81
  # @param name [String] the unique identifier for the robot
93
- # @param template [Symbol, nil] the ERB template for the robot's prompt
94
- # @param system_prompt [String, nil] inline system prompt (can be used alone or with template)
95
- # @param context [Hash, Proc] variables to pass to the template at build time
96
- # @param description [String, nil] an optional description of the robot's purpose
97
- # @param local_tools [Array] tools defined locally for this robot
98
- # @param model [String, nil] the LLM model to use (defaults to config.default_model)
82
+ # @param template [Symbol, nil] the prompt_manager template
83
+ # @param system_prompt [String, nil] inline system prompt
84
+ # @param context [Hash, Proc] variables to pass to the template
85
+ # @param description [String, nil] optional description
86
+ # @param local_tools [Array] tools defined locally
87
+ # @param model [String, nil] the LLM model to use
99
88
  # @param mcp_servers [Array] legacy parameter for MCP server configurations
100
- # @param mcp [Symbol, Array] hierarchical MCP config (:none, :inherit, or array of servers)
101
- # @param tools [Symbol, Array] hierarchical tools config (:none, :inherit, or array of tool names)
89
+ # @param mcp [Symbol, Array] hierarchical MCP config
90
+ # @param tools [Symbol, Array] hierarchical tools config
102
91
  # @param on_tool_call [Proc, nil] callback invoked when a tool is called
103
92
  # @param on_tool_result [Proc, nil] callback invoked when a tool returns a result
104
- # @param enable_cache [Boolean] whether to enable semantic caching (default: true)
105
- #
106
- # @example Basic robot with template
107
- # Robot.new(name: "helper", template: :helper)
108
- #
109
- # @example Robot with inline system prompt
110
- # Robot.new(name: "bot", system_prompt: "You are helpful.")
111
- #
112
- # @example Robot with template and additional system prompt
113
- # Robot.new(name: "bot", template: :base, system_prompt: "Extra context here.")
114
- #
115
- # @example Robot with tools and callbacks
116
- # Robot.new(
117
- # name: "support",
118
- # template: :support,
119
- # local_tools: [OrderLookup],
120
- # on_tool_call: ->(call) { puts "Calling #{call.name}" }
121
- # )
122
- #
123
- # @raise [ArgumentError] if neither template nor system_prompt is provided
93
+ # @param enable_cache [Boolean] whether to enable semantic caching
94
+ # @param bus [TypedBus::MessageBus, nil] optional message bus for inter-robot communication
95
+ # @param temperature [Float, nil] controls randomness
96
+ # @param top_p [Float, nil] nucleus sampling threshold
97
+ # @param top_k [Integer, nil] top-k sampling
98
+ # @param max_tokens [Integer, nil] maximum tokens in response
99
+ # @param presence_penalty [Float, nil] penalize based on presence
100
+ # @param frequency_penalty [Float, nil] penalize based on frequency
101
+ # @param stop [String, Array, nil] stop sequences
124
102
  def initialize(
125
103
  name:,
126
104
  template: nil,
@@ -134,24 +112,27 @@ module RobotLab
134
112
  tools: :none,
135
113
  on_tool_call: nil,
136
114
  on_tool_result: nil,
137
- enable_cache: true
115
+ enable_cache: true,
116
+ bus: nil,
117
+ temperature: nil,
118
+ top_p: nil,
119
+ top_k: nil,
120
+ max_tokens: nil,
121
+ presence_penalty: nil,
122
+ frequency_penalty: nil,
123
+ stop: nil
138
124
  )
139
- unless template || system_prompt
140
- raise ArgumentError, "Must provide either template or system_prompt"
141
- end
142
-
143
125
  @name = name.to_s
126
+ @name_from_constructor = (name.to_s != "robot")
144
127
  @template = template
145
128
  @system_prompt = system_prompt
146
129
  @build_context = context
147
130
  @description = description
148
131
  @local_tools = Array(local_tools)
149
- @model = model || RobotLab.configuration.default_model
150
132
  @on_tool_call = on_tool_call
151
133
  @on_tool_result = on_tool_result
152
134
 
153
135
  # Store raw config values for hierarchical resolution
154
- # mcp_servers is legacy parameter, mcp is the new hierarchical one
155
136
  @mcp_config = mcp_servers.any? ? mcp_servers : mcp
156
137
  @tools_config = tools
157
138
 
@@ -160,45 +141,97 @@ module RobotLab
160
141
  @mcp_tools = []
161
142
  @mcp_initialized = false
162
143
 
144
+ # Bus state (optional inter-robot communication)
145
+ @bus = bus
146
+ @message_counter = 0
147
+ @outbox = {}
148
+ @message_handler = nil
149
+
163
150
  # Inherent memory (used when standalone, not in a network)
164
151
  @memory = Memory.new(enable_cache: enable_cache)
152
+
153
+ # Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
154
+ config = RobotLab.config
155
+
156
+ # Build chat kwargs for Agent's super
157
+ resolved_model = model || config.ruby_llm.model
158
+ chat_kwargs = { model: resolved_model }
159
+
160
+ # Create the persistent chat via Agent's initialize
161
+ super(chat: nil, **chat_kwargs)
162
+
163
+ # Apply template first (includes front matter config like model, temperature)
164
+ # then constructor params override — constructor is more specific than template.
165
+ apply_template_to_chat(context) if @template
166
+ @chat.with_instructions(@system_prompt) if @system_prompt
167
+
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)
176
+
177
+ # Apply callbacks
178
+ @chat.on_tool_call(&@on_tool_call) if @on_tool_call
179
+ @chat.on_tool_result(&@on_tool_result) if @on_tool_result
180
+
181
+ # Set up bus channel if a bus was provided
182
+ setup_bus_channel if @bus
165
183
  end
166
184
 
167
- # Returns the robot's local tools (alias for local_tools).
185
+
186
+ # Returns the model identifier
168
187
  #
169
- # Provided for backward compatibility with earlier API versions.
188
+ # @return [String, nil] the LLM model ID string
189
+ def model
190
+ return nil unless @chat.respond_to?(:model)
191
+
192
+ m = @chat.model
193
+ m.respond_to?(:id) ? m.id : m.to_s
194
+ end
195
+
196
+ # Forward with_* methods to the persistent chat, returning self for chaining
197
+ %i[
198
+ with_model with_temperature with_top_p with_top_k with_max_tokens
199
+ with_presence_penalty with_frequency_penalty with_stop
200
+ with_instructions with_tool with_tools with_params
201
+ with_headers with_schema with_context with_thinking
202
+ ].each do |method|
203
+ define_method(method) do |*args, **kwargs, &block|
204
+ @chat.public_send(method, *args, **kwargs, &block)
205
+ self
206
+ end
207
+ end
208
+
209
+ # Apply a prompt_manager template to the robot's chat
170
210
  #
171
- # @return [Array] the locally defined tools
172
- def tools
173
- @local_tools
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
174
219
  end
175
220
 
176
- # Run the robot with the given context
221
+
222
+ # Send a message and get a response, with Robot's extended capabilities
177
223
  #
178
- # @param network [NetworkRun, nil] Network context if running in network (legacy)
179
- # @param network_memory [Memory, nil] Shared network memory (preferred)
180
- # @param memory [Memory, Hash, nil] Runtime memory to merge
181
- # @param mcp [Symbol, Array, nil] Runtime MCP override (:inherit, :none, nil, [], or array of servers)
182
- # @param tools [Symbol, Array, nil] Runtime tools override (:inherit, :none, nil, [], or array of tool names)
183
- # @param run_context [Hash] Context for rendering user template
224
+ # @param message [String] the user message
225
+ # @param network [NetworkRun, nil] network context (legacy)
226
+ # @param network_memory [Memory, nil] shared network memory
227
+ # @param memory [Memory, Hash, nil] runtime memory to merge
228
+ # @param mcp [Symbol, Array, nil] runtime MCP override
229
+ # @param tools [Symbol, Array, nil] runtime tools override
184
230
  # @return [RobotResult]
185
- #
186
- # @example Standalone robot with inherent memory
187
- # robot.run(message: "My name is Alice")
188
- # robot.run(message: "What's my name?") # Memory persists
189
- #
190
- # @example Runtime memory injection
191
- # robot.run(message: "Hello", memory: { user_id: 123, session: "abc" })
192
- #
193
- # @example With network shared memory
194
- # robot.run(message: "Analyze this", network_memory: network.memory)
195
- #
196
- def run(network: nil, network_memory: nil, memory: nil, mcp: :none, tools: :none, **run_context)
197
- # Determine which memory to use (priority order):
198
- # 1. Explicit network_memory parameter
199
- # 2. Network object's memory (legacy)
200
- # 3. Robot's inherent memory
201
- run_memory = network_memory || network&.memory || @memory
231
+ def run(message = nil, network: nil, network_memory: nil, memory: nil, mcp: :none, tools: :none,
232
+ **kwargs)
233
+ # Determine which memory to use
234
+ run_memory = resolve_active_memory(network: network, network_memory: network_memory)
202
235
 
203
236
  # Merge runtime memory if provided
204
237
  case memory
@@ -220,84 +253,219 @@ module RobotLab
220
253
  # Initialize or update MCP clients based on resolved config
221
254
  ensure_mcp_clients(resolved_mcp)
222
255
 
223
- # Merge build context + run context
224
- full_context = resolve_context(@build_context, network: network)
225
- .merge(run_context)
256
+ # Apply filtered tools to the persistent chat
257
+ filtered = filtered_tools(resolved_tools)
258
+ @chat.with_tools(*filtered) if filtered.any?
226
259
 
227
- # Build chat with template, filtered tools, and semantic cache
228
- chat = build_chat(full_context, allowed_tools: resolved_tools, memory: run_memory)
260
+ # Re-render template with run-time context merged into build-time context.
261
+ # Template parameters (e.g. customer: null) may require values that are
262
+ # only available at run time — the robot gathers them before rendering.
263
+ run_context = kwargs.except(:with)
264
+ rerender_template(run_context) if @template && run_context.any?
229
265
 
230
- # Execute and return result
231
- response = chat.complete
266
+ # Delegate to Agent's ask (which calls @chat.ask)
267
+ ask_kwargs = kwargs.slice(:with)
268
+ response = ask(message, **ask_kwargs)
232
269
 
233
270
  build_result(response, run_memory)
234
271
  ensure
235
- # Restore previous writer
236
272
  run_memory.current_writer = previous_writer
237
273
  end
238
274
  end
239
275
 
240
- # SimpleFlow step interface
276
+
277
+ # Reconfigure the robot for a new context
241
278
  #
242
- # Allows Robot to be used directly as a step in a SimpleFlow::Pipeline.
243
- # The robot receives a SimpleFlow::Result, executes, and returns a new
244
- # SimpleFlow::Result with the robot's output.
279
+ # @param template [Symbol, nil] new template to apply
280
+ # @param context [Hash, nil] new context for the template
281
+ # @param system_prompt [String, nil] new system prompt
282
+ # @param model [String, nil] new model
283
+ # @param temperature [Float, nil] new temperature
284
+ # @return [self]
285
+ def update(template: nil, context: nil, system_prompt: nil, model: nil, temperature: nil, **kwargs)
286
+ if template
287
+ @template = template
288
+ ctx = context || @build_context
289
+ apply_template_to_chat(ctx)
290
+ end
291
+
292
+ @chat.with_instructions(system_prompt) if system_prompt
293
+ @chat.with_model(model) if model
294
+ apply_chat_option(:with_temperature, temperature)
295
+
296
+ kwargs.each do |key, value|
297
+ method = :"with_#{key}"
298
+ @chat.public_send(method, value) if value && @chat.respond_to?(method)
299
+ end
300
+
301
+ self
302
+ end
303
+
304
+
305
+ # SimpleFlow step interface
245
306
  #
246
307
  # @param result [SimpleFlow::Result] incoming result from previous step
247
308
  # @return [SimpleFlow::Result] result with robot output
248
- #
249
- # @example Using a robot as a pipeline step
250
- # pipeline = SimpleFlow::Pipeline.new do
251
- # step :classifier, classifier_robot, depends_on: :none
252
- # step :billing, billing_robot, depends_on: :optional
253
- # end
254
- #
255
309
  def call(result)
256
- robot_result = run(**extract_run_context(result))
310
+ run_context = extract_run_context(result)
311
+
312
+ # Extract the message from run context
313
+ message = run_context.delete(:message)
314
+
315
+ robot_result = run(message, **run_context)
257
316
 
258
317
  result
259
318
  .with_context(@name.to_sym, robot_result)
260
319
  .continue(robot_result)
261
320
  end
262
321
 
322
+
263
323
  # Reset the robot's inherent memory
264
324
  #
265
- # NOTE: This only affects the robot's standalone memory. When a robot runs
266
- # as part of a network, it uses the network's shared memory instead.
267
- # To reset memory for network execution, use `network.reset_memory`.
268
- #
269
325
  # @return [self]
326
+ def reset_memory
327
+ @memory.reset
328
+ self
329
+ end
330
+
331
+
332
+ # Send a message to another robot via the bus.
270
333
  #
271
- # @example Standalone robot
272
- # robot.run(message: "My name is Alice")
273
- # robot.reset_memory # Clears the conversation
274
- # robot.run(message: "What's my name?") # Won't remember Alice
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.
275
350
  #
276
- # @example Robot in network (reset_memory has no effect on network runs)
277
- # network.run(message: "Hello")
278
- # robot.reset_memory # Does NOT affect network memory
279
- # network.run(message: "Hi") # Network memory still intact
280
- # network.reset_memory # Use this to reset network memory
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.
281
367
  #
282
- def reset_memory
283
- @memory.reset
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
284
386
  self
285
387
  end
286
388
 
287
- # Disconnect all MCP clients
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.
288
432
  #
289
- # Call this method when done using the robot to clean up MCP connections.
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.
290
436
  #
437
+ # @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
291
438
  # @return [self]
292
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
+ # Disconnect all MCP clients and bus channel.
457
+ #
458
+ # @return [self]
293
459
  def disconnect
294
460
  @mcp_clients.each_value(&:disconnect)
461
+ teardown_bus_channel if @bus
295
462
  self
296
463
  end
297
464
 
298
- # Converts the robot to a hash representation.
465
+
466
+ # Converts the robot to a hash representation
299
467
  #
300
- # @return [Hash] a hash containing the robot's configuration
468
+ # @return [Hash]
301
469
  def to_h
302
470
  {
303
471
  name: name,
@@ -309,24 +477,131 @@ module RobotLab
309
477
  mcp_config: @mcp_config,
310
478
  tools_config: @tools_config,
311
479
  mcp_servers: @mcp_clients.keys,
312
- model: model.respond_to?(:model_id) ? model.model_id : model
480
+ model: model,
481
+ bus: @bus ? true : nil
313
482
  }.compact
314
483
  end
315
484
 
316
485
  private
317
486
 
487
+ # Apply a chat option if the value is non-nil
488
+ def apply_chat_option(method, value)
489
+ @chat.public_send(method, value) if value
490
+ end
491
+
492
+
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
+ # Determine which memory to use
595
+ def resolve_active_memory(network: nil, network_memory: nil)
596
+ network_memory || network&.memory || @memory
597
+ end
598
+
599
+
318
600
  # Extract run context from SimpleFlow::Result
319
- #
320
- # Merges original run params (preserved in context) with current value.
321
- # Extracts special parameters (mcp, tools, memory, network_memory) for Robot#run.
322
- #
323
- # @param result [SimpleFlow::Result] the incoming result
324
- # @return [Hash] context for run method including mcp/tools config
325
- #
326
601
  def extract_run_context(result)
327
602
  run_params = result.context[:run_params] || {}
328
603
 
329
- # Extract robot-specific params that should be passed to run()
604
+ # Extract robot-specific params
330
605
  mcp = run_params.delete(:mcp) || :none
331
606
  tools = run_params.delete(:tools) || :none
332
607
  memory = run_params.delete(:memory)
@@ -347,7 +622,7 @@ module RobotLab
347
622
  base.merge(message: result.value.to_s)
348
623
  end
349
624
 
350
- # Add back the special params for run()
625
+ # Add back the special params
351
626
  merged[:mcp] = mcp
352
627
  merged[:tools] = tools
353
628
  merged[:memory] = memory if memory
@@ -356,6 +631,7 @@ module RobotLab
356
631
  merged
357
632
  end
358
633
 
634
+
359
635
  def resolve_context(context, network:)
360
636
  case context
361
637
  when Proc then context.call(network: network)
@@ -364,41 +640,10 @@ module RobotLab
364
640
  end
365
641
  end
366
642
 
367
- def build_chat(context, allowed_tools:, memory:)
368
- model_id = @model.respond_to?(:model_id) ? @model.model_id : @model.to_s
369
-
370
- chat = RubyLLM.chat(model: model_id)
371
-
372
- # Apply template and/or system_prompt
373
- # - Template only: use with_template
374
- # - system_prompt only: use with_instructions
375
- # - Both: use with_template, then append with_instructions
376
- if @template
377
- chat = chat.with_template(@template, **context)
378
- chat = chat.with_instructions(@system_prompt) if @system_prompt
379
- else
380
- chat = chat.with_instructions(@system_prompt)
381
- end
382
-
383
- # Get filtered tools based on whitelist
384
- filtered = filtered_tools(allowed_tools)
385
- chat = chat.with_tools(*filtered) if filtered.any?
386
-
387
- # Add callbacks if provided
388
- chat = chat.on_tool_call(&@on_tool_call) if @on_tool_call
389
- chat = chat.on_tool_result(&@on_tool_result) if @on_tool_result
390
-
391
- # NOTE: Semantic cache wrapping is disabled because the SemanticCache::Middleware
392
- # only supports `ask` method, not `complete`. The caching feature needs to be
393
- # re-designed to use the `ask` interface or the `fetch` pattern.
394
- # See: https://github.com/ruby-llm/ruby_llm-semantic_cache
395
-
396
- chat
397
- end
398
643
 
399
644
  def build_result(response, _memory)
400
645
  output = if response.respond_to?(:content) && response.content
401
- [TextMessage.new(role: "assistant", content: response.content)]
646
+ [TextMessage.new(role: 'assistant', content: response.content)]
402
647
  else
403
648
  []
404
649
  end
@@ -413,6 +658,7 @@ module RobotLab
413
658
  )
414
659
  end
415
660
 
661
+
416
662
  def normalize_tool_calls(tool_calls)
417
663
  return [] unless tool_calls
418
664
 
@@ -420,7 +666,7 @@ module RobotLab
420
666
  if tc.is_a?(Hash)
421
667
  ToolResultMessage.new(
422
668
  tool: tc,
423
- content: tc[:result] || tc["result"]
669
+ content: tc[:result] || tc['result']
424
670
  )
425
671
  else
426
672
  tc
@@ -428,57 +674,32 @@ module RobotLab
428
674
  end
429
675
  end
430
676
 
677
+
431
678
  # Resolve MCP hierarchy: runtime -> robot build -> network -> config
432
- #
433
- # @param runtime_value [Symbol, Array, nil] Runtime MCP override
434
- # @param network [NetworkRun, nil] Network context
435
- # @return [Array] Resolved MCP server configurations
436
- #
437
679
  def resolve_mcp_hierarchy(runtime_value, network:)
438
- # Get parent value (network or config)
439
- parent_value = network&.network&.mcp || RobotLab.configuration.mcp
440
-
441
- # Resolve robot build config against parent
680
+ parent_value = network&.network&.mcp || RobotLab.config.mcp
442
681
  build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
443
-
444
- # Resolve runtime against build
445
682
  ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
446
683
  end
447
684
 
685
+
448
686
  # Resolve tools hierarchy: runtime -> robot build -> network -> config
449
- #
450
- # @param runtime_value [Symbol, Array, nil] Runtime tools override
451
- # @param network [NetworkRun, nil] Network context
452
- # @return [Array<String>] Resolved tool names whitelist
453
- #
454
687
  def resolve_tools_hierarchy(runtime_value, network:)
455
- # Get parent value (network or config)
456
- parent_value = network&.network&.tools || RobotLab.configuration.tools
457
-
458
- # Resolve robot build config against parent
688
+ parent_value = network&.network&.tools || RobotLab.config.tools
459
689
  build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
460
-
461
- # Resolve runtime against build
462
690
  ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
463
691
  end
464
692
 
693
+
465
694
  # Ensure MCP clients are initialized for the given server configs
466
- #
467
- # @param mcp_servers [Array] MCP server configurations
468
- #
469
695
  def ensure_mcp_clients(mcp_servers)
470
696
  return if mcp_servers.empty?
471
697
 
472
- # Get server names from configs
473
698
  needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
474
-
475
- # Skip if already initialized with same servers
476
699
  return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
477
700
 
478
- # Disconnect existing clients if config changed
479
701
  disconnect if @mcp_initialized
480
702
 
481
- # Initialize new clients
482
703
  @mcp_clients = {}
483
704
  @mcp_tools = []
484
705
 
@@ -489,10 +710,7 @@ module RobotLab
489
710
  @mcp_initialized = true
490
711
  end
491
712
 
492
- # Initialize a single MCP client
493
- #
494
- # @param server_config [Hash] MCP server configuration
495
- #
713
+
496
714
  def init_mcp_client(server_config)
497
715
  client = MCP::Client.new(server_config)
498
716
  client.connect
@@ -502,17 +720,13 @@ module RobotLab
502
720
  @mcp_clients[server_name] = client
503
721
  discover_mcp_tools(client, server_name)
504
722
  else
505
- RobotLab.configuration.logger.warn(
723
+ RobotLab.config.logger.warn(
506
724
  "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
507
725
  )
508
726
  end
509
727
  end
510
728
 
511
- # Discover tools from an MCP server and add them to @mcp_tools
512
- #
513
- # @param client [MCP::Client] Connected MCP client
514
- # @param server_name [String] Name of the MCP server
515
- #
729
+
516
730
  def discover_mcp_tools(client, server_name)
517
731
  tools = client.list_tools
518
732
 
@@ -520,41 +734,101 @@ module RobotLab
520
734
  tool_name = tool_def[:name]
521
735
  mcp_client = client
522
736
 
523
- # Create a Tool that delegates to the MCP client
524
- tool = Tool.new(
737
+ tool = Tool.create(
525
738
  name: tool_name,
526
739
  description: tool_def[:description],
527
740
  parameters: tool_def[:inputSchema],
528
- mcp: server_name,
529
- handler: ->(input, **_opts) { mcp_client.call_tool(tool_name, input) }
530
- )
741
+ mcp: server_name
742
+ ) { |args| mcp_client.call_tool(tool_name, args) }
531
743
 
532
744
  @mcp_tools << tool
533
745
  end
534
746
 
535
- RobotLab.configuration.logger.info(
747
+ RobotLab.config.logger.info(
536
748
  "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
537
749
  )
538
750
  end
539
751
 
540
- # Get all tools (local + MCP)
541
- #
542
- # @return [Array] Combined array of local and MCP tools
543
- #
752
+
544
753
  def all_tools
545
754
  @local_tools + @mcp_tools
546
755
  end
547
756
 
548
- # Filter tools based on allowed tool names whitelist
549
- #
550
- # @param allowed_names [Array<String>] Whitelist of tool names (empty = all allowed)
551
- # @return [Array] Filtered tools
552
- #
757
+
553
758
  def filtered_tools(allowed_names)
554
759
  available = all_tools
555
760
  return available if allowed_names.empty?
556
761
 
557
762
  ToolConfig.filter_tools(available, allowed_names: allowed_names)
558
763
  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
559
833
  end
560
834
  end