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
@@ -143,8 +143,8 @@ module RobotLab
143
143
  #
144
144
  # @param args [Array] arguments passed to to_json
145
145
  # @return [String] JSON representation
146
- def to_json(*args)
147
- export.to_json(*args)
146
+ def to_json(*)
147
+ export.to_json(*)
148
148
  end
149
149
 
150
150
  # Get the last text content from output
@@ -152,9 +152,9 @@ module RobotLab
152
152
  # @return [String, nil] The content of the last text message
153
153
  #
154
154
  def last_text_content
155
- output.reverse.find(&:text?)&.content
155
+ output.rfind(&:text?)&.content
156
156
  end
157
- alias_method :reply, :last_text_content
157
+ alias reply last_text_content
158
158
 
159
159
  # Check if result contains tool calls
160
160
  #
@@ -217,7 +217,8 @@ module RobotLab
217
217
  when ToolResultMessage
218
218
  result
219
219
  when Hash
220
- result[:type] == "tool_result" ? ToolResultMessage.new(**result.slice(:tool, :content, :stop_reason)) : Message.from_hash(result)
220
+ result[:type] == "tool_result" ? ToolResultMessage.new(**result.slice(:tool, :content,
221
+ :stop_reason)) : Message.from_hash(result)
221
222
  else
222
223
  raise ArgumentError, "Invalid tool result: must be ToolResultMessage or Hash"
223
224
  end
@@ -41,13 +41,15 @@ module RobotLab
41
41
  CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
42
 
43
43
  # Infrastructure fields
44
- INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size max_concurrent_robots].freeze
44
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget cost_budget ractor_pool_size
45
+ max_concurrent_robots doom_loop_threshold auto_compact compact_threshold max_tools].freeze
45
46
 
46
47
  # All recognized fields
47
48
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
48
49
 
49
50
  # Fields that cannot be serialized to JSON (Procs, IO objects, etc.)
50
- NON_SERIALIZABLE_FIELDS = (CALLBACK_FIELDS + %i[bus]).freeze
51
+ # auto_compact is excluded because it may be a Proc.
52
+ NON_SERIALIZABLE_FIELDS = (CALLBACK_FIELDS + %i[bus auto_compact]).freeze
51
53
 
52
54
  # Creates a new RunConfig.
53
55
  #
@@ -85,15 +87,13 @@ module RobotLab
85
87
  @fields.dup
86
88
  end
87
89
 
88
-
89
90
  # Returns a JSON-safe hash (skips Procs, IO, and other non-serializable values).
90
91
  #
91
92
  # @return [Hash]
92
93
  def to_json_hash
93
- @fields.reject { |k, _| NON_SERIALIZABLE_FIELDS.include?(k) }
94
+ @fields.except(*NON_SERIALIZABLE_FIELDS)
94
95
  end
95
96
 
96
-
97
97
  # Merges another RunConfig (or Hash) on top of this one.
98
98
  # The other's non-nil values win. Returns a new RunConfig.
99
99
  #
@@ -105,21 +105,38 @@ module RobotLab
105
105
  self.class.new(**merged)
106
106
  end
107
107
 
108
-
109
108
  # Applies LLM fields to a chat object via its with_* methods.
110
109
  #
110
+ # +provider+/+assume_model_exists+ are threaded through to +with_model+
111
+ # specifically (RunConfig itself has no provider field -- provider lives
112
+ # on the Robot). Without this, re-applying a RunConfig after the chat's
113
+ # provider/model were already set via Robot#initialize (e.g. during
114
+ # template front-matter merging) drops that context on the floor and
115
+ # `with_model` falls back to the static model registry lookup, which
116
+ # raises ModelNotFoundError for any local-provider model (Ollama, etc.)
117
+ # not in RubyLLM's bundled registry.
118
+ #
111
119
  # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
