robot_lab 0.2.1 → 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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +61 -0
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +1 -0
  10. data/docs/api/core/network.md +31 -1
  11. data/docs/api/core/robot.md +91 -5
  12. data/docs/api/errors.md +47 -2
  13. data/docs/api/index.md +3 -0
  14. data/docs/architecture/core-concepts.md +40 -0
  15. data/docs/architecture/network-orchestration.md +8 -0
  16. data/docs/architecture/robot-execution.md +1 -1
  17. data/docs/getting-started/configuration.md +20 -1
  18. data/docs/guides/building-robots.md +39 -1
  19. data/docs/guides/creating-networks.md +6 -1
  20. data/docs/guides/hooks.md +997 -0
  21. data/docs/guides/index.md +5 -0
  22. data/docs/guides/knowledge.md +2 -2
  23. data/docs/guides/observability.md +74 -7
  24. data/docs/guides/using-tools.md +69 -0
  25. data/docs/index.md +1 -1
  26. data/examples/03_network.rb +11 -2
  27. data/examples/34_agentskills.rb +0 -0
  28. data/examples/35_hooks.rb +256 -0
  29. data/examples/README.md +7 -0
  30. data/examples/common.rb +7 -3
  31. data/examples/xyzzy.rb +90 -0
  32. data/lib/robot_lab/agent_skill.rb +5 -4
  33. data/lib/robot_lab/budget/ledger.rb +98 -0
  34. data/lib/robot_lab/capabilities.rb +84 -0
  35. data/lib/robot_lab/config/defaults.yml +10 -0
  36. data/lib/robot_lab/config.rb +4 -4
  37. data/lib/robot_lab/error.rb +34 -4
  38. data/lib/robot_lab/errors.rb +45 -0
  39. data/lib/robot_lab/hook.rb +79 -0
  40. data/lib/robot_lab/hook_context.rb +194 -0
  41. data/lib/robot_lab/hook_registry.rb +55 -0
  42. data/lib/robot_lab/hooks.rb +87 -0
  43. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  44. data/lib/robot_lab/narrator.rb +87 -0
  45. data/lib/robot_lab/network.rb +77 -20
  46. data/lib/robot_lab/robot/budget.rb +89 -0
  47. data/lib/robot_lab/robot/bus_messaging.rb +76 -12
  48. data/lib/robot_lab/robot/hooking.rb +56 -0
  49. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  50. data/lib/robot_lab/robot/template_rendering.rb +2 -2
  51. data/lib/robot_lab/robot.rb +211 -88
  52. data/lib/robot_lab/run_config.rb +24 -5
  53. data/lib/robot_lab/runnable.rb +51 -0
  54. data/lib/robot_lab/sandbox/null.rb +13 -0
  55. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  56. data/lib/robot_lab/sandbox.rb +52 -0
  57. data/lib/robot_lab/script_tool.rb +60 -2
  58. data/lib/robot_lab/task.rb +26 -20
  59. data/lib/robot_lab/tool.rb +52 -11
  60. data/lib/robot_lab/version.rb +1 -1
  61. data/lib/robot_lab.rb +49 -0
  62. data/mkdocs.yml +1 -0
  63. metadata +36 -99
  64. data/examples/temp.md +0 -51
  65. data/site/404.html +0 -2300
  66. data/site/api/core/index.html +0 -2706
  67. data/site/api/core/memory/index.html +0 -3793
  68. data/site/api/core/network/index.html +0 -3500
  69. data/site/api/core/robot/index.html +0 -4566
  70. data/site/api/core/state/index.html +0 -3390
  71. data/site/api/core/tool/index.html +0 -3843
  72. data/site/api/index.html +0 -2635
  73. data/site/api/mcp/client/index.html +0 -3435
  74. data/site/api/mcp/index.html +0 -2783
  75. data/site/api/mcp/server/index.html +0 -3252
  76. data/site/api/mcp/transports/index.html +0 -3352
  77. data/site/api/messages/index.html +0 -2641
  78. data/site/api/messages/text-message/index.html +0 -3087
  79. data/site/api/messages/tool-call-message/index.html +0 -3159
  80. data/site/api/messages/tool-result-message/index.html +0 -3252
  81. data/site/api/messages/user-message/index.html +0 -3212
  82. data/site/api/streaming/context/index.html +0 -3282
  83. data/site/api/streaming/events/index.html +0 -3347
  84. data/site/api/streaming/index.html +0 -2738
  85. data/site/architecture/core-concepts/index.html +0 -3757
  86. data/site/architecture/index.html +0 -2797
  87. data/site/architecture/message-flow/index.html +0 -3238
  88. data/site/architecture/network-orchestration/index.html +0 -3433
  89. data/site/architecture/robot-execution/index.html +0 -3140
  90. data/site/architecture/state-management/index.html +0 -3498
  91. data/site/assets/css/custom.css +0 -56
  92. data/site/assets/images/favicon.png +0 -0
  93. data/site/assets/images/robot_lab.jpg +0 -0
  94. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  95. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  96. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  97. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  98. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  99. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  100. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  101. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  102. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  103. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  104. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  105. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  106. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  107. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  108. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  109. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  110. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  111. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  112. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  113. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  114. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  115. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  116. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  117. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  118. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  119. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  120. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  121. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  122. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  123. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  124. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  125. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  126. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  127. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  128. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  129. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  130. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  131. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  132. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  133. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  134. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  135. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  136. data/site/concepts/index.html +0 -3455
  137. data/site/examples/basic-chat/index.html +0 -2880
  138. data/site/examples/index.html +0 -2907
  139. data/site/examples/mcp-server/index.html +0 -3018
  140. data/site/examples/multi-robot-network/index.html +0 -3131
  141. data/site/examples/rails-application/index.html +0 -3329
  142. data/site/examples/tool-usage/index.html +0 -3085
  143. data/site/getting-started/configuration/index.html +0 -3745
  144. data/site/getting-started/index.html +0 -2572
  145. data/site/getting-started/installation/index.html +0 -2981
  146. data/site/getting-started/quick-start/index.html +0 -2942
  147. data/site/guides/building-robots/index.html +0 -4290
  148. data/site/guides/creating-networks/index.html +0 -3858
  149. data/site/guides/index.html +0 -2586
  150. data/site/guides/mcp-integration/index.html +0 -3581
  151. data/site/guides/memory/index.html +0 -3586
  152. data/site/guides/rails-integration/index.html +0 -4019
  153. data/site/guides/streaming/index.html +0 -3157
  154. data/site/guides/using-tools/index.html +0 -3802
  155. data/site/index.html +0 -2671
  156. data/site/search/search_index.json +0 -1
  157. data/site/sitemap.xml +0 -183
  158. data/site/sitemap.xml.gz +0 -0
  159. data/site/tags.json +0 -1
