robot_lab 0.2.8 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +2 -2
  3. data/CHANGELOG.md +53 -0
  4. data/CLAUDE.md +4 -2
  5. data/README.md +15 -17
  6. data/docs/architecture/core-concepts.md +1 -1
  7. data/docs/architecture/state-management.md +4 -4
  8. data/docs/concepts.md +1 -1
  9. data/docs/getting-started/installation.md +1 -4
  10. data/docs/guides/memory.md +2 -2
  11. data/examples/.envrc +2 -0
  12. data/examples/02_tools.rb +8 -8
  13. data/examples/03_network.rb +1 -1
  14. data/examples/04_mcp.rb +7 -5
  15. data/examples/08_llm_config.rb +5 -5
  16. data/examples/09_chaining.rb +3 -3
  17. data/examples/14_rusty_circuit/comic.rb +8 -8
  18. data/examples/14_rusty_circuit/scout.rb +4 -4
  19. data/examples/15_memory_network_and_bus/README.md +66 -0
  20. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  21. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  22. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  23. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  24. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  25. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  26. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  27. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  28. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  29. data/examples/16_writers_room/tools.rb +14 -14
  30. data/examples/19_token_tracking.rb +2 -2
  31. data/examples/20_circuit_breaker.rb +3 -3
  32. data/examples/22_context_compression.rb +1 -1
  33. data/examples/27_incident_response/README.md +65 -0
  34. data/examples/28_mcp_discovery.rb +2 -2
  35. data/examples/29_ractor_tools.rb +4 -4
  36. data/examples/30_ractor_network.rb +2 -2
  37. data/examples/33_stock_predictor.rb +8 -8
  38. data/examples/35_hooks.rb +3 -3
  39. data/examples/README.md +17 -0
  40. data/examples/common.rb +55 -23
  41. data/examples/run_all.rb +60 -0
  42. data/lib/robot_lab/ask_user.rb +3 -3
  43. data/lib/robot_lab/config/defaults.yml +5 -5
  44. data/lib/robot_lab/config.rb +2 -2
  45. data/lib/robot_lab/mcp/transports/streamable_http.rb +1 -3
  46. data/lib/robot_lab/memory.rb +16 -7
  47. data/lib/robot_lab/robot/hooking.rb +26 -0
  48. data/lib/robot_lab/robot/result_building.rb +119 -0
  49. data/lib/robot_lab/robot.rb +37 -120
  50. data/lib/robot_lab/run_config.rb +52 -20
  51. data/lib/robot_lab/tool.rb +7 -12
  52. data/lib/robot_lab/version.rb +1 -1
  53. data/lib/robot_lab.rb +3 -3
  54. metadata +18 -27
@@ -46,7 +46,7 @@ end
46
46
  class WordStatsTool < TextTool
47
47
  description "Count words, sentences, and average word length"
48
48
 
49
- param :text, type: :string, desc: "Text to analyze"
49
+ parameter :text, type: :string, description: "Text to analyze"
50
50
 
51
51
  def execute(text:)
52
52
  words = text.scan(/\b\w+\b/)
@@ -61,7 +61,7 @@ end
61
61
  class ReadabilityTool < TextTool
62
62
  description "Estimate words-per-sentence and long-word density"
63
63
 
64
- param :text, type: :string, desc: "Text to analyze"
64
+ parameter :text, type: :string, description: "Text to analyze"
65
65
 
66
66
  def execute(text:)
67
67
  words = text.scan(/\b\w+\b/)
@@ -86,7 +86,7 @@ class HeavyDigestTool < TextTool
86
86
 
87
87
  ROUNDS = 500_000
88
88
 
89
- param :text, type: :string, desc: "Seed text"
89
+ parameter :text, type: :string, description: "Seed text"
90
90
 
91
91
  def execute(text:)
92
92
  digest = text
@@ -102,7 +102,7 @@ class RequestCounterTool < RobotLab::Tool
102
102
 
103
103
  @@hits = 0 # mutable class variable — Ractor workers cannot access this
104
104
 
105
- param :text, type: :string, desc: "Text to count"
105
+ parameter :text, type: :string, description: "Text to count"
106
106
 
