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,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module RobotLab
6
+ module Sandbox
7
+ # macOS strategy: generates a deny-by-default sandbox-exec profile from the
8
+ # effective grant and wraps the command as
9
+ # sandbox-exec -f <profile> <cmd...>
10
+ #
11
+ # The profile allows process exec plus reads of system locations (so the
12
+ # interpreter can load), reads of the skill bundle and granted paths, writes
13
+ # only to granted paths (and the standard /dev sinks), and network only when
14
+ # granted. Everything else -- notably $HOME, so SSH/cloud credentials --
15
+ # is denied. Interpreters installed under $HOME (e.g. rbenv) are not visible;
16
+ # declare them in fs_read or mark the skill trust: core.
17
+ class Seatbelt
18
+ # System locations a typical interpreter needs to read to start up.
19
+ SYSTEM_READ = %w[/usr /bin /sbin /System /Library /opt /private/etc /dev /var/select].freeze
20
+ DEV_WRITE = %w[/dev/null /dev/stdout /dev/stderr /dev/dtracehelper /dev/tty].freeze
21
+
22
+ def initialize(grant, skill_dir:)
23
+ @grant = grant
24
+ @skill_dir = File.expand_path(skill_dir.to_s)
25
+ @profile = nil
26
+ end
27
+
28
+ def wrap(cmd)
29
+ @profile = write_profile
30
+ ["sandbox-exec", "-f", @profile, *cmd]
31
+ end
32
+
33
+ def cleanup
34
+ File.unlink(@profile) if @profile && File.exist?(@profile)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ # The generated Seatbelt profile text (public for testing).
40
+ def profile_text
41
+ reads = canonicalize(SYSTEM_READ + [@skill_dir] + @grant.fs_read)
42
+ writes = canonicalize(@grant.fs_write)
43
+ lines = [
44
+ "(version 1)",
45
+ # bsd.sb supplies the base rules a process needs to start (dyld, mach
46
+ # bootstrap, etc.); without it a deny-default profile aborts the binary.
47
+ '(import "bsd.sb")',
48
+ "(deny default)",
49
+ "(allow process-fork)",
50
+ "(allow process-exec)",
51
+ "(allow sysctl-read)",
52
+ "(allow mach-lookup)",
53
+ # Metadata (stat/lookup) on any path so the interpreter can traverse to
54
+ # granted files; reading file *contents* stays restricted below.
55
+ "(allow file-read-metadata)",
56
+ read_rule(reads),
57
+ write_rule(DEV_WRITE.map { |p| [:literal, p] } + writes.map { |p| [:subpath, p] })
58
+ ]
59
+ lines << "(allow network*)" if @grant.network
60
+ "#{lines.compact.join("\n")}\n"
61
+ end
62
+
63
+ private
64
+
65
+ def write_profile
66
+ file = Tempfile.create(["robot_lab-sandbox-", ".sb"])
67
+ file.write(profile_text)
68
+ file.close
69
+ file.path
70
+ end
71
+
72
+ # Resolve to the real (symlink-free) path the kernel matches against. macOS
73
+ # symlinks /tmp -> /private/tmp, /var -> /private/var, etc., so logical
74
+ # paths would never match. For not-yet-existing write targets, resolve the
75
+ # nearest existing ancestor and re-append the remainder.
76
+ def canonicalize(paths)
77
+ Array(paths).map { |p| real_path(File.expand_path(p.to_s)) }.compact.uniq
78
+ end
79
+
80
+ def real_path(expanded)
81
+ existing = expanded
82
+ rest = []
83
+ until File.exist?(existing) || existing == "/"
84
+ rest.unshift(File.basename(existing))
85
+ existing = File.dirname(existing)
86
+ end
87
+ real = File.realpath(existing)
88
+ rest.empty? ? real : File.join(real, *rest)
89
+ rescue StandardError
90
+ expanded
91
+ end
92
+
93
+ def read_rule(paths)
94
+ subpaths = paths.map { |p| "(subpath #{p.inspect})" }.join(" ")
95
+ "(allow file-read* #{subpaths})"
96
+ end
97
+
98
+ def write_rule(entries)
99
+ clauses = entries.map { |kind, p| "(#{kind} #{p.inspect})" }.join(" ")
100
+ "(allow file-write* #{clauses})"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Confines skill-script execution to a granted set of capabilities.
5
+ #
6
+ # Sandboxing is opt-in (config.sandbox.enabled). When off, every script runs
7
+ # unconfined exactly as before. When on, each script runs under a strategy:
8
+ #
9
+ # - {Sandbox::Seatbelt} on macOS — a generated deny-by-default sandbox-exec
10
+ # profile derived from the effective {Capabilities} grant.
11
+ # - {Sandbox::Null} elsewhere (or for +trust: core+ skills) — a passthrough.
12
+ #
13
+ # OS-level confinement is therefore best-effort and platform-specific; it is
14
+ # never a hard dependency.
15
+ module Sandbox
16
+ module_function
17
+
18
+ # @return [Boolean] whether sandboxing is turned on in config
19
+ def enabled?(config = RobotLab.config)
20
+ config.respond_to?(:sandbox) && config.sandbox && config.sandbox.enabled == true
21
+ end
22
+
23
+ def macos?
24
+ RUBY_PLATFORM.include?("darwin")
25
+ end
26
+
27
+ # Pick a strategy for the given (already-intersected) grant.
28
+ #
29
+ # @param grant [Capabilities] effective grant
30
+ # @param skill_dir [String] skill bundle root, always granted read access
31
+ # @param macos [Boolean] whether to use the macOS strategy; defaults to the
32
+ # real platform check and is injectable so both branches are testable on
33
+ # any host without stubbing.
34
+ # @return [#wrap, #cleanup]
35
+ def for(grant, skill_dir:, macos: macos?)
36
+ return Null.new if grant.core?
37
+ return Seatbelt.new(grant, skill_dir: skill_dir) if macos
38
+
39
+ warn_once_non_macos
40
+ Null.new
41
+ end
42
+
43
+ def warn_once_non_macos
44
+ return if @warned_non_macos
45
+
46
+ @warned_non_macos = true
47
+ RobotLab.config.logger.warn(
48
+ "Sandbox: OS-level confinement is only available on macOS; scripts run unconfined here"
49
+ )
50
+ end
51
+ end
52
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'shellwords'
5
+ require 'timeout'
5
6
 
