robot_lab 0.2.7 → 0.3.0

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -2
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +70 -1
  6. data/CLAUDE.md +4 -2
  7. data/README.md +15 -17
  8. data/Rakefile +6 -111
  9. data/_typos.toml +21 -0
  10. data/docs/api/index.md +2 -2
  11. data/docs/api/skills.md +53 -149
  12. data/docs/api/support.md +1 -1
  13. data/docs/architecture/core-concepts.md +1 -1
  14. data/docs/architecture/index.md +5 -4
  15. data/docs/architecture/state-management.md +4 -4
  16. data/docs/concepts.md +1 -1
  17. data/docs/getting-started/configuration.md +4 -1
  18. data/docs/getting-started/installation.md +1 -4
  19. data/docs/guides/hooks.md +122 -0
  20. data/docs/guides/memory.md +2 -2
  21. data/docs/guides/using-tools.md +10 -1
  22. data/examples/.envrc +2 -0
  23. data/examples/02_tools.rb +8 -8
  24. data/examples/03_network.rb +1 -1
  25. data/examples/04_mcp.rb +7 -5
  26. data/examples/08_llm_config.rb +5 -5
  27. data/examples/09_chaining.rb +3 -3
  28. data/examples/14_rusty_circuit/comic.rb +8 -8
  29. data/examples/14_rusty_circuit/scout.rb +4 -4
  30. data/examples/15_memory_network_and_bus/README.md +66 -0
  31. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  32. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  33. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  34. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  35. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  36. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  37. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  38. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  39. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  40. data/examples/16_writers_room/tools.rb +14 -14
  41. data/examples/19_token_tracking.rb +2 -2
  42. data/examples/20_circuit_breaker.rb +3 -3
  43. data/examples/22_context_compression.rb +1 -1
  44. data/examples/27_incident_response/README.md +65 -0
  45. data/examples/28_mcp_discovery.rb +2 -2
  46. data/examples/29_ractor_tools.rb +4 -4
  47. data/examples/30_ractor_network.rb +2 -2
  48. data/examples/33_stock_predictor.rb +8 -8
  49. data/examples/35_hooks.rb +3 -3
  50. data/examples/README.md +17 -0
  51. data/examples/common.rb +55 -23
  52. data/examples/run_all.rb +60 -0
  53. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  54. data/lib/robot_lab/ask_user.rb +5 -3
  55. data/lib/robot_lab/bus_poller.rb +2 -0
  56. data/lib/robot_lab/capabilities.rb +4 -0
  57. data/lib/robot_lab/config/defaults.yml +5 -5
  58. data/lib/robot_lab/config.rb +10 -2
  59. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  60. data/lib/robot_lab/history_compressor.rb +5 -0
  61. data/lib/robot_lab/hook.rb +1 -0
  62. data/lib/robot_lab/hook_context.rb +4 -0
  63. data/lib/robot_lab/hook_registry.rb +1 -0
  64. data/lib/robot_lab/hooks.rb +6 -3
  65. data/lib/robot_lab/mcp/client.rb +2 -2
  66. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  67. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  68. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  69. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  70. data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
  71. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  72. data/lib/robot_lab/memory.rb +39 -13
  73. data/lib/robot_lab/memory_change.rb +1 -0
  74. data/lib/robot_lab/message.rb +3 -0
  75. data/lib/robot_lab/names.rb +2 -4
  76. data/lib/robot_lab/network.rb +13 -6
  77. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  78. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  79. data/lib/robot_lab/robot/history_search.rb +2 -0
  80. data/lib/robot_lab/robot/hooking.rb +29 -0
  81. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  82. data/lib/robot_lab/robot/result_building.rb +119 -0
  83. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  84. data/lib/robot_lab/robot.rb +88 -130
  85. data/lib/robot_lab/robot_result.rb +3 -0
  86. data/lib/robot_lab/run_config.rb +57 -20
  87. data/lib/robot_lab/script_tool.rb +20 -39
  88. data/lib/robot_lab/state_proxy.rb +1 -0
  89. data/lib/robot_lab/streaming/context.rb +1 -0
  90. data/lib/robot_lab/streaming/events.rb +1 -0
  91. data/lib/robot_lab/task.rb +2 -0
  92. data/lib/robot_lab/tool.rb +11 -12
  93. data/lib/robot_lab/user_message.rb +1 -0
  94. data/lib/robot_lab/utils.rb +2 -0
  95. data/lib/robot_lab/version.rb +1 -1
  96. data/lib/robot_lab/waiter.rb +3 -0
  97. data/lib/robot_lab.rb +9 -6
  98. metadata +23 -32
  99. data/lib/robot_lab/sandbox/null.rb +0 -13
  100. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  101. data/lib/robot_lab/sandbox.rb +0 -52
