robot_lab 0.1.0 → 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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RobotLab
6
+ # Value object representing an AgentSkills.io skill folder.
7
+ #
8
+ # A skill is a directory containing SKILL.md with required front matter
9
+ # fields (name, description) and optional scripts/, references/, assets/.
10
+ class AgentSkill
11
+ attr_reader :name, :description, :path, :capabilities
12
+
13
+ # @param skill_md_path [String, Pathname] path to the SKILL.md file
14
+ # @raise [ConfigurationError] if name or description is missing
15
+ def initialize(skill_md_path)
16
+ @path = Pathname.new(skill_md_path).dirname
17
+ content = File.read(skill_md_path)
18
+ front_matter, @_body = parse_skill_md(content)
19
+
20
+ @name = front_matter["name"]
21
+ @description = front_matter["description"]
22
+ @capabilities = Capabilities.from_front_matter(front_matter)
23
+
24
+ raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'name'" if @name.to_s.strip.empty?
25
+ raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'description'" if @description.to_s.strip.empty?
26
+ end
27
+
28
+ # Full instruction text from the SKILL.md body (below the front matter).
29
+ def instructions
30
+ @instructions ||= @_body.strip
31
+ end
32
+
33
+ # Pathnames of all files inside the scripts/ subdirectory, sorted.
34
+ def scripts
35
+ @scripts ||= begin
36
+ dir = @path.join("scripts")
37
+ dir.directory? ? dir.children.select(&:file?).sort : []
38
+ end
39
+ end
40
+
41
+ # RobotLab::Tool instances wrapping each executable script.
42
+ # Non-executable scripts are skipped with a warning.
43
+ def script_tools
44
+ @script_tools ||= scripts.filter_map do |script_path|
45
+ ScriptTool.from_path(script_path, capabilities: @capabilities, skill_dir: @path.to_s)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Split SKILL.md content into front matter Hash and body String.
52
+ def parse_skill_md(content)
53
+ if content.start_with?("---\n")
54
+ parts = content.split(/^---\s*$/, 3)
55
+ if parts.length >= 3
56
+ fm = YAML.safe_load(parts[1])
57
+ front_matter = fm.is_a?(Hash) ? fm : {}
58
+ return [front_matter, parts[2]]
59
+ end
60
+ end
61
+ [{}, content]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Singleton registry that scans ~/.prompts/skills/ and provides
5
+ # AgentSkill lookup by ID.
6
+ #
7
+ # Skills are loaded lazily on first access (thread-safe via Mutex).
8
+ # Bad SKILL.md files (missing name/description) are skipped with a warning.
9
+ class AgentSkillCatalog
10
+ SKILLS_ROOT = Pathname.new(File.expand_path("~/.prompts/skills"))
11
+
12
+ class << self
13
+ # The process-level singleton instance (uses SKILLS_ROOT).
14
+ def instance
15
+ @instance ||= new(SKILLS_ROOT)
16
+ end
17
+
18
+ # Reset the singleton (used in tests to swap the skills root).
19
+ def reset!
20
+ @instance = nil
21
+ end
22
+ end
23
+
24
+ # @param skills_root [String, Pathname] directory to scan for skill folders
25
+ def initialize(skills_root = SKILLS_ROOT)
26
+ @skills_root = Pathname.new(skills_root)
27
+ @skills = {}
28
+ @mutex = Mutex.new
29
+ @loaded = false
30
+ end
31
+
32
+ # Return the AgentSkill for the given ID, or nil if not found.
33
+ #
34
+ # @param id [Symbol, String] skill folder name
35
+ # @return [AgentSkill, nil]
36
+ def find(id)
37
+ load!
38
+ @skills[id.to_sym]
39
+ end
40
+
41
+ # All discovered AgentSkill objects.
42
+ #
43
+ # @return [Array<AgentSkill>]
44
+ def all
45
+ load!
46
+ @skills.values
47
+ end
48
+
49
+ private
50
+
51
+ def load!
52
+ @mutex.synchronize { load_skills! unless @loaded }
53
+ end
54
+
55
+ def load_skills!
56
+ @loaded = true
57
+ return unless @skills_root.directory?
58
+
59
+ @skills_root.each_child do |dir|
60
+ next unless dir.directory?
61
+
62
+ skill_file = dir.join("SKILL.md")
63
+ next unless skill_file.exist?
64
+
65
+ begin
66
+ skill = AgentSkill.new(skill_file)
67
+ @skills[skill.name.to_sym] = skill
68
+ rescue ConfigurationError, Psych::SyntaxError => e
69
+ RobotLab.config.logger.warn("AgentSkillCatalog: #{e.message}, skipping #{dir.basename}")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -65,11 +65,11 @@ module RobotLab
65
65
  private
66
66
 
67
67
  def input_io