112
- def apply_to(chat)
120
+ # @param provider [String, Symbol, nil] passed through to chat.with_model's provider: kwarg
121
+ # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_exists: kwarg
122
+ def apply_to(chat, provider: nil, assume_model_exists: false)
113
123
  LLM_FIELDS.each do |field|
114
124
  value = @fields[field]
115
125
  next unless value
116
126
 
117
- method = :"with_#{field}"
118
- chat.public_send(method, value) if chat.respond_to?(method)
127
+ if field == :model && provider
128
+ # Only take the provider-aware path when a provider was actually
129
+ # given -- preserves the original single-arg call (and thus
130
+ # compatibility with any chat-like object exposing only
131
+ # `with_model(value)`) for the common case.
132
+ chat.with_model(value, provider:, assume_exists: assume_model_exists) if chat.respond_to?(:with_model)
133
+ else
134
+ method = :"with_#{field}"
135
+ chat.public_send(method, value) if chat.respond_to?(method)
136
+ end
119
137
  end
120
138
  end
121
139
 
122
-
123
140
  # Build a RunConfig from prompt_manager front matter metadata.
124
141
  #
125
142
  # @param metadata [Object] a PM::Metadata object (responds to field names)
@@ -141,27 +158,23 @@ module RobotLab
141
158
  new(**fields)
142
159
  end
143
160
 
144
-
145
161
  # @return [Boolean] true if no fields have been set
146
162
  def empty?
147
163
  @fields.empty?
148
164
  end
149
165
 
150
-
151
166
  # @param field [Symbol] the field name
152
167
  # @return [Boolean] true if the field has been explicitly set
153
168
  def key?(field)
154
169
  @fields.key?(field)
155
170
  end
156
171
 
157
-
158
172
  # @param other [RunConfig] the other RunConfig to compare
159
173
  # @return [Boolean]
160
174
  def ==(other)
161
175
  other.is_a?(RunConfig) && to_h == other.to_h
162
176
  end
163
177
 
164
-
165
178
  # @return [String]
166
179
  def inspect
167
180
  "#<#{self.class} #{@fields.inspect}>"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Shared interface for things you can run with a message: a single {Robot} or a
