robot_lab 0.1.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.envrc +1 -0
  10. data/.irbrc +2 -2
  11. data/.loki +60 -0
  12. data/.quality/reek_baseline.txt +43 -0
  13. data/.rubocop.yml +10 -0
  14. data/CHANGELOG.md +126 -0
  15. data/CLAUDE.md +139 -0
  16. data/README.md +130 -73
  17. data/Rakefile +131 -9
  18. data/agent2agent_review.md +192 -0
  19. data/agentf_improvements.md +253 -0
  20. data/agents.md +14 -0
  21. data/docs/api/core/index.md +1 -0
  22. data/docs/api/core/network.md +31 -1
  23. data/docs/api/core/robot.md +91 -5
  24. data/docs/api/errors.md +47 -2
  25. data/docs/api/index.md +3 -0
  26. data/docs/architecture/core-concepts.md +40 -0
  27. data/docs/architecture/network-orchestration.md +8 -0
  28. data/docs/architecture/robot-execution.md +1 -1
  29. data/docs/examples/index.md +37 -2
  30. data/docs/getting-started/configuration.md +39 -7
  31. data/docs/guides/building-robots.md +39 -1
  32. data/docs/guides/creating-networks.md +6 -1
  33. data/docs/guides/hooks.md +997 -0
  34. data/docs/guides/index.md +19 -14
  35. data/docs/guides/knowledge.md +9 -3
  36. data/docs/guides/observability.md +206 -7
  37. data/docs/guides/using-tools.md +69 -0
  38. data/docs/index.md +31 -4
  39. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  40. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  41. data/examples/.envrc +1 -0
  42. data/examples/01_simple_robot.rb +5 -9
  43. data/examples/02_tools.rb +5 -9
  44. data/examples/03_network.rb +18 -10
  45. data/examples/04_mcp.rb +21 -29
  46. data/examples/05_streaming.rb +12 -18
  47. data/examples/06_prompt_templates.rb +11 -19
  48. data/examples/07_network_memory.rb +16 -31
  49. data/examples/08_llm_config.rb +10 -22
  50. data/examples/09_chaining.rb +16 -27
  51. data/examples/10_memory.rb +12 -28
  52. data/examples/11_network_introspection.rb +15 -29
  53. data/examples/12_message_bus.rb +5 -12
  54. data/examples/13_spawn.rb +5 -10
  55. data/examples/14_rusty_circuit/.envrc +1 -0
  56. data/examples/14_rusty_circuit/comic.rb +2 -0
  57. data/examples/14_rusty_circuit/heckler.rb +1 -1
  58. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  59. data/examples/14_rusty_circuit/scout.rb +2 -0
  60. data/examples/15_memory_network_and_bus/.envrc +1 -0
  61. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  62. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  63. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  64. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  65. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  66. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  67. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  68. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  69. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  70. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  71. data/examples/16_writers_room/.envrc +1 -0
  72. data/examples/16_writers_room/writers_room.rb +2 -4
  73. data/examples/17_skills.rb +8 -17
  74. data/examples/18_rails/Gemfile +1 -0
  75. data/examples/19_token_tracking.rb +9 -15
  76. data/examples/20_circuit_breaker.rb +10 -19
  77. data/examples/21_learning_loop.rb +11 -20
  78. data/examples/22_context_compression.rb +6 -13
  79. data/examples/23_convergence.rb +6 -17
  80. data/examples/24_structured_delegation.rb +11 -15
  81. data/examples/25_history_search.rb +5 -12
  82. data/examples/26_document_store.rb +6 -13
  83. data/examples/27_incident_response/incident_response.rb +4 -5
  84. data/examples/28_mcp_discovery.rb +8 -11
  85. data/examples/29_ractor_tools.rb +4 -9
  86. data/examples/30_ractor_network.rb +10 -19
  87. data/examples/31_launch_assessment.rb +10 -23
  88. data/examples/32_newsletter_reader.rb +188 -0
  89. data/examples/33_stock_generator.rb +80 -0
  90. data/examples/33_stock_predictor.rb +306 -0
  91. data/examples/34_agentskills.rb +72 -0
  92. data/examples/35_hooks.rb +256 -0
  93. data/examples/README.md +8 -1
  94. data/examples/common.rb +80 -0
  95. data/examples/ruboruby.md +423 -0
  96. data/examples/xyzzy.rb +90 -0
  97. data/lib/robot_lab/agent_skill.rb +64 -0
  98. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  99. data/lib/robot_lab/ask_user.rb +2 -2
  100. data/lib/robot_lab/budget/ledger.rb +98 -0
  101. data/lib/robot_lab/bus_poller.rb +12 -5
  102. data/lib/robot_lab/capabilities.rb +84 -0
  103. data/lib/robot_lab/config/defaults.yml +10 -0
  104. data/lib/robot_lab/config.rb +5 -16
  105. data/lib/robot_lab/delegation_future.rb +1 -1
  106. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  107. data/lib/robot_lab/error.rb +34 -4
  108. data/lib/robot_lab/errors.rb +45 -0
  109. data/lib/robot_lab/history_compressor.rb +4 -10
  110. data/lib/robot_lab/hook.rb +79 -0
  111. data/lib/robot_lab/hook_context.rb +194 -0
  112. data/lib/robot_lab/hook_registry.rb +55 -0
  113. data/lib/robot_lab/hooks.rb +87 -0
  114. data/lib/robot_lab/mcp/client.rb +1 -2
  115. data/lib/robot_lab/mcp/connection_poller.rb +5 -5
  116. data/lib/robot_lab/mcp/server.rb +1 -1
  117. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  118. data/lib/robot_lab/memory.rb +32 -27
  119. data/lib/robot_lab/memory_change.rb +2 -2
  120. data/lib/robot_lab/message.rb +4 -4
  121. data/lib/robot_lab/narrator.rb +87 -0
  122. data/lib/robot_lab/network.rb +87 -25
  123. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  124. data/lib/robot_lab/robot/budget.rb +89 -0
  125. data/lib/robot_lab/robot/bus_messaging.rb +79 -33
  126. data/lib/robot_lab/robot/history_search.rb +4 -1
  127. data/lib/robot_lab/robot/hooking.rb +56 -0
  128. data/lib/robot_lab/robot/mcp_management.rb +12 -12
  129. data/lib/robot_lab/robot/template_rendering.rb +62 -42
  130. data/lib/robot_lab/robot.rb +482 -242
  131. data/lib/robot_lab/robot_result.rb +6 -5
  132. data/lib/robot_lab/run_config.rb +27 -14
  133. data/lib/robot_lab/runnable.rb +51 -0
  134. data/lib/robot_lab/sandbox/null.rb +13 -0
  135. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  136. data/lib/robot_lab/sandbox.rb +52 -0
  137. data/lib/robot_lab/script_tool.rb +134 -0
  138. data/lib/robot_lab/state_proxy.rb +7 -5
  139. data/lib/robot_lab/task.rb +26 -20
  140. data/lib/robot_lab/tool.rb +54 -13
  141. data/lib/robot_lab/tool_config.rb +1 -1
  142. data/lib/robot_lab/tool_manifest.rb +5 -7
  143. data/lib/robot_lab/user_message.rb +2 -2
  144. data/lib/robot_lab/version.rb +1 -1
  145. data/lib/robot_lab/waiter.rb +1 -1
  146. data/lib/robot_lab.rb +89 -51
  147. data/logfile +8 -0
  148. data/mkdocs.yml +3 -3
  149. data/robot_concurrency.md +38 -0
  150. data/simple_acp_review.md +298 -0
  151. data/temp.md +6 -0
  152. data/tool_manifest_plan.md +155 -0
  153. metadata +82 -83
  154. data/docs/examples/rails-application.md +0 -419
  155. data/docs/guides/ractor-parallelism.md +0 -364
  156. data/docs/guides/rails-integration.md +0 -681
  157. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  158. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  159. data/lib/generators/robot_lab/install_generator.rb +0 -90
  160. data/lib/generators/robot_lab/job_generator.rb +0 -40
  161. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  162. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  163. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  164. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  165. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  166. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  167. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  168. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  169. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  170. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  171. data/lib/robot_lab/document_store.rb +0 -155
  172. data/lib/robot_lab/ractor_boundary.rb +0 -42
  173. data/lib/robot_lab/ractor_job.rb +0 -37
  174. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  175. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  176. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  177. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  178. data/lib/robot_lab/rails_integration/job.rb +0 -158
  179. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  180. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -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
