ruby-pi 0.1.3 → 0.1.5

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.
@@ -31,16 +31,32 @@ module RubyPi
31
31
  # loop = RubyPi::Agent::Loop.new(state: state, emitter: agent)
32
32
  # result = loop.run
33
33
  class Loop
34
+ # Issue #18: Programming errors that should NOT be rescued by the loop.
35
+ # These indicate bugs in the calling code, not LLM/provider/tool failures.
36
+ # Rescuing them would silently swallow real bugs like typos or type mismatches.
37
+ PROGRAMMING_ERRORS = [
38
+ NoMethodError,
39
+ NameError,
40
+ ArgumentError,
41
+ TypeError
42
+ ].freeze
43
+
34
44
  # Creates a new Loop bound to the given state and event emitter.
35
45
  #
36
46
  # @param state [RubyPi::Agent::State] mutable agent state
37
47
  # @param emitter [#emit] object that responds to `emit(event, data)`
38
48
  # @param compaction [RubyPi::Context::Compaction, nil] optional compaction
39
49
  # strategy for managing context window size
40
- def initialize(state:, emitter:, compaction: nil)
50
+ # @param execution_mode [Symbol] tool execution mode (:parallel or
51
+ # :sequential, default: :parallel)
52
+ # @param tool_timeout [Numeric] per-tool execution timeout in seconds
53
+ # (default: 30)
54
+ def initialize(state:, emitter:, compaction: nil, execution_mode: :parallel, tool_timeout: 30)
41
55
  @state = state
42
56
  @emitter = emitter
43
57
  @compaction = compaction
58
+ @execution_mode = execution_mode
59
+ @tool_timeout = tool_timeout
44
60
  @tool_calls_made = []
45
61
  @total_usage = { input_tokens: 0, output_tokens: 0 }
46
62
  end
@@ -49,12 +65,24 @@ module RubyPi
49
65
  # Returns an Agent::Result capturing the final content, messages, tool
50
66
  # calls, usage, and turn count.
51
67
  #
68
+ # Issue #18: Re-raises programming errors (NoMethodError, NameError,
69
+ # ArgumentError, TypeError) instead of swallowing them. Only LLM/provider/
70
+ # tool errors are caught and wrapped in a failed Result.
71
+ #
72
+ # Issue #19: When max_iterations is reached, the Result now includes
73
+ # stop_reason: :max_iterations and success? returns false. Previously,
74
+ # the Result had no error set, so success? returned true even though
75
+ # the agent was forcibly stopped.
76
+ #
52
77
  # @return [RubyPi::Agent::Result] the outcome of the agent run
53
78
  def run
54
79
  loop do
55
80
  # Check iteration limit before starting a new turn
56
81
  if @state.max_iterations_reached?
57
- return build_result(content: last_assistant_content)
82
+ return build_result(
83
+ content: last_assistant_content,
84
+ stop_reason: :max_iterations
85
+ )
58
86
  end
59
87
 
60
88
  # Apply context compaction if configured and needed
@@ -78,9 +106,14 @@ module RubyPi
78
106
  else
79
107
  # No tool calls — the LLM is done
80
108
  @emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: false)
81
- return build_result(content: response.content)
109
+ return build_result(content: response.content, stop_reason: :complete)
82
110
  end
83
111
  end
112
+ rescue *PROGRAMMING_ERRORS
113
+ # Issue #18: Re-raise programming errors immediately. These are bugs
114
+ # in the calling code (typos, wrong argument counts, type mismatches)
115
+ # that should never be silently swallowed.
116
+ raise
84
117
  rescue StandardError => e
85
118
  @emitter.emit(:error, error: e, source: :agent_loop)
86
119
  Result.new(
@@ -89,14 +122,15 @@ module RubyPi
89
122
  tool_calls_made: @tool_calls_made,
90
123
  usage: @total_usage,
91
124
  turns: @state.iteration,
92
- error: e
125
+ error: e,
126
+ stop_reason: :error
93
127
  )
94
128
  end
