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.
- checksums.yaml +4 -4
- data/.envrc +6 -2
- data/.loki +5 -0
- data/Archspec.rb +44 -0
- data/CHANGELOG.md +70 -1
- data/CLAUDE.md +4 -2
- data/README.md +15 -17
- data/Rakefile +6 -111
- data/_typos.toml +21 -0
- data/docs/api/index.md +2 -2
- data/docs/api/skills.md +53 -149
- data/docs/api/support.md +1 -1
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/index.md +5 -4
- data/docs/architecture/state-management.md +4 -4
- data/docs/concepts.md +1 -1
- data/docs/getting-started/configuration.md +4 -1
- data/docs/getting-started/installation.md +1 -4
- data/docs/guides/hooks.md +122 -0
- data/docs/guides/memory.md +2 -2
- data/docs/guides/using-tools.md +10 -1
- data/examples/.envrc +2 -0
- data/examples/02_tools.rb +8 -8
- data/examples/03_network.rb +1 -1
- data/examples/04_mcp.rb +7 -5
- data/examples/08_llm_config.rb +5 -5
- data/examples/09_chaining.rb +3 -3
- data/examples/14_rusty_circuit/comic.rb +8 -8
- data/examples/14_rusty_circuit/scout.rb +4 -4
- data/examples/15_memory_network_and_bus/README.md +66 -0
- data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
- data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
- data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
- data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
- data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/tools.rb +14 -14
- data/examples/19_token_tracking.rb +2 -2
- data/examples/20_circuit_breaker.rb +3 -3
- data/examples/22_context_compression.rb +1 -1
- data/examples/27_incident_response/README.md +65 -0
- data/examples/28_mcp_discovery.rb +2 -2
- data/examples/29_ractor_tools.rb +4 -4
- data/examples/30_ractor_network.rb +2 -2
- data/examples/33_stock_predictor.rb +8 -8
- data/examples/35_hooks.rb +3 -3
- data/examples/README.md +17 -0
- data/examples/common.rb +55 -23
- data/examples/run_all.rb +60 -0
- data/lib/robot_lab/agent_skill_catalog.rb +1 -0
- data/lib/robot_lab/ask_user.rb +5 -3
- data/lib/robot_lab/bus_poller.rb +2 -0
- data/lib/robot_lab/capabilities.rb +4 -0
- data/lib/robot_lab/config/defaults.yml +5 -5
- data/lib/robot_lab/config.rb +10 -2
- data/lib/robot_lab/doom_loop_detector.rb +6 -3
- data/lib/robot_lab/history_compressor.rb +5 -0
- data/lib/robot_lab/hook.rb +1 -0
- data/lib/robot_lab/hook_context.rb +4 -0
- data/lib/robot_lab/hook_registry.rb +1 -0
- data/lib/robot_lab/hooks.rb +6 -3
- data/lib/robot_lab/mcp/client.rb +2 -2
- data/lib/robot_lab/mcp/connection_poller.rb +16 -8
- data/lib/robot_lab/mcp/server_discovery.rb +1 -0
- data/lib/robot_lab/mcp/transports/sse.rb +3 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
- data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
- data/lib/robot_lab/memory.rb +39 -13
- data/lib/robot_lab/memory_change.rb +1 -0
- data/lib/robot_lab/message.rb +3 -0
- data/lib/robot_lab/names.rb +2 -4
- data/lib/robot_lab/network.rb +13 -6
- data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
- data/lib/robot_lab/robot/bus_messaging.rb +3 -0
- data/lib/robot_lab/robot/history_search.rb +2 -0
- data/lib/robot_lab/robot/hooking.rb +29 -0
- data/lib/robot_lab/robot/mcp_management.rb +11 -3
- data/lib/robot_lab/robot/result_building.rb +119 -0
- data/lib/robot_lab/robot/template_rendering.rb +13 -4
- data/lib/robot_lab/robot.rb +88 -130
- data/lib/robot_lab/robot_result.rb +3 -0
- data/lib/robot_lab/run_config.rb +57 -20
- data/lib/robot_lab/script_tool.rb +20 -39
- data/lib/robot_lab/state_proxy.rb +1 -0
- data/lib/robot_lab/streaming/context.rb +1 -0
- data/lib/robot_lab/streaming/events.rb +1 -0
- data/lib/robot_lab/task.rb +2 -0
- data/lib/robot_lab/tool.rb +11 -12
- data/lib/robot_lab/user_message.rb +1 -0
- data/lib/robot_lab/utils.rb +2 -0
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +3 -0
- data/lib/robot_lab.rb +9 -6
- metadata +23 -32
- data/lib/robot_lab/sandbox/null.rb +0 -13
- data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
- data/lib/robot_lab/sandbox.rb +0 -52
data/lib/robot_lab/robot.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
389
|
+
@chat.messages = []
|
|
376
390
|
@chat.add_message(system_msg) if system_msg
|
|
377
391
|
else
|
|
378
|
-
@chat.
|
|
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.
|
|
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.
|
|
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.
|
|
707
|
-
|
|
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
|
-
#
|
|
756
|
-
# RubyLLM's with_tools appends
|
|
757
|
-
#
|
|
758
|
-
#
|
|
759
|
-
#
|
|
760
|
-
|
|
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
|
|
787
|
-
|
|
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 (#{
|
|
791
|
-
"sending #{max}, dropping #{
|
|
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:
|
|
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(
|
|
868
|
+
registries: hook_registries(network), per_run_hooks: hooks) do
|
|
818
869
|
effective_message = inject_learnings(generation_context.request)
|
|
819
|
-
maybe_compact(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
|
-
|
|
901
|
+
value = result.value
|
|
902
|
+
merged = case value
|
|
850
903
|
when Hash
|
|
851
|
-
base.merge(
|
|
904
|
+
base.merge(value.transform_keys(&:to_sym))
|
|
852
905
|
when RobotResult
|
|
853
|
-
base.merge(message:
|
|
906
|
+
base.merge(message: value.last_text_content)
|
|
854
907
|
when String
|
|
855
|
-
base.merge(message:
|
|
908
|
+
base.merge(message: value)
|
|
856
909
|
else
|
|
857
|
-
base.merge(message:
|
|
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
|
|
data/lib/robot_lab/run_config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|