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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -2
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +70 -1
  6. data/CLAUDE.md +4 -2
  7. data/README.md +15 -17
  8. data/Rakefile +6 -111
  9. data/_typos.toml +21 -0
  10. data/docs/api/index.md +2 -2
  11. data/docs/api/skills.md +53 -149
  12. data/docs/api/support.md +1 -1
  13. data/docs/architecture/core-concepts.md +1 -1
  14. data/docs/architecture/index.md +5 -4
  15. data/docs/architecture/state-management.md +4 -4
  16. data/docs/concepts.md +1 -1
  17. data/docs/getting-started/configuration.md +4 -1
  18. data/docs/getting-started/installation.md +1 -4
  19. data/docs/guides/hooks.md +122 -0
  20. data/docs/guides/memory.md +2 -2
  21. data/docs/guides/using-tools.md +10 -1
  22. data/examples/.envrc +2 -0
  23. data/examples/02_tools.rb +8 -8
  24. data/examples/03_network.rb +1 -1
  25. data/examples/04_mcp.rb +7 -5
  26. data/examples/08_llm_config.rb +5 -5
  27. data/examples/09_chaining.rb +3 -3
  28. data/examples/14_rusty_circuit/comic.rb +8 -8
  29. data/examples/14_rusty_circuit/scout.rb +4 -4
  30. data/examples/15_memory_network_and_bus/README.md +66 -0
  31. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  32. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  33. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  34. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  35. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  36. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  37. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  38. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  39. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  40. data/examples/16_writers_room/tools.rb +14 -14
  41. data/examples/19_token_tracking.rb +2 -2
  42. data/examples/20_circuit_breaker.rb +3 -3
  43. data/examples/22_context_compression.rb +1 -1
  44. data/examples/27_incident_response/README.md +65 -0
  45. data/examples/28_mcp_discovery.rb +2 -2
  46. data/examples/29_ractor_tools.rb +4 -4
  47. data/examples/30_ractor_network.rb +2 -2
  48. data/examples/33_stock_predictor.rb +8 -8
  49. data/examples/35_hooks.rb +3 -3
  50. data/examples/README.md +17 -0
  51. data/examples/common.rb +55 -23
  52. data/examples/run_all.rb +60 -0
  53. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  54. data/lib/robot_lab/ask_user.rb +5 -3
  55. data/lib/robot_lab/bus_poller.rb +2 -0
  56. data/lib/robot_lab/capabilities.rb +4 -0
  57. data/lib/robot_lab/config/defaults.yml +5 -5
  58. data/lib/robot_lab/config.rb +10 -2
  59. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  60. data/lib/robot_lab/history_compressor.rb +5 -0
  61. data/lib/robot_lab/hook.rb +1 -0
  62. data/lib/robot_lab/hook_context.rb +4 -0
  63. data/lib/robot_lab/hook_registry.rb +1 -0
  64. data/lib/robot_lab/hooks.rb +6 -3
  65. data/lib/robot_lab/mcp/client.rb +2 -2
  66. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  67. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  68. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  69. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  70. data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
  71. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  72. data/lib/robot_lab/memory.rb +39 -13
  73. data/lib/robot_lab/memory_change.rb +1 -0
  74. data/lib/robot_lab/message.rb +3 -0
  75. data/lib/robot_lab/names.rb +2 -4
  76. data/lib/robot_lab/network.rb +13 -6
  77. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  78. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  79. data/lib/robot_lab/robot/history_search.rb +2 -0
  80. data/lib/robot_lab/robot/hooking.rb +29 -0
  81. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  82. data/lib/robot_lab/robot/result_building.rb +119 -0
  83. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  84. data/lib/robot_lab/robot.rb +88 -130
  85. data/lib/robot_lab/robot_result.rb +3 -0
  86. data/lib/robot_lab/run_config.rb +57 -20
  87. data/lib/robot_lab/script_tool.rb +20 -39
  88. data/lib/robot_lab/state_proxy.rb +1 -0
  89. data/lib/robot_lab/streaming/context.rb +1 -0
  90. data/lib/robot_lab/streaming/events.rb +1 -0
  91. data/lib/robot_lab/task.rb +2 -0
  92. data/lib/robot_lab/tool.rb +11 -12
  93. data/lib/robot_lab/user_message.rb +1 -0
  94. data/lib/robot_lab/utils.rb +2 -0
  95. data/lib/robot_lab/version.rb +1 -1
  96. data/lib/robot_lab/waiter.rb +3 -0
  97. data/lib/robot_lab.rb +9 -6
  98. metadata +23 -32
  99. data/lib/robot_lab/sandbox/null.rb +0 -13
  100. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  101. 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
- assume_exists: null # assume the model exists primarily for local providers
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
- with_params:
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-3-haiku-20240307
120
+ model: claude-haiku-4-5
121
121
  request_timeout: 30
122
122
  max_retries: 1
123
123
  log_level: :warn
@@ -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
- return false if seq.length < @threshold
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, seq.length / @threshold].min
47
+ max_period = [MAX_PERIOD, length / @threshold].min
46
48
  (2..max_period).each do |period|
47
49
  window = @threshold * period
48
- next if seq.length < window
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
 
@@ -31,6 +31,7 @@ module RobotLab
31
31
  # robot.on(AuditHook)
32
32
  # network.on(AuditHook)
33
33
  #
34
+ # :reek:InstanceVariableAssumption -- @namespace is a class-level ivar, initialized here and reset in .inherited; there is no #initialize.
34
35
  class Hook
35
36
  @namespace = nil
36
37
 
@@ -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"
@@ -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
- context.with_namespace(registration.namespace) do
57
- if registration.context && registration.namespace
58
- context.ext(registration.namespace).merge_defaults(registration.context)
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
@@ -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
- io = client.transport.stdout
109
- queue = Thread::Queue.new
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
- client.transport.stdin.puts(message.to_json)
115
- client.transport.stdin.flush
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
- client.transport.is_a?(Transports::Stdio) &&
182
- client.transport.respond_to?(:stdout) &&
183
- !client.transport.stdout.nil?
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
- def session_id
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
 
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ruby_llm/semantic_cache"
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 ? RubyLLM::SemanticCache : nil)
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
- # The cache is always active and provides semantic similarity matching
214
- # for LLM responses, reducing costs and latency by returning cached
215
- # responses for semantically equivalent queries.
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 = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
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 = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
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
@@ -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")