robot_lab 0.0.9 → 0.0.12
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 +53 -0
- data/README.md +210 -1
- data/Rakefile +2 -1
- data/docs/api/core/result.md +123 -0
- data/docs/api/core/robot.md +182 -0
- data/docs/api/errors.md +185 -0
- data/docs/guides/building-robots.md +125 -0
- data/docs/guides/creating-networks.md +21 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +106 -0
- data/docs/guides/memory.md +2 -0
- data/docs/guides/observability.md +486 -0
- data/docs/guides/ractor-parallelism.md +364 -0
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
- data/examples/19_token_tracking.rb +128 -0
- data/examples/20_circuit_breaker.rb +153 -0
- data/examples/21_learning_loop.rb +164 -0
- data/examples/22_context_compression.rb +179 -0
- data/examples/23_convergence.rb +137 -0
- data/examples/24_structured_delegation.rb +150 -0
- data/examples/25_history_search/conversation.jsonl +30 -0
- data/examples/25_history_search.rb +136 -0
- data/examples/26_document_store/api_versioning_adr.md +52 -0
- data/examples/26_document_store/incident_postmortem.md +46 -0
- data/examples/26_document_store/postgres_runbook.md +49 -0
- data/examples/26_document_store/redis_caching_guide.md +48 -0
- data/examples/26_document_store/sidekiq_guide.md +51 -0
- data/examples/26_document_store.rb +147 -0
- data/examples/27_incident_response/incident_response.rb +244 -0
- data/examples/28_mcp_discovery.rb +112 -0
- data/examples/29_ractor_tools.rb +243 -0
- data/examples/30_ractor_network.rb +256 -0
- data/examples/README.md +136 -0
- data/examples/prompts/skill_with_mcp_test.md +9 -0
- data/examples/prompts/skill_with_robot_name_test.md +5 -0
- data/examples/prompts/skill_with_tools_test.md +6 -0
- data/lib/robot_lab/bus_poller.rb +149 -0
- data/lib/robot_lab/convergence.rb +69 -0
- data/lib/robot_lab/delegation_future.rb +93 -0
- data/lib/robot_lab/document_store.rb +155 -0
- data/lib/robot_lab/error.rb +25 -0
- data/lib/robot_lab/history_compressor.rb +205 -0
- data/lib/robot_lab/mcp/client.rb +17 -5
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +7 -2
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
- data/lib/robot_lab/memory.rb +103 -6
- data/lib/robot_lab/network.rb +44 -9
- data/lib/robot_lab/ractor_boundary.rb +42 -0
- data/lib/robot_lab/ractor_job.rb +37 -0
- data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
- data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
- data/lib/robot_lab/ractor_worker_pool.rb +117 -0
- data/lib/robot_lab/robot/bus_messaging.rb +43 -65
- data/lib/robot_lab/robot/history_search.rb +69 -0
- data/lib/robot_lab/robot.rb +228 -11
- data/lib/robot_lab/robot_result.rb +24 -5
- data/lib/robot_lab/run_config.rb +1 -1
- data/lib/robot_lab/text_analysis.rb +103 -0
- data/lib/robot_lab/tool.rb +42 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +49 -29
- data/lib/robot_lab.rb +25 -0
- data/mkdocs.yml +1 -0
- metadata +72 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ractor/wrapper"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
# Wraps a Memory instance via Ractor::Wrapper so Ractor workers can safely
|
|
7
|
+
# read and write shared state.
|
|
8
|
+
#
|
|
9
|
+
# Only get, set, and keys are proxied across the Ractor boundary.
|
|
10
|
+
# Subscriptions and callbacks are NOT proxied — closures are not
|
|
11
|
+
# Ractor-safe. Use the thread-side Memory directly for reactive subscriptions.
|
|
12
|
+
#
|
|
13
|
+
# Values passed to set() must be Ractor-shareable; RactorBoundary.freeze_deep
|
|
14
|
+
# is applied automatically.
|
|
15
|
+
#
|
|
16
|
+
# The proxy uses use_current_ractor: true so the Memory object stays in the
|
|
17
|
+
# calling Ractor and is not moved. This allows direct access alongside the
|
|
18
|
+
# proxy and works with Memory's mutex-based internals.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# memory = Memory.new
|
|
22
|
+
# proxy = RactorMemoryProxy.new(memory)
|
|
23
|
+
#
|
|
24
|
+
# # From any Ractor via the stub:
|
|
25
|
+
# proxy.set(:result, "done")
|
|
26
|
+
# proxy.get(:result) #=> "done"
|
|
27
|
+
#
|
|
28
|
+
# proxy.shutdown # call when done
|
|
29
|
+
#
|
|
30
|
+
class RactorMemoryProxy
|
|
31
|
+
# @param memory [Memory] the memory instance to wrap
|
|
32
|
+
def initialize(memory)
|
|
33
|
+
@memory = memory
|
|
34
|
+
@wrapper = Ractor::Wrapper.new(memory, use_current_ractor: true)
|
|
35
|
+
@stub = @wrapper.stub
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the Ractor-shareable stub for use inside Ractors.
|
|
39
|
+
#
|
|
40
|
+
# The stub proxies get/set/keys to the wrapped Memory. Pass this to
|
|
41
|
+
# Ractor.new rather than the proxy itself (the proxy is not shareable).
|
|
42
|
+
#
|
|
43
|
+
# @return [Ractor::Wrapper stub]
|
|
44
|
+
def stub
|
|
45
|
+
@stub
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Read a value from the proxied Memory.
|
|
49
|
+
#
|
|
50
|
+
# @param key [Symbol]
|
|
51
|
+
# @return [Object, nil]
|
|
52
|
+
def get(key)
|
|
53
|
+
@stub.get(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Write a frozen value to the proxied Memory.
|
|
57
|
+
# The value is deep-frozen before crossing the Ractor boundary.
|
|
58
|
+
#
|
|
59
|
+
# @param key [Symbol]
|
|
60
|
+
# @param value [Object] must be Ractor-shareable after freeze_deep
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [RactorBoundaryError] if value cannot be made shareable
|
|
63
|
+
def set(key, value)
|
|
64
|
+
frozen_value = RactorBoundary.freeze_deep(value)
|
|
65
|
+
@stub.set(key, frozen_value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# List all keys currently set in the proxied Memory.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<Symbol>]
|
|
71
|
+
def keys
|
|
72
|
+
@stub.keys
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Shut down the ractor-wrapper.
|
|
76
|
+
#
|
|
77
|
+
# @return [void]
|
|
78
|
+
def shutdown
|
|
79
|
+
@wrapper.async_stop
|
|
80
|
+
@wrapper.join
|
|
81
|
+
rescue => e
|
|
82
|
+
RobotLab.config.logger.warn("RactorMemoryProxy shutdown error: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "ractor_queue"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
# Schedules frozen robot task descriptions across Ractor workers.
|
|
8
|
+
#
|
|
9
|
+
# Robots stay in threads for LLM calls (ruby_llm is not Ractor-safe).
|
|
10
|
+
# The scheduler distributes frozen RobotSpec payloads; each worker
|
|
11
|
+
# constructs a fresh Robot, runs the task, and returns a frozen result.
|
|
12
|
+
#
|
|
13
|
+
# Task ordering respects depends_on: tasks are only dispatched once all
|
|
14
|
+
# named dependencies have resolved (same topological semantics as
|
|
15
|
+
# SimpleFlow::Pipeline).
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# scheduler = RactorNetworkScheduler.new(memory: shared_memory)
|
|
19
|
+
# scheduler.run_pipeline([
|
|
20
|
+
# { spec: analyst_spec, depends_on: :none },
|
|
21
|
+
# { spec: writer_spec, depends_on: ["analyst"] }
|
|
22
|
+
# ], message: "Process this")
|
|
23
|
+
# scheduler.shutdown
|
|
24
|
+
#
|
|
25
|
+
class RactorNetworkScheduler
|
|
26
|
+
# Capacity for the work queue.
|
|
27
|
+
QUEUE_CAPACITY = 256
|
|
28
|
+
|
|
29
|
+
# @param memory [Memory] shared network memory for all robot tasks
|
|
30
|
+
# @param pool_size [Integer, :auto] number of Ractor workers
|
|
31
|
+
def initialize(memory:, pool_size: :auto)
|
|
32
|
+
@memory = memory
|
|
33
|
+
@work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
34
|
+
@size = pool_size == :auto ? Etc.nprocessors : pool_size.to_i
|
|
35
|
+
@workers = @size.times.map { spawn_worker(@work_q) }
|
|
36
|
+
@closed = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Run a single spec and return the result string.
|
|
40
|
+
#
|
|
41
|
+
# @param spec [RobotSpec]
|
|
42
|
+
# @param message [String]
|
|
43
|
+
# @return [String] the robot's last_text_content
|
|
44
|
+
def run_spec(spec, message:)
|
|
45
|
+
execute_spec(spec, message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run a pipeline of specs in dependency order.
|
|
49
|
+
#
|
|
50
|
+
# @param specs_with_deps [Array<Hash>] each entry has :spec and :depends_on
|
|
51
|
+
# :depends_on is :none, :optional, or an Array<String> of spec names
|
|
52
|
+
# @param message [String] initial message passed to entry-point robots
|
|
53
|
+
# @return [Hash<String, String>] name => result for each completed robot
|
|
54
|
+
def run_pipeline(specs_with_deps, message:)
|
|
55
|
+
completed = {} # name => result string
|
|
56
|
+
remaining = specs_with_deps.dup
|
|
57
|
+
|
|
58
|
+
until remaining.empty?
|
|
59
|
+
ready, remaining = remaining.partition do |entry|
|
|
60
|
+
deps = entry[:depends_on]
|
|
61
|
+
deps == :none || deps == :optional ||
|
|
62
|
+
Array(deps).all? { |d| completed.key?(d) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
raise RobotLab::Error, "Circular dependency or unresolvable deps in RactorNetworkScheduler" if ready.empty?
|
|
66
|
+
|
|
67
|
+
# Submit all ready tasks concurrently via threads.
|
|
68
|
+
# report_on_exception is disabled because exceptions are propagated
|
|
69
|
+
# to the caller via t.value — the default reporting is redundant noise.
|
|
70
|
+
threads = ready.map do |entry|
|
|
71
|
+
spec = entry[:spec]
|
|
72
|
+
msg = completed.values.last || message
|
|
73
|
+
Thread.new { [spec.name, execute_spec(spec, msg)] }.tap { |t| t.report_on_exception = false }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
threads.each do |t|
|
|
77
|
+
name, result = t.value
|
|
78
|
+
completed[name] = result
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
completed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Gracefully shut down worker Ractors.
|
|
86
|
+
# @return [void]
|
|
87
|
+
def shutdown
|
|
88
|
+
return if @closed
|
|
89
|
+
|
|
90
|
+
@closed = true
|
|
91
|
+
@size.times { @work_q.push(nil) }
|
|
92
|
+
@workers.each { |w| w.join rescue nil }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Dispatch a spec to a Ractor worker and block for the result.
|
|
98
|
+
def execute_spec(spec, message)
|
|
99
|
+
frozen_spec = Ractor.make_shareable(spec)
|
|
100
|
+
frozen_message = message.to_s.freeze
|
|
101
|
+
reply_q = RactorQueue.new(capacity: 1)
|
|
102
|
+
|
|
103
|
+
job = RactorJob.new(
|
|
104
|
+
id: SecureRandom.uuid.freeze,
|
|
105
|
+
type: :robot,
|
|
106
|
+
payload: RactorBoundary.freeze_deep({
|
|
107
|
+
spec: frozen_spec,
|
|
108
|
+
message: frozen_message
|
|
109
|
+
}),
|
|
110
|
+
reply_queue: reply_q
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@work_q.push(job)
|
|
114
|
+
result = reply_q.pop
|
|
115
|
+
|
|
116
|
+
if result.is_a?(RactorJobError)
|
|
117
|
+
raise RobotLab::Error, "Robot '#{spec.name}' failed in Ractor: #{result.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def spawn_worker(work_q)
|
|
124
|
+
Ractor.new(work_q) do |q|
|
|
125
|
+
loop do
|
|
126
|
+
job = q.pop
|
|
127
|
+
break if job.nil?
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
spec = job.payload[:spec]
|
|
131
|
+
message = job.payload[:message]
|
|
132
|
+
|
|
133
|
+
robot = RobotLab::Robot.new(
|
|
134
|
+
name: spec.name,
|
|
135
|
+
template: spec.template ? spec.template.to_sym : nil,
|
|
136
|
+
system_prompt: spec.system_prompt,
|
|
137
|
+
config: spec.config_hash.empty? ? nil : RobotLab::RunConfig.new(**spec.config_hash.transform_keys(&:to_sym))
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
robot_result = robot.run(message)
|
|
141
|
+
frozen_reply = robot_result.last_text_content.to_s.freeze
|
|
142
|
+
job.reply_queue.push(frozen_reply)
|
|
143
|
+
rescue => e
|
|
144
|
+
err = RobotLab::RactorJobError.new(
|
|
145
|
+
message: e.message.freeze,
|
|
146
|
+
backtrace: (e.backtrace || []).map(&:freeze).freeze
|
|
147
|
+
)
|
|
148
|
+
job.reply_queue.push(err)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "ractor_queue"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
# A pool of Ractor workers that execute CPU-bound, Ractor-safe tools.
|
|
8
|
+
#
|
|
9
|
+
# Work is distributed via a shared RactorQueue. Each worker runs a
|
|
10
|
+
# blocking loop, pops RactorJob instances, dispatches to the named
|
|
11
|
+
# tool class, and pushes the frozen result (or a RactorJobError) to
|
|
12
|
+
# the job's per-job reply_queue.
|
|
13
|
+
#
|
|
14
|
+
# Shutdown uses a poison-pill pattern: one nil sentinel per worker is
|
|
15
|
+
# pushed to the work queue; each worker exits when it pops nil.
|
|
16
|
+
#
|
|
17
|
+
# Only tools that declare +ractor_safe true+ should be submitted.
|
|
18
|
+
# Tool classes are instantiated fresh inside the Ractor for each call.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# pool = RactorWorkerPool.new(size: 4)
|
|
22
|
+
# result = pool.submit("MyTool", { "arg" => "value" })
|
|
23
|
+
# pool.shutdown
|
|
24
|
+
#
|
|
25
|
+
class RactorWorkerPool
|
|
26
|
+
# Capacity of the shared work queue.
|
|
27
|
+
QUEUE_CAPACITY = 1024
|
|
28
|
+
|
|
29
|
+
# @return [Integer] number of worker Ractors
|
|
30
|
+
attr_reader :size
|
|
31
|
+
|
|
32
|
+
# Creates a new pool and starts worker Ractors immediately.
|
|
33
|
+
#
|
|
34
|
+
# @param size [Integer, :auto] number of workers (:auto = Etc.nprocessors)
|
|
35
|
+
def initialize(size: :auto)
|
|
36
|
+
@size = size == :auto ? Etc.nprocessors : size.to_i
|
|
37
|
+
@closed = false
|
|
38
|
+
@work_q = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
39
|
+
@workers = @size.times.map { spawn_worker(@work_q) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Submit a tool job and block until the result is available.
|
|
43
|
+
#
|
|
44
|
+
# @param tool_class_name [String] fully-qualified Ruby constant name of the tool class
|
|
45
|
+
# @param args [Hash] tool arguments (deep-frozen before crossing Ractor boundary)
|
|
46
|
+
# @return [Object] the tool's return value
|
|
47
|
+
# @raise [RactorBoundaryError] if args cannot be made Ractor-shareable
|
|
48
|
+
# @raise [ToolError] if the tool raises inside the Ractor
|
|
49
|
+
def submit(tool_class_name, args)
|
|
50
|
+
raise ToolError, "Pool is shut down" if @closed
|
|
51
|
+
|
|
52
|
+
reply_q = RactorQueue.new(capacity: 1)
|
|
53
|
+
payload = RactorBoundary.freeze_deep({
|
|
54
|
+
tool_class: tool_class_name.to_s,
|
|
55
|
+
args: args
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
job = RactorJob.new(
|
|
59
|
+
id: SecureRandom.uuid.freeze,
|
|
60
|
+
type: :tool,
|
|
61
|
+
payload: payload,
|
|
62
|
+
reply_queue: reply_q
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@work_q.push(job)
|
|
66
|
+
result = reply_q.pop
|
|
67
|
+
|
|
68
|
+
if result.is_a?(RactorJobError)
|
|
69
|
+
raise ToolError, "Tool '#{tool_class_name}' failed in Ractor: #{result.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Gracefully shut down the pool.
|
|
76
|
+
#
|
|
77
|
+
# Pushes one nil poison pill per worker so each exits its loop.
|
|
78
|
+
# Waits for all workers to terminate.
|
|
79
|
+
#
|
|
80
|
+
# @return [void]
|
|
81
|
+
def shutdown
|
|
82
|
+
return if @closed
|
|
83
|
+
|
|
84
|
+
@closed = true
|
|
85
|
+
# Push one nil poison pill per worker
|
|
86
|
+
@size.times { @work_q.push(nil) }
|
|
87
|
+
@workers.each { |w| w.join rescue nil }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def spawn_worker(work_q)
|
|
93
|
+
Ractor.new(work_q) do |q|
|
|
94
|
+
loop do
|
|
95
|
+
job = q.pop
|
|
96
|
+
|
|
97
|
+
# nil is the poison pill — exit cleanly
|
|
98
|
+
break if job.nil?
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
tool_class = Object.const_get(job.payload[:tool_class])
|
|
102
|
+
tool = tool_class.new
|
|
103
|
+
result = tool.execute(**job.payload[:args].transform_keys(&:to_sym))
|
|
104
|
+
frozen_result = Ractor.make_shareable(result.frozen? ? result : result.dup.freeze)
|
|
105
|
+
job.reply_queue.push(frozen_result)
|
|
106
|
+
rescue => e
|
|
107
|
+
err = RobotLab::RactorJobError.new(
|
|
108
|
+
message: e.message.freeze,
|
|
109
|
+
backtrace: (e.backtrace || []).map(&:freeze).freeze
|
|
110
|
+
)
|
|
111
|
+
job.reply_queue.push(err)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -5,21 +5,16 @@ module RobotLab
|
|
|
5
5
|
# Inter-robot communication via TypedBus.
|
|
6
6
|
#
|
|
7
7
|
# Expects the including class to provide:
|
|
8
|
-
# @bus, @
|
|
9
|
-
# @
|
|
10
|
-
# and the `run` instance method
|
|
8
|
+
# @bus, @bus_poller, @bus_poller_group, @message_counter,
|
|
9
|
+
# @outbox, @message_handler, @bus_subscriber_id, @name
|
|
10
|
+
# and the `run` instance method.
|
|
11
11
|
#
|
|
12
|
-
# ==
|
|
12
|
+
# == Delivery Serialization
|
|
13
13
|
#
|
|
14
|
-
# TypedBus delivers messages in concurrent Async fibers.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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.
|
|
14
|
+
# TypedBus delivers messages in concurrent Async fibers. Robots
|
|
15
|
+
# enqueue deliveries into a BusPoller rather than handling them
|
|
16
|
+
# inline. The BusPoller drains each group's queue sequentially on
|
|
17
|
+
# a dedicated OS thread, so robot.run() calls never interleave.
|
|
23
18
|
#
|
|
24
19
|
module BusMessaging
|
|
25
20
|
# Send a message to another robot via the bus.
|
|
@@ -84,19 +79,6 @@ module RobotLab
|
|
|
84
79
|
# @param options [Hash] additional options passed to RobotLab.build
|
|
85
80
|
# @return [Robot] the newly created robot
|
|
86
81
|
#
|
|
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
82
|
def spawn(name: "robot", system_prompt: nil, template: nil, local_tools: [], **options)
|
|
101
83
|
ensure_bus
|
|
102
84
|
|
|
@@ -120,12 +102,6 @@ module RobotLab
|
|
|
120
102
|
# @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
|
|
121
103
|
# @return [self]
|
|
122
104
|
#
|
|
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
105
|
def with_bus(bus = nil)
|
|
130
106
|
return self if bus && @bus == bus
|
|
131
107
|
|
|
@@ -135,6 +111,22 @@ module RobotLab
|
|
|
135
111
|
self
|
|
136
112
|
end
|
|
137
113
|
|
|
114
|
+
# Assign a shared BusPoller from a Network.
|
|
115
|
+
#
|
|
116
|
+
# Stops any private poller this robot auto-created, then adopts
|
|
117
|
+
# the network's shared poller for the given group.
|
|
118
|
+
#
|
|
119
|
+
# @param poller [BusPoller] the network's shared poller
|
|
120
|
+
# @param group [Symbol] poller group for this robot (default: :default)
|
|
121
|
+
# @return [void]
|
|
122
|
+
#
|
|
123
|
+
def assign_bus_poller(poller, group: :default)
|
|
124
|
+
@private_bus_poller&.stop
|
|
125
|
+
@private_bus_poller = nil
|
|
126
|
+
@bus_poller = poller
|
|
127
|
+
@bus_poller_group = group
|
|
128
|
+
end
|
|
129
|
+
|
|
138
130
|
private
|
|
139
131
|
|
|
140
132
|
# Create a bus if one doesn't exist and connect this robot to it
|
|
@@ -143,46 +135,42 @@ module RobotLab
|
|
|
143
135
|
end
|
|
144
136
|
|
|
145
137
|
|
|
146
|
-
# Create a typed channel on the bus and subscribe to it
|
|
138
|
+
# Create a typed channel on the bus and subscribe to it.
|
|
139
|
+
# Auto-creates a private BusPoller if none has been assigned.
|
|
147
140
|
def setup_bus_channel
|
|
141
|
+
unless @bus_poller
|
|
142
|
+
@private_bus_poller = BusPoller.new.start
|
|
143
|
+
@bus_poller = @private_bus_poller
|
|
144
|
+
@bus_poller_group = :default
|
|
145
|
+
end
|
|
146
|
+
|
|
148
147
|
channel_name = @name.to_sym
|
|
149
148
|
@bus.add_channel(channel_name, type: RobotMessage) unless @bus.channel?(channel_name)
|
|
150
|
-
@bus_subscriber_id = @bus.subscribe(channel_name) { |delivery|
|
|
149
|
+
@bus_subscriber_id = @bus.subscribe(channel_name) { |delivery| enqueue_delivery(delivery) }
|
|
151
150
|
end
|
|
152
151
|
|
|
153
152
|
|
|
154
|
-
# Unsubscribe from the bus channel
|
|
153
|
+
# Unsubscribe from the bus channel and stop the private poller if any.
|
|
155
154
|
def teardown_bus_channel
|
|
156
155
|
channel_name = @name.to_sym
|
|
157
156
|
@bus.unsubscribe(channel_name, @bus_subscriber_id) if @bus_subscriber_id
|
|
158
157
|
@bus_subscriber_id = nil
|
|
159
|
-
end
|
|
160
158
|
|
|
159
|
+
@private_bus_poller&.stop
|
|
160
|
+
@private_bus_poller = nil
|
|
161
|
+
@bus_poller = nil
|
|
162
|
+
@bus_poller_group = :default
|
|
163
|
+
end
|
|
161
164
|
|
|
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
165
|
|
|
177
|
-
|
|
178
|
-
|
|
166
|
+
# Enqueue a delivery to the robot's assigned poller.
|
|
167
|
+
def enqueue_delivery(delivery)
|
|
168
|
+
@bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
|
|
179
169
|
end
|
|
180
170
|
|
|
181
171
|
|
|
182
|
-
# Process a single delivery (called
|
|
172
|
+
# Process a single delivery (called by BusPoller drain thread).
|
|
183
173
|
def process_delivery(delivery)
|
|
184
|
-
@bus_processing = true
|
|
185
|
-
|
|
186
174
|
message = delivery.message
|
|
187
175
|
|
|
188
176
|
# Correlate replies with outbox entries
|
|
@@ -205,16 +193,6 @@ module RobotLab
|
|
|
205
193
|
rescue => e
|
|
206
194
|
delivery.nack! if delivery.pending?
|
|
207
195
|
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
196
|
end
|
|
219
197
|
|
|
220
198
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot
|
|
5
|
+
# Semantic search over a robot's conversation history.
|
|
6
|
+
#
|
|
7
|
+
# Scores each message in @chat.messages against the query using stemmed
|
|
8
|
+
# term-frequency cosine similarity (via the +classifier+ gem). Returns the
|
|
9
|
+
# top-N messages ranked by relevance.
|
|
10
|
+
#
|
|
11
|
+
# Requires the optional 'classifier' gem (~> 2.3).
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# results = robot.search_history("quarterly revenue", limit: 3)
|
|
15
|
+
# results.each do |r|
|
|
16
|
+
# puts "[#{r.role}] (score #{r.score.round(3)}) #{r.text}"
|
|
17
|
+
# end
|
|
18
|
+
module HistorySearch
|
|
19
|
+
# Minimum text length (characters) for a message to be considered.
|
|
20
|
+
MIN_SCORE_LENGTH = 20
|
|
21
|
+
|
|
22
|
+
# Value object returned by {#search_history}.
|
|
23
|
+
HistoryResult = Data.define(:text, :role, :score, :index)
|
|
24
|
+
|
|
25
|
+
# Search the robot's conversation history for messages relevant to +query+.
|
|
26
|
+
#
|
|
27
|
+
# @param query [String] natural-language search query
|
|
28
|
+
# @param limit [Integer] maximum number of results to return (default 5)
|
|
29
|
+
# @return [Array<HistoryResult>] results sorted by score descending
|
|
30
|
+
# @raise [RobotLab::DependencyError] if the 'classifier' gem is not installed
|
|
31
|
+
def search_history(query, limit: 5)
|
|
32
|
+
TextAnalysis.require_classifier!
|
|
33
|
+
|
|
34
|
+
query_vec = TextAnalysis.l2_normalize(query.word_hash)
|
|
35
|
+
return [] if query_vec.empty?
|
|
36
|
+
|
|
37
|
+
results = []
|
|
38
|
+
|
|
39
|
+
@chat.messages.each_with_index do |msg, idx|
|
|
40
|
+
text = extract_history_text(msg)
|
|
41
|
+
next if text.nil? || text.strip.length < MIN_SCORE_LENGTH
|
|
42
|
+
|
|
43
|
+
vec = TextAnalysis.l2_normalize(text.word_hash)
|
|
44
|
+
score = TextAnalysis.cosine_similarity(query_vec, vec)
|
|
45
|
+
next if score.zero?
|
|
46
|
+
|
|
47
|
+
results << HistoryResult.new(text: text, role: msg.role, score: score, index: idx)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
results.sort_by { |r| -r.score }.first(limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Extract plain text from a message's content field.
|
|
56
|
+
#
|
|
57
|
+
# @param msg [Object] a RubyLLM message-like object
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def extract_history_text(msg)
|
|
60
|
+
content = msg.content
|
|
61
|
+
case content
|
|
62
|
+
when String then content
|
|
63
|
+
when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
|
|
64
|
+
else nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|