6
7
  module RobotLab
7
8
  # Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.
@@ -14,7 +15,11 @@ module RobotLab
14
15
  #
15
16
  # @param script_path [String, Pathname] path to the script file
16
17
  # @return [RobotLab::Tool, nil] nil if the script is not executable
17
- def self.from_path(script_path)
18
+ # @param script_path [String, Pathname] path to the script file
19
+ # @param capabilities [Capabilities, nil] declared capabilities (from SKILL.md)
20
+ # @param skill_dir [String, nil] skill bundle root (defaults to the script's dir)
21
+ # @return [RobotLab::Tool, nil] nil if the script is not executable
22
+ def self.from_path(script_path, capabilities: nil, skill_dir: nil)
18
23
  path = Pathname.new(script_path)
19
24
 
20
25
  unless path.executable?
@@ -27,6 +32,8 @@ module RobotLab
27
32
  tool_name = derive_name(path)
28
33
  description = extract_description(path)
29
34
  script = path.to_s
35
+ caps = capabilities || Capabilities.new
36
+ dir = skill_dir || path.dirname.to_s
30
37
 
31
38
  Tool.create(
32
39
  name: tool_name,
@@ -41,11 +48,62 @@ module RobotLab
41
48
  ) do |tool_args|
42
49
  cli_args = tool_args[:args].to_s.strip
43
50
  cmd = cli_args.empty? ? ['bash', script] : ['bash', script, *Shellwords.split(cli_args)]
51
+ ScriptTool.execute(cmd, capabilities: caps, skill_dir: dir)
52
+ end
53
+ end
54
+
55
+ # Run a command, optionally confined by the sandbox and a timeout.
56
+ #
57
+ # When sandboxing is disabled (the default) this is the original, unconfined
58
+ # capture2e path with no timeout — behaviour is unchanged. When enabled, the
59
+ # command is wrapped by the sandbox strategy for the effective grant and
60
+ # bounded by the grant's timeout.
61
+ #
62
+ # @return [String] combined stdout+stderr, or an error string on failure
63
+ def self.execute(cmd, capabilities:, skill_dir:)
64
+ unless Sandbox.enabled?
44
65
  output, status = Open3.capture2e(*cmd)
