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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +61 -0
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +1 -0
  10. data/docs/api/core/network.md +31 -1
  11. data/docs/api/core/robot.md +91 -5
  12. data/docs/api/errors.md +47 -2
  13. data/docs/api/index.md +3 -0
  14. data/docs/architecture/core-concepts.md +40 -0
  15. data/docs/architecture/network-orchestration.md +8 -0
  16. data/docs/architecture/robot-execution.md +1 -1
  17. data/docs/getting-started/configuration.md +20 -1
  18. data/docs/guides/building-robots.md +39 -1
  19. data/docs/guides/creating-networks.md +6 -1
  20. data/docs/guides/hooks.md +997 -0
  21. data/docs/guides/index.md +5 -0
  22. data/docs/guides/knowledge.md +2 -2
  23. data/docs/guides/observability.md +74 -7
  24. data/docs/guides/using-tools.md +69 -0
  25. data/docs/index.md +1 -1
  26. data/examples/03_network.rb +11 -2
  27. data/examples/34_agentskills.rb +0 -0
  28. data/examples/35_hooks.rb +256 -0
  29. data/examples/README.md +7 -0
  30. data/examples/common.rb +7 -3
  31. data/examples/xyzzy.rb +90 -0
  32. data/lib/robot_lab/agent_skill.rb +5 -4
  33. data/lib/robot_lab/budget/ledger.rb +98 -0
  34. data/lib/robot_lab/capabilities.rb +84 -0
  35. data/lib/robot_lab/config/defaults.yml +10 -0
  36. data/lib/robot_lab/config.rb +4 -4
  37. data/lib/robot_lab/error.rb +34 -4
  38. data/lib/robot_lab/errors.rb +45 -0
  39. data/lib/robot_lab/hook.rb +79 -0
  40. data/lib/robot_lab/hook_context.rb +194 -0
  41. data/lib/robot_lab/hook_registry.rb +55 -0
  42. data/lib/robot_lab/hooks.rb +87 -0
  43. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  44. data/lib/robot_lab/narrator.rb +87 -0
  45. data/lib/robot_lab/network.rb +77 -20
  46. data/lib/robot_lab/robot/budget.rb +89 -0
  47. data/lib/robot_lab/robot/bus_messaging.rb +76 -12
  48. data/lib/robot_lab/robot/hooking.rb +56 -0
  49. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  50. data/lib/robot_lab/robot/template_rendering.rb +2 -2
  51. data/lib/robot_lab/robot.rb +211 -88
  52. data/lib/robot_lab/run_config.rb +24 -5
  53. data/lib/robot_lab/runnable.rb +51 -0
  54. data/lib/robot_lab/sandbox/null.rb +13 -0
  55. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  56. data/lib/robot_lab/sandbox.rb +52 -0
  57. data/lib/robot_lab/script_tool.rb +60 -2
  58. data/lib/robot_lab/task.rb +26 -20
  59. data/lib/robot_lab/tool.rb +52 -11
  60. data/lib/robot_lab/version.rb +1 -1
  61. data/lib/robot_lab.rb +49 -0
  62. data/mkdocs.yml +1 -0
  63. metadata +36 -99
  64. data/examples/temp.md +0 -51
  65. data/site/404.html +0 -2300
  66. data/site/api/core/index.html +0 -2706
  67. data/site/api/core/memory/index.html +0 -3793
  68. data/site/api/core/network/index.html +0 -3500
  69. data/site/api/core/robot/index.html +0 -4566
  70. data/site/api/core/state/index.html +0 -3390
  71. data/site/api/core/tool/index.html +0 -3843
  72. data/site/api/index.html +0 -2635
  73. data/site/api/mcp/client/index.html +0 -3435
  74. data/site/api/mcp/index.html +0 -2783
  75. data/site/api/mcp/server/index.html +0 -3252
  76. data/site/api/mcp/transports/index.html +0 -3352
  77. data/site/api/messages/index.html +0 -2641
  78. data/site/api/messages/text-message/index.html +0 -3087
  79. data/site/api/messages/tool-call-message/index.html +0 -3159
  80. data/site/api/messages/tool-result-message/index.html +0 -3252
  81. data/site/api/messages/user-message/index.html +0 -3212
  82. data/site/api/streaming/context/index.html +0 -3282
  83. data/site/api/streaming/events/index.html +0 -3347
  84. data/site/api/streaming/index.html +0 -2738
  85. data/site/architecture/core-concepts/index.html +0 -3757
  86. data/site/architecture/index.html +0 -2797
  87. data/site/architecture/message-flow/index.html +0 -3238
  88. data/site/architecture/network-orchestration/index.html +0 -3433
  89. data/site/architecture/robot-execution/index.html +0 -3140
  90. data/site/architecture/state-management/index.html +0 -3498
  91. data/site/assets/css/custom.css +0 -56
  92. data/site/assets/images/favicon.png +0 -0
  93. data/site/assets/images/robot_lab.jpg +0 -0
  94. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  95. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  96. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  97. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  98. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  99. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  100. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  101. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  102. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  103. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  104. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  105. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  106. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  107. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  108. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  109. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  110. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  111. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  112. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  113. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  114. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  115. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  116. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  117. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  118. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  119. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  120. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  121. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  122. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  123. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  124. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  125. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  126. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  127. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  128. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  129. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  130. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  131. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  132. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  133. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  134. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  135. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  136. data/site/concepts/index.html +0 -3455
  137. data/site/examples/basic-chat/index.html +0 -2880
  138. data/site/examples/index.html +0 -2907
  139. data/site/examples/mcp-server/index.html +0 -3018
  140. data/site/examples/multi-robot-network/index.html +0 -3131
  141. data/site/examples/rails-application/index.html +0 -3329
  142. data/site/examples/tool-usage/index.html +0 -3085
  143. data/site/getting-started/configuration/index.html +0 -3745
  144. data/site/getting-started/index.html +0 -2572
  145. data/site/getting-started/installation/index.html +0 -2981
  146. data/site/getting-started/quick-start/index.html +0 -2942
  147. data/site/guides/building-robots/index.html +0 -4290
  148. data/site/guides/creating-networks/index.html +0 -3858
  149. data/site/guides/index.html +0 -2586
  150. data/site/guides/mcp-integration/index.html +0 -3581
  151. data/site/guides/memory/index.html +0 -3586
  152. data/site/guides/rails-integration/index.html +0 -4019
  153. data/site/guides/streaming/index.html +0 -3157
  154. data/site/guides/using-tools/index.html +0 -3802
  155. data/site/index.html +0 -2671
  156. data/site/search/search_index.json +0 -1
  157. data/site/sitemap.xml +0 -183
  158. data/site/sitemap.xml.gz +0 -0
  159. data/site/tags.json +0 -1