@@ -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
@@ -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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Opt-in live observability hook: narrates what a robot is doing as it happens,
5
+ # so a run is never silent between events. Complements RobotLab::Audit (which
6
+ # records to a persistent store) with a human-facing console feed.
7
+ #
8
+ # Enable it globally (applies to every robot run, including networks):
9
+ #
10
+ # RobotLab::Narrator.enable! # narrate to $stderr
11
+ # RobotLab::Narrator.enable!(output: $stdout)
12
+ #
13
+ # or register like any hook for finer scope:
14
+ #
15
+ # robot.on(RobotLab::Narrator)
16
+ # network.on(RobotLab::Narrator)
17
+ #
18
+ # Output goes to $stderr by default and uses IO#puts (not Kernel#warn, which is
19
+ # silenced when Ruby warnings are disabled — i.e. $VERBOSE is nil, common under
20
+ # `bundle exec`).
21
+ class Narrator < RobotLab::Hook
22
+ self.namespace = :narrator
23
+
24
+ MAX = 80
25
+
26
+ class << self
27
+ attr_writer :output
28
+
29
+ # @return [IO] where narration is written (default $stderr)
30
+ def output
31
+ @output ||= $stderr
32
+ end
33
+
34
+ # Register the narrator globally and set its output.
35
+ #
36
+ # @param output [IO]
37
+ # @return [Class] self
38
+ def enable!(output: $stderr)
39
+ self.output = output
40
+ RobotLab.on(self)
41
+ self
42
+ end
43
+
44
+ # --- hooks ---------------------------------------------------------------
45
+
46
+ def before_llm_generation(ctx)
47
+ who = ctx.respond_to?(:robot) ? ctx.robot&.name : nil
48
+ say "#{"#{who}: " if who}thinking…"
49
+ rescue StandardError
50
+ nil
51
+ end
52
+
53
+ def before_tool_call(ctx)
54
+ say "→ #{ctx.tool_name}#{summarize(ctx.tool_args)}"
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def after_tool_call(ctx)
60
+ say " ✗ #{clip(ctx.tool_error.message)}" if ctx.tool_error
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ def say(message)
68
+ output.puts(" · #{message}")
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ # Compact one-line summary of the first tool argument, e.g. " path=\"a.rb\"".
74
+ def summarize(args)
75
+ return "" unless args.is_a?(Hash) && !args.empty?
76
+
77
+ key, value = args.first
78
+ " #{key}=#{clip(value).inspect}"
79
+ end
80
+
81
+ def clip(text)
82
+ line = text.to_s.lines.first.to_s.strip
83
+ line.length > MAX ? "#{line[0, MAX]}…" : line
84
+ end
85
+ end
86
+ end
87
+ end
@@ -59,6 +59,7 @@ module RobotLab
59
59
  #