95
129
 
96
130
  private
97
131
 
98
132
  # THINK phase: applies transforms, calls the LLM, and streams text
99
- # deltas back through the emitter.
133
+ # deltas and tool call deltas back through the emitter.
100
134
  #
101
135
  # @return [RubyPi::LLM::Response] the LLM response
102
136
  def think
@@ -123,6 +157,20 @@ module RubyPi
123
157
  if event.text_delta?
124
158
  streamed_content << event.data.to_s
125
159
  @emitter.emit(:text_delta, content: event.data)
160
+ elsif event.tool_call_delta?
161
+ # Emit tool call delta events so subscribers can observe partial
162
+ # tool call data as it streams in (e.g. for progress indicators
163
+ # or incremental JSON parsing).
164
+ @emitter.emit(:tool_call_delta, data: event.data)
165
+ elsif event.fallback_start?
166
+ # The primary LLM provider failed mid-stream and a Fallback
167
+ # provider is now taking over. Discard the partial text we
168
+ # accumulated from the failed primary so the agent's recorded
169
+ # response reflects only the fallback's output, and surface a
170
+ # :provider_fallback event so subscribers can clear any UI
171
+ # state they rendered from the discarded primary deltas.
172
+ streamed_content.clear
173
+ @emitter.emit(:provider_fallback, **event.data)
126
174
  end
127
175
  end
128
176
 
@@ -137,26 +185,48 @@ module RubyPi
137
185
  end
138
186
 
139
187
  # ACT phase: executes each tool call from the LLM response, firing
140
- # lifecycle hooks and events around each execution.
188
+ # lifecycle hooks and events around each execution. Uses the
189
+ # execution_mode and tool_timeout configured on the Loop.
190
+ #
191
+ # Issue #17: Checks for nil/empty tools before creating the Executor.
192
+ # If the LLM hallucinates tool calls but no tools are registered,
193
+ # raises NoToolsRegisteredError instead of crashing with NoMethodError.
141
194
  #
142
195
  # @param response [RubyPi::LLM::Response] the LLM response with tool calls
143
196
  # @return [void]
144
197
  def act(response)
198
+ # Issue #17: Guard against nil tools — if the model returned tool calls
199
+ # but no tools are registered, raise a clear typed error.
200
+ if @state.tools.nil?
201
+ raise RubyPi::NoToolsRegisteredError,
202
+ "Model returned #{response.tool_calls.size} tool call(s) but no tools are registered"
203
+ end
204
+
145
205
  executor = RubyPi::Tools::Executor.new(
146
206
  @state.tools,
147
- mode: :parallel,
148
- timeout: 30
207
+ mode: @execution_mode,
208
+ timeout: @tool_timeout
149
209
  )
150
210
 
211
+ # Symbolize the JSON-parsed (string-keyed) tool_call arguments once,
212
+ # up front. Both the executor (which actually invokes the tool block)
213
+ # and the recorded `tool_calls_made` payload use this symbol-keyed
214
+ # form, keeping a single consistent shape across the pipeline rather
215
+ # than mixing string keys (raw from JSON) and symbol keys (post-
216
+ # symbolize) in different places.
217
+ symbolized = response.tool_calls.map do |tc|
218
+ RubyPi::Tools::Executor.deep_symbolize_keys(tc.arguments)
219
+ end
220
+
151
221
  # Prepare call hashes for the executor
152
- calls = response.tool_calls.map do |tc|
153
- { name: tc.name, arguments: tc.arguments }
222
+ calls = response.tool_calls.each_with_index.map do |tc, idx|
223
+ { name: tc.name, arguments: symbolized[idx] }
154
224
  end
155
225
 
156
226
  # Fire before_tool_call hooks and emit start events
157
- response.tool_calls.each do |tc|
227
+ response.tool_calls.each_with_index do |tc, idx|
158
228
  @state.before_tool_call&.call(tc)
159
- @emitter.emit(:tool_execution_start, tool_name: tc.name, arguments: tc.arguments)
229
+ @emitter.emit(:tool_execution_start, tool_name: tc.name, arguments: symbolized[idx])
160
230
  end