@@ -5,6 +5,8 @@ require_relative 'robot/mcp_management'
5
5
  require_relative 'robot/bus_messaging'
6
6
  require_relative 'robot/history_search'
7
7
  require_relative 'robot/agent_skill_matching'
8
+ require_relative 'robot/budget'
9
+ require_relative 'robot/hooking'
8
10
 
9
11
  module RobotLab
10
12
  # LLM-powered robot built on RubyLLM::Agent
@@ -44,8 +46,16 @@ module RobotLab
44
46
  include Robot::MCPManagement
45
47
  include Robot::BusMessaging
46
48
  include Robot::HistorySearch
49
+ include Robot::Budget
50
+ include Robot::Hooking
51
+ include Runnable
47
52
  prepend Robot::AgentSkillMatching
48
53
 
54
+ # Default ceiling on tools handed to the provider per turn. OpenAI and
55
+ # Anthropic both reject tool arrays longer than 128. Override per robot via
56
+ # RunConfig#max_tools.
57
+ DEFAULT_MAX_TOOLS = 128
58
+
49
59
  # @!attribute [r] name
50
60
  # @return [String] the unique identifier for the robot
51
61
  # @!attribute [r] description
@@ -70,9 +80,9 @@ module RobotLab
70
80
 
71
81
  attr_reader :name, :description, :template, :system_prompt,
72
82
  :local_tools, :mcp_clients, :mcp_tools, :memory,
