robot_lab 0.1.0 → 0.2.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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -16,6 +16,9 @@ module RobotLab
16
16
  # inline. The BusPoller drains each group's queue sequentially on
17
17
  # a dedicated OS thread, so robot.run() calls never interleave.
18
18
  #
19
+ # Owns: @bus, @bus_poller, @private_bus_poller, @bus_poller_group, @bus_subscriber_id, @message_counter, @outbox, @message_handler
20
+ # Reads: @name
21
+ # Contract: ivars initialized by initialize_runtime_state before first bus operation
19
22
  module BusMessaging
20
23
  # Send a message to another robot via the bus.
21
24
  #
@@ -26,14 +29,19 @@ module RobotLab
26
29
  def send_message(to:, content:)
27
30
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
28
31
 
29
- @message_counter += 1
30
- message = RobotMessage.build(id: @message_counter, from: @name, content: content)
31
- @outbox[message.key] = { message: message, status: :sent, replies: [] }
32
+ # Counter + outbox are shared with the poller thread (reply correlation)
33
+ # and with other senders; mutate them under the bus mutex. Publish (which
34
+ # does I/O) stays outside the lock.
35
+ message = @bus_mutex.synchronize do
36
+ @message_counter += 1
37
+ msg = RobotMessage.build(id: @message_counter, from: @name, content: content)
38
+ @outbox[msg.key] = { message: msg, status: :sent, replies: [] }
39
+ msg
40
+ end
32
41
  publish_to_bus(to.to_sym, message)
33
42
  message
34
43
  end
35
44
 
36
-
37
45
  # Send a reply to a specific message via the bus.
38
46
  #
39
47
  # @param to [String, Symbol] target robot's channel name
@@ -44,13 +52,14 @@ module RobotLab
44
52
  def send_reply(to:, content:, in_reply_to:)
45
53
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
46
54
 
47
- @message_counter += 1
48
- reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
55
+ reply = @bus_mutex.synchronize do
56
+ @message_counter += 1
57
+ RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
58
+ end
49
59
  publish_to_bus(to.to_sym, reply)
50
60
  reply
51
61
  end
52
62
 
53
-
54
63
  # Register a custom handler for incoming bus messages.
55
64
  #
56
65
  # Block arity controls delivery handling:
@@ -64,6 +73,36 @@ module RobotLab
64
73
  self
65
74
  end
66
75
 
76
+ # Automatically respond to inbound (non-reply) bus tasks: run +responder+ to
77
+ # produce a reply, and send it back to the sender. This is the symmetric
78
+ # counterpart to how a Cyborg answers its human — one call makes any bus
79
+ # member a first-class responder instead of hand-wiring {#on_message}.
80
+ #
81
+ # The responder runs on the poller drain thread, so deliveries to this member
82
+ # are handled one at a time (a long turn delays the next inbound message).
83
+ #
84
+ # @param auto_reply [Boolean] send the responder's result back to the sender
85
+ # @yield [message] the inbound task; return the reply content (nil => no reply)
86
+ # @return [self]
87
+ def respond_to_tasks(auto_reply: true, &responder)
88
+ on_message do |message|
89
+ next if message.reply?
90
+
91
+ reply = responder.call(message)
92
+ send_reply(to: message.from, content: reply, in_reply_to: message.key) if auto_reply && reply
93
+ end
94
+ self
95
+ end
96
+
97
+ # Serve inbound bus tasks by running each through this member's #run and
98
+ # replying with the result — the one-call way to make a Robot cooperate on
99
+ # the bus the way a Cyborg already does out of the box.
100
+ #
101
+ # @param auto_reply [Boolean]
102
+ # @return [self]
103
+ def serve(auto_reply: true)
104
+ respond_to_tasks(auto_reply: auto_reply) { |message| run(bus_task_content(message)).reply }
105
+ end
67
106
 
68
107
  # Spawn a new robot on a shared bus.
69
108
  #
@@ -79,7 +118,7 @@ module RobotLab
79
118
  # @param options [Hash] additional options passed to RobotLab.build
