robot_lab 0.1.0 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.architecture/AGENTS.md +32 -0
- data/.architecture/config.yml +8 -0
- data/.architecture/members.yml +60 -0
- data/.architecture/reviews/feature-free-will.md +490 -0
- data/.architecture/reviews/overall-codebase.md +427 -0
- data/.claude/settings.local.json +57 -0
- data/.codex/config.toml +2 -0
- data/.envrc +1 -0
- data/.irbrc +2 -2
- data/.loki +60 -0
- data/.quality/reek_baseline.txt +43 -0
- data/.rubocop.yml +10 -0
- data/CHANGELOG.md +126 -0
- data/CLAUDE.md +139 -0
- data/README.md +130 -73
- data/Rakefile +131 -9
- data/agent2agent_review.md +192 -0
- data/agentf_improvements.md +253 -0
- data/agents.md +14 -0
- data/docs/api/core/index.md +1 -0
- data/docs/api/core/network.md +31 -1
- data/docs/api/core/robot.md +91 -5
- data/docs/api/errors.md +47 -2
- data/docs/api/index.md +3 -0
- data/docs/architecture/core-concepts.md +40 -0
- data/docs/architecture/network-orchestration.md +8 -0
- data/docs/architecture/robot-execution.md +1 -1
- data/docs/examples/index.md +37 -2
- data/docs/getting-started/configuration.md +39 -7
- data/docs/guides/building-robots.md +39 -1
- data/docs/guides/creating-networks.md +6 -1
- data/docs/guides/hooks.md +997 -0
- data/docs/guides/index.md +19 -14
- data/docs/guides/knowledge.md +9 -3
- data/docs/guides/observability.md +206 -7
- data/docs/guides/using-tools.md +69 -0
- data/docs/index.md +31 -4
- data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
- data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
- data/examples/.envrc +1 -0
- data/examples/01_simple_robot.rb +5 -9
- data/examples/02_tools.rb +5 -9
- data/examples/03_network.rb +18 -10
- data/examples/04_mcp.rb +21 -29
- data/examples/05_streaming.rb +12 -18
- data/examples/06_prompt_templates.rb +11 -19
- data/examples/07_network_memory.rb +16 -31
- data/examples/08_llm_config.rb +10 -22
- data/examples/09_chaining.rb +16 -27
- data/examples/10_memory.rb +12 -28
- data/examples/11_network_introspection.rb +15 -29
- data/examples/12_message_bus.rb +5 -12
- data/examples/13_spawn.rb +5 -10
- data/examples/14_rusty_circuit/.envrc +1 -0
- data/examples/14_rusty_circuit/comic.rb +2 -0
- data/examples/14_rusty_circuit/heckler.rb +1 -1
- data/examples/14_rusty_circuit/open_mic.rb +1 -3
- data/examples/14_rusty_circuit/scout.rb +2 -0
- data/examples/15_memory_network_and_bus/.envrc +1 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
- data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
- data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
- data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
- data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
- data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/.envrc +1 -0
- data/examples/16_writers_room/writers_room.rb +2 -4
- data/examples/17_skills.rb +8 -17
- data/examples/18_rails/Gemfile +1 -0
- data/examples/19_token_tracking.rb +9 -15
- data/examples/20_circuit_breaker.rb +10 -19
- data/examples/21_learning_loop.rb +11 -20
- data/examples/22_context_compression.rb +6 -13
- data/examples/23_convergence.rb +6 -17
- data/examples/24_structured_delegation.rb +11 -15
- data/examples/25_history_search.rb +5 -12
- data/examples/26_document_store.rb +6 -13
- data/examples/27_incident_response/incident_response.rb +4 -5
- data/examples/28_mcp_discovery.rb +8 -11
- data/examples/29_ractor_tools.rb +4 -9
- data/examples/30_ractor_network.rb +10 -19
- data/examples/31_launch_assessment.rb +10 -23
- data/examples/32_newsletter_reader.rb +188 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +306 -0
- data/examples/34_agentskills.rb +72 -0
- data/examples/35_hooks.rb +256 -0
- data/examples/README.md +8 -1
- data/examples/common.rb +80 -0
- data/examples/ruboruby.md +423 -0
- data/examples/xyzzy.rb +90 -0
- data/lib/robot_lab/agent_skill.rb +64 -0
- data/lib/robot_lab/agent_skill_catalog.rb +74 -0
- data/lib/robot_lab/ask_user.rb +2 -2
- data/lib/robot_lab/budget/ledger.rb +98 -0
- data/lib/robot_lab/bus_poller.rb +12 -5
- data/lib/robot_lab/capabilities.rb +84 -0
- data/lib/robot_lab/config/defaults.yml +10 -0
- data/lib/robot_lab/config.rb +5 -16
- data/lib/robot_lab/delegation_future.rb +1 -1
- data/lib/robot_lab/doom_loop_detector.rb +98 -0
- data/lib/robot_lab/error.rb +34 -4
- data/lib/robot_lab/errors.rb +45 -0
- data/lib/robot_lab/history_compressor.rb +4 -10
- data/lib/robot_lab/hook.rb +79 -0
- data/lib/robot_lab/hook_context.rb +194 -0
- data/lib/robot_lab/hook_registry.rb +55 -0
- data/lib/robot_lab/hooks.rb +87 -0
- data/lib/robot_lab/mcp/client.rb +1 -2
- data/lib/robot_lab/mcp/connection_poller.rb +5 -5
- data/lib/robot_lab/mcp/server.rb +1 -1
- data/lib/robot_lab/mcp/server_discovery.rb +0 -2
- data/lib/robot_lab/memory.rb +32 -27
- data/lib/robot_lab/memory_change.rb +2 -2
- data/lib/robot_lab/message.rb +4 -4
- data/lib/robot_lab/narrator.rb +87 -0
- data/lib/robot_lab/network.rb +87 -25
- data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
- data/lib/robot_lab/robot/budget.rb +89 -0
- data/lib/robot_lab/robot/bus_messaging.rb +79 -33
- data/lib/robot_lab/robot/history_search.rb +4 -1
- data/lib/robot_lab/robot/hooking.rb +56 -0
- data/lib/robot_lab/robot/mcp_management.rb +12 -12
- data/lib/robot_lab/robot/template_rendering.rb +62 -42
- data/lib/robot_lab/robot.rb +482 -242
- data/lib/robot_lab/robot_result.rb +6 -5
- data/lib/robot_lab/run_config.rb +27 -14
- data/lib/robot_lab/runnable.rb +51 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
- data/lib/robot_lab/sandbox.rb +52 -0
- data/lib/robot_lab/script_tool.rb +134 -0
- data/lib/robot_lab/state_proxy.rb +7 -5
- data/lib/robot_lab/task.rb +26 -20
- data/lib/robot_lab/tool.rb +54 -13
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +5 -7
- data/lib/robot_lab/user_message.rb +2 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +1 -1
- data/lib/robot_lab.rb +89 -51
- data/logfile +8 -0
- data/mkdocs.yml +3 -3
- data/robot_concurrency.md +38 -0
- data/simple_acp_review.md +298 -0
- data/temp.md +6 -0
- data/tool_manifest_plan.md +155 -0
- metadata +82 -83
- data/docs/examples/rails-application.md +0 -419
- data/docs/guides/ractor-parallelism.md +0 -364
- data/docs/guides/rails-integration.md +0 -681
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
- data/lib/generators/robot_lab/install_generator.rb +0 -90
- data/lib/generators/robot_lab/job_generator.rb +0 -40
- data/lib/generators/robot_lab/robot_generator.rb +0 -55
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
- data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
- data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
- data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
- data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
- data/lib/robot_lab/document_store.rb +0 -155
- data/lib/robot_lab/ractor_boundary.rb +0 -42
- data/lib/robot_lab/ractor_job.rb +0 -37
- data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
- data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
- data/lib/robot_lab/ractor_worker_pool.rb +0 -117
- data/lib/robot_lab/rails_integration/engine.rb +0 -29
- data/lib/robot_lab/rails_integration/job.rb +0 -158
- data/lib/robot_lab/rails_integration/railtie.rb +0 -51
- data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
data/lib/robot_lab/robot.rb
CHANGED
|
@@ -4,6 +4,9 @@ require_relative 'robot/template_rendering'
|
|
|
4
4
|
require_relative 'robot/mcp_management'
|
|
5
5
|
require_relative 'robot/bus_messaging'
|
|
6
6
|
require_relative 'robot/history_search'
|
|
7
|
+
require_relative 'robot/agent_skill_matching'
|
|
8
|
+
require_relative 'robot/budget'
|
|
9
|
+
require_relative 'robot/hooking'
|
|
7
10
|
|
|
8
11
|
module RobotLab
|
|
9
12
|
# LLM-powered robot built on RubyLLM::Agent
|
|
@@ -43,6 +46,15 @@ module RobotLab
|
|
|
43
46
|
include Robot::MCPManagement
|
|
44
47
|
include Robot::BusMessaging
|
|
45
48
|
include Robot::HistorySearch
|
|
49
|
+
include Robot::Budget
|
|
50
|
+
include Robot::Hooking
|
|
51
|
+
include Runnable
|
|
52
|
+
prepend Robot::AgentSkillMatching
|
|
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
|
|
46
58
|
|
|
47
59
|
# @!attribute [r] name
|
|
48
60
|
# @return [String] the unique identifier for the robot
|
|
@@ -68,8 +80,9 @@ module RobotLab
|
|
|
68
80
|
|
|
69
81
|
attr_reader :name, :description, :template, :system_prompt,
|
|
70
82
|
:local_tools, :mcp_clients, :mcp_tools, :memory,
|
|
71
|
-
:bus, :outbox, :config, :skills, :provider,
|
|
72
|
-
:total_input_tokens, :total_output_tokens, :learnings
|
|
83
|
+
:bus, :outbox, :config, :skills, :provider, :hooks,
|
|
84
|
+
:total_input_tokens, :total_output_tokens, :learnings,
|
|
85
|
+
:budget_ledger
|
|
73
86
|
|
|
74
87
|
# @!attribute [r] mcp_config
|
|
75
88
|
# @return [Symbol, Array] build-time MCP configuration (raw, unresolved)
|
|
@@ -77,6 +90,45 @@ module RobotLab
|
|
|
77
90
|
# @return [Symbol, Array] build-time tools configuration (raw, unresolved)
|
|
78
91
|
attr_reader :mcp_config, :tools_config
|
|
79
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
|
+
|
|
101
|
+
# Returns the fully-merged configuration for this robot at runtime.
|
|
102
|
+
#
|
|
103
|
+
# Reflects the result of merging the RunConfig hierarchy (global → network →
|
|
104
|
+
# constructor kwargs → template front matter). Nil fields are omitted.
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] merged config keyed by field name
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# robot.effective_config
|
|
110
|
+
# #=> { model: "claude-sonnet-4-6", temperature: 0.7, max_tokens: 4096 }
|
|
111
|
+
def effective_config
|
|
112
|
+
{
|
|
113
|
+
model: @config.model,
|
|
114
|
+
temperature: @config.temperature,
|
|
115
|
+
top_p: @config.top_p,
|
|
116
|
+
top_k: @config.top_k,
|
|
117
|
+
max_tokens: @config.max_tokens,
|
|
118
|
+
presence_penalty: @config.presence_penalty,
|
|
119
|
+
frequency_penalty: @config.frequency_penalty,
|
|
120
|
+
stop: @config.stop,
|
|
121
|
+
tools: @config.tools,
|
|
122
|
+
mcp: @config.mcp,
|
|
123
|
+
max_tool_rounds: @config.max_tool_rounds,
|
|
124
|
+
doom_loop_threshold: @config.doom_loop_threshold,
|
|
125
|
+
auto_compact: @config.auto_compact,
|
|
126
|
+
compact_threshold: @config.compact_threshold,
|
|
127
|
+
token_budget: @config.token_budget,
|
|
128
|
+
cost_budget: @config.cost_budget
|
|
129
|
+
}.compact
|
|
130
|
+
end
|
|
131
|
+
|
|
80
132
|
# Creates a new Robot instance.
|
|
81
133
|
#
|
|
82
134
|
# @param name [String] the unique identifier for the robot
|
|
@@ -130,141 +182,51 @@ module RobotLab
|
|
|
130
182
|
stop: nil,
|
|
131
183
|
max_tool_rounds: nil,
|
|
132
184
|
token_budget: nil,
|
|
185
|
+
cost_budget: nil,
|
|
186
|
+
doom_loop_threshold: nil,
|
|
133
187
|
mcp_discovery: false,
|
|
134
188
|
config: nil
|
|
135
189
|
)
|
|
136
|
-
|
|
137
|
-
@name_from_constructor = (name.to_s != "robot")
|
|
138
|
-
@template = template
|
|
139
|
-
@system_prompt = system_prompt
|
|
140
|
-
@build_context = context
|
|
141
|
-
@description = description
|
|
142
|
-
@local_tools = Array(local_tools)
|
|
143
|
-
@skills = skills ? Array(skills).map(&:to_sym) : nil
|
|
144
|
-
@expanded_skills = nil
|
|
145
|
-
@mcp_discovery = mcp_discovery
|
|
190
|
+
validate_tools_filter!(tools)
|
|
146
191
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
assign_identity_ivars(name: name, template: template, system_prompt: system_prompt,
|
|
193
|
+
context: context, description: description, local_tools: local_tools,
|
|
194
|
+
skills: skills, mcp_discovery: mcp_discovery)
|
|
195
|
+
|
|
196
|
+
build_effective_config(
|
|
150
197
|
model: model, temperature: temperature, top_p: top_p, top_k: top_k,
|
|
151
198
|
max_tokens: max_tokens, presence_penalty: presence_penalty,
|
|
152
199
|
frequency_penalty: frequency_penalty, stop: stop,
|
|
153
200
|
on_tool_call: on_tool_call, on_tool_result: on_tool_result,
|
|
154
201
|
on_content: on_content, bus: bus, enable_cache: enable_cache,
|
|
155
|
-
max_tool_rounds: max_tool_rounds, token_budget: token_budget
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
|
|
160
|
-
explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
|
|
161
|
-
explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
|
|
162
|
-
|
|
163
|
-
explicit_config = RunConfig.new(**explicit_fields)
|
|
164
|
-
@config = config ? config.merge(explicit_config) : explicit_config
|
|
165
|
-
|
|
166
|
-
# Extract values from effective config for backward compatibility
|
|
167
|
-
@on_tool_call = @config.on_tool_call
|
|
168
|
-
@on_tool_result = @config.on_tool_result
|
|
169
|
-
@on_content = @config.on_content
|
|
170
|
-
|
|
171
|
-
# Store raw config values for hierarchical resolution
|
|
172
|
-
@mcp_config = @config.mcp || :none
|
|
173
|
-
@tools_config = @config.tools || :none
|
|
174
|
-
|
|
175
|
-
# MCP state
|
|
176
|
-
@mcp_clients = {}
|
|
177
|
-
@mcp_tools = []
|
|
178
|
-
@mcp_initialized = false
|
|
179
|
-
|
|
180
|
-
# Bus state (optional inter-robot communication)
|
|
181
|
-
@bus = @config.bus
|
|
182
|
-
@message_counter = 0
|
|
183
|
-
@outbox = {}
|
|
184
|
-
@message_handler = nil
|
|
185
|
-
@bus_poller = nil
|
|
186
|
-
@private_bus_poller = nil
|
|
187
|
-
@bus_poller_group = :default
|
|
188
|
-
|
|
189
|
-
# Token tracking
|
|
190
|
-
@total_input_tokens = 0
|
|
191
|
-
@total_output_tokens = 0
|
|
192
|
-
|
|
193
|
-
# Learning accumulation
|
|
194
|
-
@learnings = []
|
|
195
|
-
|
|
196
|
-
# Inherent memory (used when standalone, not in a network)
|
|
197
|
-
cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
|
|
198
|
-
@memory = Memory.new(enable_cache: cache_enabled)
|
|
199
|
-
|
|
200
|
-
# Restore persisted learnings from inherent memory if present
|
|
201
|
-
persisted = @memory.get(:learnings)
|
|
202
|
-
@learnings = Array(persisted) if persisted
|
|
202
|
+
max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
|
|
203
|
+
doom_loop_threshold: doom_loop_threshold, mcp_servers: mcp_servers,
|
|
204
|
+
mcp: mcp, tools: tools, config: config
|
|
205
|
+
)
|
|
203
206
|
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
extract_config_ivars
|
|
208
|
+
initialize_runtime_state
|
|
209
|
+
initialize_memory
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
resolved_model = @config.model ||
|
|
209
|
-
chat_kwargs
|
|
211
|
+
lab_config = RobotLab.config
|
|
212
|
+
resolved_model = @config.model || lab_config.ruby_llm.model
|
|
213
|
+
chat_kwargs = { model: resolved_model }
|
|
210
214
|
|
|
211
|
-
#
|
|
212
|
-
# RubyLLM auto-sets assume_model_exists for local providers when
|
|
213
|
-
# provider is specified.
|
|
215
|
+
# RubyLLM auto-sets assume_model_exists for local providers when provider is specified.
|
|
214
216
|
@provider = provider
|
|
215
217
|
if @provider
|
|
216
218
|
chat_kwargs[:provider] = @provider
|
|
217
219
|
chat_kwargs[:assume_model_exists] = true
|
|
218
220
|
end
|
|
219
221
|
|
|
220
|
-
# Create the persistent chat via Agent's initialize
|
|
221
222
|
super(chat: nil, **chat_kwargs)
|
|
222
223
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
all_skill_ids = Array(@skills)
|
|
228
|
-
if @template
|
|
229
|
-
parsed_main = PM.parse(@template)
|
|
230
|
-
fm_skills = extract_skills_from_metadata(parsed_main.metadata)
|
|
231
|
-
all_skill_ids = all_skill_ids + fm_skills
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
if all_skill_ids.any?
|
|
235
|
-
# Skills path: expand skills, merge config, concatenate bodies
|
|
236
|
-
apply_skills_and_template_to_chat(all_skill_ids, context)
|
|
237
|
-
elsif @template
|
|
238
|
-
# Standard path: single template, no skills
|
|
239
|
-
apply_template_to_chat(context)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
@chat.with_instructions(@system_prompt) if @system_prompt
|
|
243
|
-
|
|
244
|
-
# Constructor params override template front matter (use config values)
|
|
245
|
-
@chat.with_temperature(@config.temperature) if @config.temperature
|
|
246
|
-
|
|
247
|
-
# These parameters don't have dedicated with_* methods on Chat;
|
|
248
|
-
# pass them through with_params.
|
|
249
|
-
extra_params = {
|
|
250
|
-
top_p: @config.top_p,
|
|
251
|
-
top_k: @config.top_k,
|
|
252
|
-
max_tokens: @config.max_tokens,
|
|
253
|
-
presence_penalty: @config.presence_penalty,
|
|
254
|
-
frequency_penalty: @config.frequency_penalty,
|
|
255
|
-
stop: @config.stop
|
|
256
|
-
}.compact
|
|
257
|
-
@chat.with_params(**extra_params) if extra_params.any?
|
|
258
|
-
|
|
259
|
-
# Apply callbacks
|
|
260
|
-
@chat.on_tool_call(&@on_tool_call) if @on_tool_call
|
|
261
|
-
@chat.on_tool_result(&@on_tool_result) if @on_tool_result
|
|
262
|
-
|
|
263
|
-
# Set up bus channel if a bus was provided
|
|
264
|
-
setup_bus_channel if @bus
|
|
224
|
+
apply_template
|
|
225
|
+
apply_system_prompt
|
|
226
|
+
apply_chat_params
|
|
227
|
+
register_chat_callbacks
|
|
265
228
|
end
|
|
266
229
|
|
|
267
|
-
|
|
268
230
|
# Returns the model identifier
|
|
269
231
|
#
|
|
270
232
|
# @return [String, nil] the LLM model ID string
|
|
@@ -275,100 +237,6 @@ module RobotLab
|
|
|
275
237
|
m.respond_to?(:id) ? m.id : m.to_s
|
|
276
238
|
end
|
|
277
239
|
|
|
278
|
-
# Dynamically delegate all with_* methods from @chat, returning self for chaining.
|
|
279
|
-
# Discovered from the actual Chat class to avoid maintenance sync issues.
|
|
280
|
-
private def define_chat_delegators
|
|
281
|
-
@chat.class.public_instance_methods(false)
|
|
282
|
-
.select { |m| m.start_with?('with_') }
|
|
283
|
-
.each do |method_name|
|
|
284
|
-
define_singleton_method(method_name) do |*args, **kwargs, &block|
|
|
285
|
-
@chat.public_send(method_name, *args, **kwargs, &block)
|
|
286
|
-
self
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
# Send a message and get a response, with Robot's extended capabilities
|
|
293
|
-
#
|
|
294
|
-
# @param message [String] the user message
|
|
295
|
-
# @param network [NetworkRun, nil] network context (legacy)
|
|
296
|
-
# @param network_memory [Memory, nil] shared network memory
|
|
297
|
-
# @param memory [Memory, Hash, nil] runtime memory to merge
|
|
298
|
-
# @param mcp [Symbol, Array, nil] runtime MCP override
|
|
299
|
-
# @param tools [Symbol, Array, nil] runtime tools override
|
|
300
|
-
# @yield [chunk] optional streaming block called with each content chunk
|
|
301
|
-
# @return [RobotResult]
|
|
302
|
-
def run(message = nil, network: nil, network_memory: nil, network_config: nil,
|
|
303
|
-
memory: nil, mcp: :none, tools: :none, **kwargs, &block)
|
|
304
|
-
# Determine which memory to use
|
|
305
|
-
run_memory = resolve_active_memory(network: network, network_memory: network_memory)
|
|
306
|
-
|
|
307
|
-
# Merge runtime memory if provided
|
|
308
|
-
case memory
|
|
309
|
-
when Memory
|
|
310
|
-
run_memory = memory
|
|
311
|
-
when Hash
|
|
312
|
-
run_memory.merge!(memory)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# Set current_writer so memory notifications know who wrote the value
|
|
316
|
-
previous_writer = run_memory.current_writer
|
|
317
|
-
run_memory.current_writer = @name
|
|
318
|
-
|
|
319
|
-
begin
|
|
320
|
-
# Resolve hierarchical MCP and tools configuration
|
|
321
|
-
resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
|
|
322
|
-
resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
|
|
323
|
-
|
|
324
|
-
# Filter MCP servers by semantic relevance when discovery is enabled.
|
|
325
|
-
# Only applies on the first run (before @mcp_initialized) so connections
|
|
326
|
-
# are not torn down mid-conversation.
|
|
327
|
-
if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
|
|
328
|
-
resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
# Initialize or update MCP clients based on resolved config
|
|
332
|
-
ensure_mcp_clients(resolved_mcp)
|
|
333
|
-
|
|
334
|
-
# Apply filtered tools to the persistent chat
|
|
335
|
-
filtered = filtered_tools(resolved_tools)
|
|
336
|
-
@chat.with_tools(*filtered) if filtered.any?
|
|
337
|
-
|
|
338
|
-
# Re-render template with run-time context merged into build-time context.
|
|
339
|
-
# Template parameters (e.g. customer: null) may require values that are
|
|
340
|
-
# only available at run time — the robot gathers them before rendering.
|
|
341
|
-
run_context = kwargs.except(:with)
|
|
342
|
-
rerender_template(run_context) if @template && run_context.any?
|
|
343
|
-
|
|
344
|
-
# Prepend accumulated learnings to the user message
|
|
345
|
-
effective_message = inject_learnings(message)
|
|
346
|
-
|
|
347
|
-
# Install circuit breaker for this run if max_tool_rounds is configured
|
|
348
|
-
install_circuit_breaker if @config.max_tool_rounds
|
|
349
|
-
|
|
350
|
-
# Delegate to Agent's ask (which calls @chat.ask)
|
|
351
|
-
ask_kwargs = kwargs.slice(:with)
|
|
352
|
-
streaming = effective_streaming_block(block)
|
|
353
|
-
response = ask(effective_message, **ask_kwargs, &streaming)
|
|
354
|
-
|
|
355
|
-
result = build_result(response, run_memory)
|
|
356
|
-
|
|
357
|
-
# Enforce token budget if configured
|
|
358
|
-
budget = @config.token_budget
|
|
359
|
-
if budget && @total_input_tokens + @total_output_tokens > budget
|
|
360
|
-
raise InferenceError,
|
|
361
|
-
"Token budget exceeded: #{@total_input_tokens + @total_output_tokens} tokens used, budget is #{budget}"
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
result
|
|
365
|
-
ensure
|
|
366
|
-
restore_tool_call_callback if @config.max_tool_rounds
|
|
367
|
-
run_memory.current_writer = previous_writer
|
|
368
|
-
end
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
|
|
372
240
|
# Reconfigure the robot for a new context
|
|
373
241
|
#
|
|
374
242
|
# @param template [Symbol, nil] new template to apply
|
|
@@ -396,7 +264,6 @@ module RobotLab
|
|
|
396
264
|
self
|
|
397
265
|
end
|
|
398
266
|
|
|
399
|
-
|
|
400
267
|
# SimpleFlow step interface
|
|
401
268
|
#
|
|
402
269
|
# @param result [SimpleFlow::Result] incoming result from previous step
|
|
@@ -429,7 +296,6 @@ module RobotLab
|
|
|
429
296
|
.continue(error_result)
|
|
430
297
|
end
|
|
431
298
|
|
|
432
|
-
|
|
433
299
|
# Reset the robot's inherent memory
|
|
434
300
|
#
|
|
435
301
|
# @return [self]
|
|
@@ -438,7 +304,6 @@ module RobotLab
|
|
|
438
304
|
self
|
|
439
305
|
end
|
|
440
306
|
|
|
441
|
-
|
|
442
307
|
# Eagerly connect to configured MCP servers and discover tools.
|
|
443
308
|
# Normally MCP connections are lazy (established on first run).
|
|
444
309
|
# Call this to connect early, e.g. to display connection status at startup.
|
|
@@ -468,7 +333,6 @@ module RobotLab
|
|
|
468
333
|
self
|
|
469
334
|
end
|
|
470
335
|
|
|
471
|
-
|
|
472
336
|
# --- Public APIs for external MCP and history management (A4) ---
|
|
473
337
|
|
|
474
338
|
# Inject pre-connected MCP clients and their tools into this robot.
|
|
@@ -508,10 +372,10 @@ module RobotLab
|
|
|
508
372
|
def clear_messages(keep_system: true)
|
|
509
373
|
if keep_system
|
|
510
374
|
system_msg = @chat.messages.find { |m| m.role == :system }
|
|
511
|
-
@chat.
|
|
375
|
+
@chat.reset_messages!
|
|
512
376
|
@chat.add_message(system_msg) if system_msg
|
|
513
377
|
else
|
|
514
|
-
@chat.
|
|
378
|
+
@chat.reset_messages!
|
|
515
379
|
end
|
|
516
380
|
self
|
|
517
381
|
end
|
|
@@ -521,7 +385,8 @@ module RobotLab
|
|
|
521
385
|
# @param messages [Array<RubyLLM::Message>] the messages to restore
|
|
522
386
|
# @return [self]
|
|
523
387
|
def replace_messages(messages)
|
|
524
|
-
@chat.
|
|
388
|
+
@chat.reset_messages!
|
|
389
|
+
messages.each { |m| @chat.add_message(m) }
|
|
525
390
|
self
|
|
526
391
|
end
|
|
527
392
|
|
|
@@ -583,14 +448,14 @@ module RobotLab
|
|
|
583
448
|
# @param kwargs [Hash] additional keyword args forwarded to Robot#run
|
|
584
449
|
# @return [RobotResult] when async: false
|
|
585
450
|
# @return [DelegationFuture] when async: true
|
|
586
|
-
def delegate(to:, task:, async: false, **
|
|
451
|
+
def delegate(to:, task:, async: false, **)
|
|
587
452
|
if async
|
|
588
453
|
future = DelegationFuture.new(robot_name: to.name, delegated_by: @name)
|
|
589
454
|
delegator_name = @name
|
|
590
455
|
|
|
591
456
|
Thread.new do
|
|
592
457
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
593
|
-
result = to.run(task, **
|
|
458
|
+
result = to.run(task, **)
|
|
594
459
|
result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
595
460
|
result.delegated_by = delegator_name
|
|
596
461
|
future.resolve!(result)
|
|
@@ -601,7 +466,7 @@ module RobotLab
|
|
|
601
466
|
future
|
|
602
467
|
else
|
|
603
468
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
604
|
-
result = to.run(task, **
|
|
469
|
+
result = to.run(task, **)
|
|
605
470
|
result.duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
606
471
|
result.delegated_by = @name
|
|
607
472
|
result
|
|
@@ -628,7 +493,6 @@ module RobotLab
|
|
|
628
493
|
@mcp_clients[server_name]
|
|
629
494
|
end
|
|
630
495
|
|
|
631
|
-
|
|
632
496
|
# Add a learning to this robot's accumulation store.
|
|
633
497
|
#
|
|
634
498
|
# Deduplicates by bidirectional substring matching: a new learning is
|
|
@@ -644,19 +508,24 @@ module RobotLab
|
|
|
644
508
|
text = text.to_s.strip
|
|
645
509
|
return self if text.empty?
|
|
646
510
|
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
649
522
|
|
|
650
|
-
|
|
651
|
-
unless @learnings.any? { |existing| existing.include?(text) }
|
|
652
|
-
@learnings << text
|
|
653
|
-
@memory.set(:learnings, @learnings.dup)
|
|
523
|
+
RobotLab::Hooks.call(:on_learn, ctx, registries: registries)
|
|
654
524
|
end
|
|
655
525
|
|
|
656
526
|
self
|
|
657
527
|
end
|
|
658
528
|
|
|
659
|
-
|
|
660
529
|
# Reset cumulative token counters to zero.
|
|
661
530
|
#
|
|
662
531
|
# @return [self]
|
|
@@ -666,7 +535,6 @@ module RobotLab
|
|
|
666
535
|
self
|
|
667
536
|
end
|
|
668
537
|
|
|
669
|
-
|
|
670
538
|
# Converts the robot to a hash representation
|
|
671
539
|
#
|
|
672
540
|
# @return [Hash]
|
|
@@ -690,15 +558,259 @@ module RobotLab
|
|
|
690
558
|
|
|
691
559
|
private
|
|
692
560
|
|
|
693
|
-
#
|
|
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
|
+
|
|
577
|
+
def assign_identity_ivars(name:, template:, system_prompt:, context:, description:,
|
|
578
|
+
local_tools:, skills:, mcp_discovery:)
|
|
579
|
+
@name = name.to_s
|
|
580
|
+
@name_from_constructor = (name.to_s != "robot")
|
|
581
|
+
@template = template
|
|
582
|
+
@system_prompt = system_prompt
|
|
583
|
+
@build_context = context
|
|
584
|
+
@description = description
|
|
585
|
+
@local_tools = Array(local_tools)
|
|
586
|
+
@skills = skills ? Array(skills).map(&:to_sym) : nil
|
|
587
|
+
@expanded_skills = nil
|
|
588
|
+
@pending_agent_skills = []
|
|
589
|
+
@agent_skill_store = nil
|
|
590
|
+
@mcp_discovery = mcp_discovery
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Build RunConfig from explicit kwargs, merged on top of any passed-in config.
|
|
594
|
+
# Explicit constructor kwargs always win.
|
|
595
|
+
def build_effective_config(model:, temperature:, top_p:, top_k:, max_tokens:,
|
|
596
|
+
presence_penalty:, frequency_penalty:, stop:,
|
|
597
|
+
on_tool_call:, on_tool_result:, on_content:,
|
|
598
|
+
bus:, enable_cache:, max_tool_rounds:, token_budget:, cost_budget:,
|
|
599
|
+
doom_loop_threshold:, mcp_servers:, mcp:, tools:, config:)
|
|
600
|
+
explicit_fields = {
|
|
601
|
+
model: model, temperature: temperature, top_p: top_p, top_k: top_k,
|
|
602
|
+
max_tokens: max_tokens, presence_penalty: presence_penalty,
|
|
603
|
+
frequency_penalty: frequency_penalty, stop: stop,
|
|
604
|
+
on_tool_call: on_tool_call, on_tool_result: on_tool_result,
|
|
605
|
+
on_content: on_content, bus: bus, enable_cache: enable_cache,
|
|
606
|
+
max_tool_rounds: max_tool_rounds, token_budget: token_budget, cost_budget: cost_budget,
|
|
607
|
+
doom_loop_threshold: doom_loop_threshold
|
|
608
|
+
}.compact
|
|
609
|
+
|
|
610
|
+
resolved_mcp = mcp_servers.any? ? mcp_servers : mcp
|
|
611
|
+
explicit_fields[:mcp] = resolved_mcp unless ToolConfig.none_value?(resolved_mcp)
|
|
612
|
+
explicit_fields[:tools] = tools unless ToolConfig.none_value?(tools)
|
|
613
|
+
|
|
614
|
+
explicit_config = RunConfig.new(**explicit_fields)
|
|
615
|
+
@config = config ? config.merge(explicit_config) : explicit_config
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def extract_config_ivars
|
|
619
|
+
@on_tool_call = @config.on_tool_call
|
|
620
|
+
@on_tool_result = @config.on_tool_result
|
|
621
|
+
@on_content = @config.on_content
|
|
622
|
+
@mcp_config = @config.mcp || :none
|
|
623
|
+
@tools_config = @config.tools || :none
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def initialize_runtime_state
|
|
627
|
+
@mcp_clients = {}
|
|
628
|
+
@mcp_tools = []
|
|
629
|
+
@mcp_initialized = false
|
|
630
|
+
@bus = @config.bus
|
|
631
|
+
@message_counter = 0
|
|
632
|
+
@outbox = {}
|
|
633
|
+
@bus_mutex = Mutex.new
|
|
634
|
+
@message_handler = ->(_msg) {}
|
|
635
|
+
@bus_poller = nil
|
|
636
|
+
@private_bus_poller = nil
|
|
637
|
+
@bus_poller_group = :default
|
|
638
|
+
@total_input_tokens = 0
|
|
639
|
+
@total_output_tokens = 0
|
|
640
|
+
@learnings = []
|
|
641
|
+
@hooks = HookRegistry.new
|
|
642
|
+
@budget_ledger = build_budget_ledger
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def initialize_memory
|
|
646
|
+
cache_enabled = @config.key?(:enable_cache) ? @config.enable_cache : true
|
|
647
|
+
@memory = Memory.new(enable_cache: cache_enabled)
|
|
648
|
+
persisted = @memory.get(:learnings)
|
|
649
|
+
@learnings = Array(persisted) if persisted
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def apply_template
|
|
653
|
+
define_chat_delegators
|
|
654
|
+
|
|
655
|
+
all_skill_ids = Array(@skills)
|
|
656
|
+
if @template
|
|
657
|
+
parsed_main = PM.parse(@template)
|
|
658
|
+
fm_skills = extract_skills_from_metadata(parsed_main.metadata)
|
|
659
|
+
all_skill_ids += fm_skills
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
if all_skill_ids.any?
|
|
663
|
+
apply_skills_and_template_to_chat(all_skill_ids, @build_context)
|
|
664
|
+
elsif @template
|
|
665
|
+
apply_template_to_chat(@build_context)
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def apply_system_prompt
|
|
670
|
+
@chat.with_instructions(@system_prompt) if @system_prompt
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def apply_chat_params
|
|
674
|
+
@chat.with_temperature(@config.temperature) if @config.temperature
|
|
675
|
+
|
|
676
|
+
extra_params = {
|
|
677
|
+
top_p: @config.top_p, top_k: @config.top_k,
|
|
678
|
+
max_tokens: @config.max_tokens,
|
|
679
|
+
presence_penalty: @config.presence_penalty,
|
|
680
|
+
frequency_penalty: @config.frequency_penalty,
|
|
681
|
+
stop: @config.stop
|
|
682
|
+
}.compact
|
|
683
|
+
@chat.with_params(**extra_params) if extra_params.any?
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def register_chat_callbacks
|
|
687
|
+
@chat.on_tool_call(&@on_tool_call) if @on_tool_call
|
|
688
|
+
@chat.on_tool_result(&@on_tool_result) if @on_tool_result
|
|
689
|
+
setup_bus_channel if @bus
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Dynamically delegate all with_* methods from @chat, returning self for chaining.
|
|
693
|
+
# Discovered from the actual Chat class to avoid maintenance sync issues.
|
|
694
|
+
def define_chat_delegators
|
|
695
|
+
@chat.class.public_instance_methods(false)
|
|
696
|
+
.select { |m| m.start_with?('with_') }
|
|
697
|
+
.each do |method_name|
|
|
698
|
+
define_singleton_method(method_name) do |*args, **kwargs, &block|
|
|
699
|
+
@chat.public_send(method_name, *args, **kwargs, &block)
|
|
700
|
+
self
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
694
705
|
def resolve_active_memory(network: nil, network_memory: nil)
|
|
695
706
|
network_memory || network&.memory || @memory
|
|
696
707
|
end
|
|
697
708
|
|
|
709
|
+
def resolve_run_memory(memory, network:, network_memory:)
|
|
710
|
+
run_memory = resolve_active_memory(network: network, network_memory: network_memory)
|
|
711
|
+
case memory
|
|
712
|
+
when Memory then memory
|
|
713
|
+
when Hash then run_memory.tap { |m| m.merge!(memory) }
|
|
714
|
+
else run_memory
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def prepare_tools(message:, mcp:, tools:, network:, network_config:)
|
|
719
|
+
resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
|
|
720
|
+
resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
|
|
721
|
+
|
|
722
|
+
if @mcp_discovery && !@mcp_initialized && resolved_mcp.is_a?(Array)
|
|
723
|
+
resolved_mcp = MCP::ServerDiscovery.select(message.to_s, from: resolved_mcp)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
ensure_mcp_clients(resolved_mcp)
|
|
727
|
+
|
|
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)
|
|
742
|
+
end
|
|
743
|
+
|
|
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?)
|
|
753
|
+
end
|
|
754
|
+
|
|
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
|
|
774
|
+
|
|
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]
|
|
809
|
+
end
|
|
698
810
|
|
|
699
811
|
# Extract run context from SimpleFlow::Result
|
|
700
812
|
def extract_run_context(result)
|
|
701
|
-
run_params = result.context[:run_params] || {}
|
|
813
|
+
run_params = (result.context[:run_params] || {}).dup
|
|
702
814
|
|
|
703
815
|
# Extract robot-specific params
|
|
704
816
|
mcp = run_params.delete(:mcp) || :none
|
|
@@ -706,6 +818,8 @@ module RobotLab
|
|
|
706
818
|
memory = run_params.delete(:memory)
|
|
707
819
|
network_memory = run_params.delete(:network_memory)
|
|
708
820
|
network_config = run_params.delete(:network_config)
|
|
821
|
+
network = run_params.delete(:network)
|
|
822
|
+
task = run_params.delete(:task)
|
|
709
823
|
|
|
710
824
|
# Build base context from remaining run params
|
|
711
825
|
base = run_params.dup
|
|
@@ -728,17 +842,15 @@ module RobotLab
|
|
|
728
842
|
merged[:memory] = memory if memory
|
|
729
843
|
merged[:network_memory] = network_memory if network_memory
|
|
730
844
|
merged[:network_config] = network_config if network_config
|
|
845
|
+
merged[:network] = network if network
|
|
846
|
+
merged[:task] = task if task
|
|
731
847
|
|
|
732
848
|
merged
|
|
733
849
|
end
|
|
734
850
|
|
|
735
|
-
|
|
736
851
|
def build_result(response, _memory)
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
else
|
|
740
|
-
[]
|
|
741
|
-
end
|
|
852
|
+
text = result_text(response)
|
|
853
|
+
output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
|
|
742
854
|
|
|
743
855
|
tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
|
|
744
856
|
|
|
@@ -767,6 +879,36 @@ module RobotLab
|
|
|
767
879
|
)
|
|
768
880
|
end
|
|
769
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
|
|
770
912
|
|
|
771
913
|
def normalize_tool_calls(tool_calls)
|
|
772
914
|
return [] unless tool_calls
|
|
@@ -783,7 +925,6 @@ module RobotLab
|
|
|
783
925
|
end
|
|
784
926
|
end
|
|
785
927
|
|
|
786
|
-
|
|
787
928
|
# Merge the stored on_content callback with a runtime streaming block.
|
|
788
929
|
# If both exist, both fire (stored first, then runtime block).
|
|
789
930
|
#
|
|
@@ -794,15 +935,16 @@ module RobotLab
|
|
|
794
935
|
return runtime_block unless @on_content
|
|
795
936
|
|
|
796
937
|
stored = @on_content
|
|
797
|
-
proc { |chunk|
|
|
938
|
+
proc { |chunk|
|
|
939
|
+
stored.call(chunk)
|
|
940
|
+
runtime_block.call(chunk)
|
|
941
|
+
}
|
|
798
942
|
end
|
|
799
943
|
|
|
800
|
-
|
|
801
944
|
def all_tools
|
|
802
945
|
@local_tools + @mcp_tools
|
|
803
946
|
end
|
|
804
947
|
|
|
805
|
-
|
|
806
948
|
def filtered_tools(allowed_names)
|
|
807
949
|
available = all_tools
|
|
808
950
|
return available if allowed_names.empty?
|
|
@@ -810,7 +952,6 @@ module RobotLab
|
|
|
810
952
|
ToolConfig.filter_tools(available, allowed_names: allowed_names)
|
|
811
953
|
end
|
|
812
954
|
|
|
813
|
-
|
|
814
955
|
# Prepend accumulated learnings to a user message when learnings exist.
|
|
815
956
|
def inject_learnings(message)
|
|
816
957
|
return message if @learnings.empty? || message.nil?
|
|
@@ -821,6 +962,106 @@ module RobotLab
|
|
|
821
962
|
"#{learning_block}#{message}"
|
|
822
963
|
end
|
|
823
964
|
|
|
965
|
+
# Install per-run doom loop detection on @chat's execute_tool.
|
|
966
|
+
# Tracks tool call names; when a consecutive or cyclic repetition exceeds
|
|
967
|
+
# the threshold, embeds a self-correction warning in the tool result so the
|
|
968
|
+
# LLM can change strategy without requiring an external circuit breaker.
|
|
969
|
+
def install_doom_loop_detection
|
|
970
|
+
threshold = @config.doom_loop_threshold || DoomLoopDetector::DEFAULT_THRESHOLD
|
|
971
|
+
detector = DoomLoopDetector.new(threshold: threshold)
|
|
972
|
+
|
|
973
|
+
@chat.define_singleton_method(:execute_tool) do |tool_call|
|
|
974
|
+
result = super(tool_call)
|
|
975
|
+
detector.track(tool_call.name)
|
|
976
|
+
|
|
977
|
+
if detector.doom_loop?
|
|
978
|
+
warning = detector.warning_message
|
|
979
|
+
detector.reset
|
|
980
|
+
case result
|
|
981
|
+
when Hash then result.merge(_doom_loop_warning: warning)
|
|
982
|
+
when String then "#{result}\n\n⚠️ #{warning}"
|
|
983
|
+
else result
|
|
984
|
+
end
|
|
985
|
+
else
|
|
986
|
+
result
|
|
987
|
+
end
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Remove the doom loop detection singleton method from @chat.
|
|
992
|
+
def remove_doom_loop_detection
|
|
993
|
+
sc = @chat.singleton_class
|
|
994
|
+
sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool)
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
# Compact conversation history before an ask() call if auto_compact is set.
|
|
998
|
+
#
|
|
999
|
+
# :none (default) — no-op
|
|
1000
|
+
# :context_window — compress when estimated tokens exceed compact_threshold
|
|
1001
|
+
# fraction of the model's context window (default 80%)
|
|
1002
|
+
# Proc — called with self; application owns the decision and strategy
|
|
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)
|
|
1008
|
+
return if @chat.messages.empty?
|
|
1009
|
+
|
|
1010
|
+
compact = @config.auto_compact
|
|
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))
|
|
1024
|
+
|
|
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
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def over_compact_threshold?
|
|
1044
|
+
threshold = (@config.compact_threshold || 0.80).to_f
|
|
1045
|
+
estimated_tok = @chat.messages.sum { |m| m.content.to_s.length } / 4
|
|
1046
|
+
|
|
1047
|
+
window = begin
|
|
1048
|
+
RubyLLM.models.find(model)&.context_window || 200_000
|
|
1049
|
+
rescue StandardError
|
|
1050
|
+
200_000
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
estimated_tok >= window * threshold
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
def compact_if_over_context_window
|
|
1057
|
+
return unless over_compact_threshold?
|
|
1058
|
+
|
|
1059
|
+
begin
|
|
1060
|
+
compress_history
|
|
1061
|
+
rescue DependencyError => e
|
|
1062
|
+
RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
824
1065
|
|
|
825
1066
|
# Install a per-run circuit breaker on the chat's on_tool_call hook.
|
|
826
1067
|
# Raises ToolLoopError if tool calls exceed @config.max_tool_rounds.
|
|
@@ -841,7 +1082,6 @@ module RobotLab
|
|
|
841
1082
|
end
|
|
842
1083
|
end
|
|
843
1084
|
|
|
844
|
-
|
|
845
1085
|
# Restore the original on_tool_call callback after a circuit-breaker run.
|
|
846
1086
|
def restore_tool_call_callback
|
|
847
1087
|
@chat.on_tool_call(&@on_tool_call)
|