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.
- checksums.yaml +4 -4
- data/.architecture/AGENTS.md +32 -0
- data/.architecture/config.yml +8 -0
- data/.architecture/members.yml +60 -0
- data/.architecture/reviews/feature-free-will.md +490 -0
- data/.architecture/reviews/overall-codebase.md +427 -0
- data/.claude/settings.local.json +57 -0
- data/.codex/config.toml +2 -0
- data/.envrc +1 -0
- data/.irbrc +2 -2
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +10 -0
- data/CHANGELOG.md +126 -0
- data/CLAUDE.md +139 -0
- data/README.md +130 -73
- data/Rakefile +131 -9
- data/agent2agent_review.md +192 -0
- data/agentf_improvements.md +253 -0
- data/agents.md +14 -0
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/examples/index.md +37 -2
- data/docs/getting-started/configuration.md +39 -7
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +19 -14
- data/docs/guides/knowledge.md +9 -3
- data/docs/guides/observability.md +206 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +31 -4
- data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
- data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
- data/examples/.envrc +1 -0
- data/examples/01_simple_robot.rb +5 -9
- data/examples/02_tools.rb +5 -9
- data/examples/03_network.rb +18 -10
- data/examples/04_mcp.rb +21 -29
- data/examples/05_streaming.rb +12 -18
- data/examples/06_prompt_templates.rb +11 -19
- data/examples/07_network_memory.rb +16 -31
- data/examples/08_llm_config.rb +10 -22
- data/examples/09_chaining.rb +16 -27
- data/examples/10_memory.rb +12 -28
- data/examples/11_network_introspection.rb +15 -29
- data/examples/12_message_bus.rb +5 -12
- data/examples/13_spawn.rb +5 -10
- data/examples/14_rusty_circuit/.envrc +1 -0
- data/examples/14_rusty_circuit/comic.rb +2 -0
- data/examples/14_rusty_circuit/heckler.rb +1 -1
- data/examples/14_rusty_circuit/open_mic.rb +1 -3
- data/examples/14_rusty_circuit/scout.rb +2 -0
- data/examples/15_memory_network_and_bus/.envrc +1 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
- data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
- data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
- data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
- data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
- data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/.envrc +1 -0
- data/examples/16_writers_room/writers_room.rb +2 -4
- data/examples/17_skills.rb +8 -17
- data/examples/18_rails/Gemfile +1 -0
- data/examples/19_token_tracking.rb +9 -15
- data/examples/20_circuit_breaker.rb +10 -19
- data/examples/21_learning_loop.rb +11 -20
- data/examples/22_context_compression.rb +6 -13
- data/examples/23_convergence.rb +6 -17
- data/examples/24_structured_delegation.rb +11 -15
- data/examples/25_history_search.rb +5 -12
- data/examples/26_document_store.rb +6 -13
- data/examples/27_incident_response/incident_response.rb +4 -5
- data/examples/28_mcp_discovery.rb +8 -11
- data/examples/29_ractor_tools.rb +4 -9
- data/examples/30_ractor_network.rb +10 -19
- data/examples/31_launch_assessment.rb +10 -23
- data/examples/32_newsletter_reader.rb +188 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +306 -0
- data/examples/34_agentskills.rb +72 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +8 -1
- data/examples/common.rb +80 -0
- data/examples/ruboruby.md +423 -0
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +64 -0
- data/lib/robot_lab/agent_skill_catalog.rb +74 -0
- data/lib/robot_lab/ask_user.rb +2 -2
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/bus_poller.rb +12 -5
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +5 -16
- data/lib/robot_lab/delegation_future.rb +1 -1
- data/lib/robot_lab/doom_loop_detector.rb +98 -0
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/history_compressor.rb +4 -10
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/client.rb +1 -2
- data/lib/robot_lab/mcp/connection_poller.rb +5 -5
- data/lib/robot_lab/mcp/server.rb +1 -1
- data/lib/robot_lab/mcp/server_discovery.rb +0 -2
- data/lib/robot_lab/memory.rb +32 -27
- data/lib/robot_lab/memory_change.rb +2 -2
- data/lib/robot_lab/message.rb +4 -4
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +87 -25
- data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +79 -33
- data/lib/robot_lab/robot/history_search.rb +4 -1
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +12 -12
- data/lib/robot_lab/robot/template_rendering.rb +62 -42
- data/lib/robot_lab/robot.rb +482 -242
- data/lib/robot_lab/robot_result.rb +6 -5
- data/lib/robot_lab/run_config.rb +27 -14
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +134 -0
- data/lib/robot_lab/state_proxy.rb +7 -5
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +54 -13
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +5 -7
- data/lib/robot_lab/user_message.rb +2 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +1 -1
- data/lib/robot_lab.rb +89 -51
- data/logfile +8 -0
- data/mkdocs.yml +3 -3
- data/robot_concurrency.md +38 -0
- data/simple_acp_review.md +298 -0
- data/temp.md +6 -0
- data/tool_manifest_plan.md +155 -0
- metadata +82 -83
- data/docs/examples/rails-application.md +0 -419
- data/docs/guides/ractor-parallelism.md +0 -364
- data/docs/guides/rails-integration.md +0 -681
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
- data/lib/generators/robot_lab/install_generator.rb +0 -90
- data/lib/generators/robot_lab/job_generator.rb +0 -40
- data/lib/generators/robot_lab/robot_generator.rb +0 -55
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
- data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
- data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
- data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
- data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
- data/lib/robot_lab/document_store.rb +0 -155
- data/lib/robot_lab/ractor_boundary.rb +0 -42
- data/lib/robot_lab/ractor_job.rb +0 -37
- data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
- data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
- data/lib/robot_lab/ractor_worker_pool.rb +0 -117
- data/lib/robot_lab/rails_integration/engine.rb +0 -29
- data/lib/robot_lab/rails_integration/job.rb +0 -158
- data/lib/robot_lab/rails_integration/railtie.rb +0 -51
- 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
@
|
|
48
|
-
|
|
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: [], **
|
|
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
|
-
**
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
#
|
|
8
|
-
# @mcp_config, @tools_config, @
|
|
9
|
-
#
|
|
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&.
|
|
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&.
|
|
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(
|
|
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.
|
|
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
|
-
#
|
|
10
|
-
# @chat, @template, @build_context, @name, @name_from_constructor,
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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)
|