73
- :bus, :outbox, :config, :skills, :provider,
83
+ :bus, :outbox, :config, :skills, :provider, :hooks,
74
84
  :total_input_tokens, :total_output_tokens, :learnings,
75
- :durable_store, :learn_domain
85
+ :budget_ledger
76
86
 
77
87
  # @!attribute [r] mcp_config
78
88
  # @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
@@ -80,6 +90,14 @@ module RobotLab
80
90
  # @return [Symbol, Array] build-time tools configuration (raw, unresolved)
81
91
  attr_reader :mcp_config, :tools_config
82
92
 
93
+ # Runnable protocol: a single robot is a crew of one. (network? defaults to
94
+ # false from RobotLab::Runnable.)
95
+ #
96
+ # @return [Array<Robot>]
97
+ def crew
98
+ [self]
99
+ end
100
+
83
101
  # Returns the fully-merged configuration for this robot at runtime.
84
102
  #
85
103
  # Reflects the result of merging the RunConfig hierarchy (global → network →
@@ -106,7 +124,8 @@ module RobotLab
106
124
  doom_loop_threshold: @config.doom_loop_threshold,
107
125
  auto_compact: @config.auto_compact,
108
126
  compact_threshold: @config.compact_threshold,
109
- token_budget: @config.token_budget
127
+ token_budget: @config.token_budget,
128
+ cost_budget: @config.cost_budget
110
129
  }.compact
111
130
  end
112
131
 
@@ -163,13 +182,13 @@ module RobotLab
163
182
  stop: nil,
164
183
  max_tool_rounds: nil,
165
184
  token_budget: nil,
185
+ cost_budget: nil,
166
186
  doom_loop_threshold: nil,
167
187
  mcp_discovery: false,
168
- config: nil,
169
- learn: false,
170
- learn_domain: nil,
171
- store_path: nil
188
+ config: nil
172
189
  )
190
+ validate_tools_filter!(tools)
191
+
173
192
  assign_identity_ivars(name: name, template: template, system_prompt: system_prompt,
174
193
  context: context, description: description, local_tools: local_tools,
175
194
  skills: skills, mcp_discovery: mcp_discovery)
@@ -180,7 +199,7 @@ module RobotLab
180
199
  frequency_penalty: frequency_penalty, stop: stop,
181
200
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
182
201
  on_content: on_content, bus: bus, enable_cache: enable_cache,
183
- max_tool_rounds: max_tool_rounds, token_budget: token_budget,
202
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
184
203
  doom_loop_threshold: doom_loop_threshold, mcp_servers: mcp_servers,
185
204
  mcp: mcp, tools: tools, config: config
186
205
  )
@@ -188,7 +207,6 @@ module RobotLab
188
207
  extract_config_ivars
189
208
  initialize_runtime_state
190
209
  initialize_memory
191
- configure_learning(learn: learn, learn_domain: learn_domain, store_path: store_path)
192
210
 
193
211
  lab_config = RobotLab.config
194
212
  resolved_model = @config.model || lab_config.ruby_llm.model
@@ -219,39 +237,6 @@ module RobotLab
219
237
  m.respond_to?(:id) ? m.id : m.to_s
220
238
  end
221
239
 
222
- # Send a message and get a response, with Robot's extended capabilities
223
- #
224
- # @param message [String] the user message
225
- # @param network [NetworkRun, nil] network context (legacy)
226
- # @param network_memory [Memory, nil] shared network memory
227
- # @param memory [Memory, Hash, nil] runtime memory to merge
228
- # @param mcp [Symbol, Array, nil] runtime MCP override
229
- # @param tools [Symbol, Array, nil] runtime tools override
230
- # @yield [chunk] optional streaming block called with each content chunk
231
- # @return [RobotResult]
232
- def run(message = nil, network: nil, network_memory: nil, network_config: nil,
233
- memory: nil, mcp: :none, tools: :none, **kwargs, &block)
234
- run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
235
- previous_writer = run_memory.current_writer
236
- run_memory.current_writer = @name
237
-
238
- begin
239
- run_context = kwargs.except(:with)
240
- prepare_tools(message: message, mcp: mcp, tools: tools,
241
- network: network, network_config: network_config)
242
- rerender_template(run_context) if @template && run_context.any?
243
- response = invoke_ask(message: message, kwargs: kwargs, block: block)
244
- result = build_result(response, run_memory)
245
- enforce_token_budget!
246
- result
247
- ensure
248
- remove_doom_loop_detection
249
- restore_tool_call_callback if @config.max_tool_rounds
250
- run_reflector if @durable_store
251
- run_memory.current_writer = previous_writer
252
- end
253
- end
254
-
255
240
  # Reconfigure the robot for a new context