@@ -7,6 +7,7 @@ require_relative 'robot/history_search'
7
7
  require_relative 'robot/agent_skill_matching'
8
8
  require_relative 'robot/budget'
9
9
  require_relative 'robot/hooking'
10
+ require_relative 'robot/result_building'
10
11
 
11
12
  module RobotLab
12
13
  # LLM-powered robot built on RubyLLM::Agent
@@ -41,6 +42,12 @@ module RobotLab
41
42
  # local_tools: [OrderLookup, RefundProcessor]
42
43
  # )
43
44
  #
45
+ # :reek:TooManyMethods :reek:TooManyInstanceVariables -- Robot is intentionally the central orchestrator
46
+ # (see CLAUDE.md); behavior is split into the included modules below.
47
+ # :reek:InstanceVariableAssumption -- every ivar is assigned in #initialize via assign_identity_ivars/
48
+ # extract_config_ivars/initialize_runtime_state/initialize_memory; reek does not trace those helpers.
49
+ # :reek:RepeatedConditional -- `@bus` presence gates optional bus wiring at each independent site.
50
+ # :reek:DataClump -- (context, system_prompt, template) travel together as the robot's identity triple across construction APIs.
44
51
  class Robot < RubyLLM::Agent
45
52
  include Robot::TemplateRendering
46
53
  include Robot::MCPManagement
@@ -48,6 +55,7 @@ module RobotLab
48
55
  include Robot::HistorySearch
49
56
  include Robot::Budget
50
57
  include Robot::Hooking
58
+ include Robot::ResultBuilding
51
59
  include Runnable
52
60
  prepend Robot::AgentSkillMatching
53
61
 
@@ -155,6 +163,8 @@ module RobotLab
155
163
  # @param stop [String, Array, nil] stop sequences
156
164
  # @param skills [Symbol, Array<Symbol>, nil] skill templates to prepend
157
165
  # @param config [RunConfig, nil] shared configuration (merged with explicit kwargs)
