robot_lab 0.0.1 → 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/.github/workflows/deploy-github-pages.yml +9 -9
- data/.irbrc +6 -0
- data/CHANGELOG.md +140 -0
- data/README.md +263 -48
- data/Rakefile +71 -1
- data/docs/api/core/index.md +53 -46
- data/docs/api/core/memory.md +200 -154
- data/docs/api/core/network.md +13 -3
- data/docs/api/core/robot.md +490 -130
- data/docs/api/core/state.md +55 -73
- data/docs/api/core/tool.md +205 -209
- data/docs/api/index.md +7 -28
- 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/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/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 +360 -116
- 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 +311 -49
- 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 +347 -154
- 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 +418 -212
- data/docs/guides/creating-networks.md +143 -24
- data/docs/guides/index.md +0 -5
- data/docs/guides/mcp-integration.md +152 -113
- data/docs/guides/memory.md +220 -164
- data/docs/guides/rails-integration.md +244 -162
- data/docs/guides/streaming.md +137 -187
- data/docs/guides/using-tools.md +259 -212
- data/docs/index.md +46 -41
- data/examples/01_simple_robot.rb +6 -9
- data/examples/02_tools.rb +6 -9
- data/examples/03_network.rb +19 -17
- 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 +169 -0
- data/examples/09_chaining.rb +262 -0
- data/examples/10_memory.rb +331 -0
- data/examples/11_network_introspection.rb +253 -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 +63 -0
- data/examples/14_rusty_circuit/open_mic.rb +123 -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 +156 -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/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/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 +0 -13
- 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 +10 -34
- 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 +240 -330
- data/lib/robot_lab/robot_message.rb +44 -0
- data/lib/robot_lab/robot_result.rb +1 -0
- data/lib/robot_lab/run_config.rb +184 -0
- data/lib/robot_lab/state_proxy.rb +2 -12
- data/lib/robot_lab/streaming/context.rb +1 -1
- data/lib/robot_lab/task.rb +8 -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/utils.rb +39 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +89 -57
- data/mkdocs.yml +0 -11
- metadata +121 -135
- 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 -195
- data/docs/api/history/config.md +0 -191
- data/docs/api/history/index.md +0 -132
- data/docs/api/history/thread-manager.md +0 -144
- data/docs/guides/history.md +0 -359
- 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/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 -159
- data/lib/robot_lab/adapters/registry.rb +0 -81
- data/lib/robot_lab/configuration.rb +0 -143
- 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
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -59,6 +59,8 @@ module RobotLab
|
|
|
59
59
|
# memory.cache # => RubyLLM::SemanticCache instance
|
|
60
60
|
#
|
|
61
61
|
class Memory
|
|
62
|
+
include Utils
|
|
63
|
+
|
|
62
64
|
# Reserved keys that have special behavior
|
|
63
65
|
RESERVED_KEYS = %i[data results messages session_id cache].freeze
|
|
64
66
|
|
|
@@ -553,8 +555,8 @@ module RobotLab
|
|
|
553
555
|
def clone
|
|
554
556
|
cloned = Memory.new(
|
|
555
557
|
data: deep_dup(data.to_h),
|
|
556
|
-
results: results
|
|
557
|
-
messages: messages
|
|
558
|
+
results: results,
|
|
559
|
+
messages: messages,
|
|
558
560
|
session_id: session_id,
|
|
559
561
|
backend: @backend.is_a?(Hash) ? :hash : :auto,
|
|
560
562
|
enable_cache: @enable_cache,
|
|
@@ -654,7 +656,7 @@ module RobotLab
|
|
|
654
656
|
return false unless defined?(Redis)
|
|
655
657
|
|
|
656
658
|
# Check if Redis is configured in RobotLab
|
|
657
|
-
redis_config = RobotLab.
|
|
659
|
+
redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
|
|
658
660
|
redis_config || ENV["REDIS_URL"]
|
|
659
661
|
end
|
|
660
662
|
|
|
@@ -685,25 +687,15 @@ module RobotLab
|
|
|
685
687
|
->(result) { result.output + result.tool_calls }
|
|
686
688
|
end
|
|
687
689
|
|
|
688
|
-
def deep_dup(obj)
|
|
689
|
-
case obj
|
|
690
|
-
when Hash
|
|
691
|
-
obj.transform_values { |v| deep_dup(v) }
|
|
692
|
-
when Array
|
|
693
|
-
obj.map { |v| deep_dup(v) }
|
|
694
|
-
else
|
|
695
|
-
obj.dup rescue obj
|
|
696
|
-
end
|
|
697
|
-
end
|
|
698
|
-
|
|
699
690
|
# =========================================================================
|
|
700
691
|
# Reactive Memory Helpers
|
|
701
692
|
# =========================================================================
|
|
702
693
|
|
|
703
694
|
def get_single(key, wait:)
|
|
704
695
|
# Try immediate read
|
|
705
|
-
|
|
706
|
-
|
|
696
|
+
@mutex.synchronize { return @backend[key] if @backend.key?(key) }
|
|
697
|
+
|
|
698
|
+
return nil unless wait
|
|
707
699
|
|
|
708
700
|
# Need to wait
|
|
709
701
|
timeout = wait == true ? nil : wait
|
|
@@ -740,8 +732,7 @@ module RobotLab
|
|
|
740
732
|
|
|
741
733
|
@waiter_mutex.synchronize do
|
|
742
734
|
# Double-check - value might have arrived while setting up
|
|
743
|
-
|
|
744
|
-
return value unless value.nil?
|
|
735
|
+
@mutex.synchronize { return @backend[key] if @backend.key?(key) }
|
|
745
736
|
|
|
746
737
|
@waiters[key] << waiter
|
|
747
738
|
end
|
|
@@ -795,21 +786,6 @@ module RobotLab
|
|
|
795
786
|
end
|
|
796
787
|
end
|
|
797
788
|
|
|
798
|
-
def dispatch_async(&block)
|
|
799
|
-
# Use Async if available (preferred for fiber-based concurrency)
|
|
800
|
-
if defined?(Async) && Async::Task.current?
|
|
801
|
-
Async { block.call }
|
|
802
|
-
else
|
|
803
|
-
# Fall back to Thread for basic async dispatch
|
|
804
|
-
Thread.new do
|
|
805
|
-
block.call
|
|
806
|
-
rescue StandardError => e
|
|
807
|
-
# Log but don't crash the notification system
|
|
808
|
-
warn "Memory subscription callback error: #{e.message}"
|
|
809
|
-
end
|
|
810
|
-
end
|
|
811
|
-
end
|
|
812
|
-
|
|
813
789
|
def generate_subscription_id
|
|
814
790
|
SecureRandom.uuid
|
|
815
791
|
end
|
|
@@ -868,7 +844,7 @@ module RobotLab
|
|
|
868
844
|
private
|
|
869
845
|
|
|
870
846
|
def create_redis_connection
|
|
871
|
-
redis_config = RobotLab.
|
|
847
|
+
redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
|
|
872
848
|
|
|
873
849
|
if redis_config.is_a?(Hash)
|
|
874
850
|
Redis.new(**redis_config)
|
data/lib/robot_lab/network.rb
CHANGED
|
@@ -58,6 +58,8 @@ module RobotLab
|
|
|
58
58
|
# network.broadcast(event: :pause, reason: "rate limit")
|
|
59
59
|
#
|
|
60
60
|
class Network
|
|
61
|
+
include Utils
|
|
62
|
+
|
|
61
63
|
# Reserved key for broadcast messages in memory
|
|
62
64
|
BROADCAST_KEY = :_network_broadcast
|
|
63
65
|
|
|
@@ -69,7 +71,7 @@ module RobotLab
|
|
|
69
71
|
# @return [Hash<String, Robot>] robots in this network, keyed by name
|
|
70
72
|
# @!attribute [r] memory
|
|
71
73
|
# @return [Memory] shared memory for all robots in the network
|
|
72
|
-
attr_reader :name, :pipeline, :robots, :memory
|
|
74
|
+
attr_reader :name, :pipeline, :robots, :memory, :config
|
|
73
75
|
|
|
74
76
|
# Creates a new Network instance.
|
|
75
77
|
#
|
|
@@ -84,12 +86,13 @@ module RobotLab
|
|
|
84
86
|
# task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
|
|
85
87
|
# end
|
|
86
88
|
#
|
|
87
|
-
def initialize(name:, concurrency: :auto, memory: nil, &block)
|
|
89
|
+
def initialize(name:, concurrency: :auto, memory: nil, config: nil, &block)
|
|
88
90
|
@name = name.to_s
|
|
89
91
|
@robots = {}
|
|
90
92
|
@tasks = {}
|
|
91
93
|
@pipeline = SimpleFlow::Pipeline.new(concurrency: concurrency)
|
|
92
94
|
@memory = memory || Memory.new(network_name: @name)
|
|
95
|
+
@config = config || RunConfig.new
|
|
93
96
|
@broadcast_handlers = []
|
|
94
97
|
|
|
95
98
|
instance_eval(&block) if block_given?
|
|
@@ -118,14 +121,15 @@ module RobotLab
|
|
|
118
121
|
# @example Task with dependencies
|
|
119
122
|
# task :writer, writer_robot, depends_on: [:analyst]
|
|
120
123
|
#
|
|
121
|
-
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, depends_on: :none)
|
|
124
|
+
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none)
|
|
122
125
|
task_wrapper = Task.new(
|
|
123
126
|
name: name,
|
|
124
127
|
robot: robot,
|
|
125
128
|
context: context,
|
|
126
129
|
mcp: mcp,
|
|
127
130
|
tools: tools,
|
|
128
|
-
memory: memory
|
|
131
|
+
memory: memory,
|
|
132
|
+
config: config
|
|
129
133
|
)
|
|
130
134
|
|
|
131
135
|
@robots[name.to_s] = robot
|
|
@@ -170,6 +174,9 @@ module RobotLab
|
|
|
170
174
|
# Include shared memory in run params so robots can access it
|
|
171
175
|
run_context[:network_memory] = @memory
|
|
172
176
|
|
|
177
|
+
# Pass network's config so robots can inherit it
|
|
178
|
+
run_context[:network_config] = @config unless @config.empty?
|
|
179
|
+
|
|
173
180
|
initial_result = SimpleFlow::Result.new(
|
|
174
181
|
run_context,
|
|
175
182
|
context: { run_params: run_context }
|
|
@@ -327,24 +334,10 @@ module RobotLab
|
|
|
327
334
|
name: name,
|
|
328
335
|
robots: @robots.keys,
|
|
329
336
|
tasks: @tasks.keys,
|
|
330
|
-
optional_tasks: @pipeline.optional_steps.to_a
|
|
337
|
+
optional_tasks: @pipeline.optional_steps.to_a,
|
|
338
|
+
config: (@config.empty? ? nil : @config.to_json_hash)
|
|
331
339
|
}.compact
|
|
332
340
|
end
|
|
333
341
|
|
|
334
|
-
private
|
|
335
|
-
|
|
336
|
-
def dispatch_async(&block)
|
|
337
|
-
# Use Async if available (preferred for fiber-based concurrency)
|
|
338
|
-
if defined?(Async) && Async::Task.current?
|
|
339
|
-
Async { block.call }
|
|
340
|
-
else
|
|
341
|
-
# Fall back to Thread for basic async dispatch
|
|
342
|
-
Thread.new do
|
|
343
|
-
block.call
|
|
344
|
-
rescue StandardError => e
|
|
345
|
-
warn "Network broadcast handler error: #{e.message}"
|
|
346
|
-
end
|
|
347
|
-
end
|
|
348
|
-
end
|
|
349
342
|
end
|
|
350
343
|
end
|
|
@@ -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
|