robot_lab 0.2.6 → 0.2.8

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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +4 -0
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +17 -1
  6. data/README.md +124 -64
  7. data/Rakefile +6 -111
  8. data/_typos.toml +21 -0
  9. data/docs/api/core/index.md +41 -15
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +255 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +551 -61
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +110 -17
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +80 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +423 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +79 -31
  33. data/docs/architecture/index.md +87 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +145 -38
  36. data/docs/architecture/robot-execution.md +172 -90
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +288 -98
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +258 -50
  48. data/docs/guides/creating-networks.md +214 -30
  49. data/docs/guides/hooks.md +263 -54
  50. data/docs/guides/knowledge.md +35 -4
  51. data/docs/guides/mcp-integration.md +211 -44
  52. data/docs/guides/memory.md +103 -12
  53. data/docs/guides/observability.md +95 -47
  54. data/docs/guides/streaming.md +184 -125
  55. data/docs/guides/using-tools.md +247 -18
  56. data/docs/index.md +36 -4
  57. data/examples/01_simple_robot.rb +2 -2
  58. data/examples/02_tools.rb +14 -4
  59. data/examples/03_network.rb +12 -7
  60. data/examples/04_mcp.rb +11 -4
  61. data/examples/05_streaming.rb +8 -5
  62. data/examples/06_prompt_templates.rb +13 -9
  63. data/examples/07_network_memory.rb +5 -5
  64. data/examples/08_llm_config.rb +20 -15
  65. data/examples/09_chaining.rb +4 -4
  66. data/examples/11_network_introspection.rb +4 -4
  67. data/examples/12_message_bus.rb +2 -2
  68. data/examples/13_spawn.rb +2 -2
  69. data/examples/14_rusty_circuit/README.md +1 -0
  70. data/examples/14_rusty_circuit/comic.rb +7 -3
  71. data/examples/14_rusty_circuit/display.rb +14 -0
  72. data/examples/14_rusty_circuit/heckler.rb +8 -6
  73. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  74. data/examples/14_rusty_circuit/scout.rb +17 -10
  75. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  76. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  77. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  78. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  79. data/examples/16_writers_room/writer.rb +22 -22
  80. data/examples/16_writers_room/writers_room.rb +2 -0
  81. data/examples/17_skills.rb +14 -13
  82. data/examples/18_rails/README.md +20 -1
  83. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  84. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  85. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  86. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  87. data/examples/19_token_tracking.rb +25 -9
  88. data/examples/20_circuit_breaker.rb +10 -7
  89. data/examples/21_learning_loop.rb +42 -16
  90. data/examples/22_context_compression.rb +23 -23
  91. data/examples/23_convergence.rb +24 -17
  92. data/examples/24_structured_delegation.rb +13 -8
  93. data/examples/25_history_search.rb +12 -8
  94. data/examples/27_incident_response/incident_response.rb +31 -13
  95. data/examples/28_mcp_discovery.rb +17 -13
  96. data/examples/29_ractor_tools.rb +4 -2
  97. data/examples/30_ractor_network.rb +22 -17
  98. data/examples/31_launch_assessment.rb +20 -9
  99. data/examples/32_newsletter_reader.rb +7 -2
  100. data/examples/33_stock_predictor.rb +34 -13
  101. data/examples/34_agentskills.rb +7 -3
  102. data/examples/35_hooks.rb +18 -8
  103. data/examples/README.md +199 -45
  104. data/examples/common.rb +79 -11
  105. data/examples/xyzzy.rb +8 -1
  106. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  107. data/lib/robot_lab/ask_user.rb +2 -0
  108. data/lib/robot_lab/bus_poller.rb +2 -0
  109. data/lib/robot_lab/capabilities.rb +4 -0
  110. data/lib/robot_lab/config.rb +18 -5
  111. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  112. data/lib/robot_lab/history_compressor.rb +5 -0
  113. data/lib/robot_lab/hook.rb +1 -0
  114. data/lib/robot_lab/hook_context.rb +4 -0
  115. data/lib/robot_lab/hook_registry.rb +1 -0
  116. data/lib/robot_lab/hooks.rb +6 -3
  117. data/lib/robot_lab/mcp/client.rb +2 -2
  118. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  119. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  120. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  121. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  122. data/lib/robot_lab/mcp/transports/streamable_http.rb +4 -0
  123. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  124. data/lib/robot_lab/memory.rb +23 -6
  125. data/lib/robot_lab/memory_change.rb +1 -0
  126. data/lib/robot_lab/message.rb +3 -0
  127. data/lib/robot_lab/names.rb +400 -0
  128. data/lib/robot_lab/network.rb +13 -6
  129. data/lib/robot_lab/robot/agent_skill_matching.rb +3 -3
  130. data/lib/robot_lab/robot/bus_messaging.rb +19 -8
  131. data/lib/robot_lab/robot/history_search.rb +2 -0
  132. data/lib/robot_lab/robot/hooking.rb +3 -0
  133. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  134. data/lib/robot_lab/robot/template_rendering.rb +29 -7
  135. data/lib/robot_lab/robot.rb +81 -19
  136. data/lib/robot_lab/robot_result.rb +3 -0
  137. data/lib/robot_lab/run_config.rb +5 -0
  138. data/lib/robot_lab/script_tool.rb +20 -39
  139. data/lib/robot_lab/state_proxy.rb +1 -0
  140. data/lib/robot_lab/streaming/context.rb +1 -0
  141. data/lib/robot_lab/streaming/events.rb +1 -0
  142. data/lib/robot_lab/task.rb +2 -0
  143. data/lib/robot_lab/tool.rb +4 -0
  144. data/lib/robot_lab/user_message.rb +1 -0
  145. data/lib/robot_lab/utils.rb +2 -0
  146. data/lib/robot_lab/version.rb +1 -1
  147. data/lib/robot_lab/waiter.rb +3 -0
  148. data/lib/robot_lab.rb +27 -18
  149. data/mkdocs.yml +6 -1
  150. metadata +12 -7
  151. data/lib/robot_lab/sandbox/null.rb +0 -13
  152. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  153. data/lib/robot_lab/sandbox.rb +0 -52
