robot_lab 0.0.1

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 (153) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.github/workflows/deploy-yard-docs.yml +52 -0
  5. data/CHANGELOG.md +55 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +332 -0
  9. data/Rakefile +67 -0
  10. data/docs/api/adapters/anthropic.md +121 -0
  11. data/docs/api/adapters/gemini.md +133 -0
  12. data/docs/api/adapters/index.md +104 -0
  13. data/docs/api/adapters/openai.md +134 -0
  14. data/docs/api/core/index.md +113 -0
  15. data/docs/api/core/memory.md +314 -0
  16. data/docs/api/core/network.md +291 -0
  17. data/docs/api/core/robot.md +273 -0
  18. data/docs/api/core/state.md +273 -0
  19. data/docs/api/core/tool.md +353 -0
  20. data/docs/api/history/active-record-adapter.md +195 -0
  21. data/docs/api/history/config.md +191 -0
  22. data/docs/api/history/index.md +132 -0
  23. data/docs/api/history/thread-manager.md +144 -0
  24. data/docs/api/index.md +82 -0
  25. data/docs/api/mcp/client.md +221 -0
  26. data/docs/api/mcp/index.md +111 -0
  27. data/docs/api/mcp/server.md +225 -0
  28. data/docs/api/mcp/transports.md +264 -0
  29. data/docs/api/messages/index.md +67 -0
  30. data/docs/api/messages/text-message.md +102 -0
  31. data/docs/api/messages/tool-call-message.md +144 -0
  32. data/docs/api/messages/tool-result-message.md +154 -0
  33. data/docs/api/messages/user-message.md +171 -0
  34. data/docs/api/streaming/context.md +174 -0
  35. data/docs/api/streaming/events.md +237 -0
  36. data/docs/api/streaming/index.md +108 -0
  37. data/docs/architecture/core-concepts.md +243 -0
  38. data/docs/architecture/index.md +138 -0
  39. data/docs/architecture/message-flow.md +320 -0
  40. data/docs/architecture/network-orchestration.md +216 -0
  41. data/docs/architecture/robot-execution.md +243 -0
  42. data/docs/architecture/state-management.md +323 -0
  43. data/docs/assets/css/custom.css +56 -0
  44. data/docs/assets/images/robot_lab.jpg +0 -0
  45. data/docs/concepts.md +216 -0
  46. data/docs/examples/basic-chat.md +193 -0
  47. data/docs/examples/index.md +129 -0
  48. data/docs/examples/mcp-server.md +290 -0
  49. data/docs/examples/multi-robot-network.md +312 -0
  50. data/docs/examples/rails-application.md +420 -0
  51. data/docs/examples/tool-usage.md +310 -0
  52. data/docs/getting-started/configuration.md +230 -0
  53. data/docs/getting-started/index.md +56 -0
  54. data/docs/getting-started/installation.md +179 -0
  55. data/docs/getting-started/quick-start.md +203 -0
  56. data/docs/guides/building-robots.md +376 -0
  57. data/docs/guides/creating-networks.md +366 -0
  58. data/docs/guides/history.md +359 -0
  59. data/docs/guides/index.md +68 -0
  60. data/docs/guides/mcp-integration.md +356 -0
  61. data/docs/guides/memory.md +309 -0
  62. data/docs/guides/rails-integration.md +432 -0
  63. data/docs/guides/streaming.md +314 -0
  64. data/docs/guides/using-tools.md +394 -0
  65. data/docs/index.md +160 -0
  66. data/examples/01_simple_robot.rb +38 -0
  67. data/examples/02_tools.rb +106 -0
  68. data/examples/03_network.rb +103 -0
  69. data/examples/04_mcp.rb +219 -0
  70. data/examples/05_streaming.rb +124 -0
  71. data/examples/06_prompt_templates.rb +324 -0
  72. data/examples/07_network_memory.rb +329 -0
  73. data/examples/prompts/assistant/system.txt.erb +2 -0
  74. data/examples/prompts/assistant/user.txt.erb +1 -0
  75. data/examples/prompts/billing/system.txt.erb +7 -0
  76. data/examples/prompts/billing/user.txt.erb +1 -0
  77. data/examples/prompts/classifier/system.txt.erb +4 -0
  78. data/examples/prompts/classifier/user.txt.erb +1 -0
  79. data/examples/prompts/entity_extractor/system.txt.erb +11 -0
  80. data/examples/prompts/entity_extractor/user.txt.erb +3 -0
  81. data/examples/prompts/escalation/system.txt.erb +35 -0
  82. data/examples/prompts/escalation/user.txt.erb +34 -0
  83. data/examples/prompts/general/system.txt.erb +4 -0
  84. data/examples/prompts/general/user.txt.erb +1 -0
  85. data/examples/prompts/github_assistant/system.txt.erb +6 -0
  86. data/examples/prompts/github_assistant/user.txt.erb +1 -0
  87. data/examples/prompts/helper/system.txt.erb +1 -0
  88. data/examples/prompts/helper/user.txt.erb +1 -0
  89. data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
  90. data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
  91. data/examples/prompts/order_support/system.txt.erb +27 -0
  92. data/examples/prompts/order_support/user.txt.erb +22 -0
  93. data/examples/prompts/product_support/system.txt.erb +30 -0
  94. data/examples/prompts/product_support/user.txt.erb +32 -0
  95. data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
  96. data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
  97. data/examples/prompts/synthesizer/system.txt.erb +14 -0
  98. data/examples/prompts/synthesizer/user.txt.erb +15 -0
  99. data/examples/prompts/technical/system.txt.erb +7 -0
  100. data/examples/prompts/technical/user.txt.erb +1 -0
  101. data/examples/prompts/triage/system.txt.erb +16 -0
  102. data/examples/prompts/triage/user.txt.erb +17 -0
  103. data/lib/generators/robot_lab/install_generator.rb +78 -0
  104. data/lib/generators/robot_lab/robot_generator.rb +55 -0
  105. data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
  106. data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
  107. data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
  108. data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
  109. data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
  110. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
  111. data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
  112. data/lib/robot_lab/adapters/anthropic.rb +163 -0
  113. data/lib/robot_lab/adapters/base.rb +85 -0
  114. data/lib/robot_lab/adapters/gemini.rb +193 -0
  115. data/lib/robot_lab/adapters/openai.rb +159 -0
  116. data/lib/robot_lab/adapters/registry.rb +81 -0
  117. data/lib/robot_lab/configuration.rb +143 -0
  118. data/lib/robot_lab/error.rb +32 -0
  119. data/lib/robot_lab/errors.rb +70 -0
  120. data/lib/robot_lab/history/active_record_adapter.rb +146 -0
  121. data/lib/robot_lab/history/config.rb +115 -0
  122. data/lib/robot_lab/history/thread_manager.rb +93 -0
  123. data/lib/robot_lab/mcp/client.rb +210 -0
  124. data/lib/robot_lab/mcp/server.rb +84 -0
  125. data/lib/robot_lab/mcp/transports/base.rb +56 -0
  126. data/lib/robot_lab/mcp/transports/sse.rb +117 -0
  127. data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
  128. data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
  129. data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
  130. data/lib/robot_lab/memory.rb +882 -0
  131. data/lib/robot_lab/memory_change.rb +123 -0
  132. data/lib/robot_lab/message.rb +357 -0
  133. data/lib/robot_lab/network.rb +350 -0
  134. data/lib/robot_lab/rails/engine.rb +29 -0
  135. data/lib/robot_lab/rails/railtie.rb +42 -0
  136. data/lib/robot_lab/robot.rb +560 -0
  137. data/lib/robot_lab/robot_result.rb +205 -0
  138. data/lib/robot_lab/robotic_model.rb +324 -0
  139. data/lib/robot_lab/state_proxy.rb +188 -0
  140. data/lib/robot_lab/streaming/context.rb +144 -0
  141. data/lib/robot_lab/streaming/events.rb +95 -0
  142. data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
  143. data/lib/robot_lab/task.rb +117 -0
  144. data/lib/robot_lab/tool.rb +223 -0
  145. data/lib/robot_lab/tool_config.rb +112 -0
  146. data/lib/robot_lab/tool_manifest.rb +234 -0
  147. data/lib/robot_lab/user_message.rb +118 -0
  148. data/lib/robot_lab/version.rb +5 -0
  149. data/lib/robot_lab/waiter.rb +73 -0
  150. data/lib/robot_lab.rb +195 -0
  151. data/mkdocs.yml +214 -0
  152. data/sig/robot_lab.rbs +4 -0
  153. metadata +442 -0