60
60
  class Network
61
61
  include Utils
62
+ include Runnable
62
63
 
63
64
  # Reserved key for broadcast messages in memory
64
65
  BROADCAST_KEY = :_network_broadcast
@@ -71,7 +72,7 @@ module RobotLab
71
72
  # @return [Hash<String, Robot>] robots in this network, keyed by name
72
73
  # @!attribute [r] memory
73
74
  # @return [Memory] shared memory for all robots in the network
74
- attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode
75
+ attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode, :hooks
75
76
 
76
77
  # Creates a new Network instance.
77
78
  #
@@ -94,6 +95,7 @@ module RobotLab
94
95
  @memory = memory || Memory.new(network_name: @name)
95
96
  @config = config || RunConfig.new
96
97
  @parallel_mode = parallel_mode
98
+ @hooks = HookRegistry.new
97
99
  @broadcast_handlers = []
98
100
  @bus_poller = BusPoller.new.start
99
101
 
@@ -132,7 +134,8 @@ module RobotLab
132
134
  mcp: mcp,
133
135
  tools: tools,
134
136
  memory: memory,
135
- config: config
137
+ config: config,
138
+ network: self
136
139
  )
137
140
 
138
141
  # Register the group and assign the shared poller to the robot
@@ -177,24 +180,55 @@ module RobotLab
177
180
  # result.value # => RobotResult from last robot
178
181
  # result.context[:classifier] # => RobotResult from classifier
179
182
  #
180
- def run(**run_context)
183
+ def run(message = nil, **run_context)
184
+ # Runnable protocol: accept a positional message like Robot#run does, so
185
+ # callers can `run(msg, ...)` uniformly. `run(message: msg)` still works.
186
+ run_context[:message] = message unless message.nil?
187
+
181
188
  # Include shared memory in run params so robots can access it
182
189
  run_context[:network_memory] = @memory
190
+ run_context[:network] = self
183
191
 
184
192
  # Pass network's config so robots can inherit it
185
193
  run_context[:network_config] = @config unless @config.empty?
186
194
 
187
- if @parallel_mode == :ractor
188
- run_with_ractor_scheduler(run_context)
189
- else
190
- initial_result = SimpleFlow::Result.new(
191
- run_context,
192
- context: { run_params: run_context }
193
- )
194
- @pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
195
+ context = NetworkRunHookContext.new(
196
+ network: self,
197
+ context: run_context,
198
+ memory: @memory,
199
+ config: @config
200
+ )
201
+
202
+ RobotLab::Hooks.run(:network_run, context, registries: [RobotLab.hooks, @hooks]) do
203
+ if @parallel_mode == :ractor
204
+ run_with_ractor_scheduler(context.context)
205
+ else
206
+ initial_result = SimpleFlow::Result.new(
207
+ context.context,
208
+ context: { run_params: context.context }
209
+ )
210
+ @pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
211
+ end
195
212
  end
