robot_lab 0.2.1 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +5 -167
- data/CHANGELOG.md +54 -0
- data/README.md +61 -0
- data/Rakefile +28 -12
- 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/getting-started/configuration.md +20 -1
- 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 +5 -0
- data/docs/guides/knowledge.md +2 -2
- data/docs/guides/observability.md +74 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +1 -1
- data/examples/03_network.rb +11 -2
- data/examples/34_agentskills.rb +0 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +7 -0
- data/examples/common.rb +7 -3
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +5 -4
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +4 -4
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- 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/connection_poller.rb +2 -2
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +76 -12
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +8 -2
- data/lib/robot_lab/robot/template_rendering.rb +2 -2
- data/lib/robot_lab/robot.rb +211 -88
- data/lib/robot_lab/run_config.rb +24 -5
- 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 +60 -2
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +52 -11
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +49 -0
- data/mkdocs.yml +1 -0
- metadata +36 -99
- data/examples/temp.md +0 -51
- data/site/404.html +0 -2300
- data/site/api/core/index.html +0 -2706
- data/site/api/core/memory/index.html +0 -3793
- data/site/api/core/network/index.html +0 -3500
- data/site/api/core/robot/index.html +0 -4566
- data/site/api/core/state/index.html +0 -3390
- data/site/api/core/tool/index.html +0 -3843
- data/site/api/index.html +0 -2635
- data/site/api/mcp/client/index.html +0 -3435
- data/site/api/mcp/index.html +0 -2783
- data/site/api/mcp/server/index.html +0 -3252
- data/site/api/mcp/transports/index.html +0 -3352
- data/site/api/messages/index.html +0 -2641
- data/site/api/messages/text-message/index.html +0 -3087
- data/site/api/messages/tool-call-message/index.html +0 -3159
- data/site/api/messages/tool-result-message/index.html +0 -3252
- data/site/api/messages/user-message/index.html +0 -3212
- data/site/api/streaming/context/index.html +0 -3282
- data/site/api/streaming/events/index.html +0 -3347
- data/site/api/streaming/index.html +0 -2738
- data/site/architecture/core-concepts/index.html +0 -3757
- data/site/architecture/index.html +0 -2797
- data/site/architecture/message-flow/index.html +0 -3238
- data/site/architecture/network-orchestration/index.html +0 -3433
- data/site/architecture/robot-execution/index.html +0 -3140
- data/site/architecture/state-management/index.html +0 -3498
- data/site/assets/css/custom.css +0 -56
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
- data/site/assets/javascripts/lunr/tinyseg.js +0 -206
- data/site/assets/javascripts/lunr/wordcut.js +0 -6708
- data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
- data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
- data/site/concepts/index.html +0 -3455
- data/site/examples/basic-chat/index.html +0 -2880
- data/site/examples/index.html +0 -2907
- data/site/examples/mcp-server/index.html +0 -3018
- data/site/examples/multi-robot-network/index.html +0 -3131
- data/site/examples/rails-application/index.html +0 -3329
- data/site/examples/tool-usage/index.html +0 -3085
- data/site/getting-started/configuration/index.html +0 -3745
- data/site/getting-started/index.html +0 -2572
- data/site/getting-started/installation/index.html +0 -2981
- data/site/getting-started/quick-start/index.html +0 -2942
- data/site/guides/building-robots/index.html +0 -4290
- data/site/guides/creating-networks/index.html +0 -3858
- data/site/guides/index.html +0 -2586
- data/site/guides/mcp-integration/index.html +0 -3581
- data/site/guides/memory/index.html +0 -3586
- data/site/guides/rails-integration/index.html +0 -4019
- data/site/guides/streaming/index.html +0 -3157
- data/site/guides/using-tools/index.html +0 -3802
- data/site/index.html +0 -2671
- data/site/search/search_index.json +0 -1
- data/site/sitemap.xml +0 -183
- data/site/sitemap.xml.gz +0 -0
- data/site/tags.json +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Sandbox
|
|
7
|
+
# macOS strategy: generates a deny-by-default sandbox-exec profile from the
|
|
8
|
+
# effective grant and wraps the command as
|
|
9
|
+
# sandbox-exec -f <profile> <cmd...>
|
|
10
|
+
#
|
|
11
|
+
# The profile allows process exec plus reads of system locations (so the
|
|
12
|
+
# interpreter can load), reads of the skill bundle and granted paths, writes
|
|
13
|
+
# only to granted paths (and the standard /dev sinks), and network only when
|
|
14
|
+
# granted. Everything else -- notably $HOME, so SSH/cloud credentials --
|
|
15
|
+
# is denied. Interpreters installed under $HOME (e.g. rbenv) are not visible;
|
|
16
|
+
# declare them in fs_read or mark the skill trust: core.
|
|
17
|
+
class Seatbelt
|
|
18
|
+
# System locations a typical interpreter needs to read to start up.
|
|
19
|
+
SYSTEM_READ = %w[/usr /bin /sbin /System /Library /opt /private/etc /dev /var/select].freeze
|
|
20
|
+
DEV_WRITE = %w[/dev/null /dev/stdout /dev/stderr /dev/dtracehelper /dev/tty].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(grant, skill_dir:)
|
|
23
|
+
@grant = grant
|
|
24
|
+
@skill_dir = File.expand_path(skill_dir.to_s)
|
|
25
|
+
@profile = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def wrap(cmd)
|
|
29
|
+
@profile = write_profile
|
|
30
|
+
["sandbox-exec", "-f", @profile, *cmd]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cleanup
|
|
34
|
+
File.unlink(@profile) if @profile && File.exist?(@profile)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The generated Seatbelt profile text (public for testing).
|
|
40
|
+
def profile_text
|
|
41
|
+
reads = canonicalize(SYSTEM_READ + [@skill_dir] + @grant.fs_read)
|
|
42
|
+
writes = canonicalize(@grant.fs_write)
|
|
43
|
+
lines = [
|
|
44
|
+
"(version 1)",
|
|
45
|
+
# bsd.sb supplies the base rules a process needs to start (dyld, mach
|
|
46
|
+
# bootstrap, etc.); without it a deny-default profile aborts the binary.
|
|
47
|
+
'(import "bsd.sb")',
|
|
48
|
+
"(deny default)",
|
|
49
|
+
"(allow process-fork)",
|
|
50
|
+
"(allow process-exec)",
|
|
51
|
+
"(allow sysctl-read)",
|
|
52
|
+
"(allow mach-lookup)",
|
|
53
|
+
# Metadata (stat/lookup) on any path so the interpreter can traverse to
|
|
54
|
+
# granted files; reading file *contents* stays restricted below.
|
|
55
|
+
"(allow file-read-metadata)",
|
|
56
|
+
read_rule(reads),
|
|
57
|
+
write_rule(DEV_WRITE.map { |p| [:literal, p] } + writes.map { |p| [:subpath, p] })
|
|
58
|
+
]
|
|
59
|
+
lines << "(allow network*)" if @grant.network
|
|
60
|
+
"#{lines.compact.join("\n")}\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def write_profile
|
|
66
|
+
file = Tempfile.create(["robot_lab-sandbox-", ".sb"])
|
|
67
|
+
file.write(profile_text)
|
|
68
|
+
file.close
|
|
69
|
+
file.path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve to the real (symlink-free) path the kernel matches against. macOS
|
|
73
|
+
# symlinks /tmp -> /private/tmp, /var -> /private/var, etc., so logical
|
|
74
|
+
# paths would never match. For not-yet-existing write targets, resolve the
|
|
75
|
+
# nearest existing ancestor and re-append the remainder.
|
|
76
|
+
def canonicalize(paths)
|
|
77
|
+
Array(paths).map { |p| real_path(File.expand_path(p.to_s)) }.compact.uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def real_path(expanded)
|
|
81
|
+
existing = expanded
|
|
82
|
+
rest = []
|
|
83
|
+
until File.exist?(existing) || existing == "/"
|
|
84
|
+
rest.unshift(File.basename(existing))
|
|
85
|
+
existing = File.dirname(existing)
|
|
86
|
+
end
|
|
87
|
+
real = File.realpath(existing)
|
|
88
|
+
rest.empty? ? real : File.join(real, *rest)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
expanded
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_rule(paths)
|
|
94
|
+
subpaths = paths.map { |p| "(subpath #{p.inspect})" }.join(" ")
|
|
95
|
+
"(allow file-read* #{subpaths})"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def write_rule(entries)
|
|
99
|
+
clauses = entries.map { |kind, p| "(#{kind} #{p.inspect})" }.join(" ")
|
|
100
|
+
"(allow file-write* #{clauses})"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Confines skill-script execution to a granted set of capabilities.
|
|
5
|
+
#
|
|
6
|
+
# Sandboxing is opt-in (config.sandbox.enabled). When off, every script runs
|
|
7
|
+
# unconfined exactly as before. When on, each script runs under a strategy:
|
|
8
|
+
#
|
|
9
|
+
# - {Sandbox::Seatbelt} on macOS — a generated deny-by-default sandbox-exec
|
|
10
|
+
# profile derived from the effective {Capabilities} grant.
|
|
11
|
+
# - {Sandbox::Null} elsewhere (or for +trust: core+ skills) — a passthrough.
|
|
12
|
+
#
|
|
13
|
+
# OS-level confinement is therefore best-effort and platform-specific; it is
|
|
14
|
+
# never a hard dependency.
|
|
15
|
+
module Sandbox
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] whether sandboxing is turned on in config
|
|
19
|
+
def enabled?(config = RobotLab.config)
|
|
20
|
+
config.respond_to?(:sandbox) && config.sandbox && config.sandbox.enabled == true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def macos?
|
|
24
|
+
RUBY_PLATFORM.include?("darwin")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pick a strategy for the given (already-intersected) grant.
|
|
28
|
+
#
|
|
29
|
+
# @param grant [Capabilities] effective grant
|
|
30
|
+
# @param skill_dir [String] skill bundle root, always granted read access
|
|
31
|
+
# @param macos [Boolean] whether to use the macOS strategy; defaults to the
|
|
32
|
+
# real platform check and is injectable so both branches are testable on
|
|
33
|
+
# any host without stubbing.
|
|
34
|
+
# @return [#wrap, #cleanup]
|
|
35
|
+
def for(grant, skill_dir:, macos: macos?)
|
|
36
|
+
return Null.new if grant.core?
|
|
37
|
+
return Seatbelt.new(grant, skill_dir: skill_dir) if macos
|
|
38
|
+
|
|
39
|
+
warn_once_non_macos
|
|
40
|
+
Null.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def warn_once_non_macos
|
|
44
|
+
return if @warned_non_macos
|
|
45
|
+
|
|
46
|
+
@warned_non_macos = true
|
|
47
|
+
RobotLab.config.logger.warn(
|
|
48
|
+
"Sandbox: OS-level confinement is only available on macOS; scripts run unconfined here"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'shellwords'
|
|
5
|
+
require 'timeout'
|
|
5
6
|
|
|
6
7
|
module RobotLab
|
|
7
8
|
# Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.
|
|
@@ -14,7 +15,11 @@ module RobotLab
|
|
|
14
15
|
#
|
|
15
16
|
# @param script_path [String, Pathname] path to the script file
|
|
16
17
|
# @return [RobotLab::Tool, nil] nil if the script is not executable
|
|
17
|
-
|
|
18
|
+
# @param script_path [String, Pathname] path to the script file
|
|
19
|
+
# @param capabilities [Capabilities, nil] declared capabilities (from SKILL.md)
|
|
20
|
+
# @param skill_dir [String, nil] skill bundle root (defaults to the script's dir)
|
|
21
|
+
# @return [RobotLab::Tool, nil] nil if the script is not executable
|
|
22
|
+
def self.from_path(script_path, capabilities: nil, skill_dir: nil)
|
|
18
23
|
path = Pathname.new(script_path)
|
|
19
24
|
|
|
20
25
|
unless path.executable?
|
|
@@ -27,6 +32,8 @@ module RobotLab
|
|
|
27
32
|
tool_name = derive_name(path)
|
|
28
33
|
description = extract_description(path)
|
|
29
34
|
script = path.to_s
|
|
35
|
+
caps = capabilities || Capabilities.new
|
|
36
|
+
dir = skill_dir || path.dirname.to_s
|
|
30
37
|
|
|
31
38
|
Tool.create(
|
|
32
39
|
name: tool_name,
|
|
@@ -41,11 +48,62 @@ module RobotLab
|
|
|
41
48
|
) do |tool_args|
|
|
42
49
|
cli_args = tool_args[:args].to_s.strip
|
|
43
50
|
cmd = cli_args.empty? ? ['bash', script] : ['bash', script, *Shellwords.split(cli_args)]
|
|
51
|
+
ScriptTool.execute(cmd, capabilities: caps, skill_dir: dir)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run a command, optionally confined by the sandbox and a timeout.
|
|
56
|
+
#
|
|
57
|
+
# When sandboxing is disabled (the default) this is the original, unconfined
|
|
58
|
+
# capture2e path with no timeout — behaviour is unchanged. When enabled, the
|
|
59
|
+
# command is wrapped by the sandbox strategy for the effective grant and
|
|
60
|
+
# bounded by the grant's timeout.
|
|
61
|
+
#
|
|
62
|
+
# @return [String] combined stdout+stderr, or an error string on failure
|
|
63
|
+
def self.execute(cmd, capabilities:, skill_dir:)
|
|
64
|
+
unless Sandbox.enabled?
|
|
44
65
|
output, status = Open3.capture2e(*cmd)
|
|
45
|
-
|
|
66
|
+
return format_result(output, status)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
grant = capabilities.intersect(Capabilities.ceiling)
|
|
70
|
+
sandbox = Sandbox.for(grant, skill_dir: skill_dir)
|
|
71
|
+
begin
|
|
72
|
+
output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
|
|
73
|
+
format_result(output, status)
|
|
74
|
+
ensure
|
|
75
|
+
sandbox.cleanup
|
|
46
76
|
end
|
|
47
77
|
end
|
|
48
78
|
|
|
79
|
+
# @return [Array(String, Process::Status|nil)] output and status (nil = timed out)
|
|
80
|
+
def self.run_with_timeout(cmd, timeout)
|
|
81
|
+
Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
|
|
82
|
+
stdin.close
|
|
83
|
+
output = +''
|
|
84
|
+
begin
|
|
85
|
+
Timeout.timeout(timeout) { output << out.read }
|
|
86
|
+
rescue Timeout::Error
|
|
87
|
+
terminate(wait.pid)
|
|
88
|
+
return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
|
|
89
|
+
end
|
|
90
|
+
[output, wait.value]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.terminate(pid)
|
|
95
|
+
Process.kill('-TERM', Process.getpgid(pid))
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param status [Process::Status, nil] nil indicates a timeout kill
|
|
101
|
+
def self.format_result(output, status)
|
|
102
|
+
return "Error (timed out):\n#{output}" if status.nil?
|
|
103
|
+
|
|
104
|
+
status.success? ? output : "Error (exit #{status.exitstatus}):\n#{output}"
|
|
105
|
+
end
|
|
106
|
+
|
|
49
107
|
# @param path [Pathname]
|
|
50
108
|
# @return [String] snake_case tool name derived from filename
|
|
51
109
|
def self.derive_name(path)
|
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
|
data/lib/robot_lab/tool.rb
CHANGED
|
@@ -94,19 +94,17 @@ module RobotLab
|
|
|
94
94
|
# @param args [Hash] the tool arguments from the LLM
|
|
95
95
|
# @return [Object] the tool result or an error string
|
|
96
96
|
def call(args)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
super
|
|
97
|
+
context = ToolCallHookContext.new(tool: self, tool_args: args, robot: @robot)
|
|
98
|
+
|
|
99
|
+
RobotLab::Hooks.run(:tool_call, context, **tool_hook_options) do
|
|
100
|
+
context.tool_result = use_ractor_pool? ? execute_ractor_tool_call(args) : super
|
|
101
|
+
rescue RobotLab::ToolError => e
|
|
102
|
+
handle_tool_error(context, e)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
handle_standard_error(context, e)
|
|
101
105
|
end
|
|
102
|
-
rescue RobotLab::ToolError => e
|
|
103
|
-
raise if self.class.raise_on_error?
|
|
104
|
-
"Error (#{name}): #{e.message}"
|
|
105
|
-
rescue StandardError => e
|
|
106
|
-
raise if self.class.raise_on_error?
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
"Error (#{name}): #{e.message}"
|
|
107
|
+
context.tool_result
|
|
110
108
|
end
|
|
111
109
|
|
|
112
110
|
# Override name to support explicit names for dynamic/MCP tools.
|
|
@@ -210,6 +208,49 @@ module RobotLab
|
|
|
210
208
|
|
|
211
209
|
private
|
|
212
210
|
|
|
211
|
+
def tool_hook_options
|
|
212
|
+
hook_scope = RobotLab.current_hook_scope
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
registries: tool_hook_registries(hook_scope),
|
|
216
|
+
per_run_hooks: hook_scope && hook_scope[:per_run_hooks]
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def tool_hook_registries(hook_scope)
|
|
221
|
+
return hook_scope[:registries] if hook_scope
|
|
222
|
+
|
|
223
|
+
[RobotLab.hooks, robot_hook_registry]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def robot_hook_registry
|
|
227
|
+
@robot.hooks if @robot.respond_to?(:hooks)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def execute_ractor_tool_call(args)
|
|
231
|
+
RobotLab.ractor_pool.submit(self.class.name, args)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def use_ractor_pool?
|
|
235
|
+
self.class.ractor_safe? && !self.class.name.nil? && RobotLab.extension_loaded?(:ractor)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def handle_tool_error(context, error)
|
|
239
|
+
context.tool_error = error
|
|
240
|
+
raise if self.class.raise_on_error?
|
|
241
|
+
|
|
242
|
+
suffix = RobotLab::Errors.retryable?(error) ? " (retryable)" : ""
|
|
243
|
+
context.tool_result = "Error (#{name}): #{error.message}#{suffix}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def handle_standard_error(context, error)
|
|
247
|
+
context.tool_error = error
|
|
248
|
+
raise if self.class.raise_on_error?
|
|
249
|
+
|
|
250
|
+
RobotLab.config.logger.warn("Tool '#{name}' error: #{error.class}: #{error.message}")
|
|
251
|
+
context.tool_result = "Error (#{name}): #{error.message}"
|
|
252
|
+
end
|
|
253
|
+
|
|
213
254
|
def deep_symbolize_keys(obj)
|
|
214
255
|
case obj
|
|
215
256
|
when Hash
|
data/lib/robot_lab/version.rb
CHANGED
data/lib/robot_lab.rb
CHANGED
|
@@ -64,6 +64,10 @@ loader.setup
|
|
|
64
64
|
# individually (e.g. TextMessage lives in message.rb, not text_message.rb).
|
|
65
65
|
# Require them explicitly so their constants are available without eager loading.
|
|
66
66
|
require_relative 'robot_lab/error'
|
|
67
|
+
require_relative 'robot_lab/hook_context'
|
|
68
|
+
require_relative 'robot_lab/hook'
|
|
69
|
+
require_relative 'robot_lab/hook_registry'
|
|
70
|
+
require_relative 'robot_lab/hooks'
|
|
67
71
|
require_relative 'robot_lab/message'
|
|
68
72
|
require_relative 'robot_lab/memory'
|
|
69
73
|
|
|
@@ -109,6 +113,33 @@ module RobotLab
|
|
|
109
113
|
@extensions.keys
|
|
110
114
|
end
|
|
111
115
|
|
|
116
|
+
def hooks
|
|
117
|
+
@hooks ||= HookRegistry.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def on(handler_class, context: nil)
|
|
121
|
+
hooks.on(handler_class, context: context)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def clear_hooks!
|
|
125
|
+
@hooks = HookRegistry.new
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def with_hook_scope(registries, per_run_hooks)
|
|
129
|
+
previous = Thread.current[:robot_lab_hook_scope]
|
|
130
|
+
Thread.current[:robot_lab_hook_scope] = {
|
|
131
|
+
registries: registries,
|
|
132
|
+
per_run_hooks: per_run_hooks
|
|
133
|
+
}
|
|
134
|
+
yield
|
|
135
|
+
ensure
|
|
136
|
+
Thread.current[:robot_lab_hook_scope] = previous
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def current_hook_scope
|
|
140
|
+
Thread.current[:robot_lab_hook_scope]
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
# Returns the Config object (MywayConfig-based).
|
|
113
144
|
#
|
|
114
145
|
# Configuration is automatically loaded from:
|
|
@@ -152,6 +183,24 @@ module RobotLab
|
|
|
152
183
|
config
|
|
153
184
|
end
|
|
154
185
|
|
|
186
|
+
# Render a named prompt template to a String using the configured template
|
|
187
|
+
# library (the prompts_dir from config / ROBOT_LAB_TEMPLATE_PATH). Front
|
|
188
|
+
# matter parameters are supplied as keyword arguments.
|
|
189
|
+
#
|
|
190
|
+
# Unlike #build (which renders a template as a robot's *system prompt*), this
|
|
191
|
+
# returns the plain text, so callers can use it as a task, a message, or a
|
|
192
|
+
# grading rubric.
|
|
193
|
+
#
|
|
194
|
+
# @param name [Symbol, String] template id (filename without extension)
|
|
195
|
+
# @param context [Hash] values for the template's parameters
|
|
196
|
+
# @return [String] the rendered prompt text
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# RobotLab.render_template(:objective, topic: "commit messages")
|
|
200
|
+
def render_template(name, **context)
|
|
201
|
+
PM.parse(name.to_sym).to_s(**context)
|
|
202
|
+
end
|
|
203
|
+
|
|
155
204
|
# Factory method to create a new Robot instance.
|
|
156
205
|
#
|
|
157
206
|
# @param name [String, nil] the unique identifier for the robot (auto-generated if nil)
|
data/mkdocs.yml
CHANGED
|
@@ -170,6 +170,7 @@ nav:
|
|
|
170
170
|
- Streaming Responses: guides/streaming.md
|
|
171
171
|
- Memory System: guides/memory.md
|
|
172
172
|
- Observability & Safety: guides/observability.md
|
|
173
|
+
- Hook System: guides/hooks.md
|
|
173
174
|
- Knowledge Search: guides/knowledge.md
|
|
174
175
|
- API Reference:
|
|
175
176
|
- api/index.md
|