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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
# Value object representing an AgentSkills.io skill folder.
|
|
7
|
+
#
|
|
8
|
+
# A skill is a directory containing SKILL.md with required front matter
|
|
9
|
+
# fields (name, description) and optional scripts/, references/, assets/.
|
|
10
|
+
class AgentSkill
|
|
11
|
+
attr_reader :name, :description, :path, :capabilities
|
|
12
|
+
|
|
13
|
+
# @param skill_md_path [String, Pathname] path to the SKILL.md file
|
|
14
|
+
# @raise [ConfigurationError] if name or description is missing
|
|
15
|
+
def initialize(skill_md_path)
|
|
16
|
+
@path = Pathname.new(skill_md_path).dirname
|
|
17
|
+
content = File.read(skill_md_path)
|
|
18
|
+
front_matter, @_body = parse_skill_md(content)
|
|
19
|
+
|
|
20
|
+
@name = front_matter["name"]
|
|
21
|
+
@description = front_matter["description"]
|
|
22
|
+
@capabilities = Capabilities.from_front_matter(front_matter)
|
|
23
|
+
|
|
24
|
+
raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'name'" if @name.to_s.strip.empty?
|
|
25
|
+
raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'description'" if @description.to_s.strip.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Full instruction text from the SKILL.md body (below the front matter).
|
|
29
|
+
def instructions
|
|
30
|
+
@instructions ||= @_body.strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Pathnames of all files inside the scripts/ subdirectory, sorted.
|
|
34
|
+
def scripts
|
|
35
|
+
@scripts ||= begin
|
|
36
|
+
dir = @path.join("scripts")
|
|
37
|
+
dir.directory? ? dir.children.select(&:file?).sort : []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# RobotLab::Tool instances wrapping each executable script.
|
|
42
|
+
# Non-executable scripts are skipped with a warning.
|
|
43
|
+
def script_tools
|
|
44
|
+
@script_tools ||= scripts.filter_map do |script_path|
|
|
45
|
+
ScriptTool.from_path(script_path, capabilities: @capabilities, skill_dir: @path.to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Split SKILL.md content into front matter Hash and body String.
|
|
52
|
+
def parse_skill_md(content)
|
|
53
|
+
if content.start_with?("---\n")
|
|
54
|
+
parts = content.split(/^---\s*$/, 3)
|
|
55
|
+
if parts.length >= 3
|
|
56
|
+
fm = YAML.safe_load(parts[1])
|
|
57
|
+
front_matter = fm.is_a?(Hash) ? fm : {}
|
|
58
|
+
return [front_matter, parts[2]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
[{}, content]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Singleton registry that scans ~/.prompts/skills/ and provides
|
|
5
|
+
# AgentSkill lookup by ID.
|
|
6
|
+
#
|
|
7
|
+
# Skills are loaded lazily on first access (thread-safe via Mutex).
|
|
8
|
+
# Bad SKILL.md files (missing name/description) are skipped with a warning.
|
|
9
|
+
class AgentSkillCatalog
|
|
10
|
+
SKILLS_ROOT = Pathname.new(File.expand_path("~/.prompts/skills"))
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# The process-level singleton instance (uses SKILLS_ROOT).
|
|
14
|
+
def instance
|
|
15
|
+
@instance ||= new(SKILLS_ROOT)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Reset the singleton (used in tests to swap the skills root).
|
|
19
|
+
def reset!
|
|
20
|
+
@instance = nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param skills_root [String, Pathname] directory to scan for skill folders
|
|
25
|
+
def initialize(skills_root = SKILLS_ROOT)
|
|
26
|
+
@skills_root = Pathname.new(skills_root)
|
|
27
|
+
@skills = {}
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@loaded = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Return the AgentSkill for the given ID, or nil if not found.
|
|
33
|
+
#
|
|
34
|
+
# @param id [Symbol, String] skill folder name
|
|
35
|
+
# @return [AgentSkill, nil]
|
|
36
|
+
def find(id)
|
|
37
|
+
load!
|
|
38
|
+
@skills[id.to_sym]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# All discovered AgentSkill objects.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<AgentSkill>]
|
|
44
|
+
def all
|
|
45
|
+
load!
|
|
46
|
+
@skills.values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def load!
|
|
52
|
+
@mutex.synchronize { load_skills! unless @loaded }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_skills!
|
|
56
|
+
@loaded = true
|
|
57
|
+
return unless @skills_root.directory?
|
|
58
|
+
|
|
59
|
+
@skills_root.each_child do |dir|
|
|
60
|
+
next unless dir.directory?
|
|
61
|
+
|
|
62
|
+
skill_file = dir.join("SKILL.md")
|
|
63
|
+
next unless skill_file.exist?
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
skill = AgentSkill.new(skill_file)
|
|
67
|
+
@skills[skill.name.to_sym] = skill
|
|
68
|
+
rescue ConfigurationError, Psych::SyntaxError => e
|
|
69
|
+
RobotLab.config.logger.warn("AgentSkillCatalog: #{e.message}, skipping #{dir.basename}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/robot_lab/ask_user.rb
CHANGED
|
@@ -65,11 +65,11 @@ module RobotLab
|
|
|
65
65
|
private
|
|
66
66
|
|
|
67
67
|
def input_io
|
|
68
|
-
robot
|
|
68
|
+
robot.respond_to?(:input) && robot.input ? robot.input : $stdin
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def output_io
|
|
72
|
-
robot
|
|
72
|
+
robot.respond_to?(:output) && robot.output ? robot.output : $stdout
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Budget
|
|
5
|
+
# Thread-safe reserve/reconcile ledger for tracking consumption against
|
|
6
|
+
# per-dimension limits (e.g. :tokens, :cost).
|
|
7
|
+
#
|
|
8
|
+
# Call +reserve!+ before doing billable work, so an already-exhausted
|
|
9
|
+
# budget is caught before the work is attempted rather than after.
|
|
10
|
+
# Once the work completes, +reconcile!+ replaces the reservation with
|
|
11
|
+
# the actual amount consumed (which may be more or less than what was
|
|
12
|
+
# reserved — LLM call sizes aren't known in advance). +release!+ drops
|
|
13
|
+
# an unused reservation without recording any consumption.
|
|
14
|
+
#
|
|
15
|
+
# A dimension with no configured limit is treated as unlimited:
|
|
16
|
+
# +reserve!+ never raises for it and +remaining+ returns
|
|
17
|
+
# +Float::INFINITY+.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# ledger = RobotLab::Budget::Ledger.new(limits: { tokens: 10_000, cost: 0.50 })
|
|
21
|
+
# ledger.reserve!(:tokens, ledger.remaining(:tokens))
|
|
22
|
+
# # ... do the billable work ...
|
|
23
|
+
# ledger.reconcile!(:tokens, ledger.remaining(:tokens), actual_tokens_used)
|
|
24
|
+
class Ledger
|
|
25
|
+
# @!attribute [r] limits
|
|
26
|
+
# @return [Hash{Symbol=>Numeric}] per-dimension ceilings
|
|
27
|
+
# @!attribute [r] consumed
|
|
28
|
+
# @return [Hash{Symbol=>Numeric}] actual amount consumed per dimension so far
|
|
29
|
+
attr_reader :limits, :consumed
|
|
30
|
+
|
|
31
|
+
# @param limits [Hash{Symbol=>Numeric}] per-dimension ceilings; a dimension absent
|
|
32
|
+
# from this hash is treated as unlimited
|
|
33
|
+
# @param consumed [Hash{Symbol=>Numeric}] starting consumption (e.g. restored from a prior run)
|
|
34
|
+
def initialize(limits: {}, consumed: {})
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@limits = limits
|
|
37
|
+
@consumed = Hash.new(0).merge(consumed)
|
|
38
|
+
@reserved = Hash.new(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reserves +amount+ against +key+'s remaining budget.
|
|
42
|
+
#
|
|
43
|
+
# A no-op (never raises) when +key+ has no configured limit.
|
|
44
|
+
#
|
|
45
|
+
# @param key [Symbol] the budget dimension
|
|
46
|
+
# @param amount [Numeric] the amount to reserve
|
|
47
|
+
# @raise [RobotLab::BudgetExceeded] if the reservation would exceed the limit
|
|
48
|
+
# @return [void]
|
|
49
|
+
def reserve!(key, amount)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
limit = @limits[key]
|
|
52
|
+
next unless limit
|
|
53
|
+
|
|
54
|
+
committed = @consumed[key] + @reserved[key]
|
|
55
|
+
if committed + amount > limit
|
|
56
|
+
raise BudgetExceeded, "budget exceeded for #{key}: #{committed + amount} > #{limit}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@reserved[key] += amount
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Replaces a prior reservation with the actual amount consumed.
|
|
64
|
+
#
|
|
65
|
+
# @param key [Symbol] the budget dimension
|
|
66
|
+
# @param reserved_amount [Numeric] the amount previously passed to +reserve!+
|
|
67
|
+
# @param actual_amount [Numeric] the amount actually consumed
|
|
68
|
+
# @return [void]
|
|
69
|
+
def reconcile!(key, reserved_amount, actual_amount)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@reserved[key] = [0, @reserved[key] - reserved_amount].max
|
|
72
|
+
@consumed[key] += actual_amount
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Drops a reservation without recording any consumption (e.g. the
|
|
77
|
+
# reserved work was skipped).
|
|
78
|
+
#
|
|
79
|
+
# @param key [Symbol] the budget dimension
|
|
80
|
+
# @param amount [Numeric] the amount previously passed to +reserve!+
|
|
81
|
+
# @return [void]
|
|
82
|
+
def release!(key, amount)
|
|
83
|
+
@mutex.synchronize { @reserved[key] = [0, @reserved[key] - amount].max }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param key [Symbol] the budget dimension
|
|
87
|
+
# @return [Numeric] remaining budget for +key+, floored at 0; +Float::INFINITY+ when unlimited
|
|
88
|
+
def remaining(key)
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
limit = @limits[key]
|
|
91
|
+
next Float::INFINITY unless limit
|
|
92
|
+
|
|
93
|
+
[limit - @consumed[key] - @reserved[key], 0].max
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/robot_lab/bus_poller.rb
CHANGED
|
@@ -115,7 +115,16 @@ module RobotLab
|
|
|
115
115
|
# Process one delivery, then drain any queued deliveries for the robot.
|
|
116
116
|
def process_and_drain(robot, delivery)
|
|
117
117
|
robot.send(:process_delivery, delivery)
|
|
118
|
+
drain_queued_deliveries(robot)
|
|
119
|
+
rescue BusError => e
|
|
120
|
+
release_robot(robot)
|
|
121
|
+
RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
|
|
122
|
+
rescue => e
|
|
123
|
+
release_robot(robot)
|
|
124
|
+
RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
|
|
125
|
+
end
|
|
118
126
|
|
|
127
|
+
def drain_queued_deliveries(robot)
|
|
119
128
|
loop do
|
|
120
129
|
next_delivery = @mutex.synchronize do
|
|
121
130
|
name = robot.name
|
|
@@ -138,12 +147,10 @@ module RobotLab
|
|
|
138
147
|
RobotLab.config.logger.warn("BusPoller: delivery error: #{e.message}")
|
|
139
148
|
end
|
|
140
149
|
end
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
rescue => e
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def release_robot(robot)
|
|
145
153
|
@mutex.synchronize { @robot_busy[robot.name] = false }
|
|
146
|
-
RobotLab.config.logger.warn("BusPoller: unexpected error: #{e.message}")
|
|
147
154
|
end
|
|
148
155
|
end
|
|
149
156
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Declared execution capabilities for a skill script: which paths it may read
|
|
5
|
+
# and write, whether it may use the network, how long it may run, and how much
|
|
6
|
+
# it is trusted.
|
|
7
|
+
#
|
|
8
|
+
# A skill declares what it *wants* in SKILL.md front matter; the global config
|
|
9
|
+
# declares the *ceiling* of what any skill may have. The effective grant is the
|
|
10
|
+
# intersection of the two (see {#intersect}) — anything the skill asks for that
|
|
11
|
+
# the ceiling does not permit is dropped.
|
|
12
|
+
class Capabilities
|
|
13
|
+
DEFAULT_TIMEOUT = 60
|
|
14
|
+
TRUST_LEVELS = %w[core external].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :fs_read, :fs_write, :network, :timeout, :trust
|
|
17
|
+
|
|
18
|
+
def initialize(fs_read: [], fs_write: [], network: false, timeout: DEFAULT_TIMEOUT, trust: "external")
|
|
19
|
+
@fs_read = Array(fs_read).map(&:to_s)
|
|
20
|
+
@fs_write = Array(fs_write).map(&:to_s)
|
|
21
|
+
@network = network ? true : false
|
|
22
|
+
@timeout = timeout.to_i.positive? ? timeout.to_i : DEFAULT_TIMEOUT
|
|
23
|
+
@trust = TRUST_LEVELS.include?(trust.to_s) ? trust.to_s : "external"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build from a SKILL.md front matter hash (string or symbol keys).
|
|
27
|
+
def self.from_front_matter(front_matter)
|
|
28
|
+
fm = front_matter || {}
|
|
29
|
+
new(
|
|
30
|
+
fs_read: fm_value(fm, :fs_read, []),
|
|
31
|
+
fs_write: fm_value(fm, :fs_write, []),
|
|
32
|
+
network: fm_value(fm, :network, false),
|
|
33
|
+
timeout: fm_value(fm, :timeout, DEFAULT_TIMEOUT),
|
|
34
|
+
trust: fm_value(fm, :trust, "external")
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Look up a front-matter key tolerating string or symbol keys.
|
|
39
|
+
def self.fm_value(front_matter, key, default)
|
|
40
|
+
value = front_matter[key.to_s]
|
|
41
|
+
value = front_matter[key] if value.nil?
|
|
42
|
+
value.nil? ? default : value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build the ceiling from the global config's sandbox section.
|
|
46
|
+
def self.ceiling(config = RobotLab.config)
|
|
47
|
+
section = config.respond_to?(:sandbox) ? config.sandbox : nil
|
|
48
|
+
return new(fs_read: ["."]) unless section
|
|
49
|
+
|
|
50
|
+
new(
|
|
51
|
+
fs_read: section.fs_read || ["."],
|
|
52
|
+
fs_write: section.fs_write || [],
|
|
53
|
+
network: section.network || false,
|
|
54
|
+
timeout: section.timeout || DEFAULT_TIMEOUT
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def core? = trust == "core"
|
|
59
|
+
|
|
60
|
+
# Effective grant = this (declared) ∩ ceiling. Paths survive only when
|
|
61
|
+
# inside a ceiling root; network requires both; timeout is the smaller;
|
|
62
|
+
# trust stays as declared.
|
|
63
|
+
def intersect(ceiling)
|
|
64
|
+
self.class.new(
|
|
65
|
+
fs_read: clamp_paths(@fs_read, ceiling.fs_read),
|
|
66
|
+
fs_write: clamp_paths(@fs_write, ceiling.fs_write),
|
|
67
|
+
network: @network && ceiling.network,
|
|
68
|
+
timeout: [@timeout, ceiling.timeout].min,
|
|
69
|
+
trust: @trust
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clamp_paths(requested, allowed)
|
|
76
|
+
roots = expand(allowed)
|
|
77
|
+
expand(requested).select { |p| roots.any? { |r| p == r || p.start_with?("#{r}/") } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expand(paths)
|
|
81
|
+
Array(paths).map { |p| File.expand_path(p.to_s) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -25,6 +25,16 @@ defaults:
|
|
|
25
25
|
mcp: :none
|
|
26
26
|
tools: :none
|
|
27
27
|
|
|
28
|
+
# Skill-script sandboxing (opt-in). When enabled, skill scripts run confined
|
|
29
|
+
# to the effective grant (their SKILL.md declaration ∩ this ceiling). macOS
|
|
30
|
+
# uses sandbox-exec; other platforms run unconfined with a warning.
|
|
31
|
+
sandbox:
|
|
32
|
+
enabled: false # opt-in; off = scripts run exactly as before
|
|
33
|
+
fs_read: ["."] # ceiling: paths any skill may read (relative to cwd)
|
|
34
|
+
fs_write: [] # ceiling: paths any skill may write
|
|
35
|
+
network: false # ceiling: may any skill use the network
|
|
36
|
+
timeout: 60 # ceiling: max seconds a skill script may run
|
|
37
|
+
|
|
28
38
|
# Chat Configuration (passed to RubyLLM.chat)
|
|
29
39
|
# These are global defaults that can be overridden per-robot or per-run
|
|
30
40
|
chat:
|
data/lib/robot_lab/config.rb
CHANGED
|
@@ -43,7 +43,6 @@ module RobotLab
|
|
|
43
43
|
@logger ||= default_logger
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
47
46
|
# Apply RubyLLM configuration after loading.
|
|
48
47
|
#
|
|
49
48
|
# This method should be called after initialization to configure
|
|
@@ -56,7 +55,6 @@ module RobotLab
|
|
|
56
55
|
apply_prompt_manager!
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
|
|
60
58
|
# Apply all RubyLLM settings from the ruby_llm configuration section.
|
|
61
59
|
#
|
|
62
60
|
# @return [void]
|
|
@@ -100,7 +98,6 @@ module RobotLab
|
|
|
100
98
|
set_if_present(c, :vertexai_location, :vertexai_location, 'GOOGLE_CLOUD_LOCATION')
|
|
101
99
|
end
|
|
102
100
|
|
|
103
|
-
|
|
104
101
|
def apply_provider_endpoints(c)
|
|
105
102
|
c.openai_api_base = ruby_llm.openai_api_base if ruby_llm.openai_api_base
|
|
106
103
|
c.gemini_api_base = ruby_llm.gemini_api_base if ruby_llm.gemini_api_base
|
|
@@ -109,14 +106,12 @@ module RobotLab
|
|
|
109
106
|
c.xai_api_base = ruby_llm.xai_api_base if ruby_llm.xai_api_base
|
|
110
107
|
end
|
|
111
108
|
|
|
112
|
-
|
|
113
109
|
def apply_openai_options(c)
|
|
114
110
|
c.openai_organization_id = ruby_llm.openai_organization_id if ruby_llm.openai_organization_id
|
|
115
111
|
c.openai_project_id = ruby_llm.openai_project_id if ruby_llm.openai_project_id
|
|
116
112
|
c.openai_use_system_role = ruby_llm.openai_use_system_role unless ruby_llm.openai_use_system_role.nil?
|
|
117
113
|
end
|
|
118
114
|
|
|
119
|
-
|
|
120
115
|
def apply_default_models(c)
|
|
121
116
|
c.default_model = ruby_llm.default_model if ruby_llm.default_model
|
|
122
117
|
c.default_embedding_model = ruby_llm.default_embedding_model if ruby_llm.default_embedding_model
|
|
@@ -124,7 +119,6 @@ module RobotLab
|
|
|
124
119
|
c.default_moderation_model = ruby_llm.default_moderation_model if ruby_llm.default_moderation_model
|
|
125
120
|
end
|
|
126
121
|
|
|
127
|
-
|
|
128
122
|
def apply_connection_settings(c)
|
|
129
123
|
c.request_timeout = ruby_llm.request_timeout if ruby_llm.request_timeout
|
|
130
124
|
c.max_retries = ruby_llm.max_retries if ruby_llm.max_retries
|
|
@@ -134,14 +128,12 @@ module RobotLab
|
|
|
134
128
|
c.http_proxy = ruby_llm.http_proxy if ruby_llm.http_proxy
|
|
135
129
|
end
|
|
136
130
|
|
|
137
|
-
|
|
138
131
|
def apply_logging_options(c)
|
|
139
132
|
c.log_file = ruby_llm.log_file if ruby_llm.log_file
|
|
140
133
|
c.log_level = ruby_llm.log_level if ruby_llm.log_level
|
|
141
134
|
c.log_stream_debug = ruby_llm.log_stream_debug unless ruby_llm.log_stream_debug.nil?
|
|
142
135
|
end
|
|
143
136
|
|
|
144
|
-
|
|
145
137
|
def apply_prompt_manager!
|
|
146
138
|
path = resolved_template_path
|
|
147
139
|
return unless path
|
|
@@ -151,29 +143,26 @@ module RobotLab
|
|
|
151
143
|
end
|
|
152
144
|
end
|
|
153
145
|
|
|
154
|
-
|
|
155
146
|
# Set a RubyLLM config attribute from config value or standard env var.
|
|
156
147
|
# Only sets when a non-nil value is found, to avoid overwriting defaults.
|
|
157
148
|
def set_if_present(c, setter, config_key, env_var)
|
|
158
|
-
value = ruby_llm.public_send(config_key) || ENV
|
|
149
|
+
value = ruby_llm.public_send(config_key) || ENV.fetch(env_var, nil)
|
|
159
150
|
c.public_send(:"#{setter}=", value) if value
|
|
160
151
|
end
|
|
161
152
|
|
|
162
|
-
|
|
163
153
|
def resolved_template_path
|
|
164
154
|
return template_path if template_path
|
|
165
155
|
|
|
166
|
-
if defined?(Rails) && Rails.root
|
|
167
|
-
Rails.root.join('app', 'prompts').to_s
|
|
156
|
+
if defined?(::Rails) && ::Rails.root
|
|
157
|
+
::Rails.root.join('app', 'prompts').to_s
|
|
168
158
|
else
|
|
169
159
|
'prompts'
|
|
170
160
|
end
|
|
171
161
|
end
|
|
172
162
|
|
|
173
|
-
|
|
174
163
|
def default_logger
|
|
175
|
-
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
176
|
-
Rails.logger
|
|
164
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
165
|
+
::Rails.logger
|
|
177
166
|
else
|
|
178
167
|
require 'logger'
|
|
179
168
|
Logger.new($stdout, level: Logger::INFO)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Detects and interrupts tool-call doom loops inside the LLM agent loop.
|
|
5
|
+
#
|
|
6
|
+
# A doom loop occurs when the LLM repeatedly calls the same tool(s) in a
|
|
7
|
+
# cycle — either identical consecutive calls (A, A, A) or a repeating
|
|
8
|
+
# multi-step pattern (A, B, C, A, B, C, A, B, C).
|
|
9
|
+
#
|
|
10
|
+
# When a doom loop is detected the warning is embedded in the tool result
|
|
11
|
+
# so the LLM sees it in its next context window and can self-correct.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# detector = DoomLoopDetector.new(threshold: 3)
|
|
15
|
+
# detector.track("search")
|
|
16
|
+
# detector.track("search")
|
|
17
|
+
# detector.track("search")
|
|
18
|
+
# detector.doom_loop? # => true
|
|
19
|
+
# detector.warning_message
|
|
20
|
+
# # => "Tool 'search' has been called 3 times consecutively..."
|
|
21
|
+
class DoomLoopDetector
|
|
22
|
+
DEFAULT_THRESHOLD = 3
|
|
23
|
+
MAX_PERIOD = 10
|
|
24
|
+
|
|
25
|
+
attr_reader :sequence
|
|
26
|
+
|
|
27
|
+
def initialize(threshold: DEFAULT_THRESHOLD)
|
|
28
|
+
@threshold = threshold
|
|
29
|
+
@sequence = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def track(tool_name)
|
|
33
|
+
@sequence << tool_name.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def doom_loop?
|
|
37
|
+
seq = @sequence
|
|
38
|
+
return false if seq.length < @threshold
|
|
39
|
+
|
|
40
|
+
# Consecutive identical calls: A, A, A
|
|
41
|
+
tail = seq.last(@threshold)
|
|
42
|
+
return true if tail.uniq.length == 1
|
|
43
|
+
|
|
44
|
+
# Cyclic multi-step patterns: A,B,C, A,B,C, A,B,C
|
|
45
|
+
max_period = [MAX_PERIOD, seq.length / @threshold].min
|
|
46
|
+
(2..max_period).each do |period|
|
|
47
|
+
window = @threshold * period
|
|
48
|
+
next if seq.length < window
|
|
49
|
+
|
|
50
|
+
chunk = seq.last(window)
|
|
51
|
+
pattern = chunk.first(period)
|
|
52
|
+
return true if chunk == pattern * @threshold
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset
|
|
59
|
+
@sequence = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def warning_message
|
|
63
|
+
seq = @sequence
|
|
64
|
+
return "" if seq.empty?
|
|
65
|
+
|
|
66
|
+
period = detect_period(seq)
|
|
67
|
+
if period == 1
|
|
68
|
+
tool = seq.last
|
|
69
|
+
"Tool '#{tool}' has been called #{@threshold}+ times consecutively with " \
|
|
70
|
+
"no progress. You appear to be stuck in a loop. Try a fundamentally " \
|
|
71
|
+
"different approach, use a different tool, or ask for clarification."
|
|
72
|
+
else
|
|
73
|
+
pattern = seq.last(period).join(" → ")
|
|
74
|
+
"Tool call pattern [#{pattern}] has repeated #{@threshold}+ times. " \
|
|
75
|
+
"You appear to be stuck in a loop. Try a fundamentally different " \
|
|
76
|
+
"approach, use a different tool, or ask for clarification."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def detect_period(seq)
|
|
83
|
+
return 1 if seq.last(@threshold).uniq.length == 1
|
|
84
|
+
|
|
85
|
+
max_period = [MAX_PERIOD, seq.length / @threshold].min
|
|
86
|
+
(2..max_period).each do |period|
|
|
87
|
+
window = @threshold * period
|
|
88
|
+
next if seq.length < window
|
|
89
|
+
|
|
90
|
+
chunk = seq.last(window)
|
|
91
|
+
pattern = chunk.first(period)
|
|
92
|
+
return period if chunk == pattern * @threshold
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
1
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
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
|