161
231
 
162
232
  # Execute all tool calls
@@ -173,15 +243,28 @@ module RubyPi
173
243
  success: result.success?,
174
244
  duration_ms: result.duration_ms)
175
245
 
176
- # Record the tool call for the final result
246
+ # Record the tool call for the final result, using the symbolized
247
+ # arguments so callers see the same shape the tool itself received.
177
248
  @tool_calls_made << {
178
249
  tool_name: tc.name,
179
- arguments: tc.arguments,
250
+ arguments: symbolized[idx],
180
251
  result: result.to_h
181
252
  }
182
253
 
183
- # Add tool result to conversation as a tool-role message
184
- result_content = result.success? ? JSON.generate(result.value) : "Error: #{result.error}"
254
+ # Add tool result to conversation as a tool-role message. Tools may
255
+ # return values containing types JSON cannot natively serialize
256
+ # (Time, Date, custom objects). JSON.generate would raise and abort
257
+ # the agent run, so fall back to a string representation in that
258
+ # case rather than crashing the loop.
259
+ result_content = if result.success?
260
+ begin
261
+ JSON.generate(result.value)
262
+ rescue JSON::GeneratorError, TypeError
263
+ result.value.to_s
264
+ end
265
+ else
266
+ "Error: #{result.error}"
267
+ end
185
268
  @state.add_message(
186
269
  role: :tool,
187
270
  content: result_content,
@@ -250,14 +333,16 @@ module RubyPi
250
333
  # Constructs the final Agent::Result from the current state.
251
334
  #
252
335
  # @param content [String, nil] the final text content
336
+ # @param stop_reason [Symbol] why the agent stopped (:complete, :max_iterations, :error)
253
337
  # @return [RubyPi::Agent::Result]
254
- def build_result(content:)
338
+ def build_result(content:, stop_reason: :complete)
255
339
  Result.new(
256
340
  content: content,
257
341
  messages: @state.messages,
258
342
  tool_calls_made: @tool_calls_made,
259
343
  usage: @total_usage,
260
- turns: @state.iteration
344
+ turns: @state.iteration,
345
+ stop_reason: stop_reason
261
346
  )
262
347
  end
263
348
  end
@@ -23,6 +23,12 @@ module RubyPi
23
23
  # else
24
24
  # puts "Error: #{result.error.message}"
25
25
  # end
26
+ #
27
+ # @example Checking for truncation (Issue #19)
28
+ # result = agent.run("Complex task")
29
+ # if result.truncated?
30
+ # puts "Warning: agent hit max iterations — result may be incomplete"
31
+ # end
26
32
  class Result
27
33
  # @return [String, nil] the final text content from the assistant
28
34
  attr_reader :content
@@ -44,6 +50,16 @@ module RubyPi
44
50
  # @return [RubyPi::Error, StandardError, nil] the error if the run failed
45
51
  attr_reader :error
46
52
 
53
+ # @return [Symbol] the reason the agent stopped — :complete, :max_iterations,
54
+ # or :error. Allows callers to distinguish between a clean finish and
55
+ # being guillotined by the iteration limit.
56
+ #
57
+ # Issue #19: Added stop_reason to distinguish between a natural stop
58
+ # (LLM signaled completion) and hitting the max iteration limit. Previously,
59
+ # max_iterations produced a Result with no error, making success? return
60
+ # true even though the agent was forcibly stopped.
61
+ attr_reader :stop_reason
62
+
47
63
  # Creates a new Result instance.
48
64
  #
49
65
  # @param content [String, nil] the final assistant text
@@ -52,23 +68,43 @@ module RubyPi
52
68
  # @param usage [Hash] token usage statistics
53
69
  # @param turns [Integer] number of completed cycles
54
70
  # @param error [Exception, nil] error if the run failed
55
- def initialize(content: nil, messages: [], tool_calls_made: [], usage: {}, turns: 0, error: nil)
71
+ # @param stop_reason [Symbol] why the agent stopped (:complete, :max_iterations, :error)
72
+ def initialize(content: nil, messages: [], tool_calls_made: [], usage: {}, turns: 0, error: nil, stop_reason: :complete)
56
73
  @content = content
57
74
  @messages = Array(messages).freeze
58
75
  @tool_calls_made = Array(tool_calls_made).freeze
59
76
  @usage = usage
60
77
  @turns = turns
61
78
  @error = error
79
+ @stop_reason = stop_reason
62
80
  end
63
81
 
64
- # Returns true if the agent run completed without error.
82
+ # Returns true if the agent run completed without error AND was not
83
+ # truncated by the max iteration limit.
84
+ #
85
+ # Issue #19: Previously returned true when max_iterations was reached
86
+ # (because error was nil). Now returns false for truncated runs so
87
+ # callers can detect incomplete results.
65
88
  #
66
- # @return [Boolean] true unless an error is present
89
+ # @return [Boolean] true only if the run completed naturally without error
67
90
  def success?
68
- @error.nil?
91
+ @error.nil? && @stop_reason != :max_iterations
92
+ end
93
+
94
+ # Returns true if the agent was stopped by hitting the max iteration
95
+ # limit rather than completing naturally.
96
+ #
97
+ # Issue #19: Provides a convenient predicate for checking truncation
98
+ # without inspecting stop_reason directly.
99
+ #
100
+ # @return [Boolean] true if the run was truncated by max_iterations
101
+ def truncated?
102
+ @stop_reason == :max_iterations
69
103
  end
70
104
 
71
105
  # Returns a hash representation of the result for serialization.
106
+ # Includes both the error class name and message for full diagnostic
107
+ # context when an error is present.
72
108
  #
73
109
  # @return [Hash]
74
110
  def to_h
@@ -78,8 +114,10 @@ module RubyPi
78
114
  tool_calls_made: @tool_calls_made,
79
115
  usage: @usage,
80
116
  turns: @turns,
81
- error: @error&.message,
82
- success: success?
117
+ error: @error ? { class: @error.class.name, message: @error.message } : nil,
118
+ success: success?,
119
+ stop_reason: @stop_reason,
120
+ truncated: truncated?
83
121
  }
