robot_lab 0.2.7 → 0.2.8

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +4 -0
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +17 -1
  6. data/Rakefile +6 -111
  7. data/_typos.toml +21 -0
  8. data/docs/api/index.md +2 -2
  9. data/docs/api/skills.md +53 -149
  10. data/docs/api/support.md +1 -1
  11. data/docs/architecture/index.md +5 -4
  12. data/docs/getting-started/configuration.md +4 -1
  13. data/docs/guides/hooks.md +122 -0
  14. data/docs/guides/using-tools.md +10 -1
  15. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  16. data/lib/robot_lab/ask_user.rb +2 -0
  17. data/lib/robot_lab/bus_poller.rb +2 -0
  18. data/lib/robot_lab/capabilities.rb +4 -0
  19. data/lib/robot_lab/config.rb +8 -0
  20. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  21. data/lib/robot_lab/history_compressor.rb +5 -0
  22. data/lib/robot_lab/hook.rb +1 -0
  23. data/lib/robot_lab/hook_context.rb +4 -0
  24. data/lib/robot_lab/hook_registry.rb +1 -0
  25. data/lib/robot_lab/hooks.rb +6 -3
  26. data/lib/robot_lab/mcp/client.rb +2 -2
  27. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  28. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  29. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  30. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  31. data/lib/robot_lab/mcp/transports/streamable_http.rb +4 -0
  32. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  33. data/lib/robot_lab/memory.rb +23 -6
  34. data/lib/robot_lab/memory_change.rb +1 -0
  35. data/lib/robot_lab/message.rb +3 -0
  36. data/lib/robot_lab/names.rb +2 -4
  37. data/lib/robot_lab/network.rb +13 -6
  38. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  39. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  40. data/lib/robot_lab/robot/history_search.rb +2 -0
  41. data/lib/robot_lab/robot/hooking.rb +3 -0
  42. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  43. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  44. data/lib/robot_lab/robot.rb +58 -17
  45. data/lib/robot_lab/robot_result.rb +3 -0
  46. data/lib/robot_lab/run_config.rb +5 -0
  47. data/lib/robot_lab/script_tool.rb +20 -39
  48. data/lib/robot_lab/state_proxy.rb +1 -0
  49. data/lib/robot_lab/streaming/context.rb +1 -0
  50. data/lib/robot_lab/streaming/events.rb +1 -0
  51. data/lib/robot_lab/task.rb +2 -0
  52. data/lib/robot_lab/tool.rb +4 -0
  53. data/lib/robot_lab/user_message.rb +1 -0
  54. data/lib/robot_lab/utils.rb +2 -0
  55. data/lib/robot_lab/version.rb +1 -1
  56. data/lib/robot_lab/waiter.rb +3 -0
  57. data/lib/robot_lab.rb +6 -3
  58. metadata +7 -7
  59. data/lib/robot_lab/sandbox/null.rb +0 -13
  60. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  61. data/lib/robot_lab/sandbox.rb +0 -52
data/docs/guides/hooks.md CHANGED
@@ -1076,6 +1076,128 @@ RobotLab.on(RedactionHook)
1076
1076
 
1077
1077
  ---
1078
1078
 
