robot_lab 0.0.9 → 0.0.11

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +80 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/robot.md +182 -0
  6. data/docs/guides/creating-networks.md +21 -0
  7. data/docs/guides/index.md +10 -0
  8. data/docs/guides/knowledge.md +182 -0
  9. data/docs/guides/mcp-integration.md +106 -0
  10. data/docs/guides/memory.md +2 -0
  11. data/docs/guides/observability.md +486 -0
  12. data/docs/guides/ractor-parallelism.md +364 -0
  13. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  14. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  15. data/examples/19_token_tracking.rb +128 -0
  16. data/examples/20_circuit_breaker.rb +153 -0
  17. data/examples/21_learning_loop.rb +164 -0
  18. data/examples/22_context_compression.rb +179 -0
  19. data/examples/23_convergence.rb +137 -0
  20. data/examples/24_structured_delegation.rb +150 -0
  21. data/examples/25_history_search/conversation.jsonl +30 -0
  22. data/examples/25_history_search.rb +136 -0
  23. data/examples/26_document_store/api_versioning_adr.md +52 -0
  24. data/examples/26_document_store/incident_postmortem.md +46 -0
  25. data/examples/26_document_store/postgres_runbook.md +49 -0
  26. data/examples/26_document_store/redis_caching_guide.md +48 -0
  27. data/examples/26_document_store/sidekiq_guide.md +51 -0
  28. data/examples/26_document_store.rb +147 -0
  29. data/examples/27_incident_response/incident_response.rb +244 -0
  30. data/examples/28_mcp_discovery.rb +112 -0
  31. data/examples/29_ractor_tools.rb +243 -0
  32. data/examples/30_ractor_network.rb +256 -0
  33. data/examples/README.md +136 -0
  34. data/examples/prompts/skill_with_mcp_test.md +9 -0
  35. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  36. data/examples/prompts/skill_with_tools_test.md +6 -0
  37. data/lib/robot_lab/bus_poller.rb +149 -0
  38. data/lib/robot_lab/convergence.rb +69 -0
  39. data/lib/robot_lab/delegation_future.rb +93 -0
  40. data/lib/robot_lab/document_store.rb +155 -0
  41. data/lib/robot_lab/error.rb +25 -0
  42. data/lib/robot_lab/history_compressor.rb +205 -0
  43. data/lib/robot_lab/mcp/client.rb +17 -5
  44. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  45. data/lib/robot_lab/mcp/server.rb +7 -2
  46. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  47. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  48. data/lib/robot_lab/memory.rb +103 -6
  49. data/lib/robot_lab/network.rb +44 -9
  50. data/lib/robot_lab/ractor_boundary.rb +42 -0
  51. data/lib/robot_lab/ractor_job.rb +37 -0
  52. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  53. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  54. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  55. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  56. data/lib/robot_lab/robot/history_search.rb +69 -0
  57. data/lib/robot_lab/robot.rb +228 -11
  58. data/lib/robot_lab/robot_result.rb +24 -5
  59. data/lib/robot_lab/run_config.rb +1 -1
  60. data/lib/robot_lab/text_analysis.rb +103 -0
  61. data/lib/robot_lab/tool.rb +42 -3
  62. data/lib/robot_lab/tool_config.rb +1 -1
  63. data/lib/robot_lab/version.rb +1 -1
  64. data/lib/robot_lab/waiter.rb +49 -29
  65. data/lib/robot_lab.rb +25 -0
  66. data/mkdocs.yml +1 -0
  67. metadata +70 -2
@@ -3,6 +3,7 @@
3
3
  require_relative 'robot/template_rendering'
4
4
  require_relative 'robot/mcp_management'
5
5
  require_relative 'robot/bus_messaging'
6
+ require_relative 'robot/history_search'
6
7
 
7
8
  module RobotLab
8
9
  # LLM-powered robot built on RubyLLM::Agent
@@ -41,6 +42,7 @@ module RobotLab
41
42
  include Robot::TemplateRendering
42
43
  include Robot::MCPManagement
43
44
  include Robot::BusMessaging
45
+ include Robot::HistorySearch
44
46
 
45
47
  # @!attribute [r] name