196
213
  end
197
214
 
215
+ # Runnable protocol: the network's robots as an Array (in pipeline order),
216
+ # and the network? predicate. (`robots` itself stays a name=>robot Hash.)
217
+ #
218
+ # @return [Array<Robot>]
219
+ def crew
220
+ robots.values
221
+ end
222
+
223
+ # @return [Boolean] always true for a Network
224
+ def network?
225
+ true
226
+ end
227
+
228
+ def on(handler_class, context: nil)
229
+ @hooks.on(handler_class, context: context)
230
+ end
231
+
198
232
  # Broadcast a message to all robots in the network.
199
233
  #
200
234
  # This sends a network-wide message that all robots subscribed via
@@ -303,6 +337,19 @@ module RobotLab
303
337
  self
304
338
  end
305
339
 
340
+ # Remove a dynamically-added robot from the network by name. Returns the
341
+ # removed robot, or nil if no robot by that name was present.
342
+ #
343
+ # Only removes the robot from the crew (`@robots`); it does not rewrite the
344
+ # pipeline, so callers should not remove a robot that is a pipeline task.
345
+ #
346
+ # @param name [String, Symbol] the robot's name
347
+ # @return [Robot, nil]
348
+ #
349
+ def remove_robot(name)
350
+ @robots.delete(name.to_s)
351
+ end
352
+
306
353
  # Visualize the pipeline as ASCII
307
354
  #
308
355
  # @return [String, nil]
@@ -363,15 +410,7 @@ module RobotLab
363
410
  specs_with_deps = @tasks.map do |task_name, task_wrapper|
364
411
  deps = dep_graph[task_name.to_sym] || []
365
412
  deps = deps.empty? ? :none : deps.map(&:to_s)
366
-
367
- spec = RobotSpec.new(
368
- name: task_wrapper.robot.name.freeze,
369
- template: task_wrapper.robot.template&.to_s&.freeze,
370
- system_prompt: task_wrapper.robot.system_prompt&.freeze,
371
- config_hash: RactorBoundary.freeze_deep(task_wrapper.robot.config.to_json_hash)
372
- )
373
-
374
- { spec: spec, depends_on: deps }
413
+ { spec: build_robot_spec(task_wrapper), depends_on: deps }
375
414
  end
376
415
 
377
416
  scheduler = RactorNetworkScheduler.new(memory: @memory)
@@ -379,5 +418,23 @@ module RobotLab
379
418
  scheduler.shutdown
380
419
  results
381
420
  end
421
+
422
+ def build_robot_spec(task_wrapper)
423
+ robot = task_wrapper.robot
424
+ RobotSpec.new(
425
+ name: robot.name.freeze,
426
+ template: robot.template&.to_s&.freeze,
427
+ system_prompt: robot.system_prompt&.freeze,
428
+ config_hash: RactorBoundary.freeze_deep(robot.config.to_json_hash),
429
+ hook_classes: ractor_hook_classes_for(robot)
430
+ )
431
+ end
432
+
433
+ def ractor_hook_classes_for(robot)
434
+ [RobotLab.hooks, @hooks, robot.hooks]
435
+ .flat_map { |registry| registry.registrations.map(&:handler_class) }
436
+ .uniq
437
+ .freeze
438
+ end
382
439
  end