1079
+ ## Extension Registration
1080
+
1081
+ Hooks inject *behavior* ("do this on these events"). A separate, lighter
1082
+ mechanism — **extension registration** — answers a different question: *"is
1083
+ optional feature X loaded?"* This is how core lights up conditional behavior
1084
+ without depending on, or probing for, the gem that provides it. The two
1085
+ mechanisms are orthogonal but commonly used together: a gem registers itself
1086
+ (discovery), then wires behavior in via a hook (injection).
1087
+
1088
+ ### The API
1089
+
1090
+ ```ruby
1091
+ RobotLab.register_extension(:audit, RobotLab::Audit) # gem announces itself
1092
+ RobotLab.extension_loaded?(:audit) # => true/false — core's guard
1093
+ RobotLab.extension(:audit) # => RobotLab::Audit
1094
+ RobotLab.loaded_extensions # => [:audit, :ractor, ...]
1095
+ ```
1096
+
1097
+ It is deliberately tiny: a `Hash` and four methods. The registered value can be
1098
+ the extension's primary module, or just a sentinel when the gem only needs a
1099
+ presence flag:
1100
+
1101
+ ```ruby
1102
+ RobotLab.register_extension(:ractor, :ractor_extension_loaded)
1103
+ ```
1104
+
1105
+ ### How an Extension Announces Itself
1106
+
1107
+ At the bottom of the gem's entry file, after everything it provides is
1108
+ defined, guarded so the gem can also load standalone or against an older core:
1109
+
1110
+ ```ruby
1111
+ # robot_lab-audit/lib/robot_lab/audit.rb (tail)
1112
+ if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
1113
+ RobotLab.register_extension(:audit, RobotLab::Audit)
1114
+ end
1115
+ ```
1116
+
1117
+ The same one-liner appears verbatim in `robot_lab-document_store`,
1118
+ `robot_lab-durable`, `robot_lab-discovery`, etc. Registration last, guarded
1119
+ always.
1120
+
1121
+ ### How Core Consumes It
1122
+
1123
+ Core guards optional behavior with `extension_loaded?` instead of scattered
1124
+ `defined?` / `respond_to?` probes — one named question, asked in one style:
1125
+
1126
+ ```ruby
1127
+ # tool.rb — only use the Ractor pool when the gem is present
1128
+ self.class.ractor_safe? && !self.class.name.nil? && RobotLab.extension_loaded?(:ractor)
1129
+
1130
+ # memory.rb — semantic features require the document_store gem
1131
+ unless RobotLab.extension_loaded?(:document_store)
1132
+
1133
+ end
1134
+ ```
1135
+
1136
+ When a guarded feature is *requested* without its extension, core raises a
1137
+ helpful error naming the exact gem to add — not a cryptic `NoMethodError`:
1138
+
1139
+ ```ruby
1140
+ # network.rb — parallel_mode: :ractor needs the gem
1141
+ def run_with_ractor_scheduler(run_context)
1142
+ unless RobotLab.extension_loaded?(:ractor)
1143
+ raise RobotLab::DependencyError,
1144
+ "parallel_mode: :ractor requires the robot_lab-ractor gem. " \
1145
+ "Add `gem 'robot_lab-ractor'` to your Gemfile."
1146
+ end
1147
+
1148
+ end
1149
+ ```
1150
+
1151
+ ### Version-Skew Tolerance
1152
+
1153
+ `robot_lab-a2a` ships a fallback so it works even against a core too old to
1154
+ provide the registry API: if `register_extension` isn't defined, the gem
1155
+ **defines it** (plus `extension_loaded?`/`extension`) over its own private
1156
+ `@_extensions` hash, then registers itself:
1157
+
1158
+ ```ruby
1159
+ module RobotLab
1160
+ @_extensions = {} unless instance_variable_defined?(:@_extensions)
1161
+
1162
+ class << self
1163
+ unless method_defined?(:register_extension) || respond_to?(:register_extension)
1164
+ def register_extension(name, mod) = @_extensions[name.to_sym] = mod
1165
+ end
1166
+ unless method_defined?(:extension_loaded?) || respond_to?(:extension_loaded?)
1167
+ def extension_loaded?(name) = @_extensions.key?(name.to_sym)
1168
+ end
1169
+ # … extension(name) likewise …
1170
+ end
1171
+ end
1172
+
1173
+ RobotLab.register_extension(:a2a, RobotLab::A2A)
1174
+ ```
1175
+
1176
+ The gem never assumes the core's age; it provides the contract if it must.
1177
+
1178
+ ### How It Composes with Hooks
1179
+
1180
+ - `register_extension(:audit, RobotLab::Audit)` — *"I exist"* (discovery).
1181
+ - Inside `Audit.enable(db_path:)` → `RobotLab.on(Hook)` — *"do this on these
1182
+ events"* (behavior).
1183
+
1184
+ Two common shapes:
1185
+
1186
+ - **Auto-wire at load** — register the extension *and* `RobotLab.on(SomeHook)`
1187
+ at require time (always-on features).
1188
+ - **Opt-in enable** — register the extension at load, but expose
1189
+ `enable!`/`enable(...)` that registers the hook on demand
1190
+ (`Audit.enable`, `Narrator.enable!`). Lets the user choose scope/config.
1191
+
1192
+ > [!TIP]
1193
+ > Design decisions worth stealing: a named capability registry beats
1194
+ > duck-typing; core never `require`s an extension — the extension `require`s
1195
+ > core and announces itself; a sentinel value works when you only need a
1196
+ > presence flag; and using a guarded feature without its gem should raise a
1197
+ > helpful error naming the exact gem to add.
1198
+
1199
+ ---
1200
+
1079
1201
  ## See Also
1080
1202
 