46
48
  # @return [String] the unique identifier for the robot
@@ -66,7 +68,8 @@ module RobotLab
66
68
 
67
69
  attr_reader :name, :description, :template, :system_prompt,
68
70
  :local_tools, :mcp_clients, :mcp_tools, :memory,
69
- :bus, :outbox, :config, :skills, :provider
71
+ :bus, :outbox, :config, :skills, :provider,
72
+ :total_input_tokens, :total_output_tokens, :learnings
70
73
 
71
74
  # @!attribute [r] mcp_config
72
75
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -125,6 +128,9 @@ module RobotLab
125
128
  presence_penalty: nil,
126
129
  frequency_penalty: nil,
127
130
  stop: nil,
131
+ max_tool_rounds: nil,
132
+ token_budget: nil,
133
+ mcp_discovery: false,
128
134
  config: nil
129
135
  )
130
136
  @name = name.to_s
@@ -136,6 +142,7 @@ module RobotLab
136
142
  @local_tools = Array(local_tools)
137
143
  @skills = skills ? Array(skills).map(&:to_sym) : nil
138
144
  @expanded_skills = nil
145
+ @mcp_discovery = mcp_discovery
139
146
 
140
147
  # Build RunConfig from explicit kwargs, merged on top of passed-in config.
141
148
  # Explicit constructor kwargs always override the shared config.
@@ -144,7 +151,8 @@ module RobotLab
144
151
  max_tokens: max_tokens, presence_penalty: presence_penalty,
145
152
  frequency_penalty: frequency_penalty, stop: stop,
146
153
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
147
- on_content: on_content, bus: bus, enable_cache: enable_cache
154
+ on_content: on_content, bus: bus, enable_cache: enable_cache,
155
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget
148
156
  }.compact
149
157
 
150
158
  # Only include mcp/tools if explicitly set (not the default :none sentinel)
@@ -170,17 +178,29 @@ module RobotLab
170
178
  @mcp_initialized = false
171
179
 
172
180
  # Bus state (optional inter-robot communication)
173
- @bus = @config.bus
174
- @message_counter = 0
175
- @outbox = {}
176
- @message_handler = nil
177
- @bus_processing = false
178
- @bus_queue = []
181
+ @bus = @config.bus
182
+ @message_counter = 0
183
+ @outbox = {}
184
+ @message_handler = nil
185
+ @bus_poller = nil
186
+ @private_bus_poller = nil
187
+ @bus_poller_group = :default
188
+
189
+ # Token tracking
190
+ @total_input_tokens = 0
191
+ @total_output_tokens = 0
192
+
193
+ # Learning accumulation
194
+ @learnings = []
179
195
 
180
196
  # Inherent memory (used when standalone, not in a network)
181
197
  cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
182
198
  @memory = Memory.new(enable_cache: cache_enabled)
183
199
 
200
+ # Restore persisted learnings from inherent memory if present
201
+ persisted = @memory.get(:learnings)
202
+ @learnings = Array(persisted) if persisted
203
+
184
204
  # Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
185
205
  config = RobotLab.config
186
206
 
@@ -301,6 +321,13 @@ module RobotLab
301
321
  resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
302
322
  resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
303
323
 
324
+ # Filter MCP servers by semantic relevance when discovery is enabled.
325
+ # Only applies on the first run (before @mcp_initialized) so connections
326
+ # are not torn down mid-conversation.
327
+ if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
328
+ resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
329
+ end
330
+
304
331
  # Initialize or update MCP clients based on resolved config
305
332
  ensure_mcp_clients(resolved_mcp)
306
333
 
@@ -314,13 +341,29 @@ module RobotLab
314
341
  run_context = kwargs.except(:with)
315
342
  rerender_template(run_context) if @template && run_context.any?
316
343
 
344
+ # Prepend accumulated learnings to the user message
345
+ effective_message = inject_learnings(message)
346
+
347
+ # Install circuit breaker for this run if max_tool_rounds is configured
348
+ install_circuit_breaker if @config.max_tool_rounds
349
+
317
350
  # Delegate to Agent's ask (which calls @chat.ask)
318
351
  ask_kwargs = kwargs.slice(:with)