@@ -35,6 +35,7 @@ module RobotLab
35
35
  # Apply a prompt_manager template to the persistent chat.
36
36
  # If required parameters are missing, applies front matter config but
37
37
  # defers rendering until run time when all values are available.
38
+ # :reek:TooManyStatements -- linear parse/merge/apply/render sequence with a documented deferred-render rescue.
38
39
  def apply_template_to_chat(context)
39
40
  parsed = PM.parse(@template)
40
41
 
@@ -64,6 +65,11 @@ module RobotLab
64
65
 
65
66
  # Re-render the template with run-time context merged into build-time context.
66
67
  # prompt_manager parameters may be required (null) and only available at run time.
68
+ #
69
+ # Re-rendering replaces the system message, so the inline system_prompt must be
70
+ # re-appended here exactly as apply_system_prompt does at construction --
71
+ # otherwise it would be silently dropped on any run that supplies context.
72
+ # :reek:TooManyStatements -- must rebuild skills + template + inline prompt in one pass (see comment above).
67
73
  def rerender_template(run_context)
68
74
  merged = (@build_context || {}).merge(run_context)
69
75
  resolved_ctx = resolve_context(merged, network: nil)
@@ -81,15 +87,24 @@ module RobotLab
81
87
  bodies << body if body
82
88
  end
83
89
 
84
- combined = bodies.join("\n\n")
85
- @chat.with_instructions(combined)
90
+ @chat.with_instructions(with_system_prompt_appended(bodies.join("\n\n")))
86
91
  else
87
92
  parsed = PM.parse(@template)
88
93
  rendered = parsed.to_s(**resolved_ctx)
89
- @chat.with_instructions(rendered)
94
+ @chat.with_instructions(with_system_prompt_appended(rendered))
90
95
  end
91
96
  end
92
97
 
98
+ # Append the inline system_prompt to freshly rendered template text.
99
+ #
100
+ # @param rendered [String] newly rendered template body
101
+ # @return [String] rendered text with system_prompt appended, if one is set
102
+ def with_system_prompt_appended(rendered)
103
+ return rendered unless @system_prompt
104
+
105
+ [rendered, @system_prompt].reject { |s| s.to_s.empty? }.join("\n\n")
106
+ end
107
+
93
108
  # Orchestrate skill expansion and template application.
94
109
  #
95
110
  # @param skill_ids [Array<Symbol>] skill IDs from constructor + front matter
@@ -103,6 +118,7 @@ module RobotLab
103
118
  # Pure computation — reads ivars but does not mutate @chat.
104
119
  #
105
120
  # @return [Array(Array<String>, RunConfig, Hash)] bodies, merged config, extras hash
121
+ # :reek:TooManyStatements -- accumulates bodies/config/extras across skills then the main template in one pure pass.
106
122
  def collect_prompt_content(skill_ids, context)
107
123
  visited = Set.new
108
124
  visited.add(@template) if @template
@@ -115,16 +131,18 @@ module RobotLab
115
131
 
116
132
  @expanded_skills.each do |skill_id|
117
133
  parsed = PM.parse(skill_id)
118
- accumulate_extras(parsed.metadata, extras)
119
- accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
134
+ metadata = parsed.metadata
135
+ accumulate_extras(metadata, extras)
136
+ accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(metadata))
120
137
  body = render_body(parsed, resolved_ctx)
121
138
  bodies << body if body
122
139
  end
123
140
 
124
141
  if @template
125
142
  parsed = PM.parse(@template)
126
- accumulate_extras(parsed.metadata, extras)
127
- accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
143
+ metadata = parsed.metadata
144
+ accumulate_extras(metadata, extras)
145
+ accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(metadata))
128
146
  body = render_body(parsed, resolved_ctx)
129
147
  bodies << body if body
130
148
  end
@@ -168,6 +186,7 @@ module RobotLab
168
186
  # @param visited [Set<Symbol>] already-visited IDs for cycle detection
169
187
  # @param catalog [AgentSkillCatalog] catalog to check first
170
188
  # @return [Array<Symbol>] flat ordered list of PM-based skill IDs