1081
1203
  - [examples/35_hooks.rb](https://github.com/MadBomber/robot_lab/blob/main/examples/35_hooks.rb) — full demo with xyzzy extension, perf timer, LLM response cache, and tracer hooks
@@ -335,13 +335,22 @@ trust: external # or "core" for trusted, always-unconfined skills
335
335
  ---
336
336
  ```
337
337
 
338
- Sandboxing itself is **opt-in and off by default** — see the [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section). When disabled, scripts run exactly as they always have, unconfined. When enabled:
338
+ `Capabilities` (the declaration above) lives in core, but core itself has **no sandboxing
339
+ behavior and no built-in limitations** — a script always runs unconfined via a plain
340
+ `Open3.capture2e`, with no timeout, unless the optional
341
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem is required.
342
+ Requiring it installs `RobotLab::Sandbox::Executor` as `RobotLab::ScriptTool.executor`;
343
+ confinement still stays **opt-in and off by default** from there — see the
344
+ [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section).
345
+ When enabled:
339
346
 
340
347
  - The global `sandbox:` config is a **ceiling** (`fs_read`, `fs_write`, `network`, `timeout`); each skill's front matter is its **declared** request. The script actually runs under the **intersection** of the two — a path outside the ceiling's roots is dropped even if the skill declares it, `network` requires both sides to allow it, and `timeout` is the smaller of the two.
341
348
  - On macOS, confinement is enforced with a generated `sandbox-exec` (Seatbelt) profile: deny-by-default, with narrow allowances for the interpreter to boot, the granted read/write paths, and (optionally) the network. Notably, `$HOME` is never implicitly readable — SSH keys and cloud credentials stay out of reach unless a path under `$HOME` is explicitly granted.
342
349
  - Off macOS, or for any skill declaring `trust: core`, sandboxing is a passthrough — confinement is currently macOS-only and is always skipped for trusted "core" skills regardless of platform.
343
350
  - A script that runs past its `timeout` is killed (its whole process group) and reported back to the LLM as a timed-out error rather than hanging the turn.
344
351
 
352
+ Full API reference: [robot_lab-sandbox](https://github.com/MadBomber/robot_lab-sandbox).
353
+
345
354
  ## Parameter Types
346
355
 
347
356
  Define parameters on `RubyLLM::Tool` subclasses using `param`:
@@ -52,6 +52,7 @@ module RobotLab
52
52
  @mutex.synchronize { load_skills! unless @loaded }
53
53
  end
54
54
 
55
+ # :reek:TooManyStatements -- linear directory scan; each guard clause skips a non-skill entry.
55
56
  def load_skills!
56
57
  @loaded = true
57
58
  return unless @skills_root.directory?
@@ -37,6 +37,8 @@ module RobotLab
37
37
  param :choices, type: "array", desc: "Optional list of choices to present", required: false
38
38
  param :default, type: "string", desc: "Default value if user presses Enter", required: false
39
39
 
40
+ # :reek:FeatureEnvy -- rendering and resolving the caller-supplied choices list is this tool's whole job.
41
+ # :reek:TooManyStatements -- linear prompt/read/resolve terminal interaction.
40
42
  def execute(question:, choices: nil, default: nil)
41
43
  out = output_io
42
44
  label = robot&.name || "Robot"
@@ -67,6 +67,7 @@ module RobotLab
67
67
  # @param group [Symbol] poller group label (informational only)
68
68
  # @return [void]
69
69
  #
70
+ # :reek:TooManyStatements -- the queue-or-run decision must stay inside one mutex critical section.
70
71
  def enqueue(robot:, delivery:, group: :default)
71
72
  should_process = @mutex.synchronize do
72
73
  name = robot.name
@@ -124,6 +125,7 @@ module RobotLab
124
125
  RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
125
126
  end
126
127
 
128
+ # :reek:TooManyStatements -- pop-under-mutex then process-outside-mutex loop; splitting it would separate the lock from its release.
127
129
  def drain_queued_deliveries(robot)
128
130
  loop do
129
131
  next_delivery = @mutex.synchronize do
@@ -15,6 +15,8 @@ module RobotLab
15
15
 
16
16
  attr_reader :fs_read, :fs_write, :network, :timeout, :trust
17
17
 
18
+ # :reek:BooleanParameter -- `network` is a declared capability value (part of the data model), not a mode switch.
19
+ # :reek:ControlParameter -- `network ? true : false` is boolean coercion of untrusted front-matter input, not behavior selection.
18
20
  def initialize(fs_read: [], fs_write: [], network: false, timeout: DEFAULT_TIMEOUT, trust: "external")
19
21
  @fs_read = Array(fs_read).map(&:to_s)
20
22
  @fs_write = Array(fs_write).map(&:to_s)
@@ -24,6 +26,7 @@ module RobotLab
24
26
  end
25
27
 
26
28
  # Build from a SKILL.md front matter hash (string or symbol keys).
29
+ # :reek:ControlParameter -- `front_matter || {}` is a nil-safe default, not control coupling.
27
30
  def self.from_front_matter(front_matter)
28
31
  fm = front_matter || {}
29
32
  new(
@@ -72,6 +75,7 @@ module RobotLab
72
75
 
73
76
  private
74
77
 
78
+ # :reek:NestedIterators -- 2-deep select/any? over two small path lists is idiomatic Ruby.
75
79
  def clamp_paths(requested, allowed)
76
80
  roots = expand(allowed)
77
81
  expand(requested).select { |p| roots.any? { |r| p == r || p.start_with?("#{r}/") } }
@@ -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
 
@@ -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