robot_lab 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +6 -2
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +70 -1
  6. data/CLAUDE.md +4 -2
  7. data/README.md +15 -17
  8. data/Rakefile +6 -111
  9. data/_typos.toml +21 -0
  10. data/docs/api/index.md +2 -2
  11. data/docs/api/skills.md +53 -149
  12. data/docs/api/support.md +1 -1
  13. data/docs/architecture/core-concepts.md +1 -1
  14. data/docs/architecture/index.md +5 -4
  15. data/docs/architecture/state-management.md +4 -4
  16. data/docs/concepts.md +1 -1
  17. data/docs/getting-started/configuration.md +4 -1
  18. data/docs/getting-started/installation.md +1 -4
  19. data/docs/guides/hooks.md +122 -0
  20. data/docs/guides/memory.md +2 -2
  21. data/docs/guides/using-tools.md +10 -1
  22. data/examples/.envrc +2 -0
  23. data/examples/02_tools.rb +8 -8
  24. data/examples/03_network.rb +1 -1
  25. data/examples/04_mcp.rb +7 -5
  26. data/examples/08_llm_config.rb +5 -5
  27. data/examples/09_chaining.rb +3 -3
  28. data/examples/14_rusty_circuit/comic.rb +8 -8
  29. data/examples/14_rusty_circuit/scout.rb +4 -4
  30. data/examples/15_memory_network_and_bus/README.md +66 -0
  31. data/examples/15_memory_network_and_bus/output/combined_article.md +5 -7
  32. data/examples/15_memory_network_and_bus/output/final_article.md +5 -10
  33. data/examples/15_memory_network_and_bus/output/linux_draft.md +3 -3
  34. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  35. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  36. data/examples/15_memory_network_and_bus/output/revision_1.md +21 -10
  37. data/examples/15_memory_network_and_bus/output/revision_2.md +43 -6
  38. data/examples/15_memory_network_and_bus/output/revision_3.md +8 -0
  39. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  40. data/examples/16_writers_room/tools.rb +14 -14
  41. data/examples/19_token_tracking.rb +2 -2
  42. data/examples/20_circuit_breaker.rb +3 -3
  43. data/examples/22_context_compression.rb +1 -1
  44. data/examples/27_incident_response/README.md +65 -0
  45. data/examples/28_mcp_discovery.rb +2 -2
  46. data/examples/29_ractor_tools.rb +4 -4
  47. data/examples/30_ractor_network.rb +2 -2
  48. data/examples/33_stock_predictor.rb +8 -8
  49. data/examples/35_hooks.rb +3 -3
  50. data/examples/README.md +17 -0
  51. data/examples/common.rb +55 -23
  52. data/examples/run_all.rb +60 -0
  53. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  54. data/lib/robot_lab/ask_user.rb +5 -3
  55. data/lib/robot_lab/bus_poller.rb +2 -0
  56. data/lib/robot_lab/capabilities.rb +4 -0
  57. data/lib/robot_lab/config/defaults.yml +5 -5
  58. data/lib/robot_lab/config.rb +10 -2
  59. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  60. data/lib/robot_lab/history_compressor.rb +5 -0
  61. data/lib/robot_lab/hook.rb +1 -0
  62. data/lib/robot_lab/hook_context.rb +4 -0
  63. data/lib/robot_lab/hook_registry.rb +1 -0
  64. data/lib/robot_lab/hooks.rb +6 -3
  65. data/lib/robot_lab/mcp/client.rb +2 -2
  66. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  67. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  68. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  69. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  70. data/lib/robot_lab/mcp/transports/streamable_http.rb +5 -3
  71. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  72. data/lib/robot_lab/memory.rb +39 -13
  73. data/lib/robot_lab/memory_change.rb +1 -0
  74. data/lib/robot_lab/message.rb +3 -0
  75. data/lib/robot_lab/names.rb +2 -4
  76. data/lib/robot_lab/network.rb +13 -6
  77. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  78. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  79. data/lib/robot_lab/robot/history_search.rb +2 -0
  80. data/lib/robot_lab/robot/hooking.rb +29 -0
  81. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  82. data/lib/robot_lab/robot/result_building.rb +119 -0
  83. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  84. data/lib/robot_lab/robot.rb +88 -130
  85. data/lib/robot_lab/robot_result.rb +3 -0
  86. data/lib/robot_lab/run_config.rb +57 -20
  87. data/lib/robot_lab/script_tool.rb +20 -39
  88. data/lib/robot_lab/state_proxy.rb +1 -0
  89. data/lib/robot_lab/streaming/context.rb +1 -0
  90. data/lib/robot_lab/streaming/events.rb +1 -0
  91. data/lib/robot_lab/task.rb +2 -0
  92. data/lib/robot_lab/tool.rb +11 -12
  93. data/lib/robot_lab/user_message.rb +1 -0
  94. data/lib/robot_lab/utils.rb +2 -0
  95. data/lib/robot_lab/version.rb +1 -1
  96. data/lib/robot_lab/waiter.rb +3 -0
  97. data/lib/robot_lab.rb +9 -6
  98. metadata +23 -32
  99. data/lib/robot_lab/sandbox/null.rb +0 -13
  100. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  101. data/lib/robot_lab/sandbox.rb +0 -52
@@ -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
@@ -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(context.context)
208
+ run_with_ractor_scheduler(run_params)
205
209
  else
206
210
  initial_result = SimpleFlow::Result.new(
207
- context.context,
208
- context: { run_params: context.context }
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
- if @robots.key?(robot.name)
333
- raise ArgumentError, "Robot '#{robot.name}' already exists in network '#{@name}'"
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[robot.name] = robot
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
- RobotLab.config.logger.info(
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
- RobotLab.config.logger.info(
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
- RobotLab.config.logger.warn(
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
- accumulate_extras(parsed.metadata, extras)
132
- accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
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
- accumulate_extras(parsed.metadata, extras)
140
- accumulated_config = accumulated_config.merge(RunConfig.from_front_matter(parsed.metadata))
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