robot_lab 0.2.1 → 0.2.7

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 (236) 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 +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -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
@@ -12,9 +12,15 @@ module RobotLab
12
12
  # == Delivery Serialization
13
13
  #
14
14
  # TypedBus delivers messages in concurrent Async fibers. Robots
15
- # enqueue deliveries into a BusPoller rather than handling them
16
- # inline. The BusPoller drains each group's queue sequentially on
17
- # a dedicated OS thread, so robot.run() calls never interleave.
15
+ # enqueue deliveries into a shared BusPoller rather than dispatching
16
+ # them straight to the handler. Despite the name, BusPoller runs no
17
+ # background thread: `enqueue` processes the delivery inline in the
18
+ # caller's own execution context (Async fiber or OS thread) when the
19
+ # robot is idle, and otherwise queues it behind the delivery already
20
+ # in flight and drains it once that one returns. A mutex therefore
21
+ # serializes deliveries per robot, so a robot's run() calls never
22
+ # interleave — but nothing makes progress while the calling fiber is
23
+ # parked.
18
24
  #
19
25
  # Owns: @bus, @bus_poller, @private_bus_poller, @bus_poller_group, @bus_subscriber_id, @message_counter, @outbox, @message_handler
20
26
  # Reads: @name
@@ -29,9 +35,15 @@ module RobotLab
29
35
  def send_message(to:, content:)
30
36
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
31
37
 
32
- @message_counter += 1
33
- message = RobotMessage.build(id: @message_counter, from: @name, content: content)
34
- @outbox[message.key] = { message: message, status: :sent, replies: [] }
38
+ # Counter + outbox are shared with the poller thread (reply correlation)
39
+ # and with other senders; mutate them under the bus mutex. Publish (which
40
+ # does I/O) stays outside the lock.
41
+ message = @bus_mutex.synchronize do
42
+ @message_counter += 1
43
+ msg = RobotMessage.build(id: @message_counter, from: @name, content: content)
44
+ @outbox[msg.key] = { message: msg, status: :sent, replies: [] }
45
+ msg
46
+ end
35
47
  publish_to_bus(to.to_sym, message)
36
48
  message
37
49
  end
@@ -46,8 +58,10 @@ module RobotLab
46
58
  def send_reply(to:, content:, in_reply_to:)
47
59
  raise BusError, "No bus configured on robot '#{@name}'" unless @bus
48
60
 
49
- @message_counter += 1
50
- reply = RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
61
+ reply = @bus_mutex.synchronize do
62
+ @message_counter += 1
63
+ RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
64
+ end
51
65
  publish_to_bus(to.to_sym, reply)
52
66
  reply
53
67
  end
@@ -65,6 +79,38 @@ module RobotLab
65
79
  self
66
80
  end
67
81
 
82
+ # Automatically respond to inbound (non-reply) bus tasks: run +responder+ to
83
+ # produce a reply, and send it back to the sender. This is the symmetric
84
+ # counterpart to how a Cyborg answers its human — one call makes any bus
85
+ # member a first-class responder instead of hand-wiring {#on_message}.
86
+ #
87
+ # The responder runs inline in the caller's execution context (BusPoller has
88
+ # no drain thread), and deliveries to this member are serialized, so a long
89
+ # turn blocks both the sender and the next inbound message.
90
+ #
91
+ # @param auto_reply [Boolean] send the responder's result back to the sender
92
+ # @yield [message] the inbound task; return the reply content (nil => no reply)
93
+ # @return [self]
94
+ def respond_to_tasks(auto_reply: true, &responder)
95
+ on_message do |message|
96
+ next if message.reply?
97
+
98
+ reply = responder.call(message)
99
+ send_reply(to: message.from, content: reply, in_reply_to: message.key) if auto_reply && reply
100
+ end
101
+ self
102
+ end
103
+
104
+ # Serve inbound bus tasks by running each through this member's #run and
105
+ # replying with the result — the one-call way to make a Robot cooperate on
106
+ # the bus the way a Cyborg already does out of the box.
107
+ #
108
+ # @param auto_reply [Boolean]
109
+ # @return [self]
110
+ def serve(auto_reply: true)
111
+ respond_to_tasks(auto_reply: auto_reply) { |message| run(bus_task_content(message)).reply }
112
+ end
113
+
68
114
  # Spawn a new robot on a shared bus.
69
115
  #
70
116
  # Creates a new Robot instance that shares this robot's bus,
@@ -88,10 +134,24 @@ module RobotLab
88
134
  template: template,
89
135
  local_tools: local_tools,
90
136
  bus: @bus,
137
+ **inherited_llm_settings,
91
138
  **
92
139
  )
93
140
  end
94
141
 