256
241
  #
257
242
  # @param template [Symbol, nil] new template to apply
@@ -523,13 +508,19 @@ module RobotLab
523
508
  text = text.to_s.strip
524
509
  return self if text.empty?
525
510
 
526
- # Remove any existing learning that is a substring of the new one
527
- @learnings.reject! { |existing| text.include?(existing) }
511
+ ctx = LearnHookContext.new(robot: self, text: text, learnings_before: @learnings.dup)
512
+ registries = [RobotLab.hooks, @hooks]
513
+
514
+ RobotLab::Hooks.run(:learn, ctx, registries: registries) do
515
+ @learnings.reject! { |existing| text.include?(existing) }
516
+
517
+ unless @learnings.any? { |existing| existing.include?(text) }
518
+ @learnings << text
519
+ @memory.set(:learnings, @learnings.dup)
520
+ ctx.stored = true
521
+ end
528
522
 
529
- # Skip if any existing learning already covers the new one
530
- unless @learnings.any? { |existing| existing.include?(text) }
531
- @learnings << text
532
- @memory.set(:learnings, @learnings.dup)
523
+ RobotLab::Hooks.call(:on_learn, ctx, registries: registries)
533
524
  end
534
525
 
535
526
  self
@@ -567,6 +558,22 @@ module RobotLab
567
558
 
568
559
  private
569
560
 
561
+ # `tools:` is a NAME allowlist (a filter over available tools), not a place
562
+ # to attach tool instances — passing instances there silently attaches
563
+ # nothing. Catch the mistake with a clear, actionable error.
564
+ def validate_tools_filter!(tools)
565
+ return unless tools.is_a?(Array)
566
+
567
+ offenders = tools.reject { |t| t.is_a?(String) || t.is_a?(Symbol) }
568
+ return if offenders.empty?
569
+
570
+ classes = offenders.map { |t| t.is_a?(Class) ? t.name : t.class.name }.uniq.join(", ")
571
+ raise ArgumentError,
572
+ "`tools:` expects tool names (String/Symbol) to allow, but received #{classes}. " \
573
+ "To attach tool instances or classes, pass them as `local_tools:` " \
574
+ "(e.g. RobotLab.build(local_tools: [MyTool.new]))."
575
+ end
576
+
570
577
  def assign_identity_ivars(name:, template:, system_prompt:, context:, description:,
571
578
  local_tools:, skills:, mcp_discovery:)
572
579
  @name = name.to_s
@@ -588,7 +595,7 @@ module RobotLab
588
595
  def build_effective_config(model:, temperature:, top_p:, top_k:, max_tokens:,
589
596
  presence_penalty:, frequency_penalty:, stop:,
590
597
  on_tool_call:, on_tool_result:, on_content:,
591
- bus:, enable_cache:, max_tool_rounds:, token_budget:,
598
+ bus:, enable_cache:, max_tool_rounds:, token_budget:, cost_budget:,
592
599
  doom_loop_threshold:, mcp_servers:, mcp:, tools:, config:)
593
600
  explicit_fields = {
594
601
  model: model, temperature: temperature, top_p: top_p, top_k: top_k,
@@ -596,7 +603,7 @@ module RobotLab
596
603
  frequency_penalty: frequency_penalty, stop: stop,
597
604
  on_tool_call: on_tool_call, on_tool_result: on_tool_result,
598
605
  on_content: on_content, bus: bus, enable_cache: enable_cache,
599
- max_tool_rounds: max_tool_rounds, token_budget: token_budget,
606
+ max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
600
607
  doom_loop_threshold: doom_loop_threshold
601
608
  }.compact
602
609
 