45
- status.success? ? output : "Error (exit #{status.exitstatus}):\n#{output}"
66
+ return format_result(output, status)
67
+ end
68
+
69
+ grant = capabilities.intersect(Capabilities.ceiling)
70
+ sandbox = Sandbox.for(grant, skill_dir: skill_dir)
71
+ begin
72
+ output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
73
+ format_result(output, status)
74
+ ensure
75
+ sandbox.cleanup
46
76
  end
47
77
  end
48
78
 
79
+ # @return [Array(String, Process::Status|nil)] output and status (nil = timed out)
80
+ def self.run_with_timeout(cmd, timeout)
81
+ Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
82
+ stdin.close
83
+ output = +''
84
+ begin
85
+ Timeout.timeout(timeout) { output << out.read }
86
+ rescue Timeout::Error
87
+ terminate(wait.pid)
88
+ return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
89
+ end
90
+ [output, wait.value]
91
+ end
92
+ end
93
+
94
+ def self.terminate(pid)
95
+ Process.kill('-TERM', Process.getpgid(pid))
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ # @param status [Process::Status, nil] nil indicates a timeout kill
101
+ def self.format_result(output, status)
102
+ return "Error (timed out):\n#{output}" if status.nil?
103
+
104
+ status.success? ? output : "Error (exit #{status.exitstatus}):\n#{output}"
105
+ end
106
+
49
107
  # @param path [Pathname]
50
108
  # @return [String] snake_case tool name derived from filename
51
109
  def self.derive_name(path)
@@ -39,7 +39,7 @@ module RobotLab
39
39
  # @param tools [Symbol, Array] tools config (:none, :inherit, or array)
40
40
  # @param memory [Memory, Hash, nil] task-specific memory
41
41
  #
42
- def initialize(name:, robot:, context: {}, mcp: :none, tools: :none, memory: nil, config: nil)
42
+ def initialize(name:, robot:, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, network: nil)
43
43
  @name = name.to_sym
44
44
  @robot = robot
45
45
  @context = context
@@ -47,6 +47,7 @@ module RobotLab
47
47
  @tools = tools
48
48
  @memory = memory
49
49
  @config = config
50
+ @network = network
50
51
  end
51
52
 
52
53
  # SimpleFlow step interface
@@ -58,28 +59,17 @@ module RobotLab
58
59
  # @return [SimpleFlow::Result] result with robot output
59
60
  #
60
61
  def call(result)
