robot_lab 0.2.1 → 0.2.7
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 +185 -64
- data/Rakefile +28 -12
- data/docs/api/core/index.md +41 -14
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +285 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +636 -60
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +152 -14
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +83 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +118 -30
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +153 -38
- data/docs/architecture/robot-execution.md +173 -91
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +299 -93
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +296 -50
- data/docs/guides/creating-networks.md +220 -31
- data/docs/guides/hooks.md +1084 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +37 -6
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +166 -51
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +300 -11
- data/docs/index.md +37 -5
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +23 -9
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +266 -0
- data/examples/README.md +203 -42
- data/examples/common.rb +79 -7
- data/examples/xyzzy.rb +97 -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 +14 -9
- 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/names.rb +402 -0
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +90 -18
- 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 +18 -5
- data/lib/robot_lab/robot.rb +233 -89
- 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 +70 -15
- data/mkdocs.yml +7 -1
- metadata +41 -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
data/examples/common.rb
CHANGED
|
@@ -7,23 +7,95 @@ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
|
|
|
7
7
|
|
|
8
8
|
require_relative "../lib/robot_lab"
|
|
9
9
|
|
|
10
|
+
# ── Local LLM Configuration ───────────────────────────────────────────────────
|
|
11
|
+
#
|
|
12
|
+
# Every example runs against a LOCAL model served by Ollama. No API keys, no
|
|
13
|
+
# network egress, no per-token cost. Pull the model once before running:
|
|
14
|
+
#
|
|
15
|
+
# ollama pull qwen3.6
|
|
16
|
+
#
|
|
17
|
+
# Ollama models are not in RubyLLM's model registry, so a `provider:` must be
|
|
18
|
+
# supplied alongside `model:` — that is what makes RubyLLM skip the registry
|
|
19
|
+
# lookup (see Robot#initialize, which sets assume_model_exists when provider is
|
|
20
|
+
# given). Use the `llm_opts` helper below so every robot gets both.
|
|
21
|
+
|
|
10
22
|
LlmConfig = Data.define(:provider, :model)
|
|
11
23
|
|
|
12
24
|
LLM = {
|
|
13
|
-
default:
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
default: LlmConfig.new(provider: "ollama", model: "qwen3.6:latest"),
|
|
26
|
+
small: LlmConfig.new(provider: "ollama", model: "qwen2.5:7b"),
|
|
27
|
+
large: LlmConfig.new(provider: "ollama", model: "llama3.3:latest")
|
|
16
28
|
}.freeze
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
c.logger = Logger.new(File::NULL)
|
|
20
|
-
c.default_model = LLM[:default].model
|
|
21
|
-
end
|
|
30
|
+
OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
|
|
22
31
|
|
|
32
|
+
# ORDER MATTERS. The first touch of RobotLab.config runs Config#after_load,
|
|
33
|
+
# which calls RubyLLM.configure itself and would clobber anything set before
|
|
34
|
+
# it. So configure RobotLab first, then RubyLLM — the later block wins.
|
|
23
35
|
RobotLab.configure do |c|
|
|
24
36
|
c.logger = Logger.new(File::NULL)
|
|
25
37
|
end
|
|
26
38
|
|
|
39
|
+
RubyLLM.configure do |c|
|
|
40
|
+
c.logger = Logger.new(File::NULL)
|
|
41
|
+
c.default_model = LLM[:default].model
|
|
42
|
+
c.ollama_api_base = OLLAMA_API_BASE
|
|
43
|
+
|
|
44
|
+
# A large local model on consumer hardware is far slower than a hosted API,
|
|
45
|
+
# and robot_lab's bundled 120s default is comfortably exceeded by a long
|
|
46
|
+
# answer from a 20B+ model — which surfaces mid-run as Net::ReadTimeout.
|
|
47
|
+
#
|
|
48
|
+
# Integer(), not the raw env string: this value reaches Net::HTTP directly,
|
|
49
|
+
# and a String raises "can't convert String into time interval". That is
|
|
50
|
+
# also why the ROBOT_LAB_RUBY_LLM__REQUEST_TIMEOUT env var is the wrong
|
|
51
|
+
# lever here — env values arrive as strings.
|
|
52
|
+
c.request_timeout = Integer(ENV.fetch("LLM_REQUEST_TIMEOUT", "900"))
|
|
53
|
+
c.max_retries = 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Which LLM entry an unqualified llm_opts resolves to. Lets you run any
|
|
57
|
+
# example against a faster model without editing it — worth knowing for the
|
|
58
|
+
# multi-robot demos (14, 15, 16), which are 30+ sequential LLM calls and take
|
|
59
|
+
# over half an hour on a 20B+ model:
|
|
60
|
+
#
|
|
61
|
+
# LLM_PROFILE=small bundle exec ruby examples/14_rusty_circuit/open_mic.rb
|
|
62
|
+
LLM_PROFILE = ENV.fetch("LLM_PROFILE", "default").to_sym
|
|
63
|
+
|
|
64
|
+
unless LLM.key?(LLM_PROFILE)
|
|
65
|
+
abort "Unknown LLM_PROFILE #{LLM_PROFILE.inspect}. Choose one of: #{LLM.keys.join(', ')}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Provider + model keyword pair for RobotLab.build / Robot.new.
|
|
69
|
+
#
|
|
70
|
+
# Both are required for a local Ollama model. Splat it into any robot
|
|
71
|
+
# constructor:
|
|
72
|
+
#
|
|
73
|
+
# RobotLab.build(name: "helper", **llm_opts) # honors LLM_PROFILE
|
|
74
|
+
# RobotLab.build(name: "cheap", **llm_opts(:small)) # pinned regardless
|
|
75
|
+
#
|
|
76
|
+
# @param key [Symbol, nil] which entry of LLM to use; defaults to LLM_PROFILE
|
|
77
|
+
# @return [Hash] { provider:, model: }
|
|
78
|
+
def llm_opts(key = nil)
|
|
79
|
+
cfg = LLM.fetch(key || LLM_PROFILE)
|
|
80
|
+
{ provider: cfg.provider, model: cfg.model }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fail fast with an actionable message when Ollama isn't reachable, instead of
|
|
84
|
+
# letting every example die inside an HTTP adapter.
|
|
85
|
+
def require_ollama!
|
|
86
|
+
require "net/http"
|
|
87
|
+
uri = URI(OLLAMA_API_BASE.sub(%r{/v1/?$}, "") + "/api/tags")
|
|
88
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
abort <<~ERROR
|
|
91
|
+
Cannot reach Ollama at #{OLLAMA_API_BASE} (#{e.class}).
|
|
92
|
+
|
|
93
|
+
Start it and pull the model used by the examples:
|
|
94
|
+
ollama serve
|
|
95
|
+
ollama pull #{LLM[:default].model.sub(/:latest\z/, "")}
|
|
96
|
+
ERROR
|
|
97
|
+
end
|
|
98
|
+
|
|
27
99
|
# ── Example Output Helpers ─────────────────────────────────────────────────────
|
|
28
100
|
|
|
29
101
|
module ExOut
|
data/examples/xyzzy.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# xyzzy.rb — single-file RobotLab hook extension demo
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates the Hook handler class pattern: implement class methods for
|
|
6
|
+
# every hook under a dedicated namespace and log each callback with its
|
|
7
|
+
# context snapshot. Useful as a live trace of the full hook pipeline during
|
|
8
|
+
# development.
|
|
9
|
+
#
|
|
10
|
+
# Fully Ractor-safe: no class-level mutable state. LOG_PATH is a frozen
|
|
11
|
+
# string constant (shareable); each call opens its own file handle.
|
|
12
|
+
#
|
|
13
|
+
# stdout : one tagline per hook call → [xyzzy] HH:MM:SS.mmm hook_name
|
|
14
|
+
# logfile: full context snapshot written to LOG_PATH via PP.pp
|
|
15
|
+
#
|
|
16
|
+
# The log destination is read from XYZZY_LOG_PATH at load time and frozen
|
|
17
|
+
# into a constant. Set it BEFORE requiring this file to redirect the log;
|
|
18
|
+
# there is deliberately no writer method, so the constant stays shareable.
|
|
19
|
+
#
|
|
20
|
+
# Usage (from any example that requires common):
|
|
21
|
+
# ENV["XYZZY_LOG_PATH"] = "/tmp/xyzzy.log" # optional, must precede require
|
|
22
|
+
# require_relative "xyzzy"
|
|
23
|
+
|
|
24
|
+
require "pp"
|
|
25
|
+
require "fileutils"
|
|
26
|
+
|
|
27
|
+
module RobotLab
|
|
28
|
+
class Xyzzy < Hook
|
|
29
|
+
self.namespace = :xyzzy
|
|
30
|
+
|
|
31
|
+
LOG_PATH = File.expand_path(
|
|
32
|
+
ENV.fetch("XYZZY_LOG_PATH", "~/.robot_lab/xyzzy_hooks.log")
|
|
33
|
+
).freeze
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(LOG_PATH))
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def before_run(ctx) = log_hook(:before_run, ctx)
|
|
38
|
+
def after_run(ctx) = log_hook(:after_run, ctx)
|
|
39
|
+
def on_error(ctx) = log_hook(:on_error, ctx)
|
|
40
|
+
def before_llm_generation(ctx) = log_hook(:before_llm_generation, ctx)
|
|
41
|
+
def after_llm_generation(ctx) = log_hook(:after_llm_generation, ctx)
|
|
42
|
+
def before_tool_call(ctx) = log_hook(:before_tool_call, ctx)
|
|
43
|
+
def after_tool_call(ctx) = log_hook(:after_tool_call, ctx)
|
|
44
|
+
def before_network_run(ctx) = log_hook(:before_network_run, ctx)
|
|
45
|
+
def after_network_run(ctx) = log_hook(:after_network_run, ctx)
|
|
46
|
+
def before_task(ctx) = log_hook(:before_task, ctx)
|
|
47
|
+
def after_task(ctx) = log_hook(:after_task, ctx)
|
|
48
|
+
|
|
49
|
+
def around_run(ctx, &block)
|
|
50
|
+
log_hook(:around_run, ctx)
|
|
51
|
+
block.call
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def around_llm_generation(ctx, &block)
|
|
55
|
+
log_hook(:around_llm_generation, ctx)
|
|
56
|
+
block.call
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def around_tool_call(ctx, &block)
|
|
60
|
+
log_hook(:around_tool_call, ctx)
|
|
61
|
+
block.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def around_network_run(ctx, &block)
|
|
65
|
+
log_hook(:around_network_run, ctx)
|
|
66
|
+
block.call
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def around_task(ctx, &block)
|
|
70
|
+
log_hook(:around_task, ctx)
|
|
71
|
+
block.call
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def log_hook(hook_name, ctx)
|
|
77
|
+
ts = Time.now.strftime('%H:%M:%S.%3N')
|
|
78
|
+
snapshot = context_snapshot(ctx)
|
|
79
|
+
$stdout.puts " [xyzzy] #{ts} #{hook_name}"
|
|
80
|
+
File.open(LOG_PATH, "a") { |f| f.puts "#{ts} #{hook_name} | #{PP.pp(snapshot, +"").chomp}" }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def context_snapshot(ctx)
|
|
84
|
+
{
|
|
85
|
+
robot: ctx.respond_to?(:robot) ? ctx.robot&.name : nil,
|
|
86
|
+
request: ctx.respond_to?(:request) ? ctx.request : nil,
|
|
87
|
+
network: ctx.respond_to?(:network) ? ctx.network&.name : nil,
|
|
88
|
+
task: ctx.respond_to?(:task) ? ctx.task : nil,
|
|
89
|
+
error: ctx.respond_to?(:error) ? ctx.error&.message : nil
|
|
90
|
+
}.compact
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
RobotLab.register_extension(:xyzzy, RobotLab::Xyzzy) if RobotLab.respond_to?(:register_extension)
|
|
97
|
+
RobotLab.on(RobotLab::Xyzzy)
|
|
@@ -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
|
@@ -8,7 +8,8 @@ module RobotLab
|
|
|
8
8
|
# Provides:
|
|
9
9
|
# - Nested configuration with a dedicated `ruby_llm:` section
|
|
10
10
|
# - Environment-specific settings (development, test, production)
|
|
11
|
-
# - XDG config file loading (~/.config/robot_lab/
|
|
11
|
+
# - XDG config file loading (~/.config/robot_lab/robot_lab.yml — the filename
|
|
12
|
+
# repeats the `config_name`; `config.yml` is never read)
|
|
12
13
|
# - Environment variable overrides (ROBOT_LAB_*)
|
|
13
14
|
# - Automatic RubyLLM configuration application
|
|
14
15
|
#
|
|
@@ -21,10 +22,14 @@ module RobotLab
|
|
|
21
22
|
# # ROBOT_LAB_RUBY_LLM__MODEL=gpt-4
|
|
22
23
|
# # ROBOT_LAB_RUBY_LLM__ANTHROPIC_API_KEY=sk-ant-...
|
|
23
24
|
#
|
|
24
|
-
# @example User config file (~/.config/robot_lab/
|
|
25
|
-
# defaults
|
|
26
|
-
#
|
|
27
|
-
#
|
|
25
|
+
# @example User config file (~/.config/robot_lab/robot_lab.yml)
|
|
26
|
+
# # Flat keys, or a section named for the current environment. A `defaults:`
|
|
27
|
+
# # wrapper is IGNORED here — it applies only to the gem's bundled
|
|
28
|
+
# # defaults.yml. This file is NOT run through ERB, so keep secrets in
|
|
29
|
+
# # environment variables or in ./config/robot_lab.yml (which is).
|
|
30
|
+
# ruby_llm:
|
|
31
|
+
# model: claude-sonnet-4
|
|
32
|
+
# request_timeout: 120
|
|
28
33
|
#
|
|
29
34
|
class Config < MywayConfig::Base
|
|
30
35
|
config_name :robot_lab
|
|
@@ -153,16 +158,16 @@ module RobotLab
|
|
|
153
158
|
def resolved_template_path
|
|
154
159
|
return template_path if template_path
|
|
155
160
|
|
|
156
|
-
if defined?(Rails) && Rails.root
|
|
157
|
-
Rails.root.join('app', 'prompts').to_s
|
|
161
|
+
if defined?(::Rails) && ::Rails.root
|
|
162
|
+
::Rails.root.join('app', 'prompts').to_s
|
|
158
163
|
else
|
|
159
164
|
'prompts'
|
|
160
165
|
end
|
|
161
166
|
end
|
|
162
167
|
|
|
163
168
|
def default_logger
|
|
164
|
-
if defined?(Rails) && Rails.respond_to?(:logger)
|
|
165
|
-
Rails.logger
|
|
169
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
170
|
+
::Rails.logger
|
|
166
171
|
else
|
|
167
172
|
require 'logger'
|
|
168
173
|
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
|