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.
- checksums.yaml +4 -4
- data/.envrc +4 -0
- data/.loki +5 -0
- data/Archspec.rb +44 -0
- data/CHANGELOG.md +17 -1
- 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/index.md +5 -4
- data/docs/getting-started/configuration.md +4 -1
- data/docs/guides/hooks.md +122 -0
- data/docs/guides/using-tools.md +10 -1
- data/lib/robot_lab/agent_skill_catalog.rb +1 -0
- data/lib/robot_lab/ask_user.rb +2 -0
- data/lib/robot_lab/bus_poller.rb +2 -0
- data/lib/robot_lab/capabilities.rb +4 -0
- data/lib/robot_lab/config.rb +8 -0
- 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 +4 -0
- data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
- data/lib/robot_lab/memory.rb +23 -6
- 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 +3 -0
- data/lib/robot_lab/robot/mcp_management.rb +11 -3
- data/lib/robot_lab/robot/template_rendering.rb +13 -4
- data/lib/robot_lab/robot.rb +58 -17
- data/lib/robot_lab/robot_result.rb +3 -0
- data/lib/robot_lab/run_config.rb +5 -0
- 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 +4 -0
- 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 +6 -3
- metadata +7 -7
- 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
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
|
data/docs/guides/using-tools.md
CHANGED
|
@@ -335,13 +335,22 @@ trust: external # or "core" for trusted, always-unconfined skills
|
|
|
335
335
|
---
|
|
336
336
|
```
|
|
337
337
|
|
|
338
|
-
|
|
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`:
|
data/lib/robot_lab/ask_user.rb
CHANGED
|
@@ -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"
|
data/lib/robot_lab/bus_poller.rb
CHANGED
|
@@ -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}/") } }
|
data/lib/robot_lab/config.rb
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|