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
data/examples/common.rb CHANGED
@@ -7,23 +7,95 @@ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
7
7
 
8
8
  require_relative "../lib/robot_lab"
9
9
 
10
+ # ── Local LLM Configuration ───────────────────────────────────────────────────
11
+ #
12
+ # Every example runs against a LOCAL model served by Ollama. No API keys, no
13
+ # network egress, no per-token cost. Pull the model once before running:
14
+ #
15
+ # ollama pull qwen3.6
16
+ #
17
+ # Ollama models are not in RubyLLM's model registry, so a `provider:` must be
18
+ # supplied alongside `model:` — that is what makes RubyLLM skip the registry
19
+ # lookup (see Robot#initialize, which sets assume_model_exists when provider is
20
+ # given). Use the `llm_opts` helper below so every robot gets both.
21
+
10
22
  LlmConfig = Data.define(:provider, :model)
11
23
 
12
24
  LLM = {
13
- default: LlmConfig.new(provider: "openai", model: "gpt-5.4"),
14
- local: LlmConfig.new(provider: "ollama", model: "llama3.2"),
15
- anthropic: LlmConfig.new(provider: "anthropic", model: "claude-opus-4-7")
25
+ default: LlmConfig.new(provider: "ollama", model: "qwen3.6:latest"),
26
+ small: LlmConfig.new(provider: "ollama", model: "qwen2.5:7b"),
27
+ large: LlmConfig.new(provider: "ollama", model: "llama3.3:latest")
16
28
  }.freeze
17
29
 
18
- RubyLLM.configure do |c|
19
- c.logger = Logger.new(File::NULL)
20
- c.default_model = LLM[:default].model
21
- end
30
+ OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
22
31
 
32
+ # ORDER MATTERS. The first touch of RobotLab.config runs Config#after_load,
33
+ # which calls RubyLLM.configure itself and would clobber anything set before
34
+ # it. So configure RobotLab first, then RubyLLM — the later block wins.
23
35
  RobotLab.configure do |c|
24
36
  c.logger = Logger.new(File::NULL)
25
37
  end
26
38
 
39
+ RubyLLM.configure do |c|
40
+ c.logger = Logger.new(File::NULL)
41
+ c.default_model = LLM[:default].model
42
+ c.ollama_api_base = OLLAMA_API_BASE
43
+
44
+ # A large local model on consumer hardware is far slower than a hosted API,
45
+ # and robot_lab's bundled 120s default is comfortably exceeded by a long
46
+ # answer from a 20B+ model — which surfaces mid-run as Net::ReadTimeout.
47
+ #
48
+ # Integer(), not the raw env string: this value reaches Net::HTTP directly,
49
+ # and a String raises "can't convert String into time interval". That is
50
+ # also why the ROBOT_LAB_RUBY_LLM__REQUEST_TIMEOUT env var is the wrong
51
+ # lever here — env values arrive as strings.
52
+ c.request_timeout = Integer(ENV.fetch("LLM_REQUEST_TIMEOUT", "900"))
53
+ c.max_retries = 1
54
+ end
55
+
56
+ # Which LLM entry an unqualified llm_opts resolves to. Lets you run any
57
+ # example against a faster model without editing it — worth knowing for the
58
+ # multi-robot demos (14, 15, 16), which are 30+ sequential LLM calls and take
59
+ # over half an hour on a 20B+ model:
60
+ #
61
+ # LLM_PROFILE=small bundle exec ruby examples/14_rusty_circuit/open_mic.rb
62
+ LLM_PROFILE = ENV.fetch("LLM_PROFILE", "default").to_sym
63
+
64
+ unless LLM.key?(LLM_PROFILE)
65
+ abort "Unknown LLM_PROFILE #{LLM_PROFILE.inspect}. Choose one of: #{LLM.keys.join(', ')}"
66
+ end
67
+
68
+ # Provider + model keyword pair for RobotLab.build / Robot.new.
69
+ #
70
+ # Both are required for a local Ollama model. Splat it into any robot
71
+ # constructor:
72
+ #
73
+ # RobotLab.build(name: "helper", **llm_opts) # honors LLM_PROFILE
74
+ # RobotLab.build(name: "cheap", **llm_opts(:small)) # pinned regardless
75
+ #
76
+ # @param key [Symbol, nil] which entry of LLM to use; defaults to LLM_PROFILE
77
+ # @return [Hash] { provider:, model: }
78
+ def llm_opts(key = nil)
79
+ cfg = LLM.fetch(key || LLM_PROFILE)
80
+ { provider: cfg.provider, model: cfg.model }
81
+ end
82
+
83
+ # Fail fast with an actionable message when Ollama isn't reachable, instead of
84
+ # letting every example die inside an HTTP adapter.
85
+ def require_ollama!
86
+ require "net/http"
87
+ uri = URI(OLLAMA_API_BASE.sub(%r{/v1/?$}, "") + "/api/tags")
88
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
89
+ rescue StandardError => e
90
+ abort <<~ERROR
91
+ Cannot reach Ollama at #{OLLAMA_API_BASE} (#{e.class}).
92
+
93
+ Start it and pull the model used by the examples:
94
+ ollama serve
95
+ ollama pull #{LLM[:default].model.sub(/:latest\z/, "")}
96
+ ERROR
97
+ end
98
+
27
99
  # ── Example Output Helpers ─────────────────────────────────────────────────────