189
+ # :reek:TooManyStatements -- depth-first skill expansion with cycle guard and catalog-vs-PM branching.
171
190
  def expand_skills_with_catalog(skill_ids, visited, catalog)
172
191
  result = []
173
192
 
@@ -213,6 +232,7 @@ module RobotLab
213
232
  #
214
233
  # @param metadata [PM::Metadata] front matter metadata
215
234
  # @return [Array<Symbol>]
235
+ # :reek:FeatureEnvy -- reading the metadata argument's skills list is the extraction itself.
216
236
  def extract_skills_from_metadata(metadata)
217
237
  return [] unless metadata.respond_to?(:skills) && metadata.skills
218
238
 
@@ -263,6 +283,7 @@ module RobotLab
263
283
 
264
284
  # Extract identity and capability keys from front matter metadata.
265
285
  # Constructor-provided values take precedence over frontmatter.
286
+ # :reek:FeatureEnvy -- copying front-matter metadata fields into this robot's ivars is the method's purpose.
266
287
  def apply_front_matter_extras(metadata)
267
288
  if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
268
289
  @name = metadata.robot_name.to_s
@@ -296,6 +317,7 @@ module RobotLab
296
317
  # Resolve string tool names from frontmatter to Ruby constants.
297
318
  # Tool subclasses are instantiated; instances are used as-is.
298
319
  # Unresolvable names are skipped with a warning.
320
+ # :reek:FeatureEnvy -- inspecting each resolved constant to decide instantiate-vs-use-as-is.
299
321
  def resolve_frontmatter_tools(tool_names)
300
322
  tool_names.filter_map do |name|
301
323
  case name
@@ -41,6 +41,12 @@ module RobotLab
41
41
  # local_tools: [OrderLookup, RefundProcessor]
42
42
  # )
43
43
  #
44
+ # :reek:TooManyMethods :reek:TooManyInstanceVariables -- Robot is intentionally the central orchestrator
45
+ # (see CLAUDE.md); behavior is split into the included modules below.
46
+ # :reek:InstanceVariableAssumption -- every ivar is assigned in #initialize via assign_identity_ivars/
47
+ # extract_config_ivars/initialize_runtime_state/initialize_memory; reek does not trace those helpers.
48
+ # :reek:RepeatedConditional -- `@bus` presence gates optional bus wiring at each independent site.
49
+ # :reek:DataClump -- (context, system_prompt, template) travel together as the robot's identity triple across construction APIs.
44
50
  class Robot < RubyLLM::Agent
45
51
  include Robot::TemplateRendering
46
52
  include Robot::MCPManagement
@@ -155,6 +161,8 @@ module RobotLab
155
161
  # @param stop [String, Array, nil] stop sequences
156
162
  # @param skills [Symbol, Array<Symbol>, nil] skill templates to prepend
157
163
  # @param config [RunConfig, nil] shared configuration (merged with explicit kwargs)
