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
|
@@ -8,7 +8,7 @@ module RobotLab
|
|
|
8
8
|
# A skill is a directory containing SKILL.md with required front matter
|
|
9
9
|
# fields (name, description) and optional scripts/, references/, assets/.
|
|
10
10
|
class AgentSkill
|
|
11
|
-
attr_reader :name, :description, :path
|
|
11
|
+
attr_reader :name, :description, :path, :capabilities
|
|
12
12
|
|
|
13
13
|
# @param skill_md_path [String, Pathname] path to the SKILL.md file
|
|
14
14
|
# @raise [ConfigurationError] if name or description is missing
|
|
@@ -17,8 +17,9 @@ module RobotLab
|
|
|
17
17
|
content = File.read(skill_md_path)
|
|
18
18
|
front_matter, @_body = parse_skill_md(content)
|
|
19
19
|
|
|
20
|
-
@name
|
|
21
|
-
@description
|
|
20
|
+
@name = front_matter["name"]
|
|
21
|
+
@description = front_matter["description"]
|
|
22
|
+
@capabilities = Capabilities.from_front_matter(front_matter)
|
|
22
23
|
|
|
23
24
|
raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'name'" if @name.to_s.strip.empty?
|
|
24
25
|
raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'description'" if @description.to_s.strip.empty?
|
|
@@ -41,7 +42,7 @@ module RobotLab
|
|
|
41
42
|
# Non-executable scripts are skipped with a warning.
|
|
42
43
|
def script_tools
|
|
43
44
|
@script_tools ||= scripts.filter_map do |script_path|
|
|
44
|
-
ScriptTool.from_path(script_path)
|
|
45
|
+
ScriptTool.from_path(script_path, capabilities: @capabilities, skill_dir: @path.to_s)
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Budget
|
|
5
|
+
# Thread-safe reserve/reconcile ledger for tracking consumption against
|
|
6
|
+
# per-dimension limits (e.g. :tokens, :cost).
|
|
7
|
+
#
|
|
8
|
+
# Call +reserve!+ before doing billable work, so an already-exhausted
|
|
9
|
+
# budget is caught before the work is attempted rather than after.
|
|
10
|
+
# Once the work completes, +reconcile!+ replaces the reservation with
|
|
11
|
+
# the actual amount consumed (which may be more or less than what was
|
|
12
|
+
# reserved — LLM call sizes aren't known in advance). +release!+ drops
|
|
13
|
+
# an unused reservation without recording any consumption.
|
|
14
|
+
#
|
|
15
|
+
# A dimension with no configured limit is treated as unlimited:
|
|
16
|
+
# +reserve!+ never raises for it and +remaining+ returns
|
|
17
|
+
# +Float::INFINITY+.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# ledger = RobotLab::Budget::Ledger.new(limits: { tokens: 10_000, cost: 0.50 })
|
|
21
|
+
# ledger.reserve!(:tokens, ledger.remaining(:tokens))
|
|
22
|
+
# # ... do the billable work ...
|
|
23
|
+
# ledger.reconcile!(:tokens, ledger.remaining(:tokens), actual_tokens_used)
|
|
24
|
+
class Ledger
|
|
25
|
+
# @!attribute [r] limits
|
|
26
|
+
# @return [Hash{Symbol=>Numeric}] per-dimension ceilings
|
|
27
|
+
# @!attribute [r] consumed
|
|
28
|
+
# @return [Hash{Symbol=>Numeric}] actual amount consumed per dimension so far
|
|
29
|
+
attr_reader :limits, :consumed
|
|
30
|
+
|
|
31
|
+
# @param limits [Hash{Symbol=>Numeric}] per-dimension ceilings; a dimension absent
|
|
32
|
+
# from this hash is treated as unlimited
|
|
33
|
+
# @param consumed [Hash{Symbol=>Numeric}] starting consumption (e.g. restored from a prior run)
|
|
34
|
+
def initialize(limits: {}, consumed: {})
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@limits = limits
|
|
37
|
+
@consumed = Hash.new(0).merge(consumed)
|
|
38
|
+
@reserved = Hash.new(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reserves +amount+ against +key+'s remaining budget.
|
|
42
|
+
#
|
|
43
|
+
# A no-op (never raises) when +key+ has no configured limit.
|
|
44
|
+
#
|
|
45
|
+
# @param key [Symbol] the budget dimension
|
|
46
|
+
# @param amount [Numeric] the amount to reserve
|
|
47
|
+
# @raise [RobotLab::BudgetExceeded] if the reservation would exceed the limit
|
|
48
|
+
# @return [void]
|
|
49
|
+
def reserve!(key, amount)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
limit = @limits[key]
|
|
52
|
+
next unless limit
|
|
53
|
+
|
|
54
|
+
committed = @consumed[key] + @reserved[key]
|
|
55
|
+
if committed + amount > limit
|
|
56
|
+
raise BudgetExceeded, "budget exceeded for #{key}: #{committed + amount} > #{limit}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@reserved[key] += amount
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Replaces a prior reservation with the actual amount consumed.
|
|
64
|
+
#
|
|
65
|
+
# @param key [Symbol] the budget dimension
|
|
66
|
+
# @param reserved_amount [Numeric] the amount previously passed to +reserve!+
|
|
67
|
+
# @param actual_amount [Numeric] the amount actually consumed
|
|
68
|
+
# @return [void]
|
|
69
|
+
def reconcile!(key, reserved_amount, actual_amount)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@reserved[key] = [0, @reserved[key] - reserved_amount].max
|
|
72
|
+
@consumed[key] += actual_amount
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Drops a reservation without recording any consumption (e.g. the
|
|
77
|
+
# reserved work was skipped).
|
|
78
|
+
#
|
|
79
|
+
# @param key [Symbol] the budget dimension
|
|
80
|
+
# @param amount [Numeric] the amount previously passed to +reserve!+
|
|
81
|
+
# @return [void]
|
|
82
|
+
def release!(key, amount)
|
|
83
|
+
@mutex.synchronize { @reserved[key] = [0, @reserved[key] - amount].max }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param key [Symbol] the budget dimension
|
|
87
|
+
# @return [Numeric] remaining budget for +key+, floored at 0; +Float::INFINITY+ when unlimited
|
|
88
|
+
def remaining(key)
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
limit = @limits[key]
|
|
91
|
+
next Float::INFINITY unless limit
|
|
92
|
+
|
|
93
|
+
[limit - @consumed[key] - @reserved[key], 0].max
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Declared execution capabilities for a skill script: which paths it may read
|
|
5
|
+
# and write, whether it may use the network, how long it may run, and how much
|
|
6
|
+
# it is trusted.
|
|
7
|
+
#
|
|
8
|
+
# A skill declares what it *wants* in SKILL.md front matter; the global config
|
|
9
|
+
# declares the *ceiling* of what any skill may have. The effective grant is the
|
|
10
|
+
# intersection of the two (see {#intersect}) — anything the skill asks for that
|
|
11
|
+
# the ceiling does not permit is dropped.
|
|
12
|
+
class Capabilities
|
|
13
|
+
DEFAULT_TIMEOUT = 60
|
|
14
|
+
TRUST_LEVELS = %w[core external].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :fs_read, :fs_write, :network, :timeout, :trust
|
|
17
|
+
|
|
18
|
+
def initialize(fs_read: [], fs_write: [], network: false, timeout: DEFAULT_TIMEOUT, trust: "external")
|
|
19
|
+
@fs_read = Array(fs_read).map(&:to_s)
|
|
20
|
+
@fs_write = Array(fs_write).map(&:to_s)
|
|
21
|
+
@network = network ? true : false
|
|
22
|
+
@timeout = timeout.to_i.positive? ? timeout.to_i : DEFAULT_TIMEOUT
|
|
23
|
+
@trust = TRUST_LEVELS.include?(trust.to_s) ? trust.to_s : "external"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build from a SKILL.md front matter hash (string or symbol keys).
|
|
27
|
+
def self.from_front_matter(front_matter)
|
|
28
|
+
fm = front_matter || {}
|
|
29
|
+
new(
|
|
30
|
+
fs_read: fm_value(fm, :fs_read, []),
|
|
31
|
+
fs_write: fm_value(fm, :fs_write, []),
|
|
32
|
+
network: fm_value(fm, :network, false),
|
|
33
|
+
timeout: fm_value(fm, :timeout, DEFAULT_TIMEOUT),
|
|
34
|
+
trust: fm_value(fm, :trust, "external")
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Look up a front-matter key tolerating string or symbol keys.
|
|
39
|
+
def self.fm_value(front_matter, key, default)
|
|
40
|
+
value = front_matter[key.to_s]
|
|
41
|
+
value = front_matter[key] if value.nil?
|
|
42
|
+
value.nil? ? default : value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build the ceiling from the global config's sandbox section.
|
|
46
|
+
def self.ceiling(config = RobotLab.config)
|
|
47
|
+
section = config.respond_to?(:sandbox) ? config.sandbox : nil
|
|
48
|
+
return new(fs_read: ["."]) unless section
|
|
49
|
+
|
|
50
|
+
new(
|
|
51
|
+
fs_read: section.fs_read || ["."],
|
|
52
|
+
fs_write: section.fs_write || [],
|
|
53
|
+
network: section.network || false,
|
|
54
|
+
timeout: section.timeout || DEFAULT_TIMEOUT
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def core? = trust == "core"
|
|
59
|
+
|
|
60
|
+
# Effective grant = this (declared) ∩ ceiling. Paths survive only when
|
|
61
|
+
# inside a ceiling root; network requires both; timeout is the smaller;
|
|
62
|
+
# trust stays as declared.
|
|
63
|
+
def intersect(ceiling)
|
|
64
|
+
self.class.new(
|
|
65
|
+
fs_read: clamp_paths(@fs_read, ceiling.fs_read),
|
|
66
|
+
fs_write: clamp_paths(@fs_write, ceiling.fs_write),
|
|
67
|
+
network: @network && ceiling.network,
|
|
68
|
+
timeout: [@timeout, ceiling.timeout].min,
|
|
69
|
+
trust: @trust
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clamp_paths(requested, allowed)
|
|
76
|
+
roots = expand(allowed)
|
|
77
|
+
expand(requested).select { |p| roots.any? { |r| p == r || p.start_with?("#{r}/") } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expand(paths)
|
|
81
|
+
Array(paths).map { |p| File.expand_path(p.to_s) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -25,6 +25,16 @@ defaults:
|
|
|
25
25
|
mcp: :none
|
|
26
26
|
tools: :none
|
|
27
27
|
|
|
28
|
+
# Skill-script sandboxing (opt-in). When enabled, skill scripts run confined
|
|
29
|
+
# to the effective grant (their SKILL.md declaration ∩ this ceiling). macOS
|
|
30
|
+
# uses sandbox-exec; other platforms run unconfined with a warning.
|
|
31
|
+
sandbox:
|
|
32
|
+
enabled: false # opt-in; off = scripts run exactly as before
|
|
33
|
+
fs_read: ["."] # ceiling: paths any skill may read (relative to cwd)
|
|
34
|
+
fs_write: [] # ceiling: paths any skill may write
|
|
35
|
+
network: false # ceiling: may any skill use the network
|
|
36
|
+
timeout: 60 # ceiling: max seconds a skill script may run
|
|
37
|
+
|
|
28
38
|
# Chat Configuration (passed to RubyLLM.chat)
|
|
29
39
|
# These are global defaults that can be overridden per-robot or per-run
|
|
30
40
|
chat:
|
data/lib/robot_lab/config.rb
CHANGED
|
@@ -153,16 +153,16 @@ module RobotLab
|
|
|
153
153
|
def resolved_template_path
|
|
154
154
|
return template_path if template_path
|
|
155
155
|
|
|
156
|
-
if defined?(Rails) && Rails.root
|
|
157
|
-
Rails.root.join('app', 'prompts').to_s
|
|
156
|
+
if defined?(::Rails) && ::Rails.root
|
|
157
|
+
::Rails.root.join('app', 'prompts').to_s
|
|
158
158
|
else
|
|
159
159
|
'prompts'
|
|
160
160
|
end
|
|
161
161
|
end
|
|
162
162
|
|
|
163
163
|
def default_logger
|
|
164
|
-
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
165
|
-
Rails.logger
|
|
164
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
165
|
+
::Rails.logger
|
|
166
166
|
else
|
|
167
167
|
require 'logger'
|
|
168
168
|
Logger.new($stdout, level: Logger::INFO)
|
data/lib/robot_lab/error.rb
CHANGED
|
@@ -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
|
|
31
|
-
class MCPError < Error
|
|
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
|
|
62
|
-
class ToolError < Error
|
|
84
|
+
# raise ToolError.new("Tool 'MyTool' failed: division by zero")
|
|
85
|
+
class ToolError < Error
|
|
86
|
+
attr_reader :retryable
|
|
87
|
+
|
|
88
|
+
def initialize(message = nil, retryable: nil)
|
|
89
|
+
@retryable = retryable
|
|
90
|
+
super(message)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
63
93
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Classification surface for "should this error be retried?" — so hosts
|
|
5
|
+
# (ActiveJob retry_on lists, robot_lab-to's takeover loop, custom retry
|
|
6
|
+
# wrappers) don't reimplement the same case statement against RobotLab's
|
|
7
|
+
# error hierarchy.
|
|
8
|
+
#
|
|
9
|
+
# - +InferenceError+ (transient LLM/API failures) is always retryable,
|
|
10
|
+
# except its more specific subclass +ToolLoopError+ (a circuit breaker
|
|
11
|
+
# tripped by a repeating tool-call pattern — retrying immediately would
|
|
12
|
+
# just re-trigger the same loop).
|
|
13
|
+
# - +MCPError+ and +ToolError+ are opt-in: retryable only when raised
|
|
14
|
+
# with +retryable: true+, since some instances are transient (a
|
|
15
|
+
# connection drop, a timeout) and some are not (an explicit rejection
|
|
16
|
+
# from the server or tool).
|
|
17
|
+
# - Everything else (+ConfigurationError+, +ToolNotFoundError+,
|
|
18
|
+
# +DependencyError+, +RactorBoundaryError+, +BusError+, and any
|
|
19
|
+
# non-RobotLab error) is never retryable.
|
|
20
|
+
module Errors
|
|
21
|
+
# @param error [Exception, nil]
|
|
22
|
+
# @return [Boolean] true when the host should retry the operation that raised +error+
|
|
23
|
+
def self.retryable?(error)
|
|
24
|
+
return false if error.nil? || error.is_a?(RobotLab::ToolLoopError)
|
|
25
|
+
|
|
26
|
+
case error
|
|
27
|
+
when RobotLab::MCPError, RobotLab::ToolError
|
|
28
|
+
error.respond_to?(:retryable) && error.retryable == true
|
|
29
|
+
when RobotLab::InferenceError
|
|
30
|
+
true
|
|
31
|
+
else
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Always-retryable error classes, for explicit ActiveJob-style
|
|
37
|
+
# +retry_on+ allow-lists. Excludes +MCPError+/+ToolError+ because
|
|
38
|
+
# their retryability is per-raise (via +retryable:+), not per-class.
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<Class>]
|
|
41
|
+
def self.retryable_classes
|
|
42
|
+
[RobotLab::InferenceError].freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Base class for hook handlers.
|
|
5
|
+
#
|
|
6
|
+
# Subclasses implement class methods for each hook they handle.
|
|
7
|
+
# Unimplemented hooks are silently skipped; around hooks passthrough
|
|
8
|
+
# automatically so the chain never breaks.
|
|
9
|
+
#
|
|
10
|
+
# The namespace defaults to the snake_case form of the last segment of the
|
|
11
|
+
# class name (e.g. TokenLogger -> :token_logger). Override with
|
|
12
|
+
# `self.namespace = :custom` when needed.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# class AuditHook < RobotLab::Hook
|
|
16
|
+
# def self.before_run(ctx)
|
|
17
|
+
# ctx.local.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# def self.after_run(ctx)
|
|
21
|
+
# elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.local.started_at
|
|
22
|
+
# puts "[#{ctx.robot.name}] #{(elapsed * 1000).round(1)}ms"
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# def self.on_error(ctx)
|
|
26
|
+
# puts "[#{ctx.robot.name}] ERROR: #{ctx.error.message}"
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# RobotLab.on(AuditHook)
|
|
31
|
+
# robot.on(AuditHook)
|
|
32
|
+
# network.on(AuditHook)
|
|
33
|
+
#
|
|
34
|
+
class Hook
|
|
35
|
+
@namespace = nil
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
attr_writer :namespace
|
|
39
|
+
|
|
40
|
+
# Returns the hook's namespace symbol, derived from the class name by
|
|
41
|
+
# default. Returns nil for the base Hook class itself.
|
|
42
|
+
#
|
|
43
|
+
# @return [Symbol, nil]
|
|
44
|
+
def namespace
|
|
45
|
+
return @namespace if @namespace
|
|
46
|
+
return nil if self == Hook
|
|
47
|
+
|
|
48
|
+
name.split('::').last
|
|
49
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
50
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
51
|
+
.downcase
|
|
52
|
+
.to_sym
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Dispatch a single hook to this handler.
|
|
56
|
+
#
|
|
57
|
+
# If the handler implements the method it is called. For around hooks that
|
|
58
|
+
# are not implemented the block is called directly (passthrough). Non-around
|
|
59
|
+
# hooks that are not implemented are silent no-ops.
|
|
60
|
+
#
|
|
61
|
+
# @param hook_name [Symbol] e.g. :before_run, :around_tool_call
|
|
62
|
+
# @param context [HookContext] the hook context object
|
|
63
|
+
# @yield for around hooks — the next link in the chain
|
|
64
|
+
def call(hook_name, context, &block)
|
|
65
|
+
if singleton_class.public_method_defined?(hook_name)
|
|
66
|
+
block ? public_send(hook_name, context, &block)
|
|
67
|
+
: public_send(hook_name, context)
|
|
68
|
+
elsif block
|
|
69
|
+
block.call
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inherited(subclass)
|
|
74
|
+
super
|
|
75
|
+
subclass.instance_variable_set(:@namespace, nil)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class HookContext
|
|
5
|
+
attr_reader :event, :metadata
|
|
6
|
+
|
|
7
|
+
def initialize(event:, metadata: nil)
|
|
8
|
+
@event = event.to_sym
|
|
9
|
+
@metadata = metadata || ExtensionState.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ext(name)
|
|
13
|
+
metadata.ext(name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def local
|
|
17
|
+
raise ArgumentError, "No hook namespace active" unless @namespace
|
|
18
|
+
|
|
19
|
+
ext(@namespace)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_namespace(namespace)
|
|
23
|
+
previous = @namespace
|
|
24
|
+
@namespace = namespace&.to_sym
|
|
25
|
+
yield self
|
|
26
|
+
ensure
|
|
27
|
+
@namespace = previous
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
public_snapshot.merge(metadata: metadata.to_h)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def public_snapshot
|
|
37
|
+
public_methods(false)
|
|
38
|
+
.grep_v(/=\z/)
|
|
39
|
+
.reject { |name| %i[to_h local].include?(name) }
|
|
40
|
+
.sort
|
|
41
|
+
.to_h { |name| [name, public_send(name)] }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class RunHookContext < HookContext
|
|
46
|
+
attr_reader :robot, :network, :task, :memory, :config
|
|
47
|
+
attr_accessor :request, :response, :error
|
|
48
|
+
|
|
49
|
+
def initialize(robot:, request:, network: nil, task: nil, memory: nil, config: nil, response: nil, error: nil, **)
|
|
50
|
+
super(event: :run, **)
|
|
51
|
+
@robot = robot
|
|
52
|
+
@network = network
|
|
53
|
+
@task = task
|
|
54
|
+
@memory = memory
|
|
55
|
+
@config = config
|
|
56
|
+
@request = request
|
|
57
|
+
@response = response
|
|
58
|
+
@error = error
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class LlmGenerationHookContext < RunHookContext
|
|
63
|
+
attr_reader :iteration
|
|
64
|
+
attr_accessor :generation_response
|
|
65
|
+
|
|
66
|
+
def initialize(iteration: 0, generation_response: nil, **)
|
|
67
|
+
super(**)
|
|
68
|
+
@iteration = iteration
|
|
69
|
+
@generation_response = generation_response
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class ToolCallHookContext < HookContext
|
|
74
|
+
attr_reader :tool, :tool_name, :tool_args, :robot
|
|
75
|
+
attr_accessor :tool_result, :tool_error
|
|
76
|
+
|
|
77
|
+
def initialize(tool:, tool_args:, robot: nil, tool_result: nil, tool_error: nil, **)
|
|
78
|
+
super(event: :tool_call, **)
|
|
79
|
+
@tool = tool
|
|
80
|
+
@tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
81
|
+
@tool_args = tool_args
|
|
82
|
+
@robot = robot
|
|
83
|
+
@tool_result = tool_result
|
|
84
|
+
@tool_error = tool_error
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class NetworkRunHookContext < HookContext
|
|
89
|
+
attr_reader :network, :memory, :config
|
|
90
|
+
attr_accessor :context, :result, :error
|
|
91
|
+
|
|
92
|
+
def initialize(network:, context:, memory: nil, config: nil, result: nil, error: nil, **)
|
|
93
|
+
super(event: :network_run, **)
|
|
94
|
+
@network = network
|
|
95
|
+
@context = context
|
|
96
|
+
@memory = memory
|
|
97
|
+
@config = config
|
|
98
|
+
@result = result
|
|
99
|
+
@error = error
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class TaskHookContext < HookContext
|
|
104
|
+
attr_reader :network, :task, :task_name, :robot, :memory, :config
|
|
105
|
+
attr_accessor :result, :error
|
|
106
|
+
|
|
107
|
+
def initialize(task:, network: nil, robot: nil, memory: nil, config: nil, result: nil, error: nil, **)
|
|
108
|
+
super(event: :task, **)
|
|
109
|
+
@network = network
|
|
110
|
+
@task = task
|
|
111
|
+
@task_name = task.name
|
|
112
|
+
@robot = robot || task.robot
|
|
113
|
+
@memory = memory
|
|
114
|
+
@config = config
|
|
115
|
+
@result = result
|
|
116
|
+
@error = error
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class LearnHookContext < HookContext
|
|
121
|
+
attr_reader :robot, :text, :learnings_before
|
|
122
|
+
attr_accessor :stored, :error
|
|
123
|
+
|
|
124
|
+
def initialize(robot:, text:, learnings_before:, **)
|
|
125
|
+
super(event: :learn, **)
|
|
126
|
+
@robot = robot
|
|
127
|
+
@text = text
|
|
128
|
+
@learnings_before = learnings_before.freeze
|
|
129
|
+
@stored = false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class CompactionHookContext < HookContext
|
|
134
|
+
attr_reader :robot, :messages_before, :config, :strategy
|
|
135
|
+
attr_accessor :compacted_messages, :error
|
|
136
|
+
|
|
137
|
+
def initialize(robot:, messages_before:, config:, strategy:, **)
|
|
138
|
+
super(event: :compaction, **)
|
|
139
|
+
@robot = robot
|
|
140
|
+
@messages_before = messages_before.freeze
|
|
141
|
+
@config = config
|
|
142
|
+
@strategy = strategy
|
|
143
|
+
@compacted_messages = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# True once an on_compaction handler has supplied a replacement message set.
|
|
147
|
+
def handled?
|
|
148
|
+
!@compacted_messages.nil?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class ExtensionState
|
|
153
|
+
def initialize
|
|
154
|
+
@states = Hash.new { |h, key| h[key] = DotState.new }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ext(name)
|
|
158
|
+
@states[name.to_sym]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def to_h
|
|
162
|
+
@states.transform_values(&:to_h)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class DotState
|
|
167
|
+
def initialize
|
|
168
|
+
@data = {}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def merge_defaults(hash)
|
|
172
|
+
hash.each { |k, v| @data[k.to_sym] = v unless @data.key?(k.to_sym) }
|
|
173
|
+
self
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def method_missing(name, *args)
|
|
177
|
+
key = name.to_s.delete_suffix('=').to_sym
|
|
178
|
+
|
|
179
|
+
if name.to_s.end_with?('=')
|
|
180
|
+
@data[key] = args.first
|
|
181
|
+
else
|
|
182
|
+
@data[key]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def respond_to_missing?(*)
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_h
|
|
191
|
+
@data.dup
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class HookRegistry
|
|
5
|
+
Registration = Data.define(:handler_class, :context) do
|
|
6
|
+
def initialize(handler_class:, context: nil)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def namespace
|
|
11
|
+
handler_class.namespace
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@registrations = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Register a hook handler class.
|
|
20
|
+
#
|
|
21
|
+
# @param handler_class [Class] a RobotLab::Hook subclass
|
|
22
|
+
# @param context [Hash, nil] optional default values merged into ctx.local on each call
|
|
23
|
+
# @return [Registration]
|
|
24
|
+
def on(handler_class, context: nil)
|
|
25
|
+
unless handler_class.is_a?(Class) && handler_class < RobotLab::Hook
|
|
26
|
+
raise ArgumentError, "#{handler_class.inspect} must be a RobotLab::Hook subclass"
|
|
27
|
+
end
|
|
28
|
+
raise ArgumentError, "#{handler_class} has no namespace" if handler_class.namespace.nil?
|
|
29
|
+
|
|
30
|
+
registration = Registration.new(handler_class: handler_class, context: context)
|
|
31
|
+
@registrations << registration
|
|
32
|
+
registration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns registrations whose handler implements the given hook method.
|
|
36
|
+
#
|
|
37
|
+
# @param hook_name [Symbol]
|
|
38
|
+
# @return [Array<Registration>]
|
|
39
|
+
def registrations_for(hook_name)
|
|
40
|
+
hook_sym = hook_name.to_sym
|
|
41
|
+
@registrations.select { |reg| reg.handler_class.singleton_class.public_method_defined?(hook_sym) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns all registrations (defensive copy).
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Registration>]
|
|
47
|
+
def registrations
|
|
48
|
+
@registrations.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clear
|
|
52
|
+
@registrations.clear
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|