robot_lab 0.0.8 → 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 +71 -0
- data/README.md +106 -4
- data/Rakefile +2 -1
- data/docs/api/core/robot.md +336 -1
- data/docs/api/mcp/client.md +1 -0
- data/docs/api/mcp/server.md +27 -8
- data/docs/api/mcp/transports.md +21 -6
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/robot-execution.md +20 -2
- data/docs/concepts.md +4 -0
- data/docs/guides/building-robots.md +18 -0
- data/docs/guides/creating-networks.md +39 -0
- data/docs/guides/index.md +10 -0
- data/docs/guides/knowledge.md +182 -0
- data/docs/guides/mcp-integration.md +180 -2
- 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/14_rusty_circuit/.gitignore +1 -0
- data/examples/14_rusty_circuit/open_mic.rb +1 -1
- 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 +23 -9
- data/lib/robot_lab/mcp/connection_poller.rb +187 -0
- data/lib/robot_lab/mcp/server.rb +26 -3
- data/lib/robot_lab/mcp/server_discovery.rb +110 -0
- data/lib/robot_lab/mcp/transports/base.rb +10 -2
- data/lib/robot_lab/mcp/transports/stdio.rb +58 -26
- 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/mcp_management.rb +61 -4
- data/lib/robot_lab/robot.rb +351 -11
- data/lib/robot_lab/robot_result.rb +26 -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 +71 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# A promise-like object returned by robot.delegate(async: true).
|
|
5
|
+
#
|
|
6
|
+
# The delegated task runs in a background thread. The caller can check
|
|
7
|
+
# whether the result is ready with +resolved?+ and block until it arrives
|
|
8
|
+
# with +value+ (or its alias +wait+).
|
|
9
|
+
#
|
|
10
|
+
# @example Fan-out to two specialists in parallel
|
|
11
|
+
# f1 = manager.delegate(to: summarizer, task: "summarize ...", async: true)
|
|
12
|
+
# f2 = manager.delegate(to: analyst, task: "analyze ...", async: true)
|
|
13
|
+
#
|
|
14
|
+
# # Other work here while both run in parallel
|
|
15
|
+
#
|
|
16
|
+
# summary = f1.value # blocks if not yet done
|
|
17
|
+
# analysis = f2.value
|
|
18
|
+
#
|
|
19
|
+
# @example With a timeout
|
|
20
|
+
# result = future.value(timeout: 10) # raises DelegationTimeout if too slow
|
|
21
|
+
#
|
|
22
|
+
class DelegationFuture
|
|
23
|
+
# Raised when +value(timeout: N)+ expires before the result arrives.
|
|
24
|
+
class DelegationTimeout < Error; end
|
|
25
|
+
|
|
26
|
+
# @return [String] name of the robot that was delegated to
|
|
27
|
+
attr_reader :robot_name
|
|
28
|
+
|
|
29
|
+
# @return [String] name of the robot that created this future
|
|
30
|
+
attr_reader :delegated_by
|
|
31
|
+
|
|
32
|
+
# @param robot_name [String]
|
|
33
|
+
# @param delegated_by [String]
|
|
34
|
+
def initialize(robot_name:, delegated_by:)
|
|
35
|
+
@robot_name = robot_name
|
|
36
|
+
@delegated_by = delegated_by
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@cv = ConditionVariable.new
|
|
39
|
+
@result = nil
|
|
40
|
+
@error = nil
|
|
41
|
+
@resolved = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True once the delegated task has completed (successfully or with an error).
|
|
45
|
+
#
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def resolved?
|
|
48
|
+
@mutex.synchronize { @resolved }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Block until the result is available and return it.
|
|
52
|
+
#
|
|
53
|
+
# @param timeout [Numeric, nil] maximum seconds to wait; nil means wait forever
|
|
54
|
+
# @return [RobotResult]
|
|
55
|
+
# @raise [DelegationTimeout] if +timeout+ is given and expires
|
|
56
|
+
# @raise [StandardError] re-raises any error thrown by the delegated task
|
|
57
|
+
def value(timeout: nil)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
unless @resolved
|
|
60
|
+
@cv.wait(@mutex, timeout)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless @resolved
|
|
64
|
+
raise DelegationTimeout,
|
|
65
|
+
"Delegation to '#{@robot_name}' timed out after #{timeout}s"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
raise @error if @error
|
|
69
|
+
|
|
70
|
+
@result
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
alias_method :wait, :value
|
|
74
|
+
|
|
75
|
+
# @api private — called by Robot#delegate from the worker thread
|
|
76
|
+
def resolve!(result)
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@result = result
|
|
79
|
+
@resolved = true
|
|
80
|
+
@cv.broadcast
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @api private — called by Robot#delegate from the worker thread on error
|
|
85
|
+
def reject!(error)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@error = error
|
|
88
|
+
@resolved = true
|
|
89
|
+
@cv.broadcast
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fastembed"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
# Embedding-based document store for semantic search over arbitrary text.
|
|
7
|
+
#
|
|
8
|
+
# Documents are embedded using {https://github.com/khasinski/fastembed-rb fastembed}
|
|
9
|
+
# (BAAI/bge-small-en-v1.5 by default) and stored in memory. Queries are
|
|
10
|
+
# embedded the same way, then compared by cosine similarity to find the
|
|
11
|
+
# closest documents.
|
|
12
|
+
#
|
|
13
|
+
# The embedding model is initialised lazily on first use — the ONNX model
|
|
14
|
+
# file is downloaded on that first call (cached locally afterwards).
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = RobotLab::DocumentStore.new
|
|
18
|
+
# store.store(:q4_report, "Q4 revenue came in at $4.2M, up 18% YoY…")
|
|
19
|
+
# store.store(:q3_report, "Q3 showed 15% growth, driven by APAC…")
|
|
20
|
+
#
|
|
21
|
+
# results = store.search("revenue growth", limit: 2)
|
|
22
|
+
# results.each { |r| puts "#{r[:key]} (#{r[:score].round(3)}): #{r[:text][0..60]}" }
|
|
23
|
+
#
|
|
24
|
+
# @example Via Memory
|
|
25
|
+
# memory.store_document(:readme, File.read("README.md"))
|
|
26
|
+
# memory.search_documents("how to configure redis", limit: 3)
|
|
27
|
+
#
|
|
28
|
+
class DocumentStore
|
|
29
|
+
# Default embedding model used when none is specified.
|
|
30
|
+
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
|
31
|
+
|
|
32
|
+
# @param model_name [String] fastembed model name (default: BAAI/bge-small-en-v1.5)
|
|
33
|
+
def initialize(model_name: DEFAULT_MODEL)
|
|
34
|
+
@model_name = model_name
|
|
35
|
+
@documents = {} # key (Symbol) => { text: String, vector: Array<Float> }
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@model = nil # lazy: initialised on first embed call
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Embed +text+ and store it under +key+.
|
|
41
|
+
#
|
|
42
|
+
# If a document already exists under +key+ it is replaced.
|
|
43
|
+
#
|
|
44
|
+
# @param key [Symbol, String] identifier for this document
|
|
45
|
+
# @param text [String] the document text to embed and store
|
|
46
|
+
# @return [self]
|
|
47
|
+
def store(key, text)
|
|
48
|
+
key = key.to_sym
|
|
49
|
+
vector = passage_vector(text)
|
|
50
|
+
@mutex.synchronize { @documents[key] = { text: text, vector: vector } }
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Search for documents semantically similar to +query+.
|
|
55
|
+
#
|
|
56
|
+
# @param query [String] natural-language search query
|
|
57
|
+
# @param limit [Integer] maximum number of results (default 5)
|
|
58
|
+
# @return [Array<Hash>] results sorted by score descending.
|
|
59
|
+
# Each hash contains +:key+, +:text+, and +:score+ (Float 0.0..1.0).
|
|
60
|
+
def search(query, limit: 5)
|
|
61
|
+
return [] if empty?
|
|
62
|
+
|
|
63
|
+
query_vec = query_vector(query)
|
|
64
|
+
results = []
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@documents.each do |key, doc|
|
|
68
|
+
score = cosine_similarity(query_vec, doc[:vector])
|
|
69
|
+
results << { key: key, text: doc[:text], score: score }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
results.sort_by { |r| -r[:score] }.first(limit)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Number of stored documents.
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
def size
|
|
80
|
+
@mutex.synchronize { @documents.size }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Keys of all stored documents.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Symbol>]
|
|
86
|
+
def keys
|
|
87
|
+
@mutex.synchronize { @documents.keys }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether the store contains no documents.
|
|
91
|
+
#
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def empty?
|
|
94
|
+
@mutex.synchronize { @documents.empty? }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Remove the document stored under +key+.
|
|
98
|
+
#
|
|
99
|
+
# @param key [Symbol, String]
|
|
100
|
+
# @return [self]
|
|
101
|
+
def delete(key)
|
|
102
|
+
@mutex.synchronize { @documents.delete(key.to_sym) }
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Remove all stored documents.
|
|
107
|
+
#
|
|
108
|
+
# @return [self]
|
|
109
|
+
def clear
|
|
110
|
+
@mutex.synchronize { @documents.clear }
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Return (or lazily create) the fastembed model instance.
|
|
117
|
+
def model
|
|
118
|
+
@model ||= Fastembed::TextEmbedding.new(model_name: @model_name, show_progress: false)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Embed a single passage string.
|
|
122
|
+
def passage_vector(text)
|
|
123
|
+
model.passage_embed([text]).to_a.first
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Embed a single query string.
|
|
127
|
+
def query_vector(text)
|
|
128
|
+
model.query_embed([text]).to_a.first
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Cosine similarity between two dense float vectors.
|
|
132
|
+
#
|
|
133
|
+
# Returns 0.0 for nil, empty, or mismatched-length vectors.
|
|
134
|
+
def cosine_similarity(vec_a, vec_b)
|
|
135
|
+
return 0.0 unless vec_a && vec_b
|
|
136
|
+
return 0.0 if vec_a.empty? || vec_b.empty?
|
|
137
|
+
return 0.0 if vec_a.length != vec_b.length
|
|
138
|
+
|
|
139
|
+
dot = 0.0
|
|
140
|
+
norm_a = 0.0
|
|
141
|
+
norm_b = 0.0
|
|
142
|
+
|
|
143
|
+
vec_a.each_with_index do |a, i|
|
|
144
|
+
b = vec_b[i]
|
|
145
|
+
dot += a * b
|
|
146
|
+
norm_a += a * a
|
|
147
|
+
norm_b += b * b
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
return 0.0 if norm_a.zero? || norm_b.zero?
|
|
151
|
+
|
|
152
|
+
dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/robot_lab/error.rb
CHANGED
|
@@ -35,4 +35,29 @@ module RobotLab
|
|
|
35
35
|
# @example
|
|
36
36
|
# raise BusError, "No bus configured on this robot"
|
|
37
37
|
class BusError < Error; end
|
|
38
|
+
|
|
39
|
+
# Raised when a robot's tool call loop exceeds the configured limit.
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# raise ToolLoopError, "Circuit breaker: 26 tool calls exceeded max_tool_rounds (25)"
|
|
43
|
+
class ToolLoopError < InferenceError; end
|
|
44
|
+
|
|
45
|
+
# Raised when a required optional gem dependency is not installed.
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# raise DependencyError, "Add gem 'classifier', '~> 2.3' to your Gemfile"
|
|
49
|
+
class DependencyError < ConfigurationError; end
|
|
50
|
+
|
|
51
|
+
# Raised when a value cannot be made Ractor-shareable before crossing
|
|
52
|
+
# a Ractor boundary (e.g., a live IO, Proc, or object with mutable state).
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# raise RactorBoundaryError, "Cannot freeze IO object"
|
|
56
|
+
class RactorBoundaryError < Error; end
|
|
57
|
+
|
|
58
|
+
# Raised when a tool fails during execution, including inside a Ractor worker.
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# raise ToolError, "Tool 'MyTool' failed: division by zero"
|
|
62
|
+
class ToolError < Error; end
|
|
38
63
|
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Compresses a robot's conversation history using TF-IDF relevance scoring.
|
|
5
|
+
#
|
|
6
|
+
# Old conversation turns are tiered against the most recent context:
|
|
7
|
+
# - High relevance (score >= keep_threshold) → kept verbatim
|
|
8
|
+
# - Medium relevance (drop_threshold..keep_threshold) → summarized or dropped
|
|
9
|
+
# - Low relevance (score < drop_threshold) → dropped
|
|
10
|
+
#
|
|
11
|
+
# System messages and tool call/result messages are always preserved.
|
|
12
|
+
# The most recent +recent_turns+ user+assistant pairs are also always kept.
|
|
13
|
+
#
|
|
14
|
+
# Requires the optional 'classifier' gem (~> 2.3).
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage from Robot
|
|
17
|
+
# robot.compress_history(recent_turns: 3, keep_threshold: 0.6, drop_threshold: 0.2)
|
|
18
|
+
#
|
|
19
|
+
# @example With LLM summarizer (separate robot)
|
|
20
|
+
# summarizer_robot = RobotLab.build(name: "summarizer", system_prompt: "Summarize concisely.")
|
|
21
|
+
# robot.compress_history(
|
|
22
|
+
# summarizer: ->(text) { summarizer_robot.run("One sentence: #{text}").reply }
|
|
23
|
+
# )
|
|
24
|
+
class HistoryCompressor
|
|
25
|
+
# Minimum text length (characters) to score; shorter messages are kept as-is.
|
|
26
|
+
MIN_SCORE_LENGTH = 20
|
|
27
|
+
|
|
28
|
+
# Minimal duck-type for a summary replacement message compatible with
|
|
29
|
+
# RubyLLM::Chat's message array. Preserves the original message role so
|
|
30
|
+
# user/assistant turn ordering is maintained after compression.
|
|
31
|
+
SUMMARY_STRUCT = Struct.new(:role, :content, :tool_calls, :stop_reason) do
|
|
32
|
+
def text? = true
|
|
33
|
+
def tool_use? = false
|
|
34
|
+
def system? = role == :system
|
|
35
|
+
def user? = role == :user
|
|
36
|
+
def assistant? = role == :assistant
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param messages [Array] full @chat.messages array
|
|
40
|
+
# @param recent_turns [Integer] number of user+assistant turn pairs to protect
|
|
41
|
+
# @param keep_threshold [Float] score >= this → keep verbatim
|
|
42
|
+
# @param drop_threshold [Float] score < this → drop
|
|
43
|
+
# @param summarizer [#call, nil] callable(text) -> String for medium-tier;
|
|
44
|
+
# nil means drop medium-tier
|
|
45
|
+
# @raise [ArgumentError] if keep_threshold <= drop_threshold
|
|
46
|
+
def initialize(messages:, recent_turns:, keep_threshold:, drop_threshold:, summarizer:)
|
|
47
|
+
if keep_threshold <= drop_threshold
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"keep_threshold (#{keep_threshold}) must be greater than drop_threshold (#{drop_threshold})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@messages = messages
|
|
53
|
+
@recent_turns = recent_turns
|
|
54
|
+
@keep_threshold = keep_threshold
|
|
55
|
+
@drop_threshold = drop_threshold
|
|
56
|
+
@summarizer = summarizer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Execute compression and return the new message array.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array] compressed message array
|
|
62
|
+
def call
|
|
63
|
+
return @messages if @messages.empty?
|
|
64
|
+
|
|
65
|
+
# Classify each message index as pinned (always keep) or scorable
|
|
66
|
+
pinned_indices = []
|
|
67
|
+
scorable_indices = []
|
|
68
|
+
|
|
69
|
+
@messages.each_with_index do |msg, idx|
|
|
70
|
+
if pinned_message?(msg)
|
|
71
|
+
pinned_indices << idx
|
|
72
|
+
else
|
|
73
|
+
scorable_indices << idx
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Nothing scorable, or everything fits inside the recent window: return as-is
|
|
78
|
+
return @messages if scorable_indices.empty?
|
|
79
|
+
return @messages if scorable_indices.size <= @recent_turns * 2
|
|
80
|
+
|
|
81
|
+
recent_count = @recent_turns * 2
|
|
82
|
+
compressible = scorable_indices[0..-(recent_count + 1)]
|
|
83
|
+
recent = scorable_indices[-recent_count..]
|
|
84
|
+
|
|
85
|
+
return @messages if compressible.nil? || compressible.empty?
|
|
86
|
+
|
|
87
|
+
# Build reference vector from the recent window using stemmed term frequencies.
|
|
88
|
+
# Term frequencies (no IDF) are used because IDF on a topic-focused corpus
|
|
89
|
+
# would suppress the very terms that indicate relevance to that topic.
|
|
90
|
+
recent_texts = recent.filter_map { |i| extract_text(@messages[i]) }
|
|
91
|
+
.reject { |t| t.strip.length < MIN_SCORE_LENGTH }
|
|
92
|
+
|
|
93
|
+
# No meaningful recent text → cannot score; return unchanged
|
|
94
|
+
return @messages if recent_texts.empty?
|
|
95
|
+
|
|
96
|
+
TextAnalysis.require_classifier!
|
|
97
|
+
|
|
98
|
+
recent_vectors = recent_texts.map { |t| TextAnalysis.l2_normalize(t.word_hash) }
|
|
99
|
+
reference = mean_vector(recent_vectors)
|
|
100
|
+
|
|
101
|
+
# Decide action for each compressible message
|
|
102
|
+
actions = {}
|
|
103
|
+
compressible.each do |idx|
|
|
104
|
+
actions[idx] = score_action(reference, @messages[idx])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Reconstruct the message array in original order
|
|
108
|
+
build_result(actions)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Determine the action for one compressible message.
|
|
114
|
+
#
|
|
115
|
+
# @return [Symbol, String] :keep, :drop, or a summary String
|
|
116
|
+
def score_action(reference, msg)
|
|
117
|
+
text = extract_text(msg)
|
|
118
|
+
|
|
119
|
+
# Too short to score reliably → keep
|
|
120
|
+
return :keep if text.nil? || text.strip.length < MIN_SCORE_LENGTH
|
|
121
|
+
|
|
122
|
+
vec = TextAnalysis.l2_normalize(text.word_hash)
|
|
123
|
+
score = TextAnalysis.cosine_similarity(vec, reference)
|
|
124
|
+
|
|
125
|
+
if score >= @keep_threshold
|
|
126
|
+
:keep
|
|
127
|
+
elsif score < @drop_threshold
|
|
128
|
+
:drop
|
|
129
|
+
elsif @summarizer
|
|
130
|
+
summary = @summarizer.call(text).to_s.strip
|
|
131
|
+
summary.empty? ? :drop : summary
|
|
132
|
+
else
|
|
133
|
+
:drop
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Build the final message array from the decided actions.
|
|
138
|
+
def build_result(actions)
|
|
139
|
+
result = []
|
|
140
|
+
|
|
141
|
+
@messages.each_with_index do |msg, idx|
|
|
142
|
+
action = actions[idx]
|
|
143
|
+
|
|
144
|
+
if action.nil?
|
|
145
|
+
# Pinned or recent: always include
|
|
146
|
+
result << msg
|
|
147
|
+
elsif action == :keep
|
|
148
|
+
result << msg
|
|
149
|
+
elsif action == :drop
|
|
150
|
+
# Omit entirely
|
|
151
|
+
else
|
|
152
|
+
# action is a summary String; preserve original role to keep turn ordering
|
|
153
|
+
result << SUMMARY_STRUCT.new(msg.role, action, nil, :stop)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
result
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# True if a message should never be scored or removed.
|
|
161
|
+
#
|
|
162
|
+
# System messages and tool-related messages are always pinned.
|
|
163
|
+
# Assistant messages with no text content (tool call dispatchers)
|
|
164
|
+
# are also pinned to avoid breaking tool_use/tool_result pairing.
|
|
165
|
+
def pinned_message?(msg)
|
|
166
|
+
role = msg.role
|
|
167
|
+
|
|
168
|
+
return true if role == :system
|
|
169
|
+
return true if role == :tool || role == :tool_result
|
|
170
|
+
|
|
171
|
+
# Assistant tool-call dispatcher: content is nil or blank
|
|
172
|
+
if role == :assistant
|
|
173
|
+
text = extract_text(msg)
|
|
174
|
+
return true if text.nil? || text.strip.empty?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Extract plain text from a message's content field.
|
|
181
|
+
#
|
|
182
|
+
# @return [String, nil]
|
|
183
|
+
def extract_text(msg)
|
|
184
|
+
content = msg.content
|
|
185
|
+
case content
|
|
186
|
+
when String then content
|
|
187
|
+
when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
|
|
188
|
+
else nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Element-wise mean of a list of sparse vectors.
|
|
193
|
+
#
|
|
194
|
+
# @param vectors [Array<Hash{Symbol => Float}>]
|
|
195
|
+
# @return [Hash{Symbol => Float}]
|
|
196
|
+
def mean_vector(vectors)
|
|
197
|
+
return {} if vectors.empty?
|
|
198
|
+
|
|
199
|
+
sum = Hash.new(0.0)
|
|
200
|
+
vectors.each { |v| v.each { |k, val| sum[k] += val } }
|
|
201
|
+
n = vectors.size.to_f
|
|
202
|
+
sum.transform_values { |v| v / n }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/lib/robot_lab/mcp/client.rb
CHANGED
|
@@ -18,13 +18,15 @@ module RobotLab
|
|
|
18
18
|
# @return [Server] the MCP server configuration
|
|
19
19
|
# @!attribute [r] connected
|
|
20
20
|
# @return [Boolean] whether currently connected
|
|
21
|
-
attr_reader :server, :connected
|
|
21
|
+
attr_reader :server, :connected, :transport
|
|
22
22
|
|
|
23
23
|
# Creates a new MCP Client instance.
|
|
24
24
|
#
|
|
25
25
|
# @param server_or_config [Server, Hash] the server or configuration hash
|
|
26
|
+
# @param poller [MCP::ConnectionPoller, nil] optional shared IO.select poller
|
|
27
|
+
# for multiplexing multiple stdio transports (opt-in, default nil = per-client blocking)
|
|
26
28
|
# @raise [ArgumentError] if config is invalid
|
|
27
|
-
def initialize(server_or_config)
|
|
29
|
+
def initialize(server_or_config, poller: nil)
|
|
28
30
|
@server = case server_or_config
|
|
29
31
|
when Server
|
|
30
32
|
server_or_config
|
|
@@ -33,9 +35,10 @@ module RobotLab
|
|
|
33
35
|
else
|
|
34
36
|
raise ArgumentError, "Invalid server config"
|
|
35
37
|
end
|
|
36
|
-
@connected
|
|
37
|
-
@transport
|
|
38
|
+
@connected = false
|
|
39
|
+
@transport = nil
|
|
38
40
|
@request_id = 0
|
|
41
|
+
@poller = poller
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
# Connect to the MCP server
|
|
@@ -49,6 +52,9 @@ module RobotLab
|
|
|
49
52
|
@transport.connect if @transport.respond_to?(:connect)
|
|
50
53
|
@connected = true
|
|
51
54
|
|
|
55
|
+
# Register with shared poller after the transport is connected
|
|
56
|
+
@poller.register(self) if @poller
|
|
57
|
+
|
|
52
58
|
self
|
|
53
59
|
rescue StandardError => e
|
|
54
60
|
RobotLab.config.logger.warn("MCP connection failed for #{@server.name}: #{e.message}")
|
|
@@ -63,6 +69,7 @@ module RobotLab
|
|
|
63
69
|
def disconnect
|
|
64
70
|
return self unless @connected
|
|
65
71
|
|
|
72
|
+
@poller.unregister(self) if @poller
|
|
66
73
|
@transport.close if @transport.respond_to?(:close)
|
|
67
74
|
@connected = false
|
|
68
75
|
@transport = nil
|
|
@@ -162,15 +169,17 @@ module RobotLab
|
|
|
162
169
|
end
|
|
163
170
|
|
|
164
171
|
def create_transport
|
|
172
|
+
config = @server.transport.merge(timeout: @server.timeout)
|
|
173
|
+
|
|
165
174
|
case @server.transport_type
|
|
166
175
|
when "stdio"
|
|
167
|
-
Transports::Stdio.new(
|
|
176
|
+
Transports::Stdio.new(config)
|
|
168
177
|
when "ws", "websocket"
|
|
169
|
-
Transports::WebSocket.new(
|
|
178
|
+
Transports::WebSocket.new(config)
|
|
170
179
|
when "sse"
|
|
171
|
-
Transports::SSE.new(
|
|
180
|
+
Transports::SSE.new(config)
|
|
172
181
|
when "streamable-http", "http"
|
|
173
|
-
Transports::StreamableHTTP.new(
|
|
182
|
+
Transports::StreamableHTTP.new(config)
|
|
174
183
|
else
|
|
175
184
|
raise MCPError, "Unsupported transport type: #{@server.transport_type}"
|
|
176
185
|
end
|
|
@@ -186,7 +195,12 @@ module RobotLab
|
|
|
186
195
|
}
|
|
187
196
|
message[:params] = params if params
|
|
188
197
|
|
|
189
|
-
response = @transport.
|
|
198
|
+
response = if @poller && @transport.is_a?(Transports::Stdio)
|
|
199
|
+
@poller.send_request(self, message, timeout: @server.timeout)
|
|
200
|
+
else
|
|
201
|
+
@transport.send_request(message)
|
|
202
|
+
end
|
|
203
|
+
|
|
190
204
|
parse_response(response)
|
|
191
205
|
end
|
|
192
206
|
|