@@ -623,6 +630,7 @@ module RobotLab
623
630
  @bus = @config.bus
624
631
  @message_counter = 0
625
632
  @outbox = {}
633
+ @bus_mutex = Mutex.new
626
634
  @message_handler = ->(_msg) {}
627
635
  @bus_poller = nil
628
636
  @private_bus_poller = nil
@@ -630,6 +638,8 @@ module RobotLab
630
638
  @total_input_tokens = 0
631
639
  @total_output_tokens = 0
632
640
  @learnings = []
641
+ @hooks = HookRegistry.new
642
+ @budget_ledger = build_budget_ledger
633
643
  end
634
644
 
635
645
  def initialize_memory
@@ -639,16 +649,6 @@ module RobotLab
639
649
  @learnings = Array(persisted) if persisted
640
650
  end
641
651
 
642
- def configure_learning(learn:, learn_domain:, store_path:)
643
- return unless learn && RobotLab.extension_loaded?(:durable)
644
-
645
- if learn_domain
646
- setup_durable_learning(domain: learn_domain, store_path: store_path)
647
- else
648
- warn "[RobotLab] Robot '#{@name}': learn: true requires learn_domain: to be set. Durable learning disabled."
649
- end
650
- end
651
-
652
652
  def apply_template
653
653
  define_chat_delegators
654
654
 
@@ -725,26 +725,87 @@ module RobotLab
725
725
 
726
726
  ensure_mcp_clients(resolved_mcp)
727
727
 
728
- filtered = filtered_tools(resolved_tools)
729
- @chat.with_tools(*filtered) if filtered.any?
728
+ # An EXPLICIT `:none` (or literal `[]`) means "send zero tools this turn" —
729
+ # e.g. a relevance filter judged no tool useful for the prompt. That is
730
+ # distinct from `:inherit`/unset, which means "no filter, use every attached
731
+ # tool". Both collapse to `[]` once resolved, and `filtered_tools([])`
732
+ # treats an empty allowlist as "all tools", so we must branch on the raw
733
+ # runtime value before resolution to honor the zero-tools intent.
734
+ filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
735
+
736
+ # replace: true so the chat holds EXACTLY this turn's resolved+capped set.
737
+ # RubyLLM's with_tools appends by default; on a persistent chat that lets
738
+ # tools accumulate across turns, so a capped per-turn addition could still
739
+ # push the chat's total past the provider limit. An explicit none clears
740
+ # the chat's tools to zero (with_tools(replace: true) with no tools).
741
+ @chat.with_tools(*filtered, replace: true) if filtered.any? || explicit_none_tools?(tools)
730
742
  end
731
743
 
732
- def invoke_ask(message:, kwargs:, block:)
733
- effective_message = inject_learnings(message)
734
- maybe_compact
735
- install_circuit_breaker if @config.max_tool_rounds
736
- install_doom_loop_detection
737
- ask_kwargs = kwargs.slice(:with)
738
- streaming = effective_streaming_block(block)
739
- ask(effective_message, **ask_kwargs, &streaming)
744
+ # True when the runtime tools value explicitly requests zero tools — `:none`
745
+ # or a literal empty array — as opposed to `:inherit`/`nil`, which mean "no
746
+ # filter, use all attached tools". Lets a per-turn filter that found nothing
747
+ # relevant actually send no tools instead of the whole set.
748
+ #
749
+ # @param tools [Symbol, Array, nil]
750
+ # @return [Boolean]
751
+ def explicit_none_tools?(tools)
752
+ tools == :none || (tools.is_a?(Array) && tools.empty?)
740
753
  end
741
754
 