166
+ # :reek:LongParameterList -- the documented public constructor: one keyword per robot capability.
167
+ # :reek:BooleanParameter -- enable_cache and mcp_discovery are documented feature toggles.
158
168
  def initialize(
159
169
  name:,
160
170
  template: nil,
@@ -230,6 +240,7 @@ module RobotLab
230
240
  # Returns the model identifier
231
241
  #
232
242
  # @return [String, nil] the LLM model ID string
243
+ # :reek:FeatureEnvy -- duck-type unwrapping of the chat's model object into an id string.
233
244
  def model
234
245
  return nil unless @chat.respond_to?(:model)
235
246
 
@@ -245,6 +256,8 @@ module RobotLab
245
256
  # @param model [String, nil] new model
246
257
  # @param temperature [Float, nil] new temperature
247
258
  # @return [self]
259
+ # :reek:LongParameterList :reek:TooManyStatements -- public reconfiguration API; one guarded with_* application per option.
260
+ # :reek:ControlParameter -- `context || @build_context` is a fallback default, not behavior selection.
248
261
  def update(template: nil, context: nil, system_prompt: nil, model: nil, temperature: nil, **kwargs)
249
262
  if template
250
263
  @template = template
@@ -268,6 +281,8 @@ module RobotLab
268
281
  #
269
282
  # @param result [SimpleFlow::Result] incoming result from previous step
270
283
  # @return [SimpleFlow::Result] result with robot output
284
+ # :reek:TooManyStatements -- SimpleFlow step: timing, run, and error-shielding belong together.
285
+ # :reek:DuplicateMethodCall -- each clock_gettime samples a different instant (start/stop/rescue); a local would be wrong.
271
286
  def call(result)
272
287
  run_context = extract_run_context(result)
273
288
 
@@ -354,9 +369,7 @@ module RobotLab
354
369
  # access to conversation state.
355
370
  #
356
371
  # @return [RubyLLM::Chat]
357
- def chat
358
- @chat
359
- end
372
+ attr_reader :chat
360
373
 
361
374
  # Return the conversation messages from the underlying chat.
362
375
  #
@@ -369,13 +382,14 @@ 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 }
375
- @chat.reset_messages!
389
+ @chat.messages = []
376
390
  @chat.add_message(system_msg) if system_msg
377
391
  else
378
- @chat.reset_messages!
392
+ @chat.messages = []
379
393
  end
380
394
  self
381
395
  end
@@ -385,8 +399,8 @@ module RobotLab
385
399
  # @param messages [Array<RubyLLM::Message>] the messages to restore
386
400
  # @return [self]
387
401
  def replace_messages(messages)
388
- @chat.reset_messages!
389
- messages.each { |m| @chat.add_message(m) }
402
+ @chat.messages = []
403
+ messages.each { |m| @chat.add_message(coerce_replacement_message(m)) }
390
404
  self
391
405
  end
392
406
 
@@ -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:,
@@ -640,6 +665,8 @@ module RobotLab
640
665
  @learnings = []
641
666
  @hooks = HookRegistry.new
642
667
  @budget_ledger = build_budget_ledger
668
+ @circuit_breaker_armed = false
669
+ @circuit_breaker_call_count = 0
643
670
  end
644
671
 
645
672
  def initialize_memory
@@ -691,25 +718,39 @@ module RobotLab
691
718
 
692
719
  def apply_chat_params
693
720
  @chat.with_temperature(@config.temperature) if @config.temperature
721
+ @chat.with_max_output_tokens(@config.max_tokens) if @config.max_tokens
694
722
 
723
+ # ruby_llm 2.0 has no with_params; provider-specific sampling knobs are
724
+ # merged into the request payload via with_provider_options instead.
725
+ # with_provider_options replaces prior options, so merge with whatever
726
+ # the chat already carries (e.g. from template front matter).
695
727
  extra_params = {
696
728
  top_p: @config.top_p, top_k: @config.top_k,
697
- max_tokens: @config.max_tokens,
698
729
  presence_penalty: @config.presence_penalty,
699
730
  frequency_penalty: @config.frequency_penalty,
700
731
  stop: @config.stop
701
732
  }.compact
702
- @chat.with_params(**extra_params) if extra_params.any?
733
+ @chat.with_provider_options(@chat.provider_options.to_h.merge(extra_params)) if extra_params.any?
703
734
  end
704
735
 
736
+ # ruby_llm 2.0 callbacks (before_tool_call/after_tool_result) are additive
737
+ # and cannot be removed, so register a single dispatcher per chat that
738
+ # consults the robot's current state. The per-run circuit breaker arms and
739
+ # disarms a flag instead of swapping callbacks.
705
740
  def register_chat_callbacks
706
- @chat.on_tool_call(&@on_tool_call) if @on_tool_call
707
- @chat.on_tool_result(&@on_tool_result) if @on_tool_result
741
+ @chat.before_tool_call do |tool_call|
742
+ enforce_circuit_breaker! if @circuit_breaker_armed
743
+ @on_tool_call&.call(tool_call)
744
+ end
745
+ @chat.after_tool_result do |result|
746
+ @on_tool_result&.call(result)
747
+ end
708
748
  setup_bus_channel if @bus
