robot_lab 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/deploy-github-pages.yml +9 -9
- data/.irbrc +6 -0
- data/CHANGELOG.md +90 -0
- data/README.md +203 -46
- data/Rakefile +70 -1
- data/docs/api/core/index.md +12 -0
- data/docs/api/core/robot.md +478 -130
- data/docs/api/core/tool.md +205 -209
- data/docs/api/history/active-record-adapter.md +174 -94
- data/docs/api/history/config.md +186 -93
- data/docs/api/history/index.md +57 -61
- data/docs/api/history/thread-manager.md +123 -73
- data/docs/api/mcp/client.md +119 -48
- data/docs/api/mcp/index.md +75 -60
- data/docs/api/mcp/server.md +120 -136
- data/docs/api/mcp/transports.md +172 -184
- data/docs/api/streaming/context.md +157 -74
- data/docs/api/streaming/events.md +114 -166
- data/docs/api/streaming/index.md +74 -72
- data/docs/architecture/core-concepts.md +361 -112
- data/docs/architecture/index.md +97 -59
- data/docs/architecture/message-flow.md +138 -129
- data/docs/architecture/network-orchestration.md +197 -50
- data/docs/architecture/robot-execution.md +199 -146
- data/docs/architecture/state-management.md +255 -187
- data/docs/concepts.md +312 -48
- data/docs/examples/basic-chat.md +89 -77
- data/docs/examples/index.md +222 -47
- data/docs/examples/mcp-server.md +207 -203
- data/docs/examples/multi-robot-network.md +129 -35
- data/docs/examples/rails-application.md +159 -160
- data/docs/examples/tool-usage.md +295 -204
- data/docs/getting-started/configuration.md +275 -162
- data/docs/getting-started/index.md +1 -1
- data/docs/getting-started/installation.md +22 -13
- data/docs/getting-started/quick-start.md +166 -121
- data/docs/guides/building-robots.md +417 -212
- data/docs/guides/creating-networks.md +94 -24
- data/docs/guides/mcp-integration.md +152 -113
- data/docs/guides/memory.md +220 -164
- data/docs/guides/streaming.md +80 -110
- data/docs/guides/using-tools.md +259 -212
- data/docs/index.md +50 -37
- data/examples/01_simple_robot.rb +6 -9
- data/examples/02_tools.rb +6 -9
- data/examples/03_network.rb +13 -14
- data/examples/04_mcp.rb +5 -8
- data/examples/05_streaming.rb +5 -8
- data/examples/06_prompt_templates.rb +42 -37
- data/examples/07_network_memory.rb +13 -14
- data/examples/08_llm_config.rb +140 -0
- data/examples/09_chaining.rb +223 -0
- data/examples/10_memory.rb +331 -0
- data/examples/11_network_introspection.rb +230 -0
- data/examples/12_message_bus.rb +74 -0
- data/examples/13_spawn.rb +90 -0
- data/examples/14_rusty_circuit/comic.rb +143 -0
- data/examples/14_rusty_circuit/display.rb +203 -0
- data/examples/14_rusty_circuit/heckler.rb +57 -0
- data/examples/14_rusty_circuit/open_mic.rb +121 -0
- data/examples/14_rusty_circuit/prompts/open_mic_comic.md +20 -0
- data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +23 -0
- data/examples/14_rusty_circuit/prompts/open_mic_scout.md +20 -0
- data/examples/14_rusty_circuit/scout.rb +173 -0
- data/examples/14_rusty_circuit/scout_notes.md +89 -0
- data/examples/14_rusty_circuit/show.log +234 -0
- data/examples/15_memory_network_and_bus/editor_in_chief.rb +24 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +206 -0
- data/examples/15_memory_network_and_bus/linux_writer.rb +80 -0
- data/examples/15_memory_network_and_bus/os_editor.rb +46 -0
- data/examples/15_memory_network_and_bus/os_writer.rb +46 -0
- data/examples/15_memory_network_and_bus/output/combined_article.md +13 -0
- data/examples/15_memory_network_and_bus/output/final_article.md +15 -0
- data/examples/15_memory_network_and_bus/output/linux_draft.md +5 -0
- data/examples/15_memory_network_and_bus/output/mac_draft.md +7 -0
- data/examples/15_memory_network_and_bus/output/memory.json +13 -0
- data/examples/15_memory_network_and_bus/output/revision_1.md +19 -0
- data/examples/15_memory_network_and_bus/output/revision_2.md +15 -0
- data/examples/15_memory_network_and_bus/output/windows_draft.md +7 -0
- data/examples/15_memory_network_and_bus/prompts/os_advocate.md +13 -0
- data/examples/15_memory_network_and_bus/prompts/os_chief.md +13 -0
- data/examples/15_memory_network_and_bus/prompts/os_editor.md +13 -0
- data/examples/README.md +197 -0
- data/examples/prompts/{assistant/system.txt.erb → assistant.md} +3 -0
- data/examples/prompts/{billing/system.txt.erb → billing.md} +3 -0
- data/examples/prompts/{classifier/system.txt.erb → classifier.md} +3 -0
- data/examples/prompts/comedian.md +6 -0
- data/examples/prompts/comedy_critic.md +10 -0
- data/examples/prompts/configurable.md +9 -0
- data/examples/prompts/dispatcher.md +12 -0
- data/examples/prompts/{entity_extractor/system.txt.erb → entity_extractor.md} +3 -0
- data/examples/prompts/{escalation/system.txt.erb → escalation.md} +7 -0
- data/examples/prompts/frontmatter_mcp_test.md +9 -0
- data/examples/prompts/frontmatter_named_test.md +5 -0
- data/examples/prompts/frontmatter_tools_test.md +6 -0
- data/examples/prompts/{general/system.txt.erb → general.md} +3 -0
- data/examples/prompts/{github_assistant/system.txt.erb → github_assistant.md} +8 -0
- data/examples/prompts/{helper/system.txt.erb → helper.md} +3 -0
- data/examples/prompts/{keyword_extractor/system.txt.erb → keyword_extractor.md} +3 -0
- data/examples/prompts/llm_config_demo.md +20 -0
- data/examples/prompts/{order_support/system.txt.erb → order_support.md} +8 -0
- data/examples/prompts/os_advocate.md +13 -0
- data/examples/prompts/os_chief.md +13 -0
- data/examples/prompts/os_editor.md +13 -0
- data/examples/prompts/{product_support/system.txt.erb → product_support.md} +7 -0
- data/examples/prompts/{sentiment_analyzer/system.txt.erb → sentiment_analyzer.md} +3 -0
- data/examples/prompts/{synthesizer/system.txt.erb → synthesizer.md} +3 -0
- data/examples/prompts/{technical/system.txt.erb → technical.md} +3 -0
- data/examples/prompts/{triage/system.txt.erb → triage.md} +6 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +1 -1
- data/lib/robot_lab/adapters/openai.rb +2 -1
- data/lib/robot_lab/ask_user.rb +75 -0
- data/lib/robot_lab/config/defaults.yml +121 -0
- data/lib/robot_lab/config.rb +183 -0
- data/lib/robot_lab/error.rb +6 -0
- data/lib/robot_lab/mcp/client.rb +1 -1
- data/lib/robot_lab/memory.rb +2 -2
- data/lib/robot_lab/robot.rb +523 -249
- data/lib/robot_lab/robot_message.rb +44 -0
- data/lib/robot_lab/robot_result.rb +1 -0
- data/lib/robot_lab/robotic_model.rb +1 -1
- data/lib/robot_lab/streaming/context.rb +1 -1
- data/lib/robot_lab/tool.rb +108 -172
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +2 -18
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +66 -55
- metadata +107 -116
- data/examples/prompts/assistant/user.txt.erb +0 -1
- data/examples/prompts/billing/user.txt.erb +0 -1
- data/examples/prompts/classifier/user.txt.erb +0 -1
- data/examples/prompts/entity_extractor/user.txt.erb +0 -3
- data/examples/prompts/escalation/user.txt.erb +0 -34
- data/examples/prompts/general/user.txt.erb +0 -1
- data/examples/prompts/github_assistant/user.txt.erb +0 -1
- data/examples/prompts/helper/user.txt.erb +0 -1
- data/examples/prompts/keyword_extractor/user.txt.erb +0 -3
- data/examples/prompts/order_support/user.txt.erb +0 -22
- data/examples/prompts/product_support/user.txt.erb +0 -32
- data/examples/prompts/sentiment_analyzer/user.txt.erb +0 -3
- data/examples/prompts/synthesizer/user.txt.erb +0 -15
- data/examples/prompts/technical/user.txt.erb +0 -1
- data/examples/prompts/triage/user.txt.erb +0 -17
- data/lib/robot_lab/configuration.rb +0 -143
data/lib/robot_lab/robot.rb
CHANGED
|
@@ -1,76 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RobotLab
|
|
4
|
-
# LLM-powered robot
|
|
4
|
+
# LLM-powered robot built on RubyLLM::Agent
|
|
5
5
|
#
|
|
6
|
-
# Robot is a
|
|
7
|
-
# - Template-based prompts via
|
|
8
|
-
# -
|
|
9
|
-
# -
|
|
10
|
-
# -
|
|
11
|
-
# - Hierarchical MCP and tools configuration
|
|
6
|
+
# Robot is a subclass of RubyLLM::Agent that adds:
|
|
7
|
+
# - Template-based prompts via prompt_manager
|
|
8
|
+
# - Shared memory (standalone or network)
|
|
9
|
+
# - Tool integration with hierarchical MCP configuration
|
|
10
|
+
# - SimpleFlow pipeline integration
|
|
12
11
|
#
|
|
13
12
|
# == Memory Behavior
|
|
14
13
|
#
|
|
15
|
-
# Robots have two memory contexts depending on how they're used:
|
|
16
|
-
#
|
|
17
14
|
# *Standalone*: Robot uses its own inherent memory (`robot.memory`).
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# *In a Network*: Robot uses the network's shared memory (`network.memory`).
|
|
21
|
-
# The robot's inherent memory is ignored. Use `network.reset_memory` to clear it.
|
|
22
|
-
#
|
|
23
|
-
# This allows the same robot instance to work both standalone and as part
|
|
24
|
-
# of a network, with appropriate memory isolation in each context.
|
|
15
|
+
# *In a Network*: Robot uses the network's shared memory.
|
|
25
16
|
#
|
|
26
17
|
# @example Simple robot with template
|
|
27
|
-
# robot = Robot.new(
|
|
28
|
-
#
|
|
29
|
-
# template: :helper,
|
|
30
|
-
# context: { company_name: "Acme Corp" }
|
|
31
|
-
# )
|
|
32
|
-
# result = robot.run(message: "Hello!", user_name: "Alice")
|
|
18
|
+
# robot = Robot.new(name: "helper", template: :helper)
|
|
19
|
+
# result = robot.run("Hello!")
|
|
33
20
|
#
|
|
34
|
-
# @example Robot with inline system prompt
|
|
35
|
-
# robot = Robot.new(
|
|
36
|
-
#
|
|
37
|
-
# system_prompt: "You are a helpful assistant. Be concise."
|
|
38
|
-
# )
|
|
21
|
+
# @example Robot with inline system prompt
|
|
22
|
+
# robot = Robot.new(name: "bot", system_prompt: "You are helpful.")
|
|
23
|
+
# result = robot.run("What is 2+2?")
|
|
39
24
|
#
|
|
40
|
-
# @example
|
|
41
|
-
# robot = Robot.new(
|
|
42
|
-
#
|
|
43
|
-
# template: :support_agent,
|
|
44
|
-
# system_prompt: "Today is #{Date.today}. Current promotion: 20% off."
|
|
45
|
-
# )
|
|
25
|
+
# @example Bare robot configured via chaining
|
|
26
|
+
# robot = Robot.new(name: "bot")
|
|
27
|
+
# robot.with_instructions("Be concise.").run("Hello")
|
|
46
28
|
#
|
|
47
29
|
# @example Robot with tools
|
|
48
30
|
# robot = Robot.new(
|
|
49
31
|
# name: "support",
|
|
50
32
|
# template: :support,
|
|
51
|
-
#
|
|
52
|
-
# tools: [OrderLookup, RefundProcessor]
|
|
33
|
+
# local_tools: [OrderLookup, RefundProcessor]
|
|
53
34
|
# )
|
|
54
35
|
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
36
|
+
class Robot < RubyLLM::Agent
|
|
37
|
+
# Front matter keys that map to chat configuration methods
|
|
38
|
+
FRONT_MATTER_CONFIG_KEYS = %i[
|
|
39
|
+
model temperature top_p top_k max_tokens
|
|
40
|
+
presence_penalty frequency_penalty stop
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Front matter keys for robot identity and capabilities.
|
|
44
|
+
# Note: uses `robot_name` because PM::Metadata reserves `name` for the filename.
|
|
45
|
+
FRONT_MATTER_EXTRA_KEYS = %i[tools mcp robot_name description].freeze
|
|
46
|
+
|
|
64
47
|
# @!attribute [r] name
|
|
65
48
|
# @return [String] the unique identifier for the robot
|
|
66
49
|
# @!attribute [r] description
|
|
67
50
|
# @return [String, nil] an optional description of the robot's purpose
|
|
68
51
|
# @!attribute [r] template
|
|
69
|
-
# @return [Symbol, nil] the
|
|
52
|
+
# @return [Symbol, nil] the prompt_manager template for the robot's prompt
|
|
70
53
|
# @!attribute [r] system_prompt
|
|
71
54
|
# @return [String, nil] inline system prompt (used alone or appended to template)
|
|
72
|
-
# @!attribute [r] model
|
|
73
|
-
# @return [String, Object] the LLM model identifier or model object
|
|
74
55
|
# @!attribute [r] local_tools
|
|
75
56
|
# @return [Array] the locally defined tools for this robot
|
|
76
57
|
# @!attribute [r] mcp_clients
|
|
@@ -79,7 +60,15 @@ module RobotLab
|
|
|
79
60
|
# @return [Array<Tool>] tools discovered from MCP servers
|
|
80
61
|
# @!attribute [r] memory
|
|
81
62
|
# @return [Memory] the robot's inherent memory (used when standalone, not in network)
|
|
82
|
-
|
|
63
|
+
# @!attribute [rw] input
|
|
64
|
+
# @return [IO] input stream for user interaction (default: $stdin)
|
|
65
|
+
# @!attribute [rw] output
|
|
66
|
+
# @return [IO] output stream for user interaction (default: $stdout)
|
|
67
|
+
attr_accessor :input, :output
|
|
68
|
+
|
|
69
|
+
attr_reader :name, :description, :template, :system_prompt,
|
|
70
|
+
:local_tools, :mcp_clients, :mcp_tools, :memory,
|
|
71
|
+
:bus, :outbox
|
|
83
72
|
|
|
84
73
|
# @!attribute [r] mcp_config
|
|
85
74
|
# @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
|
|
@@ -90,37 +79,26 @@ module RobotLab
|
|
|
90
79
|
# Creates a new Robot instance.
|
|
91
80
|
#
|
|
92
81
|
# @param name [String] the unique identifier for the robot
|
|
93
|
-
# @param template [Symbol, nil] the
|
|
94
|
-
# @param system_prompt [String, nil] inline system prompt
|
|
95
|
-
# @param context [Hash, Proc] variables to pass to the template
|
|
96
|
-
# @param description [String, nil]
|
|
97
|
-
# @param local_tools [Array] tools defined locally
|
|
98
|
-
# @param model [String, nil] the LLM model to use
|
|
82
|
+
# @param template [Symbol, nil] the prompt_manager template
|
|
83
|
+
# @param system_prompt [String, nil] inline system prompt
|
|
84
|
+
# @param context [Hash, Proc] variables to pass to the template
|
|
85
|
+
# @param description [String, nil] optional description
|
|
86
|
+
# @param local_tools [Array] tools defined locally
|
|
87
|
+
# @param model [String, nil] the LLM model to use
|
|
99
88
|
# @param mcp_servers [Array] legacy parameter for MCP server configurations
|
|
100
|
-
# @param mcp [Symbol, Array] hierarchical MCP config
|
|
101
|
-
# @param tools [Symbol, Array] hierarchical tools config
|
|
89
|
+
# @param mcp [Symbol, Array] hierarchical MCP config
|
|
90
|
+
# @param tools [Symbol, Array] hierarchical tools config
|
|
102
91
|
# @param on_tool_call [Proc, nil] callback invoked when a tool is called
|
|
103
92
|
# @param on_tool_result [Proc, nil] callback invoked when a tool returns a result
|
|
104
|
-
# @param enable_cache [Boolean] whether to enable semantic caching
|
|
105
|
-
#
|
|
106
|
-
# @
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
# @
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# @
|
|
113
|
-
# Robot.new(name: "bot", template: :base, system_prompt: "Extra context here.")
|
|
114
|
-
#
|
|
115
|
-
# @example Robot with tools and callbacks
|
|
116
|
-
# Robot.new(
|
|
117
|
-
# name: "support",
|
|
118
|
-
# template: :support,
|
|
119
|
-
# local_tools: [OrderLookup],
|
|
120
|
-
# on_tool_call: ->(call) { puts "Calling #{call.name}" }
|
|
121
|
-
# )
|
|
122
|
-
#
|
|
123
|
-
# @raise [ArgumentError] if neither template nor system_prompt is provided
|
|
93
|
+
# @param enable_cache [Boolean] whether to enable semantic caching
|
|
94
|
+
# @param bus [TypedBus::MessageBus, nil] optional message bus for inter-robot communication
|
|
95
|
+
# @param temperature [Float, nil] controls randomness
|
|
96
|
+
# @param top_p [Float, nil] nucleus sampling threshold
|
|
97
|
+
# @param top_k [Integer, nil] top-k sampling
|
|
98
|
+
# @param max_tokens [Integer, nil] maximum tokens in response
|
|
99
|
+
# @param presence_penalty [Float, nil] penalize based on presence
|
|
100
|
+
# @param frequency_penalty [Float, nil] penalize based on frequency
|
|
101
|
+
# @param stop [String, Array, nil] stop sequences
|
|
124
102
|
def initialize(
|
|
125
103
|
name:,
|
|
126
104
|
template: nil,
|
|
@@ -134,24 +112,27 @@ module RobotLab
|
|
|
134
112
|
tools: :none,
|
|
135
113
|
on_tool_call: nil,
|
|
136
114
|
on_tool_result: nil,
|
|
137
|
-
enable_cache: true
|
|
115
|
+
enable_cache: true,
|
|
116
|
+
bus: nil,
|
|
117
|
+
temperature: nil,
|
|
118
|
+
top_p: nil,
|
|
119
|
+
top_k: nil,
|
|
120
|
+
max_tokens: nil,
|
|
121
|
+
presence_penalty: nil,
|
|
122
|
+
frequency_penalty: nil,
|
|
123
|
+
stop: nil
|
|
138
124
|
)
|
|
139
|
-
unless template || system_prompt
|
|
140
|
-
raise ArgumentError, "Must provide either template or system_prompt"
|
|
141
|
-
end
|
|
142
|
-
|
|
143
125
|
@name = name.to_s
|
|
126
|
+
@name_from_constructor = (name.to_s != "robot")
|
|
144
127
|
@template = template
|
|
145
128
|
@system_prompt = system_prompt
|
|
146
129
|
@build_context = context
|
|
147
130
|
@description = description
|
|
148
131
|
@local_tools = Array(local_tools)
|
|
149
|
-
@model = model || RobotLab.configuration.default_model
|
|
150
132
|
@on_tool_call = on_tool_call
|
|
151
133
|
@on_tool_result = on_tool_result
|
|
152
134
|
|
|
153
135
|
# Store raw config values for hierarchical resolution
|
|
154
|
-
# mcp_servers is legacy parameter, mcp is the new hierarchical one
|
|
155
136
|
@mcp_config = mcp_servers.any? ? mcp_servers : mcp
|
|
156
137
|
@tools_config = tools
|
|
157
138
|
|
|
@@ -160,45 +141,97 @@ module RobotLab
|
|
|
160
141
|
@mcp_tools = []
|
|
161
142
|
@mcp_initialized = false
|
|
162
143
|
|
|
144
|
+
# Bus state (optional inter-robot communication)
|
|
145
|
+
@bus = bus
|
|
146
|
+
@message_counter = 0
|
|
147
|
+
@outbox = {}
|
|
148
|
+
@message_handler = nil
|
|
149
|
+
|
|
163
150
|
# Inherent memory (used when standalone, not in a network)
|
|
164
151
|
@memory = Memory.new(enable_cache: enable_cache)
|
|
152
|
+
|
|
153
|
+
# Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
|
|
154
|
+
config = RobotLab.config
|
|
155
|
+
|
|
156
|
+
# Build chat kwargs for Agent's super
|
|
157
|
+
resolved_model = model || config.ruby_llm.model
|
|
158
|
+
chat_kwargs = { model: resolved_model }
|
|
159
|
+
|
|
160
|
+
# Create the persistent chat via Agent's initialize
|
|
161
|
+
super(chat: nil, **chat_kwargs)
|
|
162
|
+
|
|
163
|
+
# Apply template first (includes front matter config like model, temperature)
|
|
164
|
+
# then constructor params override — constructor is more specific than template.
|
|
165
|
+
apply_template_to_chat(context) if @template
|
|
166
|
+
@chat.with_instructions(@system_prompt) if @system_prompt
|
|
167
|
+
|
|
168
|
+
# Constructor params override template front matter
|
|
169
|
+
apply_chat_option(:with_temperature, temperature)
|
|
170
|
+
apply_chat_option(:with_top_p, top_p)
|
|
171
|
+
apply_chat_option(:with_top_k, top_k)
|
|
172
|
+
apply_chat_option(:with_max_tokens, max_tokens)
|
|
173
|
+
apply_chat_option(:with_presence_penalty, presence_penalty)
|
|
174
|
+
apply_chat_option(:with_frequency_penalty, frequency_penalty)
|
|
175
|
+
apply_chat_option(:with_stop, stop)
|
|
176
|
+
|
|
177
|
+
# Apply callbacks
|
|
178
|
+
@chat.on_tool_call(&@on_tool_call) if @on_tool_call
|
|
179
|
+
@chat.on_tool_result(&@on_tool_result) if @on_tool_result
|
|
180
|
+
|
|
181
|
+
# Set up bus channel if a bus was provided
|
|
182
|
+
setup_bus_channel if @bus
|
|
165
183
|
end
|
|
166
184
|
|
|
167
|
-
|
|
185
|
+
|
|
186
|
+
# Returns the model identifier
|
|
168
187
|
#
|
|
169
|
-
#
|
|
188
|
+
# @return [String, nil] the LLM model ID string
|
|
189
|
+
def model
|
|
190
|
+
return nil unless @chat.respond_to?(:model)
|
|
191
|
+
|
|
192
|
+
m = @chat.model
|
|
193
|
+
m.respond_to?(:id) ? m.id : m.to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Forward with_* methods to the persistent chat, returning self for chaining
|
|
197
|
+
%i[
|
|
198
|
+
with_model with_temperature with_top_p with_top_k with_max_tokens
|
|
199
|
+
with_presence_penalty with_frequency_penalty with_stop
|
|
200
|
+
with_instructions with_tool with_tools with_params
|
|
201
|
+
with_headers with_schema with_context with_thinking
|
|
202
|
+
].each do |method|
|
|
203
|
+
define_method(method) do |*args, **kwargs, &block|
|
|
204
|
+
@chat.public_send(method, *args, **kwargs, &block)
|
|
205
|
+
self
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Apply a prompt_manager template to the robot's chat
|
|
170
210
|
#
|
|
171
|
-
# @
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
# @param template_id [Symbol, String] the template identifier
|
|
212
|
+
# @param context [Hash] variables to pass to the template
|
|
213
|
+
# @return [self]
|
|
214
|
+
def with_template(template_id, **context)
|
|
215
|
+
@template = template_id.to_sym
|
|
216
|
+
@build_context = context
|
|
217
|
+
apply_template_to_chat(context)
|
|
218
|
+
self
|
|
174
219
|
end
|
|
175
220
|
|
|
176
|
-
|
|
221
|
+
|
|
222
|
+
# Send a message and get a response, with Robot's extended capabilities
|
|
177
223
|
#
|
|
178
|
-
# @param
|
|
179
|
-
# @param
|
|
180
|
-
# @param
|
|
181
|
-
# @param
|
|
182
|
-
# @param
|
|
183
|
-
# @param
|
|
224
|
+
# @param message [String] the user message
|
|
225
|
+
# @param network [NetworkRun, nil] network context (legacy)
|
|
226
|
+
# @param network_memory [Memory, nil] shared network memory
|
|
227
|
+
# @param memory [Memory, Hash, nil] runtime memory to merge
|
|
228
|
+
# @param mcp [Symbol, Array, nil] runtime MCP override
|
|
229
|
+
# @param tools [Symbol, Array, nil] runtime tools override
|
|
184
230
|
# @return [RobotResult]
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
#
|
|
190
|
-
# @example Runtime memory injection
|
|
191
|
-
# robot.run(message: "Hello", memory: { user_id: 123, session: "abc" })
|
|
192
|
-
#
|
|
193
|
-
# @example With network shared memory
|
|
194
|
-
# robot.run(message: "Analyze this", network_memory: network.memory)
|
|
195
|
-
#
|
|
196
|
-
def run(network: nil, network_memory: nil, memory: nil, mcp: :none, tools: :none, **run_context)
|
|
197
|
-
# Determine which memory to use (priority order):
|
|
198
|
-
# 1. Explicit network_memory parameter
|
|
199
|
-
# 2. Network object's memory (legacy)
|
|
200
|
-
# 3. Robot's inherent memory
|
|
201
|
-
run_memory = network_memory || network&.memory || @memory
|
|
231
|
+
def run(message = nil, network: nil, network_memory: nil, memory: nil, mcp: :none, tools: :none,
|
|
232
|
+
**kwargs)
|
|
233
|
+
# Determine which memory to use
|
|
234
|
+
run_memory = resolve_active_memory(network: network, network_memory: network_memory)
|
|
202
235
|
|
|
203
236
|
# Merge runtime memory if provided
|
|
204
237
|
case memory
|
|
@@ -220,84 +253,219 @@ module RobotLab
|
|
|
220
253
|
# Initialize or update MCP clients based on resolved config
|
|
221
254
|
ensure_mcp_clients(resolved_mcp)
|
|
222
255
|
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
256
|
+
# Apply filtered tools to the persistent chat
|
|
257
|
+
filtered = filtered_tools(resolved_tools)
|
|
258
|
+
@chat.with_tools(*filtered) if filtered.any?
|
|
226
259
|
|
|
227
|
-
#
|
|
228
|
-
|
|
260
|
+
# Re-render template with run-time context merged into build-time context.
|
|
261
|
+
# Template parameters (e.g. customer: null) may require values that are
|
|
262
|
+
# only available at run time — the robot gathers them before rendering.
|
|
263
|
+
run_context = kwargs.except(:with)
|
|
264
|
+
rerender_template(run_context) if @template && run_context.any?
|
|
229
265
|
|
|
230
|
-
#
|
|
231
|
-
|
|
266
|
+
# Delegate to Agent's ask (which calls @chat.ask)
|
|
267
|
+
ask_kwargs = kwargs.slice(:with)
|
|
268
|
+
response = ask(message, **ask_kwargs)
|
|
232
269
|
|
|
233
270
|
build_result(response, run_memory)
|
|
234
271
|
ensure
|
|
235
|
-
# Restore previous writer
|
|
236
272
|
run_memory.current_writer = previous_writer
|
|
237
273
|
end
|
|
238
274
|
end
|
|
239
275
|
|
|
240
|
-
|
|
276
|
+
|
|
277
|
+
# Reconfigure the robot for a new context
|
|
241
278
|
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
279
|
+
# @param template [Symbol, nil] new template to apply
|
|
280
|
+
# @param context [Hash, nil] new context for the template
|
|
281
|
+
# @param system_prompt [String, nil] new system prompt
|
|
282
|
+
# @param model [String, nil] new model
|
|
283
|
+
# @param temperature [Float, nil] new temperature
|
|
284
|
+
# @return [self]
|
|
285
|
+
def update(template: nil, context: nil, system_prompt: nil, model: nil, temperature: nil, **kwargs)
|
|
286
|
+
if template
|
|
287
|
+
@template = template
|
|
288
|
+
ctx = context || @build_context
|
|
289
|
+
apply_template_to_chat(ctx)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
@chat.with_instructions(system_prompt) if system_prompt
|
|
293
|
+
@chat.with_model(model) if model
|
|
294
|
+
apply_chat_option(:with_temperature, temperature)
|
|
295
|
+
|
|
296
|
+
kwargs.each do |key, value|
|
|
297
|
+
method = :"with_#{key}"
|
|
298
|
+
@chat.public_send(method, value) if value && @chat.respond_to?(method)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
self
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# SimpleFlow step interface
|
|
245
306
|
#
|
|
246
307
|
# @param result [SimpleFlow::Result] incoming result from previous step
|
|
247
308
|
# @return [SimpleFlow::Result] result with robot output
|
|
248
|
-
#
|
|
249
|
-
# @example Using a robot as a pipeline step
|
|
250
|
-
# pipeline = SimpleFlow::Pipeline.new do
|
|
251
|
-
# step :classifier, classifier_robot, depends_on: :none
|
|
252
|
-
# step :billing, billing_robot, depends_on: :optional
|
|
253
|
-
# end
|
|
254
|
-
#
|
|
255
309
|
def call(result)
|
|
256
|
-
|
|
310
|
+
run_context = extract_run_context(result)
|
|
311
|
+
|
|
312
|
+
# Extract the message from run context
|
|
313
|
+
message = run_context.delete(:message)
|
|
314
|
+
|
|
315
|
+
robot_result = run(message, **run_context)
|
|
257
316
|
|
|
258
317
|
result
|
|
259
318
|
.with_context(@name.to_sym, robot_result)
|
|
260
319
|
.continue(robot_result)
|
|
261
320
|
end
|
|
262
321
|
|
|
322
|
+
|
|
263
323
|
# Reset the robot's inherent memory
|
|
264
324
|
#
|
|
265
|
-
# NOTE: This only affects the robot's standalone memory. When a robot runs
|
|
266
|
-
# as part of a network, it uses the network's shared memory instead.
|
|
267
|
-
# To reset memory for network execution, use `network.reset_memory`.
|
|
268
|
-
#
|
|
269
325
|
# @return [self]
|
|
326
|
+
def reset_memory
|
|
327
|
+
@memory.reset
|
|
328
|
+
self
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Send a message to another robot via the bus.
|
|
270
333
|
#
|
|
271
|
-
# @
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
334
|
+
# @param to [String, Symbol] target robot's channel name
|
|
335
|
+
# @param content [String, Hash] message payload
|
|
336
|
+
# @return [RobotMessage] the sent message
|
|
337
|
+
# @raise [BusError] if no bus is configured
|
|
338
|
+
def send_message(to:, content:)
|
|
339
|
+
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
340
|
+
|
|
341
|
+
@message_counter += 1
|
|
342
|
+
message = RobotMessage.build(id: @message_counter, from: @name, content: content)
|
|
343
|
+
@outbox[message.key] = { message: message, status: :sent, replies: [] }
|
|
344
|
+
publish_to_bus(to.to_sym, message)
|
|
345
|
+
message
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Send a reply to a specific message via the bus.
|
|
275
350
|
#
|
|
276
|
-
# @
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
351
|
+
# @param to [String, Symbol] target robot's channel name
|
|
352
|
+
# @param content [String, Hash] reply payload
|
|
353
|
+
# @param in_reply_to [String] composite key of the message being replied to
|
|
354
|
+
# @return [RobotMessage] the reply message
|
|
355
|
+
# @raise [BusError] if no bus is configured
|
|
356
|
+
def send_reply(to:, content:, in_reply_to:)
|
|
357
|
+
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
358
|
+
|
|
359
|
+
@message_counter += 1
|
|
360
|
+
reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
|
|
361
|
+
publish_to_bus(to.to_sym, reply)
|
|
362
|
+
reply
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# Convenience method to reply to a RobotMessage.
|
|
281
367
|
#
|
|
282
|
-
|
|
283
|
-
|
|
368
|
+
# @param message [RobotMessage] the message to reply to
|
|
369
|
+
# @param content [String, Hash] reply payload
|
|
370
|
+
# @return [RobotMessage] the reply message
|
|
371
|
+
def reply(message, content)
|
|
372
|
+
send_reply(to: message.from.to_sym, content: content, in_reply_to: message.key)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Register a custom handler for incoming bus messages.
|
|
377
|
+
#
|
|
378
|
+
# Block arity controls delivery handling:
|
|
379
|
+
# - 1 argument `|message|`: auto-acks before calling, auto-nacks on exception
|
|
380
|
+
# - 2 arguments `|delivery, message|`: manual mode, you call ack!/nack!
|
|
381
|
+
#
|
|
382
|
+
# @yield [message] or [delivery, message]
|
|
383
|
+
# @return [self]
|
|
384
|
+
def on_message(&block)
|
|
385
|
+
@message_handler = block
|
|
284
386
|
self
|
|
285
387
|
end
|
|
286
388
|
|
|
287
|
-
|
|
389
|
+
|
|
390
|
+
# Spawn a new robot on a shared bus.
|
|
391
|
+
#
|
|
392
|
+
# Creates a new Robot instance that shares this robot's bus,
|
|
393
|
+
# allowing it to immediately send and receive messages with
|
|
394
|
+
# all other robots on the bus. If no bus exists yet, one is
|
|
395
|
+
# created automatically and the parent robot is connected to it.
|
|
396
|
+
#
|
|
397
|
+
# @param name [String] unique name for the new robot
|
|
398
|
+
# @param system_prompt [String, nil] inline system prompt
|
|
399
|
+
# @param template [Symbol, nil] prompt_manager template
|
|
400
|
+
# @param local_tools [Array] tools for the new robot
|
|
401
|
+
# @param options [Hash] additional options passed to RobotLab.build
|
|
402
|
+
# @return [Robot] the newly created robot
|
|
403
|
+
#
|
|
404
|
+
# @example Spawn from a bus-less robot (bus and name created automatically)
|
|
405
|
+
# bot = RobotLab.build
|
|
406
|
+
# bot2 = bot.spawn(system_prompt: "You are helpful.")
|
|
407
|
+
#
|
|
408
|
+
# @example Spawn a specialist from a message handler
|
|
409
|
+
# on_message do |message|
|
|
410
|
+
# specialist = spawn(
|
|
411
|
+
# name: "fact_checker",
|
|
412
|
+
# system_prompt: "You verify factual claims. Be concise."
|
|
413
|
+
# )
|
|
414
|
+
# specialist.send_message(to: name.to_sym, content: specialist.run(message.content).last_text_content)
|
|
415
|
+
# end
|
|
416
|
+
#
|
|
417
|
+
def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
|
|
418
|
+
ensure_bus
|
|
419
|
+
|
|
420
|
+
RobotLab.build(
|
|
421
|
+
name: name,
|
|
422
|
+
system_prompt: system_prompt,
|
|
423
|
+
template: template,
|
|
424
|
+
local_tools: local_tools,
|
|
425
|
+
bus: @bus,
|
|
426
|
+
**options
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# Connect this robot to a message bus.
|
|
288
432
|
#
|
|
289
|
-
#
|
|
433
|
+
# If a bus is provided, the robot joins it. If no bus is provided
|
|
434
|
+
# and the robot doesn't already have one, a new bus is created.
|
|
435
|
+
# No-op if the robot is already on the given bus.
|
|
290
436
|
#
|
|
437
|
+
# @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
|
|
291
438
|
# @return [self]
|
|
292
439
|
#
|
|
440
|
+
# @example Join an existing bus
|
|
441
|
+
# bot = RobotLab.build.with_bus(some_bus)
|
|
442
|
+
#
|
|
443
|
+
# @example Create a bus on demand
|
|
444
|
+
# bot = RobotLab.build.with_bus
|
|
445
|
+
#
|
|
446
|
+
def with_bus(bus = nil)
|
|
447
|
+
return self if bus && @bus == bus
|
|
448
|
+
|
|
449
|
+
teardown_bus_channel if @bus
|
|
450
|
+
@bus = bus || @bus || TypedBus::MessageBus.new
|
|
451
|
+
setup_bus_channel
|
|
452
|
+
self
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# Disconnect all MCP clients and bus channel.
|
|
457
|
+
#
|
|
458
|
+
# @return [self]
|
|
293
459
|
def disconnect
|
|
294
460
|
@mcp_clients.each_value(&:disconnect)
|
|
461
|
+
teardown_bus_channel if @bus
|
|
295
462
|
self
|
|
296
463
|
end
|
|
297
464
|
|
|
298
|
-
|
|
465
|
+
|
|
466
|
+
# Converts the robot to a hash representation
|
|
299
467
|
#
|
|
300
|
-
# @return [Hash]
|
|
468
|
+
# @return [Hash]
|
|
301
469
|
def to_h
|
|
302
470
|
{
|
|
303
471
|
name: name,
|
|
@@ -309,24 +477,131 @@ module RobotLab
|
|
|
309
477
|
mcp_config: @mcp_config,
|
|
310
478
|
tools_config: @tools_config,
|
|
311
479
|
mcp_servers: @mcp_clients.keys,
|
|
312
|
-
model: model
|
|
480
|
+
model: model,
|
|
481
|
+
bus: @bus ? true : nil
|
|
313
482
|
}.compact
|
|
314
483
|
end
|
|
315
484
|
|
|
316
485
|
private
|
|
317
486
|
|
|
487
|
+
# Apply a chat option if the value is non-nil
|
|
488
|
+
def apply_chat_option(method, value)
|
|
489
|
+
@chat.public_send(method, value) if value
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# Apply a prompt_manager template to the persistent chat.
|
|
494
|
+
# If required parameters are missing, applies front matter config but
|
|
495
|
+
# defers rendering until run time when all values are available.
|
|
496
|
+
def apply_template_to_chat(context)
|
|
497
|
+
parsed = PM.parse(@template)
|
|
498
|
+
|
|
499
|
+
# Extract extra config from front matter (name, description, tools, mcp)
|
|
500
|
+
apply_front_matter_extras(parsed.metadata)
|
|
501
|
+
|
|
502
|
+
# Extract and apply LLM config to the chat (model, temperature, etc.)
|
|
503
|
+
apply_front_matter_config(parsed.metadata)
|
|
504
|
+
|
|
505
|
+
# Resolve context (could be a Proc)
|
|
506
|
+
resolved_ctx = resolve_context(context, network: nil)
|
|
507
|
+
|
|
508
|
+
# Render the template body with context
|
|
509
|
+
begin
|
|
510
|
+
rendered = parsed.to_s(**resolved_ctx)
|
|
511
|
+
@chat.with_instructions(rendered)
|
|
512
|
+
rescue ArgumentError => e
|
|
513
|
+
raise unless e.message.start_with?("Missing required parameters:")
|
|
514
|
+
|
|
515
|
+
# Required parameters not yet available; template will be
|
|
516
|
+
# fully rendered at run time via rerender_template.
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# Re-render the template with run-time context merged into build-time context.
|
|
522
|
+
# prompt_manager parameters may be required (null) and only available at run time.
|
|
523
|
+
def rerender_template(run_context)
|
|
524
|
+
merged = (@build_context || {}).merge(run_context)
|
|
525
|
+
parsed = PM.parse(@template)
|
|
526
|
+
resolved_ctx = resolve_context(merged, network: nil)
|
|
527
|
+
rendered = parsed.to_s(**resolved_ctx)
|
|
528
|
+
@chat.with_instructions(rendered)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# Extract whitelisted config from front matter and apply to chat
|
|
533
|
+
def apply_front_matter_config(metadata)
|
|
534
|
+
FRONT_MATTER_CONFIG_KEYS.each do |key|
|
|
535
|
+
value = metadata.respond_to?(key) ? metadata.send(key) : nil
|
|
536
|
+
next unless value
|
|
537
|
+
|
|
538
|
+
method = :"with_#{key}"
|
|
539
|
+
@chat.public_send(method, value) if @chat.respond_to?(method)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Handle model specially (may need with_model)
|
|
543
|
+
return unless metadata.respond_to?(:model) && metadata.model
|
|
544
|
+
|
|
545
|
+
@chat.with_model(metadata.model)
|
|
546
|
+
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# Extract identity and capability keys from front matter metadata.
|
|
551
|
+
# Constructor-provided values take precedence over frontmatter.
|
|
552
|
+
def apply_front_matter_extras(metadata)
|
|
553
|
+
if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
|
|
554
|
+
@name = metadata.robot_name.to_s
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
if metadata.respond_to?(:description) && metadata.description && @description.nil?
|
|
558
|
+
@description = metadata.description.to_s
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
if metadata.respond_to?(:tools) && metadata.tools.is_a?(Array) && @local_tools.empty?
|
|
562
|
+
@local_tools = resolve_frontmatter_tools(metadata.tools)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
if metadata.respond_to?(:mcp) && metadata.mcp.is_a?(Array) && ToolConfig.none_value?(@mcp_config)
|
|
566
|
+
@mcp_config = metadata.mcp.map { |m| m.is_a?(Hash) ? m.transform_keys(&:to_sym) : m }
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# Resolve string tool names from frontmatter to Ruby constants.
|
|
572
|
+
# Tool subclasses are instantiated; instances are used as-is.
|
|
573
|
+
# Unresolvable names are skipped with a warning.
|
|
574
|
+
def resolve_frontmatter_tools(tool_names)
|
|
575
|
+
tool_names.filter_map do |name|
|
|
576
|
+
case name
|
|
577
|
+
when String
|
|
578
|
+
begin
|
|
579
|
+
const = Object.const_get(name)
|
|
580
|
+
const.is_a?(Class) && const < RubyLLM::Tool ? const.new : const
|
|
581
|
+
rescue NameError
|
|
582
|
+
RobotLab.config.logger.warn("Robot '#{@name}': tool '#{name}' not found, skipping")
|
|
583
|
+
nil
|
|
584
|
+
end
|
|
585
|
+
when Class
|
|
586
|
+
name.new
|
|
587
|
+
else
|
|
588
|
+
name
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# Determine which memory to use
|
|
595
|
+
def resolve_active_memory(network: nil, network_memory: nil)
|
|
596
|
+
network_memory || network&.memory || @memory
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
|
|
318
600
|
# Extract run context from SimpleFlow::Result
|
|
319
|
-
#
|
|
320
|
-
# Merges original run params (preserved in context) with current value.
|
|
321
|
-
# Extracts special parameters (mcp, tools, memory, network_memory) for Robot#run.
|
|
322
|
-
#
|
|
323
|
-
# @param result [SimpleFlow::Result] the incoming result
|
|
324
|
-
# @return [Hash] context for run method including mcp/tools config
|
|
325
|
-
#
|
|
326
601
|
def extract_run_context(result)
|
|
327
602
|
run_params = result.context[:run_params] || {}
|
|
328
603
|
|
|
329
|
-
# Extract robot-specific params
|
|
604
|
+
# Extract robot-specific params
|
|
330
605
|
mcp = run_params.delete(:mcp) || :none
|
|
331
606
|
tools = run_params.delete(:tools) || :none
|
|
332
607
|
memory = run_params.delete(:memory)
|
|
@@ -347,7 +622,7 @@ module RobotLab
|
|
|
347
622
|
base.merge(message: result.value.to_s)
|
|
348
623
|
end
|
|
349
624
|
|
|
350
|
-
# Add back the special params
|
|
625
|
+
# Add back the special params
|
|
351
626
|
merged[:mcp] = mcp
|
|
352
627
|
merged[:tools] = tools
|
|
353
628
|
merged[:memory] = memory if memory
|
|
@@ -356,6 +631,7 @@ module RobotLab
|
|
|
356
631
|
merged
|
|
357
632
|
end
|
|
358
633
|
|
|
634
|
+
|
|
359
635
|
def resolve_context(context, network:)
|
|
360
636
|
case context
|
|
361
637
|
when Proc then context.call(network: network)
|
|
@@ -364,41 +640,10 @@ module RobotLab
|
|
|
364
640
|
end
|
|
365
641
|
end
|
|
366
642
|
|
|
367
|
-
def build_chat(context, allowed_tools:, memory:)
|
|
368
|
-
model_id = @model.respond_to?(:model_id) ? @model.model_id : @model.to_s
|
|
369
|
-
|
|
370
|
-
chat = RubyLLM.chat(model: model_id)
|
|
371
|
-
|
|
372
|
-
# Apply template and/or system_prompt
|
|
373
|
-
# - Template only: use with_template
|
|
374
|
-
# - system_prompt only: use with_instructions
|
|
375
|
-
# - Both: use with_template, then append with_instructions
|
|
376
|
-
if @template
|
|
377
|
-
chat = chat.with_template(@template, **context)
|
|
378
|
-
chat = chat.with_instructions(@system_prompt) if @system_prompt
|
|
379
|
-
else
|
|
380
|
-
chat = chat.with_instructions(@system_prompt)
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# Get filtered tools based on whitelist
|
|
384
|
-
filtered = filtered_tools(allowed_tools)
|
|
385
|
-
chat = chat.with_tools(*filtered) if filtered.any?
|
|
386
|
-
|
|
387
|
-
# Add callbacks if provided
|
|
388
|
-
chat = chat.on_tool_call(&@on_tool_call) if @on_tool_call
|
|
389
|
-
chat = chat.on_tool_result(&@on_tool_result) if @on_tool_result
|
|
390
|
-
|
|
391
|
-
# NOTE: Semantic cache wrapping is disabled because the SemanticCache::Middleware
|
|
392
|
-
# only supports `ask` method, not `complete`. The caching feature needs to be
|
|
393
|
-
# re-designed to use the `ask` interface or the `fetch` pattern.
|
|
394
|
-
# See: https://github.com/ruby-llm/ruby_llm-semantic_cache
|
|
395
|
-
|
|
396
|
-
chat
|
|
397
|
-
end
|
|
398
643
|
|
|
399
644
|
def build_result(response, _memory)
|
|
400
645
|
output = if response.respond_to?(:content) && response.content
|
|
401
|
-
[TextMessage.new(role:
|
|
646
|
+
[TextMessage.new(role: 'assistant', content: response.content)]
|
|
402
647
|
else
|
|
403
648
|
[]
|
|
404
649
|
end
|
|
@@ -413,6 +658,7 @@ module RobotLab
|
|
|
413
658
|
)
|
|
414
659
|
end
|
|
415
660
|
|
|
661
|
+
|
|
416
662
|
def normalize_tool_calls(tool_calls)
|
|
417
663
|
return [] unless tool_calls
|
|
418
664
|
|
|
@@ -420,7 +666,7 @@ module RobotLab
|
|
|
420
666
|
if tc.is_a?(Hash)
|
|
421
667
|
ToolResultMessage.new(
|
|
422
668
|
tool: tc,
|
|
423
|
-
content: tc[:result] || tc[
|
|
669
|
+
content: tc[:result] || tc['result']
|
|
424
670
|
)
|
|
425
671
|
else
|
|
426
672
|
tc
|
|
@@ -428,57 +674,32 @@ module RobotLab
|
|
|
428
674
|
end
|
|
429
675
|
end
|
|
430
676
|
|
|
677
|
+
|
|
431
678
|
# Resolve MCP hierarchy: runtime -> robot build -> network -> config
|
|
432
|
-
#
|
|
433
|
-
# @param runtime_value [Symbol, Array, nil] Runtime MCP override
|
|
434
|
-
# @param network [NetworkRun, nil] Network context
|
|
435
|
-
# @return [Array] Resolved MCP server configurations
|
|
436
|
-
#
|
|
437
679
|
def resolve_mcp_hierarchy(runtime_value, network:)
|
|
438
|
-
|
|
439
|
-
parent_value = network&.network&.mcp || RobotLab.configuration.mcp
|
|
440
|
-
|
|
441
|
-
# Resolve robot build config against parent
|
|
680
|
+
parent_value = network&.network&.mcp || RobotLab.config.mcp
|
|
442
681
|
build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
|
|
443
|
-
|
|
444
|
-
# Resolve runtime against build
|
|
445
682
|
ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
|
|
446
683
|
end
|
|
447
684
|
|
|
685
|
+
|
|
448
686
|
# Resolve tools hierarchy: runtime -> robot build -> network -> config
|
|
449
|
-
#
|
|
450
|
-
# @param runtime_value [Symbol, Array, nil] Runtime tools override
|
|
451
|
-
# @param network [NetworkRun, nil] Network context
|
|
452
|
-
# @return [Array<String>] Resolved tool names whitelist
|
|
453
|
-
#
|
|
454
687
|
def resolve_tools_hierarchy(runtime_value, network:)
|
|
455
|
-
|
|
456
|
-
parent_value = network&.network&.tools || RobotLab.configuration.tools
|
|
457
|
-
|
|
458
|
-
# Resolve robot build config against parent
|
|
688
|
+
parent_value = network&.network&.tools || RobotLab.config.tools
|
|
459
689
|
build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
|
|
460
|
-
|
|
461
|
-
# Resolve runtime against build
|
|
462
690
|
ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
|
|
463
691
|
end
|
|
464
692
|
|
|
693
|
+
|
|
465
694
|
# Ensure MCP clients are initialized for the given server configs
|
|
466
|
-
#
|
|
467
|
-
# @param mcp_servers [Array] MCP server configurations
|
|
468
|
-
#
|
|
469
695
|
def ensure_mcp_clients(mcp_servers)
|
|
470
696
|
return if mcp_servers.empty?
|
|
471
697
|
|
|
472
|
-
# Get server names from configs
|
|
473
698
|
needed_servers = mcp_servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
|
|
474
|
-
|
|
475
|
-
# Skip if already initialized with same servers
|
|
476
699
|
return if @mcp_initialized && (@mcp_clients.keys.sort == needed_servers.sort)
|
|
477
700
|
|
|
478
|
-
# Disconnect existing clients if config changed
|
|
479
701
|
disconnect if @mcp_initialized
|
|
480
702
|
|
|
481
|
-
# Initialize new clients
|
|
482
703
|
@mcp_clients = {}
|
|
483
704
|
@mcp_tools = []
|
|
484
705
|
|
|
@@ -489,10 +710,7 @@ module RobotLab
|
|
|
489
710
|
@mcp_initialized = true
|
|
490
711
|
end
|
|
491
712
|
|
|
492
|
-
|
|
493
|
-
#
|
|
494
|
-
# @param server_config [Hash] MCP server configuration
|
|
495
|
-
#
|
|
713
|
+
|
|
496
714
|
def init_mcp_client(server_config)
|
|
497
715
|
client = MCP::Client.new(server_config)
|
|
498
716
|
client.connect
|
|
@@ -502,17 +720,13 @@ module RobotLab
|
|
|
502
720
|
@mcp_clients[server_name] = client
|
|
503
721
|
discover_mcp_tools(client, server_name)
|
|
504
722
|
else
|
|
505
|
-
RobotLab.
|
|
723
|
+
RobotLab.config.logger.warn(
|
|
506
724
|
"Robot '#{@name}' failed to connect to MCP server: #{server_config[:name] || server_config}"
|
|
507
725
|
)
|
|
508
726
|
end
|
|
509
727
|
end
|
|
510
728
|
|
|
511
|
-
|
|
512
|
-
#
|
|
513
|
-
# @param client [MCP::Client] Connected MCP client
|
|
514
|
-
# @param server_name [String] Name of the MCP server
|
|
515
|
-
#
|
|
729
|
+
|
|
516
730
|
def discover_mcp_tools(client, server_name)
|
|
517
731
|
tools = client.list_tools
|
|
518
732
|
|
|
@@ -520,41 +734,101 @@ module RobotLab
|
|
|
520
734
|
tool_name = tool_def[:name]
|
|
521
735
|
mcp_client = client
|
|
522
736
|
|
|
523
|
-
|
|
524
|
-
tool = Tool.new(
|
|
737
|
+
tool = Tool.create(
|
|
525
738
|
name: tool_name,
|
|
526
739
|
description: tool_def[:description],
|
|
527
740
|
parameters: tool_def[:inputSchema],
|
|
528
|
-
mcp: server_name
|
|
529
|
-
|
|
530
|
-
)
|
|
741
|
+
mcp: server_name
|
|
742
|
+
) { |args| mcp_client.call_tool(tool_name, args) }
|
|
531
743
|
|
|
532
744
|
@mcp_tools << tool
|
|
533
745
|
end
|
|
534
746
|
|
|
535
|
-
RobotLab.
|
|
747
|
+
RobotLab.config.logger.info(
|
|
536
748
|
"Robot '#{@name}' discovered #{tools.size} tools from MCP server '#{server_name}'"
|
|
537
749
|
)
|
|
538
750
|
end
|
|
539
751
|
|
|
540
|
-
|
|
541
|
-
#
|
|
542
|
-
# @return [Array] Combined array of local and MCP tools
|
|
543
|
-
#
|
|
752
|
+
|
|
544
753
|
def all_tools
|
|
545
754
|
@local_tools + @mcp_tools
|
|
546
755
|
end
|
|
547
756
|
|
|
548
|
-
|
|
549
|
-
#
|
|
550
|
-
# @param allowed_names [Array<String>] Whitelist of tool names (empty = all allowed)
|
|
551
|
-
# @return [Array] Filtered tools
|
|
552
|
-
#
|
|
757
|
+
|
|
553
758
|
def filtered_tools(allowed_names)
|
|
554
759
|
available = all_tools
|
|
555
760
|
return available if allowed_names.empty?
|
|
556
761
|
|
|
557
762
|
ToolConfig.filter_tools(available, allowed_names: allowed_names)
|
|
558
763
|
end
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# Create a bus if one doesn't exist and connect this robot to it
|
|
767
|
+
def ensure_bus
|
|
768
|
+
with_bus unless @bus
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# Create a typed channel on the bus and subscribe to it
|
|
773
|
+
def setup_bus_channel
|
|
774
|
+
channel_name = @name.to_sym
|
|
775
|
+
@bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
|
|
776
|
+
@bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| handle_incoming_delivery(delivery) }
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# Unsubscribe from the bus channel
|
|
781
|
+
def teardown_bus_channel
|
|
782
|
+
channel_name = @name.to_sym
|
|
783
|
+
@bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
|
|
784
|
+
@bus_subscriber_id = nil
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# Dispatch incoming bus delivery to handler.
|
|
789
|
+
# Auto-ack when the handler takes 1 arg (message only);
|
|
790
|
+
# manual ack/nack when the handler takes 2 args (delivery, message).
|
|
791
|
+
def handle_incoming_delivery(delivery)
|
|
792
|
+
message = delivery.message
|
|
793
|
+
|
|
794
|
+
# Correlate replies with outbox entries
|
|
795
|
+
if message.reply? && @outbox.key?(message.in_reply_to)
|
|
796
|
+
entry = @outbox[message.in_reply_to]
|
|
797
|
+
entry[:status] = :replied
|
|
798
|
+
entry[:replies] << message
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
if @message_handler
|
|
802
|
+
if @message_handler.arity == 1
|
|
803
|
+
delivery.ack!
|
|
804
|
+
@message_handler.call(message)
|
|
805
|
+
else
|
|
806
|
+
@message_handler.call(delivery, message)
|
|
807
|
+
end
|
|
808
|
+
else
|
|
809
|
+
handle_message_via_llm(delivery, message)
|
|
810
|
+
end
|
|
811
|
+
rescue => e
|
|
812
|
+
delivery.nack! if delivery.pending?
|
|
813
|
+
raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# Default handler: interpret message via LLM and reply
|
|
818
|
+
def handle_message_via_llm(delivery, message)
|
|
819
|
+
delivery.ack!
|
|
820
|
+
result = run(message.content.to_s)
|
|
821
|
+
send_reply(to: message.from.to_sym, content: result.last_text_content, in_reply_to: message.key)
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# Publish a RobotMessage to a bus channel
|
|
826
|
+
def publish_to_bus(channel_name, message)
|
|
827
|
+
if defined?(Async::Task) && Async::Task.current?
|
|
828
|
+
@bus.publish(channel_name, message)
|
|
829
|
+
else
|
|
830
|
+
Async { @bus.publish(channel_name, message) }
|
|
831
|
+
end
|
|
832
|
+
end
|
|
559
833
|
end
|
|
560
834
|
end
|