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.
- checksums.yaml +4 -4
- data/.envrc +2 -2
- data/CHANGELOG.md +53 -0
- data/CLAUDE.md +4 -2
- data/README.md +15 -17
- data/docs/architecture/core-concepts.md +1 -1
- data/docs/architecture/state-management.md +4 -4
- data/docs/concepts.md +1 -1
- data/docs/getting-started/installation.md +1 -4
- data/docs/guides/memory.md +2 -2
- 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/ask_user.rb +3 -3
- data/lib/robot_lab/config/defaults.yml +5 -5
- data/lib/robot_lab/config.rb +2 -2
- data/lib/robot_lab/mcp/transports/streamable_http.rb +1 -3
- data/lib/robot_lab/memory.rb +16 -7
- data/lib/robot_lab/robot/hooking.rb +26 -0
- data/lib/robot_lab/robot/result_building.rb +119 -0
- data/lib/robot_lab/robot.rb +37 -120
- data/lib/robot_lab/run_config.rb +52 -20
- data/lib/robot_lab/tool.rb +7 -12
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +3 -3
- metadata +18 -27
data/examples/29_ractor_tools.rb
CHANGED
|
@@ -46,7 +46,7 @@ end
|
|
|
46
46
|
class WordStatsTool < TextTool
|
|
47
47
|
description "Count words, sentences, and average word length"
|
|
48
48
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
13
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
18
|
-
# supplied alongside `model:` — that is what makes RubyLLM skip the
|
|
19
|
-
# lookup (see Robot#initialize, which sets assume_model_exists when
|
|
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: "
|
|
26
|
-
small: LlmConfig.new(provider: "
|
|
27
|
-
large: LlmConfig.new(provider: "
|
|
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
|
-
|
|
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
|
|
41
|
-
c.default_model
|
|
42
|
-
c.
|
|
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
|
|
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
|
|
84
|
-
# letting every example die inside an HTTP adapter.
|
|
85
|
-
def
|
|
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(
|
|
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
|
|
122
|
+
Cannot reach LM Studio at #{LMS_API_BASE} (#{e.class}).
|
|
92
123
|
|
|
93
|
-
Start
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
data/examples/run_all.rb
ADDED
|
@@ -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__
|
data/lib/robot_lab/ask_user.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
120
|
+
model: claude-haiku-4-5
|
|
121
121
|
request_timeout: 30
|
|
122
122
|
max_retries: 1
|
|
123
123
|
log_level: :warn
|
data/lib/robot_lab/config.rb
CHANGED
|
@@ -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
|
data/lib/robot_lab/memory.rb
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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 ?
|
|
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
|
-
#
|
|
219
|
-
#
|
|
220
|
-
# responses
|
|
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
|