robot_lab 0.2.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +61 -0
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +1 -0
  10. data/docs/api/core/network.md +31 -1
  11. data/docs/api/core/robot.md +91 -5
  12. data/docs/api/errors.md +47 -2
  13. data/docs/api/index.md +3 -0
  14. data/docs/architecture/core-concepts.md +40 -0
  15. data/docs/architecture/network-orchestration.md +8 -0
  16. data/docs/architecture/robot-execution.md +1 -1
  17. data/docs/getting-started/configuration.md +20 -1
  18. data/docs/guides/building-robots.md +39 -1
  19. data/docs/guides/creating-networks.md +6 -1
  20. data/docs/guides/hooks.md +997 -0
  21. data/docs/guides/index.md +5 -0
  22. data/docs/guides/knowledge.md +2 -2
  23. data/docs/guides/observability.md +74 -7
  24. data/docs/guides/using-tools.md +69 -0
  25. data/docs/index.md +1 -1
  26. data/examples/03_network.rb +11 -2
  27. data/examples/34_agentskills.rb +0 -0
  28. data/examples/35_hooks.rb +256 -0
  29. data/examples/README.md +7 -0
  30. data/examples/common.rb +7 -3
  31. data/examples/xyzzy.rb +90 -0
  32. data/lib/robot_lab/agent_skill.rb +5 -4
  33. data/lib/robot_lab/budget/ledger.rb +98 -0
  34. data/lib/robot_lab/capabilities.rb +84 -0
  35. data/lib/robot_lab/config/defaults.yml +10 -0
  36. data/lib/robot_lab/config.rb +4 -4
  37. data/lib/robot_lab/error.rb +34 -4
  38. data/lib/robot_lab/errors.rb +45 -0
  39. data/lib/robot_lab/hook.rb +79 -0
  40. data/lib/robot_lab/hook_context.rb +194 -0
  41. data/lib/robot_lab/hook_registry.rb +55 -0
  42. data/lib/robot_lab/hooks.rb +87 -0
  43. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  44. data/lib/robot_lab/narrator.rb +87 -0
  45. data/lib/robot_lab/network.rb +77 -20
  46. data/lib/robot_lab/robot/budget.rb +89 -0
  47. data/lib/robot_lab/robot/bus_messaging.rb +76 -12
  48. data/lib/robot_lab/robot/hooking.rb +56 -0
  49. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  50. data/lib/robot_lab/robot/template_rendering.rb +2 -2
  51. data/lib/robot_lab/robot.rb +211 -88
  52. data/lib/robot_lab/run_config.rb +24 -5
  53. data/lib/robot_lab/runnable.rb +51 -0
  54. data/lib/robot_lab/sandbox/null.rb +13 -0
  55. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  56. data/lib/robot_lab/sandbox.rb +52 -0
  57. data/lib/robot_lab/script_tool.rb +60 -2
  58. data/lib/robot_lab/task.rb +26 -20
  59. data/lib/robot_lab/tool.rb +52 -11
  60. data/lib/robot_lab/version.rb +1 -1
  61. data/lib/robot_lab.rb +49 -0
  62. data/mkdocs.yml +1 -0
  63. metadata +36 -99
  64. data/examples/temp.md +0 -51
  65. data/site/404.html +0 -2300
  66. data/site/api/core/index.html +0 -2706
  67. data/site/api/core/memory/index.html +0 -3793
  68. data/site/api/core/network/index.html +0 -3500
  69. data/site/api/core/robot/index.html +0 -4566
  70. data/site/api/core/state/index.html +0 -3390
  71. data/site/api/core/tool/index.html +0 -3843
  72. data/site/api/index.html +0 -2635
  73. data/site/api/mcp/client/index.html +0 -3435
  74. data/site/api/mcp/index.html +0 -2783
  75. data/site/api/mcp/server/index.html +0 -3252
  76. data/site/api/mcp/transports/index.html +0 -3352
  77. data/site/api/messages/index.html +0 -2641
  78. data/site/api/messages/text-message/index.html +0 -3087
  79. data/site/api/messages/tool-call-message/index.html +0 -3159
  80. data/site/api/messages/tool-result-message/index.html +0 -3252
  81. data/site/api/messages/user-message/index.html +0 -3212
  82. data/site/api/streaming/context/index.html +0 -3282
  83. data/site/api/streaming/events/index.html +0 -3347
  84. data/site/api/streaming/index.html +0 -2738
  85. data/site/architecture/core-concepts/index.html +0 -3757
  86. data/site/architecture/index.html +0 -2797
  87. data/site/architecture/message-flow/index.html +0 -3238
  88. data/site/architecture/network-orchestration/index.html +0 -3433
  89. data/site/architecture/robot-execution/index.html +0 -3140
  90. data/site/architecture/state-management/index.html +0 -3498
  91. data/site/assets/css/custom.css +0 -56
  92. data/site/assets/images/favicon.png +0 -0
  93. data/site/assets/images/robot_lab.jpg +0 -0
  94. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  95. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  96. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  97. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  98. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  99. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  100. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  101. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  102. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  103. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  104. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  105. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  106. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  107. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  108. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  109. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  110. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  111. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  112. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  113. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  114. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  115. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  116. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  117. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  118. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  119. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  120. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  121. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  122. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  123. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  124. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  125. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  126. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  127. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  128. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  129. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  130. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  131. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  132. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  133. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  134. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  135. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  136. data/site/concepts/index.html +0 -3455
  137. data/site/examples/basic-chat/index.html +0 -2880
  138. data/site/examples/index.html +0 -2907
  139. data/site/examples/mcp-server/index.html +0 -3018
  140. data/site/examples/multi-robot-network/index.html +0 -3131
  141. data/site/examples/rails-application/index.html +0 -3329
  142. data/site/examples/tool-usage/index.html +0 -3085
  143. data/site/getting-started/configuration/index.html +0 -3745
  144. data/site/getting-started/index.html +0 -2572
  145. data/site/getting-started/installation/index.html +0 -2981
  146. data/site/getting-started/quick-start/index.html +0 -2942
  147. data/site/guides/building-robots/index.html +0 -4290
  148. data/site/guides/creating-networks/index.html +0 -3858
  149. data/site/guides/index.html +0 -2586
  150. data/site/guides/mcp-integration/index.html +0 -3581
  151. data/site/guides/memory/index.html +0 -3586
  152. data/site/guides/rails-integration/index.html +0 -4019
  153. data/site/guides/streaming/index.html +0 -3157
  154. data/site/guides/using-tools/index.html +0 -3802
  155. data/site/index.html +0 -2671
  156. data/site/search/search_index.json +0 -1
  157. data/site/sitemap.xml +0 -183
  158. data/site/sitemap.xml.gz +0 -0
  159. data/site/tags.json +0 -1
