robot_lab 0.2.1 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +5 -167
- data/CHANGELOG.md +54 -0
- data/README.md +61 -0
- data/Rakefile +28 -12
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/getting-started/configuration.md +20 -1
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +2 -2
- data/docs/guides/observability.md +74 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +1 -1
- data/examples/03_network.rb +11 -2
- data/examples/34_agentskills.rb +0 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +7 -0
- data/examples/common.rb +7 -3
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +5 -4
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +4 -4
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/connection_poller.rb +2 -2
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +76 -12
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +8 -2
- data/lib/robot_lab/robot/template_rendering.rb +2 -2
- data/lib/robot_lab/robot.rb +211 -88
- data/lib/robot_lab/run_config.rb +24 -5
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +60 -2
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +52 -11
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +49 -0
- data/mkdocs.yml +1 -0
- metadata +36 -99
- data/examples/temp.md +0 -51
- data/site/404.html +0 -2300
- data/site/api/core/index.html +0 -2706
- data/site/api/core/memory/index.html +0 -3793
- data/site/api/core/network/index.html +0 -3500
- data/site/api/core/robot/index.html +0 -4566
- data/site/api/core/state/index.html +0 -3390
- data/site/api/core/tool/index.html +0 -3843
- data/site/api/index.html +0 -2635
- data/site/api/mcp/client/index.html +0 -3435
- data/site/api/mcp/index.html +0 -2783
- data/site/api/mcp/server/index.html +0 -3252
- data/site/api/mcp/transports/index.html +0 -3352
- data/site/api/messages/index.html +0 -2641
- data/site/api/messages/text-message/index.html +0 -3087
- data/site/api/messages/tool-call-message/index.html +0 -3159
- data/site/api/messages/tool-result-message/index.html +0 -3252
- data/site/api/messages/user-message/index.html +0 -3212
- data/site/api/streaming/context/index.html +0 -3282
- data/site/api/streaming/events/index.html +0 -3347
- data/site/api/streaming/index.html +0 -2738
- data/site/architecture/core-concepts/index.html +0 -3757
- data/site/architecture/index.html +0 -2797
- data/site/architecture/message-flow/index.html +0 -3238
- data/site/architecture/network-orchestration/index.html +0 -3433
- data/site/architecture/robot-execution/index.html +0 -3140
- data/site/architecture/state-management/index.html +0 -3498
- data/site/assets/css/custom.css +0 -56
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
- data/site/assets/javascripts/lunr/tinyseg.js +0 -206
- data/site/assets/javascripts/lunr/wordcut.js +0 -6708
- data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
- data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
- data/site/concepts/index.html +0 -3455
- data/site/examples/basic-chat/index.html +0 -2880
- data/site/examples/index.html +0 -2907
- data/site/examples/mcp-server/index.html +0 -3018
- data/site/examples/multi-robot-network/index.html +0 -3131
- data/site/examples/rails-application/index.html +0 -3329
- data/site/examples/tool-usage/index.html +0 -3085
- data/site/getting-started/configuration/index.html +0 -3745
- data/site/getting-started/index.html +0 -2572
- data/site/getting-started/installation/index.html +0 -2981
- data/site/getting-started/quick-start/index.html +0 -2942
- data/site/guides/building-robots/index.html +0 -4290
- data/site/guides/creating-networks/index.html +0 -3858
- data/site/guides/index.html +0 -2586
- data/site/guides/mcp-integration/index.html +0 -3581
- data/site/guides/memory/index.html +0 -3586
- data/site/guides/rails-integration/index.html +0 -4019
- data/site/guides/streaming/index.html +0 -3157
- data/site/guides/using-tools/index.html +0 -3802
- data/site/index.html +0 -2671
- data/site/search/search_index.json +0 -1
- data/site/sitemap.xml +0 -183
- data/site/sitemap.xml.gz +0 -0
- data/site/tags.json +0 -1
data/lib/robot_lab/robot.rb
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/robot_lab/run_config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|