68
- robot&.respond_to?(:input) && robot.input ? robot.input : $stdin
68
+ robot.respond_to?(:input) && robot.input ? robot.input : $stdin
69
69
  end
70
70
 
71
71
  def output_io
72
- robot&.respond_to?(:output) && robot.output ? robot.output : $stdout
72
+ robot.respond_to?(:output) && robot.output ? robot.output : $stdout
73
73
  end
74
74
  end
75
75
  end
@@ -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
@@ -115,7 +115,16 @@ module RobotLab
115
115
  # Process one delivery, then drain any queued deliveries for the robot.
116
116
  def process_and_drain(robot, delivery)
117
117
  robot.send(:process_delivery, delivery)
118
+ drain_queued_deliveries(robot)
119
+ rescue BusError => e
120
+ release_robot(robot)
121
+ RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
122
+ rescue => e
123
+ release_robot(robot)
124
+ RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
125
+ end
118
126
 
127
+ def drain_queued_deliveries(robot)
119
128
  loop do
120
129
  next_delivery = @mutex.synchronize do
121
130
  name = robot.name
@@ -138,12 +147,10 @@ module RobotLab
138
147
  RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
139
148
  end
140
149
  end
141
- rescue BusError => e
142
- @mutex.synchronize { @robot_busy[robot.name] = false }
143
- RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
144
- rescue => e
150
+ end
151
+
152
+ def release_robot(robot)
145
153
  @mutex.synchronize { @robot_busy[robot.name] = false }
146
- RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
147
154
  end
148
155
  end
149
156
  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:
@@ -43,7 +43,6 @@ module RobotLab
43
43
  @logger ||= default_logger
44
44
  end
45
45
 
46
-
47
46
  # Apply RubyLLM configuration after loading.
48
47
  #
49
48
  # This method should be called after initialization to configure
@@ -56,7 +55,6 @@ module RobotLab
56
55
  apply_prompt_manager!
57
56
  end
58
57
 
59
-
60
58
  # Apply all RubyLLM settings from the ruby_llm configuration section.
61
59
  #
62
60
  # @return [void]
@@ -100,7 +98,6 @@ module RobotLab
100
98
  set_if_present(c, :vertexai_location, :vertexai_location, 'GOOGLE_CLOUD_LOCATION')
101
99
  end
102
100
 
103
-
104
101
  def apply_provider_endpoints(c)
105
102
  c.openai_api_base = ruby_llm.openai_api_base if ruby_llm.openai_api_base
106
103
  c.gemini_api_base = ruby_llm.gemini_api_base if ruby_llm.gemini_api_base
@@ -109,14 +106,12 @@ module RobotLab
109
106
  c.xai_api_base = ruby_llm.xai_api_base if ruby_llm.xai_api_base
110
107
  end
111
108
 
112
-
113
109
  def apply_openai_options(c)
114
110
  c.openai_organization_id = ruby_llm.openai_organization_id if ruby_llm.openai_organization_id
115
111
  c.openai_project_id = ruby_llm.openai_project_id if ruby_llm.openai_project_id
116
112
  c.openai_use_system_role = ruby_llm.openai_use_system_role unless ruby_llm.openai_use_system_role.nil?
117
113
  end
118
114
 
119
-
120
115
  def apply_default_models(c)
121
116
  c.default_model = ruby_llm.default_model if ruby_llm.default_model
122
117
  c.default_embedding_model = ruby_llm.default_embedding_model if ruby_llm.default_embedding_model
@@ -124,7 +119,6 @@ module RobotLab
124
119
  c.default_moderation_model = ruby_llm.default_moderation_model if ruby_llm.default_moderation_model
125
120
  end
126
121
 
127
-
128
122
  def apply_connection_settings(c)
129
123
  c.request_timeout = ruby_llm.request_timeout if ruby_llm.request_timeout
130
124
  c.max_retries = ruby_llm.max_retries if ruby_llm.max_retries
@@ -134,14 +128,12 @@ module RobotLab
134
128
  c.http_proxy = ruby_llm.http_proxy if ruby_llm.http_proxy
135
129
  end
136
130
 
137
-
138
131
  def apply_logging_options(c)
139
132
  c.log_file = ruby_llm.log_file if ruby_llm.log_file
140
133
  c.log_level = ruby_llm.log_level if ruby_llm.log_level
141
134
  c.log_stream_debug = ruby_llm.log_stream_debug unless ruby_llm.log_stream_debug.nil?
142
135
  end
143
136
 
144
-
145
137
  def apply_prompt_manager!
146
138
  path = resolved_template_path
147
139
  return unless path
@@ -151,29 +143,26 @@ module RobotLab
151
143
  end
152
144
  end
153
145
 
154
-
155
146
  # Set a RubyLLM config attribute from config value or standard env var.
