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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +210 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/result.md +123 -0
  6. data/docs/api/core/robot.md +182 -0
  7. data/docs/api/errors.md +185 -0
  8. data/docs/guides/building-robots.md +125 -0
  9. data/docs/guides/creating-networks.md +21 -0
  10. data/docs/guides/index.md +10 -0
  11. data/docs/guides/knowledge.md +182 -0
  12. data/docs/guides/mcp-integration.md +106 -0
  13. data/docs/guides/memory.md +2 -0
  14. data/docs/guides/observability.md +486 -0
  15. data/docs/guides/ractor-parallelism.md +364 -0
  16. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  17. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  18. data/examples/19_token_tracking.rb +128 -0
  19. data/examples/20_circuit_breaker.rb +153 -0
  20. data/examples/21_learning_loop.rb +164 -0
  21. data/examples/22_context_compression.rb +179 -0
  22. data/examples/23_convergence.rb +137 -0
  23. data/examples/24_structured_delegation.rb +150 -0
  24. data/examples/25_history_search/conversation.jsonl +30 -0
  25. data/examples/25_history_search.rb +136 -0
  26. data/examples/26_document_store/api_versioning_adr.md +52 -0
  27. data/examples/26_document_store/incident_postmortem.md +46 -0
  28. data/examples/26_document_store/postgres_runbook.md +49 -0
  29. data/examples/26_document_store/redis_caching_guide.md +48 -0
  30. data/examples/26_document_store/sidekiq_guide.md +51 -0
  31. data/examples/26_document_store.rb +147 -0
  32. data/examples/27_incident_response/incident_response.rb +244 -0
  33. data/examples/28_mcp_discovery.rb +112 -0
  34. data/examples/29_ractor_tools.rb +243 -0
  35. data/examples/30_ractor_network.rb +256 -0
  36. data/examples/README.md +136 -0
  37. data/examples/prompts/skill_with_mcp_test.md +9 -0
  38. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  39. data/examples/prompts/skill_with_tools_test.md +6 -0
  40. data/lib/robot_lab/bus_poller.rb +149 -0
  41. data/lib/robot_lab/convergence.rb +69 -0
  42. data/lib/robot_lab/delegation_future.rb +93 -0
  43. data/lib/robot_lab/document_store.rb +155 -0
  44. data/lib/robot_lab/error.rb +25 -0
  45. data/lib/robot_lab/history_compressor.rb +205 -0
  46. data/lib/robot_lab/mcp/client.rb +17 -5
  47. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  48. data/lib/robot_lab/mcp/server.rb +7 -2
  49. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  50. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  51. data/lib/robot_lab/memory.rb +103 -6
  52. data/lib/robot_lab/network.rb +44 -9
  53. data/lib/robot_lab/ractor_boundary.rb +42 -0
  54. data/lib/robot_lab/ractor_job.rb +37 -0
  55. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  56. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  57. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  58. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  59. data/lib/robot_lab/robot/history_search.rb +69 -0
  60. data/lib/robot_lab/robot.rb +228 -11
  61. data/lib/robot_lab/robot_result.rb +24 -5
  62. data/lib/robot_lab/run_config.rb +1 -1
  63. data/lib/robot_lab/text_analysis.rb +103 -0
  64. data/lib/robot_lab/tool.rb +42 -3
  65. data/lib/robot_lab/tool_config.rb +1 -1
  66. data/lib/robot_lab/version.rb +1 -1
  67. data/lib/robot_lab/waiter.rb +49 -29
  68. data/lib/robot_lab.rb +25 -0
  69. data/mkdocs.yml +1 -0
  70. 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, @message_counter, @outbox, @message_handler,
9
- # @bus_subscriber_id, @bus_processing, @bus_queue, @name
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
- # == Processing Guard
12
+ # == Delivery Serialization
13
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.
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| handle_incoming_delivery(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
- process_delivery(delivery)
178
- drain_bus_queue
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 under the processing guard)
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