5
+ # multi-robot {Network}. It lets callers treat them uniformly instead of
6
+ # branching on `is_a?(RobotLab::Network)` — which otherwise spreads concrete
7
+ # type knowledge across every consumer.
8
+ #
9
+ # Implementers provide:
10
+ # - #run(message = nil, **opts) — execute with the given message (both Robot
11
+ # and Network accept a positional message and keyword options)
12
+ # - #crew — the constituent robots, as an Array
13
+ # - #network? — true for a multi-robot network
14
+ #
15
+ # The rest (chief, robot_count, single?) derive from those.
16
+ #
17
+ # @example treat either uniformly
18
+ # runnable.run(prompt, mcp: :inherit, tools: :inherit)
19
+ # names = runnable.crew.map(&:name)
20
+ # show_network(runnable) if runnable.network?
21
+ module Runnable
22
+ # The constituent robots as an Array. A single Robot is a crew of one.
23
+ #
24
+ # @return [Array<Robot>]
25
+ def crew
26
+ raise NotImplementedError, "#{self.class} must implement #crew"
27
+ end
28
+
29
+ # The lead robot — the chief of the crew.
30
+ #
31
+ # @return [Robot, nil]
32
+ def chief
33
+ crew.first
34
+ end
35
+
36
+ # @return [Integer] number of constituent robots
37
+ def robot_count
38
+ crew.size
39
+ end
40
+
41
+ # @return [Boolean] true for a multi-robot network
42
+ def network?
43
+ false
44
+ end
45
+
46
+ # @return [Boolean] true for a single robot
47
+ def single?
48
+ !network?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Sandbox
5
+ # Passthrough strategy: runs the command with no confinement. Used when
6
+ # sandboxing is unavailable (non-macOS) or unnecessary (trust: core).
7
+ class Null
8
+ def wrap(cmd) = cmd
9
+
10
+ def cleanup; end
11
+ end
12
+ end
13
+ end
@@ -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
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+ require 'timeout'
6
+
7
+ module RobotLab
8
+ # Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.
9
+ #
10
+ # Given a path to an executable shell script, produces a Tool that shells
11
+ # out to the script and returns its combined stdout+stderr output.
12
+ # Non-executable scripts return nil with a logged warning.
13
+ module ScriptTool
14
+ # Wrap a script file as a RobotLab::Tool.
15
+ #
16
+ # @param script_path [String, Pathname] path to the script file
17
+ # @return [RobotLab::Tool, nil] nil if the script is not executable
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)
23
+ path = Pathname.new(script_path)
24
+
25
+ unless path.executable?
26
+ RobotLab.config.logger.warn(
27
+ "ScriptTool: #{path.basename} is not executable, skipping"
28
+ )
29
+ return nil
30
+ end
31
+
32
+ tool_name = derive_name(path)
33
+ description = extract_description(path)
34
+ script = path.to_s
35
+ caps = capabilities || Capabilities.new
36
+ dir = skill_dir || path.dirname.to_s
37
+
38
+ Tool.create(
39
+ name: tool_name,
40
+ description: description,
41
+ parameters: {
42
+ type: 'object',
43
+ properties: {
44
+ args: { type: 'string', description: 'Optional command-line arguments' }
45
+ },
46
+ required: []
47
+ }
48
+ ) do |tool_args|
49
+ cli_args = tool_args[:args].to_s.strip
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?
65
+ output, status = Open3.capture2e(*cmd)
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
76
+ end
77
+ end
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
+
107
+ # @param path [Pathname]
108
+ # @return [String] snake_case tool name derived from filename
109
+ def self.derive_name(path)
110
+ path.basename.to_s
111
+ .sub(/\.[^.]+$/, '')
112
+ .gsub(/[^a-zA-Z0-9]+/, '_')
113
+ .gsub(/^_+|_+$/, '')
114
+ end
115
+
116
+ # Extract tool description from the first non-shebang comment line.
117
+ #
118
+ # @param path [Pathname]
119
+ # @return [String]
120
+ def self.extract_description(path)
121
+ File.foreach(path) do |line|
122
+ stripped = line.strip
123
+ next unless stripped.start_with?('#')
124
+ next if stripped.start_with?('#!') # skip shebang
125
+
126
+ desc = stripped.sub(/^#+\s*/, '').strip
127
+ return desc unless desc.empty?
128
+ end
129
+ derive_name(path)
130
+ rescue StandardError
131
+ derive_name(path)
132
+ end
133
+ end
134
+ end
@@ -46,7 +46,6 @@ module RobotLab
46
46
  old_value = @data[key]
47
47
  @data[key] = value
48
48
  @on_change&.call(key, old_value, value) if old_value != value
49
- value
50
49
  end
51
50
 
52
51
  # Check if key exists
@@ -85,8 +84,12 @@ module RobotLab
85
84
  #
86
85
  # @yield [Symbol, Object]
87
86
  #
88
- def each(&block)
89
- @data.each(&block)
87
+ def each(&)
88
+ @data.each(&)
89
+ end
90
+
91
+ def map(&)
92
+ @data.map(&)
90
93
  end
91
94
 
92
95
  # Delete a key
@@ -155,7 +158,7 @@ module RobotLab
155
158
  # proxy.name # Same as proxy[:name]
156
159
  # proxy.name = "x" # Same as proxy[:name] = "x"
157
160
  #
158
- def method_missing(method_name, *args, &block)
161
+ def method_missing(method_name, *args, &)
159
162
  method_str = method_name.to_s
160
163
 
161
164
  if method_str.end_with?("=")
@@ -173,6 +176,5 @@ module RobotLab
173
176
  def inspect
174
177
  "#<RobotLab::StateProxy #{@data.inspect}>"
175
178
  end
176
-
177
179
  end
178
180
  end
@@ -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