742
- def enforce_token_budget!
743
- budget = @config.token_budget
744
- return unless budget && @total_input_tokens + @total_output_tokens > budget
755
+ # Clamp the resolved tool list to the provider's hard maximum. Most LLM
756
+ # providers reject tool arrays longer than 128 and fail the whole turn. This
757
+ # is the definitive choke point: tools are fully resolved (MCP connected)
758
+ # and about to be handed to the chat, so the cap holds no matter how the
759
+ # tools were configured, filtered, or connected. Cap value comes from
760
+ # RunConfig#max_tools, defaulting to DEFAULT_MAX_TOOLS; nil/<=0 disables it.
761
+ #
762
+ # @param tools [Array<Tool>] the resolved tools
763
+ # @return [Array<Tool>] at most `max_tools` tools
764
+ def cap_tools(tools)
765
+ max = effective_max_tools
766
+ return tools if max.nil? || tools.size <= max
767
+
768
+ RobotLab.config.logger.warn(
769
+ "[#{@name}] tool list (#{tools.size}) exceeds max_tools (#{max}); " \
770
+ "sending #{max}, dropping #{tools.size - max}"
771
+ )
772
+ tools.first(max)
773
+ end
745
774
 
746
- raise InferenceError,
747
- "Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
775
+ # The provider tool cap: RunConfig#max_tools when set positive, else the
776
+ # default. Returns nil only if the default itself is disabled.
777
+ #
778
+ # @return [Integer, nil]
779
+ def effective_max_tools
780
+ configured = @config.respond_to?(:max_tools) ? @config.max_tools : nil
781
+ configured&.positive? ? configured : DEFAULT_MAX_TOOLS
782
+ end
783
+
784
+ def invoke_ask(context:, kwargs:, hooks:, block:)
785
+ generation_context = LlmGenerationHookContext.new(
786
+ robot: self,
787
+ network: context.network,
788
+ task: context.task,
789
+ memory: context.memory,
790
+ config: context.config,
791
+ request: context.request,
792
+ metadata: context.metadata
793
+ )
794
+
795
+ RobotLab::Hooks.run(:llm_generation, generation_context,
796
+ registries: hook_registries(context.network), per_run_hooks: hooks) do
797
+ effective_message = inject_learnings(generation_context.request)
798
+ maybe_compact(network: context.network)
799
+ install_circuit_breaker if @config.max_tool_rounds
800
+ install_doom_loop_detection
801
+ ask_kwargs = kwargs.slice(:with)
802
+ streaming = effective_streaming_block(block)
803
+ ask(effective_message, **ask_kwargs, &streaming)
804
+ end
805
+ end
806
+
807
+ def hook_registries(network = nil)
808
+ [RobotLab.hooks, network&.hooks, @hooks]
748
809
  end
749
810
 
750
811
  # Extract run context from SimpleFlow::Result
@@ -757,6 +818,8 @@ module RobotLab
757
818
  memory = run_params.delete(:memory)
758
819
  network_memory = run_params.delete(:network_memory)
759
820
  network_config = run_params.delete(:network_config)
821
+ network = run_params.delete(:network)
822
+ task = run_params.delete(:task)
760
823
 
761
824
  # Build base context from remaining run params
762
825
  base = run_params.dup
@@ -779,16 +842,15 @@ module RobotLab
779
842
  merged[:memory] = memory if memory
780
843
  merged[:network_memory] = network_memory if network_memory
781
844
  merged[:network_config] = network_config if network_config
845
+ merged[:network] = network if network
846
+ merged[:task] = task if task
782
847
 
783
848
  merged
784
849
  end
785
850
 
786
851
  def build_result(response, _memory)
787
- output = if response.respond_to?(:content) && response.content
788
- [TextMessage.new(role: 'assistant', content: response.content)]
789
- else
790
- []
791
- end
852
+ text = result_text(response)
853
+ output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
792
854
 
793
855
  tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
794
856
 
@@ -817,6 +879,37 @@ module RobotLab
817
879
  )
818
880
  end
819
881
 