319
352
  streaming = effective_streaming_block(block)
320
- response = ask(message, **ask_kwargs, &streaming)
353
+ response = ask(effective_message, **ask_kwargs, &streaming)
354
+
355
+ result = build_result(response, run_memory)
321
356
 
322
- build_result(response, run_memory)
357
+ # Enforce token budget if configured
358
+ budget = @config.token_budget
359
+ if budget && @total_input_tokens + @total_output_tokens > budget
360
+ raise InferenceError,
361
+ "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
362
+ end
363
+
364
+ result
323
365
  ensure
366
+ restore_tool_call_callback if @config.max_tool_rounds
324
367
  run_memory.current_writer = previous_writer
325
368
  end
326
369
  end
@@ -482,6 +525,89 @@ module RobotLab
482
525
  self
483
526
  end
484
527
 
528
+ # Compress conversation history using TF-IDF relevance scoring.
529
+ #
530
+ # Old turns are tiered against the most recent context:
531
+ # - High relevance (score >= keep_threshold) → kept verbatim
532
+ # - Medium relevance (drop_threshold..keep_threshold) → summarized or dropped
533
+ # - Low relevance (score < drop_threshold) → dropped
534
+ #
535
+ # System messages and tool call/result messages are always preserved.
536
+ # The most recent +recent_turns+ pairs are also always kept verbatim.
537
+ #
538
+ # Requires the optional 'classifier' gem (~> 2.3).
539
+ # Raises +DependencyError+ if not installed.
540
+ #
541
+ # @param recent_turns [Integer] turn pairs to protect at the end (default 3)
542
+ # @param keep_threshold [Float] cosine score >= this → keep verbatim (default 0.6)
543
+ # @param drop_threshold [Float] cosine score < this → drop (default 0.2)
544
+ # @param summarizer [#call, nil] callable(text) -> String for medium-tier;
545
+ # nil drops medium-tier instead of summarizing
546
+ # @return [self]
547
+ def compress_history(recent_turns: 3, keep_threshold: 0.6, drop_threshold: 0.2, summarizer: nil)
548
+ compressed = HistoryCompressor.new(
549
+ messages: @chat.messages,
550
+ recent_turns: recent_turns,
551
+ keep_threshold: keep_threshold,
552
+ drop_threshold: drop_threshold,
553
+ summarizer: summarizer
554
+ ).call
555
+ replace_messages(compressed)
556
+ end
557
+
558
+ # Delegate a task to another robot, synchronously or asynchronously.
559
+ #
560
+ # **Synchronous** (default, +async: false+): blocks until the delegatee
561
+ # finishes and returns a +RobotResult+ annotated with +delegated_by+,
562
+ # +duration+, and token counts.
563
+ #
564
+ # **Asynchronous** (+async: true+): starts the delegatee in a background
565
+ # thread and returns a +DelegationFuture+ immediately. Call +future.value+
566
+ # to block for the result, or +future.resolved?+ to poll.
567
+ #
568
+ # @example Synchronous
569
+ # result = manager.delegate(to: analyst, task: "What are the risks?")
570
+ # puts result.reply # analyst's answer
571
+ # puts result.delegated_by # => "manager"
572
+ # puts result.duration # => 1.43 (seconds)
573
+ #
574
+ # @example Async fan-out
575
+ # f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
576
+ # f2 = manager.delegate(to: analyst, task: "analyze ...", async: true)
577
+ # summary = f1.value # blocks if not yet done
578
+ # analysis = f2.value(timeout: 30)
579
+ #
580
+ # @param to [Robot] the robot to delegate to
581
+ # @param task [String] the message to send
582
+ # @param async [Boolean] when true, returns a DelegationFuture immediately
583
+ # @param kwargs [Hash] additional keyword args forwarded to Robot#run
584
+ # @return [RobotResult] when async: false
585
+ # @return [DelegationFuture] when async: true
586
+ def delegate(to:, task:, async: false, **kwargs)
587
+ if async
588
+ future = DelegationFuture.new(robot_name: to.name, delegated_by: @name)
589
+ delegator_name = @name
590
+
591
+ Thread.new do
592
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
593
+ result = to.run(task, **kwargs)
594
+ result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
595
+ result.delegated_by = delegator_name
596
+ future.resolve!(result)
597
+ rescue => e
598
+ future.reject!(e)
599
+ end
600
+
601
+ future
602
+ else
603
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
604
+ result = to.run(task, **kwargs)
605
+ result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
606
+ result.delegated_by = @name
607
+ result
608
+ end
609
+ end
610
+
485
611
  # Return the provider for this robot's chat.