80
119
  # @return [Robot] the newly created robot
81
120
  #
82
- def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
121
+ def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **)
83
122
  ensure_bus
84
123
 
85
124
  RobotLab.build(
@@ -88,10 +127,23 @@ module RobotLab
88
127
  template: template,
89
128
  local_tools: local_tools,
90
129
  bus: @bus,
91
- **options
130
+ **inherited_llm_settings,
131
+ **
92
132
  )
93
133
  end
94
134
 
135
+ # Model/provider a spawned robot inherits from its parent so a specialist
136
+ # runs on the SAME LLM as the robot that spawned it (e.g. a local Ollama
137
+ # model) instead of falling back to the global default. Caller-supplied
138
+ # opts override these.
139
+ #
140
+ # @return [Hash]
141
+ def inherited_llm_settings
142
+ settings = {}
143
+ settings[:model] = model if model
144
+ settings[:provider] = provider if provider
145
+ settings
146
+ end
95
147
 
96
148
  # Connect this robot to a message bus.
97
149
  #
@@ -134,7 +186,6 @@ module RobotLab
134
186
  with_bus unless @bus
135
187
  end
136
188
 
137
-
138
189
  # Create a typed channel on the bus and subscribe to it.
139
190
  # Auto-creates a private BusPoller if none has been assigned.
140
191
  def setup_bus_channel
@@ -149,7 +200,6 @@ module RobotLab
149
200
  @bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| enqueue_delivery(delivery) }
150
201
  end
151
202
 
152
-
153
203
  # Unsubscribe from the bus channel and stop the private poller if any.
154
204
  def teardown_bus_channel
155
205
  channel_name = @name.to_sym
@@ -162,48 +212,44 @@ module RobotLab
162
212
  @bus_poller_group = :default
163
213
  end
164
214
 
215
+ # Flatten a task message's content to text for #run.
216
+ def bus_task_content(message)
217
+ content = message.content
218
+ content.is_a?(Hash) ? content.map { |k, v| "#{k}: #{v}" }.join("\n") : content.to_s
219
+ end
165
220
 
166
221
  # Enqueue a delivery to the robot's assigned poller.
167
222
  def enqueue_delivery(delivery)
168
223
  @bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
169
224
  end
170
225
 
171
-
172
226
  # Process a single delivery (called by BusPoller drain thread).
173
227
  def process_delivery(delivery)
174
228
  message = delivery.message
229
+ correlate_reply(message) if message.reply?
175
230
 
176
- # Correlate replies with outbox entries
177
- if message.reply? && @outbox.key?(message.in_reply_to)
178
- entry = @outbox[message.in_reply_to]
179
- entry[:status] = :replied
180
- entry[:replies] << message
181
- end
182
-
183
- if @message_handler
184
- if @message_handler.arity == 1
185
- delivery.ack!
186
- @message_handler.call(message)
187
- else
188
- @message_handler.call(delivery, message)
189
- end
231
+ if @message_handler.arity == 1
232
+ delivery.ack!
233
+ @message_handler.call(message)
190
234
  else
191
- handle_message_via_llm(delivery, message)
235
+ @message_handler.call(delivery, message)
192
236
  end
193
237
  rescue => e
194
238
  delivery.nack! if delivery.pending?
195
239
  raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
196
240
  end
197
241
 
242
+ # Mark the sender's outbox entry replied. Shared with senders on other
243
+ # threads, so guard with the bus mutex.
244
+ def correlate_reply(message)
245
+ @bus_mutex.synchronize do
246
+ entry = @outbox[message.in_reply_to] or return
198
247
 
199
- # Default handler: interpret message via LLM and reply
200
- def handle_message_via_llm(delivery, message)
201
- delivery.ack!
202
- result = run(message.content.to_s)
203
- send_reply(to: message.from.to_sym, content: result.last_text_content, in_reply_to: message.key)
248
+ entry[:status] = :replied
249
+ entry[:replies] << message
250
+ end
204
251
  end
205
252
 
206
-
207
253
  # Publish a RobotMessage to a bus channel