383
440
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # Token/cost budget enforcement for a single Robot, backed by a
6
+ # RobotLab::Budget::Ledger.
7
+ #
8
+ # Two layers of enforcement:
9
+ # - reserve_budget!/reconcile_budget! wrap each LLM call. reserve_budget!
10
+ # raises BudgetExceeded up front when a *prior* call already pushed
11
+ # usage over budget, so a robot with an exhausted budget refuses
12
+ # further calls instead of spending on them.
13
+ # - enforce_token_budget!/enforce_cost_budget! run after the call
14
+ # completes and raise InferenceError when *this* call's actual usage
15
+ # pushed cumulative usage over budget — unavoidable for the call that
16
+ # causes the overage, since token/cost totals aren't known until the
17
+ # response comes back.
18
+ module Budget
19
+ private
20
+
21
+ # @return [RobotLab::Budget::Ledger, nil] nil when neither token_budget
22
+ # nor cost_budget is configured
23
+ def build_budget_ledger
24
+ limits = { tokens: @config.token_budget, cost: @config.cost_budget }.compact
25
+ return nil if limits.empty?
26
+
27
+ RobotLab::Budget::Ledger.new(limits: limits)
28
+ end
29
+
30
+ def enforce_token_budget!
31
+ budget = @config.token_budget
32
+ return unless budget && @total_input_tokens + @total_output_tokens > budget
33
+
34
+ raise InferenceError,
35
+ "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
36
+ end
37
+
38
+ def enforce_cost_budget!
39
+ budget = @config.cost_budget
40
+ return unless budget && @budget_ledger
41
+
42
+ consumed = @budget_ledger.consumed[:cost]
43
+ return unless consumed > budget
44
+
45
+ raise InferenceError,
46
+ format("Cost budget exceeded: $%<consumed>.6f used, budget is $%<budget>.6f",
47
+ consumed: consumed, budget: budget)
48
+ end
49
+
50
+ # Reserves whatever remains of each configured budget dimension
51
+ # before attempting the LLM call.
52
+ #
53
+ # @return [Hash{Symbol=>Numeric}] the amount reserved per dimension, to pass to reconcile_budget!
54
+ # @raise [RobotLab::BudgetExceeded] if a dimension is already exhausted
55
+ def reserve_budget!
56
+ return {} unless @budget_ledger
57
+
58
+ @budget_ledger.limits.each_key.with_object({}) do |dimension, reservation|
59
+ amount = @budget_ledger.remaining(dimension)
60
+ @budget_ledger.reserve!(dimension, amount)
61
+ reservation[dimension] = amount
62
+ end
63
+ end
64
+
65
+ # Replaces a reservation from reserve_budget! with the actual usage
66
+ # from this call.
67
+ #
68
+ # @param reservation [Hash{Symbol=>Numeric}] the value returned by reserve_budget!
69
+ # @param response [Object] the raw LLM response, used to extract actual cost
70
+ # @param result [RobotResult] the built result, used to extract actual tokens
71
+ def reconcile_budget!(reservation, response:, result:)
72
+ return unless reservation&.any?
73
+
74
+ actual = { tokens: result.input_tokens + result.output_tokens, cost: extract_cost(response) }
75
+ reservation.each_pair { |dimension, amount| @budget_ledger.reconcile!(dimension, amount, actual[dimension] || 0) }
76
+ end
77
+
78
+ # @param response [Object] the raw LLM response
79
+ # @return [Float] the response's total cost, or 0.0 when unavailable (no pricing data)
80
+ def extract_cost(response)
81
+ return 0.0 unless response.respond_to?(:cost)
82
+
83
+ response.cost.total.to_f
84
+ rescue StandardError
85
+ 0.0
86
+ end
87
+ end
88
+ end
89
+ end
@@ -29,9 +29,15 @@ module RobotLab
29
29
  def send_message(to:, content:)
30
30
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
31
31
 
32
- @message_counter += 1
33
- message = RobotMessage.build(id: @message_counter, from: @name, content: content)
34
- @outbox[message.key] = { message: message, status: :sent, replies: [] }
32
+ # Counter + outbox are shared with the poller thread (reply correlation)
33
+ # and with other senders; mutate them under the bus mutex. Publish (which
34
+ # does I/O) stays outside the lock.
35
+ message = @bus_mutex.synchronize do
36
+ @message_counter += 1
37
+ msg = RobotMessage.build(id: @message_counter, from: @name, content: content)
38
+ @outbox[msg.key] = { message: msg, status: :sent, replies: [] }
39
+ msg
40
+ end
35
41
  publish_to_bus(to.to_sym, message)
36
42
  message
37
43
  end
@@ -46,8 +52,10 @@ module RobotLab
46
52
  def send_reply(to:, content:, in_reply_to:)
47
53
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
48
54
 