164
+ # :reek:LongParameterList -- the documented public constructor: one keyword per robot capability.
165
+ # :reek:BooleanParameter -- enable_cache and mcp_discovery are documented feature toggles.
158
166
  def initialize(
159
167
  name:,
160
168
  template: nil,
@@ -230,6 +238,7 @@ module RobotLab
230
238
  # Returns the model identifier
231
239
  #
232
240
  # @return [String, nil] the LLM model ID string
241
+ # :reek:FeatureEnvy -- duck-type unwrapping of the chat's model object into an id string.
233
242
  def model
234
243
  return nil unless @chat.respond_to?(:model)
235
244
 
@@ -245,6 +254,8 @@ module RobotLab
245
254
  # @param model [String, nil] new model
246
255
  # @param temperature [Float, nil] new temperature
247
256
  # @return [self]
257
+ # :reek:LongParameterList :reek:TooManyStatements -- public reconfiguration API; one guarded with_* application per option.
258
+ # :reek:ControlParameter -- `context || @build_context` is a fallback default, not behavior selection.
248
259
  def update(template: nil, context: nil, system_prompt: nil, model: nil, temperature: nil, **kwargs)
249
260
  if template
250
261
  @template = template
@@ -268,6 +279,8 @@ module RobotLab
268
279
  #
269
280
  # @param result [SimpleFlow::Result] incoming result from previous step
270
281
  # @return [SimpleFlow::Result] result with robot output
282
+ # :reek:TooManyStatements -- SimpleFlow step: timing, run, and error-shielding belong together.
283
+ # :reek:DuplicateMethodCall -- each clock_gettime samples a different instant (start/stop/rescue); a local would be wrong.
271
284
  def call(result)
272
285
  run_context = extract_run_context(result)
273
286
 
@@ -369,6 +382,7 @@ module RobotLab
369
382
  #
370
383
  # @param keep_system [Boolean] whether to preserve the system message
371
384
  # @return [self]
385
+ # :reek:BooleanParameter :reek:ControlParameter -- keep_system is a documented public API toggle.
372
386
  def clear_messages(keep_system: true)
373
387
  if keep_system
374
388
  system_msg = @chat.messages.find { |m| m.role == :system }
@@ -448,6 +462,10 @@ module RobotLab
448
462
  # @param kwargs [Hash] additional keyword args forwarded to Robot#run
449
463
  # @return [RobotResult] when async: false
450
464
  # @return [DelegationFuture] when async: true
465
+ # :reek:BooleanParameter :reek:ControlParameter -- async is the documented sync/future API switch.
466
+ # :reek:TooManyStatements -- the sync and async timing/annotation paths read best side by side.
467
+ # :reek:DuplicateMethodCall -- clock_gettime pairs sample distinct start/stop instants in each branch.
468
+ # :reek:FeatureEnvy -- annotating the delegatee's result with duration and delegator is the point of delegate.
451
469
  def delegate(to:, task:, async: false, **)
452
470
  if async
453
471
  future = DelegationFuture.new(robot_name: to.name, delegated_by: @name)
@@ -478,6 +496,7 @@ module RobotLab
478
496
  # into chat internals.
479
497
  #
480
498
  # @return [String, nil]
499
+ # :reek:FeatureEnvy -- duck-type unwrapping of the chat's model object.
481
500
  def chat_provider
482
501
  m = @chat.model
483
502
  m.respond_to?(:provider) ? m.provider : nil
@@ -504,6 +523,7 @@ module RobotLab
504
523
  #
505
524
  # @param text [String] the insight to record
506
525
  # @return [self]
526
+ # :reek:TooManyStatements -- dedupe/store/hook sequence shares the learnings state throughout.
507
527
  def learn(text)
508
528
  text = text.to_s.strip
509
529
  return self if text.empty?
@@ -561,6 +581,7 @@ module RobotLab
561
581
  # `tools:` is a NAME allowlist (a filter over available tools), not a place
562
582
  # to attach tool instances — passing instances there silently attaches
563
583
  # nothing. Catch the mistake with a clear, actionable error.
584
+ # :reek:FeatureEnvy -- inspecting each offending entry to build an actionable error message.
564
585
  def validate_tools_filter!(tools)
565
586
  return unless tools.is_a?(Array)
566
587
 
@@ -574,6 +595,8 @@ module RobotLab
574
595
  "(e.g. RobotLab.build(local_tools: [MyTool.new]))."
575
596
  end
576
597
 
598
+ # :reek:LongParameterList :reek:TooManyStatements -- one keyword and one assignment per identity ivar;
599
+ # a hash would lose keyword checking.
577
600
  def assign_identity_ivars(name:, template:, system_prompt:, context:, description:,
578
601
  local_tools:, skills:, mcp_discovery:)
579
602
  @name = name.to_s
@@ -592,6 +615,8 @@ module RobotLab
592
615
 
593
616
  # Build RunConfig from explicit kwargs, merged on top of any passed-in config.
594
617
  # Explicit constructor kwargs always win.
618
+ # :reek:LongParameterList -- mirrors the constructor's LLM kwargs one-to-one so explicit values win over config.
619
+ # :reek:FeatureEnvy -- assembling the explicit_fields hash it just built is the merge itself.
595
620
  def build_effective_config(model:, temperature:, top_p:, top_k:, max_tokens:,
596
621
  presence_penalty:, frequency_penalty:, stop:,
597
622
  on_tool_call:, on_tool_result:, on_content:,
@@ -666,8 +691,27 @@ module RobotLab
666
691
  end
667
692
  end
668
693
 
694
+ # Append the inline system prompt to whatever apply_template already set.
695
+ #
696
+ # The template renders first and establishes the base instructions; the
697
+ # inline system_prompt is appended so both survive. Combining into a single
698
+ # system message (rather than using with_instructions(append: true), which
699
+ # adds a second one) keeps readers that look at only the first system
700
+ # message -- notably AgentSkillMatching's snapshot/restore -- correct.
669
701
  def apply_system_prompt
670
- @chat.with_instructions(@system_prompt) if @system_prompt
702
+ return unless @system_prompt
703
+
704
+ base = current_system_instructions.to_s
705
+ combined = [base, @system_prompt].reject(&:empty?).join("\n\n")
706
+ @chat.with_instructions(combined)
707
+ end
708
+
709
+ # Content of the chat's system message, or nil when none is set.
710
+ #
711
+ # @return [String, nil]
712
+ def current_system_instructions
713
+ messages = @chat.instance_variable_get(:@messages)
714
+ messages&.find { |m| m.role.to_s == "system" }&.content
671
715
  end
672
716
 
673
717
  def apply_chat_params
@@ -691,6 +735,7 @@ module RobotLab
691
735
 
692
736
  # Dynamically delegate all with_* methods from @chat, returning self for chaining.
693
737
  # Discovered from the actual Chat class to avoid maintenance sync issues.
738
+ # :reek:NestedIterators -- the inner block is the delegator method body being defined, not an iteration.
694
739
  def define_chat_delegators
695
740
  @chat.class.public_instance_methods(false)
696
741
  .select { |m| m.start_with?('with_') }
@@ -702,6 +747,7 @@ module RobotLab
702
747
  end
703
748
  end
704
749
 
750
+ # :reek:ControlParameter -- `network_memory || network&.memory || @memory` is the documented memory cascade.
705
751
  def resolve_active_memory(network: nil, network_memory: nil)
706
752
  network_memory || network&.memory || @memory
707
753
  end
@@ -757,17 +803,21 @@ module RobotLab
757
803
  # is the definitive choke point: tools are fully resolved (MCP connected)
758
804
  # and about to be handed to the chat, so the cap holds no matter how the
759
805
  # tools were configured, filtered, or connected. Cap value comes from
760
- # RunConfig#max_tools, defaulting to DEFAULT_MAX_TOOLS; nil/<=0 disables it.
806
+ # RunConfig#max_tools, defaulting to DEFAULT_MAX_TOOLS; a nil, zero, or
807
+ # negative max_tools falls back to that default, so the cap cannot be
808
+ # disabled from configuration.
761
809
  #
762
810
  # @param tools [Array<Tool>] the resolved tools
763
811
  # @return [Array<Tool>] at most `max_tools` tools
812
+ # :reek:FeatureEnvy -- clamping the passed-in tool list against the provider limit is a pure filter.
764
813
  def cap_tools(tools)
765
- max = effective_max_tools
766
- return tools if max.nil? || tools.size <= max
814
+ max = effective_max_tools
815
+ size = tools.size
816
+ return tools if max.nil? || size <= max
767
817
 
768
818
  RobotLab.config.logger.warn(
769
- "[#{@name}] tool list (#{tools.size}) exceeds max_tools (#{max}); " \
770
- "sending #{max}, dropping #{tools.size - max}"
819
+ "[#{@name}] tool list (#{size}) exceeds max_tools (#{max}); " \
820
+ "sending #{max}, dropping #{size - max}"
771
821
  )
772
822
  tools.first(max)
773
823
  end
@@ -781,10 +831,12 @@ module RobotLab
781
831
  configured&.positive? ? configured : DEFAULT_MAX_TOOLS
782
832
  end
783
833
 
834
+ # :reek:TooManyStatements -- builds the generation hook context then runs the pre-ask setup chain in order.
784
835
  def invoke_ask(context:, kwargs:, hooks:, block:)
836
+ network = context.network
785
837
  generation_context = LlmGenerationHookContext.new(
786
838
  robot: self,
787
- network: context.network,
839
+ network: network,
788
840
  task: context.task,
789
841
  memory: context.memory,
790
842
  config: context.config,
@@ -793,9 +845,9 @@ module RobotLab
793
845
  )
794
846
 
795
847
  RobotLab::Hooks.run(:llm_generation, generation_context,
796
- registries: hook_registries(context.network), per_run_hooks: hooks) do
848
+ registries: hook_registries(network), per_run_hooks: hooks) do
797
849
  effective_message = inject_learnings(generation_context.request)
798
- maybe_compact(network: context.network)
850
+ maybe_compact(network: network)
799
851
  install_circuit_breaker if @config.max_tool_rounds
800
852
  install_doom_loop_detection
801
853
  ask_kwargs = kwargs.slice(:with)
@@ -809,6 +861,7 @@ module RobotLab
809
861
  end
810
862
 
811
863
  # Extract run context from SimpleFlow::Result
864
+ # :reek:TooManyStatements -- one delete/re-add per robot-specific run param; a loop would hide which keys are special.
812
865
  def extract_run_context(result)
813
866
  run_params = (result.context[:run_params] || {}).dup
814
867
 
@@ -825,15 +878,16 @@ module RobotLab
825
878
  base = run_params.dup
826
879
 
827
880
  # Merge current value into context
828
- merged = case result.value
881
+ value = result.value
882
+ merged = case value
829
883
  when Hash
830
- base.merge(result.value.transform_keys(&:to_sym))
884
+ base.merge(value.transform_keys(&:to_sym))
831
885
  when RobotResult
832
- base.merge(message: result.value.last_text_content)
886
+ base.merge(message: value.last_text_content)
833
887
  when String
834
- base.merge(message: result.value)
888
+ base.merge(message: value)
835
889
  else
836
- base.merge(message: result.value.to_s)
890
+ base.merge(message: value.to_s)
837
891
  end
838
892
 
839
893
  # Add back the special params
@@ -848,6 +902,8 @@ module RobotLab
848
902
  merged
849
903
  end
850
904
 
905
+ # :reek:TooManyStatements :reek:FeatureEnvy -- adapting a provider response's many optional fields into
906
+ # a RobotResult is inherently response-centric.
851
907
  def build_result(response, _memory)
852
908
  text = result_text(response)
853
909
  output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
@@ -855,11 +911,10 @@ module RobotLab
855
911
  tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
856
912
 
857
913
  # Extract token usage from the response
858
- input_toks = 0
859
- output_toks = 0
860
- if response.respond_to?(:tokens) && response.tokens
861
- input_toks = response.tokens.input.to_i
862
- output_toks = response.tokens.output.to_i
914
+ input_toks = output_toks = 0
915
+ if response.respond_to?(:tokens) && (tokens = response.tokens)
916
+ input_toks = tokens.input.to_i
917
+ output_toks = tokens.output.to_i
863
918
  elsif response.respond_to?(:input_tokens)
864
919
  input_toks = response.input_tokens.to_i
865
920
  output_toks = response.respond_to?(:output_tokens) ? response.output_tokens.to_i : 0
@@ -888,6 +943,7 @@ module RobotLab
888
943
  # The chat-history fallback is scoped to messages AFTER the last user message
889
944
  # (the current turn) to prevent a previous turn's response from being returned
890
945
  # when a thinking-mode model emits nothing in response.content.
946
+ # :reek:TooManyStatements :reek:FeatureEnvy -- documented fallback chain over the response's optional content/thinking/history fields.
891
947
  def result_text(response)
892
948
  content = response.content if response.respond_to?(:content)
893
949
  return content if content && !content.to_s.empty?
@@ -966,6 +1022,8 @@ module RobotLab
966
1022
  # Tracks tool call names; when a consecutive or cyclic repetition exceeds
967
1023
  # the threshold, embeds a self-correction warning in the tool result so the
968
1024
  # LLM can change strategy without requiring an external circuit breaker.
1025
+ # :reek:TooManyStatements :reek:FeatureEnvy -- the singleton override closes over the detector;
1026
+ # tracking/checking it there is the design.
969
1027
  def install_doom_loop_detection
970
1028
  threshold = @config.doom_loop_threshold || DoomLoopDetector::DEFAULT_THRESHOLD
971
1029
  detector = DoomLoopDetector.new(threshold: threshold)
@@ -989,6 +1047,7 @@ module RobotLab
989
1047
  end
990
1048
 
991
1049
  # Remove the doom loop detection singleton method from @chat.
1050
+ # :reek:FeatureEnvy -- checking-then-removing on the chat's singleton class is one atomic operation.
992
1051
  def remove_doom_loop_detection
993
1052
  sc = @chat.singleton_class
994
1053
  sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool)