28
100
 
29
101
  module ExOut
data/examples/xyzzy.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # xyzzy.rb — single-file RobotLab hook extension demo
4
+ #
5
+ # Demonstrates the Hook handler class pattern: implement class methods for
6
+ # every hook under a dedicated namespace and log each callback with its
7
+ # context snapshot. Useful as a live trace of the full hook pipeline during
8
+ # development.
9
+ #
10
+ # Fully Ractor-safe: no class-level mutable state. LOG_PATH is a frozen
11
+ # string constant (shareable); each call opens its own file handle.
12
+ #
13
+ # stdout : one tagline per hook call → [xyzzy] HH:MM:SS.mmm hook_name
14
+ # logfile: full context snapshot written to LOG_PATH via PP.pp
15
+ #
16
+ # The log destination is read from XYZZY_LOG_PATH at load time and frozen
17
+ # into a constant. Set it BEFORE requiring this file to redirect the log;
18
+ # there is deliberately no writer method, so the constant stays shareable.
19
+ #
20
+ # Usage (from any example that requires common):
21
+ # ENV["XYZZY_LOG_PATH"] = "/tmp/xyzzy.log" # optional, must precede require
22
+ # require_relative "xyzzy"
23
+
24
+ require "pp"
25
+ require "fileutils"
26
+
27
+ module RobotLab
28
+ class Xyzzy < Hook
29
+ self.namespace = :xyzzy
30
+
31
+ LOG_PATH = File.expand_path(
32
+ ENV.fetch("XYZZY_LOG_PATH", "~/.robot_lab/xyzzy_hooks.log")
33
+ ).freeze
34
+ FileUtils.mkdir_p(File.dirname(LOG_PATH))
35
+
36
+ class << self
37
+ def before_run(ctx) = log_hook(:before_run, ctx)
38
+ def after_run(ctx) = log_hook(:after_run, ctx)
39
+ def on_error(ctx) = log_hook(:on_error, ctx)
40
+ def before_llm_generation(ctx) = log_hook(:before_llm_generation, ctx)
41
+ def after_llm_generation(ctx) = log_hook(:after_llm_generation, ctx)
42
+ def before_tool_call(ctx) = log_hook(:before_tool_call, ctx)
43
+ def after_tool_call(ctx) = log_hook(:after_tool_call, ctx)
44
+ def before_network_run(ctx) = log_hook(:before_network_run, ctx)
45
+ def after_network_run(ctx) = log_hook(:after_network_run, ctx)
46
+ def before_task(ctx) = log_hook(:before_task, ctx)
47
+ def after_task(ctx) = log_hook(:after_task, ctx)
48
+
49
+ def around_run(ctx, &block)
50
+ log_hook(:around_run, ctx)
51
+ block.call
52
+ end
53
+
54
+ def around_llm_generation(ctx, &block)
55
+ log_hook(:around_llm_generation, ctx)
56
+ block.call
57
+ end
58
+
59
+ def around_tool_call(ctx, &block)
60
+ log_hook(:around_tool_call, ctx)
61
+ block.call
62
+ end
63
+
64
+ def around_network_run(ctx, &block)
65
+ log_hook(:around_network_run, ctx)
66
+ block.call
67
+ end
68
+
69
+ def around_task(ctx, &block)
70
+ log_hook(:around_task, ctx)
71
+ block.call
72
+ end
73
+
74
+ private
75
+
76
+ def log_hook(hook_name, ctx)
77
+ ts = Time.now.strftime('%H:%M:%S.%3N')
78
+ snapshot = context_snapshot(ctx)
79
+ $stdout.puts " [xyzzy] #{ts} #{hook_name}"
80
+ File.open(LOG_PATH, "a") { |f| f.puts "#{ts} #{hook_name} | #{PP.pp(snapshot, +"").chomp}" }
81
+ end
82
+
83
+ def context_snapshot(ctx)
84
+ {
85
+ robot: ctx.respond_to?(:robot) ? ctx.robot&.name : nil,
86
+ request: ctx.respond_to?(:request) ? ctx.request : nil,
87
+ network: ctx.respond_to?(:network) ? ctx.network&.name : nil,
88
+ task: ctx.respond_to?(:task) ? ctx.task : nil,
89
+ error: ctx.respond_to?(:error) ? ctx.error&.message : nil
90
+ }.compact
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ RobotLab.register_extension(:xyzzy, RobotLab::Xyzzy) if RobotLab.respond_to?(:register_extension)
97
+ RobotLab.on(RobotLab::Xyzzy)
@@ -8,7 +8,7 @@ module RobotLab
8
8
  # A skill is a directory containing SKILL.md with required front matter