@@ -0,0 +1,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.6"
5
5
  end
data/lib/robot_lab.rb CHANGED
@@ -64,6 +64,10 @@ loader.setup
64
64
  # individually (e.g. TextMessage lives in message.rb, not text_message.rb).
65
65
  # Require them explicitly so their constants are available without eager loading.
66
66
  require_relative 'robot_lab/error'
67
+ require_relative 'robot_lab/hook_context'
68
+ require_relative 'robot_lab/hook'
69
+ require_relative 'robot_lab/hook_registry'
70
+ require_relative 'robot_lab/hooks'
67
71
  require_relative 'robot_lab/message'
68
72
  require_relative 'robot_lab/memory'
69
73
 
@@ -109,6 +113,33 @@ module RobotLab
109
113
  @extensions.keys
110
114
  end
111
115
 
116
+ def hooks
117
+ @hooks ||= HookRegistry.new
118
+ end
119
+
120
+ def on(handler_class, context: nil)
121
+ hooks.on(handler_class, context: context)
122
+ end
123
+
124
+ def clear_hooks!
125
+ @hooks = HookRegistry.new
126
+ end
127
+
128
+ def with_hook_scope(registries, per_run_hooks)
129
+ previous = Thread.current[:robot_lab_hook_scope]
130
+ Thread.current[:robot_lab_hook_scope] = {
131
+ registries: registries,
132
+ per_run_hooks: per_run_hooks
133
+ }
134
+ yield
135
+ ensure
136
+ Thread.current[:robot_lab_hook_scope] = previous
137
+ end
138
+
139
+ def current_hook_scope
140
+ Thread.current[:robot_lab_hook_scope]
141
+ end
142
+
112
143
  # Returns the Config object (MywayConfig-based).
113
144
  #
114
145
  # Configuration is automatically loaded from:
@@ -152,6 +183,24 @@ module RobotLab
152
183
  config
153
184
  end
154
185
 
186
+ # Render a named prompt template to a String using the configured template
187
+ # library (the prompts_dir from config / ROBOT_LAB_TEMPLATE_PATH). Front
188
+ # matter parameters are supplied as keyword arguments.
189
+ #
190
+ # Unlike #build (which renders a template as a robot's *system prompt*), this
191
+ # returns the plain text, so callers can use it as a task, a message, or a
192
+ # grading rubric.
193
+ #
194
+ # @param name [Symbol, String] template id (filename without extension)
195
+ # @param context [Hash] values for the template's parameters
196
+ # @return [String] the rendered prompt text
197
+ #
198
+ # @example
199
+ # RobotLab.render_template(:objective, topic: "commit messages")
200
+ def render_template(name, **context)
201
+ PM.parse(name.to_sym).to_s(**context)
202
+ end
203
+
155
204
  # Factory method to create a new Robot instance.
156
205
  #
157
206
  # @param name [String, nil] the unique identifier for the robot (auto-generated if nil)
data/mkdocs.yml CHANGED
@@ -170,6 +170,7 @@ 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