486
612
  # Useful for displaying model/provider info without reaching
487
613
  # into chat internals.
@@ -503,6 +629,44 @@ module RobotLab
503
629
  end
504
630
 
505
631
 
632
+ # Add a learning to this robot's accumulation store.
633
+ #
634
+ # Deduplicates by bidirectional substring matching: a new learning is
635
+ # skipped if it is already contained within an existing learning, or
636
+ # an existing learning is contained within the new one (the new one
637
+ # wins and replaces the weaker entry).
638
+ #
639
+ # Learnings are persisted to the robot's inherent memory under :learnings.
640
+ #
641
+ # @param text [String] the insight to record
642
+ # @return [self]
643
+ def learn(text)
644
+ text = text.to_s.strip
645
+ return self if text.empty?
646
+
647
+ # Remove any existing learning that is a substring of the new one
648
+ @learnings.reject! { |existing| text.include?(existing) }
649
+
650
+ # Skip if any existing learning already covers the new one
651
+ unless @learnings.any? { |existing| existing.include?(text) }
652
+ @learnings << text
653
+ @memory.set(:learnings, @learnings.dup)
654
+ end
655
+
656
+ self
657
+ end
658
+
659
+
660
+ # Reset cumulative token counters to zero.
661
+ #
662
+ # @return [self]
663
+ def reset_token_totals
664
+ @total_input_tokens = 0
665
+ @total_output_tokens = 0
666
+ self
667
+ end
668
+
669
+
506
670
  # Converts the robot to a hash representation
507
671
  #
508
672
  # @return [Hash]
@@ -578,12 +742,28 @@ module RobotLab
578
742
 
579
743
  tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
580
744
 
745
+ # Extract token usage from the response
746
+ input_toks = 0
747
+ output_toks = 0
748
+ if response.respond_to?(:tokens) && response.tokens
749
+ input_toks = response.tokens.input.to_i
750
+ output_toks = response.tokens.output.to_i
751
+ elsif response.respond_to?(:input_tokens)
752
+ input_toks = response.input_tokens.to_i
753
+ output_toks = response.respond_to?(:output_tokens) ? response.output_tokens.to_i : 0
754
+ end
755
+
756
+ @total_input_tokens += input_toks
757
+ @total_output_tokens += output_toks
758
+
581
759
  RobotResult.new(
582
760
  robot_name: @name,
583
761
  output: output,
584
762
  tool_calls: normalize_tool_calls(tool_calls),
585
763
  stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
586
- raw: response
764
+ raw: response,
765
+ input_tokens: input_toks,
766
+ output_tokens: output_toks
587
767
  )
588
768
  end
589
769
 
@@ -629,5 +809,42 @@ module RobotLab
629
809
 
630
810
  ToolConfig.filter_tools(available, allowed_names: allowed_names)
631
811
  end
812
+
813
+
814
+ # Prepend accumulated learnings to a user message when learnings exist.
815
+ def inject_learnings(message)
816
+ return message if @learnings.empty? || message.nil?
817
+
818
+ learning_block = "LEARNINGS FROM PREVIOUS RUNS:\n" +
819
+ @learnings.map { |l| "- #{l}" }.join("\n") +
820
+ "\n\n"
821
+ "#{learning_block}#{message}"
822
+ end
823
+
824
+
825
+ # Install a per-run circuit breaker on the chat's on_tool_call hook.
826
+ # Raises ToolLoopError if tool calls exceed @config.max_tool_rounds.
827
+ # Stores the previous callback so restore_tool_call_callback can undo it.
828
+ def install_circuit_breaker
829
+ @circuit_breaker_call_count = 0
830
+ max = @config.max_tool_rounds
831
+ original = @on_tool_call
832
+
833
+ @chat.on_tool_call do |tool_call|
834
+ @circuit_breaker_call_count += 1
835
+ if @circuit_breaker_call_count > max
836
+ raise ToolLoopError,
837
+ "Circuit breaker triggered: #{@circuit_breaker_call_count} tool calls exceeded " \
838
+ "max_tool_rounds (#{max})"
839
+ end
840
+ original&.call(tool_call)
841
+ end
842
+ end
843
+
844
+
845
+ # Restore the original on_tool_call callback after a circuit-breaker run.
846
+ def restore_tool_call_callback
847
+ @chat.on_tool_call(&@on_tool_call)
848
+ end
632
849
  end