208
254
  def publish_to_bus(channel_name, message)
209
255
  if defined?(Async::Task) && Async::Task.current?
@@ -4,6 +4,10 @@ module RobotLab
4
4
  class Robot
5
5
  # Semantic search over a robot's conversation history.
6
6
  #
7
+ # Owns: nothing (read-only mixin)
8
+ # Reads: @chat (specifically @chat.messages)
9
+ # Contract: no initialization required; safe to call any time after initialize
10
+ #
7
11
  # Scores each message in @chat.messages against the query using stemmed
8
12
  # term-frequency cosine similarity (via the +classifier+ gem). Returns the
9
13
  # top-N messages ranked by relevance.
@@ -61,7 +65,6 @@ module RobotLab
61
65
  case content
62
66
  when String then content
63
67
  when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
64
- else nil
65
68
  end
66
69
  end
67
70
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ module Hooking
6
+ def run(message = nil, network: nil, task: nil, network_memory: nil, network_config: nil,
7
+ memory: nil, mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
8
+ run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
9
+ previous_writer = run_memory.current_writer
10
+ run_memory.current_writer = @name
11
+ context = RunHookContext.new(
12
+ robot: self,
13
+ network: network,
14
+ task: task,
15
+ memory: run_memory,
16
+ config: @config,
17
+ request: message
18
+ )
19
+
20
+ registries = hook_registries(network)
21
+
22
+ begin
23
+ RobotLab.with_hook_scope(registries, hooks) do
24
+ RobotLab::Hooks.run(:run, context, registries: registries, per_run_hooks: hooks) do
25
+ run_context = kwargs.except(:with)
26
+ prepare_tools(message: context.request, mcp: mcp, tools: tools,
27
+ network: network, network_config: network_config)
28
+ rerender_template(run_context) if @template && run_context.any?
29
+ reservation = reserve_budget!
30
+ response = invoke_ask(context: context, kwargs: kwargs, hooks: hooks, block: block)
31
+ result = build_result(response, run_memory)
32
+ reconcile_budget!(reservation, response: response, result: result)
33
+ enforce_token_budget!
34
+ enforce_cost_budget!
35
+ result
36
+ end
37
+ end
38
+ ensure
39
+ remove_doom_loop_detection
40
+ restore_tool_call_callback if @config.max_tool_rounds
41
+ run_memory.current_writer = previous_writer
42
+ end
43
+ end
44
+
45
+ def on(handler_class, context: nil)
46
+ @hooks.on(handler_class, context: context)
47
+ end
48
+
49
+ private
50
+
51
+ def hook_registries(network = nil)
52
+ [RobotLab.hooks, network&.hooks, @hooks]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -4,27 +4,31 @@ module RobotLab
4
4
  class Robot < RubyLLM::Agent
5
5
  # MCP client lifecycle and hierarchical tool/MCP resolution.
6
6
  #
7
- # Expects the including class to provide:
8
- # @mcp_config, @tools_config, @mcp_clients, @mcp_tools,
9
- # @mcp_initialized, @name, @chat, @local_tools
7
+ # Owns: @mcp_clients, @mcp_tools, @mcp_initialized, @failed_mcp_configs
8
+ # Reads: @mcp_config, @tools_config, @name, @chat, @local_tools
9
+ # Contract: ivars initialized by initialize_runtime_state; lazy init on first run
10
10
  module MCPManagement
11
11
  private
12
12
 
13
13
  # Resolve MCP hierarchy: runtime -> robot build -> network -> config
14
14
  def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
15
- parent_value = network_config&.mcp || network&.network&.mcp || RobotLab.config.mcp
15
+ parent_value = network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp
16
16
  build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
17
17
  ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
18
18
  end
19
19
 
20
-
21
20
  # Resolve tools hierarchy: runtime -> robot build -> network -> config
22
21
  def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
23
- parent_value = network_config&.tools || network&.network&.tools || RobotLab.config.tools
22
+ parent_value = network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools
24
23
  build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
25
24
  ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
26
25
  end
27
26
 
27
+ def network_parent_config(network)
28
+ return network.config if network.respond_to?(:config)
29
+
30
+ network&.network
31
+ end
28
32
 
29
33
  # Ensure MCP clients are initialized for the given server configs.
30
34
  # On subsequent calls, retries any servers that previously failed to connect.
@@ -50,7 +54,6 @@ module RobotLab
50
54
  @mcp_initialized = true
51
55
  end
52
56
 
53
-
54
57
  def init_mcp_client(server_config)
55
58
  client = MCP::Client.new(server_config)
56
59
  client.connect
@@ -74,13 +77,12 @@ module RobotLab
74
77
  )
