robot_lab 0.2.7 → 0.3.0
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/.envrc +6 -2
- data/.loki +5 -0
- data/Archspec.rb +44 -0
- data/CHANGELOG.md +70 -1
- data/CLAUDE.md +4 -2
- data/README.md +15 -17
- data/Rakefile +6 -111
- data/_typos.toml +21 -0
- data/docs/api/index.md +2 -2
- data/docs/api/skills.md +53 -149
- data/docs/api/support.md +1 -1
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/index.md +5 -4
- data/docs/architecture/state-management.md +4 -4
- data/docs/concepts.md +1 -1
- data/docs/getting-started/configuration.md +4 -1
- data/docs/getting-started/installation.md +1 -4
- data/docs/guides/hooks.md +122 -0
- data/docs/guides/memory.md +2 -2
- data/docs/guides/using-tools.md +10 -1
- data/examples/.envrc +2 -0
- data/examples/02_tools.rb +8 -8
- data/examples/03_network.rb +1 -1
- data/examples/04_mcp.rb +7 -5
- data/examples/08_llm_config.rb +5 -5
- data/examples/09_chaining.rb +3 -3
- data/examples/14_rusty_circuit/comic.rb +8 -8
- data/examples/14_rusty_circuit/scout.rb +4 -4
- data/examples/15_memory_network_and_bus/README.md +66 -0
- data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
- data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
- data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
- data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
- data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/tools.rb +14 -14
- data/examples/19_token_tracking.rb +2 -2
- data/examples/20_circuit_breaker.rb +3 -3
- data/examples/22_context_compression.rb +1 -1
- data/examples/27_incident_response/README.md +65 -0
- data/examples/28_mcp_discovery.rb +2 -2
- data/examples/29_ractor_tools.rb +4 -4
- data/examples/30_ractor_network.rb +2 -2
- data/examples/33_stock_predictor.rb +8 -8
- data/examples/35_hooks.rb +3 -3
- data/examples/README.md +17 -0
- data/examples/common.rb +55 -23
- data/examples/run_all.rb +60 -0
- data/lib/robot_lab/agent_skill_catalog.rb +1 -0
- data/lib/robot_lab/ask_user.rb +5 -3
- data/lib/robot_lab/bus_poller.rb +2 -0
- data/lib/robot_lab/capabilities.rb +4 -0
- data/lib/robot_lab/config/defaults.yml +5 -5
- data/lib/robot_lab/config.rb +10 -2
- data/lib/robot_lab/doom_loop_detector.rb +6 -3
- data/lib/robot_lab/history_compressor.rb +5 -0
- data/lib/robot_lab/hook.rb +1 -0
- data/lib/robot_lab/hook_context.rb +4 -0
- data/lib/robot_lab/hook_registry.rb +1 -0
- data/lib/robot_lab/hooks.rb +6 -3
- data/lib/robot_lab/mcp/client.rb +2 -2
- data/lib/robot_lab/mcp/connection_poller.rb +16 -8
- data/lib/robot_lab/mcp/server_discovery.rb +1 -0
- data/lib/robot_lab/mcp/transports/sse.rb +3 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
- data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
- data/lib/robot_lab/memory.rb +39 -13
- data/lib/robot_lab/memory_change.rb +1 -0
- data/lib/robot_lab/message.rb +3 -0
- data/lib/robot_lab/names.rb +2 -4
- data/lib/robot_lab/network.rb +13 -6
- data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
- data/lib/robot_lab/robot/bus_messaging.rb +3 -0
- data/lib/robot_lab/robot/history_search.rb +2 -0
- data/lib/robot_lab/robot/hooking.rb +29 -0
- data/lib/robot_lab/robot/mcp_management.rb +11 -3
- data/lib/robot_lab/robot/result_building.rb +119 -0
- data/lib/robot_lab/robot/template_rendering.rb +13 -4
- data/lib/robot_lab/robot.rb +88 -130
- data/lib/robot_lab/robot_result.rb +3 -0
- data/lib/robot_lab/run_config.rb +57 -20
- data/lib/robot_lab/script_tool.rb +20 -39
- data/lib/robot_lab/state_proxy.rb +1 -0
- data/lib/robot_lab/streaming/context.rb +1 -0
- data/lib/robot_lab/streaming/events.rb +1 -0
- data/lib/robot_lab/task.rb +2 -0
- data/lib/robot_lab/tool.rb +11 -12
- data/lib/robot_lab/user_message.rb +1 -0
- data/lib/robot_lab/utils.rb +2 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +3 -0
- data/lib/robot_lab.rb +9 -6
- metadata +23 -32
- data/lib/robot_lab/sandbox/null.rb +0 -13
- data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
- data/lib/robot_lab/sandbox.rb +0 -52
|
@@ -41,13 +41,13 @@ defaults:
|
|
|
41
41
|
with_model:
|
|
42
42
|
provider: null # chat-specific provider
|
|
43
43
|
model: null # chat-specific model
|
|
44
|
-
|
|
44
|
+
assume_model_exists: null # assume the model exists primarily for local providers
|
|
45
45
|
with_temperature: 0.7 # Controls randomness (0.0-2.0, null = model default)
|
|
46
|
+
with_max_output_tokens: null # Maximum tokens in response
|
|
46
47
|
with_tools: null # ?? not sure about this one.
|
|
47
|
-
|
|
48
|
+
with_provider_options: # merged into the request payload (ruby_llm 2.0)
|
|
48
49
|
top_p: null # Nucleus sampling threshold (0.0-1.0)
|
|
49
50
|
top_k: null # Top-k sampling (integer, provider-specific)
|
|
50
|
-
max_tokens: null # Maximum tokens in response
|
|
51
51
|
presence_penalty: null # Penalize new tokens based on presence (-2.0 to 2.0)
|
|
52
52
|
frequency_penalty: null # Penalize new tokens based on frequency (-2.0 to 2.0)
|
|
53
53
|
stop: null # Stop sequences (string or array of strings)
|
|
@@ -55,7 +55,7 @@ defaults:
|
|
|
55
55
|
# RubyLLM Configuration Section
|
|
56
56
|
ruby_llm:
|
|
57
57
|
provider: :anthropic
|
|
58
|
-
model: claude-sonnet-4
|
|
58
|
+
model: claude-sonnet-4-6
|
|
59
59
|
assume_model_exists: false # (dep of assume_exists in chat section) set true for Ollama and other local LLM providers
|
|
60
60
|
# Provider API Keys (null = use env vars directly)
|
|
61
61
|
anthropic_api_key: null
|
|
@@ -117,7 +117,7 @@ test:
|
|
|
117
117
|
max_iterations: 3
|
|
118
118
|
streaming_enabled: false
|
|
119
119
|
ruby_llm:
|
|
120
|
-
model: claude-
|
|
120
|
+
model: claude-haiku-4-5
|
|
121
121
|
request_timeout: 30
|
|
122
122
|
max_retries: 1
|
|
123
123
|
log_level: :warn
|
data/lib/robot_lab/config.rb
CHANGED
|
@@ -14,7 +14,7 @@ module RobotLab
|
|
|
14
14
|
# - Automatic RubyLLM configuration application
|
|
15
15
|
#
|
|
16
16
|
# @example Access configuration values
|
|
17
|
-
# RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
|
|
17
|
+
# RobotLab.config.ruby_llm.model #=> "claude-sonnet-4-6"
|
|
18
18
|
# RobotLab.config.ruby_llm.request_timeout #=> 120
|
|
19
19
|
# RobotLab.config.development? #=> true
|
|
20
20
|
#
|
|
@@ -28,7 +28,7 @@ module RobotLab
|
|
|
28
28
|
# # defaults.yml. This file is NOT run through ERB, so keep secrets in
|
|
29
29
|
# # environment variables or in ./config/robot_lab.yml (which is).
|
|
30
30
|
# ruby_llm:
|
|
31
|
-
# model: claude-sonnet-4
|
|
31
|
+
# model: claude-sonnet-4-6
|
|
32
32
|
# request_timeout: 120
|
|
33
33
|
#
|
|
34
34
|
class Config < MywayConfig::Base
|
|
@@ -78,6 +78,8 @@ module RobotLab
|
|
|
78
78
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here (RuboCop allows it).
|
|
82
|
+
# :reek:TooManyStatements -- one set_if_present line per supported provider credential; a loop would obscure the env-var mapping.
|
|
81
83
|
def apply_provider_api_keys(c)
|
|
82
84
|
# Fall back to standard provider env vars when not set in config.
|
|
83
85
|
# This lets users set ANTHROPIC_API_KEY (etc.) directly without
|
|
@@ -103,6 +105,7 @@ module RobotLab
|
|
|
103
105
|
set_if_present(c, :vertexai_location, :vertexai_location, 'GOOGLE_CLOUD_LOCATION')
|
|
104
106
|
end
|
|
105
107
|
|
|
108
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
106
109
|
def apply_provider_endpoints(c)
|
|
107
110
|
c.openai_api_base = ruby_llm.openai_api_base if ruby_llm.openai_api_base
|
|
108
111
|
c.gemini_api_base = ruby_llm.gemini_api_base if ruby_llm.gemini_api_base
|
|
@@ -111,12 +114,14 @@ module RobotLab
|
|
|
111
114
|
c.xai_api_base = ruby_llm.xai_api_base if ruby_llm.xai_api_base
|
|
112
115
|
end
|
|
113
116
|
|
|
117
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
114
118
|
def apply_openai_options(c)
|
|
115
119
|
c.openai_organization_id = ruby_llm.openai_organization_id if ruby_llm.openai_organization_id
|
|
116
120
|
c.openai_project_id = ruby_llm.openai_project_id if ruby_llm.openai_project_id
|
|
117
121
|
c.openai_use_system_role = ruby_llm.openai_use_system_role unless ruby_llm.openai_use_system_role.nil?
|
|
118
122
|
end
|
|
119
123
|
|
|
124
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
120
125
|
def apply_default_models(c)
|
|
121
126
|
c.default_model = ruby_llm.default_model if ruby_llm.default_model
|
|
122
127
|
c.default_embedding_model = ruby_llm.default_embedding_model if ruby_llm.default_embedding_model
|
|
@@ -124,6 +129,7 @@ module RobotLab
|
|
|
124
129
|
c.default_moderation_model = ruby_llm.default_moderation_model if ruby_llm.default_moderation_model
|
|
125
130
|
end
|
|
126
131
|
|
|
132
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
127
133
|
def apply_connection_settings(c)
|
|
128
134
|
c.request_timeout = ruby_llm.request_timeout if ruby_llm.request_timeout
|
|
129
135
|
c.max_retries = ruby_llm.max_retries if ruby_llm.max_retries
|
|
@@ -133,6 +139,7 @@ module RobotLab
|
|
|
133
139
|
c.http_proxy = ruby_llm.http_proxy if ruby_llm.http_proxy
|
|
134
140
|
end
|
|
135
141
|
|
|
142
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
136
143
|
def apply_logging_options(c)
|
|
137
144
|
c.log_file = ruby_llm.log_file if ruby_llm.log_file
|
|
138
145
|
c.log_level = ruby_llm.log_level if ruby_llm.log_level
|
|
@@ -150,6 +157,7 @@ module RobotLab
|
|
|
150
157
|
|
|
151
158
|
# Set a RubyLLM config attribute from config value or standard env var.
|
|
152
159
|
# Only sets when a non-nil value is found, to avoid overwriting defaults.
|
|
160
|
+
# :reek:UncommunicativeParameterName -- `c` is the RubyLLM config being configured; conventional here.
|
|
153
161
|
def set_if_present(c, setter, config_key, env_var)
|
|
154
162
|
value = ruby_llm.public_send(config_key) || ENV.fetch(env_var, nil)
|
|
155
163
|
c.public_send(:"#{setter}=", value) if value
|
|
@@ -33,19 +33,21 @@ module RobotLab
|
|
|
33
33
|
@sequence << tool_name.to_s
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# :reek:TooManyStatements -- self-contained cycle-detection algorithm; splitting the scan loses the shape of the check.
|
|
36
37
|
def doom_loop?
|
|
37
38
|
seq = @sequence
|
|
38
|
-
|
|
39
|
+
length = seq.length
|
|
40
|
+
return false if length < @threshold
|
|
39
41
|
|
|
40
42
|
# Consecutive identical calls: A, A, A
|
|
41
43
|
tail = seq.last(@threshold)
|
|
42
44
|
return true if tail.uniq.length == 1
|
|
43
45
|
|
|
44
46
|
# Cyclic multi-step patterns: A,B,C, A,B,C, A,B,C
|
|
45
|
-
max_period = [MAX_PERIOD,
|
|
47
|
+
max_period = [MAX_PERIOD, length / @threshold].min
|
|
46
48
|
(2..max_period).each do |period|
|
|
47
49
|
window = @threshold * period
|
|
48
|
-
next if
|
|
50
|
+
next if length < window
|
|
49
51
|
|
|
50
52
|
chunk = seq.last(window)
|
|
51
53
|
pattern = chunk.first(period)
|
|
@@ -79,6 +81,7 @@ module RobotLab
|
|
|
79
81
|
|
|
80
82
|
private
|
|
81
83
|
|
|
84
|
+
# :reek:TooManyStatements -- mirrors doom_loop?'s scan to report which period matched.
|
|
82
85
|
def detect_period(seq)
|
|
83
86
|
return 1 if seq.last(@threshold).uniq.length == 1
|
|
84
87
|
|
|
@@ -59,6 +59,7 @@ module RobotLab
|
|
|
59
59
|
# Execute compression and return the new message array.
|
|
60
60
|
#
|
|
61
61
|
# @return [Array] compressed message array
|
|
62
|
+
# :reek:TooManyStatements -- linear classify/score/rebuild pipeline; each early return is a documented no-op case.
|
|
62
63
|
def call
|
|
63
64
|
return @messages if @messages.empty?
|
|
64
65
|
|
|
@@ -113,6 +114,7 @@ module RobotLab
|
|
|
113
114
|
# Determine the action for one compressible message.
|
|
114
115
|
#
|
|
115
116
|
# @return [Symbol, String] :keep, :drop, or a summary String
|
|
117
|
+
# :reek:TooManyStatements -- one linear score-then-threshold decision.
|
|
116
118
|
def score_action(reference, msg)
|
|
117
119
|
text = extract_text(msg)
|
|
118
120
|
|
|
@@ -133,6 +135,7 @@ module RobotLab
|
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
# Build the final message array from the decided actions.
|
|
138
|
+
# :reek:FeatureEnvy -- dispatching on the per-message action value (:keep/:drop/summary) is this method's purpose.
|
|
136
139
|
def build_result(actions)
|
|
137
140
|
result = []
|
|
138
141
|
|
|
@@ -157,6 +160,7 @@ module RobotLab
|
|
|
157
160
|
# System messages and tool-related messages are always pinned.
|
|
158
161
|
# Assistant messages with no text content (tool call dispatchers)
|
|
159
162
|
# are also pinned to avoid breaking tool_use/tool_result pairing.
|
|
163
|
+
# :reek:FeatureEnvy -- classifying by a message's own role and text; Message stays a plain value object.
|
|
160
164
|
def pinned_message?(msg)
|
|
161
165
|
role = msg.role
|
|
162
166
|
|
|
@@ -187,6 +191,7 @@ module RobotLab
|
|
|
187
191
|
#
|
|
188
192
|
# @param vectors [Array<Hash{Symbol => Float}>]
|
|
189
193
|
# @return [Hash{Symbol => Float}]
|
|
194
|
+
# :reek:NestedIterators -- 2-deep each over sparse vectors is the natural element-wise sum.
|
|
190
195
|
def mean_vector(vectors)
|
|
191
196
|
return {} if vectors.empty?
|
|
192
197
|
|
data/lib/robot_lab/hook.rb
CHANGED
|
@@ -4,9 +4,11 @@ module RobotLab
|
|
|
4
4
|
class HookContext
|
|
5
5
|
attr_reader :event, :metadata
|
|
6
6
|
|
|
7
|
+
# :reek:ControlParameter -- `metadata || ExtensionState.new` is a nil-safe default, not behavior selection.
|
|
7
8
|
def initialize(event:, metadata: nil)
|
|
8
9
|
@event = event.to_sym
|
|
9
10
|
@metadata = metadata || ExtensionState.new
|
|
11
|
+
@namespace = nil # no hook namespace active until with_namespace
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def ext(name)
|
|
@@ -46,6 +48,7 @@ module RobotLab
|
|
|
46
48
|
attr_reader :robot, :network, :task, :memory, :config
|
|
47
49
|
attr_accessor :request, :response, :error
|
|
48
50
|
|
|
51
|
+
# :reek:LongParameterList -- one keyword per run-context field; hooks read them all.
|
|
49
52
|
def initialize(robot:, request:, network: nil, task: nil, memory: nil, config: nil, response: nil, error: nil, **)
|
|
50
53
|
super(event: :run, **)
|
|
51
54
|
@robot = robot
|
|
@@ -104,6 +107,7 @@ module RobotLab
|
|
|
104
107
|
attr_reader :network, :task, :task_name, :robot, :memory, :config
|
|
105
108
|
attr_accessor :result, :error
|
|
106
109
|
|
|
110
|
+
# :reek:ControlParameter -- `robot || task.robot` is a fallback default, not behavior selection.
|
|
107
111
|
def initialize(task:, network: nil, robot: nil, memory: nil, config: nil, result: nil, error: nil, **)
|
|
108
112
|
super(event: :task, **)
|
|
109
113
|
@network = network
|
|
@@ -21,6 +21,7 @@ module RobotLab
|
|
|
21
21
|
# @param handler_class [Class] a RobotLab::Hook subclass
|
|
22
22
|
# @param context [Hash, nil] optional default values merged into ctx.local on each call
|
|
23
23
|
# @return [Registration]
|
|
24
|
+
# :reek:FeatureEnvy -- validating the handler_class argument before registering it is this method's job.
|
|
24
25
|
def on(handler_class, context: nil)
|
|
25
26
|
unless handler_class.is_a?(Class) && handler_class < RobotLab::Hook
|
|
26
27
|
raise ArgumentError, "#{handler_class.inspect} must be a RobotLab::Hook subclass"
|
data/lib/robot_lab/hooks.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RobotLab
|
|
4
|
+
# :reek:DataClump -- stateless module_function dispatchers; registries/per_run_hooks flow through every call by design.
|
|
4
5
|
module Hooks
|
|
5
6
|
module_function
|
|
6
7
|
|
|
8
|
+
# :reek:TooManyStatements -- the before/around/after/error hook lifecycle in one linear sequence.
|
|
7
9
|
def run(family, context, registries:, per_run_hooks: nil, &)
|
|
8
10
|
before = registrations(:"before_#{family}", registries, per_run_hooks)
|
|
9
11
|
around = registrations(:"around_#{family}", registries, per_run_hooks)
|
|
@@ -53,9 +55,10 @@ module RobotLab
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def call_registration(registration, hook_name, context, &)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
namespace = registration.namespace
|
|
59
|
+
context.with_namespace(namespace) do
|
|
60
|
+
if registration.context && namespace
|
|
61
|
+
context.ext(namespace).merge_defaults(registration.context)
|
|
59
62
|
end
|
|
60
63
|
registration.handler_class.call(hook_name, context, &)
|
|
61
64
|
end
|
data/lib/robot_lab/mcp/client.rb
CHANGED
|
@@ -13,6 +13,7 @@ module RobotLab
|
|
|
13
13
|
# tools = client.list_tools
|
|
14
14
|
# result = client.call_tool("createBranch", { project_id: "abc" })
|
|
15
15
|
#
|
|
16
|
+
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every public operation must check it.
|
|
16
17
|
class Client
|
|
17
18
|
# @!attribute [r] server
|
|
18
19
|
# @return [Server] the MCP server configuration
|
|
@@ -45,6 +46,7 @@ module RobotLab
|
|
|
45
46
|
#
|
|
46
47
|
# @return [self]
|
|
47
48
|
#
|
|
49
|
+
# :reek:TooManyStatements -- linear connect/register sequence with a best-effort rescue.
|
|
48
50
|
def connect
|
|
49
51
|
return self if @connected
|
|
50
52
|
|
|
@@ -204,8 +206,6 @@ module RobotLab
|
|
|
204
206
|
end
|
|
205
207
|
|
|
206
208
|
def parse_response(response)
|
|
207
|
-
return response[:result] if response.is_a?(Hash) && response[:result]
|
|
208
|
-
|
|
209
209
|
case response
|
|
210
210
|
when String
|
|
211
211
|
parsed = JSON.parse(response, symbolize_names: true)
|
|
@@ -104,15 +104,20 @@ module RobotLab
|
|
|
104
104
|
# @param timeout [Numeric] seconds before raising MCPError
|
|
105
105
|
# @return [Hash] parsed response
|
|
106
106
|
# @raise [MCPError] on timeout or connection error
|
|
107
|
+
# :reek:TooManyStatements -- register/write/wait/cleanup must stay in one method so ensure releases the queue.
|
|
108
|
+
# :reek:DuplicateMethodCall -- the repeated @mutex.synchronize/@clients[io] pairs are deliberately separate short critical
|
|
109
|
+
# sections; holding the lock across the blocking write/wait would deadlock the poll loop.
|
|
107
110
|
def send_request(client, message, timeout:)
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
transport = client.transport
|
|
112
|
+
io = transport.stdout
|
|
113
|
+
stdin = transport.stdin
|
|
114
|
+
queue = Thread::Queue.new
|
|
110
115
|
|
|
111
116
|
@mutex.synchronize { @clients[io][:queue] = queue }
|
|
112
117
|
|
|
113
118
|
begin
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
stdin.puts(message.to_json)
|
|
120
|
+
stdin.flush
|
|
116
121
|
rescue Errno::EPIPE, IOError => e
|
|
117
122
|
@mutex.synchronize { @clients[io][:queue] = nil }
|
|
118
123
|
raise MCPError.new("MCP connection lost: #{e.message}", retryable: true)
|
|
@@ -160,6 +165,7 @@ module RobotLab
|
|
|
160
165
|
end
|
|
161
166
|
end
|
|
162
167
|
|
|
168
|
+
# :reek:TooManyStatements -- read/parse/filter/route steps for each readable IO; each guard skips a non-response line.
|
|
163
169
|
def dispatch(readable_ios)
|
|
164
170
|
readable_ios.each do |io|
|
|
165
171
|
line = io.gets rescue nil
|
|
@@ -177,10 +183,12 @@ module RobotLab
|
|
|
177
183
|
end
|
|
178
184
|
|
|
179
185
|
def stdio_client?(client)
|
|
180
|
-
client.respond_to?(:transport)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
return false unless client.respond_to?(:transport)
|
|
187
|
+
|
|
188
|
+
transport = client.transport
|
|
189
|
+
transport.is_a?(Transports::Stdio) &&
|
|
190
|
+
transport.respond_to?(:stdout) &&
|
|
191
|
+
!transport.stdout.nil?
|
|
184
192
|
end
|
|
185
193
|
end
|
|
186
194
|
end
|
|
@@ -60,6 +60,7 @@ module RobotLab
|
|
|
60
60
|
# @param threshold [Float] minimum cosine score (default 0.05)
|
|
61
61
|
# @return [Array<Hash, MCP::Server>] matching servers, or +from+ as
|
|
62
62
|
# fallback when no match is found
|
|
63
|
+
# :reek:TooManyStatements -- linear guard/score/filter pipeline; each early return is a documented fallback.
|
|
63
64
|
def self.select(query, from:, threshold: DEFAULT_THRESHOLD)
|
|
64
65
|
return from if from.empty?
|
|
65
66
|
return from if query.to_s.strip.empty?
|
|
@@ -10,6 +10,8 @@ module RobotLab
|
|
|
10
10
|
# @example
|
|
11
11
|
# transport = SSE.new(url: "http://localhost:8080/sse")
|
|
12
12
|
#
|
|
13
|
+
# :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
|
|
14
|
+
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
|
|
13
15
|
class SSE < Base
|
|
14
16
|
# Creates a new SSE transport.
|
|
15
17
|
#
|
|
@@ -26,6 +28,7 @@ module RobotLab
|
|
|
26
28
|
#
|
|
27
29
|
# @return [self]
|
|
28
30
|
# @raise [MCPError] if async-http gem is not available
|
|
31
|
+
# :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
|
|
29
32
|
def connect
|
|
30
33
|
return self if @connected
|
|
31
34
|
|
|
@@ -19,6 +19,8 @@ module RobotLab
|
|
|
19
19
|
# timeout: 10
|
|
20
20
|
# )
|
|
21
21
|
#
|
|
22
|
+
# :reek:InstanceVariableAssumption -- @config and @timeout are assigned in Base#initialize (reek does not trace super).
|
|
23
|
+
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every IO operation must check it.
|
|
22
24
|
class Stdio < Base
|
|
23
25
|
# Creates a new Stdio transport.
|
|
24
26
|
#
|
|
@@ -41,6 +43,7 @@ module RobotLab
|
|
|
41
43
|
# @return [self]
|
|
42
44
|
# @raise [MCPError] if the server process cannot be started or does not
|
|
43
45
|
# respond to the MCP initialize handshake within the timeout period
|
|
46
|
+
# :reek:TooManyStatements -- linear spawn/verify/handshake sequence with per-failure-mode rescues.
|
|
44
47
|
def connect
|
|
45
48
|
return self if @connected
|
|
46
49
|
|
|
@@ -76,6 +79,7 @@ module RobotLab
|
|
|
76
79
|
# @param message [Hash] JSON-RPC message
|
|
77
80
|
# @return [Hash] the response
|
|
78
81
|
# @raise [MCPError] if not connected, no response, or timeout
|
|
82
|
+
# :reek:TooManyStatements -- write-then-read loop must stay inside the one Timeout block.
|
|
79
83
|
def send_request(message)
|
|
80
84
|
raise MCPError, "Not connected" unless @connected
|
|
81
85
|
|
|
@@ -148,6 +152,7 @@ module RobotLab
|
|
|
148
152
|
@stdin.flush
|
|
149
153
|
end
|
|
150
154
|
|
|
155
|
+
# :reek:TooManyStatements -- best-effort teardown; each handle is closed and nilled independently.
|
|
151
156
|
def cleanup_process
|
|
152
157
|
@connected = false
|
|
153
158
|
@stdin&.close rescue nil
|
|
@@ -13,6 +13,8 @@ module RobotLab
|
|
|
13
13
|
# session_id: "abc123"
|
|
14
14
|
# )
|
|
15
15
|
#
|
|
16
|
+
# :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
|
|
17
|
+
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
|
|
16
18
|
class StreamableHTTP < Base
|
|
17
19
|
# Creates a new StreamableHTTP transport.
|
|
18
20
|
#
|
|
@@ -31,6 +33,7 @@ module RobotLab
|
|
|
31
33
|
#
|
|
32
34
|
# @return [self]
|
|
33
35
|
# @raise [MCPError] if async-http gem is not available
|
|
36
|
+
# :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
|
|
34
37
|
def connect
|
|
35
38
|
return self if @connected
|
|
36
39
|
|
|
@@ -60,6 +63,7 @@ module RobotLab
|
|
|
60
63
|
# @param message [Hash] JSON-RPC message
|
|
61
64
|
# @return [Hash] the response
|
|
62
65
|
# @raise [MCPError] if not connected
|
|
66
|
+
# :reek:TooManyStatements -- header assembly and POST must stay inside the one Async block.
|
|
63
67
|
def send_request(message)
|
|
64
68
|
raise MCPError, "Not connected" unless @connected
|
|
65
69
|
|
|
@@ -112,9 +116,7 @@ module RobotLab
|
|
|
112
116
|
# Returns the session identifier.
|
|
113
117
|
#
|
|
114
118
|
# @return [String, nil] the session ID
|
|
115
|
-
|
|
116
|
-
@session_id
|
|
117
|
-
end
|
|
119
|
+
attr_reader :session_id
|
|
118
120
|
|
|
119
121
|
private
|
|
120
122
|
|
|
@@ -10,6 +10,8 @@ module RobotLab
|
|
|
10
10
|
# @example
|
|
11
11
|
# transport = WebSocket.new(url: "ws://localhost:8080")
|
|
12
12
|
#
|
|
13
|
+
# :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
|
|
14
|
+
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
|
|
13
15
|
class WebSocket < Base
|
|
14
16
|
# Creates a new WebSocket transport.
|
|
15
17
|
#
|
|
@@ -26,6 +28,7 @@ module RobotLab
|
|
|
26
28
|
#
|
|
27
29
|
# @return [self]
|
|
28
30
|
# @raise [MCPError] if async-websocket gem is not available
|
|
31
|
+
# :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
|
|
29
32
|
def connect
|
|
30
33
|
return self if @connected
|
|
31
34
|
|
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Optional: ruby_llm-semantic_cache has not shipped a ruby_llm 2.0-compatible
|
|
4
|
+
# release. When the gem is absent, Memory#cache returns nil and semantic
|
|
5
|
+
# caching is silently disabled.
|
|
6
|
+
begin
|
|
7
|
+
require "ruby_llm/semantic_cache"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# Semantic caching unavailable; Memory runs without it.
|
|
10
|
+
end
|
|
4
11
|
|
|
5
12
|
module RobotLab
|
|
6
13
|
# Raised when a blocking get times out
|
|
@@ -58,6 +65,9 @@ module RobotLab
|
|
|
58
65
|
# memory.results # => []
|
|
59
66
|
# memory.cache # => RubyLLM::SemanticCache instance
|
|
60
67
|
#
|
|
68
|
+
# :reek:TooManyMethods -- Memory is the deliberately broad shared-state facade
|
|
69
|
+
# (reserved keys, reactive get/subscribe, history); see CLAUDE.md.
|
|
70
|
+
# :reek:RepeatedConditional -- `@backend.key?(key)` is the check-under-mutex idiom; each site is a separate critical section.
|
|
61
71
|
class Memory
|
|
62
72
|
include Utils
|
|
63
73
|
|
|
@@ -89,6 +99,7 @@ module RobotLab
|
|
|
89
99
|
#
|
|
90
100
|
# @example Network-owned memory
|
|
91
101
|
# Memory.new(network_name: "support_pipeline")
|
|
102
|
+
# :reek:BooleanParameter -- enable_cache is a documented feature toggle in the public API.
|
|
92
103
|
def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true,
|
|
93
104
|
network_name: nil)
|
|
94
105
|
@backend = select_backend(backend)
|
|
@@ -102,7 +113,7 @@ module RobotLab
|
|
|
102
113
|
set_internal(:results, Array(results))
|
|
103
114
|
set_internal(:messages, Array(messages).map { |m| normalize_message(m) })
|
|
104
115
|
set_internal(:session_id, session_id)
|
|
105
|
-
set_internal(:cache, @enable_cache ?
|
|
116
|
+
set_internal(:cache, @enable_cache ? create_semantic_cache : nil)
|
|
106
117
|
|
|
107
118
|
# Data proxy for method-style access
|
|
108
119
|
@data = nil
|
|
@@ -144,6 +155,7 @@ module RobotLab
|
|
|
144
155
|
#
|
|
145
156
|
# @see #set
|
|
146
157
|
#
|
|
158
|
+
# :reek:TooManyStatements -- one case branch per reserved key; dispatch reads best as a single table.
|
|
147
159
|
def []=(key, value)
|
|
148
160
|
key = key.to_sym
|
|
149
161
|
|
|
@@ -210,9 +222,11 @@ module RobotLab
|
|
|
210
222
|
|
|
211
223
|
# Get the semantic cache module
|
|
212
224
|
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
# responses
|
|
225
|
+
# When the optional ruby_llm-semantic_cache gem is installed (and
|
|
226
|
+
# enable_cache is true), provides semantic similarity matching for LLM
|
|
227
|
+
# responses, reducing costs and latency by returning cached responses
|
|
228
|
+
# for semantically equivalent queries. Returns nil when the gem is
|
|
229
|
+
# absent or caching is disabled.
|
|
216
230
|
#
|
|
217
231
|
# @example Using the cache with fetch
|
|
218
232
|
# response = memory.cache.fetch("What is Ruby?") do
|
|
@@ -223,7 +237,7 @@ module RobotLab
|
|
|
223
237
|
# chat = memory.cache.wrap(RubyLLM.chat(model: "gpt-4"))
|
|
224
238
|
# chat.ask("What is Ruby?") # Cached on semantic similarity
|
|
225
239
|
#
|
|
226
|
-
# @return [RubyLLM::SemanticCache] the semantic cache module
|
|
240
|
+
# @return [RubyLLM::SemanticCache, nil] the semantic cache module, or nil when unavailable
|
|
227
241
|
#
|
|
228
242
|
def cache
|
|
229
243
|
get_internal(:cache)
|
|
@@ -292,6 +306,8 @@ module RobotLab
|
|
|
292
306
|
# memory.get(:sentiment, :entities, :keywords, wait: 60)
|
|
293
307
|
# # => { sentiment: {...}, entities: [...], keywords: [...] }
|
|
294
308
|
#
|
|
309
|
+
# :reek:BooleanParameter -- `wait` is public API: false, true (block forever) or a numeric timeout.
|
|
310
|
+
# :reek:FeatureEnvy -- normalizing this method's own varargs before dispatching on arity.
|
|
295
311
|
def get(*keys, wait: false)
|
|
296
312
|
keys = keys.flatten.map(&:to_sym)
|
|
297
313
|
|
|
@@ -370,6 +386,8 @@ module RobotLab
|
|
|
370
386
|
# @param subscription_id [Object] the subscription identifier from subscribe
|
|
371
387
|
# @return [Boolean] true if subscription was found and removed
|
|
372
388
|
#
|
|
389
|
+
# :reek:ControlParameter -- subscription_id is matched against stored ids, not used to select behavior.
|
|
390
|
+
# :reek:NestedIterators -- 2-deep scan of the per-key subscription lists is the natural shape.
|
|
373
391
|
def unsubscribe(subscription_id)
|
|
374
392
|
removed = false
|
|
375
393
|
|
|
@@ -576,6 +594,7 @@ module RobotLab
|
|
|
576
594
|
#
|
|
577
595
|
# @return [self]
|
|
578
596
|
#
|
|
597
|
+
# :reek:TooManyStatements -- reserved keys are re-seeded one by one inside a single mutex block.
|
|
579
598
|
def reset
|
|
580
599
|
cached = get_internal(:cache) # Preserve cache instance
|
|
581
600
|
@mutex.synchronize do
|
|
@@ -692,9 +711,10 @@ module RobotLab
|
|
|
692
711
|
end
|
|
693
712
|
|
|
694
713
|
def create_semantic_cache
|
|
695
|
-
RubyLLM::SemanticCache
|
|
714
|
+
defined?(RubyLLM::SemanticCache) ? RubyLLM::SemanticCache : nil
|
|
696
715
|
end
|
|
697
716
|
|
|
717
|
+
# :reek:ControlParameter -- factory method; the preference symbol is exactly what selects the backend.
|
|
698
718
|
def select_backend(preference)
|
|
699
719
|
case preference
|
|
700
720
|
when :hash
|
|
@@ -716,11 +736,11 @@ module RobotLab
|
|
|
716
736
|
{}
|
|
717
737
|
end
|
|
718
738
|
|
|
719
|
-
def redis_available?
|
|
739
|
+
def redis_available?(config = RobotLab.config)
|
|
720
740
|
return false unless defined?(Redis)
|
|
721
741
|
|
|
722
742
|
# Check if Redis is configured in RobotLab
|
|
723
|
-
redis_config =
|
|
743
|
+
redis_config = config.respond_to?(:redis) ? config.redis : nil
|
|
724
744
|
redis_config || ENV.fetch("REDIS_URL", nil)
|
|
725
745
|
end
|
|
726
746
|
|
|
@@ -766,6 +786,7 @@ module RobotLab
|
|
|
766
786
|
wait_for_key(key, timeout: timeout)
|
|
767
787
|
end
|
|
768
788
|
|
|
789
|
+
# :reek:TooManyStatements -- read-under-mutex then wait-for-missing; splitting would separate lock from wait logic.
|
|
769
790
|
def get_multiple(keys, wait:)
|
|
770
791
|
results = {}
|
|
771
792
|
missing = []
|
|
@@ -791,6 +812,7 @@ module RobotLab
|
|
|
791
812
|
results
|
|
792
813
|
end
|
|
793
814
|
|
|
815
|
+
# :reek:TooManyStatements -- double-check locking plus timeout cleanup must stay together for correctness.
|
|
794
816
|
def wait_for_key(key, timeout:)
|
|
795
817
|
waiter = Waiter.new
|
|
796
818
|
|
|
@@ -813,11 +835,13 @@ module RobotLab
|
|
|
813
835
|
result
|
|
814
836
|
end
|
|
815
837
|
|
|
838
|
+
# :reek:UncommunicativeVariableName -- single-char block vars are accepted style here (RuboCop allows them).
|
|
816
839
|
def wake_waiters(key, value)
|
|
817
840
|
waiters = @waiter_mutex.synchronize { @waiters.delete(key) || [] }
|
|
818
841
|
waiters.each { |w| w.signal(value) }
|
|
819
842
|
end
|
|
820
843
|
|
|
844
|
+
# :reek:TooManyStatements -- collect/build/coalesce steps share the change object and the scheduling flag.
|
|
821
845
|
def notify_subscribers_async(key, value, old_value)
|
|
822
846
|
# Collect all matching subscribers
|
|
823
847
|
callbacks = []
|
|
@@ -860,6 +884,8 @@ module RobotLab
|
|
|
860
884
|
# Drain all pending notification batches in a single fiber.
|
|
861
885
|
# Loops until the queue is empty, then resets the drainer flag.
|
|
862
886
|
# If new items arrive just before the flag resets, reschedules itself.
|
|
887
|
+
# :reek:TooManyStatements :reek:NestedIterators -- the drain loop and its ensure-reschedule race guard are one
|
|
888
|
+
# atomic unit; batch-of-callbacks iteration is inherently 2-deep.
|
|
863
889
|
def drain_notification_queue
|
|
864
890
|
loop do
|
|
865
891
|
batch = @notification_queue_mutex.synchronize do
|
|
@@ -908,8 +934,8 @@ module RobotLab
|
|
|
908
934
|
#
|
|
909
935
|
# @api private
|
|
910
936
|
class RedisBackend
|
|
911
|
-
def initialize
|
|
912
|
-
@redis = create_redis_connection
|
|
937
|
+
def initialize(config = RobotLab.config)
|
|
938
|
+
@redis = create_redis_connection(config)
|
|
913
939
|
@namespace = "robot_lab:memory:#{SecureRandom.uuid}"
|
|
914
940
|
end
|
|
915
941
|
|
|
@@ -945,8 +971,8 @@ module RobotLab
|
|
|
945
971
|
|
|
946
972
|
private
|
|
947
973
|
|
|
948
|
-
def create_redis_connection
|
|
949
|
-
redis_config =
|
|
974
|
+
def create_redis_connection(config = RobotLab.config)
|
|
975
|
+
redis_config = config.respond_to?(:redis) ? config.redis : nil
|
|
950
976
|
|
|
951
977
|
if redis_config.is_a?(Hash)
|
|
952
978
|
Redis.new(**redis_config)
|
|
@@ -43,6 +43,7 @@ module RobotLab
|
|
|
43
43
|
# @param timestamp [Time] when the change occurred (defaults to now)
|
|
44
44
|
# @param correlation_id [String, nil] optional correlation ID
|
|
45
45
|
#
|
|
46
|
+
# :reek:ControlParameter -- `timestamp || Time.now` is a nil-safe default, not behavior selection.
|
|
46
47
|
def initialize(key:, value:, previous: nil, writer: nil, network_name: nil, timestamp: nil, correlation_id: nil)
|
|
47
48
|
@key = key.to_sym
|
|
48
49
|
@value = value
|
data/lib/robot_lab/message.rb
CHANGED
|
@@ -178,6 +178,7 @@ module RobotLab
|
|
|
178
178
|
# @param id [String] the unique identifier for this tool call
|
|
179
179
|
# @param name [String] the name of the tool
|
|
180
180
|
# @param input [Hash, nil] the input arguments
|
|
181
|
+
# :reek:ControlParameter -- `input || {}` is a nil-safe default, not behavior selection.
|
|
181
182
|
def initialize(id:, name:, input:)
|
|
182
183
|
@id = id
|
|
183
184
|
@name = name
|
|
@@ -238,6 +239,7 @@ module RobotLab
|
|
|
238
239
|
# @param role [String, Symbol] the message role (usually assistant)
|
|
239
240
|
# @param tools [Array<ToolMessage, Hash>] the tool calls
|
|
240
241
|
# @param stop_reason [String, Symbol, nil] the stop reason (defaults to "tool")
|
|
242
|
+
# :reek:ControlParameter -- `stop_reason || "tool"` is a nil-safe default, not behavior selection.
|
|
241
243
|
def initialize(role:, tools:, stop_reason: nil)
|
|
242
244
|
@tools = normalize_tools(tools)
|
|
243
245
|
super(type: "tool_call", role: role, content: nil, stop_reason: stop_reason || "tool")
|
|
@@ -295,6 +297,7 @@ module RobotLab
|
|
|
295
297
|
# @param tool [ToolMessage, Hash] the tool call that was executed
|
|
296
298
|
# @param content [Hash] the result content (with :data or :error key)
|
|
297
299
|
# @param stop_reason [String, Symbol, nil] the stop reason (defaults to "tool")
|
|
300
|
+
# :reek:ControlParameter -- `stop_reason || "tool"` is a nil-safe default, not behavior selection.
|
|
298
301
|
def initialize(tool:, content:, stop_reason: nil)
|
|
299
302
|
@tool = normalize_tool(tool)
|
|
300
303
|
super(type: "tool_result", role: "tool_result", content: content, stop_reason: stop_reason || "tool")
|