robot_lab 0.1.0 → 0.2.6

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Classification surface for "should this error be retried?" — so hosts
5
+ # (ActiveJob retry_on lists, robot_lab-to's takeover loop, custom retry
6
+ # wrappers) don't reimplement the same case statement against RobotLab's
7
+ # error hierarchy.
8
+ #
9
+ # - +InferenceError+ (transient LLM/API failures) is always retryable,
10
+ # except its more specific subclass +ToolLoopError+ (a circuit breaker
11
+ # tripped by a repeating tool-call pattern — retrying immediately would
12
+ # just re-trigger the same loop).
13
+ # - +MCPError+ and +ToolError+ are opt-in: retryable only when raised
14
+ # with +retryable: true+, since some instances are transient (a
15
+ # connection drop, a timeout) and some are not (an explicit rejection
16
+ # from the server or tool).
17
+ # - Everything else (+ConfigurationError+, +ToolNotFoundError+,
18
+ # +DependencyError+, +RactorBoundaryError+, +BusError+, and any
19
+ # non-RobotLab error) is never retryable.
20
+ module Errors
21
+ # @param error [Exception, nil]
22
+ # @return [Boolean] true when the host should retry the operation that raised +error+
23
+ def self.retryable?(error)
24
+ return false if error.nil? || error.is_a?(RobotLab::ToolLoopError)
25
+
26
+ case error
27
+ when RobotLab::MCPError, RobotLab::ToolError
28
+ error.respond_to?(:retryable) && error.retryable == true
29
+ when RobotLab::InferenceError
30
+ true
31
+ else
32
+ false
33
+ end
34
+ end
35
+
36
+ # Always-retryable error classes, for explicit ActiveJob-style
37
+ # +retry_on+ allow-lists. Excludes +MCPError+/+ToolError+ because
38
+ # their retryability is per-raise (via +retryable:+), not per-class.
39
+ #
40
+ # @return [Array<Class>]
41
+ def self.retryable_classes
42
+ [RobotLab::InferenceError].freeze
43
+ end
44
+ end
45
+ end
@@ -124,13 +124,11 @@ module RobotLab
124
124
 
125
125
  if score >= @keep_threshold
126
126
  :keep
127
- elsif score < @drop_threshold
127
+ elsif score < @drop_threshold || !@summarizer
128
128
  :drop
129
- elsif @summarizer
129
+ else
130
130
  summary = @summarizer.call(text).to_s.strip
131
131
  summary.empty? ? :drop : summary
132
- else
133
- :drop
134
132
  end
135
133
  end
136
134
 
@@ -141,10 +139,7 @@ module RobotLab
141
139
  @messages.each_with_index do |msg, idx|
142
140
  action = actions[idx]
143
141
 
144
- if action.nil?
145
- # Pinned or recent: always include
146
- result << msg
147
- elsif action == :keep
142
+ if action.nil? || action == :keep
148
143
  result << msg
149
144
  elsif action == :drop
150
145
  # Omit entirely
@@ -166,7 +161,7 @@ module RobotLab
166
161
  role = msg.role
167
162
 
168
163
  return true if role == :system
169
- return true if role == :tool || role == :tool_result
164
+ return true if %i[tool tool_result].include?(role)
170
165
 
171
166
  # Assistant tool-call dispatcher: content is nil or blank
172
167
  if role == :assistant
@@ -185,7 +180,6 @@ module RobotLab
185
180
  case content
186
181
  when String then content
187
182
  when Array then content.filter_map { |p| p[:text] || p["text"] }.join(" ")
188
- else nil
189
183
  end
190
184
  end