709
749
  end
710
750
 
711
751
  # Dynamically delegate all with_* methods from @chat, returning self for chaining.
712
752
  # Discovered from the actual Chat class to avoid maintenance sync issues.
753
+ # :reek:NestedIterators -- the inner block is the delegator method body being defined, not an iteration.
713
754
  def define_chat_delegators
714
755
  @chat.class.public_instance_methods(false)
715
756
  .select { |m| m.start_with?('with_') }
@@ -721,6 +762,7 @@ module RobotLab
721
762
  end
722
763
  end
723
764
 
765
+ # :reek:ControlParameter -- `network_memory || network&.memory || @memory` is the documented memory cascade.
724
766
  def resolve_active_memory(network: nil, network_memory: nil)
725
767
  network_memory || network&.memory || @memory
726
768
  end
@@ -734,6 +776,7 @@ module RobotLab
734
776
  end
735
777
  end
736
778
 
779
+ # :reek:TooManyStatements -- resolve/discover/connect/filter/attach is one linear per-turn tool pipeline.
737
780
  def prepare_tools(message:, mcp:, tools:, network:, network_config:)
738
781
  resolved_mcp = resolve_mcp_hierarchy(mcp, network: network, network_config: network_config)
739
782
  resolved_tools = resolve_tools_hierarchy(tools, network: network, network_config: network_config)
@@ -752,12 +795,16 @@ module RobotLab
752
795
  # runtime value before resolution to honor the zero-tools intent.
753
796
  filtered = explicit_none_tools?(tools) ? [] : cap_tools(filtered_tools(resolved_tools))
754
797
 
755
- # replace: true so the chat holds EXACTLY this turn's resolved+capped set.
756
- # RubyLLM's with_tools appends by default; on a persistent chat that lets
757
- # tools accumulate across turns, so a capped per-turn addition could still
758
- # push the chat's total past the provider limit. An explicit none clears
759
- # the chat's tools to zero (with_tools(replace: true) with no tools).
760
- @chat.with_tools(*filtered, replace: true) if filtered.any? || explicit_none_tools?(tools)
798
+ # Clear-then-add so the chat holds EXACTLY this turn's resolved+capped
799
+ # set. RubyLLM's with_tools appends; on a persistent chat that lets tools
800
+ # accumulate across turns, so a capped per-turn addition could still push
801
+ # the chat's total past the provider limit. with_tools(nil) is ruby_llm
802
+ # 2.0's "clear the tool set"; an explicit none stops there, sending zero
803
+ # tools this turn.
804
+ return unless filtered.any? || explicit_none_tools?(tools)
805
+
806
+ @chat.with_tools(nil)
807
+ @chat.with_tools(*filtered) if filtered.any?
761
808
  end
762
809
 
763
810
  # True when the runtime tools value explicitly requests zero tools — `:none`
@@ -782,13 +829,15 @@ module RobotLab
782
829
  #
783
830
  # @param tools [Array<Tool>] the resolved tools
784
831
  # @return [Array<Tool>] at most `max_tools` tools
832
+ # :reek:FeatureEnvy -- clamping the passed-in tool list against the provider limit is a pure filter.
785
833
  def cap_tools(tools)
786
- max = effective_max_tools
787
- return tools if max.nil? || tools.size <= max
834
+ max = effective_max_tools
835
+ size = tools.size
836
+ return tools if max.nil? || size <= max
788
837
 
789
838
  RobotLab.config.logger.warn(
790
- "[#{@name}] tool list (#{tools.size}) exceeds max_tools (#{max}); " \
791
- "sending #{max}, dropping #{tools.size - max}"
839
+ "[#{@name}] tool list (#{size}) exceeds max_tools (#{max}); " \
840
+ "sending #{max}, dropping #{size - max}"
792
841
  )