61
- # Get current run params and deep merge with task context
62
- run_params = deep_merge(
63
- result.context[:run_params] || {},
64
- @context
62
+ context = TaskHookContext.new(
63
+ network: @network,
64
+ task: self,
65
+ robot: @robot,
66
+ memory: @memory || @network&.memory,
67
+ config: @config
65
68
  )
66
69
 
67
- # Add task-specific robot config
68
- run_params[:mcp] = @mcp unless @mcp == :none
69
- run_params[:tools] = @tools unless @tools == :none
70
- run_params[:memory] = @memory if @memory
71
-
72
- # Merge task's config on top of network's config
73
- if @config
74
- network_rc = run_params[:network_config]
75
- run_params[:network_config] = network_rc ? network_rc.merge(@config) : @config
70
+ RobotLab::Hooks.run(:task, context, registries: [RobotLab.hooks, @network&.hooks]) do
71
+ @robot.call(enhanced_result(result))
76
72
  end
77
-
78
- # Create enhanced result with merged params
79
- enhanced_result = result.with_context(:run_params, run_params)
80
-
81
- # Delegate to robot
82
- @robot.call(enhanced_result)
83
73
  end
84
74
 
85
75
  # Converts the task to a hash representation.
@@ -99,6 +89,22 @@ module RobotLab
99
89
 
100
90
  private
101
91
 
92
+ def enhanced_result(result)
93
+ run_params = deep_merge(result.context[:run_params] || {}, @context)
94
+ run_params[:mcp] = @mcp unless @mcp == :none
95
+ run_params[:tools] = @tools unless @tools == :none
96
+ run_params[:memory] = @memory if @memory
97
+ run_params[:task] = self
98
+ run_params[:network] = @network if @network
99
+
100
+ if @config
101
+ network_rc = run_params[:network_config]
102
+ run_params[:network_config] = network_rc ? network_rc.merge(@config) : @config
103
+ end
104
+
105
+ result.with_context(:run_params, run_params)
106
+ end
107
+
102
108
  # Deep merge two hashes
103
109
  #
104
110
  # Values from `override` take precedence. Nested hashes are merged
@@ -94,19 +94,17 @@ module RobotLab
94
94
  # @param args [Hash] the tool arguments from the LLM
95
95
  # @return [Object] the tool result or an error string
96
96
  def call(args)
97
- if self.class.ractor_safe? && !self.class.name.nil? && RobotLab.extension_loaded?(:ractor)
98
- RobotLab.ractor_pool.submit(self.class.name, args)
99
- else
100
- super
97
+ context = ToolCallHookContext.new(tool: self, tool_args: args, robot: @robot)
98
+
99
+ RobotLab::Hooks.run(:tool_call, context, **tool_hook_options) do
100
+ context.tool_result = use_ractor_pool? ? execute_ractor_tool_call(args) : super
101
+ rescue RobotLab::ToolError => e
102
+ handle_tool_error(context, e)
103
+ rescue StandardError => e
104
+ handle_standard_error(context, e)
101
105
  end
102
- rescue RobotLab::ToolError => e
103
- raise if self.class.raise_on_error?
104
- "Error (#{name}): #{e.message}"
105
- rescue StandardError => e
106
- raise if self.class.raise_on_error?
107
106
 
108
- RobotLab.config.logger.warn("Tool '#{name}' error: #{e.class}: #{e.message}")
109
- "Error (#{name}): #{e.message}"
107
+ context.tool_result
110
108
  end
111
109
 
112
110
  # Override name to support explicit names for dynamic/MCP tools.
@@ -210,6 +208,49 @@ module RobotLab
210
208
 
211
209
  private
212
210
 
211
+ def tool_hook_options
212
+ hook_scope = RobotLab.current_hook_scope
213
+
214
+ {
215
+ registries: tool_hook_registries(hook_scope),
216
+ per_run_hooks: hook_scope && hook_scope[:per_run_hooks]
217
+ }
218
+ end
219
+
220
+ def tool_hook_registries(hook_scope)
221
+ return hook_scope[:registries] if hook_scope
222
+
223
+ [RobotLab.hooks, robot_hook_registry]
224
+ end
225
+
226
+ def robot_hook_registry
227
+ @robot.hooks if @robot.respond_to?(:hooks)
228
+ end
229
+
230
+ def execute_ractor_tool_call(args)
231
+ RobotLab.ractor_pool.submit(self.class.name, args)
232
+ end
233
+
234
+ def use_ractor_pool?
235
+ self.class.ractor_safe? && !self.class.name.nil? && RobotLab.extension_loaded?(:ractor)
236
+ end
237
+
238
+ def handle_tool_error(context, error)
239
+ context.tool_error = error
240
+ raise if self.class.raise_on_error?
241
+
242
+ suffix = RobotLab::Errors.retryable?(error) ? " (retryable)" : ""
243
+ context.tool_result = "Error (#{name}): #{error.message}#{suffix}"
244
+ end
245
+
246
+ def handle_standard_error(context, error)
247
+ context.tool_error = error
248
+ raise if self.class.raise_on_error?
249
+
250
+ RobotLab.config.logger.warn("Tool '#{name}' error: #{error.class}: #{error.message}")
251
+ context.tool_result = "Error (#{name}): #{error.message}"
252
+ end
253
+
213
254
  def deep_symbolize_keys(obj)
214
255
  case obj
215
256
  when Hash
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.7"
5
5
  end
data/lib/robot_lab.rb CHANGED
@@ -24,9 +24,9 @@ require 'typed_bus'
24
24
  #
25
25
  # @example Creating a network of robots
26
26
  # network = RobotLab.create_network(name: "pipeline") do
27
- # step :analyzer, analyzer, depends_on: :none
28
- # step :writer, writer, depends_on: [:analyzer]
29
- # step :reviewer, reviewer, depends_on: [:writer]
27
+ # task :analyzer, analyzer, depends_on: :none
28
+ # task :writer, writer, depends_on: [:analyzer]
29
+ # task :reviewer, reviewer, depends_on: [:writer]
30
30
  # end
31
31
  # result = network.run(message: "Process this document")
32
32
  #
@@ -35,7 +35,7 @@ require 'typed_bus'
35
35
  # # ROBOT_LAB_DEFAULT_MODEL=gpt-4
36
36
  # # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
37
37
  #
38
- # # Or via config files (~/.config/robot_lab/config.yml or ./config/robot_lab.yml)
38
+ # # Or via config files (~/.config/robot_lab/robot_lab.yml or ./config/robot_lab.yml)
39
39
  # # See lib/robot_lab/config/defaults.yml for all options
40
40
  #
41
41
  # # Access configuration values:
@@ -47,6 +47,7 @@ end
47
47
 
48
48
  loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
49
49
  loader.ignore("#{__dir__}/robot_lab/robot")
50
+ loader.ignore("#{__dir__}/robot_lab/names.rb")
50
51
 
51
52
  # Custom inflections for classes that don't follow Zeitwerk naming conventions
52
53
  loader.inflector.inflect(
@@ -64,9 +65,18 @@ loader.setup
64
65
  # individually (e.g. TextMessage lives in message.rb, not text_message.rb).
65
66
  # Require them explicitly so their constants are available without eager loading.
66
67
  require_relative 'robot_lab/error'
68
+ require_relative 'robot_lab/hook_context'
69
+ require_relative 'robot_lab/hook'
70
+ require_relative 'robot_lab/hook_registry'
71
+ require_relative 'robot_lab/hooks'
67
72
  require_relative 'robot_lab/message'
68
73
  require_relative 'robot_lab/memory'
69
74
 
75
+ # names.rb defines the NAMES data constant plus helper methods directly on
76
+ # RobotLab, not a RobotLab::Names class/module, so Zeitwerk can't autoload it
77
+ # by convention — ignored above and required explicitly instead.
78
+ require_relative 'robot_lab/names'
79
+
70
80
  # Eager load everything in Rails or when explicitly requested.
71
81
  # Otherwise Zeitwerk's lazy autoloading keeps boot fast.
72
82
  loader.eager_load if defined?(Rails::Engine) || ENV["ROBOT_LAB_EAGER_LOAD"]
@@ -109,12 +119,39 @@ module RobotLab
109
119
  @extensions.keys
110
120
  end
111
121
 
122
+ def hooks
123
+ @hooks ||= HookRegistry.new
124
+ end
125
+
126
+ def on(handler_class, context: nil)
127
+ hooks.on(handler_class, context: context)
128
+ end
129
+
130
+ def clear_hooks!
131
+ @hooks = HookRegistry.new
132
+ end
133
+
134
+ def with_hook_scope(registries, per_run_hooks)
135
+ previous = Thread.current[:robot_lab_hook_scope]
136
+ Thread.current[:robot_lab_hook_scope] = {
137
+ registries: registries,
138
+ per_run_hooks: per_run_hooks
139
+ }
140
+ yield
141
+ ensure
142
+ Thread.current[:robot_lab_hook_scope] = previous
143
+ end
144
+
145
+ def current_hook_scope
146
+ Thread.current[:robot_lab_hook_scope]
147
+ end
148
+
112
149
  # Returns the Config object (MywayConfig-based).
113
150
  #
114
151
  # Configuration is automatically loaded from:
115
152
  # - Bundled defaults (lib/robot_lab/config/defaults.yml)
116
153
  # - Environment-specific overrides (development, test, production)
117
- # - XDG config files (~/.config/robot_lab/config.yml)
154
+ # - XDG config file (~/.config/robot_lab/robot_lab.yml)
118
155
  # - Project config (./config/robot_lab.yml)
119
156
  # - Environment variables (ROBOT_LAB_*)
120
157
  #
@@ -152,6 +189,24 @@ module RobotLab
152
189
  config
153
190
  end
154
191
 
192
+ # Render a named prompt template to a String using the configured template
193
+ # library (the prompts_dir from config / ROBOT_LAB_TEMPLATE_PATH). Front
194
+ # matter parameters are supplied as keyword arguments.
195
+ #
196
+ # Unlike #build (which renders a template as a robot's *system prompt*), this
197
+ # returns the plain text, so callers can use it as a task, a message, or a
198
+ # grading rubric.
199
+ #
200
+ # @param name [Symbol, String] template id (filename without extension)
201
+ # @param context [Hash] values for the template's parameters
202
+ # @return [String] the rendered prompt text
203
+ #
204
+ # @example
205
+ # RobotLab.render_template(:objective, topic: "commit messages")
206
+ def render_template(name, **context)
207
+ PM.parse(name.to_sym).to_s(**context)
208
+ end
209
+
155
210
  # Factory method to create a new Robot instance.
156
211
  #
157
212
  # @param name [String, nil] the unique identifier for the robot (auto-generated if nil)
@@ -197,28 +252,28 @@ module RobotLab
197
252
  #
198
253
  # @param name [String] the unique identifier for the network
199
254
  # @param concurrency [Symbol] concurrency model (:auto, :threads, :async)
200
- # @yield Block for defining pipeline steps
255
+ # @yield Block for defining pipeline tasks (the DSL method is `task`)
201
256
  # @return [Network] a new Network instance
202
257
  #
203
258
  # @example Sequential pipeline
204
259
  # network = RobotLab.create_network(name: "pipeline") do
205
- # step :first, robot1, depends_on: :none
206
- # step :second, robot2, depends_on: [:first]
260
+ # task :first, robot1, depends_on: :none
261
+ # task :second, robot2, depends_on: [:first]
207
262
  # end
208
263
  #
209
264
  # @example With optional routing
210
265
  # network = RobotLab.create_network(name: "support") do
211
- # step :classifier, classifier, depends_on: :none
212
- # step :billing, billing_robot, depends_on: :optional
213
- # step :technical, technical_robot, depends_on: :optional
266
+ # task :classifier, classifier, depends_on: :none
267
+ # task :billing, billing_robot, depends_on: :optional
268
+ # task :technical, technical_robot, depends_on: :optional
214
269
  # end
215
270
  #
216
271
  # @example Parallel execution
217
272
  # network = RobotLab.create_network(name: "analysis") do
218
- # step :fetch, fetcher, depends_on: :none
219
- # step :sentiment, sentiment_bot, depends_on: [:fetch]
220
- # step :entities, entity_bot, depends_on: [:fetch]
221
- # step :merge, merger, depends_on: [:sentiment, :entities]
273
+ # task :fetch, fetcher, depends_on: :none
274
+ # task :sentiment, sentiment_bot, depends_on: [:fetch]
275
+ # task :entities, entity_bot, depends_on: [:fetch]
276
+ # task :merge, merger, depends_on: [:sentiment, :entities]
222
277
  # end
223
278
  def create_network(name:, concurrency: :auto, config: nil, &)
224
279
  Network.new(name: name, concurrency: concurrency, config: config, &)
data/mkdocs.yml CHANGED
@@ -170,16 +170,22 @@ nav:
170
170
  - Streaming Responses: guides/streaming.md
171
171
  - Memory System: guides/memory.md
172
172
  - Observability & Safety: guides/observability.md
173
+ - Hook System: guides/hooks.md
173
174
  - Knowledge Search: guides/knowledge.md
174
175
  - API Reference:
175
176
  - api/index.md
176
177
  - Core Classes:
177
178
  - api/core/index.md
178
179
  - Robot: api/core/robot.md
180
+ - RobotResult: api/core/result.md
179
181
  - Network: api/core/network.md
180
- - State: api/core/state.md
181
182
  - Tool: api/core/tool.md
182
183
  - Memory: api/core/memory.md
184
+ - StateProxy: api/core/state.md
185
+ - Hooks: api/hooks.md
186
+ - Skills: api/skills.md
187
+ - Support Classes: api/support.md
188
+ - Errors: api/errors.md
183
189
  - Messages:
184
190
  - api/messages/index.md
185
191
  - UserMessage: api/messages/user-message.md