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
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -89,7 +89,8 @@ module RobotLab
|
|
|
89
89
|
#
|
|
90
90
|
# @example Network-owned memory
|
|
91
91
|
# Memory.new(network_name: "support_pipeline")
|
|
92
|
-
def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true,
|
|
92
|
+
def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true,
|
|
93
|
+
network_name: nil)
|
|
93
94
|
@backend = select_backend(backend)
|
|
94
95
|
@mutex = Mutex.new
|
|
95
96
|
@enable_cache = enable_cache
|
|
@@ -104,7 +105,7 @@ module RobotLab
|
|
|
104
105
|
set_internal(:cache, @enable_cache ? RubyLLM::SemanticCache : nil)
|
|
105
106
|
|
|
106
107
|
# Data proxy for method-style access
|
|
107
|
-
@
|
|
108
|
+
@data = nil
|
|
108
109
|
|
|
109
110
|
# Reactive infrastructure
|
|
110
111
|
@subscriptions = Hash.new { |h, k| h[k] = [] }
|
|
@@ -149,7 +150,7 @@ module RobotLab
|
|
|
149
150
|
# Reserved keys have special handling (no notifications)
|
|
150
151
|
case key
|
|
151
152
|
when :data
|
|
152
|
-
@
|
|
153
|
+
@data = nil # Reset proxy
|
|
153
154
|
set_internal(:data, value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value)
|
|
154
155
|
when :results
|
|
155
156
|
set_internal(:results, Array(value))
|
|
@@ -164,8 +165,6 @@ module RobotLab
|
|
|
164
165
|
# Non-reserved keys use reactive set
|
|
165
166
|
set(key, value)
|
|
166
167
|
end
|
|
167
|
-
|
|
168
|
-
value
|
|
169
168
|
end
|
|
170
169
|
|
|
171
170
|
# Access runtime data through StateProxy
|
|
@@ -173,7 +172,7 @@ module RobotLab
|
|
|
173
172
|
# @return [StateProxy] proxy for method-style data access
|
|
174
173
|
#
|
|
175
174
|
def data
|
|
176
|
-
@
|
|
175
|
+
@data ||= StateProxy.new(get_internal(:data) || {})
|
|
177
176
|
end
|
|
178
177
|
|
|
179
178
|
# Get copy of results (immutable access)
|
|
@@ -207,7 +206,6 @@ module RobotLab
|
|
|
207
206
|
#
|
|
208
207
|
def session_id=(id)
|
|
209
208
|
set_internal(:session_id, id)
|
|
210
|
-
self
|
|
211
209
|
end
|
|
212
210
|
|
|
213
211
|
# Get the semantic cache module
|
|
@@ -450,16 +448,14 @@ module RobotLab
|
|
|
450
448
|
# hits.each { |h| puts "#{h[:key]} (#{h[:score].round(3)}): #{h[:text][0..80]}" }
|
|
451
449
|
#
|
|
452
450
|
def search_documents(query, limit: 5)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
@document_store.search(query, limit: limit)
|
|
451
|
+
document_store.search(query, limit: limit)
|
|
456
452
|
end
|
|
457
453
|
|
|
458
454
|
# Keys of all documents stored in the embedded document store.
|
|
459
455
|
#
|
|
460
456
|
# @return [Array<Symbol>]
|
|
461
457
|
def document_keys
|
|
462
|
-
|
|
458
|
+
document_store.keys
|
|
463
459
|
end
|
|
464
460
|
|
|
465
461
|
# Remove a document from the store.
|
|
@@ -467,7 +463,7 @@ module RobotLab
|
|
|
467
463
|
# @param key [Symbol, String]
|
|
468
464
|
# @return [self]
|
|
469
465
|
def delete_document(key)
|
|
470
|
-
|
|
466
|
+
document_store.delete(key)
|
|
471
467
|
self
|
|
472
468
|
end
|
|
473
469
|
|
|
@@ -590,7 +586,7 @@ module RobotLab
|
|
|
590
586
|
@backend[:session_id] = nil
|
|
591
587
|
@backend[:cache] = cached # Restore cache instance
|
|
592
588
|
end
|
|
593
|
-
@
|
|
589
|
+
@data = nil
|
|
594
590
|
self
|
|
595
591
|
end
|
|
596
592
|
|
|
@@ -641,7 +637,7 @@ module RobotLab
|
|
|
641
637
|
results: results.map(&:export),
|
|
642
638
|
messages: messages.map(&:to_h),
|
|
643
639
|
session_id: session_id,
|
|
644
|
-
custom: keys.
|
|
640
|
+
custom: keys.to_h { |k| [k, self[k]] }
|
|
645
641
|
}.compact
|
|
646
642
|
end
|
|
647
643
|
|
|
@@ -650,8 +646,8 @@ module RobotLab
|
|
|
650
646
|
# @param args [Array] arguments passed to to_json
|
|
651
647
|
# @return [String]
|
|
652
648
|
#
|
|
653
|
-
def to_json(*
|
|
654
|
-
to_h.to_json(*
|
|
649
|
+
def to_json(*)
|
|
650
|
+
to_h.to_json(*)
|
|
655
651
|
end
|
|
656
652
|
|
|
657
653
|
# Reconstruct memory from hash
|
|
@@ -687,7 +683,12 @@ module RobotLab
|
|
|
687
683
|
private
|
|
688
684
|
|
|
689
685
|
def document_store
|
|
690
|
-
|
|
686
|
+
unless RobotLab.extension_loaded?(:document_store)
|
|
687
|
+
raise RobotLab::DependencyError,
|
|
688
|
+
"document storage requires the robot_lab-document_store gem. " \
|
|
689
|
+
"Add `gem 'robot_lab-document_store'` to your Gemfile."
|
|
690
|
+
end
|
|
691
|
+
@document_store ||= RobotLab::DocumentStore.new
|
|
691
692
|
end
|
|
692
693
|
|
|
693
694
|
def create_semantic_cache
|
|
@@ -696,11 +697,9 @@ module RobotLab
|
|
|
696
697
|
|
|
697
698
|
def select_backend(preference)
|
|
698
699
|
case preference
|
|
699
|
-
when :redis
|
|
700
|
-
create_redis_backend || create_hash_backend
|
|
701
700
|
when :hash
|
|
702
701
|
create_hash_backend
|
|
703
|
-
else # :auto
|
|
702
|
+
else # :redis, :auto
|
|
704
703
|
create_redis_backend || create_hash_backend
|
|
705
704
|
end
|
|
706
705
|
end
|
|
@@ -722,7 +721,7 @@ module RobotLab
|
|
|
722
721
|
|
|
723
722
|
# Check if Redis is configured in RobotLab
|
|
724
723
|
redis_config = RobotLab.config.respond_to?(:redis) ? RobotLab.config.redis : nil
|
|
725
|
-
redis_config || ENV
|
|
724
|
+
redis_config || ENV.fetch("REDIS_URL", nil)
|
|
726
725
|
end
|
|
727
726
|
|
|
728
727
|
def get_internal(key)
|
|
@@ -877,8 +876,15 @@ module RobotLab
|
|
|
877
876
|
end
|
|
878
877
|
ensure
|
|
879
878
|
reschedule = @notification_queue_mutex.synchronize do
|
|
880
|
-
@
|
|
881
|
-
|
|
879
|
+
if @notification_queue.empty?
|
|
880
|
+
@drainer_scheduled = false
|
|
881
|
+
false
|
|
882
|
+
else
|
|
883
|
+
# Keep the flag set so concurrent writers don't also spawn a drainer.
|
|
884
|
+
# Only we will reschedule — no double-schedule race.
|
|
885
|
+
@drainer_scheduled = true
|
|
886
|
+
true
|
|
887
|
+
end
|
|
882
888
|
end
|
|
883
889
|
dispatch_async { drain_notification_queue } if reschedule
|
|
884
890
|
end
|
|
@@ -890,9 +896,9 @@ module RobotLab
|
|
|
890
896
|
def pattern_to_regex(pattern)
|
|
891
897
|
# Convert glob pattern to regex
|
|
892
898
|
regex_str = pattern
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
899
|
+
.gsub(".", "\\.")
|
|
900
|
+
.gsub("*", ".*")
|
|
901
|
+
.gsub("?", ".")
|
|
896
902
|
|
|
897
903
|
Regexp.new("\\A#{regex_str}\\z")
|
|
898
904
|
end
|
|
@@ -917,7 +923,6 @@ module RobotLab
|
|
|
917
923
|
def []=(key, value)
|
|
918
924
|
serialized = value.is_a?(String) ? value : value.to_json
|
|
919
925
|
@redis.set("#{@namespace}:#{key}", serialized)
|
|
920
|
-
value
|
|
921
926
|
end
|
|
922
927
|
|
|
923
928
|
def key?(key)
|
data/lib/robot_lab/message.rb
CHANGED
|
@@ -79,8 +79,8 @@ module RobotLab
|
|
|
79
79
|
#
|
|
80
80
|
# @param args [Array] arguments passed to to_json
|
|
81
81
|
# @return [String] JSON representation of the message
|
|
82
|
-
def to_json(*
|
|
83
|
-
to_h.to_json(*
|
|
82
|
+
def to_json(*)
|
|
83
|
+
to_h.to_json(*)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# Creates a Message instance from a hash.
|
|
@@ -200,8 +200,8 @@ module RobotLab
|
|
|
200
200
|
#
|
|
201
201
|
# @param args [Array] arguments passed to to_json
|
|
202
202
|
# @return [String] JSON representation
|
|
203
|
-
def to_json(*
|
|
204
|
-
to_h.to_json(*
|
|
203
|
+
def to_json(*)
|
|
204
|
+
to_h.to_json(*)
|
|
205
205
|
end
|
|
206
206
|
|
|
207
207
|
# Creates a ToolMessage from a hash.
|
|
@@ -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
|
#
|
|
@@ -86,7 +87,7 @@ module RobotLab
|
|
|
86
87
|
# task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
|
|
87
88
|
# end
|
|
88
89
|
#
|
|
89
|
-
def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &
|
|
90
|
+
def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &)
|
|
90
91
|
@name = name.to_s
|
|
91
92
|
@robots = {}
|
|
92
93
|
@tasks = {}
|
|
@@ -94,10 +95,11 @@ 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
|
|
|
100
|
-
instance_eval(&
|
|
102
|
+
instance_eval(&) if block_given?
|
|
101
103
|
end
|
|
102
104
|
|
|
103
105
|
# Add a robot as a pipeline task with optional per-task configuration
|
|
@@ -123,7 +125,8 @@ module RobotLab
|
|
|
123
125
|
# @example Task with dependencies
|
|
124
126
|
# task :writer, writer_robot, depends_on: [:analyst]
|
|
125
127
|
#
|
|
126
|
-
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none,
|
|
128
|
+
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none,
|
|
129
|
+
poller_group: :default)
|
|
127
130
|
task_wrapper = Task.new(
|
|
128
131
|
name: name,
|
|
129
132
|
robot: robot,
|
|
@@ -131,7 +134,8 @@ module RobotLab
|
|
|
131
134
|
mcp: mcp,
|
|
132
135
|
tools: tools,
|
|
133
136
|
memory: memory,
|
|
134
|
-
config: config
|
|
137
|
+
config: config,
|
|
138
|
+
network: self
|
|
135
139
|
)
|
|
136
140
|
|
|
137
141
|
# Register the group and assign the shared poller to the robot
|
|
@@ -158,8 +162,8 @@ module RobotLab
|
|
|
158
162
|
# end
|
|
159
163
|
# task :process, processor, depends_on: :fetch_data
|
|
160
164
|
#
|
|
161
|
-
def parallel(name = nil, depends_on: :none, &
|
|
162
|
-
@pipeline.parallel(name, depends_on: depends_on, &
|
|
165
|
+
def parallel(name = nil, depends_on: :none, &)
|
|
166
|
+
@pipeline.parallel(name, depends_on: depends_on, &)
|
|
163
167
|
self
|
|
164
168
|
end
|
|
165
169
|
|
|
@@ -176,24 +180,55 @@ module RobotLab
|
|
|
176
180
|
# result.value # => RobotResult from last robot
|
|
177
181
|
# result.context[:classifier] # => RobotResult from classifier
|
|
178
182
|
#
|
|
179
|
-
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
|
+
|
|
180
188
|
# Include shared memory in run params so robots can access it
|
|
181
189
|
run_context[:network_memory] = @memory
|
|
190
|
+
run_context[:network] = self
|
|
182
191
|
|
|
183
192
|
# Pass network's config so robots can inherit it
|
|
184
193
|
run_context[:network_config] = @config unless @config.empty?
|
|
185
194
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
194
212
|
end
|
|
195
213
|
end
|
|
196
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
|
+
|
|
197
232
|
# Broadcast a message to all robots in the network.
|
|
198
233
|
#
|
|
199
234
|
# This sends a network-wide message that all robots subscribed via
|
|
@@ -302,6 +337,19 @@ module RobotLab
|
|
|
302
337
|
self
|
|
303
338
|
end
|
|
304
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
|
+
|
|
305
353
|
# Visualize the pipeline as ASCII
|
|
306
354
|
#
|
|
307
355
|
# @return [String, nil]
|
|
@@ -351,21 +399,18 @@ module RobotLab
|
|
|
351
399
|
private
|
|
352
400
|
|
|
353
401
|
def run_with_ractor_scheduler(run_context)
|
|
402
|
+
unless RobotLab.extension_loaded?(:ractor)
|
|
403
|
+
raise RobotLab::DependencyError,
|
|
404
|
+
"parallel_mode: :ractor requires the robot_lab-ractor gem. " \
|
|
405
|
+
"Add `gem 'robot_lab-ractor'` to your Gemfile."
|
|
406
|
+
end
|
|
354
407
|
message = run_context[:message].to_s
|
|
355
408
|
dep_graph = @pipeline.step_dependencies # { task_sym => [dep_sym, ...] }
|
|
356
409
|
|
|
357
410
|
specs_with_deps = @tasks.map do |task_name, task_wrapper|
|
|
358
411
|
deps = dep_graph[task_name.to_sym] || []
|
|
359
412
|
deps = deps.empty? ? :none : deps.map(&:to_s)
|
|
360
|
-
|
|
361
|
-
spec = RobotSpec.new(
|
|
362
|
-
name: task_wrapper.robot.name.freeze,
|
|
363
|
-
template: task_wrapper.robot.template&.to_s&.freeze,
|
|
364
|
-
system_prompt: task_wrapper.robot.system_prompt&.freeze,
|
|
365
|
-
config_hash: RactorBoundary.freeze_deep(task_wrapper.robot.config.to_json_hash)
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
{ spec: spec, depends_on: deps }
|
|
413
|
+
{ spec: build_robot_spec(task_wrapper), depends_on: deps }
|
|
369
414
|
end
|
|
370
415
|
|
|
371
416
|
scheduler = RactorNetworkScheduler.new(memory: @memory)
|
|
@@ -374,5 +419,22 @@ module RobotLab
|
|
|
374
419
|
results
|
|
375
420
|
end
|
|
376
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
|
|
377
439
|
end
|
|
378
440
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
class Robot < RubyLLM::Agent
|
|
5
|
+
# Prepended module that intercepts run() to inject relevant AgentSkills.io
|
|
6
|
+
# skills into the system prompt and tool list for the duration of each call.
|
|
7
|
+
#
|
|
8
|
+
# Owns: @_active_agent_skills, @_agent_skill_original_instructions, @_agent_skill_injected_tools
|
|
9
|
+
# Reads: @pending_agent_skills, @agent_skill_store, @local_tools, @chat, @name
|
|
10
|
+
# Contract: prepended (wraps super); requires TemplateRendering to have set @pending_agent_skills
|
|
11
|
+
#
|
|
12
|
+
# Skills are matched by embedding similarity between the incoming message
|
|
13
|
+
# and each pending skill's description (via DocumentStore/fastembed).
|
|
14
|
+
# Injected content is fully restored in an ensure block after run() returns.
|
|
15
|
+
module AgentSkillMatching
|
|
16
|
+
SIMILARITY_THRESHOLD = 0.70
|
|
17
|
+
|
|
18
|
+
def run(message = nil, **, &)
|
|
19
|
+
matched = match_agent_skills(message.to_s)
|
|
20
|
+
inject_agent_skills(matched) if matched.any?
|
|
21
|
+
super
|
|
22
|
+
ensure
|
|
23
|
+
restore_after_agent_skills if @_active_agent_skills&.any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Override to re-inject skill instructions after template re-render replaces
|
|
27
|
+
# the system prompt during a run() call with runtime kwargs.
|
|
28
|
+
def rerender_template(run_context)
|
|
29
|
+
super
|
|
30
|
+
return unless @_active_agent_skills&.any?
|
|
31
|
+
|
|
32
|
+
@_agent_skill_original_instructions = current_agent_skill_instructions
|
|
33
|
+
prepend_skill_instructions(@_active_agent_skills)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Find pending AgentSkills whose descriptions are semantically similar to message.
|
|
39
|
+
#
|
|
40
|
+
# @param message [String]
|
|
41
|
+
# @param threshold [Float] cosine similarity cutoff (default SIMILARITY_THRESHOLD)
|
|
42
|
+
# @return [Array<AgentSkill>]
|
|
43
|
+
def match_agent_skills(message, threshold: SIMILARITY_THRESHOLD)
|
|
44
|
+
return [] if @pending_agent_skills.nil? || @pending_agent_skills.empty?
|
|
45
|
+
|
|
46
|
+
results = @agent_skill_store.search(message, limit: @pending_agent_skills.size)
|
|
47
|
+
results
|
|
48
|
+
.select { |r| r[:score] >= threshold }
|
|
49
|
+
.filter_map { |r| @pending_agent_skills.find { |s| s.name.to_sym == r[:key] } }
|
|
50
|
+
rescue => e
|
|
51
|
+
RobotLab.config.logger.warn(
|
|
52
|
+
"Robot '#{@name}': AgentSkill embedding failed: #{e.message}"
|
|
53
|
+
)
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Prepend skill instructions before existing system prompt content.
|
|
58
|
+
#
|
|
59
|
+
# @param skills [Array<AgentSkill>]
|
|
60
|
+
def prepend_skill_instructions(skills)
|
|
61
|
+
skill_content = skills.map(&:instructions).join("\n\n")
|
|
62
|
+
base = @_agent_skill_original_instructions.to_s
|
|
63
|
+
combined = [skill_content, base].reject(&:empty?).join("\n\n")
|
|
64
|
+
@chat.with_instructions(combined)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Inject script tools and snapshot instructions before injection.
|
|
68
|
+
#
|
|
69
|
+
# @param skills [Array<AgentSkill>]
|
|
70
|
+
def inject_agent_skills(skills)
|
|
71
|
+
return if skills.empty?
|
|
72
|
+
|
|
73
|
+
@_active_agent_skills = skills
|
|
74
|
+
@_agent_skill_original_instructions = current_agent_skill_instructions
|
|
75
|
+
prepend_skill_instructions(skills)
|
|
76
|
+
@_agent_skill_injected_tools = skills.flat_map(&:script_tools).compact
|
|
77
|
+
@local_tools += @_agent_skill_injected_tools
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Remove injected tools and restore original system prompt.
|
|
81
|
+
def restore_after_agent_skills
|
|
82
|
+
@local_tools -= @_agent_skill_injected_tools || []
|
|
83
|
+
@chat.with_instructions(@_agent_skill_original_instructions.to_s)
|
|
84
|
+
@_active_agent_skills = nil
|
|
85
|
+
@_agent_skill_original_instructions = nil
|
|
86
|
+
@_agent_skill_injected_tools = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Read the current system message content from the underlying chat.
|
|
90
|
+
#
|
|
91
|
+
# @return [String, nil]
|
|
92
|
+
def current_agent_skill_instructions
|
|
93
|
+
messages = @chat.instance_variable_get(:@messages)
|
|
94
|
+
sys = messages&.find { |m| m.role.to_s == "system" }
|
|
95
|
+
sys&.content
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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
|