75
78
  end
76
79
 
77
-
78
80
  # Retry connecting to servers that previously failed
79
- def retry_failed_servers(mcp_servers, needed_servers)
81
+ def retry_failed_servers(_mcp_servers, needed_servers)
80
82
  return if @failed_mcp_configs.nil? || @failed_mcp_configs.empty?
81
83
 
82
84
  # Only retry servers that are still needed and still failed
83
- to_retry = @failed_mcp_configs.select { |name, _| needed_servers.include?(name) }
85
+ to_retry = @failed_mcp_configs.slice(*needed_servers)
84
86
  return if to_retry.empty?
85
87
 
86
88
  to_retry.each do |name, server_config|
@@ -106,7 +108,6 @@ module RobotLab
106
108
  end
107
109
  end
108
110
 
109
-
110
111
  def discover_mcp_tools(client, server_name)
111
112
  tools = client.list_tools
112
113
 
@@ -129,7 +130,6 @@ module RobotLab
129
130
  )
130
131
  end
131
132
 
132
-
133
133
  def extract_server_name(server_config)
134
134
  case server_config
135
135
  when Hash
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module RobotLab
6
4
  class Robot < RubyLLM::Agent
7
5
  # Template loading, rendering, and front-matter extraction.
8
6
  #
9
- # Expects the including class to provide:
10
- # @chat, @template, @build_context, @name, @name_from_constructor,
11
- # @description, @local_tools, @mcp_config
7
+ # Owns: @expanded_skills, @pending_agent_skills, @agent_skill_store
8
+ # Reads: @chat, @template, @build_context, @name, @name_from_constructor, @description, @local_tools, @mcp_config, @config
9
+ # Contract: called during initialize after assign_identity_ivars and build_effective_config
12
10
  module TemplateRendering
13
11
  # Front matter keys that map to chat configuration methods