9
9
  # fields (name, description) and optional scripts/, references/, assets/.
10
10
  class AgentSkill
11
- attr_reader :name, :description, :path
11
+ attr_reader :name, :description, :path, :capabilities
12
12
 
13
13
  # @param skill_md_path [String, Pathname] path to the SKILL.md file
14
14
  # @raise [ConfigurationError] if name or description is missing
@@ -17,8 +17,9 @@ module RobotLab
17
17
  content = File.read(skill_md_path)
18
18
  front_matter, @_body = parse_skill_md(content)
19
19
 
20
- @name = front_matter["name"]
21
- @description = front_matter["description"]
20
+ @name = front_matter["name"]
21
+ @description = front_matter["description"]
22
+ @capabilities = Capabilities.from_front_matter(front_matter)
22
23
 
23
24
  raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'name'" if @name.to_s.strip.empty?
24
25
  raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'description'" if @description.to_s.strip.empty?
@@ -41,7 +42,7 @@ module RobotLab
41
42
  # Non-executable scripts are skipped with a warning.
42
43
  def script_tools
43
44
  @script_tools ||= scripts.filter_map do |script_path|
44
- ScriptTool.from_path(script_path)
45
+ ScriptTool.from_path(script_path, capabilities: @capabilities, skill_dir: @path.to_s)
45
46
  end
46
47
  end