191
185
 
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Base class for hook handlers.
5
+ #
6
+ # Subclasses implement class methods for each hook they handle.
7
+ # Unimplemented hooks are silently skipped; around hooks passthrough
8
+ # automatically so the chain never breaks.
9
+ #
10
+ # The namespace defaults to the snake_case form of the last segment of the
11
+ # class name (e.g. TokenLogger -> :token_logger). Override with
12
+ # `self.namespace = :custom` when needed.
13
+ #
14
+ # @example
15
+ # class AuditHook < RobotLab::Hook
16
+ # def self.before_run(ctx)
17
+ # ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ # end
19
+ #
20
+ # def self.after_run(ctx)
21
+ # elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at
22
+ # puts "[#{ctx.robot.name}] #{(elapsed * 1000).round(1)}ms"
23
+ # end
24
+ #
25
+ # def self.on_error(ctx)
26
+ # puts "[#{ctx.robot.name}] ERROR: #{ctx.error.message}"
27
+ # end
28
+ # end
29
+ #
30
+ # RobotLab.on(AuditHook)
31
+ # robot.on(AuditHook)
32
+ # network.on(AuditHook)
33
+ #
34
+ class Hook
35
+ @namespace = nil
36
+
37
+ class << self
38
+ attr_writer :namespace
39
+
40
+ # Returns the hook's namespace symbol, derived from the class name by
41
+ # default. Returns nil for the base Hook class itself.
42
+ #
43
+ # @return [Symbol, nil]
44
+ def namespace
45
+ return @namespace if @namespace
46
+ return nil if self == Hook
47
+
48
+ name.split('::').last
49
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
50
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
51
+ .downcase
52
+ .to_sym
53
+ end
54
+
55
+ # Dispatch a single hook to this handler.
56
+ #
57
+ # If the handler implements the method it is called. For around hooks that
58
+ # are not implemented the block is called directly (passthrough). Non-around
59
+ # hooks that are not implemented are silent no-ops.
60
+ #
61
+ # @param hook_name [Symbol] e.g. :before_run, :around_tool_call
62
+ # @param context [HookContext] the hook context object
63
+ # @yield for around hooks — the next link in the chain
64
+ def call(hook_name, context, &block)
65
+ if singleton_class.public_method_defined?(hook_name)
66
+ block ? public_send(hook_name, context, &block)
67
+ : public_send(hook_name, context)
68
+ elsif block
69
+ block.call
70
+ end
71
+ end
72
+
73
+ def inherited(subclass)
74
+ super
75
+ subclass.instance_variable_set(:@namespace, nil)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class HookContext
5
+ attr_reader :event, :metadata
6
+
7
+ def initialize(event:, metadata: nil)
8
+ @event = event.to_sym
9
+ @metadata = metadata || ExtensionState.new
10
+ end
11
+
12
+ def ext(name)
13
+ metadata.ext(name)
14
+ end
15
+
16
+ def local
17
+ raise ArgumentError, "No hook namespace active" unless @namespace
18
+
19
+ ext(@namespace)
20
+ end
21
+
22
+ def with_namespace(namespace)
23
+ previous = @namespace
24
+ @namespace = namespace&.to_sym
25
+ yield self
26
+ ensure
27
+ @namespace = previous
28
+ end
29
+
30
+ def to_h
31
+ public_snapshot.merge(metadata: metadata.to_h)
32
+ end
33
+
34
+ private
35
+
36
+ def public_snapshot
37
+ public_methods(false)
38
+ .grep_v(/=\z/)
39
+ .reject { |name| %i[to_h local].include?(name) }
40
+ .sort
41
+ .to_h { |name| [name, public_send(name)] }
42
+ end
43
+ end
44
+
45
+ class RunHookContext < HookContext
46
+ attr_reader :robot, :network, :task, :memory, :config
47
+ attr_accessor :request, :response, :error
48
+
49
+ def initialize(robot:, request:, network: nil, task: nil, memory: nil, config: nil, response: nil, error: nil, **)
50
+ super(event: :run, **)
51
+ @robot = robot
52
+ @network = network
53
+ @task = task
54
+ @memory = memory
55
+ @config = config
56
+ @request = request
57
+ @response = response
58
+ @error = error
59
+ end
60
+ end
61
+
62
+ class LlmGenerationHookContext < RunHookContext
63
+ attr_reader :iteration
64
+ attr_accessor :generation_response
65
+
66
+ def initialize(iteration: 0, generation_response: nil, **)
67
+ super(**)
68
+ @iteration = iteration
69
+ @generation_response = generation_response
70
+ end
71
+ end
72
+
73
+ class ToolCallHookContext < HookContext
74
+ attr_reader :tool, :tool_name, :tool_args, :robot
75
+ attr_accessor :tool_result, :tool_error
76
+
77
+ def initialize(tool:, tool_args:, robot: nil, tool_result: nil, tool_error: nil, **)
78
+ super(event: :tool_call, **)
79
+ @tool = tool
80
+ @tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
81
+ @tool_args = tool_args
82
+ @robot = robot
83
+ @tool_result = tool_result
84
+ @tool_error = tool_error
85
+ end
86
+ end
87
+
88
+ class NetworkRunHookContext < HookContext
89
+ attr_reader :network, :memory, :config
90
+ attr_accessor :context, :result, :error
91
+
92
+ def initialize(network:, context:, memory: nil, config: nil, result: nil, error: nil, **)
93
+ super(event: :network_run, **)
94
+ @network = network
95
+ @context = context
96
+ @memory = memory
97
+ @config = config
98
+ @result = result
99
+ @error = error
100
+ end
101
+ end
102
+
103
+ class TaskHookContext < HookContext
104
+ attr_reader :network, :task, :task_name, :robot, :memory, :config
105
+ attr_accessor :result, :error
106
+
107
+ def initialize(task:, network: nil, robot: nil, memory: nil, config: nil, result: nil, error: nil, **)
108
+ super(event: :task, **)
109
+ @network = network
110
+ @task = task
111
+ @task_name = task.name
112
+ @robot = robot || task.robot
113
+ @memory = memory
114
+ @config = config
115
+ @result = result
116
+ @error = error
117
+ end
118
+ end
119
+
120
+ class LearnHookContext < HookContext
121
+ attr_reader :robot, :text, :learnings_before
122
+ attr_accessor :stored, :error
123
+
124
+ def initialize(robot:, text:, learnings_before:, **)
125
+ super(event: :learn, **)
126
+ @robot = robot
127
+ @text = text
128
+ @learnings_before = learnings_before.freeze
129
+ @stored = false
130
+ end
131
+ end
132
+
133
+ class CompactionHookContext < HookContext
134
+ attr_reader :robot, :messages_before, :config, :strategy
135
+ attr_accessor :compacted_messages, :error
136
+
137
+ def initialize(robot:, messages_before:, config:, strategy:, **)
138
+ super(event: :compaction, **)
139
+ @robot = robot
140
+ @messages_before = messages_before.freeze
141
+ @config = config
142
+ @strategy = strategy
143
+ @compacted_messages = nil
144
+ end
145
+
146
+ # True once an on_compaction handler has supplied a replacement message set.
147
+ def handled?
148
+ !@compacted_messages.nil?
149
+ end
150
+ end
151
+
152
+ class ExtensionState
153
+ def initialize
154
+ @states = Hash.new { |h, key| h[key] = DotState.new }
155
+ end
156
+
157
+ def ext(name)
158
+ @states[name.to_sym]
159
+ end
160
+
161
+ def to_h
162
+ @states.transform_values(&:to_h)
163
+ end
164
+ end
165
+
166
+ class DotState
167
+ def initialize
168
+ @data = {}
169
+ end
170
+
171
+ def merge_defaults(hash)
172
+ hash.each { |k, v| @data[k.to_sym] = v unless @data.key?(k.to_sym) }
173
+ self
174
+ end
175
+
176
+ def method_missing(name, *args)
177
+ key = name.to_s.delete_suffix('=').to_sym
178
+
179
+ if name.to_s.end_with?('=')
180
+ @data[key] = args.first
181
+ else
182
+ @data[key]
183
+ end
184
+ end
185
+
186
+ def respond_to_missing?(*)
187
+ true
188
+ end
189
+
190
+ def to_h
191
+ @data.dup
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class HookRegistry
5
+ Registration = Data.define(:handler_class, :context) do
6
+ def initialize(handler_class:, context: nil)
7
+ super
8
+ end
9
+
10
+ def namespace
11
+ handler_class.namespace
12
+ end
13
+ end
14
+
15
+ def initialize
16
+ @registrations = []
17
+ end
18
+
19
+ # Register a hook handler class.
20
+ #
21
+ # @param handler_class [Class] a RobotLab::Hook subclass
22
+ # @param context [Hash, nil] optional default values merged into ctx.local on each call
23
+ # @return [Registration]
24
+ def on(handler_class, context: nil)
25
+ unless handler_class.is_a?(Class) && handler_class < RobotLab::Hook
26
+ raise ArgumentError, "#{handler_class.inspect} must be a RobotLab::Hook subclass"
27
+ end
28
+ raise ArgumentError, "#{handler_class} has no namespace" if handler_class.namespace.nil?
29
+
30
+ registration = Registration.new(handler_class: handler_class, context: context)
31
+ @registrations << registration
32
+ registration
33
+ end
34
+
35
+ # Returns registrations whose handler implements the given hook method.
36
+ #
37
+ # @param hook_name [Symbol]
38
+ # @return [Array<Registration>]
39
+ def registrations_for(hook_name)
40
+ hook_sym = hook_name.to_sym
41
+ @registrations.select { |reg| reg.handler_class.singleton_class.public_method_defined?(hook_sym) }
42
+ end
43
+
44
+ # Returns all registrations (defensive copy).
45
+ #
46
+ # @return [Array<Registration>]
47
+ def registrations
48
+ @registrations.dup
49
+ end
50
+
51
+ def clear
52
+ @registrations.clear
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Hooks
5
+ module_function
6
+
7
+ def run(family, context, registries:, per_run_hooks: nil, &)
8
+ before = registrations(:"before_#{family}", registries, per_run_hooks)
9
+ around = registrations(:"around_#{family}", registries, per_run_hooks)
10
+ after = registrations(:"after_#{family}", registries, per_run_hooks)
11
+ errors = error_registrations(family, registries, per_run_hooks)
12
+
13
+ call_all(before, :"before_#{family}", context)
14
+ result = call_around(around, :"around_#{family}", context, &)
15
+ set_result(context, family, result)
16
+ call_all(after, :"after_#{family}", context)
17
+ result
18
+ rescue Exception => e # rubocop:disable Lint/RescueException
19
+ context.error = e if context.respond_to?(:error=)
20
+ call_all(errors, :on_error, context)
21
+ raise
22
+ end
23
+
24
+ def call(hook_name, context, registries:, per_run_hooks: nil)
25
+ call_all(registrations(hook_name, registries, per_run_hooks), hook_name, context)
26
+ end
27
+
28
+ def around(hook_name, context, registries:, per_run_hooks: nil, &)
29
+ call_around(registrations(hook_name, registries, per_run_hooks), hook_name, context, &)
30
+ end
31
+
32
+ def registrations(hook_name, registries, per_run_hooks)
33
+ registry_entries = registries.compact.flat_map { |registry| registry.registrations_for(hook_name) }
34
+ registry_entries + per_run_entries(hook_name, per_run_hooks)
35
+ end
36
+
37
+ def error_registrations(family, registries, per_run_hooks)
38
+ return [] unless %i[run network_run task].include?(family.to_sym)
39
+
40
+ registrations(:on_error, registries, per_run_hooks)
41
+ end
42
+
43
+ def call_all(registrations, hook_name, context)
44
+ registrations.each { |registration| call_registration(registration, hook_name, context) }
45
+ end
46
+
47
+ def call_around(registrations, hook_name, context, &block)
48
+ chain = registrations.reverse.inject(block) do |next_link, registration|
49
+ proc { call_registration(registration, hook_name, context, &next_link) }
50
+ end
51
+
52
+ chain.call
53
+ end
54
+
55
+ 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)
59
+ end
60
+ registration.handler_class.call(hook_name, context, &)
61
+ end
62
+ end
63
+
64
+ # per_run_hooks is now a single handler class or an array of handler classes.
65
+ def per_run_entries(hook_name, hooks)
66
+ return [] unless hooks
67
+
68
+ hook_sym = hook_name.to_sym
69
+ Array(hooks).filter_map do |handler_class|
70
+ next unless handler_class.singleton_class.public_method_defined?(hook_sym)
71
+
72
+ HookRegistry::Registration.new(handler_class: handler_class)
73
+ end
74
+ end
75
+
76
+ def set_result(context, family, result)
77
+ case family.to_sym
78
+ when :run
79
+ context.response = result if context.respond_to?(:response=)
80
+ when :network_run, :task
81
+ context.result = result if context.respond_to?(:result=)
82
+ when :llm_generation
83
+ context.generation_response = result if context.respond_to?(:generation_response=)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -141,8 +141,7 @@ module RobotLab
141
141
  #
