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.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -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, network_name: nil)
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
- @data_proxy = nil
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
- @data_proxy = nil # Reset proxy
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
- @data_proxy ||= StateProxy.new(get_internal(:data) || {})
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
- return [] unless @document_store
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
- @document_store&.keys || []
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
- @document_store&.delete(key)
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
- @data_proxy = nil
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.each_with_object({}) { |k, h| h[k] = self[k] }
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(*args)
654
- to_h.to_json(*args)
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
- @document_store ||= DocumentStore.new
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["REDIS_URL"]
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
- @drainer_scheduled = false
881
- !@notification_queue.empty?
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
- .gsub(".", "\\.")
894
- .gsub("*", ".*")
895
- .gsub("?", ".")
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)
@@ -98,8 +98,8 @@ module RobotLab
98
98
  # @param args [Array] arguments passed to to_json
99
99
  # @return [String]
100
100
  #
101
- def to_json(*args)
102
- to_h.to_json(*args)
101
+ def to_json(*)
102
+ to_h.to_json(*)
103
103
  end
104
104
 
105
105
  # Reconstruct from hash.
@@ -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(*args)
83
- to_h.to_json(*args)
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(*args)
204
- to_h.to_json(*args)
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
@@ -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, &block)
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(&block) if block_given?
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, poller_group: :default)
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, &block)
162
- @pipeline.parallel(name, depends_on: depends_on, &block)
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
- if @parallel_mode == :ractor
187
- run_with_ractor_scheduler(run_context)
188
- else
189
- initial_result = SimpleFlow::Result.new(
190
- run_context,
191
- context: { run_params: run_context }
192
- )
193
- @pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
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