107
107
  def execute(text:)
108
108
  @@hits += 1
@@ -190,7 +190,7 @@ puts
190
190
 
191
191
  unless ENV["RUN_LIVE"]
192
192
  section "Part 3: Live LLM Run"
193
- puts " Set RUN_LIVE=1 (with Ollama running) to attempt the real pipeline."
193
+ puts " Set RUN_LIVE=1 (with LM Studio running) to attempt the real pipeline."
194
194
  puts " Expected behavior: headline_finder, background_brief, and"
195
195
  puts " fact_checker run in parallel; report_writer follows."
196
196
  puts
@@ -204,7 +204,7 @@ unless ENV["RUN_LIVE"]
204
204
  exit 0
205
205
  end
206
206
 
207
- require_ollama!
207
+ require_lms!
208
208
 
209
209
  section "Part 3: Live LLM Run (RUN_LIVE set)"
210
210
 
@@ -14,7 +14,7 @@
14
14
  # Prerequisites:
15
15
  # gem install redis
16
16
  # Redis server running on localhost:6379
17
- # Ollama running with the model from common.rb pulled
17
+ # LM Studio running with the models from common.rb downloaded
18
18
  #
19
19
  # Usage:
20
20
  # ruby examples/33_stock_predictor.rb
@@ -119,12 +119,12 @@ class AdjustParameters < RobotLab::Tool
119
119
  description "Adjust one predictor parameter to improve future prediction accuracy. " \
120
120
  "Make at most one or two targeted changes per window."
121
121
 
122
- param :parameter, type: "string",
123
- desc: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
124
- param :value, type: "number",
125
- desc: "New value (sma_window: 3-30 int; std/vol multipliers: 0.5-4.0; ema_alpha: 0.05-0.5; sma_weight: 0.0-1.0)"
126
- param :reasoning, type: "string",
127
- desc: "Why this change should reduce prediction error"
122
+ parameter :parameter, type: "string",
123
+ description: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
124
+ parameter :value, type: "number",
125
+ description: "New value (sma_window: 3-30 int; std/vol multipliers: 0.5-4.0; ema_alpha: 0.05-0.5; sma_weight: 0.0-1.0)"
126
+ parameter :reasoning, type: "string",
127
+ description: "Why this change should reduce prediction error"
128
128
 
