robot_lab 0.2.1 → 0.2.7
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 +185 -64
- data/Rakefile +28 -12
- data/docs/api/core/index.md +41 -14
- data/docs/api/core/memory.md +247 -29
- data/docs/api/core/network.md +285 -33
- data/docs/api/core/result.md +120 -32
- data/docs/api/core/robot.md +636 -60
- data/docs/api/core/state.md +87 -197
- data/docs/api/core/tool.md +165 -20
- data/docs/api/errors.md +152 -14
- data/docs/api/hooks.md +469 -0
- data/docs/api/index.md +83 -7
- data/docs/api/mcp/client.md +129 -35
- data/docs/api/mcp/index.md +164 -23
- data/docs/api/mcp/server.md +27 -3
- data/docs/api/mcp/transports.md +94 -22
- data/docs/api/messages/index.md +26 -3
- data/docs/api/messages/text-message.md +33 -11
- data/docs/api/messages/tool-call-message.md +27 -4
- data/docs/api/messages/tool-result-message.md +23 -4
- data/docs/api/messages/user-message.md +45 -8
- data/docs/api/skills.md +519 -0
- data/docs/api/streaming/context.md +28 -5
- data/docs/api/streaming/index.md +57 -11
- data/docs/api/support.md +846 -0
- data/docs/architecture/core-concepts.md +118 -30
- data/docs/architecture/index.md +86 -11
- data/docs/architecture/message-flow.md +66 -29
- data/docs/architecture/network-orchestration.md +153 -38
- data/docs/architecture/robot-execution.md +173 -91
- data/docs/architecture/state-management.md +31 -12
- data/docs/concepts.md +176 -21
- data/docs/examples/basic-chat.md +72 -19
- data/docs/examples/index.md +117 -31
- data/docs/examples/mcp-server.md +154 -45
- data/docs/examples/multi-robot-network.md +91 -21
- data/docs/examples/tool-usage.md +104 -37
- data/docs/getting-started/configuration.md +299 -93
- data/docs/getting-started/installation.md +53 -41
- data/docs/getting-started/quick-start.md +51 -6
- data/docs/guides/building-robots.md +296 -50
- data/docs/guides/creating-networks.md +220 -31
- data/docs/guides/hooks.md +1084 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/knowledge.md +37 -6
- data/docs/guides/mcp-integration.md +211 -44
- data/docs/guides/memory.md +103 -12
- data/docs/guides/observability.md +166 -51
- data/docs/guides/streaming.md +184 -125
- data/docs/guides/using-tools.md +300 -11
- data/docs/index.md +37 -5
- data/examples/01_simple_robot.rb +2 -2
- data/examples/02_tools.rb +14 -4
- data/examples/03_network.rb +23 -9
- data/examples/04_mcp.rb +11 -4
- data/examples/05_streaming.rb +8 -5
- data/examples/06_prompt_templates.rb +13 -9
- data/examples/07_network_memory.rb +5 -5
- data/examples/08_llm_config.rb +20 -15
- data/examples/09_chaining.rb +4 -4
- data/examples/11_network_introspection.rb +4 -4
- data/examples/12_message_bus.rb +2 -2
- data/examples/13_spawn.rb +2 -2
- data/examples/14_rusty_circuit/README.md +1 -0
- data/examples/14_rusty_circuit/comic.rb +7 -3
- data/examples/14_rusty_circuit/display.rb +14 -0
- data/examples/14_rusty_circuit/heckler.rb +8 -6
- data/examples/14_rusty_circuit/open_mic.rb +17 -6
- data/examples/14_rusty_circuit/scout.rb +17 -10
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
- data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
- data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
- data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
- data/examples/16_writers_room/writer.rb +22 -22
- data/examples/16_writers_room/writers_room.rb +2 -0
- data/examples/17_skills.rb +14 -13
- data/examples/18_rails/README.md +20 -1
- data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
- data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
- data/examples/18_rails/app/robots/chat_robot.rb +11 -0
- data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
- data/examples/19_token_tracking.rb +25 -9
- data/examples/20_circuit_breaker.rb +10 -7
- data/examples/21_learning_loop.rb +42 -16
- data/examples/22_context_compression.rb +23 -23
- data/examples/23_convergence.rb +24 -17
- data/examples/24_structured_delegation.rb +13 -8
- data/examples/25_history_search.rb +12 -8
- data/examples/27_incident_response/incident_response.rb +31 -13
- data/examples/28_mcp_discovery.rb +17 -13
- data/examples/29_ractor_tools.rb +4 -2
- data/examples/30_ractor_network.rb +22 -17
- data/examples/31_launch_assessment.rb +20 -9
- data/examples/32_newsletter_reader.rb +7 -2
- data/examples/33_stock_predictor.rb +34 -13
- data/examples/34_agentskills.rb +7 -3
- data/examples/35_hooks.rb +266 -0
- data/examples/README.md +203 -42
- data/examples/common.rb +79 -7
- data/examples/xyzzy.rb +97 -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 +14 -9
- 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/names.rb +402 -0
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +77 -20
- data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +90 -18
- 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 +18 -5
- data/lib/robot_lab/robot.rb +233 -89
- 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 +70 -15
- data/mkdocs.yml +7 -1
- metadata +41 -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]
|
|
528
513
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
522
|
+
|
|
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
|
|
|
@@ -666,8 +666,27 @@ module RobotLab
|
|
|
666
666
|
end
|
|
667
667
|
end
|
|
668
668
|
|
|
669
|
+
# Append the inline system prompt to whatever apply_template already set.
|
|
670
|
+
#
|
|
671
|
+
# The template renders first and establishes the base instructions; the
|
|
672
|
+
# inline system_prompt is appended so both survive. Combining into a single
|
|
673
|
+
# system message (rather than using with_instructions(append: true), which
|
|
674
|
+
# adds a second one) keeps readers that look at only the first system
|
|
675
|
+
# message -- notably AgentSkillMatching's snapshot/restore -- correct.
|
|
669
676
|
def apply_system_prompt
|
|
670
|
-
|
|
677
|
+
return unless @system_prompt
|
|
678
|
+
|
|
679
|
+
base = current_system_instructions.to_s
|
|
680
|
+
combined = [base, @system_prompt].reject(&:empty?).join("\n\n")
|
|
681
|
+
@chat.with_instructions(combined)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# Content of the chat's system message, or nil when none is set.
|
|
685
|
+
#
|
|
686
|
+
# @return [String, nil]
|
|
687
|
+
def current_system_instructions
|
|
688
|
+
messages = @chat.instance_variable_get(:@messages)
|
|
689
|
+
messages&.find { |m| m.role.to_s == "system" }&.content
|
|
671
690
|
end
|
|
672
691
|
|
|
673
692
|
def apply_chat_params
|
|
@@ -725,26 +744,89 @@ module RobotLab
|
|
|
725
744
|
|
|
726
745
|
ensure_mcp_clients(resolved_mcp)
|
|
727
746
|
|
|
728
|
-
|
|
729
|
-
|
|
747
|
+
# An EXPLICIT `:none` (or literal `[]`) means "send zero tools this turn" —
|
|
748
|
+
# e.g. a relevance filter judged no tool useful for the prompt. That is
|
|
749
|
+
# distinct from `:inherit`/unset, which means "no filter, use every attached
|
|
750
|
+
# tool". Both collapse to `[]` once resolved, and `filtered_tools([])`
|
|
751
|
+
# treats an empty allowlist as "all tools", so we must branch on the raw
|
|
752
|
+
# runtime value before resolution to honor the zero-tools intent.
|
|
753
|
+
filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
|
|
754
|
+
|
|
755
|
+
# replace: true so the chat holds EXACTLY this turn's resolved+capped set.
|
|
756
|
+
# RubyLLM's with_tools appends by default; on a persistent chat that lets
|
|
757
|
+
# tools accumulate across turns, so a capped per-turn addition could still
|
|
758
|
+
# push the chat's total past the provider limit. An explicit none clears
|
|
759
|
+
# the chat's tools to zero (with_tools(replace: true) with no tools).
|
|
760
|
+
@chat.with_tools(*filtered, replace: true) if filtered.any? || explicit_none_tools?(tools)
|
|
730
761
|
end
|
|
731
762
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
763
|
+
# True when the runtime tools value explicitly requests zero tools — `:none`
|
|
764
|
+
# or a literal empty array — as opposed to `:inherit`/`nil`, which mean "no
|
|
765
|
+
# filter, use all attached tools". Lets a per-turn filter that found nothing
|
|
766
|
+
# relevant actually send no tools instead of the whole set.
|
|
767
|
+
#
|
|
768
|
+
# @param tools [Symbol, Array, nil]
|
|
769
|
+
# @return [Boolean]
|
|
770
|
+
def explicit_none_tools?(tools)
|
|
771
|
+
tools == :none || (tools.is_a?(Array) && tools.empty?)
|
|
740
772
|
end
|
|
741
773
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
774
|
+
# Clamp the resolved tool list to the provider's hard maximum. Most LLM
|
|
775
|
+
# providers reject tool arrays longer than 128 and fail the whole turn. This
|
|
776
|
+
# is the definitive choke point: tools are fully resolved (MCP connected)
|
|
777
|
+
# and about to be handed to the chat, so the cap holds no matter how the
|
|
778
|
+
# tools were configured, filtered, or connected. Cap value comes from
|
|
779
|
+
# RunConfig#max_tools, defaulting to DEFAULT_MAX_TOOLS; a nil, zero, or
|
|
780
|
+
# negative max_tools falls back to that default, so the cap cannot be
|
|
781
|
+
# disabled from configuration.
|
|
782
|
+
#
|
|
783
|
+
# @param tools [Array<Tool>] the resolved tools
|
|
784
|
+
# @return [Array<Tool>] at most `max_tools` tools
|
|
785
|
+
def cap_tools(tools)
|
|
786
|
+
max = effective_max_tools
|
|
787
|
+
return tools if max.nil? || tools.size <= max
|
|
788
|
+
|
|
789
|
+
RobotLab.config.logger.warn(
|
|
790
|
+
"[#{@name}] tool list (#{tools.size}) exceeds max_tools (#{max}); " \
|
|
791
|
+
"sending #{max}, dropping #{tools.size - max}"
|
|
792
|
+
)
|
|
793
|
+
tools.first(max)
|
|
794
|
+
end
|
|
745
795
|
|
|
746
|
-
|
|
747
|
-
|
|
796
|
+
# The provider tool cap: RunConfig#max_tools when set positive, else the
|
|
797
|
+
# default. Returns nil only if the default itself is disabled.
|
|
798
|
+
#
|
|
799
|
+
# @return [Integer, nil]
|
|
800
|
+
def effective_max_tools
|
|
801
|
+
configured = @config.respond_to?(:max_tools) ? @config.max_tools : nil
|
|
802
|
+
configured&.positive? ? configured : DEFAULT_MAX_TOOLS
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def invoke_ask(context:, kwargs:, hooks:, block:)
|
|
806
|
+
generation_context = LlmGenerationHookContext.new(
|
|
807
|
+
robot: self,
|
|
808
|
+
network: context.network,
|
|
809
|
+
task: context.task,
|
|
810
|
+
memory: context.memory,
|
|
811
|
+
config: context.config,
|
|
812
|
+
request: context.request,
|
|
813
|
+
metadata: context.metadata
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
RobotLab::Hooks.run(:llm_generation, generation_context,
|
|
817
|
+
registries: hook_registries(context.network), per_run_hooks: hooks) do
|
|
818
|
+
effective_message = inject_learnings(generation_context.request)
|
|
819
|
+
maybe_compact(network: context.network)
|
|
820
|
+
install_circuit_breaker if @config.max_tool_rounds
|
|
821
|
+
install_doom_loop_detection
|
|
822
|
+
ask_kwargs = kwargs.slice(:with)
|
|
823
|
+
streaming = effective_streaming_block(block)
|
|
824
|
+
ask(effective_message, **ask_kwargs, &streaming)
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def hook_registries(network = nil)
|
|
829
|
+
[RobotLab.hooks, network&.hooks, @hooks]
|
|
748
830
|
end
|
|
749
831
|
|
|
750
832
|
# Extract run context from SimpleFlow::Result
|
|
@@ -757,6 +839,8 @@ module RobotLab
|
|
|
757
839
|
memory = run_params.delete(:memory)
|
|
758
840
|
network_memory = run_params.delete(:network_memory)
|
|
759
841
|
network_config = run_params.delete(:network_config)
|
|
842
|
+
network = run_params.delete(:network)
|
|
843
|
+
task = run_params.delete(:task)
|
|
760
844
|
|
|
761
845
|
# Build base context from remaining run params
|
|
762
846
|
base = run_params.dup
|
|
@@ -779,16 +863,15 @@ module RobotLab
|
|
|
779
863
|
merged[:memory] = memory if memory
|
|
780
864
|
merged[:network_memory] = network_memory if network_memory
|
|
781
865
|
merged[:network_config] = network_config if network_config
|
|
866
|
+
merged[:network] = network if network
|
|
867
|
+
merged[:task] = task if task
|
|
782
868
|
|
|
783
869
|
merged
|
|
784
870
|
end
|
|
785
871
|
|
|
786
872
|
def build_result(response, _memory)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
else
|
|
790
|
-
[]
|
|
791
|
-
end
|
|
873
|
+
text = result_text(response)
|
|
874
|
+
output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
|
|
792
875
|
|
|
793
876
|
tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
|
|
794
877
|
|
|
@@ -817,6 +900,37 @@ module RobotLab
|
|
|
817
900
|
)
|
|
818
901
|
end
|
|
819
902
|
|
|
903
|
+
# Text for the result's output. Prefers the final response's content, then
|
|
904
|
+
# falls back in order to: (1) thinking text for models that route all output
|
|
905
|
+
# through reasoning_content (e.g. qwen3 on Ollama), (2) the most recent
|
|
906
|
+
# assistant text within the current turn for models that end on a tool call
|
|
907
|
+
# with no trailing text.
|
|
908
|
+
#
|
|
909
|
+
# The chat-history fallback is scoped to messages AFTER the last user message
|
|
910
|
+
# (the current turn) to prevent a previous turn's response from being returned
|
|
911
|
+
# when a thinking-mode model emits nothing in response.content.
|
|
912
|
+
def result_text(response)
|
|
913
|
+
content = response.content if response.respond_to?(:content)
|
|
914
|
+
return content if content && !content.to_s.empty?
|
|
915
|
+
|
|
916
|
+
# Ollama routes qwen3's reasoning to reasoning_content, which ruby_llm
|
|
917
|
+
# surfaces as response.thinking (a RubyLLM::Thinking object). When content
|
|
918
|
+
# is nil and thinking is present, the thinking IS the response for that turn.
|
|
919
|
+
if response.respond_to?(:thinking) && (thinking = response.thinking)
|
|
920
|
+
thinking_text = thinking.respond_to?(:text) ? thinking.text.to_s : thinking.to_s
|
|
921
|
+
return thinking_text unless thinking_text.empty?
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
return nil unless @chat.respond_to?(:messages)
|
|
925
|
+
|
|
926
|
+
messages = @chat.messages
|
|
927
|
+
last_user_idx = messages.rindex { |m| m.role == :user } || -1
|
|
928
|
+
current_turn = messages[(last_user_idx + 1)..]
|
|
929
|
+
|
|
930
|
+
last = current_turn.rfind { |m| m.role == :assistant && m.content && !m.content.to_s.empty? }
|
|
931
|
+
last&.content
|
|
932
|
+
end
|
|
933
|
+
|
|
820
934
|
def normalize_tool_calls(tool_calls)
|
|
821
935
|
return [] unless tool_calls
|
|
822
936
|
|
|
@@ -907,21 +1021,47 @@ module RobotLab
|
|
|
907
1021
|
# :context_window — compress when estimated tokens exceed compact_threshold
|
|
908
1022
|
# fraction of the model's context window (default 80%)
|
|
909
1023
|
# Proc — called with self; application owns the decision and strategy
|
|
910
|
-
|
|
1024
|
+
#
|
|
1025
|
+
# Fires the :compaction hook family (before/around/after_compaction).
|
|
1026
|
+
# An on_compaction handler can replace the default strategy entirely by
|
|
1027
|
+
# setting ctx.compacted_messages; the core algorithm is skipped when handled.
|
|
1028
|
+
def maybe_compact(network: nil)
|
|
911
1029
|
return if @chat.messages.empty?
|
|
912
1030
|
|
|
913
1031
|
compact = @config.auto_compact
|
|
914
1032
|
return if compact.nil? || compact == :none
|
|
1033
|
+
return if compact == :context_window && !over_compact_threshold?
|
|
1034
|
+
|
|
1035
|
+
strategy = compact.is_a?(Proc) ? :custom : compact
|
|
1036
|
+
ctx = CompactionHookContext.new(
|
|
1037
|
+
robot: self,
|
|
1038
|
+
messages_before: @chat.messages.dup,
|
|
1039
|
+
config: @config,
|
|
1040
|
+
strategy: strategy
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
RobotLab::Hooks.run(:compaction, ctx, registries: hook_registries(network)) do
|
|
1044
|
+
RobotLab::Hooks.call(:on_compaction, ctx, registries: hook_registries(network))
|
|
915
1045
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1046
|
+
if ctx.handled?
|
|
1047
|
+
replace_messages(ctx.compacted_messages)
|
|
1048
|
+
else
|
|
1049
|
+
case compact
|
|
1050
|
+
when :context_window
|
|
1051
|
+
begin
|
|
1052
|
+
compress_history
|
|
1053
|
+
rescue DependencyError => e
|
|
1054
|
+
RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
|
|
1055
|
+
end
|
|
1056
|
+
when Proc
|
|
1057
|
+
compact.call(self)
|
|
1058
|
+
end
|
|
1059
|
+
ctx.compacted_messages = @chat.messages.dup
|
|
1060
|
+
end
|
|
921
1061
|
end
|
|
922
1062
|
end
|
|
923
1063
|
|
|
924
|
-
def
|
|
1064
|
+
def over_compact_threshold?
|
|
925
1065
|
threshold = (@config.compact_threshold || 0.80).to_f
|
|
926
1066
|
estimated_tok = @chat.messages.sum { |m| m.content.to_s.length } / 4
|
|
927
1067
|
|
|
@@ -931,7 +1071,11 @@ module RobotLab
|
|
|
931
1071
|
200_000
|
|
932
1072
|
end
|
|
933
1073
|
|
|
934
|
-
|
|
1074
|
+
estimated_tok >= window * threshold
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def compact_if_over_context_window
|
|
1078
|
+
return unless over_compact_threshold?
|
|
935
1079
|
|
|
936
1080
|
begin
|
|
937
1081
|
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
|