142
142
  def get_prompt(name, arguments = {})
143
143
  ensure_connected!
144
- response = request(method: "prompts/get", params: { name: name, arguments: arguments })
145
- response
144
+ request(method: "prompts/get", params: { name: name, arguments: arguments })
146
145
  end
147
146
 
148
147
  # Checks if the client is connected to the server.
@@ -115,7 +115,7 @@ module RobotLab
115
115
  client.transport.stdin.flush
116
116
  rescue Errno::EPIPE, IOError => e
117
117
  @mutex.synchronize { @clients[io][:queue] = nil }
118
- raise MCPError, "MCP connection lost: #{e.message}"
118
+ raise MCPError.new("MCP connection lost: #{e.message}", retryable: true)
119
119
  end
120
120
 
121
121
  response = Timeout.timeout(timeout) { queue.pop }
@@ -127,7 +127,7 @@ module RobotLab
127
127
  response
128
128
  rescue Timeout::Error
129
129
  @mutex.synchronize { @clients[io]&.[]= :queue, nil }
130
- raise MCPError, "MCP server did not respond within #{timeout}s"
130
+ raise MCPError.new("MCP server did not respond within #{timeout}s", retryable: true)
131
131
  ensure
132
132
  @mutex.synchronize { @clients[io]&.[]= :queue, nil }
