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/names.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# A large curated word list, not application logic — kept as one inline
|
|
4
4
|
# module for lookup speed, so the size/length cops below are false positives.
|
|
5
|
-
# rubocop:disable Metrics/ModuleLength
|
|
5
|
+
# rubocop:disable-next Metrics/ModuleLength
|
|
6
6
|
module RobotLab
|
|
7
7
|
# Fictional robot names
|
|
8
|
-
# rubocop:disable Metrics/CollectionLiteralLength
|
|
8
|
+
# rubocop:disable-next Metrics/CollectionLiteralLength
|
|
9
9
|
NAMES = %w[
|
|
10
10
|
R_7723
|
|
11
11
|
R_790
|
|
@@ -394,9 +394,7 @@ module RobotLab
|
|
|
394
394
|
Zhora
|
|
395
395
|
Zoromes
|
|
396
396
|
].freeze
|
|
397
|
-
# rubocop:enable Metrics/CollectionLiteralLength
|
|
398
397
|
|
|
399
398
|
def self.name = names.first
|
|
400
399
|
def self.names(how_many = 1) = NAMES.sample(how_many).sort
|
|
401
400
|
end
|
|
402
|
-
# rubocop:enable Metrics/ModuleLength
|
data/lib/robot_lab/network.rb
CHANGED
|
@@ -87,6 +87,7 @@ module RobotLab
|
|
|
87
87
|
# task :billing, billing_robot, context: { dept: "billing" }, depends_on: :optional
|
|
88
88
|
# end
|
|
89
89
|
#
|
|
90
|
+
# :reek:ControlParameter -- `memory || ...` and `config || ...` are nil-safe defaults, not behavior selection.
|
|
90
91
|
def initialize(name:, concurrency: :auto, memory: nil, config: nil, parallel_mode: :async, &)
|
|
91
92
|
@name = name.to_s
|
|
92
93
|
@robots = {}
|
|
@@ -125,6 +126,7 @@ module RobotLab
|
|
|
125
126
|
# @example Task with dependencies
|
|
126
127
|
# task :writer, writer_robot, depends_on: [:analyst]
|
|
127
128
|
#
|
|
129
|
+
# :reek:LongParameterList -- one keyword per documented per-task option.
|
|
128
130
|
def task(name, robot, context: {}, mcp: :none, tools: :none, memory: nil, config: nil, depends_on: :none,
|
|
129
131
|
poller_group: :default)
|
|
130
132
|
task_wrapper = Task.new(
|
|
@@ -180,6 +182,7 @@ module RobotLab
|
|
|
180
182
|
# result.value # => RobotResult from last robot
|
|
181
183
|
# result.context[:classifier] # => RobotResult from classifier
|
|
182
184
|
#
|
|
185
|
+
# :reek:TooManyStatements -- linear assembly of run params and hook context before dispatch.
|
|
183
186
|
def run(message = nil, **run_context)
|
|
184
187
|
# Runnable protocol: accept a positional message like Robot#run does, so
|
|
185
188
|
# callers can `run(msg, ...)` uniformly. `run(message: msg)` still works.
|
|
@@ -200,12 +203,13 @@ module RobotLab
|
|
|
200
203
|
)
|
|
201
204
|
|
|
202
205
|
RobotLab::Hooks.run(:network_run, context, registries: [RobotLab.hooks, @hooks]) do
|
|
206
|
+
run_params = context.context
|
|
203
207
|
if @parallel_mode == :ractor
|
|
204
|
-
run_with_ractor_scheduler(
|
|
208
|
+
run_with_ractor_scheduler(run_params)
|
|
205
209
|
else
|
|
206
210
|
initial_result = SimpleFlow::Result.new(
|
|
207
|
-
|
|
208
|
-
context: { run_params:
|
|
211
|
+
run_params,
|
|
212
|
+
context: { run_params: run_params }
|
|
209
213
|
)
|
|
210
214
|
@pipeline.call_parallel(initial_result, max_concurrent: @config.max_concurrent_robots)
|
|
211
215
|
end
|
|
@@ -329,11 +333,12 @@ module RobotLab
|
|
|
329
333
|
# @raise [ArgumentError] if a robot with the same name already exists
|
|
330
334
|
#
|
|
331
335
|
def add_robot(robot)
|
|
332
|
-
|
|
333
|
-
|
|
336
|
+
name = robot.name
|
|
337
|
+
if @robots.key?(name)
|
|
338
|
+
raise ArgumentError, "Robot '#{name}' already exists in network '#{@name}'"
|
|
334
339
|
end
|
|
335
340
|
|
|
336
|
-
@robots[
|
|
341
|
+
@robots[name] = robot
|
|
337
342
|
self
|
|
338
343
|
end
|
|
339
344
|
|
|
@@ -398,6 +403,7 @@ module RobotLab
|
|
|
398
403
|
|
|
399
404
|
private
|
|
400
405
|
|
|
406
|
+
# :reek:TooManyStatements -- linear build-specs/run/shutdown sequence for the ractor scheduler.
|
|
401
407
|
def run_with_ractor_scheduler(run_context)
|
|
402
408
|
unless RobotLab.extension_loaded?(:ractor)
|
|
403
409
|
raise RobotLab::DependencyError,
|
|
@@ -419,6 +425,7 @@ module RobotLab
|
|
|
419
425
|
results
|
|
420
426
|
end
|
|
421
427
|
|
|
428
|
+
# :reek:FeatureEnvy -- snapshotting a robot's identity fields into a Ractor-shareable spec is this method's job.
|
|
422
429
|
def build_robot_spec(task_wrapper)
|
|
423
430
|
robot = task_wrapper.robot
|
|
424
431
|
RobotSpec.new(
|
|
@@ -40,6 +40,8 @@ module RobotLab
|
|
|
40
40
|
# @param message [String]
|
|
41
41
|
# @param threshold [Float] cosine similarity cutoff (default SIMILARITY_THRESHOLD)
|
|
42
42
|
# @return [Array<AgentSkill>]
|
|
43
|
+
# :reek:TooManyStatements -- linear search/filter with a best-effort rescue fallback.
|
|
44
|
+
# :reek:NestedIterators -- 2-deep filter_map/find joining search hits back to pending skills.
|
|
43
45
|
def match_agent_skills(message, threshold: SIMILARITY_THRESHOLD)
|
|
44
46
|
return [] if @pending_agent_skills.nil? || @pending_agent_skills.empty?
|
|
45
47
|
|
|
@@ -91,6 +91,7 @@ module RobotLab
|
|
|
91
91
|
# @param auto_reply [Boolean] send the responder's result back to the sender
|
|
92
92
|
# @yield [message] the inbound task; return the reply content (nil => no reply)
|
|
93
93
|
# @return [self]
|
|
94
|
+
# :reek:BooleanParameter :reek:ControlParameter -- auto_reply is a documented public API toggle for reply behavior.
|
|
94
95
|
def respond_to_tasks(auto_reply: true, &responder)
|
|
95
96
|
on_message do |message|
|
|
96
97
|
next if message.reply?
|
|
@@ -107,6 +108,7 @@ module RobotLab
|
|
|
107
108
|
#
|
|
108
109
|
# @param auto_reply [Boolean]
|
|
109
110
|
# @return [self]
|
|
111
|
+
# :reek:BooleanParameter -- auto_reply is a documented public API toggle, forwarded to respond_to_tasks.
|
|
110
112
|
def serve(auto_reply: true)
|
|
111
113
|
respond_to_tasks(auto_reply: auto_reply) { |message| run(bus_task_content(message)).reply }
|
|
112
114
|
end
|
|
@@ -161,6 +163,7 @@ module RobotLab
|
|
|
161
163
|
# @param bus [TypedBus::MessageBus, nil] bus to join (creates one if nil)
|
|
162
164
|
# @return [self]
|
|
163
165
|
#
|
|
166
|
+
# :reek:ControlParameter -- `bus || @bus || new` implements the documented join-or-create semantics.
|
|
164
167
|
def with_bus(bus = nil)
|
|
165
168
|
return self if bus && @bus == bus
|
|
166
169
|
|
|
@@ -32,6 +32,8 @@ module RobotLab
|
|
|
32
32
|
# @param limit [Integer] maximum number of results to return (default 5)
|
|
33
33
|
# @return [Array<HistoryResult>] results sorted by score descending
|
|
34
34
|
# @raise [RobotLab::DependencyError] if the 'classifier' gem is not installed
|
|
35
|
+
# :reek:TooManyStatements -- linear vectorize/score/collect scan over the chat history.
|
|
36
|
+
# :reek:FeatureEnvy -- scoring each message's extracted text against the query is the search itself.
|
|
35
37
|
def search_history(query, limit: 5)
|
|
36
38
|
TextAnalysis.require_classifier!
|
|
37
39
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module RobotLab
|
|
4
4
|
class Robot < RubyLLM::Agent
|
|
5
5
|
module Hooking
|
|
6
|
+
# :reek:LongParameterList -- the documented Robot#run public API: each keyword is a distinct run-scoped override.
|
|
7
|
+
# :reek:TooManyStatements -- the run lifecycle (memory writer swap, hook wrap, budget, cleanup) is one
|
|
8
|
+
# deliberate orchestrator; flog gates its complexity.
|
|
6
9
|
def run(message = nil, network: nil, task: nil, network_memory: nil, network_config: nil,
|
|
7
10
|
memory: nil, mcp: :none, tools: :none, hooks: nil, **kwargs, &block)
|
|
8
11
|
run_memory = resolve_run_memory(memory, network: network, network_memory: network_memory)
|
|
@@ -51,6 +54,32 @@ module RobotLab
|
|
|
51
54
|
def hook_registries(network = nil)
|
|
52
55
|
[RobotLab.hooks, network&.hooks, @hooks]
|
|
53
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
|
|
54
83
|
end
|
|
55
84
|
end
|
|
56
85
|
end
|
|
@@ -11,6 +11,7 @@ module RobotLab
|
|
|
11
11
|
private
|
|
12
12
|
|
|
13
13
|
# Resolve MCP hierarchy: runtime -> robot build -> network -> config
|
|
14
|
+
# :reek:ControlParameter -- `network_config&.mcp || ...` is the documented fallback cascade, not a mode switch.
|
|
14
15
|
def resolve_mcp_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
15
16
|
parent_value = network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp
|
|
16
17
|
build_resolved = ToolConfig.resolve_mcp(@mcp_config, parent_value: parent_value)
|
|
@@ -18,6 +19,7 @@ module RobotLab
|
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# Resolve tools hierarchy: runtime -> robot build -> network -> config
|
|
22
|
+
# :reek:ControlParameter -- `network_config&.tools || ...` is the documented fallback cascade, not a mode switch.
|
|
21
23
|
def resolve_tools_hierarchy(runtime_value, network: nil, network_config: nil)
|
|
22
24
|
parent_value = network_config&.tools || network_parent_config(network)&.tools || RobotLab.config.tools
|
|
23
25
|
build_resolved = ToolConfig.resolve_tools(@tools_config, parent_value: parent_value)
|
|
@@ -32,6 +34,7 @@ module RobotLab
|
|
|
32
34
|
|
|
33
35
|
# Ensure MCP clients are initialized for the given server configs.
|
|
34
36
|
# On subsequent calls, retries any servers that previously failed to connect.
|
|
37
|
+
# :reek:TooManyStatements -- first-run init vs retry paths share the needed-server list; linear either way.
|
|
35
38
|
def ensure_mcp_clients(mcp_servers)
|
|
36
39
|
return if mcp_servers.empty?
|
|
37
40
|
|
|
@@ -54,6 +57,7 @@ module RobotLab
|
|
|
54
57
|
@mcp_initialized = true
|
|
55
58
|
end
|
|
56
59
|
|
|
60
|
+
# :reek:TooManyStatements -- connect success/failure bookkeeping plus the rescue path for one server.
|
|
57
61
|
def init_mcp_client(server_config)
|
|
58
62
|
client = MCP::Client.new(server_config)
|
|
59
63
|
client.connect
|
|
@@ -78,6 +82,7 @@ module RobotLab
|
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
# Retry connecting to servers that previously failed
|
|
85
|
+
# :reek:TooManyStatements -- per-server retry with success bookkeeping and a best-effort rescue.
|
|
81
86
|
def retry_failed_servers(_mcp_servers, needed_servers)
|
|
82
87
|
return if @failed_mcp_configs.nil? || @failed_mcp_configs.empty?
|
|
83
88
|
|
|
@@ -85,8 +90,9 @@ module RobotLab
|
|
|
85
90
|
to_retry = @failed_mcp_configs.slice(*needed_servers)
|
|
86
91
|
return if to_retry.empty?
|
|
87
92
|
|
|
93
|
+
logger = RobotLab.config.logger
|
|
88
94
|
to_retry.each do |name, server_config|
|
|
89
|
-
|
|
95
|
+
logger.info(
|
|
90
96
|
"Robot '#{@name}' retrying MCP server: #{name}"
|
|
91
97
|
)
|
|
92
98
|
|
|
@@ -97,17 +103,19 @@ module RobotLab
|
|
|
97
103
|
@mcp_clients[name] = client
|
|
98
104
|
@failed_mcp_configs.delete(name)
|
|
99
105
|
discover_mcp_tools(client, name)
|
|
100
|
-
|
|
106
|
+
logger.info(
|
|
101
107
|
"Robot '#{@name}' successfully connected to MCP server '#{name}' on retry"
|
|
102
108
|
)
|
|
103
109
|
end
|
|
104
110
|
rescue StandardError => e
|
|
105
|
-
|
|
111
|
+
logger.warn(
|
|
106
112
|
"Robot '#{@name}' retry failed for MCP server '#{name}': #{e.message}"
|
|
107
113
|
)
|
|
108
114
|
end
|
|
109
115
|
end
|
|
110
116
|
|
|
117
|
+
# :reek:FeatureEnvy -- adapting each MCP tool definition into a local Tool is exactly this method's purpose.
|
|
118
|
+
# :reek:NestedIterators -- the inner block is the tool's execution closure, not an iteration.
|
|
111
119
|
def discover_mcp_tools(client, server_name)
|
|
112
120
|
tools = client.list_tools
|
|
113
121
|
|
|
@@ -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
|
|
@@ -35,6 +35,7 @@ module RobotLab
|
|
|
35
35
|
# Apply a prompt_manager template to the persistent chat.
|
|
36
36
|
# If required parameters are missing, applies front matter config but
|
|
37
37
|
# defers rendering until run time when all values are available.
|
|
38
|
+
# :reek:TooManyStatements -- linear parse/merge/apply/render sequence with a documented deferred-render rescue.
|
|
38
39
|
def apply_template_to_chat(context)
|
|
39
40
|
parsed = PM.parse(@template)
|
|
40
41
|
|
|
@@ -68,6 +69,7 @@ module RobotLab
|
|
|
68
69
|
# Re-rendering replaces the system message, so the inline system_prompt must be
|
|
69
70
|
# re-appended here exactly as apply_system_prompt does at construction --
|
|
70
71
|
# otherwise it would be silently dropped on any run that supplies context.
|
|
72
|
+
# :reek:TooManyStatements -- must rebuild skills + template + inline prompt in one pass (see comment above).
|
|
71
73
|
def rerender_template(run_context)
|
|
72
74
|
merged = (@build_context || {}).merge(run_context)
|
|
73
75
|
resolved_ctx = resolve_context(merged, network: nil)
|
|
@@ -116,6 +118,7 @@ module RobotLab
|
|
|
116
118
|
# Pure computation — reads ivars but does not mutate @chat.
|
|
117
119
|
#
|
|
118
120
|
# @return [Array(Array<String>, RunConfig, Hash)] bodies, merged config, extras hash
|
|
121
|
+
# :reek:TooManyStatements -- accumulates bodies/config/extras across skills then the main template in one pure pass.
|
|
119
122
|
def collect_prompt_content(skill_ids, context)
|
|
120
123
|
visited = Set.new
|
|
121
124
|
visited.add(@template) if @template
|
|
@@ -128,16 +131,18 @@ module RobotLab
|
|
|
128
131
|
|
|
129
132
|
@expanded_skills.each do |skill_id|
|
|
130
133
|
parsed = PM.parse(skill_id)
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
metadata = parsed.metadata
|
|
135
|
+
accumulate_extras(metadata, extras)
|
|
136
|
+
accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(metadata))
|
|
133
137
|
body = render_body(parsed, resolved_ctx)
|
|
134
138
|
bodies << body if body
|
|
135
139
|
end
|
|
136
140
|
|
|
137
141
|
if @template
|
|
138
142
|
parsed = PM.parse(@template)
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
metadata = parsed.metadata
|
|
144
|
+
accumulate_extras(metadata, extras)
|
|
145
|
+
accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(metadata))
|
|
141
146
|
body = render_body(parsed, resolved_ctx)
|
|
142
147
|
bodies << body if body
|
|
143
148
|
end
|
|
@@ -181,6 +186,7 @@ module RobotLab
|
|
|
181
186
|
# @param visited [Set<Symbol>] already-visited IDs for cycle detection
|
|
182
187
|
# @param catalog [AgentSkillCatalog] catalog to check first
|
|
183
188
|
# @return [Array<Symbol>] flat ordered list of PM-based skill IDs
|
|
189
|
+
# :reek:TooManyStatements -- depth-first skill expansion with cycle guard and catalog-vs-PM branching.
|
|
184
190
|
def expand_skills_with_catalog(skill_ids, visited, catalog)
|
|
185
191
|
result = []
|
|
186
192
|
|
|
@@ -226,6 +232,7 @@ module RobotLab
|
|
|
226
232
|
#
|
|
227
233
|
# @param metadata [PM::Metadata] front matter metadata
|
|
228
234
|
# @return [Array<Symbol>]
|
|
235
|
+
# :reek:FeatureEnvy -- reading the metadata argument's skills list is the extraction itself.
|
|
229
236
|
def extract_skills_from_metadata(metadata)
|
|
230
237
|
return [] unless metadata.respond_to?(:skills) && metadata.skills
|
|
231
238
|
|
|
@@ -276,6 +283,7 @@ module RobotLab
|
|
|
276
283
|
|
|
277
284
|
# Extract identity and capability keys from front matter metadata.
|
|
278
285
|
# Constructor-provided values take precedence over frontmatter.
|
|
286
|
+
# :reek:FeatureEnvy -- copying front-matter metadata fields into this robot's ivars is the method's purpose.
|
|
279
287
|
def apply_front_matter_extras(metadata)
|
|
280
288
|
if metadata.respond_to?(:robot_name) && metadata.robot_name && !@name_from_constructor
|
|
281
289
|
@name = metadata.robot_name.to_s
|
|
@@ -309,6 +317,7 @@ module RobotLab
|
|
|
309
317
|
# Resolve string tool names from frontmatter to Ruby constants.
|
|
310
318
|
# Tool subclasses are instantiated; instances are used as-is.
|
|
311
319
|
# Unresolvable names are skipped with a warning.
|
|
320
|
+
# :reek:FeatureEnvy -- inspecting each resolved constant to decide instantiate-vs-use-as-is.
|
|
312
321
|
def resolve_frontmatter_tools(tool_names)
|
|
313
322
|
tool_names.filter_map do |name|
|
|
314
323
|
case name
|