793
842
  tools.first(max)
794
843
  end
@@ -802,10 +851,12 @@ module RobotLab
802
851
  configured&.positive? ? configured : DEFAULT_MAX_TOOLS
803
852
  end
804
853
 
854
+ # :reek:TooManyStatements -- builds the generation hook context then runs the pre-ask setup chain in order.
805
855
  def invoke_ask(context:, kwargs:, hooks:, block:)
856
+ network = context.network
806
857
  generation_context = LlmGenerationHookContext.new(
807
858
  robot: self,
808
- network: context.network,
859
+ network: network,
809
860
  task: context.task,
810
861
  memory: context.memory,
811
862
  config: context.config,
@@ -814,9 +865,9 @@ module RobotLab
814
865
  )
815
866
 
816
867
  RobotLab::Hooks.run(:llm_generation, generation_context,
817
- registries: hook_registries(context.network), per_run_hooks: hooks) do
868
+ registries: hook_registries(network), per_run_hooks: hooks) do
818
869
  effective_message = inject_learnings(generation_context.request)
819
- maybe_compact(network: context.network)
870
+ maybe_compact(network: network)
820
871
  install_circuit_breaker if @config.max_tool_rounds
821
872
  install_doom_loop_detection
822
873
  ask_kwargs = kwargs.slice(:with)
@@ -830,6 +881,7 @@ module RobotLab
830
881
  end
831
882
 
832
883
  # Extract run context from SimpleFlow::Result
884
+ # :reek:TooManyStatements -- one delete/re-add per robot-specific run param; a loop would hide which keys are special.
833
885
  def extract_run_context(result)
834
886
  run_params = (result.context[:run_params] || {}).dup
835
887
 
@@ -846,15 +898,16 @@ module RobotLab
846
898
  base = run_params.dup
847
899
 
848
900
  # Merge current value into context
849
- merged = case result.value
901
+ value = result.value
902
+ merged = case value
850
903
  when Hash
851
- base.merge(result.value.transform_keys(&:to_sym))
904
+ base.merge(value.transform_keys(&:to_sym))
852
905
  when RobotResult
853
- base.merge(message: result.value.last_text_content)
906
+ base.merge(message: value.last_text_content)
854
907
  when String
855
- base.merge(message: result.value)
908
+ base.merge(message: value)
856
909
  else
857
- base.merge(message: result.value.to_s)
910
+ base.merge(message: value.to_s)
858
911
  end
859
912
 
860
913
  # Add back the special params
@@ -869,83 +922,6 @@ module RobotLab
869
922
  merged
870
923
  end
871
924
 