133
133
  end
@@ -145,15 +145,15 @@ module RobotLab
145
145
  loop do
146
146
  ios = @mutex.synchronize { @clients.keys.reject(&:closed?) }
147
147
 
148
- unless ios.empty?
148
+ if ios.empty?
149
+ sleep POLL_INTERVAL
150
+ else
149
151
  begin
150
152
  ready, = IO.select(ios, nil, nil, POLL_INTERVAL)
151
153
  dispatch(ready) if ready
152
154
  rescue Errno::EBADF
153
155
  # A pipe was closed between the reject and IO.select — harmless, loop again
154
156
  end
155
- else
156
- sleep POLL_INTERVAL
157
157
  end
158
158
 
159
159
  break unless @mutex.synchronize { @running }
@@ -99,7 +99,7 @@ module RobotLab
99
99
 
100
100
  seconds = value.to_f
101
101
  # If the caller passed milliseconds (>= 1000), convert to seconds
102
- seconds = seconds / 1000.0 if seconds >= 1000
102
+ seconds /= 1000.0 if seconds >= 1000
103
103
  [seconds, 1].max
104
104
  end
105
105
  end
@@ -76,8 +76,6 @@ module RobotLab
76
76
  from
77
77
  end
78
78
 
79
- private
80
-
81
79
  # @param servers [Array<Hash, MCP::Server>]
82
80
  def self.any_descriptions?(servers)
83
81
  servers.any? { |s| !description_for(s).empty? }