84
122
  end
85
123
 
@@ -88,10 +126,11 @@ module RubyPi
88
126
  # @return [String]
89
127
  def to_s
90
128
  status = success? ? "success" : "error"
91
- parts = ["status=#{status}", "turns=#{@turns}"]
129
+ parts = ["status=#{status}", "turns=#{@turns}", "stop_reason=#{@stop_reason}"]
92
130
  parts << "tools=#{@tool_calls_made.size}" unless @tool_calls_made.empty?
93
131
  parts << "content=#{@content&.slice(0, 80).inspect}" if @content
94
132
  parts << "error=#{@error.class}: #{@error.message}" if @error
133
+ parts << "truncated=true" if truncated?
95
134
  "#<RubyPi::Agent::Result #{parts.join(', ')}>"
96
135
  end
97
136
 
@@ -134,6 +134,18 @@ module RubyPi
134
134
  @iteration += 1
135
135
  end
136
136
 
137
+ # Resets the iteration counter to zero.
138
+ #
139
+ # Issue #16: Provides an encapsulated way to reset the iteration counter
140
+ # instead of using instance_variable_set(:@iteration, 0) which bypasses
141
+ # encapsulation. Called at the start of both run() and continue() to
142
+ # ensure each invocation gets a fresh iteration budget.
143
+ #
144
+ # @return [Integer] the reset iteration count (0)
145
+ def reset_iteration!
146
+ @iteration = 0
147
+ end
148
+
137
149
  # Returns true if the iteration count has reached or exceeded max_iterations.
138
150
  #
139
151
  # @return [Boolean]
@@ -5,6 +5,10 @@
5
5
  # Global configuration for the RubyPi framework. Provides a centralized place