129
129
  LIMITS = {
130
130
  "sma_window" => { min: 3, max: 30, integer: true },
@@ -204,7 +204,7 @@ puts "Warmup : #{PredictorConfig.sma_window} ticks"
204
204
  puts "Press Ctrl-C to stop."
205
205
  puts "-" * 60
206
206
 
207
- require_ollama!
207
+ require_lms!
208
208
 
209
209
  redis = Redis.new
210
210
  prices = []
data/examples/35_hooks.rb CHANGED
@@ -129,7 +129,7 @@ end
129
129
 
130
130
  class HookDemoTool < RobotLab::Tool
131
131
  description "Returns a deterministic hook demo value"
132
- param :label, type: "string", desc: "The label to echo"
132
+ parameter :label, type: "string", description: "The label to echo"
133
133
 
134
134
  def execute(label:)
135
135
  { label: label, status: "handled by HookDemoTool" }
@@ -187,7 +187,7 @@ class HookDemo
187
187
 
188
188
  puts " provider=#{LLM[:default].provider} model=#{LLM[:default].model}\n\n"
189
189
 
190
- # with_model alone would leave the provider unset, and an Ollama model is
190
+ # with_model alone would leave the provider unset, and an LM Studio model is
191
191
  # not in RubyLLM's registry — pass provider and model together.
192
192
  robot = RobotLab.build(
193
193
  name: "loop_demo_robot",
@@ -224,7 +224,7 @@ class HookDemo
224
224
  def run_tool
225
225
  section "Tool Call Hooks"
226
226
  tool = HookDemoTool.new
227
- result = tool.call({ "label" => "tool hook payload" })
227
+ result = tool.call(**{ "label" => "tool hook payload" })
228
228
  puts "Tool result: #{result.inspect}"
229
229
  end
230
230
 
data/examples/README.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Working demonstrations of RobotLab features, from single-robot basics to multi-robot orchestration and message bus communication.
4
4
 
5
+ > **A note on performance:** how fast (and how well) these demos run depends
6
+ > almost entirely on which provider and model you point them at. A large model
7
+ > served locally on consumer hardware can take a minute or more per LLM call,
8
+ > while a small local model or a hosted API answers in seconds — and smaller
9
+ > models may also give noticeably weaker answers on the multi-robot demos.
10
+ > Wall-clock times you see will differ from anyone else's; tune the
11
+ > provider/model in `common.rb` (or via `LLM_PROFILE`) to trade speed against
12
+ > quality.
13
+
5
14
  ## Prerequisites
6
15
 
7
16
  - Ruby >= 3.2
@@ -83,8 +92,16 @@ bundle exec rake examples:all
83
92
 
84
93
  # Run directly
85
94
  bundle exec ruby examples/01_simple_robot.rb
95
+
96
+ # Run every demo serially with a banner between each (works from any cwd)
97
+ examples/run_all.rb
86
98
  ```
87
99
 
100
+ `run_all.rb` executes every executable `NN_*.rb` demo in order, announcing
101
+ each one with a banner so the outputs stay separated. Ctrl-C kills only the
102
+ demo that is currently running — the runner notes the interruption and moves
103
+ on to the next demo.
104
+
88
105
  ## Tools require `tools: :inherit` at run time
89
106
 
90
107
  This trips up everyone once. `Robot#run` defaults to `tools: :none`, which
data/examples/common.rb CHANGED
@@ -1,33 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The examples are runnable directly (./01_simple_robot.rb) as well as via
4
+ # bundle exec. Without bundler/setup, bare requires let RubyGems activate the
5
+ # newest installed json (3.x), which conflicts with ruby_llm's json (< 3) pin;
6
+ # the lockfile pins json 2.x, so honor it in both invocation styles.
7
+ #
8
+ # The workspace .envrc exports BUNDLE_GEMFILE as a RELATIVE path ("Gemfile" or
9
+ # "Gemfile.local"), which bundler resolves against the cwd — so a demo run
10
+ # from examples/ or a subdirectory demo run from its own directory would look
11
+ # for a Gemfile there. Anchor the basename to the gem root before bundler
12
+ # sees it, preserving the prod/dev (Gemfile vs Gemfile.local) choice.
13
+ ENV["BUNDLE_GEMFILE"] = File.expand_path(
14
+ "../#{File.basename(ENV.fetch("BUNDLE_GEMFILE", "Gemfile"))}", __dir__
15
+ )
16
+ require "bundler/setup"
17
+
3
18
  require "logger"
4
19
 
5
20
  # Fallback for when direnv has not activated examples/.envrc
6
21
  ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
7
22
 
8
23
  require_relative "../lib/robot_lab"
24
+ require "ruby_llm/providers/lms"
25
+
26
+ # robot_lab's dependencies load parts of ActiveSupport, so amazing_print 3.0
27
+ # sees the constant and loads its ActiveSupport extension, which at require
28
+ # time calls ActiveSupport.try and reads ActiveSupport::LogSubscriber —
29
+ # neither of which those parts provide. Load both up front so any demo can
30
+ # `require "amazing_print"` safely.
31
+ require "active_support/core_ext/object/try"
32
+ require "active_support/log_subscriber"
9
33
 
10
34
  # ── Local LLM Configuration ───────────────────────────────────────────────────
11
35
  #
12
- # Every example runs against a LOCAL model served by Ollama. No API keys, no
13
- # network egress, no per-token cost. Pull the model once before running:
36
+ # Every example runs against a LOCAL model served by LM Studio through the
37
+ # ruby_llm-providers-lms gem (provider :lms). No API keys, no network egress,
38
+ # no per-token cost. Start the server and download the models once:
39
+ #
40
+ # lms server start
41
+ # lms get qwen/qwen3.8-27b
42
+ # lms get openai/gpt-oss-20b
14
43
  #
15
- # ollama pull qwen3.6
44
+ # Model choice: qwen/qwen3.8-27b for complex activities (tools, structured
45
+ # output, multi-robot reasoning — it honors tool_choice and schemas), and
46
+ # openai/gpt-oss-20b for simpler items (plain chat, streaming).
16
47
  #
17
- # Ollama models are not in RubyLLM's model registry, so a `provider:` must be
18
- # supplied alongside `model:` — that is what makes RubyLLM skip the registry
19
- # lookup (see Robot#initialize, which sets assume_model_exists when provider is
20
- # given). Use the `llm_opts` helper below so every robot gets both.
48
+ # LM Studio models are not in RubyLLM's model registry, so a `provider:` must
49
+ # be supplied alongside `model:` — that is what makes RubyLLM skip the
50
+ # registry lookup (see Robot#initialize, which sets assume_model_exists when
51
+ # provider is given). Use the `llm_opts` helper below so every robot gets both.
21
52
 
22
53
  LlmConfig = Data.define(:provider, :model)
23
54
 
24
55
  LLM = {
25
- default: LlmConfig.new(provider: "ollama", model: "qwen3.6:latest"),
26
- small: LlmConfig.new(provider: "ollama", model: "qwen2.5:7b"),
27
- large: LlmConfig.new(provider: "ollama", model: "llama3.3:latest")
56
+ default: LlmConfig.new(provider: "lms", model: "qwen/qwen3.8-27b"),
57
+ small: LlmConfig.new(provider: "lms", model: "openai/gpt-oss-20b"),
58
+ large: LlmConfig.new(provider: "lms", model: "qwen/qwen3.8-27b")
28
59
  }.freeze
29
60
 
30
- OLLAMA_API_BASE = ENV.fetch("OLLAMA_API_BASE", "http://localhost:11434/v1")
61
+ LMS_API_BASE = ENV.fetch("LMS_API_BASE", "http://localhost:1234/v1")
31
62
 
32
63
  # ORDER MATTERS. The first touch of RobotLab.config runs Config#after_load,
33
64
  # which calls RubyLLM.configure itself and would clobber anything set before
@@ -37,9 +68,9 @@ RobotLab.configure do |c|
37
68
  end
38
69
 
39
70
  RubyLLM.configure do |c|
40
- c.logger = Logger.new(File::NULL)
41
- c.default_model = LLM[:default].model
42
- c.ollama_api_base = OLLAMA_API_BASE
71
+ c.logger = Logger.new(File::NULL)
72
+ c.default_model = LLM[:default].model
73
+ c.lms_api_base = LMS_API_BASE
43
74
 
44
75
  # A large local model on consumer hardware is far slower than a hosted API,
45
76
  # and robot_lab's bundled 120s default is comfortably exceeded by a long
@@ -67,7 +98,7 @@ end
67
98
 
68
99
  # Provider + model keyword pair for RobotLab.build / Robot.new.
69
100
  #
70
- # Both are required for a local Ollama model. Splat it into any robot
101
+ # Both are required for a local LM Studio model. Splat it into any robot
71
102
  # constructor:
72
103
  #
73
104
  # RobotLab.build(name: "helper", **llm_opts) # honors LLM_PROFILE
@@ -80,19 +111,20 @@ def llm_opts(key = nil)
80
111
  { provider: cfg.provider, model: cfg.model }
81
112
  end
82
113
 
83
- # Fail fast with an actionable message when Ollama isn't reachable, instead of
84
- # letting every example die inside an HTTP adapter.
85
- def require_ollama!
114
+ # Fail fast with an actionable message when LM Studio isn't reachable,
115
+ # instead of letting every example die inside an HTTP adapter.
116
+ def require_lms!
86
117
  require "net/http"
87
- uri = URI(OLLAMA_API_BASE.sub(%r{/v1/?$}, "") + "/api/tags")
118
+ uri = URI("#{LMS_API_BASE.sub(%r{/v1/?\z}, '')}/v1/models")
88
119
  Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) { |h| h.get(uri.request_uri) }
89
120
  rescue StandardError => e
90
121
  abort <<~ERROR
91
- Cannot reach Ollama at #{OLLAMA_API_BASE} (#{e.class}).
122
+ Cannot reach LM Studio at #{LMS_API_BASE} (#{e.class}).
92
123
 
93
- Start it and pull the model used by the examples:
94
- ollama serve
95
- ollama pull #{LLM[:default].model.sub(/:latest\z/, "")}
124
+ Start the server and download the models used by the examples:
125
+ lms server start
126
+ lms get #{LLM[:default].model}
127
+ lms get #{LLM[:small].model}
96
128
  ERROR
97
129
  end
98
130
 
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs every executable NN_*.rb demo in this directory serially, printing a
5
+ # banner to STDOUT before each one so the outputs are easy to tell apart.
6
+
7
+ module RunAll
8
+ module_function
9
+
10
+ # Executable NN_*.rb demos in +dir+, in numeric order.
11
+ def demo_files(dir = __dir__)
12
+ Dir.glob(File.join(dir, "[0-9][0-9]_*.rb"))
13
+ .select { File.executable?(it) }
14
+ .sort
15
+ end
16
+
17
+ def banner(path)
18
+ bar = "=" * 70
19
+ <<~BANNER
20
+
21
+ #{bar}
22
+ == #{File.basename(path)}
23
+ #{bar}
24
+ BANNER
25
+ end
26
+
27
+ # Absolute path to the gem root's Gemfile, honoring the prod/dev choice
28
+ # (Gemfile vs Gemfile.local) carried by BUNDLE_GEMFILE. The inherited value
29
+ # is relative, so left alone it would resolve against the demo's cwd.
30
+ def bundle_gemfile(dir = __dir__)
31
+ name = File.basename(ENV.fetch("BUNDLE_GEMFILE", "Gemfile"))
32
+ File.expand_path("../#{name}", dir)
33
+ end
34
+
35
+ # Runs one demo with this Ruby interpreter; returns true on success.
36
+ def run_demo(path)
37
+ system({ "BUNDLE_GEMFILE" => bundle_gemfile }, RbConfig.ruby, path)
38
+ end
39
+
40
+ # True when the last demo was killed by Ctrl-C (SIGINT).
41
+ def interrupted?(status = Process.last_status)
42
+ !status.nil? && status.signaled? && status.termsig == Signal.list["INT"]
43
+ end
44
+
45
+ # Ctrl-C reaches the whole foreground process group. A proc trap (unlike
46
+ # "IGNORE") is reset to DEFAULT in the exec'd child, so the demo dies while
47
+ # this runner survives and moves on to the next one.
48
+ def run(files = demo_files)
49
+ previous = trap("INT") { nil }
50
+ files.each do |file|
51
+ puts banner(file)
52
+ run_demo(file)
53
+ puts "-- #{File.basename(file)} interrupted (Ctrl-C); moving on" if interrupted?
54
+ end
55
+ ensure
56
+ trap("INT", previous || "DEFAULT")
57
+ end
58
+ end
59
+
60
+ RunAll.run if $PROGRAM_NAME == __FILE__
@@ -33,9 +33,9 @@ module RobotLab
33
33
  #
34
34
  class AskUser < Tool
35
35
  description "Ask the user a question and wait for their typed response"
36
- param :question, type: "string", desc: "The question to ask the user"
37
- param :choices, type: "array", desc: "Optional list of choices to present", required: false
38
- param :default, type: "string", desc: "Default value if user presses Enter", required: false
36
+ parameter :question, type: "string", description: "The question to ask the user"
37
+ parameter :choices, type: "array", description: "Optional list of choices to present", required: false
38
+ parameter :default, type: "string", description: "Default value if user presses Enter", required: false
39
39
 
40
40
  # :reek:FeatureEnvy -- rendering and resolving the caller-supplied choices list is this tool's whole job.
41
41
  # :reek:TooManyStatements -- linear prompt/read/resolve terminal interaction.
@@ -41,13 +41,13 @@ defaults:
41
41
  with_model:
42
42
  provider: null # chat-specific provider
43
43
  model: null # chat-specific model
44
- assume_exists: null # assume the model exists primarily for local providers
44
+ assume_model_exists: null # assume the model exists primarily for local providers
45
45
  with_temperature: 0.7 # Controls randomness (0.0-2.0, null = model default)
46
+ with_max_output_tokens: null # Maximum tokens in response
46
47
  with_tools: null # ?? not sure about this one.
47
- with_params:
48
+ with_provider_options: # merged into the request payload (ruby_llm 2.0)
48
49
  top_p: null # Nucleus sampling threshold (0.0-1.0)
49
50
  top_k: null # Top-k sampling (integer, provider-specific)
50
- max_tokens: null # Maximum tokens in response
51
51
  presence_penalty: null # Penalize new tokens based on presence (-2.0 to 2.0)
52
52
  frequency_penalty: null # Penalize new tokens based on frequency (-2.0 to 2.0)
53
53
  stop: null # Stop sequences (string or array of strings)
@@ -55,7 +55,7 @@ defaults:
55
55
  # RubyLLM Configuration Section
56
56
  ruby_llm:
57
57
  provider: :anthropic
58
- model: claude-sonnet-4
58
+ model: claude-sonnet-4-6
59
59
  assume_model_exists: false # (dep of assume_exists in chat section) set true for Ollama and other local LLM providers
60
60
  # Provider API Keys (null = use env vars directly)
61
61
  anthropic_api_key: null
@@ -117,7 +117,7 @@ test:
117
117
  max_iterations: 3
118
118
  streaming_enabled: false
119
119
  ruby_llm:
120
- model: claude-3-haiku-20240307
120
+ model: claude-haiku-4-5
121
121
  request_timeout: 30
122
122
  max_retries: 1
123
123
  log_level: :warn
@@ -14,7 +14,7 @@ module RobotLab
14
14
  # - Automatic RubyLLM configuration application
15
15
  #
16
16
  # @example Access configuration values
17
- # RobotLab.config.ruby_llm.model #=> "claude-sonnet-4"
17
+ # RobotLab.config.ruby_llm.model #=> "claude-sonnet-4-6"
18
18
  # RobotLab.config.ruby_llm.request_timeout #=> 120
19
19
  # RobotLab.config.development? #=> true
20
20
  #
@@ -28,7 +28,7 @@ module RobotLab
28
28
  # # defaults.yml. This file is NOT run through ERB, so keep secrets in
29
29
  # # environment variables or in ./config/robot_lab.yml (which is).
30
30
  # ruby_llm:
31
- # model: claude-sonnet-4
31
+ # model: claude-sonnet-4-6
32
32
  # request_timeout: 120
33
33
  #
34
34
  class Config < MywayConfig::Base
@@ -116,9 +116,7 @@ module RobotLab
116
116
  # Returns the session identifier.
117
117
  #
118
118
  # @return [String, nil] the session ID
119
- def session_id
120
- @session_id
121
- end
119
+ attr_reader :session_id
122
120
 
123
121
  private
124
122
 
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ruby_llm/semantic_cache"
3
+ # Optional: ruby_llm-semantic_cache has not shipped a ruby_llm 2.0-compatible
4
+ # release. When the gem is absent, Memory#cache returns nil and semantic
5
+ # caching is silently disabled.
6
+ begin
7
+ require "ruby_llm/semantic_cache"
8
+ rescue LoadError
9
+ # Semantic caching unavailable; Memory runs without it.
10
+ end
4
11
 
5
12
  module RobotLab
6
13
  # Raised when a blocking get times out
@@ -106,7 +113,7 @@ module RobotLab
106
113
  set_internal(:results, Array(results))
107
114
  set_internal(:messages, Array(messages).map { |m| normalize_message(m) })
108
115
  set_internal(:session_id, session_id)
109
- set_internal(:cache, @enable_cache ? RubyLLM::SemanticCache : nil)
116
+ set_internal(:cache, @enable_cache ? create_semantic_cache : nil)
110
117
 
111
118
  # Data proxy for method-style access
112
119
  @data = nil
@@ -215,9 +222,11 @@ module RobotLab
215
222
 
216
223
  # Get the semantic cache module
217
224
  #
218
- # The cache is always active and provides semantic similarity matching
219
- # for LLM responses, reducing costs and latency by returning cached
220
- # responses for semantically equivalent queries.
225
+ # When the optional ruby_llm-semantic_cache gem is installed (and
226
+ # enable_cache is true), provides semantic similarity matching for LLM
227
+ # responses, reducing costs and latency by returning cached responses
228
+ # for semantically equivalent queries. Returns nil when the gem is
229
+ # absent or caching is disabled.
221
230
  #
222
231
  # @example Using the cache with fetch
223
232
  # response = memory.cache.fetch("What is Ruby?") do
@@ -228,7 +237,7 @@ module RobotLab
228
237
  # chat = memory.cache.wrap(RubyLLM.chat(model: "gpt-4"))
229
238
  # chat.ask("What is Ruby?") # Cached on semantic similarity
230
239
  #
231
- # @return [RubyLLM::SemanticCache] the semantic cache module
240
+ # @return [RubyLLM::SemanticCache, nil] the semantic cache module, or nil when unavailable
232
241
  #
233
242
  def cache
234
243
  get_internal(:cache)
@@ -702,7 +711,7 @@ module RobotLab
702
711
  end
703
712
 
704
713
  def create_semantic_cache
705
- RubyLLM::SemanticCache
714
+ defined?(RubyLLM::SemanticCache) ? RubyLLM::SemanticCache : nil
706
715
  end
707
716
 
708
717
  # :reek:ControlParameter -- factory method; the preference symbol is exactly what selects the backend.
@@ -54,6 +54,32 @@ module RobotLab
54
54
  def hook_registries(network = nil)
55
55
  [RobotLab.hooks, network&.hooks, @hooks]
56
56
  end
57
+
58
+ # Arm the per-run circuit breaker checked by the chat's before_tool_call
59
+ # dispatcher (see Robot#register_chat_callbacks). Raises ToolLoopError
60
+ # once tool calls exceed @config.max_tool_rounds. ruby_llm 2.0 callbacks
61
+ # are additive and cannot be removed, so the breaker toggles a flag the
62
+ # permanent dispatcher consults instead of swapping callbacks per run.
63
+ def install_circuit_breaker
64
+ @circuit_breaker_call_count = 0
65
+ @circuit_breaker_armed = true
66
+ end
67
+
68
+ # Disarm the circuit breaker after a run.
69
+ def restore_tool_call_callback
70
+ @circuit_breaker_armed = false
71
+ end
72
+
73
+ # Count a tool call against max_tool_rounds and raise once exceeded.
74
+ def enforce_circuit_breaker!
75
+ max = @config.max_tool_rounds
76
+ @circuit_breaker_call_count += 1
77
+ return if @circuit_breaker_call_count <= max
78
+
79
+ raise ToolLoopError,
80
+ "Circuit breaker triggered: #{@circuit_breaker_call_count} tool calls exceeded " \
81
+ "max_tool_rounds (#{max})"
82
+ end
57
83
  end
58
84
  end
59
85
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class Robot < RubyLLM::Agent
5
+ # Adapts a ruby_llm response into a RobotResult, including token
6
+ # accounting, stop-reason normalization, and message coercion helpers.
7
+ #
8
+ # Owns: nothing (pure adapters over the response and @chat)
9
+ # Reads: @chat, @name; Writes: @total_input_tokens, @total_output_tokens
10
+ module ResultBuilding
11
+ private
12
+
13
+ # :reek:TooManyStatements :reek:FeatureEnvy -- adapting a provider response's many optional fields into
14
+ # a RobotResult is inherently response-centric.
15
+ def build_result(response, _memory)
16
+ text = result_text(response)
17
+ output = text ? [TextMessage.new(role: 'assistant', content: text)] : []
18
+
19
+ tool_calls = response.respond_to?(:tool_calls) ? (response.tool_calls || []) : []
20
+
21
+ input_toks, output_toks = extract_token_counts(response)
22
+ @total_input_tokens += input_toks
23
+ @total_output_tokens += output_toks
24
+
25
+ RobotResult.new(
26
+ robot_name: @name,
27
+ output: output,
28
+ tool_calls: normalize_tool_calls(tool_calls),
29
+ stop_reason: extract_stop_reason(response),
30
+ raw: response,
31
+ input_tokens: input_toks,
32
+ output_tokens: output_toks
33
+ )
34
+ end
35
+
36
+ # Token usage from the response. ruby_llm 2.0 nests counts under
37
+ # response.tokens; duck-typed responses may still answer input_tokens.
38
+ # :reek:FeatureEnvy -- reading the response's optional token fields is the extraction itself.
39
+ def extract_token_counts(response)
40
+ if response.respond_to?(:tokens) && (tokens = response.tokens)
41
+ [tokens.input.to_i, tokens.output.to_i]
42
+ elsif response.respond_to?(:input_tokens)
43
+ [response.input_tokens.to_i,
44
+ response.respond_to?(:output_tokens) ? response.output_tokens.to_i : 0]
45
+ else
46
+ [0, 0]
47
+ end
48
+ end
49
+
50
+ # ruby_llm 2.0's add_message accepts a Message, an attribute Hash, or a
51
+ # record responding to to_llm. Compression summaries (and tests) hand us
52
+ # plain role/content value objects; convert those to attribute hashes so
53
+ # the chat can coerce them.
54
+ def coerce_replacement_message(message)
55
+ if message.is_a?(RubyLLM::Message) || message.is_a?(Hash) || message.respond_to?(:to_llm)
56
+ message
57
+ else
58
+ { role: message.role, content: message.content }
59
+ end
60
+ end
61
+
62
+ # The response's normalized stop reason. ruby_llm 2.0 exposes it as
63
+ # finish_reason (a Symbol such as :stop or :tool_calls); older or
64
+ # duck-typed responses may still answer stop_reason.
65
+ def extract_stop_reason(response)
66
+ return response.finish_reason if response.respond_to?(:finish_reason)
67
+
68
+ response.respond_to?(:stop_reason) ? response.stop_reason : nil
69
+ end
70
+
71
+ # Text for the result's output. Prefers the final response's content, then
72
+ # falls back in order to: (1) thinking text for models that route all output
73
+ # through reasoning_content (e.g. qwen3 on Ollama), (2) the most recent
74
+ # assistant text within the current turn for models that end on a tool call
75
+ # with no trailing text.
76
+ #
77
+ # The chat-history fallback is scoped to messages AFTER the last user message
78
+ # (the current turn) to prevent a previous turn's response from being returned
79
+ # when a thinking-mode model emits nothing in response.content.
80
+ # :reek:TooManyStatements :reek:FeatureEnvy -- documented fallback chain over the response's optional content/thinking/history fields.
81
+ def result_text(response)
82
+ content = response.content if response.respond_to?(:content)
83
+ return content if content && !content.to_s.empty?
84
+
85
+ # Ollama routes qwen3's reasoning to reasoning_content, which ruby_llm
86
+ # surfaces as response.thinking (a RubyLLM::Thinking object). When content
87
+ # is nil and thinking is present, the thinking IS the response for that turn.
88
+ if response.respond_to?(:thinking) && (thinking = response.thinking)
89
+ thinking_text = thinking.respond_to?(:text) ? thinking.text.to_s : thinking.to_s
90
+ return thinking_text unless thinking_text.empty?
91
+ end
92
+
93
+ return nil unless @chat.respond_to?(:messages)
94
+
95
+ messages = @chat.messages
96
+ last_user_idx = messages.rindex { |m| m.role == :user } || -1
97
+ current_turn = messages[(last_user_idx + 1)..]
98
+
99
+ last = current_turn.rfind { |m| m.role == :assistant && m.content && !m.content.to_s.empty? }
100
+ last&.content
101
+ end
102
+
103
+ def normalize_tool_calls(tool_calls)
104
+ return [] unless tool_calls
105
+
106
+ tool_calls.map do |tc|
107
+ if tc.is_a?(Hash)
108
+ ToolResultMessage.new(
109
+ tool: tc,
110
+ content: tc[:result] || tc['result']
111
+ )
112
+ else
113
+ tc
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end