882
+ # Text for the result's output. Prefers the final response's content, then
883
+ # falls back in order to: (1) thinking text for models that route all output
884
+ # through reasoning_content (e.g. qwen3 on Ollama), (2) the most recent
885
+ # assistant text within the current turn for models that end on a tool call
886
+ # with no trailing text.
887
+ #
888
+ # The chat-history fallback is scoped to messages AFTER the last user message
889
+ # (the current turn) to prevent a previous turn's response from being returned
890
+ # when a thinking-mode model emits nothing in response.content.
891
+ def result_text(response)
892
+ content = response.content if response.respond_to?(:content)
893
+ return content if content && !content.to_s.empty?
894
+
895
+ # Ollama routes qwen3's reasoning to reasoning_content, which ruby_llm
896
+ # surfaces as response.thinking (a RubyLLM::Thinking object). When content
897
+ # is nil and thinking is present, the thinking IS the response for that turn.
898
+ if response.respond_to?(:thinking) && (thinking = response.thinking)
899
+ thinking_text = thinking.respond_to?(:text) ? thinking.text.to_s : thinking.to_s
900
+ return thinking_text unless thinking_text.empty?
901
+ end
902
+
903
+ return nil unless @chat.respond_to?(:messages)
904
+
905
+ messages = @chat.messages
906
+ last_user_idx = messages.rindex { |m| m.role == :user } || -1
907
+ current_turn = messages[(last_user_idx + 1)..]
908
+
909
+ last = current_turn.rfind { |m| m.role == :assistant && m.content && !m.content.to_s.empty? }
910
+ last&.content
911
+ end
912
+
820
913
  def normalize_tool_calls(tool_calls)
821
914
  return [] unless tool_calls
822
915
 
@@ -907,21 +1000,47 @@ module RobotLab
907
1000
  # :context_window — compress when estimated tokens exceed compact_threshold
908
1001
  # fraction of the model's context window (default 80%)
909
1002
  # Proc — called with self; application owns the decision and strategy
910
- def maybe_compact
1003
+ #
1004
+ # Fires the :compaction hook family (before/around/after_compaction).
1005
+ # An on_compaction handler can replace the default strategy entirely by
1006
+ # setting ctx.compacted_messages; the core algorithm is skipped when handled.
1007
+ def maybe_compact(network: nil)
911
1008
  return if @chat.messages.empty?
912
1009
 
913
1010
  compact = @config.auto_compact
914
1011
  return if compact.nil? || compact == :none
1012
+ return if compact == :context_window && !over_compact_threshold?
1013
+
1014
+ strategy = compact.is_a?(Proc) ? :custom : compact
1015
+ ctx = CompactionHookContext.new(
1016
+ robot: self,
1017
+ messages_before: @chat.messages.dup,
1018
+ config: @config,
1019
+ strategy: strategy
1020
+ )
1021
+
1022
+ RobotLab::Hooks.run(:compaction, ctx, registries: hook_registries(network)) do
1023
+ RobotLab::Hooks.call(:on_compaction, ctx, registries: hook_registries(network))
915
1024
 
916
- case compact
917
- when :context_window
918
- compact_if_over_context_window
919
- when Proc
920
- compact.call(self)
1025
+ if ctx.handled?
1026
+ replace_messages(ctx.compacted_messages)
1027
+ else
1028
+ case compact
1029
+ when :context_window
1030
+ begin
1031
+ compress_history
1032
+ rescue DependencyError => e
1033
+ RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
1034
+ end
1035
+ when Proc
1036
+ compact.call(self)
1037
+ end
1038
+ ctx.compacted_messages = @chat.messages.dup
1039
+ end
921
1040
  end
922
1041
  end
923
1042
 
924
- def compact_if_over_context_window
1043
+ def over_compact_threshold?
925
1044
  threshold = (@config.compact_threshold || 0.80).to_f
926
1045
  estimated_tok = @chat.messages.sum { |m| m.content.to_s.length } / 4
927
1046
 
@@ -931,7 +1050,11 @@ module RobotLab
931
1050
  200_000
932
1051
  end
933
1052
 
934
- return if estimated_tok < window * threshold
1053
+ estimated_tok >= window * threshold
1054
+ end
1055
+
1056
+ def compact_if_over_context_window
1057
+ return unless over_compact_threshold?
935
1058
 
936
1059
  begin
937
1060
  compress_history
@@ -41,8 +41,8 @@ module RobotLab
41
41
  CALLBACK_FIELDS = %i[on_tool_call on_tool_result on_content].freeze
42
42
 
43
43
  # Infrastructure fields
44
- INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget ractor_pool_size max_concurrent_robots
45
- doom_loop_threshold auto_compact compact_threshold].freeze
44
+ INFRA_FIELDS = %i[bus enable_cache max_tool_rounds token_budget cost_budget ractor_pool_size
45
+ max_concurrent_robots doom_loop_threshold auto_compact compact_threshold max_tools].freeze
46
46
 