872
- def build_result(response, _memory)
873
- text = result_text(response)
874
- output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
875
-
876
- tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
877
-
878
- # Extract token usage from the response
879
- input_toks = 0
880
- output_toks = 0
881
- if response.respond_to?(:tokens) && response.tokens
882
- input_toks = response.tokens.input.to_i
883
- output_toks = response.tokens.output.to_i
884
- elsif response.respond_to?(:input_tokens)
885
- input_toks = response.input_tokens.to_i
886
- output_toks = response.respond_to?(:output_tokens) ? response.output_tokens.to_i : 0
887
- end
888
-
889
- @total_input_tokens += input_toks
890
- @total_output_tokens += output_toks
891
-
892
- RobotResult.new(
893
- robot_name: @name,
894
- output: output,
895
- tool_calls: normalize_tool_calls(tool_calls),
896
- stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
897
- raw: response,
898
- input_tokens: input_toks,
899
- output_tokens: output_toks
900
- )
901
- end
902
-
903
- # Text for the result's output. Prefers the final response's content, then
904
- # falls back in order to: (1) thinking text for models that route all output
905
- # through reasoning_content (e.g. qwen3 on Ollama), (2) the most recent
906
- # assistant text within the current turn for models that end on a tool call
907
- # with no trailing text.
908
- #
909
- # The chat-history fallback is scoped to messages AFTER the last user message
910
- # (the current turn) to prevent a previous turn's response from being returned
911
- # when a thinking-mode model emits nothing in response.content.
912
- def result_text(response)
913
- content = response.content if response.respond_to?(:content)
914
- return content if content && !content.to_s.empty?
915
-
916
- # Ollama routes qwen3's reasoning to reasoning_content, which ruby_llm
917
- # surfaces as response.thinking (a RubyLLM::Thinking object). When content
918
- # is nil and thinking is present, the thinking IS the response for that turn.
919
- if response.respond_to?(:thinking) && (thinking = response.thinking)
920
- thinking_text = thinking.respond_to?(:text) ? thinking.text.to_s : thinking.to_s
921
- return thinking_text unless thinking_text.empty?
922
- end
923
-
924
- return nil unless @chat.respond_to?(:messages)
925
-
926
- messages = @chat.messages
927
- last_user_idx = messages.rindex { |m| m.role == :user } || -1
928
- current_turn = messages[(last_user_idx + 1)..]
929
-
930
- last = current_turn.rfind { |m| m.role == :assistant && m.content && !m.content.to_s.empty? }
931
- last&.content
932
- end
933
-
934
- def normalize_tool_calls(tool_calls)
935
- return [] unless tool_calls
936
-
937
- tool_calls.map do |tc|
938
- if tc.is_a?(Hash)
939
- ToolResultMessage.new(
940
- tool: tc,
941
- content: tc[:result] || tc['result']
942
- )
943
- else
944
- tc
945
- end
946
- end
947
- end
948
-
949
925
  # Merge the stored on_content callback with a runtime streaming block.
950
926
  # If both exist, both fire (stored first, then runtime block).
951
927
  #
@@ -987,6 +963,8 @@ module RobotLab
987
963
  # Tracks tool call names; when a consecutive or cyclic repetition exceeds
988
964
  # the threshold, embeds a self-correction warning in the tool result so the
989
965
  # LLM can change strategy without requiring an external circuit breaker.
966
+ # :reek:TooManyStatements :reek:FeatureEnvy -- the singleton override closes over the detector;
967
+ # tracking/checking it there is the design.
990
968
  def install_doom_loop_detection
991
969
  threshold = @config.doom_loop_threshold || DoomLoopDetector::DEFAULT_THRESHOLD
992
970
  detector = DoomLoopDetector.new(threshold: threshold)
@@ -1010,6 +988,7 @@ module RobotLab
1010
988
  end
1011
989
 
1012
990
  # Remove the doom loop detection singleton method from @chat.
991
+ # :reek:FeatureEnvy -- checking-then-removing on the chat's singleton class is one atomic operation.
1013
992
  def remove_doom_loop_detection
1014
993
  sc = @chat.singleton_class
1015
994
  sc.remove_method(:execute_tool) if sc.method_defined?(:execute_tool)
@@ -1025,6 +1004,9 @@ module RobotLab
1025
1004
  # Fires the :compaction hook family (before/around/after_compaction).
1026
1005
  # An on_compaction handler can replace the default strategy entirely by
1027
1006
  # setting ctx.compacted_messages; the core algorithm is skipped when handled.
1007
+ # :reek:TooManyStatements -- guard chain plus hook-wrapped strategy dispatch; splitting would separate the hook from its guards.
1008
+ # :reek:DuplicateMethodCall -- @chat.messages is read at different lifecycle points
1009
+ # (guard, before-snapshot, after-snapshot); it mutates in between.
1028
1010
  def maybe_compact(network: nil)
1029
1011
  return if @chat.messages.empty?
1030
1012
 
@@ -1083,29 +1065,5 @@ module RobotLab
1083
1065
  RobotLab.config.logger.warn("[#{@name}] auto_compact: #{e.message}; skipping compaction")
1084
1066
  end
1085
1067
  end