6
6
  # to set API keys, retry behavior, timeouts, and default model preferences.
7
7
  # Configure via RubyPi.configure { |c| c.gemini_api_key = "..." }.
8
+ #
9
+ # Supports both global (singleton) and per-agent configuration. Pass a
10
+ # Configuration instance to Agent::Core via the `config:` kwarg to override
11
+ # the global defaults for that agent.
8
12
 
9
13
  module RubyPi
10
14
  # Holds all configurable settings for the RubyPi framework.
@@ -17,6 +21,11 @@ module RubyPi
17
21
  # config.max_retries = 5
18
22
  # config.retry_base_delay = 2.0
19
23
  # end
24
+ #
25
+ # @example Per-agent configuration override
26
+ # custom_config = RubyPi::Configuration.new
27
+ # custom_config.openai_api_key = "per-agent-key"
28
+ # agent = RubyPi::Agent.new(system_prompt: "...", model: model, config: custom_config)
20
29
  class Configuration
21
30
  # @return [String, nil] API key for Google Gemini
22
31
  attr_accessor :gemini_api_key
@@ -56,6 +65,25 @@ module RubyPi
56
65
 
57
66
  # Initializes a new Configuration with sensible defaults.
58
67
  def initialize
68
+ set_defaults
69
+ end
70
+
71
+ # Resets all configuration options to their default values.
72
+ # Uses the shared set_defaults method to avoid calling initialize directly.
73
+ #
74
+ # @return [void]
75
+ def reset!
76
+ set_defaults
77
+ end
78
+
79
+ private
80
+
81
+ # Sets all configuration ivars to their default values. Called by both
82
+ # initialize and reset! to ensure consistent defaults without the
83
+ # anti-pattern of calling initialize from reset!.
84
+ #
85
+ # @return [void]
86
+ def set_defaults
59
87
  @gemini_api_key = nil
60
88
  @anthropic_api_key = nil
61
89
  @openai_api_key = nil
@@ -69,12 +97,5 @@ module RubyPi
69
97
  @default_openai_model = "gpt-4o"
70
98
  @logger = nil
71
99
  end
72
-
73
- # Resets all configuration options to their default values.
74
- #
75
- # @return [void]
76
- def reset!
77
- initialize
78
- end
79
100
  end
80
101
  end
@@ -87,9 +87,24 @@ module RubyPi
87
87
  # Emit compaction event if an emitter is available
88
88
  @emitter&.emit(:compaction, dropped_count: droppable.size, summary: summary)
89
89
 
90
- # Build the compacted history: summary as a system-context message + preserved
90
+ # Build the compacted history: summary message + preserved.
91
+ #
92
+ # The summary role MUST NOT be :system (that would overwrite the real
93
+ # system prompt on Anthropic, which extracts the last :system message
94
+ # as the top-level `system:` parameter).
95
+ #
96
+ # The summary role must also NOT match the role of the first preserved
97
+ # message — consecutive same-role messages are rejected by Anthropic.
98
+ # We pick :user when the next preserved message is :assistant, and
99
+ # :assistant otherwise (covers :user, :tool, and an empty preserved).
100
+ # On Anthropic, :tool messages become role :user with tool_result
101
+ # blocks, so :assistant is the safe choice when the next message is
102
+ # :tool too.
103
+ first_preserved_role = preserved.first&.dig(:role)
104
+ summary_role = first_preserved_role == :assistant ? :user : :assistant
105
+
91
106
  summary_message = {
92
- role: :system,
107
+ role: summary_role,
93
108
  content: "[Conversation Summary]\n#{summary}"
94
109
  }
95
110
 
@@ -10,6 +10,12 @@
10
10
  # module provides factory methods for common transform patterns (datetime
11
11
  # injection, user preferences, workspace context) and a `compose` method for
12
12
  # chaining multiple transforms into a single callable.