47
47
  # All recognized fields
48
48
  FIELDS = (LLM_FIELDS + TOOL_FIELDS + CALLBACK_FIELDS + INFRA_FIELDS).freeze
@@ -107,14 +107,33 @@ module RobotLab
107
107
 
108
108
  # Applies LLM fields to a chat object via its with_* methods.
109
109
  #
110
+ # +provider+/+assume_model_exists+ are threaded through to +with_model+
111
+ # specifically (RunConfig itself has no provider field -- provider lives
112
+ # on the Robot). Without this, re-applying a RunConfig after the chat's
113
+ # provider/model were already set via Robot#initialize (e.g. during
114
+ # template front-matter merging) drops that context on the floor and
115
+ # `with_model` falls back to the static model registry lookup, which
116
+ # raises ModelNotFoundError for any local-provider model (Ollama, etc.)
117
+ # not in RubyLLM's bundled registry.
118
+ #
110
119
  # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
111
- def apply_to(chat)
120
+ # @param provider [String, Symbol, nil] passed through to chat.with_model's provider: kwarg
121
+ # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_exists: kwarg
122
+ def apply_to(chat, provider: nil, assume_model_exists: false)
112
123
  LLM_FIELDS.each do |field|
113
124
  value = @fields[field]
114
125
  next unless value
115
126
 
116
- method = :"with_#{field}"
117
- chat.public_send(method, value) if chat.respond_to?(method)
127
+ if field == :model && provider
128
+ # Only take the provider-aware path when a provider was actually
129
+ # given -- preserves the original single-arg call (and thus
130
+ # compatibility with any chat-like object exposing only
131
+ # `with_model(value)`) for the common case.
132
+ chat.with_model(value, provider:, assume_exists: assume_model_exists) if chat.respond_to?(:with_model)
133
+ else
134
+ method = :"with_#{field}"
135
+ chat.public_send(method, value) if chat.respond_to?(method)
136
+ end
118
137
  end
119
138
  end
120
139
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Shared interface for things you can run with a message: a single {Robot} or a
5
+ # multi-robot {Network}. It lets callers treat them uniformly instead of
6
+ # branching on `is_a?(RobotLab::Network)` — which otherwise spreads concrete
7
+ # type knowledge across every consumer.
8
+ #
9
+ # Implementers provide:
10
+ # - #run(message = nil, **opts) — execute with the given message (both Robot
11
+ # and Network accept a positional message and keyword options)
12
+ # - #crew — the constituent robots, as an Array
13
+ # - #network? — true for a multi-robot network
14
+ #
15
+ # The rest (chief, robot_count, single?) derive from those.
16
+ #
17
+ # @example treat either uniformly
18
+ # runnable.run(prompt, mcp: :inherit, tools: :inherit)
19
+ # names = runnable.crew.map(&:name)
20
+ # show_network(runnable) if runnable.network?
21
+ module Runnable
22
+ # The constituent robots as an Array. A single Robot is a crew of one.
23
+ #
24
+ # @return [Array<Robot>]
25
+ def crew
26
+ raise NotImplementedError, "#{self.class} must implement #crew"
27
+ end
28
+
29
+ # The lead robot — the chief of the crew.
30
+ #
31
+ # @return [Robot, nil]
32
+ def chief
33
+ crew.first
34
+ end
35
+
36
+ # @return [Integer] number of constituent robots
37
+ def robot_count
38
+ crew.size
39
+ end
40
+
41
+ # @return [Boolean] true for a multi-robot network
42
+ def network?
43
+ false
44
+ end
45
+
46
+ # @return [Boolean] true for a single robot
47
+ def single?
48
+ !network?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Sandbox
5
+ # Passthrough strategy: runs the command with no confinement. Used when
6
+ # sandboxing is unavailable (non-macOS) or unnecessary (trust: core).
7
+ class Null
8
+ def wrap(cmd) = cmd
9
+
10
+ def cleanup; end
11
+ end
12
+ end
13
+ end