- @name = name.to_s
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
- # Build RunConfig from explicit kwargs, merged on top of passed-in config.
148
- # Explicit constructor kwargs always override the shared config.
149
- explicit_fields = {
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
- }.compact
157
-
158
- # Only include mcp/tools if explicitly set (not the default :none sentinel)
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
- # Ensure config is loaded (triggers PM setup, RubyLLM config, etc.)
205
- config = RobotLab.config
207
+ extract_config_ivars
208
+ initialize_runtime_state
209
+ initialize_memory
206
210
 
207
- # Build chat kwargs for Agent's super
208
- resolved_model = @config.model || config.ruby_llm.model
209
- chat_kwargs = { model: resolved_model }
211
+ lab_config = RobotLab.config
212
+ resolved_model = @config.model || lab_config.ruby_llm.model
213
+ chat_kwargs = { model: resolved_model }
210
214
 
211
- # Pass provider through for local providers (Ollama, GPUStack, etc.)
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
- # Dynamically delegate all with_* methods from the Chat class
224
- define_chat_delegators
225
-
226
- # Gather all skill IDs from constructor + main template front matter
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.instance_variable_set(:@messages, [])
375
+ @chat.reset_messages!
512
376
  @chat.add_message(system_msg) if system_msg
513
377
  else
514
- @chat.instance_variable_set(:@messages, [])
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.instance_variable_set(:@messages, messages)
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, **kwargs)
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, **kwargs)
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, **kwargs)
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
- # Remove any existing learning that is a substring of the new one
648
- @learnings.reject! { |existing| text.include?(existing) }
511
+ ctx = LearnHookContext.new(robot: self, text: text, learnings_before: @learnings.dup)
512
+ registries = [RobotLab.hooks, @hooks]
513
+
514
+ RobotLab::Hooks.run(:learn, ctx, registries: registries) do
515
+ @learnings.reject! { |existing| text.include?(existing) }
516
+
517
+ unless @learnings.any? { |existing| existing.include?(text) }
518
+ @learnings << text
519
+ @memory.set(:learnings, @learnings.dup)
520
+ ctx.stored = true
521
+ end
649
522
 
650
- # Skip if any existing learning already covers the new one
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
- # Determine which memory to use
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
- output = if response.respond_to?(:content) && response.content
738
- [TextMessage.new(role: 'assistant', content: response.content)]
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| stored.call(chunk); runtime_block.call(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)