@@ -1004,6 +1063,9 @@ module RobotLab
1004
1063
  # Fires the :compaction hook family (before/around/after_compaction).
1005
1064
  # An on_compaction handler can replace the default strategy entirely by
1006
1065
  # setting ctx.compacted_messages; the core algorithm is skipped when handled.
1066
+ # :reek:TooManyStatements -- guard chain plus hook-wrapped strategy dispatch; splitting would separate the hook from its guards.
1067
+ # :reek:DuplicateMethodCall -- @chat.messages is read at different lifecycle points
1068
+ # (guard, before-snapshot, after-snapshot); it mutates in between.
1007
1069
  def maybe_compact(network: nil)
1008
1070
  return if @chat.messages.empty?
1009
1071
 
@@ -60,6 +60,8 @@ module RobotLab
60
60
  # @param stop_reason [String, nil] reason for stopping
61
61
  # @param input_tokens [Integer] input tokens consumed (default 0)
62
62
  # @param output_tokens [Integer] output tokens generated (default 0)
63
+ # :reek:LongParameterList -- serializable value object: one keyword per persisted field.
64
+ # :reek:ControlParameter -- `created_at || Time.now` and `id || SecureRandom.uuid` are nil-safe defaults.
63
65
  def initialize(
64
66
  robot_name:,
65
67
  output:,
@@ -178,6 +180,7 @@ module RobotLab
178
180
  # @param hash [Hash] Serialized result data
179
181
  # @return [RobotResult]
180
182
  #
183
+ # :reek:DuplicateMethodCall -- Message.from_hash maps four distinct message collections; there is no shared receiver to hoist.
181
184
  def self.from_hash(hash)
182
185
  hash = hash.transform_keys(&:to_sym)
183
186
 
@@ -27,6 +27,7 @@ module RobotLab
27
27
  # effective.temperature #=> 0.9
28
28
  # effective.model #=> "claude-sonnet-4"
29
29
  #
30
+ # :reek:RepeatedConditional -- `if value` guards in independent field loops; each skips unset fields.
30
31
  class RunConfig
31
32
  # LLM configuration fields (applied to chat via with_* methods)
32
33
  LLM_FIELDS = %i[
@@ -119,6 +120,8 @@ module RobotLab
119
120
  # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
120
121
  # @param provider [String, Symbol, nil] passed through to chat.with_model's provider: kwarg
121
122
  # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_exists: kwarg
123
+ # :reek:BooleanParameter -- assume_model_exists is a pass-through to RubyLLM's with_model kwarg.
124
+ # :reek:FeatureEnvy -- configuring the chat object handed in is exactly what apply_to is for.
122
125
  def apply_to(chat, provider: nil, assume_model_exists: false)
123
126
  LLM_FIELDS.each do |field|
124
127
  value = @fields[field]
@@ -141,6 +144,7 @@ module RobotLab
141
144
  #
142
145
  # @param metadata [Object] a PM::Metadata object (responds to field names)
143
146
  # @return [RunConfig]
147
+ # :reek:TooManyStatements -- linear duck-typed extraction of LLM then tool fields from front matter.
144
148
  def self.from_front_matter(metadata)
145
149
  fields = {}
146
150
 
@@ -171,6 +175,7 @@ module RobotLab
171
175
 
172
176
  # @param other [RunConfig] the other RunConfig to compare
173
177
  # @return [Boolean]
178
+ # :reek:FeatureEnvy -- equality naturally reads the other operand.
174
179
  def ==(other)
175
180
  other.is_a?(RunConfig) && to_h == other.to_h
176
181
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'shellwords'
5
- require 'timeout'
6
5
 
7
6
  module RobotLab
8
7
  # Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.
@@ -10,7 +9,18 @@ module RobotLab
10
9
  # Given a path to an executable shell script, produces a Tool that shells
11
10
  # out to the script and returns its combined stdout+stderr output.
12
11
  # Non-executable scripts return nil with a logged warning.
12
+ #
13
+ # Core has no sandboxing of its own: by default every script runs unconfined
14
+ # with no timeout. An extension gem (e.g. robot_lab-sandbox) can install a
15
+ # confinement strategy by setting {.executor} to an object responding to
16
+ # +call(cmd, capabilities:, skill_dir:)+; when set, ScriptTool.execute
17
+ # delegates to it instead of running the command directly.
13
18
  module ScriptTool
19
+ class << self
20
+ # @return [#call, nil] optional executor installed by an extension gem
21
+ attr_accessor :executor
22
+ end
23
+
14
24
  # Wrap a script file as a RobotLab::Tool.
15
25
  #
16
26
  # @param script_path [String, Pathname] path to the script file
@@ -19,6 +29,8 @@ module RobotLab
19
29
  # @param capabilities [Capabilities, nil] declared capabilities (from SKILL.md)
20
30
  # @param skill_dir [String, nil] skill bundle root (defaults to the script's dir)
21
31
  # @return [RobotLab::Tool, nil] nil if the script is not executable
32
+ # :reek:ControlParameter -- `capabilities || ...` and `skill_dir || ...` are nil-safe defaults, not behavior selection.
33
+ # :reek:TooManyStatements -- linear derive/validate/build factory; the closure needs every derived local.
22
34
  def self.from_path(script_path, capabilities: nil, skill_dir: nil)
23
35
  path = Pathname.new(script_path)
24
36
 
@@ -52,49 +64,18 @@ module RobotLab
52
64
  end
53
65
  end
54
66
 
55
- # Run a command, optionally confined by the sandbox and a timeout.
67
+ # Run a command, delegating to the installed {.executor} if one is present.
56
68
  #
57
- # When sandboxing is disabled (the default) this is the original, unconfined
58
- # capture2e path with no timeout — behaviour is unchanged. When enabled, the
59
- # command is wrapped by the sandbox strategy for the effective grant and
60
- # bounded by the grant's timeout.
69
+ # With no executor installed (the default core has no sandboxing), this
70
+ # is a plain, unconfined capture2e path with no timeout. An extension gem
71
+ # that sets {.executor} controls confinement and timeout behavior entirely.
61
72
  #
62
73
  # @return [String] combined stdout+stderr, or an error string on failure
63
74
  def self.execute(cmd, capabilities:, skill_dir:)
64
- unless Sandbox.enabled?
65
- output, status = Open3.capture2e(*cmd)
66
- return format_result(output, status)
67
- end
75
+ return executor.call(cmd, capabilities: capabilities, skill_dir: skill_dir) if executor
68
76
 
69
- grant = capabilities.intersect(Capabilities.ceiling)
70
- sandbox = Sandbox.for(grant, skill_dir: skill_dir)
71
- begin
72
- output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
73
- format_result(output, status)
74
- ensure
75
- sandbox.cleanup
76
- end
77
- end
78
-
79
- # @return [Array(String, Process::Status|nil)] output and status (nil = timed out)
80
- def self.run_with_timeout(cmd, timeout)
81
- Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
82
- stdin.close
83
- output = +''
84
- begin
85
- Timeout.timeout(timeout) { output << out.read }
86
- rescue Timeout::Error
87
- terminate(wait.pid)
88
- return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
89
- end
90
- [output, wait.value]
91
- end
92
- end
93
-
94
- def self.terminate(pid)
95
- Process.kill('-TERM', Process.getpgid(pid))
96
- rescue StandardError
97
- nil
77
+ output, status = Open3.capture2e(*cmd)
78
+ format_result(output, status)
98
79
  end
99
80
 
100
81
  # @param status [Process::Status, nil] nil indicates a timeout kill
@@ -147,6 +147,7 @@ module RobotLab
147
147
 
148
148
  # Respond to method calls as hash access
149
149
  #
150
+ # :reek:BooleanParameter -- include_private is Ruby's respond_to_missing? contract signature.
150
151
  def respond_to_missing?(method_name, include_private = false)
151
152
  key = method_name.to_s.chomp("=").to_sym
152
153
  @data.key?(key) || super
@@ -36,6 +36,7 @@ module RobotLab
36
36
  # @param publish [Proc] callback for publishing events
37
37
  # @param parent_run_id [String, nil] parent run identifier
38
38
  # @param sequence_counter [SequenceCounter, nil] shared sequence counter
39
+ # :reek:ControlParameter -- `sequence_counter || SequenceCounter.new` is a nil-safe default.
39
40
  def initialize(run_id:, message_id:, scope:, publish:, parent_run_id: nil, sequence_counter: nil)
40
41
  @run_id = run_id
41
42
  @parent_run_id = parent_run_id
@@ -7,6 +7,7 @@ module RobotLab
7
7
  # Defines the structure and types of events emitted during
8
8
  # robot and network execution.
9
9
  #
10
+ # :reek:TooManyConstants -- deliberately a flat catalog of every streaming event name.
10
11
  module Events
11
12
  # Run lifecycle events
12
13
  RUN_STARTED = "run.started"
@@ -89,6 +89,7 @@ module RobotLab
89
89
 
90
90
  private
91
91
 
92
+ # :reek:TooManyStatements -- one guarded assignment per task-level override injected into run params.
92
93
  def enhanced_result(result)
93
94
  run_params = deep_merge(result.context[:run_params] || {}, @context)
94
95
  run_params[:mcp] = @mcp unless @mcp == :none
@@ -114,6 +115,7 @@ module RobotLab
114
115
  # @param override [Hash] the overriding hash
115
116
  # @return [Hash] the merged result
116
117
  #
118
+ # :reek:FeatureEnvy -- pure recursive hash merge of its two arguments; it has no useful self.
117
119
  def deep_merge(base, override)
118
120
  base = base.transform_keys(&:to_sym)
119
121
  override = override.transform_keys(&:to_sym)
@@ -27,6 +27,8 @@ module RobotLab
27
27
  # enabling tools that modify their robot's state (temperature,
28
28
  # system prompt, spawning, etc.).
29
29
  #
30
+ # :reek:InstanceVariableAssumption -- @custom_name/@mcp are set by Tool.create via instance_variable_set,
31
+ # @raise_on_error/@ractor_safe are class-level DSL ivars; every read is defined?-guarded.
30
32
  class Tool < RubyLLM::Tool
31
33
  # @!attribute [rw] robot
32
34
  # @return [Robot, nil] the robot that owns this tool
@@ -93,6 +95,7 @@ module RobotLab
93
95
  #
94
96
  # @param args [Hash] the tool arguments from the LLM
95
97
  # @return [Object] the tool result or an error string
98
+ # :reek:TooManyStatements -- hook-wrapped dispatch with per-error-class handling; the rescue clauses are the method.
96
99
  def call(args)
97
100
  context = ToolCallHookContext.new(tool: self, tool_args: args, robot: @robot)
98
101
 
@@ -145,6 +148,7 @@ module RobotLab
145
148
  # mcp: "brave_search"
146
149
  # ) { |args| mcp_client.call_tool("search", args) }
147
150
  #
151
+ # :reek:TooManyStatements -- anonymous-class factory: DSL application and instance wiring form one linear build.
148
152
  def self.create(name:, description: nil, parameters: nil, mcp: nil, robot: nil, &handler)
149
153
  desc_text = description
150
154
  params_hash = parameters
@@ -41,6 +41,7 @@ module RobotLab
41
41
  # @param system_prompt [String, nil] additional system prompt
42
42
  # @param metadata [Hash, nil] additional metadata
43
43
  # @param id [String, nil] unique identifier (defaults to UUID)
44
+ # :reek:ControlParameter -- `metadata || {}` and `id || SecureRandom.uuid` are nil-safe defaults.
44
45
  def initialize(content, session_id: nil, system_prompt: nil, metadata: nil, id: nil)
45
46
  @content = content.to_s
46
47
  @session_id = session_id
@@ -13,6 +13,7 @@ module RobotLab
13
13
  # When already inside an Async reactor, creates a child task.
14
14
  # Otherwise, creates a temporary reactor that runs the block
15
15
  # and cleans up automatically.
16
+ # :reek:FeatureEnvy -- formatting the rescued exception for the log line; there is nothing else to reference.
16
17
  def dispatch_async(&block)
17
18
  Async do
18
19
  block.call
@@ -25,6 +26,7 @@ module RobotLab
25
26
  #
26
27
  # @param obj [Object] the object to duplicate
27
28
  # @return [Object] the deep copy
29
+ # :reek:FeatureEnvy -- pure recursive copy of its argument; it has no useful self.
28
30
  def deep_dup(obj)
29
31
  case obj
30
32
  when Hash
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RobotLab
4
- VERSION = "0.2.6"
4
+ VERSION = "0.2.8"
5
5
  end
@@ -28,6 +28,9 @@ module RobotLab
28
28
  # @param timeout [Numeric, nil] maximum seconds to wait (nil = indefinite)
29
29
  # @return [Object, :timeout] the signaled value, or :timeout if timed out
30
30
  #
31
+ # :reek:TooManyStatements -- register/block/drain steps of the wake-pipe protocol are one atomic unit.
32
+ # :reek:DuplicateMethodCall -- the synchronize blocks are deliberately separate: the lock must be released
33
+ # around the blocking wait_readable.
31
34
  def wait(timeout: nil)
32
35
  @mutex.synchronize do
33
36
  return @value if @signaled