robot_lab 0.2.1 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +5 -167
- data/CHANGELOG.md +54 -0
- data/README.md +61 -0
- data/Rakefile +28 -12
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/getting-started/configuration.md +20 -1
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +2 -2
- data/docs/guides/observability.md +74 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +1 -1
- data/examples/03_network.rb +11 -2
- data/examples/34_agentskills.rb +0 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +7 -0
- data/examples/common.rb +7 -3
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +5 -4
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +4 -4
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/connection_poller.rb +2 -2
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +76 -12
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +8 -2
- data/lib/robot_lab/robot/template_rendering.rb +2 -2
- data/lib/robot_lab/robot.rb +211 -88
- data/lib/robot_lab/run_config.rb +24 -5
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +60 -2
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +52 -11
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +49 -0
- data/mkdocs.yml +1 -0
- metadata +36 -99
- data/examples/temp.md +0 -51
- data/site/404.html +0 -2300
- data/site/api/core/index.html +0 -2706
- data/site/api/core/memory/index.html +0 -3793
- data/site/api/core/network/index.html +0 -3500
- data/site/api/core/robot/index.html +0 -4566
- data/site/api/core/state/index.html +0 -3390
- data/site/api/core/tool/index.html +0 -3843
- data/site/api/index.html +0 -2635
- data/site/api/mcp/client/index.html +0 -3435
- data/site/api/mcp/index.html +0 -2783
- data/site/api/mcp/server/index.html +0 -3252
- data/site/api/mcp/transports/index.html +0 -3352
- data/site/api/messages/index.html +0 -2641
- data/site/api/messages/text-message/index.html +0 -3087
- data/site/api/messages/tool-call-message/index.html +0 -3159
- data/site/api/messages/tool-result-message/index.html +0 -3252
- data/site/api/messages/user-message/index.html +0 -3212
- data/site/api/streaming/context/index.html +0 -3282
- data/site/api/streaming/events/index.html +0 -3347
- data/site/api/streaming/index.html +0 -2738
- data/site/architecture/core-concepts/index.html +0 -3757
- data/site/architecture/index.html +0 -2797
- data/site/architecture/message-flow/index.html +0 -3238
- data/site/architecture/network-orchestration/index.html +0 -3433
- data/site/architecture/robot-execution/index.html +0 -3140
- data/site/architecture/state-management/index.html +0 -3498
- data/site/assets/css/custom.css +0 -56
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
- data/site/assets/javascripts/lunr/tinyseg.js +0 -206
- data/site/assets/javascripts/lunr/wordcut.js +0 -6708
- data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
- data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
- data/site/concepts/index.html +0 -3455
- data/site/examples/basic-chat/index.html +0 -2880
- data/site/examples/index.html +0 -2907
- data/site/examples/mcp-server/index.html +0 -3018
- data/site/examples/multi-robot-network/index.html +0 -3131
- data/site/examples/rails-application/index.html +0 -3329
- data/site/examples/tool-usage/index.html +0 -3085
- data/site/getting-started/configuration/index.html +0 -3745
- data/site/getting-started/index.html +0 -2572
- data/site/getting-started/installation/index.html +0 -2981
- data/site/getting-started/quick-start/index.html +0 -2942
- data/site/guides/building-robots/index.html +0 -4290
- data/site/guides/creating-networks/index.html +0 -3858
- data/site/guides/index.html +0 -2586
- data/site/guides/mcp-integration/index.html +0 -3581
- data/site/guides/memory/index.html +0 -3586
- data/site/guides/rails-integration/index.html +0 -4019
- data/site/guides/streaming/index.html +0 -3157
- data/site/guides/using-tools/index.html +0 -3802
- data/site/index.html +0 -2671
- data/site/search/search_index.json +0 -1
- data/site/sitemap.xml +0 -183
- data/site/sitemap.xml.gz +0 -0
- data/site/tags.json +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Hooks
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(family, context, registries:, per_run_hooks: nil, &)
|
|
8
|
+
before = registrations(:"before_#{family}", registries, per_run_hooks)
|
|
9
|
+
around = registrations(:"around_#{family}", registries, per_run_hooks)
|
|
10
|
+
after = registrations(:"after_#{family}", registries, per_run_hooks)
|
|
11
|
+
errors = error_registrations(family, registries, per_run_hooks)
|
|
12
|
+
|
|
13
|
+
call_all(before, :"before_#{family}", context)
|
|
14
|
+
result = call_around(around, :"around_#{family}", context, &)
|
|
15
|
+
set_result(context, family, result)
|
|
16
|
+
call_all(after, :"after_#{family}", context)
|
|
17
|
+
result
|
|
18
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
19
|
+
context.error = e if context.respond_to?(:error=)
|
|
20
|
+
call_all(errors, :on_error, context)
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(hook_name, context, registries:, per_run_hooks: nil)
|
|
25
|
+
call_all(registrations(hook_name, registries, per_run_hooks), hook_name, context)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def around(hook_name, context, registries:, per_run_hooks: nil, &)
|
|
29
|
+
call_around(registrations(hook_name, registries, per_run_hooks), hook_name, context, &)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registrations(hook_name, registries, per_run_hooks)
|
|
33
|
+
registry_entries = registries.compact.flat_map { |registry| registry.registrations_for(hook_name) }
|
|
34
|
+
registry_entries + per_run_entries(hook_name, per_run_hooks)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def error_registrations(family, registries, per_run_hooks)
|
|
38
|
+
return [] unless %i[run network_run task].include?(family.to_sym)
|
|
39
|
+
|
|
40
|
+
registrations(:on_error, registries, per_run_hooks)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call_all(registrations, hook_name, context)
|
|
44
|
+
registrations.each { |registration| call_registration(registration, hook_name, context) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call_around(registrations, hook_name, context, &block)
|
|
48
|
+
chain = registrations.reverse.inject(block) do |next_link, registration|
|
|
49
|
+
proc { call_registration(registration, hook_name, context, &next_link) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
chain.call
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call_registration(registration, hook_name, context, &)
|
|
56
|
+
context.with_namespace(registration.namespace) do
|
|
57
|
+
if registration.context && registration.namespace
|
|
58
|
+
context.ext(registration.namespace).merge_defaults(registration.context)
|
|
59
|
+
end
|
|
60
|
+
registration.handler_class.call(hook_name, context, &)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# per_run_hooks is now a single handler class or an array of handler classes.
|
|
65
|
+
def per_run_entries(hook_name, hooks)
|
|
66
|
+
return [] unless hooks
|
|
67
|
+
|
|
68
|
+
hook_sym = hook_name.to_sym
|
|
69
|
+
Array(hooks).filter_map do |handler_class|
|
|
70
|
+
next unless handler_class.singleton_class.public_method_defined?(hook_sym)
|
|
71
|
+
|
|
72
|
+
HookRegistry::Registration.new(handler_class: handler_class)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set_result(context, family, result)
|
|
77
|
+
case family.to_sym
|
|
78
|
+
when :run
|
|
79
|
+
context.response = result if context.respond_to?(:response=)
|
|
80
|
+
when :network_run, :task
|
|
81
|
+
context.result = result if context.respond_to?(:result=)
|
|
82
|
+
when :llm_generation
|
|
83
|
+
context.generation_response = result if context.respond_to?(:generation_response=)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -115,7 +115,7 @@ module RobotLab
|
|
|
115
115
|
client.transport.stdin.flush
|
|
116
116
|
rescue Errno::EPIPE, IOError => e
|
|
117
117
|
@mutex.synchronize { @clients[io][:queue] = nil }
|
|
118
|
-
raise MCPError
|
|
118
|
+
raise MCPError.new("MCP connection lost: #{e.message}", retryable: true)
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
response = Timeout.timeout(timeout) { queue.pop }
|
|
@@ -127,7 +127,7 @@ module RobotLab
|
|
|
127
127
|
response
|
|
128
128
|
rescue Timeout::Error
|
|
129
129
|
@mutex.synchronize { @clients[io]&.[]= :queue, nil }
|
|
130
|
-
raise MCPError
|
|
130
|
+
raise MCPError.new("MCP server did not respond within #{timeout}s", retryable: true)
|
|
131
131
|
ensure
|
|
132
132
|
@mutex.synchronize { @clients[io]&.[]= :queue, nil }
|
|
133
133
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
# Opt-in live observability hook: narrates what a robot is doing as it happens,
|
|
5
|
+
# so a run is never silent between events. Complements RobotLab::Audit (which
|
|
6
|
+
# records to a persistent store) with a human-facing console feed.
|
|
7
|
+
#
|
|
8
|
+
# Enable it globally (applies to every robot run, including networks):
|
|
9
|
+
#
|
|
10
|
+
# RobotLab::Narrator.enable! # narrate to $stderr
|
|
11
|
+
# RobotLab::Narrator.enable!(output: $stdout)
|
|
12
|
+
#
|
|
13
|
+
# or register like any hook for finer scope:
|
|
14
|
+
#
|
|
15
|
+
# robot.on(RobotLab::Narrator)
|
|
16
|
+
# network.on(RobotLab::Narrator)
|
|
17
|
+
#
|
|
18
|
+
# Output goes to $stderr by default and uses IO#puts (not Kernel#warn, which is
|
|
19
|
+
# silenced when Ruby warnings are disabled — i.e. $VERBOSE is nil, common under
|
|
20
|
+
# `bundle exec`).
|
|
21
|
+
class Narrator < RobotLab::Hook
|
|
22
|
+
self.namespace = :narrator
|
|
23
|
+
|
|
24
|
+
MAX = 80
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_writer :output
|
|
28
|
+
|
|
29
|
+
# @return [IO] where narration is written (default $stderr)
|
|
30
|
+
def output
|
|
31
|
+
@output ||= $stderr
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register the narrator globally and set its output.
|
|
35
|
+
#
|
|
36
|
+
# @param output [IO]
|
|
37
|
+
# @return [Class] self
|
|
38
|
+
def enable!(output: $stderr)
|
|
39
|
+
self.output = output
|
|
40
|
+
RobotLab.on(self)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- hooks ---------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def before_llm_generation(ctx)
|
|
47
|
+
who = ctx.respond_to?(:robot) ? ctx.robot&.name : nil
|
|
48
|
+
say "#{"#{who}: " if who}thinking…"
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def before_tool_call(ctx)
|
|
54
|
+
say "→ #{ctx.tool_name}#{summarize(ctx.tool_args)}"
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def after_tool_call(ctx)
|
|
60
|
+
say " ✗ #{clip(ctx.tool_error.message)}" if ctx.tool_error
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def say(message)
|
|
68
|
+
output.puts(" · #{message}")
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Compact one-line summary of the first tool argument, e.g. " path=\"a.rb\"".
|
|
74
|
+
def summarize(args)
|
|
75
|
+
return "" unless args.is_a?(Hash) && !args.empty?
|
|
76
|
+
|
|
77
|
+
key, value = args.first
|
|
78
|
+
" #{key}=#{clip(value).inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def clip(text)
|
|
82
|
+
line = text.to_s.lines.first.to_s.strip
|
|
83
|
+
line.length > MAX ? "#{line[0, MAX]}…" : line
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/robot_lab/network.rb
CHANGED
|
@@ -59,6 +59,7 @@ module RobotLab
|
|
|
59
59
|
#
|
|
60
60
|
class Network
|
|
61
61
|
include Utils
|
|
62
|
+
include Runnable
|
|
62
63
|
|
|
63
64
|
# Reserved key for broadcast messages in memory
|
|
64
65
|
BROADCAST_KEY = :_network_broadcast
|
|
@@ -71,7 +72,7 @@ module RobotLab
|
|
|
71
72
|
# @return [Hash<String, Robot>] robots in this network, keyed by name
|
|
72
73
|
# @!attribute [r] memory
|
|
73
74
|
# @return [Memory] shared memory for all robots in the network
|
|
74
|
-
attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode
|
|
75
|
+
attr_reader :name, :pipeline, :robots, :memory, :config, :parallel_mode, :hooks
|
|
75
76
|
|
|
76
77
|
# Creates a new Network instance.
|
|
77
78
|
#
|
|
@@ -94,6 +95,7 @@ module RobotLab
|
|
|
94
95
|
@memory = memory || Memory.new(network_name: @name)
|
|
95
96
|
@config = config || RunConfig.new
|
|
96
97
|
@parallel_mode = parallel_mode
|
|
98
|
+
@hooks = HookRegistry.new
|
|
97
99
|
@broadcast_handlers = []
|
|
98
100
|
@bus_poller = BusPoller.new.start
|
|
99
101
|
|
|
@@ -132,7 +134,8 @@ module RobotLab
|
|
|
132
134
|
mcp: mcp,
|
|
133
135
|
tools: tools,
|
|
134
136
|
memory: memory,
|
|
135
|
-
config: config
|
|
137
|
+
config: config,
|
|
138
|
+
network: self
|
|
136
139
|
)
|
|
137
140
|
|
|
138
141
|
# Register the group and assign the shared poller to the robot
|
|
@@ -177,24 +180,55 @@ module RobotLab
|
|
|
177
180
|
# result.value # => RobotResult from last robot
|
|
178
181
|
# result.context[:classifier] # => RobotResult from classifier
|
|
179
182
|
#
|
|
180
|
-
def run(**run_context)
|
|
183
|
+
def run(message = nil, **run_context)
|
|
184
|
+
# Runnable protocol: accept a positional message like Robot#run does, so
|
|
185
|
+
# callers can `run(msg, ...)` uniformly. `run(message: msg)` still works.
|
|
186
|
+
run_context[:message] = message unless message.nil?
|
|
187
|
+
|
|
181
188
|
# Include shared memory in run params so robots can access it
|
|
182
189
|
run_context[:network_memory] = @memory
|
|
190
|
+
run_context[:network] = self
|
|
183
191
|
|
|
184
192
|
# Pass network's config so robots can inherit it
|
|
185
193
|
run_context[:network_config] = @config unless @config.empty?
|
|
186
194
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
context = NetworkRunHookContext.new(
|
|
196
|
+
network: self,
|
|
197
|
+
context: run_context,
|
|
198
|
+
memory: @memory,
|
|
199
|
+
config: @config
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
RobotLab::Hooks.run(:network_run, context, registries: [RobotLab.hooks, @hooks]) do
|
|
203
|
+
if @parallel_mode == :ractor
|
|
204
|
+
run_with_ractor_scheduler(context.context)
|
|
205
|
+
else
|
|
206
|
+
initial_result = SimpleFlow::Result.new(
|
|
207
|
+
context.context,
|
|
208
|
+
context: { run_params: context.context }
|
|
209
|
+
)
|
|
210
|
+
@pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
|
|
211
|
+
end
|
|
195
212
|
end
|
|
196
213
|
end
|
|
197
214
|
|
|
215
|
+
# Runnable protocol: the network's robots as an Array (in pipeline order),
|
|
216
|
+
# and the network? predicate. (`robots` itself stays a name=>robot Hash.)
|
|
217
|
+
#
|
|
218
|
+
# @return [Array<Robot>]
|
|
219
|
+
def crew
|
|
220
|
+
robots.values
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @return [Boolean] always true for a Network
|
|
224
|
+
def network?
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def on(handler_class, context: nil)
|
|
229
|
+
@hooks.on(handler_class, context: context)
|
|
230
|
+
end
|
|
231
|
+
|
|
198
232
|
# Broadcast a message to all robots in the network.
|
|
199
233
|
#
|
|
200
234
|
# This sends a network-wide message that all robots subscribed via
|
|
@@ -303,6 +337,19 @@ module RobotLab
|
|
|
303
337
|
self
|
|
304
338
|
end
|
|
305
339
|
|
|
340
|
+
# Remove a dynamically-added robot from the network by name. Returns the
|
|
341
|
+
# removed robot, or nil if no robot by that name was present.
|
|
342
|
+
#
|
|
343
|
+
# Only removes the robot from the crew (`@robots`); it does not rewrite the
|
|
344
|
+
# pipeline, so callers should not remove a robot that is a pipeline task.
|
|
345
|
+
#
|
|
346
|
+
# @param name [String, Symbol] the robot's name
|
|
347
|
+
# @return [Robot, nil]
|
|
348
|
+
#
|
|
349
|
+
def remove_robot(name)
|
|
350
|
+
@robots.delete(name.to_s)
|
|
351
|
+
end
|
|
352
|
+
|
|
306
353
|
# Visualize the pipeline as ASCII
|
|
307
354
|
#
|
|
308
355
|
# @return [String, nil]
|
|
@@ -363,15 +410,7 @@ module RobotLab
|
|
|
363
410
|
specs_with_deps = @tasks.map do |task_name, task_wrapper|
|
|
364
411
|
deps = dep_graph[task_name.to_sym] || []
|
|
365
412
|
deps = deps.empty? ? :none : deps.map(&:to_s)
|
|
366
|
-
|
|
367
|
-
spec = RobotSpec.new(
|
|
368
|
-
name: task_wrapper.robot.name.freeze,
|
|
369
|
-
template: task_wrapper.robot.template&.to_s&.freeze,
|
|
370
|
-
system_prompt: task_wrapper.robot.system_prompt&.freeze,
|
|
371
|
-
config_hash: RactorBoundary.freeze_deep(task_wrapper.robot.config.to_json_hash)
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
{ spec: spec, depends_on: deps }
|
|
413
|
+
{ spec: build_robot_spec(task_wrapper), depends_on: deps }
|
|
375
414
|
end
|
|
376
415
|
|
|
377
416
|
scheduler = RactorNetworkScheduler.new(memory: @memory)
|
|
@@ -379,5 +418,23 @@ module RobotLab
|
|
|
379
418
|
scheduler.shutdown
|
|
380
419
|
results
|
|
381
420
|
end
|
|
421
|
+
|
|
422
|
+
def build_robot_spec(task_wrapper)
|
|
423
|
+
robot = task_wrapper.robot
|
|
424
|
+
RobotSpec.new(
|
|
425
|
+
name: robot.name.freeze,
|
|
426
|
+
template: robot.template&.to_s&.freeze,
|
|
427
|
+
system_prompt: robot.system_prompt&.freeze,
|
|
428
|
+
config_hash: RactorBoundary.freeze_deep(robot.config.to_json_hash),
|
|
429
|
+
hook_classes: ractor_hook_classes_for(robot)
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def ractor_hook_classes_for(robot)
|
|
434
|
+
[RobotLab.hooks, @hooks, robot.hooks]
|
|
435
|
+
.flat_map { |registry| registry.registrations.map(&:handler_class) }
|
|
436
|
+
.uniq
|
|
437
|
+
.freeze
|
|
438
|
+
end
|
|
382
439
|
end
|
|
383
440
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
# Token/cost budget enforcement for a single Robot, backed by a
|
|
6
|
+
# RobotLab::Budget::Ledger.
|
|
7
|
+
#
|
|
8
|
+
# Two layers of enforcement:
|
|
9
|
+
# - reserve_budget!/reconcile_budget! wrap each LLM call. reserve_budget!
|
|
10
|
+
# raises BudgetExceeded up front when a *prior* call already pushed
|
|
11
|
+
# usage over budget, so a robot with an exhausted budget refuses
|
|
12
|
+
# further calls instead of spending on them.
|
|
13
|
+
# - enforce_token_budget!/enforce_cost_budget! run after the call
|
|
14
|
+
# completes and raise InferenceError when *this* call's actual usage
|
|
15
|
+
# pushed cumulative usage over budget — unavoidable for the call that
|
|
16
|
+
# causes the overage, since token/cost totals aren't known until the
|
|
17
|
+
# response comes back.
|
|
18
|
+
module Budget
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @return [RobotLab::Budget::Ledger, nil] nil when neither token_budget
|
|
22
|
+
# nor cost_budget is configured
|
|
23
|
+
def build_budget_ledger
|
|
24
|
+
limits = { tokens: @config.token_budget, cost: @config.cost_budget }.compact
|
|
25
|
+
return nil if limits.empty?
|
|
26
|
+
|
|
27
|
+
RobotLab::Budget::Ledger.new(limits: limits)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def enforce_token_budget!
|
|
31
|
+
budget = @config.token_budget
|
|
32
|
+
return unless budget && @total_input_tokens + @total_output_tokens > budget
|
|
33
|
+
|
|
34
|
+
raise InferenceError,
|
|
35
|
+
"Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enforce_cost_budget!
|
|
39
|
+
budget = @config.cost_budget
|
|
40
|
+
return unless budget && @budget_ledger
|
|
41
|
+
|
|
42
|
+
consumed = @budget_ledger.consumed[:cost]
|
|
43
|
+
return unless consumed > budget
|
|
44
|
+
|
|
45
|
+
raise InferenceError,
|
|
46
|
+
format("Cost budget exceeded: $%<consumed>.6f used, budget is $%<budget>.6f",
|
|
47
|
+
consumed: consumed, budget: budget)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reserves whatever remains of each configured budget dimension
|
|
51
|
+
# before attempting the LLM call.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash{Symbol=>Numeric}] the amount reserved per dimension, to pass to reconcile_budget!
|
|
54
|
+
# @raise [RobotLab::BudgetExceeded] if a dimension is already exhausted
|
|
55
|
+
def reserve_budget!
|
|
56
|
+
return {} unless @budget_ledger
|
|
57
|
+
|
|
58
|
+
@budget_ledger.limits.each_key.with_object({}) do |dimension, reservation|
|
|
59
|
+
amount = @budget_ledger.remaining(dimension)
|
|
60
|
+
@budget_ledger.reserve!(dimension, amount)
|
|
61
|
+
reservation[dimension] = amount
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Replaces a reservation from reserve_budget! with the actual usage
|
|
66
|
+
# from this call.
|
|
67
|
+
#
|
|
68
|
+
# @param reservation [Hash{Symbol=>Numeric}] the value returned by reserve_budget!
|
|
69
|
+
# @param response [Object] the raw LLM response, used to extract actual cost
|
|
70
|
+
# @param result [RobotResult] the built result, used to extract actual tokens
|
|
71
|
+
def reconcile_budget!(reservation, response:, result:)
|
|
72
|
+
return unless reservation&.any?
|
|
73
|
+
|
|
74
|
+
actual = { tokens: result.input_tokens + result.output_tokens, cost: extract_cost(response) }
|
|
75
|
+
reservation.each_pair { |dimension, amount| @budget_ledger.reconcile!(dimension, amount, actual[dimension] || 0) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param response [Object] the raw LLM response
|
|
79
|
+
# @return [Float] the response's total cost, or 0.0 when unavailable (no pricing data)
|
|
80
|
+
def extract_cost(response)
|
|
81
|
+
return 0.0 unless response.respond_to?(:cost)
|
|
82
|
+
|
|
83
|
+
response.cost.total.to_f
|
|
84
|
+
rescue StandardError
|
|
85
|
+
0.0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -29,9 +29,15 @@ module RobotLab
|
|
|
29
29
|
def send_message(to:, content:)
|
|
30
30
|
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
# Counter + outbox are shared with the poller thread (reply correlation)
|
|
33
|
+
# and with other senders; mutate them under the bus mutex. Publish (which
|
|
34
|
+
# does I/O) stays outside the lock.
|
|
35
|
+
message = @bus_mutex.synchronize do
|
|
36
|
+
@message_counter += 1
|
|
37
|
+
msg = RobotMessage.build(id: @message_counter, from: @name, content: content)
|
|
38
|
+
@outbox[msg.key] = { message: msg, status: :sent, replies: [] }
|
|
39
|
+
msg
|
|
40
|
+
end
|
|
35
41
|
publish_to_bus(to.to_sym, message)
|
|
36
42
|
message
|
|
37
43
|
end
|
|
@@ -46,8 +52,10 @@ module RobotLab
|
|
|
46
52
|
def send_reply(to:, content:, in_reply_to:)
|
|
47
53
|
raise BusError, "No bus configured on robot '#{@name}'" unless @bus
|
|
48
54
|
|
|
49
|
-
@
|
|
50
|
-
|
|
55
|
+
reply = @bus_mutex.synchronize do
|
|
56
|
+
@message_counter += 1
|
|
57
|
+
RobotMessage.build(id: @message_counter, from: @name, content: content, in_reply_to: in_reply_to)
|
|
58
|
+
end
|
|
51
59
|
publish_to_bus(to.to_sym, reply)
|
|
52
60
|
reply
|
|
53
61
|
end
|
|
@@ -65,6 +73,37 @@ module RobotLab
|
|
|
65
73
|
self
|
|
66
74
|
end
|
|
67
75
|
|
|
76
|
+
# Automatically respond to inbound (non-reply) bus tasks: run +responder+ to
|
|
77
|
+
# produce a reply, and send it back to the sender. This is the symmetric
|
|
78
|
+
# counterpart to how a Cyborg answers its human — one call makes any bus
|
|
79
|
+
# member a first-class responder instead of hand-wiring {#on_message}.
|
|
80
|
+
#
|
|
81
|
+
# The responder runs on the poller drain thread, so deliveries to this member
|
|
82
|
+
# are handled one at a time (a long turn delays the next inbound message).
|
|
83
|
+
#
|
|
84
|
+
# @param auto_reply [Boolean] send the responder's result back to the sender
|
|
85
|
+
# @yield [message] the inbound task; return the reply content (nil => no reply)
|
|
86
|
+
# @return [self]
|
|
87
|
+
def respond_to_tasks(auto_reply: true, &responder)
|
|
88
|
+
on_message do |message|
|
|
89
|
+
next if message.reply?
|
|
90
|
+
|
|
91
|
+
reply = responder.call(message)
|
|
92
|
+
send_reply(to: message.from, content: reply, in_reply_to: message.key) if auto_reply && reply
|
|
93
|
+
end
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Serve inbound bus tasks by running each through this member's #run and
|
|
98
|
+
# replying with the result — the one-call way to make a Robot cooperate on
|
|
99
|
+
# the bus the way a Cyborg already does out of the box.
|
|
100
|
+
#
|
|
101
|
+
# @param auto_reply [Boolean]
|
|
102
|
+
# @return [self]
|
|
103
|
+
def serve(auto_reply: true)
|
|
104
|
+
respond_to_tasks(auto_reply: auto_reply) { |message| run(bus_task_content(message)).reply }
|
|
105
|
+
end
|
|
106
|
+
|
|
68
107
|
# Spawn a new robot on a shared bus.
|
|
69
108
|
#
|
|
70
109
|
# Creates a new Robot instance that shares this robot's bus,
|
|
@@ -88,10 +127,24 @@ module RobotLab
|
|
|
88
127
|
template: template,
|
|
89
128
|
local_tools: local_tools,
|
|
90
129
|
bus: @bus,
|
|
130
|
+
**inherited_llm_settings,
|
|
91
131
|
**
|
|
92
132
|
)
|
|
93
133
|
end
|
|
94
134
|
|
|
135
|
+
# Model/provider a spawned robot inherits from its parent so a specialist
|
|
136
|
+
# runs on the SAME LLM as the robot that spawned it (e.g. a local Ollama
|
|
137
|
+
# model) instead of falling back to the global default. Caller-supplied
|
|
138
|
+
# opts override these.
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash]
|
|
141
|
+
def inherited_llm_settings
|
|
142
|
+
settings = {}
|
|
143
|
+
settings[:model] = model if model
|
|
144
|
+
settings[:provider] = provider if provider
|
|
145
|
+
settings
|
|
146
|
+
end
|
|
147
|
+
|
|
95
148
|
# Connect this robot to a message bus.
|
|
96
149
|
#
|
|
97
150
|
# If a bus is provided, the robot joins it. If no bus is provided
|
|
@@ -159,6 +212,12 @@ module RobotLab
|
|
|
159
212
|
@bus_poller_group = :default
|
|
160
213
|
end
|
|
161
214
|
|
|
215
|
+
# Flatten a task message's content to text for #run.
|
|
216
|
+
def bus_task_content(message)
|
|
217
|
+
content = message.content
|
|
218
|
+
content.is_a?(Hash) ? content.map { |k, v| "#{k}: #{v}" }.join("\n") : content.to_s
|
|
219
|
+
end
|
|
220
|
+
|
|
162
221
|
# Enqueue a delivery to the robot's assigned poller.
|
|
163
222
|
def enqueue_delivery(delivery)
|
|
164
223
|
@bus_poller.enqueue(robot: self, delivery: delivery, group: @bus_poller_group)
|
|
@@ -167,13 +226,7 @@ module RobotLab
|
|
|
167
226
|
# Process a single delivery (called by BusPoller drain thread).
|
|
168
227
|
def process_delivery(delivery)
|
|
169
228
|
message = delivery.message
|
|
170
|
-
|
|
171
|
-
# Correlate replies with outbox entries
|
|
172
|
-
if message.reply? && @outbox.key?(message.in_reply_to)
|
|
173
|
-
entry = @outbox[message.in_reply_to]
|
|
174
|
-
entry[:status] = :replied
|
|
175
|
-
entry[:replies] << message
|
|
176
|
-
end
|
|
229
|
+
correlate_reply(message) if message.reply?
|
|
177
230
|
|
|
178
231
|
if @message_handler.arity == 1
|
|
179
232
|
delivery.ack!
|
|
@@ -186,6 +239,17 @@ module RobotLab
|
|
|
186
239
|
raise BusError, "Error handling bus message on robot '#{@name}': #{e.message}"
|
|
187
240
|
end
|
|
188
241
|
|
|
242
|
+
# Mark the sender's outbox entry replied. Shared with senders on other
|
|
243
|
+
# threads, so guard with the bus mutex.
|
|
244
|
+
def correlate_reply(message)
|
|
245
|
+
@bus_mutex.synchronize do
|
|
246
|
+
entry = @outbox[message.in_reply_to] or return
|
|
247
|
+
|
|
248
|
+
entry[:status] = :replied
|
|
249
|
+
entry[:replies] << message
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
189
253
|
# Publish a RobotMessage to a bus channel
|
|
190
254
|
def publish_to_bus(channel_name, message)
|
|
191
255
|
if defined?(Async::Task) && Async::Task.current?
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
module Hooking
|
|
6
|
+
def run(message = nil, network: nil, task: nil, network_memory: nil, network_config: nil,
|
|
7
|
+
memory: nil, mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
|
|
8
|
+
run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
|
|
9
|
+
previous_writer = run_memory.current_writer
|
|
10
|
+
run_memory.current_writer = @name
|
|
11
|
+
context = RunHookContext.new(
|
|
12
|
+
robot: self,
|
|
13
|
+
network: network,
|
|
14
|
+
task: task,
|
|
15
|
+
memory: run_memory,
|
|
16
|
+
config: @config,
|
|
17
|
+
request: message
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
registries = hook_registries(network)
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
RobotLab.with_hook_scope(registries, hooks) do
|
|
24
|
+
RobotLab::Hooks.run(:run, context, registries: registries, per_run_hooks: hooks) do
|
|
25
|
+
run_context = kwargs.except(:with)
|
|
26
|
+
prepare_tools(message: context.request, mcp: mcp, tools: tools,
|
|
27
|
+
network: network, network_config: network_config)
|
|
28
|
+
rerender_template(run_context) if @template && run_context.any?
|
|
29
|
+
reservation = reserve_budget!
|
|
30
|
+
response = invoke_ask(context: context, kwargs: kwargs, hooks: hooks, block: block)
|
|
31
|
+
result = build_result(response, run_memory)
|
|
32
|
+
reconcile_budget!(reservation, response: response, result: result)
|
|
33
|
+
enforce_token_budget!
|
|
34
|
+
enforce_cost_budget!
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
ensure
|
|
39
|
+
remove_doom_loop_detection
|
|
40
|
+
restore_tool_call_callback if @config.max_tool_rounds
|
|
41
|
+
run_memory.current_writer = previous_writer
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on(handler_class, context: nil)
|
|
46
|
+
@hooks.on(handler_class, context: context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def hook_registries(network = nil)
|
|
52
|
+
[RobotLab.hooks, network&.hooks, @hooks]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -12,18 +12,24 @@ module RobotLab
|
|
|
12
12
|
|
|
13
13
|
# Resolve MCP hierarchy: runtime -> robot build -> network -> config
|
|
14
14
|
def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
15
|
-
parent_value = network_config&.mcp || network&.
|
|
15
|
+
parent_value = network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp
|
|
16
16
|
build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
|
|
17
17
|
ToolConfig.resolve_mcp(runtime_value, parent_value: build_resolved)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Resolve tools hierarchy: runtime -> robot build -> network -> config
|
|
21
21
|
def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
22
|
-
parent_value = network_config&.tools || network&.
|
|
22
|
+
parent_value = network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools
|
|
23
23
|
build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
|
|
24
24
|
ToolConfig.resolve_tools(runtime_value, parent_value: build_resolved)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def network_parent_config(network)
|
|
28
|
+
return network.config if network.respond_to?(:config)
|
|
29
|
+
|
|
30
|
+
network&.network
|
|
31
|
+
end
|
|
32
|
+
|
|
27
33
|
# Ensure MCP clients are initialized for the given server configs.
|
|
28
34
|
# On subsequent calls, retries any servers that previously failed to connect.
|
|
29
35
|
def ensure_mcp_clients(mcp_servers)
|
|
@@ -45,7 +45,7 @@ module RobotLab
|
|
|
45
45
|
# Front matter is the base; @config (from constructor kwargs) overrides.
|
|
46
46
|
fm_config = RunConfig.from_front_matter(parsed.metadata)
|
|
47
47
|
effective = fm_config.merge(@config)
|
|
48
|
-
effective.apply_to(@chat)
|
|
48
|
+
effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
|
|
49
49
|
|
|
50
50
|
# Resolve context (could be a Proc)
|
|
51
51
|
resolved_ctx = resolve_context(context, network: nil)
|
|
@@ -142,7 +142,7 @@ module RobotLab
|
|
|
142
142
|
apply_accumulated_extras(extras)
|
|
143
143
|
|
|
144
144
|
effective = accumulated_config.merge(@config)
|
|
145
|
-
effective.apply_to(@chat)
|
|
145
|
+
effective.apply_to(@chat, provider: @provider, assume_model_exists: !@provider.nil?)
|
|
146
146
|
|
|
147
147
|
combined = bodies.join("\n\n")
|
|
148
148
|
@chat.with_instructions(combined) unless combined.empty?
|