49
- @message_counter += 1
50
- reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
55
+ reply = @bus_mutex.synchronize do
56
+ @message_counter += 1
57
+ RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
58
+ end
51
59
  publish_to_bus(to.to_sym, reply)
52
60
  reply
53
61
  end
@@ -65,6 +73,37 @@ module RobotLab
65
73
  self
66
74
  end
67
75
 
76
+ # Automatically respond to inbound (non-reply) bus tasks: run +responder+ to
77
+ # produce a reply, and send it back to the sender. This is the symmetric
78
+ # counterpart to how a Cyborg answers its human — one call makes any bus
79
+ # member a first-class responder instead of hand-wiring {#on_message}.
80
+ #
81
+ # The responder runs on the poller drain thread, so deliveries to this member
82
+ # are handled one at a time (a long turn delays the next inbound message).
83
+ #
84
+ # @param auto_reply [Boolean] send the responder's result back to the sender
85
+ # @yield [message] the inbound task; return the reply content (nil => no reply)
86
+ # @return [self]
87
+ def respond_to_tasks(auto_reply: true, &responder)
88
+ on_message do |message|
89
+ next if message.reply?
90
+
91
+ reply = responder.call(message)
92
+ send_reply(to: message.from, content: reply, in_reply_to: message.key) if auto_reply && reply
93
+ end
94
+ self
95
+ end
96
+
97
+ # Serve inbound bus tasks by running each through this member's #run and
98
+ # replying with the result — the one-call way to make a Robot cooperate on
99
+ # the bus the way a Cyborg already does out of the box.
100
+ #
101
+ # @param auto_reply [Boolean]
102
+ # @return [self]
103
+ def serve(auto_reply: true)
104
+ respond_to_tasks(auto_reply: auto_reply) { |message| run(bus_task_content(message)).reply }
105
+ end
106
+
68
107
  # Spawn a new robot on a shared bus.
69
108
  #
70
109
  # Creates a new Robot instance that shares this robot's bus,
@@ -88,10 +127,24 @@ module RobotLab
88
127
  template: template,
89
128
  local_tools: local_tools,
90
129
  bus: @bus,
130
+ **inherited_llm_settings,
91
131
  **
92
132
  )
93
133
  end
94
134
 
135
+ # Model/provider a spawned robot inherits from its parent so a specialist
136
+ # runs on the SAME LLM as the robot that spawned it (e.g. a local Ollama
137
+ # model) instead of falling back to the global default. Caller-supplied
138
+ # opts override these.
139
+ #
140
+ # @return [Hash]
141
+ def inherited_llm_settings
142
+ settings = {}
143
+ settings[:model] = model if model
144
+ settings[:provider] = provider if provider
145
+ settings
146
+ end
147
+
95
148
  # Connect this robot to a message bus.
96
149
  #
97
150
  # If a bus is provided, the robot joins it. If no bus is provided
@@ -159,6 +212,12 @@ module RobotLab
159
212
  @bus_poller_group = :default
160
213
  end
161
214
 
215
+ # Flatten a task message's content to text for #run.
216
+ def bus_task_content(message)
217
+ content = message.content
218
+ content.is_a?(Hash) ? content.map { |k, v| "#{k}: #{v}" }.join("\n") : content.to_s
219
+ end
220
+
162
221
  # Enqueue a delivery to the robot's assigned poller.
163
222
  def enqueue_delivery(delivery)
164
223
  @bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
@@ -167,13 +226,7 @@ module RobotLab
167
226
  # Process a single delivery (called by BusPoller drain thread).
168
227
  def process_delivery(delivery)
169
228
  message = delivery.message
170
-
171
- # Correlate replies with outbox entries
172
- if message.reply? && @outbox.key?(message.in_reply_to)
173
- entry = @outbox[message.in_reply_to]
174
- entry[:status] = :replied
175
- entry[:replies] << message
176
- end
229
+ correlate_reply(message) if message.reply?
177
230
 
178
231
  if @message_handler.arity == 1
179
232
  delivery.ack!
@@ -186,6 +239,17 @@ module RobotLab
186
239
  raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
187
240
  end
188
241
 
