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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +77 -29
- data/lib/ruby_pi/agent/core.rb +59 -4
- data/lib/ruby_pi/agent/events.rb +17 -3
- data/lib/ruby_pi/agent/loop.rb +103 -18
- data/lib/ruby_pi/agent/result.rb +46 -7
- data/lib/ruby_pi/agent/state.rb +12 -0
- data/lib/ruby_pi/configuration.rb +28 -7
- data/lib/ruby_pi/context/compaction.rb +17 -2
- data/lib/ruby_pi/context/transform.rb +67 -3
- data/lib/ruby_pi/errors.rb +19 -1
- data/lib/ruby_pi/llm/anthropic.rb +231 -59
- data/lib/ruby_pi/llm/base_provider.rb +44 -46
- data/lib/ruby_pi/llm/fallback.rb +106 -1
- data/lib/ruby_pi/llm/gemini.rb +161 -41
- data/lib/ruby_pi/llm/openai.rb +173 -42
- data/lib/ruby_pi/llm/stream_event.rb +13 -3
- data/lib/ruby_pi/llm/tool_call.rb +26 -3
- data/lib/ruby_pi/tools/executor.rb +130 -21
- data/lib/ruby_pi/tools/registry.rb +26 -16
- data/lib/ruby_pi/version.rb +1 -1
- data/lib/ruby_pi.rb +2 -1
- metadata +5 -39
data/lib/ruby_pi/agent/loop.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
148
|
-
timeout:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/ruby_pi/agent/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
data/lib/ruby_pi/agent/state.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
#
|
data/lib/ruby_pi/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|