1086
-
1087
- # Install a per-run circuit breaker on the chat's on_tool_call hook.
1088
- # Raises ToolLoopError if tool calls exceed @config.max_tool_rounds.
1089
- # Stores the previous callback so restore_tool_call_callback can undo it.
1090
- def install_circuit_breaker
1091
- @circuit_breaker_call_count = 0
1092
- max = @config.max_tool_rounds
1093
- original = @on_tool_call
1094
-
1095
- @chat.on_tool_call do |tool_call|
1096
- @circuit_breaker_call_count += 1
1097
- if @circuit_breaker_call_count > max
1098
- raise ToolLoopError,
1099
- "Circuit breaker triggered: #{@circuit_breaker_call_count} tool calls exceeded " \
1100
- "max_tool_rounds (#{max})"
1101
- end
1102
- original&.call(tool_call)
1103
- end
1104
- end
1105
-
1106
- # Restore the original on_tool_call callback after a circuit-breaker run.
1107
- def restore_tool_call_callback
1108
- @chat.on_tool_call(&@on_tool_call)
1109
- end
1110
1068
  end
1111
1069
  end
@@ -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
 
@@ -12,21 +12,22 @@ module RobotLab
12
12
  # config's non-nil values win over the less-specific config.
13
13
  #
14
14
  # @example Keyword construction
15
- # config = RunConfig.new(model: "claude-sonnet-4", temperature: 0.7)
15
+ # config = RunConfig.new(model: "claude-sonnet-4-6", temperature: 0.7)
16
16
  #
17
17
  # @example Block DSL
18
18
  # config = RunConfig.new do |c|
19
- # c.model "claude-sonnet-4"
19
+ # c.model "claude-sonnet-4-6"
20
20
  # c.temperature 0.7
21
21
  # end
22
22
  #
23
23
  # @example Merge (more-specific wins)
24
- # network_config = RunConfig.new(model: "claude-sonnet-4", temperature: 0.5)
24
+ # network_config = RunConfig.new(model: "claude-sonnet-4-6", temperature: 0.5)
25
25
  # robot_config = RunConfig.new(temperature: 0.9)
26
26
  # effective = network_config.merge(robot_config)
27
27
  # effective.temperature #=> 0.9