156
147
  # Only sets when a non-nil value is found, to avoid overwriting defaults.
157
148
  def set_if_present(c, setter, config_key, env_var)
158
- value = ruby_llm.public_send(config_key) || ENV[env_var]
149
+ value = ruby_llm.public_send(config_key) || ENV.fetch(env_var, nil)
159
150
  c.public_send(:"#{setter}=", value) if value
160
151
  end
161
152
 
162
-
163
153
  def resolved_template_path
164
154
  return template_path if template_path
165
155
 
166
- if defined?(Rails) && Rails.root
167
- Rails.root.join('app', 'prompts').to_s
156
+ if defined?(::Rails) && ::Rails.root
157
+ ::Rails.root.join('app', 'prompts').to_s
168
158
  else
169
159
  'prompts'
170
160
  end
171
161
  end
172
162
 
173
-
174
163
  def default_logger
175
- if defined?(Rails) && Rails.respond_to?(:logger)
176
- Rails.logger
164
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
165
+ ::Rails.logger
177
166
  else
178
167
  require 'logger'
179
168
  Logger.new($stdout, level: Logger::INFO)
@@ -70,7 +70,7 @@ module RobotLab
70
70
  @result
71
71
  end
72
72
  end
73
- alias_method :wait, :value
73
+ alias wait value
74
74
 
75
75
  # @api private — called by Robot#delegate from the worker thread
76
76
  def resolve!(result)
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Detects and interrupts tool-call doom loops inside the LLM agent loop.
5
+ #
6
+ # A doom loop occurs when the LLM repeatedly calls the same tool(s) in a
7
+ # cycle — either identical consecutive calls (A, A, A) or a repeating
8
+ # multi-step pattern (A, B, C, A, B, C, A, B, C).
9
+ #
10
+ # When a doom loop is detected the warning is embedded in the tool result
11
+ # so the LLM sees it in its next context window and can self-correct.
12
+ #
13
+ # @example Basic usage
14
+ # detector = DoomLoopDetector.new(threshold: 3)
15
+ # detector.track("search")
16
+ # detector.track("search")
17
+ # detector.track("search")
18
+ # detector.doom_loop? # => true
19
+ # detector.warning_message
20
+ # # => "Tool 'search' has been called 3 times consecutively..."
21
+ class DoomLoopDetector
22
+ DEFAULT_THRESHOLD = 3
23
+ MAX_PERIOD = 10
24
+
25
+ attr_reader :sequence
26
+
27
+ def initialize(threshold: DEFAULT_THRESHOLD)
28
+ @threshold = threshold
29
+ @sequence = []
30
+ end
31
+
32
+ def track(tool_name)
33
+ @sequence << tool_name.to_s
34
+ end
35
+
36
+ def doom_loop?
37
+ seq = @sequence
38
+ return false if seq.length < @threshold
39
+
40
+ # Consecutive identical calls: A, A, A
41
+ tail = seq.last(@threshold)
42
+ return true if tail.uniq.length == 1
43
+
44
+ # Cyclic multi-step patterns: A,B,C, A,B,C, A,B,C
45
+ max_period = [MAX_PERIOD, seq.length / @threshold].min
46
+ (2..max_period).each do |period|
47
+ window = @threshold * period
48
+ next if seq.length < window
49
+
50
+ chunk = seq.last(window)
51
+ pattern = chunk.first(period)
52
+ return true if chunk == pattern * @threshold
53
+ end
54
+
55
+ false
56
+ end
57
+
58
+ def reset
59
+ @sequence = []
60
+ end
61
+
62
+ def warning_message
63
+ seq = @sequence
64
+ return "" if seq.empty?
65
+
66
+ period = detect_period(seq)
67
+ if period == 1
68
+ tool = seq.last
69
+ "Tool '#{tool}' has been called #{@threshold}+ times consecutively with " \
70
+ "no progress. You appear to be stuck in a loop. Try a fundamentally " \
71
+ "different approach, use a different tool, or ask for clarification."
72
+ else
73
+ pattern = seq.last(period).join(" → ")
74
+ "Tool call pattern [#{pattern}] has repeated #{@threshold}+ times. " \
75
+ "You appear to be stuck in a loop. Try a fundamentally different " \
76
+ "approach, use a different tool, or ask for clarification."
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def detect_period(seq)
83
+ return 1 if seq.last(@threshold).uniq.length == 1
84
+
85
+ max_period = [MAX_PERIOD, seq.length / @threshold].min
86
+ (2..max_period).each do |period|
87
+ window = @threshold * period
88
+ next if seq.length < window
89
+
90
+ chunk = seq.last(window)
91
+ pattern = chunk.first(period)
92
+ return period if chunk == pattern * @threshold
93
+ end
94
+
95
+ 1
96
+ end
97
+ end
98
+ end
@@ -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