robot_lab 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +64 -6
- data/Rakefile +2 -1
- data/docs/api/core/index.md +41 -46
- data/docs/api/core/memory.md +200 -154
- data/docs/api/core/network.md +13 -3
- data/docs/api/core/robot.md +38 -26
- data/docs/api/core/state.md +55 -73
- data/docs/api/index.md +7 -28
- data/docs/api/messages/index.md +35 -20
- data/docs/api/messages/text-message.md +67 -21
- data/docs/api/messages/tool-call-message.md +80 -41
- data/docs/api/messages/tool-result-message.md +119 -50
- data/docs/api/messages/user-message.md +48 -24
- data/docs/architecture/core-concepts.md +10 -15
- data/docs/concepts.md +5 -7
- data/docs/examples/index.md +2 -2
- data/docs/getting-started/configuration.md +80 -0
- data/docs/guides/building-robots.md +10 -9
- data/docs/guides/creating-networks.md +49 -0
- data/docs/guides/index.md +0 -5
- data/docs/guides/rails-integration.md +244 -162
- data/docs/guides/streaming.md +118 -138
- data/docs/index.md +0 -8
- data/examples/03_network.rb +10 -7
- data/examples/08_llm_config.rb +40 -11
- data/examples/09_chaining.rb +45 -6
- data/examples/11_network_introspection.rb +30 -7
- data/examples/12_message_bus.rb +1 -1
- data/examples/14_rusty_circuit/heckler.rb +14 -8
- data/examples/14_rusty_circuit/open_mic.rb +5 -3
- data/examples/14_rusty_circuit/scout.rb +14 -31
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +1 -1
- data/examples/16_writers_room/display.rb +158 -0
- data/examples/16_writers_room/output/.gitignore +2 -0
- data/examples/16_writers_room/output/opus_001.md +263 -0
- data/examples/16_writers_room/output/opus_001_notes.log +470 -0
- data/examples/16_writers_room/prompts/writer.md +37 -0
- data/examples/16_writers_room/room.rb +150 -0
- data/examples/16_writers_room/tools.rb +162 -0
- data/examples/16_writers_room/writer.rb +121 -0
- data/examples/16_writers_room/writers_room.rb +162 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -13
- data/lib/robot_lab/memory.rb +8 -32
- data/lib/robot_lab/network.rb +13 -20
- data/lib/robot_lab/robot/bus_messaging.rb +239 -0
- data/lib/robot_lab/robot/mcp_management.rb +88 -0
- data/lib/robot_lab/robot/template_rendering.rb +130 -0
- data/lib/robot_lab/robot.rb +56 -420
- data/lib/robot_lab/run_config.rb +184 -0
- data/lib/robot_lab/state_proxy.rb +2 -12
- data/lib/robot_lab/task.rb +8 -1
- data/lib/robot_lab/utils.rb +39 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +29 -8
- data/mkdocs.yml +0 -11
- metadata +15 -20
- data/docs/api/adapters/anthropic.md +0 -121
- data/docs/api/adapters/gemini.md +0 -133
- data/docs/api/adapters/index.md +0 -104
- data/docs/api/adapters/openai.md +0 -134
- data/docs/api/history/active-record-adapter.md +0 -275
- data/docs/api/history/config.md +0 -284
- data/docs/api/history/index.md +0 -128
- data/docs/api/history/thread-manager.md +0 -194
- data/docs/guides/history.md +0 -359
- data/lib/robot_lab/adapters/anthropic.rb +0 -163
- data/lib/robot_lab/adapters/base.rb +0 -85
- data/lib/robot_lab/adapters/gemini.rb +0 -193
- data/lib/robot_lab/adapters/openai.rb +0 -160
- data/lib/robot_lab/adapters/registry.rb +0 -81
- data/lib/robot_lab/errors.rb +0 -70
- data/lib/robot_lab/history/active_record_adapter.rb +0 -146
- data/lib/robot_lab/history/config.rb +0 -115
- data/lib/robot_lab/history/thread_manager.rb +0 -93
- data/lib/robot_lab/robotic_model.rb +0 -324
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
# Inter-robot communication via TypedBus.
|
|
6
|
+
#
|
|
7
|
+
# Expects the including class to provide:
|
|
8
|
+
# @bus, @message_counter, @outbox, @message_handler,
|
|
9
|
+
# @bus_subscriber_id, @bus_processing, @bus_queue, @name
|
|
10
|
+
# and the `run` instance method
|
|
11
|
+
#
|
|
12
|
+
# == Processing Guard
|
|
13
|
+
#
|
|
14
|
+
# TypedBus delivers messages in concurrent Async fibers. When a robot's
|
|
15
|
+
# +run()+ yields during HTTP I/O, the Async scheduler can switch to
|
|
16
|
+
# another fiber delivering a new bus message to the same robot. This
|
|
17
|
+
# would interleave user messages between +tool_use+ / +tool_result+
|
|
18
|
+
# pairs in +@chat+, corrupting Anthropic API message ordering.
|
|
19
|
+
#
|
|
20
|
+
# The processing guard serializes delivery handling: deliveries that
|
|
21
|
+
# arrive while the robot is already processing are queued and drained
|
|
22
|
+
# sequentially after the current one completes.
|
|
23
|
+
#
|
|
24
|
+
module BusMessaging
|
|
25
|
+
# Send a message to another robot via the bus.
|
|
26
|
+
#
|
|
27
|
+
# @param to [String, Symbol] target robot's channel name
|
|
28
|
+
# @param content [String, Hash] message payload
|
|
29
|
+
# @return [RobotMessage] the sent message
|
|
30
|
+
# @raise [BusError] if no bus is configured
|
|
31
|
+
def send_message(to:, content:)
|
|
32
|
+
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
33
|
+
|
|
34
|
+
@message_counter += 1
|
|
35
|
+
message = RobotMessage.build(id: @message_counter, from: @name, content: content)
|
|
36
|
+
@outbox[message.key] = { message: message, status: :sent, replies: [] }
|
|
37
|
+
publish_to_bus(to.to_sym, message)
|
|
38
|
+
message
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Send a reply to a specific message via the bus.
|
|
43
|
+
#
|
|
44
|
+
# @param to [String, Symbol] target robot's channel name
|
|
45
|
+
# @param content [String, Hash] reply payload
|
|
46
|
+
# @param in_reply_to [String] composite key of the message being replied to
|
|
47
|
+
# @return [RobotMessage] the reply message
|
|
48
|
+
# @raise [BusError] if no bus is configured
|
|
49
|
+
def send_reply(to:, content:, in_reply_to:)
|
|
50
|
+
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
51
|
+
|
|
52
|
+
@message_counter += 1
|
|
53
|
+
reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
|
|
54
|
+
publish_to_bus(to.to_sym, reply)
|
|
55
|
+
reply
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Register a custom handler for incoming bus messages.
|
|
60
|
+
#
|
|
61
|
+
# Block arity controls delivery handling:
|
|
62
|
+
# - 1 argument `|message|`: auto-acks before calling, auto-nacks on exception
|
|
63
|
+
# - 2 arguments `|delivery, message|`: manual mode, you call ack!/nack!
|
|
64
|
+
#
|
|
65
|
+
# @yield [message] or [delivery, message]
|
|
66
|
+
# @return [self]
|
|
67
|
+
def on_message(&block)
|
|
68
|
+
@message_handler = block
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Spawn a new robot on a shared bus.
|
|
74
|
+
#
|
|
75
|
+
# Creates a new Robot instance that shares this robot's bus,
|
|
76
|
+
# allowing it to immediately send and receive messages with
|
|
77
|
+
# all other robots on the bus. If no bus exists yet, one is
|
|
78
|
+
# created automatically and the parent robot is connected to it.
|
|
79
|
+
#
|
|
80
|
+
# @param name [String] unique name for the new robot
|
|
81
|
+
# @param system_prompt [String, nil] inline system prompt
|
|
82
|
+
# @param template [Symbol, nil] prompt_manager template
|
|
83
|
+
# @param local_tools [Array] tools for the new robot
|
|
84
|
+
# @param options [Hash] additional options passed to RobotLab.build
|
|
85
|
+
# @return [Robot] the newly created robot
|
|
86
|
+
#
|
|
87
|
+
# @example Spawn from a bus-less robot (bus and name created automatically)
|
|
88
|
+
# bot = RobotLab.build
|
|
89
|
+
# bot2 = bot.spawn(system_prompt: "You are helpful.")
|
|
90
|
+
#
|
|
91
|
+
# @example Spawn a specialist from a message handler
|
|
92
|
+
# on_message do |message|
|
|
93
|
+
# specialist = spawn(
|
|
94
|
+
# name: "fact_checker",
|
|
95
|
+
# system_prompt: "You verify factual claims. Be concise."
|
|
96
|
+
# )
|
|
97
|
+
# specialist.send_message(to: name.to_sym, content: specialist.run(message.content).last_text_content)
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
|
|
101
|
+
ensure_bus
|
|
102
|
+
|
|
103
|
+
RobotLab.build(
|
|
104
|
+
name: name,
|
|
105
|
+
system_prompt: system_prompt,
|
|
106
|
+
template: template,
|
|
107
|
+
local_tools: local_tools,
|
|
108
|
+
bus: @bus,
|
|
109
|
+
**options
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Connect this robot to a message bus.
|
|
115
|
+
#
|
|
116
|
+
# If a bus is provided, the robot joins it. If no bus is provided
|
|
117
|
+
# and the robot doesn't already have one, a new bus is created.
|
|
118
|
+
# No-op if the robot is already on the given bus.
|
|
119
|
+
#
|
|
120
|
+
# @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
|
|
121
|
+
# @return [self]
|
|
122
|
+
#
|
|
123
|
+
# @example Join an existing bus
|
|
124
|
+
# bot = RobotLab.build.with_bus(some_bus)
|
|
125
|
+
#
|
|
126
|
+
# @example Create a bus on demand
|
|
127
|
+
# bot = RobotLab.build.with_bus
|
|
128
|
+
#
|
|
129
|
+
def with_bus(bus = nil)
|
|
130
|
+
return self if bus && @bus == bus
|
|
131
|
+
|
|
132
|
+
teardown_bus_channel if @bus
|
|
133
|
+
@bus = bus || @bus || TypedBus::MessageBus.new
|
|
134
|
+
setup_bus_channel
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Create a bus if one doesn't exist and connect this robot to it
|
|
141
|
+
def ensure_bus
|
|
142
|
+
with_bus unless @bus
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Create a typed channel on the bus and subscribe to it
|
|
147
|
+
def setup_bus_channel
|
|
148
|
+
channel_name = @name.to_sym
|
|
149
|
+
@bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
|
|
150
|
+
@bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| handle_incoming_delivery(delivery) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Unsubscribe from the bus channel
|
|
155
|
+
def teardown_bus_channel
|
|
156
|
+
channel_name = @name.to_sym
|
|
157
|
+
@bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
|
|
158
|
+
@bus_subscriber_id = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Dispatch incoming bus delivery to handler.
|
|
163
|
+
#
|
|
164
|
+
# Uses a processing guard to serialize delivery handling. When
|
|
165
|
+
# the robot is already processing a delivery (e.g., inside a
|
|
166
|
+
# run() call that yields during HTTP I/O), new deliveries are
|
|
167
|
+
# queued and drained sequentially after the current one completes.
|
|
168
|
+
#
|
|
169
|
+
# Auto-ack when the handler takes 1 arg (message only);
|
|
170
|
+
# manual ack/nack when the handler takes 2 args (delivery, message).
|
|
171
|
+
def handle_incoming_delivery(delivery)
|
|
172
|
+
if @bus_processing
|
|
173
|
+
@bus_queue << delivery
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
process_delivery(delivery)
|
|
178
|
+
drain_bus_queue
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Process a single delivery (called under the processing guard)
|
|
183
|
+
def process_delivery(delivery)
|
|
184
|
+
@bus_processing = true
|
|
185
|
+
|
|
186
|
+
message = delivery.message
|
|
187
|
+
|
|
188
|
+
# Correlate replies with outbox entries
|
|
189
|
+
if message.reply? && @outbox.key?(message.in_reply_to)
|
|
190
|
+
entry = @outbox[message.in_reply_to]
|
|
191
|
+
entry[:status] = :replied
|
|
192
|
+
entry[:replies] << message
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if @message_handler
|
|
196
|
+
if @message_handler.arity == 1
|
|
197
|
+
delivery.ack!
|
|
198
|
+
@message_handler.call(message)
|
|
199
|
+
else
|
|
200
|
+
@message_handler.call(delivery, message)
|
|
201
|
+
end
|
|
202
|
+
else
|
|
203
|
+
handle_message_via_llm(delivery, message)
|
|
204
|
+
end
|
|
205
|
+
rescue => e
|
|
206
|
+
delivery.nack! if delivery.pending?
|
|
207
|
+
raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
|
|
208
|
+
ensure
|
|
209
|
+
@bus_processing = false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Drain queued deliveries sequentially
|
|
214
|
+
def drain_bus_queue
|
|
215
|
+
while (queued = @bus_queue.shift)
|
|
216
|
+
process_delivery(queued)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Default handler: interpret message via LLM and reply
|
|
222
|
+
def handle_message_via_llm(delivery, message)
|
|
223
|
+
delivery.ack!
|
|
224
|
+
result = run(message.content.to_s)
|
|
225
|
+
send_reply(to: message.from.to_sym, content: result.last_text_content, in_reply_to: message.key)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Publish a RobotMessage to a bus channel
|
|
230
|
+
def publish_to_bus(channel_name, message)
|
|
231
|
+
if defined?(Async::Task) && Async::Task.current?
|
|
232
|
+
@bus.publish(channel_name, message)
|
|
233
|
+
else
|
|
234
|
+
Async { @bus.publish(channel_name, message) }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
# MCP client lifecycle and hierarchical tool/MCP resolution.
|
|
6
|
+
#
|
|
7
|
+
# Expects the including class to provide:
|
|
8
|
+
# @mcp_config, @tools_config, @mcp_clients, @mcp_tools,
|
|
9
|
+
# @mcp_initialized, @name, @chat, @local_tools
|
|
10
|
+
module MCPManagement
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Resolve MCP hierarchy: runtime -> robot build -> network -> config
|
|
14
|
+
def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
15
|
+
parent_value = network_config&.mcp || network&.network&.mcp || RobotLab.config.mcp
|
|
16
|
+
build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
|
|
17
|
+
ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Resolve tools hierarchy: runtime -> robot build -> network -> config
|
|
22
|
+
def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
23
|
+
parent_value = network_config&.tools || network&.network&.tools || RobotLab.config.tools
|
|
24
|
+
build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
|
|
25
|
+
ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Ensure MCP clients are initialized for the given server configs
|
|
30
|
+
def ensure_mcp_clients(mcp_servers)
|
|
31
|
+
return if mcp_servers.empty?
|
|
32
|
+
|
|
33
|
+
needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
|
|
34
|
+
return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
|
|
35
|
+
|
|
36
|
+
disconnect if @mcp_initialized
|
|
37
|
+
|
|
38
|
+
@mcp_clients = {}
|
|
39
|
+
@mcp_tools = []
|
|
40
|
+
|
|
41
|
+
mcp_servers.each do |server_config|
|
|
42
|
+
init_mcp_client(server_config)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@mcp_initialized = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init_mcp_client(server_config)
|
|
50
|
+
client = MCP::Client.new(server_config)
|
|
51
|
+
client.connect
|
|
52
|
+
|
|
53
|
+
if client.connected?
|
|
54
|
+
server_name = client.server.name
|
|
55
|
+
@mcp_clients[server_name] = client
|
|
56
|
+
discover_mcp_tools(client, server_name)
|
|
57
|
+
else
|
|
58
|
+
RobotLab.config.logger.warn(
|
|
59
|
+
"Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def discover_mcp_tools(client, server_name)
|
|
66
|
+
tools = client.list_tools
|
|
67
|
+
|
|
68
|
+
tools.each do |tool_def|
|
|
69
|
+
tool_name = tool_def[:name]
|
|
70
|
+
mcp_client = client
|
|
71
|
+
|
|
72
|
+
tool = Tool.create(
|
|
73
|
+
name: tool_name,
|
|
74
|
+
description: tool_def[:description],
|
|
75
|
+
parameters: tool_def[:inputSchema],
|
|
76
|
+
mcp: server_name
|
|
77
|
+
) { |args| mcp_client.call_tool(tool_name, args) }
|
|
78
|
+
|
|
79
|
+
@mcp_tools << tool
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
RobotLab.config.logger.info(
|
|
83
|
+
"Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
# Template loading, rendering, and front-matter extraction.
|
|
6
|
+
#
|
|
7
|
+
# Expects the including class to provide:
|
|
8
|
+
# @chat, @template, @build_context, @name, @name_from_constructor,
|
|
9
|
+
# @description, @local_tools, @mcp_config
|
|
10
|
+
module TemplateRendering
|
|
11
|
+
# Front matter keys that map to chat configuration methods
|
|
12
|
+
FRONT_MATTER_CONFIG_KEYS = %i[
|
|
13
|
+
model temperature top_p top_k max_tokens
|
|
14
|
+
presence_penalty frequency_penalty stop
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Front matter keys for robot identity and capabilities.
|
|
18
|
+
# Note: uses `robot_name` because PM::Metadata reserves `name` for the filename.
|
|
19
|
+
FRONT_MATTER_EXTRA_KEYS = %i[tools mcp robot_name description].freeze
|
|
20
|
+
|
|
21
|
+
# Apply a prompt_manager template to the robot's chat
|
|
22
|
+
#
|
|
23
|
+
# @param template_id [Symbol, String] the template identifier
|
|
24
|
+
# @param context [Hash] variables to pass to the template
|
|
25
|
+
# @return [self]
|
|
26
|
+
def with_template(template_id, **context)
|
|
27
|
+
@template = template_id.to_sym
|
|
28
|
+
@build_context = context
|
|
29
|
+
apply_template_to_chat(context)
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Apply a prompt_manager template to the persistent chat.
|
|
36
|
+
# If required parameters are missing, applies front matter config but
|
|
37
|
+
# defers rendering until run time when all values are available.
|
|
38
|
+
def apply_template_to_chat(context)
|
|
39
|
+
parsed = PM.parse(@template)
|
|
40
|
+
|
|
41
|
+
# Extract extra config from front matter (name, description, tools, mcp)
|
|
42
|
+
apply_front_matter_extras(parsed.metadata)
|
|
43
|
+
|
|
44
|
+
# Extract LLM config from front matter and apply to chat.
|
|
45
|
+
# Front matter is the base; @config (from constructor kwargs) overrides.
|
|
46
|
+
fm_config = RunConfig.from_front_matter(parsed.metadata)
|
|
47
|
+
effective = fm_config.merge(@config)
|
|
48
|
+
effective.apply_to(@chat)
|
|
49
|
+
|
|
50
|
+
# Resolve context (could be a Proc)
|
|
51
|
+
resolved_ctx = resolve_context(context, network: nil)
|
|
52
|
+
|
|
53
|
+
# Render the template body with context
|
|
54
|
+
begin
|
|
55
|
+
rendered = parsed.to_s(**resolved_ctx)
|
|
56
|
+
@chat.with_instructions(rendered)
|
|
57
|
+
rescue ArgumentError => e
|
|
58
|
+
raise unless e.message.start_with?("Missing required parameters:")
|
|
59
|
+
|
|
60
|
+
# Required parameters not yet available; template will be
|
|
61
|
+
# fully rendered at run time via rerender_template.
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Re-render the template with run-time context merged into build-time context.
|
|
67
|
+
# prompt_manager parameters may be required (null) and only available at run time.
|
|
68
|
+
def rerender_template(run_context)
|
|
69
|
+
merged = (@build_context || {}).merge(run_context)
|
|
70
|
+
parsed = PM.parse(@template)
|
|
71
|
+
resolved_ctx = resolve_context(merged, network: nil)
|
|
72
|
+
rendered = parsed.to_s(**resolved_ctx)
|
|
73
|
+
@chat.with_instructions(rendered)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Extract identity and capability keys from front matter metadata.
|
|
78
|
+
# Constructor-provided values take precedence over frontmatter.
|
|
79
|
+
def apply_front_matter_extras(metadata)
|
|
80
|
+
if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
|
|
81
|
+
@name = metadata.robot_name.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if metadata.respond_to?(:description) && metadata.description && @description.nil?
|
|
85
|
+
@description = metadata.description.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if metadata.respond_to?(:tools) && metadata.tools.is_a?(Array) && @local_tools.empty?
|
|
89
|
+
@local_tools = resolve_frontmatter_tools(metadata.tools)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
|
|
93
|
+
@mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Resolve string tool names from frontmatter to Ruby constants.
|
|
99
|
+
# Tool subclasses are instantiated; instances are used as-is.
|
|
100
|
+
# Unresolvable names are skipped with a warning.
|
|
101
|
+
def resolve_frontmatter_tools(tool_names)
|
|
102
|
+
tool_names.filter_map do |name|
|
|
103
|
+
case name
|
|
104
|
+
when String
|
|
105
|
+
begin
|
|
106
|
+
const = Object.const_get(name)
|
|
107
|
+
const.is_a?(Class) && const < RubyLLM::Tool ? const.new : const
|
|
108
|
+
rescue NameError
|
|
109
|
+
RobotLab.config.logger.warn("Robot '#{@name}': tool '#{name}' not found, skipping")
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
when Class
|
|
113
|
+
name.new
|
|
114
|
+
else
|
|
115
|
+
name
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve_context(context, network:)
|
|
122
|
+
case context
|
|
123
|
+
when Proc then context.call(network: network)
|
|
124
|
+
when Hash then context
|
|
125
|
+
else {}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|