242
+ # Mark the sender's outbox entry replied. Shared with senders on other
243
+ # threads, so guard with the bus mutex.
244
+ def correlate_reply(message)
245
+ @bus_mutex.synchronize do
246
+ entry = @outbox[message.in_reply_to] or return
247
+
248
+ entry[:status] = :replied
249
+ entry[:replies] << message
250
+ end
251
+ end
252
+
189
253
  # Publish a RobotMessage to a bus channel
190
254
  def publish_to_bus(channel_name, message)
191
255
  if defined?(Async::Task) && Async::Task.current?
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ module Hooking
6
+ def run(message = nil, network: nil, task: nil, network_memory: nil, network_config: nil,
7
+ memory: nil, mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
8
+ run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
9
+ previous_writer = run_memory.current_writer
10
+ run_memory.current_writer = @name
11
+ context = RunHookContext.new(
12
+ robot: self,
13
+ network: network,
14
+ task: task,
15
+ memory: run_memory,
16
+ config: @config,
17
+ request: message
18
+ )
19
+
20
+ registries = hook_registries(network)
21
+
22
+ begin
23
+ RobotLab.with_hook_scope(registries, hooks) do
24
+ RobotLab::Hooks.run(:run, context, registries: registries, per_run_hooks: hooks) do
25
+ run_context = kwargs.except(:with)
26
+ prepare_tools(message: context.request, mcp: mcp, tools: tools,
27
+ network: network, network_config: network_config)
28
+ rerender_template(run_context) if @template && run_context.any?
29
+ reservation = reserve_budget!
30
+ response = invoke_ask(context: context, kwargs: kwargs, hooks: hooks, block: block)
31
+ result = build_result(response, run_memory)
32
+ reconcile_budget!(reservation, response: response, result: result)
33
+ enforce_token_budget!
34
+ enforce_cost_budget!
35
+ result
36
+ end
37
+ end
38
+ ensure
39
+ remove_doom_loop_detection
40
+ restore_tool_call_callback if @config.max_tool_rounds
41
+ run_memory.current_writer = previous_writer
42
+ end
43
+ end
44
+
45
+ def on(handler_class, context: nil)
46
+ @hooks.on(handler_class, context: context)
47
+ end
48
+
49
+ private
50
+
51
+ def hook_registries(network = nil)
52
+ [RobotLab.hooks, network&.hooks, @hooks]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -12,18 +12,24 @@ module RobotLab
12
12
 
13
13
  # Resolve MCP hierarchy: runtime -> robot build -> network -> config
14
14
  def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
15
- parent_value = network_config&.mcp || network&.network&.mcp || RobotLab.config.mcp
15
+ parent_value = network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp
16
16
  build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
17
17
  ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
18
18
  end
19
19
 
20
20
  # Resolve tools hierarchy: runtime -> robot build -> network -> config
21
21
  def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
22
- parent_value = network_config&.tools || network&.network&.tools || RobotLab.config.tools
22
+ parent_value = network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools
23
23
  build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
24
24
  ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
25
25
  end
26
26
 
27
+ def network_parent_config(network)
28
+ return network.config if network.respond_to?(:config)
29
+
30
+ network&.network
31
+ end
32
+
27
33
  # Ensure MCP clients are initialized for the given server configs.
28
34
  # On subsequent calls, retries any servers that previously failed to connect.
29
35
  def ensure_mcp_clients(mcp_servers)
@@ -45,7 +45,7 @@ module RobotLab
45
45
  # Front matter is the base; @config (from constructor kwargs) overrides.
46
46
  fm_config = RunConfig.from_front_matter(parsed.metadata)
47
47
  effective = fm_config.merge(@config)
48
- effective.apply_to(@chat)
48
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
49
49
 
50
50
  # Resolve context (could be a Proc)
51
51
  resolved_ctx = resolve_context(context, network: nil)
@@ -142,7 +142,7 @@ module RobotLab
142
142
  apply_accumulated_extras(extras)
143
143
 
144
144
  effective = accumulated_config.merge(@config)
145
- effective.apply_to(@chat)
145
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
146
146
 
147
147
  combined = bodies.join("\n\n")
148
148
  @chat.with_instructions(combined) unless combined.empty?