13
+ #
14
+ # IDEMPOTENCY: Each injection method uses unique marker delimiters
15
+ # (e.g., <!-- RUBYPI_DATETIME_START --> ... <!-- RUBYPI_DATETIME_END -->) to
16
+ # wrap injected content. Before re-adding, the transform strips any existing
17
+ # injection matching its markers. This prevents the system prompt from
18
+ # accumulating duplicate injections across multiple LLM calls in a loop.
13
19
 
14
20
  module RubyPi
15
21
  module Context
@@ -24,6 +30,16 @@ module RubyPi
24
30
  # )
25
31
  # agent = RubyPi::Agent.new(transform_context: transform, ...)
26
32
  module Transform
33
+ # Marker delimiters for idempotent injection. Each injection type has
34
+ # unique start/end markers so they can be independently stripped and
35
+ # re-added without affecting each other.
36
+ DATETIME_START_MARKER = "<!-- RUBYPI_DATETIME_START -->"
37
+ DATETIME_END_MARKER = "<!-- RUBYPI_DATETIME_END -->"
38
+ PREFS_START_MARKER = "<!-- RUBYPI_PREFS_START -->"
39
+ PREFS_END_MARKER = "<!-- RUBYPI_PREFS_END -->"
40
+ WORKSPACE_START_MARKER = "<!-- RUBYPI_WORKSPACE_START -->"
41
+ WORKSPACE_END_MARKER = "<!-- RUBYPI_WORKSPACE_END -->"
42
+
27
43
  class << self
28
44
  # Chains multiple transform callables into a single callable that
29
45
  # executes them in order. Each transform receives the same State
@@ -44,6 +60,10 @@ module RubyPi
44
60
  # Returns a transform that appends the current date and time to the
45
61
  # system prompt. Useful for giving the LLM temporal awareness.
46
62
  #
63
+ # This injection is IDEMPOTENT: it strips any existing datetime
64
+ # injection (identified by markers) before re-adding, so calling it
65
+ # N times in a loop produces exactly one datetime block.
66
+ #
47
67
  # @return [Proc] transform callable
48
68
  #
49
69
  # @example
@@ -52,7 +72,17 @@ module RubyPi
52
72
  def inject_datetime
53
73
  ->(state) do
54
74
  timestamp = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
55
- state.system_prompt += "\n\nCurrent date and time: #{timestamp}"
75
+
76
+ # Strip any existing datetime injection before re-adding.
77
+ # This makes the transform idempotent — calling it multiple times
78
+ # across loop iterations does not accumulate duplicate timestamps.
79
+ state.system_prompt = strip_between_markers(
80
+ state.system_prompt,
81
+ DATETIME_START_MARKER,
82
+ DATETIME_END_MARKER
83
+ )
84
+
85
+ state.system_prompt += "\n\n#{DATETIME_START_MARKER}\nCurrent date and time: #{timestamp}\n#{DATETIME_END_MARKER}"
56
86
  end
57
87
  end
58
88
 
@@ -61,6 +91,9 @@ module RubyPi
61
91
  # string or hash of preferences. If nil is returned, nothing is
62
92
  # appended.
63
93
  #
94
+ # This injection is IDEMPOTENT: existing preferences are stripped
95
+ # before re-adding.
96
+ #
64
97
  # @yield [state] block that extracts preferences from the state
65
98
  # @yieldparam state [RubyPi::Agent::State] the current agent state
66
99
  # @yieldreturn [String, Hash, nil] preferences to inject
@@ -71,10 +104,16 @@ module RubyPi
71
104
  def inject_user_preferences(&block)
72
105
  ->(state) do
73
106
  preferences = block.call(state)
107
+ # Always strip existing preferences injection first for idempotency
108
+ state.system_prompt = strip_between_markers(
109
+ state.system_prompt,
110
+ PREFS_START_MARKER,
111
+ PREFS_END_MARKER
112
+ )
74
113
  return if preferences.nil?
75
114
 
76
115
  prefs_text = preferences.is_a?(Hash) ? format_hash(preferences) : preferences.to_s