47
48
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Budget
5
+ # Thread-safe reserve/reconcile ledger for tracking consumption against
6
+ # per-dimension limits (e.g. :tokens, :cost).
7
+ #
8
+ # Call +reserve!+ before doing billable work, so an already-exhausted
9
+ # budget is caught before the work is attempted rather than after.
10
+ # Once the work completes, +reconcile!+ replaces the reservation with
11
+ # the actual amount consumed (which may be more or less than what was
12
+ # reserved — LLM call sizes aren't known in advance). +release!+ drops
13
+ # an unused reservation without recording any consumption.
14
+ #
15
+ # A dimension with no configured limit is treated as unlimited:
16
+ # +reserve!+ never raises for it and +remaining+ returns
17
+ # +Float::INFINITY+.
18
+ #
19
+ # @example
20
+ # ledger = RobotLab::Budget::Ledger.new(limits: { tokens: 10_000, cost: 0.50 })
21
+ # ledger.reserve!(:tokens, ledger.remaining(:tokens))
22
+ # # ... do the billable work ...
23
+ # ledger.reconcile!(:tokens, ledger.remaining(:tokens), actual_tokens_used)
24
+ class Ledger
25
+ # @!attribute [r] limits
26
+ # @return [Hash{Symbol=>Numeric}] per-dimension ceilings
27
+ # @!attribute [r] consumed
28
+ # @return [Hash{Symbol=>Numeric}] actual amount consumed per dimension so far
29
+ attr_reader :limits, :consumed
30
+
31
+ # @param limits [Hash{Symbol=>Numeric}] per-dimension ceilings; a dimension absent
32
+ # from this hash is treated as unlimited
33
+ # @param consumed [Hash{Symbol=>Numeric}] starting consumption (e.g. restored from a prior run)
34
+ def initialize(limits: {}, consumed: {})
35
+ @mutex = Mutex.new
36
+ @limits = limits
37
+ @consumed = Hash.new(0).merge(consumed)
38
+ @reserved = Hash.new(0)
39
+ end
40
+
41
+ # Reserves +amount+ against +key+'s remaining budget.
42
+ #
43
+ # A no-op (never raises) when +key+ has no configured limit.
44
+ #
45
+ # @param key [Symbol] the budget dimension
46
+ # @param amount [Numeric] the amount to reserve
47
+ # @raise [RobotLab::BudgetExceeded] if the reservation would exceed the limit
48
+ # @return [void]
49
+ def reserve!(key, amount)
50
+ @mutex.synchronize do
51
+ limit = @limits[key]
52
+ next unless limit
53
+
54
+ committed = @consumed[key] + @reserved[key]
55
+ if committed + amount > limit
56
+ raise BudgetExceeded, "budget exceeded for #{key}: #{committed + amount} > #{limit}"
57
+ end
58
+
59
+ @reserved[key] += amount
60
+ end
61
+ end
62
+
63
+ # Replaces a prior reservation with the actual amount consumed.
64
+ #
65
+ # @param key [Symbol] the budget dimension
66
+ # @param reserved_amount [Numeric] the amount previously passed to +reserve!+
67
+ # @param actual_amount [Numeric] the amount actually consumed
68
+ # @return [void]
69
+ def reconcile!(key, reserved_amount, actual_amount)
70
+ @mutex.synchronize do
71
+ @reserved[key] = [0, @reserved[key] - reserved_amount].max
72
+ @consumed[key] += actual_amount
73
+ end
74
+ end
75
+
76
+ # Drops a reservation without recording any consumption (e.g. the
77
+ # reserved work was skipped).
78
+ #
79
+ # @param key [Symbol] the budget dimension
80
+ # @param amount [Numeric] the amount previously passed to +reserve!+
81
+ # @return [void]
82
+ def release!(key, amount)
83
+ @mutex.synchronize { @reserved[key] = [0, @reserved[key] - amount].max }
84
+ end
85
+
86
+ # @param key [Symbol] the budget dimension
87
+ # @return [Numeric] remaining budget for +key+, floored at 0; +Float::INFINITY+ when unlimited
88
+ def remaining(key)
89
+ @mutex.synchronize do
90
+ limit = @limits[key]
91
+ next Float::INFINITY unless limit
92
+
93
+ [limit - @consumed[key] - @reserved[key], 0].max
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Declared execution capabilities for a skill script: which paths it may read
5
+ # and write, whether it may use the network, how long it may run, and how much
6
+ # it is trusted.
7
+ #
8
+ # A skill declares what it *wants* in SKILL.md front matter; the global config
9
+ # declares the *ceiling* of what any skill may have. The effective grant is the
10
+ # intersection of the two (see {#intersect}) — anything the skill asks for that
11
+ # the ceiling does not permit is dropped.
12
+ class Capabilities
13
+ DEFAULT_TIMEOUT = 60
14
+ TRUST_LEVELS = %w[core external].freeze
15
+
16
+ attr_reader :fs_read, :fs_write, :network, :timeout, :trust
17
+
18
+ def initialize(fs_read: [], fs_write: [], network: false, timeout: DEFAULT_TIMEOUT, trust: "external")
19
+ @fs_read = Array(fs_read).map(&:to_s)
20
+ @fs_write = Array(fs_write).map(&:to_s)
21
+ @network = network ? true : false
22
+ @timeout = timeout.to_i.positive? ? timeout.to_i : DEFAULT_TIMEOUT
23
+ @trust = TRUST_LEVELS.include?(trust.to_s) ? trust.to_s : "external"
24
+ end
25
+
26
+ # Build from a SKILL.md front matter hash (string or symbol keys).
27
+ def self.from_front_matter(front_matter)
28
+ fm = front_matter || {}
29
+ new(
30
+ fs_read: fm_value(fm, :fs_read, []),
31
+ fs_write: fm_value(fm, :fs_write, []),
32
+ network: fm_value(fm, :network, false),
33
+ timeout: fm_value(fm, :timeout, DEFAULT_TIMEOUT),
34
+ trust: fm_value(fm, :trust, "external")
35
+ )
36
+ end
37
+
38
+ # Look up a front-matter key tolerating string or symbol keys.
39
+ def self.fm_value(front_matter, key, default)
40
+ value = front_matter[key.to_s]
41
+ value = front_matter[key] if value.nil?
42
+ value.nil? ? default : value
43
+ end
44
+
45
+ # Build the ceiling from the global config's sandbox section.
46
+ def self.ceiling(config = RobotLab.config)
47
+ section = config.respond_to?(:sandbox) ? config.sandbox : nil
48
+ return new(fs_read: ["."]) unless section
49
+
50
+ new(
51
+ fs_read: section.fs_read || ["."],
52
+ fs_write: section.fs_write || [],
53
+ network: section.network || false,
54
+ timeout: section.timeout || DEFAULT_TIMEOUT
55
+ )
56
+ end
57
+
58
+ def core? = trust == "core"
59
+
60
+ # Effective grant = this (declared) ∩ ceiling. Paths survive only when
61
+ # inside a ceiling root; network requires both; timeout is the smaller;
62
+ # trust stays as declared.
63
+ def intersect(ceiling)
64
+ self.class.new(
65
+ fs_read: clamp_paths(@fs_read, ceiling.fs_read),
66
+ fs_write: clamp_paths(@fs_write, ceiling.fs_write),
67
+ network: @network && ceiling.network,
68
+ timeout: [@timeout, ceiling.timeout].min,
69
+ trust: @trust
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def clamp_paths(requested, allowed)
76
+ roots = expand(allowed)
77
+ expand(requested).select { |p| roots.any? { |r| p == r || p.start_with?("#{r}/") } }
78
+ end
79
+
80
+ def expand(paths)
81
+ Array(paths).map { |p| File.expand_path(p.to_s) }
82
+ end
83
+ end
84
+ end
@@ -25,6 +25,16 @@ defaults:
25
25
  mcp: :none
26
26
  tools: :none
27
27
 
28
+ # Skill-script sandboxing (opt-in). When enabled, skill scripts run confined
29
+ # to the effective grant (their SKILL.md declaration ∩ this ceiling). macOS
30
+ # uses sandbox-exec; other platforms run unconfined with a warning.
31
+ sandbox:
32
+ enabled: false # opt-in; off = scripts run exactly as before
33
+ fs_read: ["."] # ceiling: paths any skill may read (relative to cwd)
34
+ fs_write: [] # ceiling: paths any skill may write
35
+ network: false # ceiling: may any skill use the network
36
+ timeout: 60 # ceiling: max seconds a skill script may run
37
+
28
38
  # Chat Configuration (passed to RubyLLM.chat)
29
39
  # These are global defaults that can be overridden per-robot or per-run
30
40
  chat:
@@ -8,7 +8,8 @@ module RobotLab
8
8
  # Provides:
9
9
  # - Nested configuration with a dedicated `ruby_llm:` section
10
10
  # - Environment-specific settings (development, test, production)
11
- # - XDG config file loading (~/.config/robot_lab/config.yml)
11
+ # - XDG config file loading (~/.config/robot_lab/robot_lab.yml — the filename
12
+ # repeats the `config_name`; `config.yml` is never read)
12
13
  # - Environment variable overrides (ROBOT_LAB_*)
13
14
  # - Automatic RubyLLM configuration application
14
15
  #
@@ -21,10 +22,14 @@ module RobotLab
21
22
  # # ROBOT_LAB_RUBY_LLM__MODEL=gpt-4
22
23
  # # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
23
24
  #
24
- # @example User config file (~/.config/robot_lab/config.yml)
25
- # defaults:
26
- # ruby_llm:
27
- # anthropic_api_key: <%= ENV['ANTHROPIC_API_KEY'] %>
25
+ # @example User config file (~/.config/robot_lab/robot_lab.yml)
26
+ # # Flat keys, or a section named for the current environment. A `defaults:`
27
+ # # wrapper is IGNORED here — it applies only to the gem's bundled
28
+ # # defaults.yml. This file is NOT run through ERB, so keep secrets in
29
+ # # environment variables or in ./config/robot_lab.yml (which is).
30
+ # ruby_llm:
31
+ # model: claude-sonnet-4
32
+ # request_timeout: 120
28
33
  #
29
34
  class Config < MywayConfig::Base
30
35
  config_name :robot_lab
@@ -153,16 +158,16 @@ module RobotLab
153
158
  def resolved_template_path
154
159
  return template_path if template_path
155
160
 
156
- if defined?(Rails) && Rails.root
157
- Rails.root.join('app', 'prompts').to_s
161
+ if defined?(::Rails) && ::Rails.root
162
+ ::Rails.root.join('app', 'prompts').to_s
158
163
  else
159
164
  'prompts'
160
165
  end
161
166
  end
162
167
 
163
168
  def default_logger
164
- if defined?(Rails) && Rails.respond_to?(:logger)
165
- Rails.logger
169
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
170
+ ::Rails.logger
166
171
  else
167
172
  require 'logger'
168
173
  Logger.new($stdout, level: Logger::INFO)
@@ -26,9 +26,19 @@ module RobotLab
26
26
 
27
27
  # Raised when MCP communication fails.
28
28
  #
29
+ # @param retryable [Boolean, nil] opt-in hint for RobotLab::Errors.retryable? —
30
+ # set true at the raise site for transient failures (connection drop, timeout)
31
+ #
29
32
  # @example
30
- # raise MCPError, "Connection to MCP server refused"
31
- class MCPError < Error; end
33
+ # raise MCPError.new("Connection to MCP server refused", retryable: true)
34
+ class MCPError < Error
35
+ attr_reader :retryable
36
+
37
+ def initialize(message = nil, retryable: nil)
38
+ @retryable = retryable
39
+ super(message)
40
+ end
41
+ end
32
42
 
33
43
  # Raised when message bus communication fails.
34
44
  #
@@ -36,6 +46,16 @@ module RobotLab
36
46
  # raise BusError, "No bus configured on this robot"
37
47
  class BusError < Error; end
38
48
 
49
+ # Raised when a Robot's configured token or cost budget is already
50
+ # exhausted before an LLM call is attempted (see RobotLab::Budget::Ledger).
51
+ # Distinct from InferenceError, which covers a call that completed but
52
+ # pushed cumulative usage over budget — BudgetExceeded means the call
53
+ # was refused outright.
54
+ #
55
+ # @example
56
+ # raise BudgetExceeded, "budget exceeded for cost: 0.62 > 0.5"
57
+ class BudgetExceeded < Error; end
58
+
39
59
  # Raised when a robot's tool call loop exceeds the configured limit.
40
60
  #
41
61
  # @example
@@ -57,7 +77,17 @@ module RobotLab
57
77
 
58
78
  # Raised when a tool fails during execution, including inside a Ractor worker.
59
79
  #
80
+ # @param retryable [Boolean, nil] opt-in hint for RobotLab::Errors.retryable? —
81
+ # set true at the raise site for transient failures worth retrying
82
+ #
60
83
  # @example
61
- # raise ToolError, "Tool 'MyTool' failed: division by zero"
62
- class ToolError < Error; end
84
+ # raise ToolError.new("Tool 'MyTool' failed: division by zero")
85
+ class ToolError < Error
86
+ attr_reader :retryable
87
+
88
+ def initialize(message = nil, retryable: nil)
89
+ @retryable = retryable
90
+ super(message)
91
+ end
92
+ end
63
93
  end
@@ -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
@@ -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