@@ -0,0 +1,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # LLM-powered robot using ruby_llm-template for prompts
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
12
+ #
13
+ # == Memory Behavior
14
+ #
15
+ # Robots have two memory contexts depending on how they're used:
16
+ #
17
+ # *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.
25
+ #
26
+ # @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")
33
+ #
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
+ # )
39
+ #
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
+ # )
46
+ #
47
+ # @example Robot with tools
48
+ # robot = Robot.new(
49
+ # name: "support",
50
+ # template: :support,
51
+ # context: { policies: POLICIES },
52
+ # tools: [OrderLookup, RefundProcessor]
53
+ # )
54
+ #
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
64
+ # @!attribute [r] name
65
+ # @return [String] the unique identifier for the robot
66
+ # @!attribute [r] description
67
+ # @return [String, nil] an optional description of the robot's purpose
68
+ # @!attribute [r] template
69
+ # @return [Symbol, nil] the ERB template for the robot's prompt
70
+ # @!attribute [r] system_prompt
71
+ # @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
+ # @!attribute [r] local_tools
75
+ # @return [Array] the locally defined tools for this robot
76
+ # @!attribute [r] mcp_clients
77
+ # @return [Hash<String, MCP::Client>] connected MCP clients by server name
78
+ # @!attribute [r] mcp_tools
79
+ # @return [Array<Tool>] tools discovered from MCP servers
80
+ # @!attribute [r] memory
81
+ # @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
83
+
84
+ # @!attribute [r] mcp_config
85
+ # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
86
+ # @!attribute [r] tools_config
87
+ # @return [Symbol, Array] build-time tools configuration (raw, unresolved)
88
+ attr_reader :mcp_config, :tools_config
89
+
90
+ # Creates a new Robot instance.
91
+ #
92
+ # @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)
99
+ # @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)
102
+ # @param on_tool_call [Proc, nil] callback invoked when a tool is called
103
+ # @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
124
+ def initialize(
125
+ name:,
126
+ template: nil,
127
+ system_prompt: nil,
128
+ context: {},
129
+ description: nil,
130
+ local_tools: [],
131
+ model: nil,
132
+ mcp_servers: [],
133
+ mcp: :none,
134
+ tools: :none,
135
+ on_tool_call: nil,
136
+ on_tool_result: nil,
137
+ enable_cache: true
138
+ )
139
+ unless template || system_prompt
140
+ raise ArgumentError, "Must provide either template or system_prompt"
141
+ end
142
+
143
+ @name = name.to_s
144
+ @template = template
145
+ @system_prompt = system_prompt
146
+ @build_context = context
147
+ @description = description
148
+ @local_tools = Array(local_tools)
149
+ @model = model || RobotLab.configuration.default_model
150
+ @on_tool_call = on_tool_call
151
+ @on_tool_result = on_tool_result
152
+
153
+ # Store raw config values for hierarchical resolution
154
+ # mcp_servers is legacy parameter, mcp is the new hierarchical one
155
+ @mcp_config = mcp_servers.any? ? mcp_servers : mcp
156
+ @tools_config = tools
157
+
158
+ # MCP state
159
+ @mcp_clients = {}
160
+ @mcp_tools = []
161
+ @mcp_initialized = false
162
+
163
+ # Inherent memory (used when standalone, not in a network)
164
+ @memory = Memory.new(enable_cache: enable_cache)
165
+ end
166
+
167
+ # Returns the robot's local tools (alias for local_tools).
168
+ #
169
+ # Provided for backward compatibility with earlier API versions.
170
+ #
171
+ # @return [Array] the locally defined tools
172
+ def tools
173
+ @local_tools
174
+ end
175
+
176
+ # Run the robot with the given context
177
+ #
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
184
+ # @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
202
+
203
+ # Merge runtime memory if provided
204
+ case memory
205
+ when Memory
206
+ run_memory = memory
207
+ when Hash
208
+ run_memory.merge!(memory)
209
+ end
210
+
211
+ # Set current_writer so memory notifications know who wrote the value
212
+ previous_writer = run_memory.current_writer
213
+ run_memory.current_writer = @name
214
+
215
+ begin
216
+ # Resolve hierarchical MCP and tools configuration
217
+ resolved_mcp = resolve_mcp_hierarchy(mcp, network: network)
218
+ resolved_tools = resolve_tools_hierarchy(tools, network: network)
219
+
220
+ # Initialize or update MCP clients based on resolved config
221
+ ensure_mcp_clients(resolved_mcp)
222
+
223
+ # Merge build context + run context
224
+ full_context = resolve_context(@build_context, network: network)
225
+ .merge(run_context)
226
+
227
+ # Build chat with template, filtered tools, and semantic cache
228
+ chat = build_chat(full_context, allowed_tools: resolved_tools, memory: run_memory)
229
+
230
+ # Execute and return result
231
+ response = chat.complete
232
+
233
+ build_result(response, run_memory)
234
+ ensure
235
+ # Restore previous writer
236
+ run_memory.current_writer = previous_writer
237
+ end
238
+ end
239
+
240
+ # SimpleFlow step interface
241
+ #
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.
245
+ #
246
+ # @param result [SimpleFlow::Result] incoming result from previous step
247
+ # @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
+ def call(result)
256
+ robot_result = run(**extract_run_context(result))
257
+
258
+ result
259
+ .with_context(@name.to_sym, robot_result)
260
+ .continue(robot_result)
261
+ end
262
+
263
+ # Reset the robot's inherent memory
264
+ #
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
+ # @return [self]
270
+ #
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
275
+ #
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
281
+ #
282
+ def reset_memory
283
+ @memory.reset
284
+ self
285
+ end
286
+
287
+ # Disconnect all MCP clients
288
+ #
289
+ # Call this method when done using the robot to clean up MCP connections.
290
+ #
291
+ # @return [self]
292
+ #
293
+ def disconnect
294
+ @mcp_clients.each_value(&:disconnect)
295
+ self
296
+ end
297
+
298
+ # Converts the robot to a hash representation.
299
+ #
300
+ # @return [Hash] a hash containing the robot's configuration
301
+ def to_h
302
+ {
303
+ name: name,
304
+ description: description,
305
+ template: template,
306
+ system_prompt: system_prompt,
307
+ local_tools: local_tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s },
308
+ mcp_tools: mcp_tools.map(&:name),
309
+ mcp_config: @mcp_config,
310
+ tools_config: @tools_config,
311
+ mcp_servers: @mcp_clients.keys,
312
+ model: model.respond_to?(:model_id) ? model.model_id : model
313
+ }.compact
314
+ end
315
+
316
+ private
317
+
318
+ # 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
+ def extract_run_context(result)
327
+ run_params = result.context[:run_params] || {}
328
+
329
+ # Extract robot-specific params that should be passed to run()
330
+ mcp = run_params.delete(:mcp) || :none
331
+ tools = run_params.delete(:tools) || :none
332
+ memory = run_params.delete(:memory)
333
+ network_memory = run_params.delete(:network_memory)
334
+
335
+ # Build base context from remaining run params
336
+ base = run_params.dup
337
+
338
+ # Merge current value into context
339
+ merged = case result.value
340
+ when Hash
341
+ base.merge(result.value.transform_keys(&:to_sym))
342
+ when RobotResult
343
+ base.merge(message: result.value.last_text_content)
344
+ when String
345
+ base.merge(message: result.value)
346
+ else
347
+ base.merge(message: result.value.to_s)
348
+ end
349
+
350
+ # Add back the special params for run()
351
+ merged[:mcp] = mcp
352
+ merged[:tools] = tools
353
+ merged[:memory] = memory if memory
354
+ merged[:network_memory] = network_memory if network_memory
355
+
356
+ merged
357
+ end
358
+
359
+ def resolve_context(context, network:)
360
+ case context
361
+ when Proc then context.call(network: network)
362
+ when Hash then context
363
+ else {}
364
+ end
365
+ end
366
+
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
+
399
+ def build_result(response, _memory)
400
+ output = if response.respond_to?(:content) && response.content
401
+ [TextMessage.new(role: "assistant", content: response.content)]
402
+ else
403
+ []
404
+ end
405
+
406
+ tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
407
+
408
+ RobotResult.new(
409
+ robot_name: @name,
410
+ output: output,
411
+ tool_calls: normalize_tool_calls(tool_calls),
412
+ stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil
413
+ )
414
+ end
415
+
416
+ def normalize_tool_calls(tool_calls)
417
+ return [] unless tool_calls
418
+
419
+ tool_calls.map do |tc|
420
+ if tc.is_a?(Hash)
421
+ ToolResultMessage.new(
422
+ tool: tc,
423
+ content: tc[:result] || tc["result"]
424
+ )
425
+ else
426
+ tc
427
+ end
428
+ end
429
+ end
430
+
431
+ # 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
+ 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
442
+ build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
443
+
444
+ # Resolve runtime against build
445
+ ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
446
+ end
447
+
448
+ # 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
+ 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
459
+ build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
460
+
461
+ # Resolve runtime against build
462
+ ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
463
+ end
464
+
465
+ # Ensure MCP clients are initialized for the given server configs
466
+ #
467
+ # @param mcp_servers [Array] MCP server configurations
468
+ #
469
+ def ensure_mcp_clients(mcp_servers)
470
+ return if mcp_servers.empty?
471
+
472
+ # Get server names from configs
473
+ 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
+ return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
477
+
478
+ # Disconnect existing clients if config changed
479
+ disconnect if @mcp_initialized
480
+
481
+ # Initialize new clients
482
+ @mcp_clients = {}
483
+ @mcp_tools = []
484
+
485
+ mcp_servers.each do |server_config|
486
+ init_mcp_client(server_config)
487
+ end
488
+
489
+ @mcp_initialized = true
490
+ end
491
+
492
+ # Initialize a single MCP client
493
+ #
494
+ # @param server_config [Hash] MCP server configuration
495
+ #
496
+ def init_mcp_client(server_config)
497
+ client = MCP::Client.new(server_config)
498
+ client.connect
499
+
500
+ if client.connected?
501
+ server_name = client.server.name
502
+ @mcp_clients[server_name] = client
503
+ discover_mcp_tools(client, server_name)
504
+ else
505
+ RobotLab.configuration.logger.warn(
506
+ "Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
507
+ )
508
+ end
509
+ end
510
+
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
+ #
516
+ def discover_mcp_tools(client, server_name)
517
+ tools = client.list_tools
518
+
519
+ tools.each do |tool_def|
520
+ tool_name = tool_def[:name]
521
+ mcp_client = client
522
+
523
+ # Create a Tool that delegates to the MCP client
524
+ tool = Tool.new(
525
+ name: tool_name,
526
+ description: tool_def[:description],
527
+ parameters: tool_def[:inputSchema],
528
+ mcp: server_name,
529
+ handler: ->(input, **_opts) { mcp_client.call_tool(tool_name, input) }
530
+ )
531
+
532
+ @mcp_tools << tool
533
+ end
534
+
535
+ RobotLab.configuration.logger.info(
536
+ "Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
537
+ )
538
+ end
539
+
540
+ # Get all tools (local + MCP)
541
+ #
542
+ # @return [Array] Combined array of local and MCP tools
543
+ #
544
+ def all_tools
545
+ @local_tools + @mcp_tools
546
+ end
547
+
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
+ #
553
+ def filtered_tools(allowed_names)
554
+ available = all_tools
555
+ return available if allowed_names.empty?
556
+
557
+ ToolConfig.filter_tools(available, allowed_names: allowed_names)
558
+ end
559
+ end
560
+ end