28
- # effective.model #=> "claude-sonnet-4"
28
+ # effective.model #=> "claude-sonnet-4-6"
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[
@@ -105,6 +106,10 @@ module RobotLab
105
106
  self.class.new(**merged)
106
107
  end
107
108
 
109
+ # Fields that ruby_llm 2.0 accepts only through with_provider_options
110
+ # (merged into the request payload in the provider's own vocabulary).
111
+ PROVIDER_OPTION_FIELDS = %i[top_p top_k presence_penalty frequency_penalty stop].freeze
112
+
108
113
  # Applies LLM fields to a chat object via its with_* methods.
109
114
  #
110
115
  # +provider+/+assume_model_exists+ are threaded through to +with_model+
@@ -116,31 +121,33 @@ module RobotLab
116
121
  # raises ModelNotFoundError for any local-provider model (Ollama, etc.)
117
122
  # not in RubyLLM's bundled registry.
118
123
  #
124
+ # ruby_llm 2.0 mapping: +max_tokens+ applies via with_max_output_tokens,
125
+ # and the sampling knobs (top_p, top_k, penalties, stop) go through
126
+ # with_provider_options, merged with any options the chat already has.
127
+ #
119
128
  # @param chat [Object] a RubyLLM::Chat (or similar) that responds to with_model, with_temperature, etc.
120
129
  # @param provider [String, Symbol, nil] passed through to chat.with_model's provider: kwarg
121
- # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_exists: kwarg
130
+ # @param assume_model_exists [Boolean] passed through to chat.with_model's assume_model_exists: kwarg
131
+ # :reek:BooleanParameter -- assume_model_exists is a pass-through to RubyLLM's with_model kwarg.
132
+ # :reek:FeatureEnvy -- configuring the chat object handed in is exactly what apply_to is for.
133
+ # :reek:TooManyStatements -- one guarded application per LLM field group.
122
134
  def apply_to(chat, provider: nil, assume_model_exists: false)
123
- LLM_FIELDS.each do |field|
124
- value = @fields[field]
125
- next unless value
126
-
127
- if field == :model && provider
128
- # Only take the provider-aware path when a provider was actually
129
- # given -- preserves the original single-arg call (and thus
130
- # compatibility with any chat-like object exposing only
131
- # `with_model(value)`) for the common case.
132
- chat.with_model(value, provider:, assume_exists: assume_model_exists) if chat.respond_to?(:with_model)
133
- else
134
- method = :"with_#{field}"
135
- chat.public_send(method, value) if chat.respond_to?(method)
136
- end
137
- end
135
+ apply_model_to(chat, provider: provider, assume_model_exists: assume_model_exists)
136
+
137
+ temperature = @fields[:temperature]
138
+ chat.with_temperature(temperature) if temperature && chat.respond_to?(:with_temperature)
139
+
140
+ max_tokens = @fields[:max_tokens]
141
+ chat.with_max_output_tokens(max_tokens) if max_tokens && chat.respond_to?(:with_max_output_tokens)
142
+
143
+ apply_provider_options_to(chat)
138
144
  end
139
145
 
140
146
  # Build a RunConfig from prompt_manager front matter metadata.
141
147
  #
142
148
  # @param metadata [Object] a PM::Metadata object (responds to field names)
143
149
  # @return [RunConfig]
150
+ # :reek:TooManyStatements -- linear duck-typed extraction of LLM then tool fields from front matter.
144
151
  def self.from_front_matter(metadata)
145
152
  fields = {}
146
153
 
@@ -171,6 +178,7 @@ module RobotLab
171
178
 
172
179
  # @param other [RunConfig] the other RunConfig to compare
173
180
  # @return [Boolean]
181
+ # :reek:FeatureEnvy -- equality naturally reads the other operand.
174
182
  def ==(other)
175
183
  other.is_a?(RunConfig) && to_h == other.to_h
176
184
  end
@@ -182,6 +190,35 @@ module RobotLab
182
190
 
183
191
  private
184
192
 
193
+ # Apply the model field, threading provider/assume_model_exists through
194
+ # only when a provider was actually given -- preserves the original
195
+ # single-arg call (and thus compatibility with any chat-like object
196
+ # exposing only `with_model(value)`) for the common case.
197
+ # :reek:BooleanParameter -- pass-through to RubyLLM's with_model kwarg.
198
+ # :reek:FeatureEnvy -- configuring the chat handed in is exactly what apply_to delegates here.
199
+ def apply_model_to(chat, provider:, assume_model_exists:)
200
+ model = @fields[:model]
201
+ return unless model && chat.respond_to?(:with_model)
202
+
203
+ if provider
204
+ chat.with_model(model, provider: provider, assume_model_exists: assume_model_exists)
205
+ else
206
+ chat.with_model(model)
207
+ end
208
+ end
209
+
210
+ # Merge the sampling knobs into the chat's provider options.
211
+ # with_provider_options replaces prior options wholesale, so merge with
212
+ # the chat's current set to keep options applied earlier.
213
+ # :reek:FeatureEnvy -- configuring the chat handed in is exactly what apply_to delegates here.
214
+ def apply_provider_options_to(chat)
215
+ options = @fields.slice(*PROVIDER_OPTION_FIELDS)
216
+ return if options.empty? || !chat.respond_to?(:with_provider_options)
217
+
218
+ current = chat.respond_to?(:provider_options) ? chat.provider_options.to_h : {}
219
+ chat.with_provider_options(current.merge(options))
220
+ end
221
+
185
222
  # Validates and stores a field value. Nil removes the key.
186
223
  def set(field, value)
187
224
  field = field.to_sym