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.
- checksums.yaml +4 -4
- data/.architecture/AGENTS.md +32 -0
- data/.architecture/config.yml +8 -0
- data/.architecture/members.yml +60 -0
- data/.architecture/reviews/feature-free-will.md +490 -0
- data/.architecture/reviews/overall-codebase.md +427 -0
- data/.claude/settings.local.json +57 -0
- data/.codex/config.toml +2 -0
- data/.envrc +1 -0
- data/.irbrc +2 -2
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +10 -0
- data/CHANGELOG.md +126 -0
- data/CLAUDE.md +139 -0
- data/README.md +130 -73
- data/Rakefile +131 -9
- data/agent2agent_review.md +192 -0
- data/agentf_improvements.md +253 -0
- data/agents.md +14 -0
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/examples/index.md +37 -2
- data/docs/getting-started/configuration.md +39 -7
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +19 -14
- data/docs/guides/knowledge.md +9 -3
- data/docs/guides/observability.md +206 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +31 -4
- data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
- data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
- data/examples/.envrc +1 -0
- data/examples/01_simple_robot.rb +5 -9
- data/examples/02_tools.rb +5 -9
- data/examples/03_network.rb +18 -10
- data/examples/04_mcp.rb +21 -29
- data/examples/05_streaming.rb +12 -18
- data/examples/06_prompt_templates.rb +11 -19
- data/examples/07_network_memory.rb +16 -31
- data/examples/08_llm_config.rb +10 -22
- data/examples/09_chaining.rb +16 -27
- data/examples/10_memory.rb +12 -28
- data/examples/11_network_introspection.rb +15 -29
- data/examples/12_message_bus.rb +5 -12
- data/examples/13_spawn.rb +5 -10
- data/examples/14_rusty_circuit/.envrc +1 -0
- data/examples/14_rusty_circuit/comic.rb +2 -0
- data/examples/14_rusty_circuit/heckler.rb +1 -1
- data/examples/14_rusty_circuit/open_mic.rb +1 -3
- data/examples/14_rusty_circuit/scout.rb +2 -0
- data/examples/15_memory_network_and_bus/.envrc +1 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
- data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
- data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
- data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
- data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
- data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/.envrc +1 -0
- data/examples/16_writers_room/writers_room.rb +2 -4
- data/examples/17_skills.rb +8 -17
- data/examples/18_rails/Gemfile +1 -0
- data/examples/19_token_tracking.rb +9 -15
- data/examples/20_circuit_breaker.rb +10 -19
- data/examples/21_learning_loop.rb +11 -20
- data/examples/22_context_compression.rb +6 -13
- data/examples/23_convergence.rb +6 -17
- data/examples/24_structured_delegation.rb +11 -15
- data/examples/25_history_search.rb +5 -12
- data/examples/26_document_store.rb +6 -13
- data/examples/27_incident_response/incident_response.rb +4 -5
- data/examples/28_mcp_discovery.rb +8 -11
- data/examples/29_ractor_tools.rb +4 -9
- data/examples/30_ractor_network.rb +10 -19
- data/examples/31_launch_assessment.rb +10 -23
- data/examples/32_newsletter_reader.rb +188 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +306 -0
- data/examples/34_agentskills.rb +72 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +8 -1
- data/examples/common.rb +80 -0
- data/examples/ruboruby.md +423 -0
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +64 -0
- data/lib/robot_lab/agent_skill_catalog.rb +74 -0
- data/lib/robot_lab/ask_user.rb +2 -2
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/bus_poller.rb +12 -5
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +5 -16
- data/lib/robot_lab/delegation_future.rb +1 -1
- data/lib/robot_lab/doom_loop_detector.rb +98 -0
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/history_compressor.rb +4 -10
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/client.rb +1 -2
- data/lib/robot_lab/mcp/connection_poller.rb +5 -5
- data/lib/robot_lab/mcp/server.rb +1 -1
- data/lib/robot_lab/mcp/server_discovery.rb +0 -2
- data/lib/robot_lab/memory.rb +32 -27
- data/lib/robot_lab/memory_change.rb +2 -2
- data/lib/robot_lab/message.rb +4 -4
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +87 -25
- data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +79 -33
- data/lib/robot_lab/robot/history_search.rb +4 -1
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +12 -12
- data/lib/robot_lab/robot/template_rendering.rb +62 -42
- data/lib/robot_lab/robot.rb +482 -242
- data/lib/robot_lab/robot_result.rb +6 -5
- data/lib/robot_lab/run_config.rb +27 -14
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +134 -0
- data/lib/robot_lab/state_proxy.rb +7 -5
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +54 -13
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +5 -7
- data/lib/robot_lab/user_message.rb +2 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +1 -1
- data/lib/robot_lab.rb +89 -51
- data/logfile +8 -0
- data/mkdocs.yml +3 -3
- data/robot_concurrency.md +38 -0
- data/simple_acp_review.md +298 -0
- data/temp.md +6 -0
- data/tool_manifest_plan.md +155 -0
- metadata +82 -83
- data/docs/examples/rails-application.md +0 -419
- data/docs/guides/ractor-parallelism.md +0 -364
- data/docs/guides/rails-integration.md +0 -681
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
- data/lib/generators/robot_lab/install_generator.rb +0 -90
- data/lib/generators/robot_lab/job_generator.rb +0 -40
- data/lib/generators/robot_lab/robot_generator.rb +0 -55
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
- data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
- data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
- data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
- data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
- data/lib/robot_lab/document_store.rb +0 -155
- data/lib/robot_lab/ractor_boundary.rb +0 -42
- data/lib/robot_lab/ractor_job.rb +0 -37
- data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
- data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
- data/lib/robot_lab/ractor_worker_pool.rb +0 -117
- data/lib/robot_lab/rails_integration/engine.rb +0 -29
- data/lib/robot_lab/rails_integration/job.rb +0 -158
- data/lib/robot_lab/rails_integration/railtie.rb +0 -51
- 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(*
|
|
147
|
-
export.to_json(*
|
|
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.
|
|
155
|
+
output.rfind(&:text?)&.content
|
|
156
156
|
end
|
|
157
|
-
|
|
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,
|
|
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
|
data/lib/robot_lab/run_config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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(&
|
|
89
|
-
@data.each(&
|
|
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, &
|
|
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
|
data/lib/robot_lab/task.rb
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@
|
|
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
|
-
|
|
68
|
-
|
|
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
|