633
850
  end
@@ -28,17 +28,24 @@ module RobotLab
28
28
  # @return [String] unique identifier for this result
29
29
  # @!attribute [r] stop_reason
30
30
  # @return [String, nil] reason execution stopped
31
- attr_reader :robot_name, :output, :tool_calls, :created_at, :id, :stop_reason
31
+ # @!attribute [r] input_tokens
32
+ # @return [Integer] input tokens consumed by this run
33
+ # @!attribute [r] output_tokens
34
+ # @return [Integer] output tokens generated by this run
35
+ attr_reader :robot_name, :output, :tool_calls, :created_at, :id, :stop_reason,
36
+ :input_tokens, :output_tokens
32
37
 
33
38
  # @!attribute [rw] duration
34
39
  # @return [Float, nil] elapsed seconds for this run
40
+ # @!attribute [rw] delegated_by
41
+ # @return [String, nil] name of the robot that delegated this task
35
42
  # @!attribute [rw] prompt
36
43
  # @return [Array<Message>, nil] the prompt messages used (debug)
37
44
  # @!attribute [rw] history
38
45
  # @return [Array<Message>, nil] the history used (debug)
39
46
  # @!attribute [rw] raw
40
47
  # @return [Object, nil] the raw LLM response (debug)
41
- attr_accessor :duration, :prompt, :history, :raw
48
+ attr_accessor :duration, :delegated_by, :prompt, :history, :raw
42
49
 
43
50
  # Creates a new RobotResult instance.
44
51
  #
@@ -51,6 +58,8 @@ module RobotLab
51
58
  # @param history [Array<Message>, nil] history messages (debug)
52
59
  # @param raw [Object, nil] raw LLM response (debug)
53
60
  # @param stop_reason [String, nil] reason for stopping
61
+ # @param input_tokens [Integer] input tokens consumed (default 0)
62
+ # @param output_tokens [Integer] output tokens generated (default 0)
54
63
  def initialize(
55
64
  robot_name:,
56
65
  output:,
@@ -60,7 +69,9 @@ module RobotLab
60
69
  prompt: nil,
61
70
  history: nil,
62
71
  raw: nil,
63
- stop_reason: nil
72
+ stop_reason: nil,
73
+ input_tokens: 0,
74
+ output_tokens: 0
64
75
  )
65
76
  @robot_name = robot_name
66
77
  @output = normalize_messages(output)
@@ -71,6 +82,8 @@ module RobotLab
71
82
  @history = history
72
83
  @raw = raw
73
84
  @stop_reason = stop_reason
85
+ @input_tokens = input_tokens.to_i
86
+ @output_tokens = output_tokens.to_i
74
87
  end
75
88
 
76
89
  # Generate a checksum for deduplication
@@ -98,12 +111,16 @@ module RobotLab
98
111
  def export
99
112
  {
100
113
  robot_name: robot_name,
114
+ delegated_by: delegated_by,
101
115
  output: output.map(&:to_h),
102
116
  tool_calls: tool_calls.map(&:to_h),
103
117
  created_at: created_at.iso8601,
104
118
  id: id,
105
119
  checksum: checksum,
106
- stop_reason: stop_reason
120
+ stop_reason: stop_reason,
121
+ duration: duration,
122
+ input_tokens: input_tokens.positive? ? input_tokens : nil,
123
+ output_tokens: output_tokens.positive? ? output_tokens : nil
107
124
  }.compact
108
125
  end
