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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +80 -1
- data/Rakefile +2 -1
- data/docs/api/core/robot.md +182 -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 +70 -2
data/lib/robot_lab/robot.rb
CHANGED
|
@@ -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
|
|
174
|
-
@message_counter
|
|
175
|
-
@outbox
|
|
176
|
-
@message_handler
|
|
177
|
-
@
|
|
178
|
-
@
|
|
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(
|
|
353
|
+
response = ask(effective_message, **ask_kwargs, &streaming)
|
|
354
|
+
|
|
355
|
+
result = build_result(response, run_memory)
|
|
321
356
|
|
|
322
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/robot_lab/run_config.rb
CHANGED
|
@@ -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
|
data/lib/robot_lab/tool.rb
CHANGED
|
@@ -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
|
-
#
|
|
60
|
-
#
|
|
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
|
-
|
|
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
|
|
data/lib/robot_lab/version.rb
CHANGED