142
+ # Model/provider a spawned robot inherits from its parent so a specialist
143
+ # runs on the SAME LLM as the robot that spawned it (e.g. a local Ollama
144
+ # model) instead of falling back to the global default. Caller-supplied
145
+ # opts override these.
146
+ #
147
+ # @return [Hash]
148
+ def inherited_llm_settings
149
+ settings = {}
150
+ settings[:model] = model if model
151
+ settings[:provider] = provider if provider
152
+ settings
153
+ end
154
+
95
155
  # Connect this robot to a message bus.
96
156
  #
97
157
  # If a bus is provided, the robot joins it. If no bus is provided
@@ -112,8 +172,9 @@ module RobotLab
112
172
 
113
173
  # Assign a shared BusPoller from a Network.
114
174
  #
115
- # Stops any private poller this robot auto-created, then adopts
116
- # the network's shared poller for the given group.
175
+ # Drops any private poller this robot auto-created, then adopts
176
+ # the network's shared poller for the given group. Called by
177
+ # Network#task; groups are informational labels only.
117
178
  #
118
179
  # @param poller [BusPoller] the network's shared poller
119
180
  # @param group [Symbol] poller group for this robot (default: :default)
@@ -159,21 +220,21 @@ module RobotLab
159
220
  @bus_poller_group = :default
160
221
  end
161
222
 
223
+ # Flatten a task message's content to text for #run.
224
+ def bus_task_content(message)
225
+ content = message.content
226
+ content.is_a?(Hash) ? content.map { |k, v| "#{k}: #{v}" }.join("\n") : content.to_s
227
+ end
228
+
162
229
  # Enqueue a delivery to the robot's assigned poller.
163
230
  def enqueue_delivery(delivery)
164
231
  @bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
165
232
  end
166
233
 
167
- # Process a single delivery (called by BusPoller drain thread).
234
+ # Process a single delivery (called by BusPoller during its inline drain).
168
235
  def process_delivery(delivery)
169
236
  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
237
+ correlate_reply(message) if message.reply?
177
238
 
178
239
  if @message_handler.arity == 1
179
240
  delivery.ack!
@@ -186,6 +247,17 @@ module RobotLab
186
247
  raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
187
248
  end
188
249
 
250
+ # Mark the sender's outbox entry replied. Shared with senders on other
251
+ # threads, so guard with the bus mutex.
252
+ def correlate_reply(message)
253
+ @bus_mutex.synchronize do
254
+ entry = @outbox[message.in_reply_to] or return
255
+
256
+ entry[:status] = :replied
257
+ entry[:replies] << message
258
+ end
259
+ end
260
+
189
261
  # Publish a RobotMessage to a bus channel
190
262
  def publish_to_bus(channel_name, message)
191
263
  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)
@@ -64,6 +64,10 @@ module RobotLab
64
64
 
65
65
  # Re-render the template with run-time context merged into build-time context.
66
66
  # prompt_manager parameters may be required (null) and only available at run time.
67
+ #
68
+ # Re-rendering replaces the system message, so the inline system_prompt must be
69
+ # re-appended here exactly as apply_system_prompt does at construction --
70
+ # otherwise it would be silently dropped on any run that supplies context.
67
71
  def rerender_template(run_context)
68
72
  merged = (@build_context || {}).merge(run_context)
69
73
  resolved_ctx = resolve_context(merged, network: nil)
@@ -81,15 +85,24 @@ module RobotLab
81
85
  bodies << body if body
82
86
  end
83
87
 
84
- combined = bodies.join("\n\n")
85
- @chat.with_instructions(combined)
88
+ @chat.with_instructions(with_system_prompt_appended(bodies.join("\n\n")))
86
89
  else
87
90
  parsed = PM.parse(@template)
88
91
  rendered = parsed.to_s(**resolved_ctx)
89
- @chat.with_instructions(rendered)
92
+ @chat.with_instructions(with_system_prompt_appended(rendered))
90
93
  end
91
94
  end
92
95
 
96
+ # Append the inline system_prompt to freshly rendered template text.
97
+ #
98
+ # @param rendered [String] newly rendered template body
99
+ # @return [String] rendered text with system_prompt appended, if one is set
100
+ def with_system_prompt_appended(rendered)
101
+ return rendered unless @system_prompt
102
+
103
+ [rendered, @system_prompt].reject { |s| s.to_s.empty? }.join("\n\n")
104
+ end
105
+
93
106
  # Orchestrate skill expansion and template application.
94
107
  #
95
108
  # @param skill_ids [Array<Symbol>] skill IDs from constructor + front matter
@@ -142,7 +155,7 @@ module RobotLab
142
155
  apply_accumulated_extras(extras)
143
156
 
144
157
  effective = accumulated_config.merge(@config)
145
- effective.apply_to(@chat)
158
+ effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
146
159
 
147
160
  combined = bodies.join("\n\n")
148
161
  @chat.with_instructions(combined) unless combined.empty?