109
126
 
@@ -173,7 +190,9 @@ module RobotLab
173
190
  prompt: hash[:prompt]&.map { |m| Message.from_hash(m) },
174
191
  history: hash[:history]&.map { |m| Message.from_hash(m) },
175
192
  raw: hash[:raw],
176
- stop_reason: hash[:stop_reason]
193
+ stop_reason: hash[:stop_reason],
194
+ input_tokens: hash[:input_tokens] || 0,
195
+ output_tokens: hash[:output_tokens] || 0
177
196
  )
178
197
  end
179
198
 
@@ -41,7 +41,7 @@ module RobotLab
41
41
  CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
42
 
43
43
  # Infrastructure fields
44
- INFRA_FIELDS = %i[bus enable_cache].freeze
44
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size].freeze
45
45
 
46
46
  # All recognized fields
47
47
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Shared TF-IDF text analysis utilities.
5
+ #
6
+ # Wraps +Classifier::TFIDF+ from the optional 'classifier' gem (~> 2.3).
7
+ # Call +require_classifier!+ before any analysis method to get a descriptive
8
+ # error if the gem is missing rather than a bare NameError.
9
+ #
10
+ # Vectors returned by +transform+ are L2-normalized, so cosine similarity
11
+ # equals the dot product of two vectors — no magnitude division needed.
12
+ module TextAnalysis
13
+ # Attempt to load the classifier gem.
14
+ #
15
+ # @raise [DependencyError] if the gem is not installed
16
+ def self.require_classifier!
17
+ load_classifier_gem
18
+ rescue LoadError
19
+ raise DependencyError,
20
+ "The 'classifier' gem is required for text analysis features. " \
21
+ "Add it to your Gemfile: gem 'classifier', '~> 2.3'"
22
+ end
23
+
24
+ # Load the classifier gem. Extracted for testability.
25
+ #
26
+ # @api private
27
+ def self.load_classifier_gem
28
+ require "classifier"
29
+ end
30
+
31
+ # Fit a TF-IDF model on a corpus of strings.
32
+ #
33
+ # @param corpus [Array<String>] non-empty array of document strings
34
+ # @return [Classifier::TFIDF] fitted model
35
+ def self.fit(corpus)
36
+ model = Classifier::TFIDF.new(min_df: 1)
37
+ model.fit(corpus)
38
+ model
39
+ end
40
+
41
+ # Transform a string into an L2-normalized TF-IDF term vector.
42
+ #
43
+ # @param model [Classifier::TFIDF] a fitted model
44
+ # @param text [String]
45
+ # @return [Hash{Symbol => Float}] sparse term vector; empty if no known terms
46
+ def self.transform(model, text)
47
+ model.transform(text.to_s) || {}
48
+ end
49
+
50
+ # Cosine similarity between two L2-normalized sparse vectors.
51
+ #
52
+ # Since +Classifier::TFIDF+ returns L2-normalized vectors, this is just
53
+ # a dot product. Result is clamped to [0.0, 1.0] to absorb float noise.
54
+ #
55
+ # @param vec_a [Hash{Symbol => Float}]
56
+ # @param vec_b [Hash{Symbol => Float}]
57
+ # @return [Float] in [0.0, 1.0]
58
+ def self.cosine_similarity(vec_a, vec_b)
59
+ return 0.0 if vec_a.empty? || vec_b.empty?
60
+
61
+ [dot(vec_a, vec_b), 1.0].min
62
+ end
63
+
64
+ # Dot product of two sparse vectors (shared keys only).
65
+ #
66
+ # @param vec_a [Hash{Symbol => Float}]
67
+ # @param vec_b [Hash{Symbol => Float}]
68
+ # @return [Float]
69
+ def self.dot(vec_a, vec_b)
70
+ (vec_a.keys & vec_b.keys).sum { |k| vec_a[k] * vec_b[k] }.to_f
71
+ end
72
+
73
+ # L2-normalize a sparse vector.
74
+ #
75
+ # @param vec [Hash{Symbol => Numeric}]
76
+ # @return [Hash{Symbol => Float}] normalized vector; {} if magnitude is zero
77
+ def self.l2_normalize(vec)
78
+ magnitude = Math.sqrt(vec.values.sum { |v| v * v }.to_f)
79
+ return {} if magnitude.zero?
80
+
81
+ vec.transform_values { |v| v.to_f / magnitude }
82
+ end
83
+
84
+ # Cosine similarity between two texts using stemmed term-frequency vectors.
85
+ #
86
+ # Uses +String#word_hash+ from the classifier gem (stems, removes stopwords)
87
+ # and L2-normalized term frequencies. Unlike TF-IDF, this does not require
88
+ # a reference corpus, making it reliable for direct 2-text comparison.
89
+ # Returns 0.0 when either text is too short to produce a term vector.
90
+ #
91
+ # @param text_a [String]
92
+ # @param text_b [String]
93
+ # @return [Float] in [0.0, 1.0]
94
+ def self.tf_cosine_similarity(text_a, text_b)
95
+ require_classifier!
96
+
97
+ vec_a = l2_normalize(text_a.word_hash)
98
+ vec_b = l2_normalize(text_b.word_hash)
99
+
100
+ cosine_similarity(vec_a, vec_b)
101
+ end
102
+ end
103
+ end
@@ -46,6 +46,33 @@ module RobotLab
46
46
  def raise_on_error?
