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
@@ -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:
@@ -153,16 +153,16 @@ module RobotLab
153
153
  def resolved_template_path
154
154
  return template_path if template_path
155
155
 
156
- if defined?(Rails) && Rails.root
157
- Rails.root.join('app', 'prompts').to_s
156
+ if defined?(::Rails) && ::Rails.root
157
+ ::Rails.root.join('app', 'prompts').to_s
158
158
  else
159
159
  'prompts'
160
160
  end
161
161
  end
162
162
 
163
163
  def default_logger
164
- if defined?(Rails) && Rails.respond_to?(:logger)
165
- Rails.logger
164
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
165
+ ::Rails.logger
166
166
  else
167
167
  require 'logger'
168
168
  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
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class HookContext
5
+ attr_reader :event, :metadata
6
+
7
+ def initialize(event:, metadata: nil)
8
+ @event = event.to_sym
9
+ @metadata = metadata || ExtensionState.new
10
+ end
11
+
12
+ def ext(name)
13
+ metadata.ext(name)
14
+ end
15
+
16
+ def local
17
+ raise ArgumentError, "No hook namespace active" unless @namespace
18
+
19
+ ext(@namespace)
20
+ end
21
+
22
+ def with_namespace(namespace)
23
+ previous = @namespace
24
+ @namespace = namespace&.to_sym
25
+ yield self
26
+ ensure
27
+ @namespace = previous
28
+ end
29
+
30
+ def to_h
31
+ public_snapshot.merge(metadata: metadata.to_h)
32
+ end
33
+
34
+ private
35
+
36
+ def public_snapshot
37
+ public_methods(false)
38
+ .grep_v(/=\z/)
39
+ .reject { |name| %i[to_h local].include?(name) }
40
+ .sort
41
+ .to_h { |name| [name, public_send(name)] }
42
+ end
43
+ end
44
+
45
+ class RunHookContext < HookContext
46
+ attr_reader :robot, :network, :task, :memory, :config
47
+ attr_accessor :request, :response, :error
48
+
49
+ def initialize(robot:, request:, network: nil, task: nil, memory: nil, config: nil, response: nil, error: nil, **)
50
+ super(event: :run, **)
51
+ @robot = robot
52
+ @network = network
53
+ @task = task
54
+ @memory = memory
55
+ @config = config
56
+ @request = request
57
+ @response = response
58
+ @error = error
59
+ end
60
+ end
61
+
62
+ class LlmGenerationHookContext < RunHookContext
63
+ attr_reader :iteration
64
+ attr_accessor :generation_response
65
+
66
+ def initialize(iteration: 0, generation_response: nil, **)
67
+ super(**)
68
+ @iteration = iteration
69
+ @generation_response = generation_response
70
+ end
71
+ end
72
+
73
+ class ToolCallHookContext < HookContext
74
+ attr_reader :tool, :tool_name, :tool_args, :robot
75
+ attr_accessor :tool_result, :tool_error
76
+
77
+ def initialize(tool:, tool_args:, robot: nil, tool_result: nil, tool_error: nil, **)
78
+ super(event: :tool_call, **)
79
+ @tool = tool
80
+ @tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
81
+ @tool_args = tool_args
82
+ @robot = robot
83
+ @tool_result = tool_result
84
+ @tool_error = tool_error
85
+ end
86
+ end
87
+
88
+ class NetworkRunHookContext < HookContext
89
+ attr_reader :network, :memory, :config
90
+ attr_accessor :context, :result, :error
91
+
92
+ def initialize(network:, context:, memory: nil, config: nil, result: nil, error: nil, **)
93
+ super(event: :network_run, **)
94
+ @network = network
95
+ @context = context
96
+ @memory = memory
97
+ @config = config
98
+ @result = result
99
+ @error = error
100
+ end
101
+ end
102
+
103
+ class TaskHookContext < HookContext
104
+ attr_reader :network, :task, :task_name, :robot, :memory, :config
105
+ attr_accessor :result, :error
106
+
107
+ def initialize(task:, network: nil, robot: nil, memory: nil, config: nil, result: nil, error: nil, **)
108
+ super(event: :task, **)
109
+ @network = network
110
+ @task = task
111
+ @task_name = task.name
112
+ @robot = robot || task.robot
113
+ @memory = memory
114
+ @config = config
115
+ @result = result
116
+ @error = error
117
+ end
118
+ end
119
+
120
+ class LearnHookContext < HookContext
121
+ attr_reader :robot, :text, :learnings_before
122
+ attr_accessor :stored, :error
123
+
124
+ def initialize(robot:, text:, learnings_before:, **)
125
+ super(event: :learn, **)
126
+ @robot = robot
127
+ @text = text
128
+ @learnings_before = learnings_before.freeze
129
+ @stored = false
130
+ end
131
+ end
132
+
133
+ class CompactionHookContext < HookContext
134
+ attr_reader :robot, :messages_before, :config, :strategy
135
+ attr_accessor :compacted_messages, :error
136
+
137
+ def initialize(robot:, messages_before:, config:, strategy:, **)
138
+ super(event: :compaction, **)
139
+ @robot = robot
140
+ @messages_before = messages_before.freeze
141
+ @config = config
142
+ @strategy = strategy
143
+ @compacted_messages = nil
144
+ end
145
+
146
+ # True once an on_compaction handler has supplied a replacement message set.
147
+ def handled?
148
+ !@compacted_messages.nil?
149
+ end
150
+ end
151
+
152
+ class ExtensionState
153
+ def initialize
154
+ @states = Hash.new { |h, key| h[key] = DotState.new }
155
+ end
156
+
157
+ def ext(name)
158
+ @states[name.to_sym]
159
+ end
160
+
161
+ def to_h
162
+ @states.transform_values(&:to_h)
163
+ end
164
+ end
165
+
166
+ class DotState
167
+ def initialize
168
+ @data = {}
169
+ end
170
+
171
+ def merge_defaults(hash)
172
+ hash.each { |k, v| @data[k.to_sym] = v unless @data.key?(k.to_sym) }
173
+ self
174
+ end
175
+
176
+ def method_missing(name, *args)
177
+ key = name.to_s.delete_suffix('=').to_sym
178
+
179
+ if name.to_s.end_with?('=')
180
+ @data[key] = args.first
181
+ else
182
+ @data[key]
183
+ end
184
+ end
185
+
186
+ def respond_to_missing?(*)
187
+ true
188
+ end
189
+
190
+ def to_h
191
+ @data.dup
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class HookRegistry
5
+ Registration = Data.define(:handler_class, :context) do
6
+ def initialize(handler_class:, context: nil)
7
+ super
8
+ end
9
+
10
+ def namespace
11
+ handler_class.namespace
12
+ end
13
+ end
14
+
15
+ def initialize
16
+ @registrations = []
17
+ end
18
+
19
+ # Register a hook handler class.
20
+ #
21
+ # @param handler_class [Class] a RobotLab::Hook subclass
22
+ # @param context [Hash, nil] optional default values merged into ctx.local on each call
23
+ # @return [Registration]
24
+ def on(handler_class, context: nil)
25
+ unless handler_class.is_a?(Class) && handler_class < RobotLab::Hook
26
+ raise ArgumentError, "#{handler_class.inspect} must be a RobotLab::Hook subclass"
27
+ end
28
+ raise ArgumentError, "#{handler_class} has no namespace" if handler_class.namespace.nil?
29
+
30
+ registration = Registration.new(handler_class: handler_class, context: context)
31
+ @registrations << registration
32
+ registration
33
+ end
34
+
35
+ # Returns registrations whose handler implements the given hook method.
36
+ #
37
+ # @param hook_name [Symbol]
38
+ # @return [Array<Registration>]
39
+ def registrations_for(hook_name)
40
+ hook_sym = hook_name.to_sym
41
+ @registrations.select { |reg| reg.handler_class.singleton_class.public_method_defined?(hook_sym) }
42
+ end
43
+
44
+ # Returns all registrations (defensive copy).
45
+ #
46
+ # @return [Array<Registration>]
47
+ def registrations
48
+ @registrations.dup
49
+ end
50
+
51
+ def clear
52
+ @registrations.clear
53
+ end
54
+ end
55
+ end