77
- state.system_prompt += "\n\n[User Preferences]\n#{prefs_text}"
116
+ state.system_prompt += "\n\n#{PREFS_START_MARKER}\n[User Preferences]\n#{prefs_text}\n#{PREFS_END_MARKER}"
78
117
  end
79
118
  end
80
119
 
@@ -82,6 +121,9 @@ module RubyPi
82
121
  # prompt. The block is called with the state and should return
83
122
  # contextual information about the current workspace/project.
84
123
  #
124
+ # This injection is IDEMPOTENT: existing workspace context is stripped
125
+ # before re-adding.
126
+ #
85
127
  # @yield [state] block that extracts workspace context from the state
86
128
  # @yieldparam state [RubyPi::Agent::State] the current agent state
87
129
  # @yieldreturn [String, Hash, nil] workspace context to inject
@@ -92,15 +134,37 @@ module RubyPi
92
134
  def inject_workspace_context(&block)
93
135
  ->(state) do
94
136
  context = block.call(state)
137
+ # Always strip existing workspace injection first for idempotency
138
+ state.system_prompt = strip_between_markers(
139
+ state.system_prompt,
140
+ WORKSPACE_START_MARKER,
141
+ WORKSPACE_END_MARKER
142
+ )
95
143
  return if context.nil?
96
144
 
97
145
  ctx_text = context.is_a?(Hash) ? format_hash(context) : context.to_s
98
- state.system_prompt += "\n\n[Workspace Context]\n#{ctx_text}"
146
+ state.system_prompt += "\n\n#{WORKSPACE_START_MARKER}\n[Workspace Context]\n#{ctx_text}\n#{WORKSPACE_END_MARKER}"
99
147
  end
100
148
  end
101
149
 
102
150
  private
103
151
 
152
+ # Strips content between (and including) the given start and end
153
+ # markers from the text. Used to remove a previous injection before
154
+ # re-adding it, ensuring idempotency.
155
+ #
156
+ # @param text [String] the text to strip markers from
157
+ # @param start_marker [String] the opening marker
158
+ # @param end_marker [String] the closing marker
159
+ # @return [String] text with the marked section removed
160
+ def strip_between_markers(text, start_marker, end_marker)
161
+ # Use a regex that matches the markers and everything between them,
162
+ # including any leading whitespace (newlines) before the start marker.
163
+ escaped_start = Regexp.escape(start_marker)
164
+ escaped_end = Regexp.escape(end_marker)
165
+ text.gsub(/\s*#{escaped_start}.*?#{escaped_end}/m, "")
166
+ end
167
+
104
168
  # Formats a hash into a human-readable key-value string for injection
105
169
  # into the system prompt.
106
170
  #
@@ -88,10 +88,28 @@ module RubyPi
88
88
 
89
89
  # Raised when a subclass does not implement a required abstract method
90
90
  # from a base class.
91
- class NotImplementedError < Error
91
+ #
92
+ # Issue #14: Renamed from NotImplementedError to AbstractMethodError to
93
+ # avoid shadowing Ruby's stdlib NotImplementedError < ScriptError. The
94
+ # stdlib class is raised by Kernel#fork on platforms that don't support it,
95
+ # and shadowing it can cause confusing rescue behavior.
96
+ class AbstractMethodError < Error
92
97
  # @param method_name [String, Symbol] the name of the unimplemented method
93
98
  def initialize(method_name = nil)
94
99
  super(method_name ? "Subclass must implement ##{method_name}" : "Subclass must implement this method")
95
100
  end
96
101
  end
102
+
103
+ # Raised when the LLM returns tool calls but no tools are registered
104
+ # with the agent. This typically means the model hallucinated a tool
105
+ # invocation that cannot be fulfilled.
106
+ #
107
+ # Issue #17: Provides a clear typed error instead of a NoMethodError
108
+ # when nil.find is called on a nil tools registry.
109
+ class NoToolsRegisteredError < Error
110
+ # @param message [String] human-readable error description
111
+ def initialize(message = nil)
112
+ super(message || "Model returned tool calls but no tools are registered")
113
+ end
114
+ end
97
115
  end