14
12
  FRONT_MATTER_CONFIG_KEYS = %i[
@@ -47,7 +45,7 @@ module RobotLab
47
45
  # Front matter is the base; @config (from constructor kwargs) overrides.
48
46
  fm_config = RunConfig.from_front_matter(parsed.metadata)
49
47
  effective = fm_config.merge(@config)
50
- effective.apply_to(@chat)
48
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
51
49
 
52
50
  # Resolve context (could be a Proc)
53
51
  resolved_ctx = resolve_context(context, network: nil)
@@ -64,7 +62,6 @@ module RobotLab
64
62
  end
65
63
  end
66
64
 
67
-
68
65
  # Re-render the template with run-time context merged into build-time context.
69
66
  # prompt_manager parameters may be required (null) and only available at run time.
70
67
  def rerender_template(run_context)
@@ -93,64 +90,85 @@ module RobotLab
93
90
  end
94
91
  end
95
92
 
96
-
97
93
  # Orchestrate skill expansion and template application.
98
94
  #
99
95
  # @param skill_ids [Array<Symbol>] skill IDs from constructor + front matter
100
96
  # @param context [Hash, Proc] variables to pass to all templates
101
97
  def apply_skills_and_template_to_chat(skill_ids, context)
98
+ bodies, accumulated_config, extras = collect_prompt_content(skill_ids, context)
99
+ apply_prompt_to_chat(bodies, accumulated_config, extras)
100
+ end
101
+
102
+ # Expand skills and render all bodies, configs, and extras into plain data.
103
+ # Pure computation — reads ivars but does not mutate @chat.
104
+ #
105
+ # @return [Array(Array<String>, RunConfig, Hash)] bodies, merged config, extras hash
106
+ def collect_prompt_content(skill_ids, context)
102
107
  visited = Set.new
103
- # Prevent skills from pulling in the main template
104
108
  visited.add(@template) if @template
105
-
106
109
  @expanded_skills = expand_skills(skill_ids, visited)
107
110
 
108
111
  extras = {}
109
112
  accumulated_config = RunConfig.new
110
113
  bodies = []
111
-
112
114
  resolved_ctx = resolve_context(context, network: nil)
113
115
 
114
- # Process each expanded skill
115
116
  @expanded_skills.each do |skill_id|
116
117
  parsed = PM.parse(skill_id)
117
118
  accumulate_extras(parsed.metadata, extras)
118
- fm_config = RunConfig.from_front_matter(parsed.metadata)
119
- accumulated_config = accumulated_config.merge(fm_config)
119
+ accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
120
120
  body = render_body(parsed, resolved_ctx)
121
121
  bodies << body if body
122
122
  end
123
123
 
124
- # Process main template if present
125
124
  if @template
126
125
  parsed = PM.parse(@template)
127
126
  accumulate_extras(parsed.metadata, extras)
128
- fm_config = RunConfig.from_front_matter(parsed.metadata)
129
- accumulated_config = accumulated_config.merge(fm_config)
127
+ accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
130
128
  body = render_body(parsed, resolved_ctx)
131
129
  bodies << body if body
132
130
  end
133
131
 
134
- # Apply accumulated extras (respects constructor precedence)
132
+ [bodies, accumulated_config, extras]
133
+ end
134
+
135
+ # Apply collected prompt content to @chat.
136
+ # Pure mutation — takes plain data and writes to @chat.
137
+ #
138
+ # @param bodies [Array<String>] rendered template bodies
139
+ # @param accumulated_config [RunConfig] merged skill + template config
140
+ # @param extras [Hash] accumulated front-matter extras (name, description, tools, mcp)
141
+ def apply_prompt_to_chat(bodies, accumulated_config, extras)
135
142
  apply_accumulated_extras(extras)
136
143
 
137
- # Constructor config overrides skill + template config
138
144
  effective = accumulated_config.merge(@config)
139
- effective.apply_to(@chat)
145
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
140
146
 
141
- # Set instructions once with all bodies joined
142
147
  combined = bodies.join("\n\n")
143
148
  @chat.with_instructions(combined) unless combined.empty?
144
149
  end
145
150
 
146
-
147
151
  # Recursively expand skill IDs depth-first.
152
+ # Checks AgentSkillCatalog first; falls back to PM template lookup.
148
153
  # Returns a flat Array<Symbol> in processing order (deepest first).
149
154
  #
150
155
  # @param skill_ids [Array<Symbol>] skill IDs to expand
151
156
  # @param visited [Set<Symbol>] already-visited IDs for cycle detection
152
157
  # @return [Array<Symbol>] flat ordered list of skill IDs
153
158
  def expand_skills(skill_ids, visited = Set.new)
159
+ expand_skills_with_catalog(skill_ids, visited, AgentSkillCatalog.instance)
160
+ end
161
+
162
+ # Recursively expand skill IDs depth-first, using the given catalog.
163
+ # AgentSkills folder format takes priority over PM template lookup.
164
+ # Catalog hits are stored in @pending_agent_skills / @agent_skill_store
165
+ # and are NOT included in the returned Array (they are handled separately).
166
+ #
167
+ # @param skill_ids [Array<Symbol>] skill IDs to expand
168
+ # @param visited [Set<Symbol>] already-visited IDs for cycle detection
169
+ # @param catalog [AgentSkillCatalog] catalog to check first
170
+ # @return [Array<Symbol>] flat ordered list of PM-based skill IDs
171
+ def expand_skills_with_catalog(skill_ids, visited, catalog)
154
172
  result = []
155
173
 
156
174
  skill_ids.each do |skill_id|
@@ -165,21 +183,32 @@ module RobotLab
165
183
 
166
184
  visited.add(skill_id)
167
185
 
168
- # Parse the skill template and check for nested skills
186
+ # Check catalog first: AgentSkills folder format takes priority
187
+ if (agent_skill = catalog.find(skill_id))
188
+ @pending_agent_skills ||= []
189
+ unless defined?(RobotLab::DocumentStore)
190
+ raise LoadError,
191
+ "robot_lab-document_store is required to use AgentSkill catalogs. " \
192
+ "Add `gem 'robot_lab-document_store'` to your Gemfile."
193
+ end
194
+ @agent_skill_store ||= RobotLab::DocumentStore.new
195
+ @pending_agent_skills << agent_skill
196
+ @agent_skill_store.store(agent_skill.name.to_sym, agent_skill.description)
197
+ next
198
+ end
199
+
200
+ # Fall back to PM template (existing behavior)
169
201
  parsed = PM.parse(skill_id)
170
202
  nested = extract_skills_from_metadata(parsed.metadata)
171
203
 
172
- # Recurse on nested skills first (depth-first)
173
- result.concat(expand_skills(nested, visited)) if nested.any?
204
+ result.concat(expand_skills_with_catalog(nested, visited, catalog)) if nested.any?
174
205
 
175
- # Then append this skill
176
206
  result << skill_id
177
207
  end
178
208
 
179
209
  result
180
210
  end
181
211
 
182
-
183
212
  # Extract skills array from metadata.
184
213
  #
185
214
  # @param metadata [PM::Metadata] front matter metadata
@@ -190,7 +219,6 @@ module RobotLab
190
219
  Array(metadata.skills).map(&:to_sym)
191
220
  end
192
221
 
193
-
194
222
  # Accumulate extras from metadata into a hash.
195
223
  # Later calls overwrite earlier values (last-write-wins).
196
224
  #
@@ -209,12 +237,10 @@ module RobotLab
209
237
  extras[:tools] = metadata.tools
210
238
  end
211
239
 
212
- if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array)
213
- extras[:mcp] = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
214
- end
240
+ return unless metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array)
241
+ extras[:mcp] = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
215
242
  end