47
47
  defined?(@raise_on_error) ? @raise_on_error : false
48
48
  end
49
+
50
+ # Declare that this tool class is safe to run inside a Ractor.
51
+ #
52
+ # Ractor-safe tools must be stateless — no captured mutable closures
53
+ # and no non-shareable class-level state. The tool is instantiated
54
+ # fresh inside the Ractor worker for each call.
55
+ #
56
+ # With no argument, acts as a getter (walks the inheritance chain).
57
+ # With a Boolean argument, sets the value.
58
+ #
59
+ # @param value [Boolean, nil]
60
+ # @return [Boolean]
61
+ def ractor_safe(value = nil)
62
+ if value.nil?
63
+ if instance_variable_defined?(:@ractor_safe)
64
+ @ractor_safe
65
+ elsif superclass.respond_to?(:ractor_safe)
66
+ superclass.ractor_safe
67
+ else
68
+ false
69
+ end
70
+ else
71
+ @ractor_safe = value
72
+ end
73
+ end
74
+
75
+ alias ractor_safe? ractor_safe
49
76
  end
50
77
 
51
78
  # Creates a new Tool instance.
@@ -56,13 +83,25 @@ module RobotLab
56
83
  @robot = robot
57
84
  end
58
85
 
59
- # Wraps RubyLLM::Tool#call with error handling so the LLM receives
60
- # a plain-text error message instead of crashing the run.
86
+ # Invokes the tool, routing through the Ractor worker pool if ractor_safe.
87
+ #
88
+ # For Ractor-safe tools with a resolvable class name: submits to
89
+ # RobotLab.ractor_pool and blocks for the frozen result. Anonymous
90
+ # classes (name.nil?) fall through to the inline path.
91
+ #
92
+ # For non-Ractor-safe tools: runs execute directly in the calling thread.
61
93
  #
62
94
  # @param args [Hash] the tool arguments from the LLM
63
95
  # @return [Object] the tool result or an error string
64
96
  def call(args)
65
- super
97
+ if self.class.ractor_safe? && !self.class.name.nil?
98
+ RobotLab.ractor_pool.submit(self.class.name, args)
99
+ else
100
+ super
101
+ end
102
+ rescue RobotLab::ToolError => e
103
+ raise if self.class.raise_on_error?
104
+ "Error (#{name}): #{e.message}"
66
105
  rescue StandardError => e
67
106
  raise if self.class.raise_on_error?
68
107
 
@@ -27,7 +27,7 @@ module RobotLab
27
27
  # # => ["tool3"]
28
28
  #
29
29
  module ToolConfig
30
- NONE_VALUES = [nil, [], :none].freeze
30
+ NONE_VALUES = [nil, [].freeze, :none].freeze
31
31
 
32
32
  class << self
33
33
  # Resolve a configuration value against its parent
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.0.9"
4
+ VERSION = "0.0.11"
5
5
  end