216
243
 
217
-
218
244
  # Apply accumulated extras to the robot, respecting constructor precedence.
219
245
  #
220
246
  # @param extras [Hash] accumulated extras from skills + main template
@@ -231,12 +257,10 @@ module RobotLab
231
257
  @local_tools = resolve_frontmatter_tools(extras[:tools])
232
258
  end
233
259
 
234
- if extras[:mcp] && ToolConfig.none_value?(@mcp_config)
235
- @mcp_config = extras[:mcp]
236
- end
260
+ return unless extras[:mcp] && ToolConfig.none_value?(@mcp_config)
261
+ @mcp_config = extras[:mcp]
237
262
  end
238
263
 
239
-
240
264
  # Extract identity and capability keys from front matter metadata.
241
265
  # Constructor-provided values take precedence over frontmatter.
242
266
  def apply_front_matter_extras(metadata)
@@ -252,12 +276,10 @@ module RobotLab
252
276
  @local_tools = resolve_frontmatter_tools(metadata.tools)
253
277
  end
254
278
 
255
- if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
256
- @mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
257
- end
279
+ return unless metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
280
+ @mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
258
281
  end
259
282
 
260
-
261
283
  # Render a parsed template body, returning nil if required params are missing.
262
284
  #
263
285
  # @param parsed [PM::Parsed] the parsed template
@@ -271,7 +293,6 @@ module RobotLab
271
293
  nil
272
294
  end
273
295
 
274
-
275
296
  # Resolve string tool names from frontmatter to Ruby constants.
276
297
  # Tool subclasses are instantiated; instances are used as-is.
277
298
  # Unresolvable names are skipped with a warning.
@@ -294,7 +315,6 @@ module RobotLab
294
315
  end
295
316
  end
296
317
 
297
-
298
318
  def resolve_context(context, network:)
299
319
  case context
300
320
  when Proc then context.call(network: network)