ruby-pi 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +415 -0
- data/lib/ruby_pi/agent/core.rb +175 -0
- data/lib/ruby_pi/agent/events.rb +120 -0
- data/lib/ruby_pi/agent/loop.rb +265 -0
- data/lib/ruby_pi/agent/result.rb +101 -0
- data/lib/ruby_pi/agent/state.rb +155 -0
- data/lib/ruby_pi/configuration.rb +80 -0
- data/lib/ruby_pi/context/compaction.rb +160 -0
- data/lib/ruby_pi/context/transform.rb +115 -0
- data/lib/ruby_pi/errors.rb +97 -0
- data/lib/ruby_pi/extensions/base.rb +96 -0
- data/lib/ruby_pi/llm/anthropic.rb +314 -0
- data/lib/ruby_pi/llm/base_provider.rb +220 -0
- data/lib/ruby_pi/llm/fallback.rb +96 -0
- data/lib/ruby_pi/llm/gemini.rb +260 -0
- data/lib/ruby_pi/llm/model.rb +82 -0
- data/lib/ruby_pi/llm/openai.rb +287 -0
- data/lib/ruby_pi/llm/response.rb +82 -0
- data/lib/ruby_pi/llm/stream_event.rb +91 -0
- data/lib/ruby_pi/llm/tool_call.rb +78 -0
- data/lib/ruby_pi/tools/definition.rb +149 -0
- data/lib/ruby_pi/tools/executor.rb +168 -0
- data/lib/ruby_pi/tools/registry.rb +120 -0
- data/lib/ruby_pi/tools/result.rb +83 -0
- data/lib/ruby_pi/tools/schema.rb +170 -0
- data/lib/ruby_pi/version.rb +11 -0
- data/lib/ruby_pi.rb +112 -0
- metadata +192 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/agent/events.rb
|
|
4
|
+
#
|
|
5
|
+
# Defines the canonical set of agent lifecycle event types and the EventEmitter
|
|
6
|
+
# mixin that provides publish/subscribe functionality. Any class that includes
|
|
7
|
+
# EventEmitter gains the ability to register event handlers with `on`, fire them
|
|
8
|
+
# with `emit`, and remove them with `off`. The Agent::Core class uses this to
|
|
9
|
+
# broadcast progress events (text deltas, tool execution, errors) to subscribers.
|
|
10
|
+
|
|
11
|
+
module RubyPi
|
|
12
|
+
module Agent
|
|
13
|
+
# Canonical event types emitted during the agent lifecycle. Each symbol
|
|
14
|
+
# represents a specific moment or occurrence:
|
|
15
|
+
#
|
|
16
|
+
# - :text_delta — An incremental text chunk from the LLM stream.
|
|
17
|
+
# - :tool_execution_start — A tool is about to be executed.
|
|
18
|
+
# - :tool_execution_end — A tool has finished executing.
|
|
19
|
+
# - :turn_start — A new think-act-observe cycle is beginning.
|
|
20
|
+
# - :turn_end — A think-act-observe cycle has completed.
|
|
21
|
+
# - :agent_end — The agent has finished its run (final event).
|
|
22
|
+
# - :error — A recoverable or fatal error occurred.
|
|
23
|
+
# - :compaction — Context compaction was triggered.
|
|
24
|
+
EVENTS = %i[
|
|
25
|
+
text_delta
|
|
26
|
+
tool_execution_start
|
|
27
|
+
tool_execution_end
|
|
28
|
+
turn_start
|
|
29
|
+
turn_end
|
|
30
|
+
agent_end
|
|
31
|
+
error
|
|
32
|
+
compaction
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Mixin that adds event subscription and emission to any class. Include
|
|
36
|
+
# this module and call `on`, `emit`, and `off` to wire up event-driven
|
|
37
|
+
# communication between components.
|
|
38
|
+
#
|
|
39
|
+
# @example Using EventEmitter in a class
|
|
40
|
+
# class MyService
|
|
41
|
+
# include RubyPi::Agent::EventEmitter
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# svc = MyService.new
|
|
45
|
+
# svc.on(:text_delta) { |data| puts data[:content] }
|
|
46
|
+
# svc.emit(:text_delta, content: "Hello")
|
|
47
|
+
module EventEmitter
|
|
48
|
+
# Subscribes a handler block to a specific event type. The block will
|
|
49
|
+
# be called every time `emit` fires for that event. Multiple handlers
|
|
50
|
+
# can be registered for the same event — they are invoked in the order
|
|
51
|
+
# they were registered.
|
|
52
|
+
#
|
|
53
|
+
# @param event [Symbol] the event type to subscribe to (must be in EVENTS)
|
|
54
|
+
# @param block [Proc] the handler to invoke when the event fires
|
|
55
|
+
# @return [Proc] the registered handler block, for later removal via `off`
|
|
56
|
+
# @raise [ArgumentError] if the event type is not in EVENTS
|
|
57
|
+
def on(event, &block)
|
|
58
|
+
validate_event!(event)
|
|
59
|
+
event_handlers[event] << block
|
|
60
|
+
block
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fires all handlers registered for the given event type. Each handler
|
|
64
|
+
# receives the `data` hash as its argument. Handlers that raise are
|
|
65
|
+
# rescued individually — one failing handler does not prevent others
|
|
66
|
+
# from executing.
|
|
67
|
+
#
|
|
68
|
+
# @param event [Symbol] the event type to fire
|
|
69
|
+
# @param data [Hash] arbitrary payload passed to each handler
|
|
70
|
+
# @return [void]
|
|
71
|
+
def emit(event, data = {})
|
|
72
|
+
validate_event!(event)
|
|
73
|
+
event_handlers[event].each do |handler|
|
|
74
|
+
handler.call(data)
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
# Log but do not propagate handler errors — they should not break
|
|
77
|
+
# the agent loop. Emit an :error event if this is not already an
|
|
78
|
+
# error event (to prevent infinite recursion).
|
|
79
|
+
if event != :error
|
|
80
|
+
emit(:error, error: e, source: :event_handler, event: event)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Removes a specific handler from an event's subscriber list. If the
|
|
86
|
+
# handler is not found, this is a no-op. Pass the same block reference
|
|
87
|
+
# that was given to `on`.
|
|
88
|
+
#
|
|
89
|
+
# @param event [Symbol] the event type to unsubscribe from
|
|
90
|
+
# @param block [Proc] the handler to remove
|
|
91
|
+
# @return [Proc, nil] the removed handler, or nil if not found
|
|
92
|
+
def off(event, &block)
|
|
93
|
+
validate_event!(event)
|
|
94
|
+
event_handlers[event].delete(block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Returns (and lazily initializes) the internal handler registry.
|
|
100
|
+
# Each event type maps to an array of callable handler blocks.
|
|
101
|
+
#
|
|
102
|
+
# @return [Hash{Symbol => Array<Proc>}] handlers keyed by event type
|
|
103
|
+
def event_handlers
|
|
104
|
+
@event_handlers ||= Hash.new { |h, k| h[k] = [] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validates that the given event symbol is a recognized event type.
|
|
108
|
+
#
|
|
109
|
+
# @param event [Symbol] the event type to validate
|
|
110
|
+
# @raise [ArgumentError] if the event is not in EVENTS
|
|
111
|
+
def validate_event!(event)
|
|
112
|
+
return if EVENTS.include?(event)
|
|
113
|
+
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"Unknown event type: #{event.inspect}. " \
|
|
116
|
+
"Must be one of: #{EVENTS.join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/agent/loop.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Agent::Loop — Implements the think-act-observe agentic cycle.
|
|
6
|
+
#
|
|
7
|
+
# The Loop drives the core agent behavior: calling the LLM (think), executing
|
|
8
|
+
# any tool calls (act), feeding results back into the conversation (observe),
|
|
9
|
+
# and repeating until the LLM signals completion or the max iteration limit
|
|
10
|
+
# is reached. It handles streaming, lifecycle events, compaction, and all
|
|
11
|
+
# pre/post tool call hooks.
|
|
12
|
+
|
|
13
|
+
module RubyPi
|
|
14
|
+
module Agent
|
|
15
|
+
# Executes the think-act-observe cycle against a given State, emitting
|
|
16
|
+
# events through the provided EventEmitter-compatible emitter. Returns
|
|
17
|
+
# an Agent::Result when the cycle terminates.
|
|
18
|
+
#
|
|
19
|
+
# The cycle:
|
|
20
|
+
# 1. THINK — Call the LLM with current messages and tools. Apply
|
|
21
|
+
# transform_context if present. Emit :turn_start and stream
|
|
22
|
+
# :text_delta events.
|
|
23
|
+
# 2. ACT — If the LLM returned tool calls, execute them via
|
|
24
|
+
# Tools::Executor. Fire before_tool_call / after_tool_call hooks
|
|
25
|
+
# and emit :tool_execution_start / :tool_execution_end events.
|
|
26
|
+
# 3. OBSERVE — Append tool results to messages and loop back to THINK.
|
|
27
|
+
# 4. DONE — Return when finish_reason == "stop" (no more tool calls)
|
|
28
|
+
# or max_iterations is reached.
|
|
29
|
+
#
|
|
30
|
+
# @example Running the loop directly
|
|
31
|
+
# loop = RubyPi::Agent::Loop.new(state: state, emitter: agent)
|
|
32
|
+
# result = loop.run
|
|
33
|
+
class Loop
|
|
34
|
+
# Creates a new Loop bound to the given state and event emitter.
|
|
35
|
+
#
|
|
36
|
+
# @param state [RubyPi::Agent::State] mutable agent state
|
|
37
|
+
# @param emitter [#emit] object that responds to `emit(event, data)`
|
|
38
|
+
# @param compaction [RubyPi::Context::Compaction, nil] optional compaction
|
|
39
|
+
# strategy for managing context window size
|
|
40
|
+
def initialize(state:, emitter:, compaction: nil)
|
|
41
|
+
@state = state
|
|
42
|
+
@emitter = emitter
|
|
43
|
+
@compaction = compaction
|
|
44
|
+
@tool_calls_made = []
|
|
45
|
+
@total_usage = { input_tokens: 0, output_tokens: 0 }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs the think-act-observe cycle until completion or max iterations.
|
|
49
|
+
# Returns an Agent::Result capturing the final content, messages, tool
|
|
50
|
+
# calls, usage, and turn count.
|
|
51
|
+
#
|
|
52
|
+
# @return [RubyPi::Agent::Result] the outcome of the agent run
|
|
53
|
+
def run
|
|
54
|
+
loop do
|
|
55
|
+
# Check iteration limit before starting a new turn
|
|
56
|
+
if @state.max_iterations_reached?
|
|
57
|
+
return build_result(content: last_assistant_content)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Apply context compaction if configured and needed
|
|
61
|
+
compact_if_needed!
|
|
62
|
+
|
|
63
|
+
# THINK: Call the LLM
|
|
64
|
+
response = think
|
|
65
|
+
|
|
66
|
+
# Track usage from this turn
|
|
67
|
+
accumulate_usage(response.usage)
|
|
68
|
+
|
|
69
|
+
# Increment iteration counter
|
|
70
|
+
@state.increment_iteration!
|
|
71
|
+
|
|
72
|
+
if response.tool_calls?
|
|
73
|
+
# ACT: Execute tool calls
|
|
74
|
+
act(response)
|
|
75
|
+
|
|
76
|
+
# OBSERVE: Tool results have been added to messages; loop continues
|
|
77
|
+
@emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: true)
|
|
78
|
+
else
|
|
79
|
+
# No tool calls — the LLM is done
|
|
80
|
+
@emitter.emit(:turn_end, turn: @state.iteration, has_tool_calls: false)
|
|
81
|
+
return build_result(content: response.content)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
@emitter.emit(:error, error: e, source: :agent_loop)
|
|
86
|
+
Result.new(
|
|
87
|
+
content: nil,
|
|
88
|
+
messages: @state.messages,
|
|
89
|
+
tool_calls_made: @tool_calls_made,
|
|
90
|
+
usage: @total_usage,
|
|
91
|
+
turns: @state.iteration,
|
|
92
|
+
error: e
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# THINK phase: applies transforms, calls the LLM, and streams text
|
|
99
|
+
# deltas back through the emitter.
|
|
100
|
+
#
|
|
101
|
+
# @return [RubyPi::LLM::Response] the LLM response
|
|
102
|
+
def think
|
|
103
|
+
# Apply transform_context hook before the LLM call
|
|
104
|
+
@state.transform_context&.call(@state)
|
|
105
|
+
|
|
106
|
+
@emitter.emit(:turn_start, turn: @state.iteration + 1)
|
|
107
|
+
|
|
108
|
+
# Build the messages array for the LLM call, prepending the system prompt
|
|
109
|
+
messages = build_llm_messages
|
|
110
|
+
|
|
111
|
+
# Build tools array for the LLM
|
|
112
|
+
tools = build_tools_array
|
|
113
|
+
|
|
114
|
+
# Accumulate streamed content
|
|
115
|
+
streamed_content = +""
|
|
116
|
+
|
|
117
|
+
# Call the LLM with streaming
|
|
118
|
+
response = @state.model.complete(
|
|
119
|
+
messages: messages,
|
|
120
|
+
tools: tools,
|
|
121
|
+
stream: true
|
|
122
|
+
) do |event|
|
|
123
|
+
if event.text_delta?
|
|
124
|
+
streamed_content << event.data.to_s
|
|
125
|
+
@emitter.emit(:text_delta, content: event.data)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Add the assistant's response to conversation history
|
|
130
|
+
assistant_message = { role: :assistant, content: response.content }
|
|
131
|
+
if response.tool_calls?
|
|
132
|
+
assistant_message[:tool_calls] = response.tool_calls.map(&:to_h)
|
|
133
|
+
end
|
|
134
|
+
@state.add_message(**assistant_message)
|
|
135
|
+
|
|
136
|
+
response
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ACT phase: executes each tool call from the LLM response, firing
|
|
140
|
+
# lifecycle hooks and events around each execution.
|
|
141
|
+
#
|
|
142
|
+
# @param response [RubyPi::LLM::Response] the LLM response with tool calls
|
|
143
|
+
# @return [void]
|
|
144
|
+
def act(response)
|
|
145
|
+
executor = RubyPi::Tools::Executor.new(
|
|
146
|
+
@state.tools,
|
|
147
|
+
mode: :parallel,
|
|
148
|
+
timeout: 30
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Prepare call hashes for the executor
|
|
152
|
+
calls = response.tool_calls.map do |tc|
|
|
153
|
+
{ name: tc.name, arguments: tc.arguments }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Fire before_tool_call hooks and emit start events
|
|
157
|
+
response.tool_calls.each do |tc|
|
|
158
|
+
@state.before_tool_call&.call(tc)
|
|
159
|
+
@emitter.emit(:tool_execution_start, tool_name: tc.name, arguments: tc.arguments)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Execute all tool calls
|
|
163
|
+
results = executor.execute(calls)
|
|
164
|
+
|
|
165
|
+
# Fire after_tool_call hooks, emit end events, and add results to messages
|
|
166
|
+
response.tool_calls.each_with_index do |tc, idx|
|
|
167
|
+
result = results[idx]
|
|
168
|
+
|
|
169
|
+
@state.after_tool_call&.call(tc, result)
|
|
170
|
+
@emitter.emit(:tool_execution_end,
|
|
171
|
+
tool_name: tc.name,
|
|
172
|
+
result: result,
|
|
173
|
+
success: result.success?,
|
|
174
|
+
duration_ms: result.duration_ms)
|
|
175
|
+
|
|
176
|
+
# Record the tool call for the final result
|
|
177
|
+
@tool_calls_made << {
|
|
178
|
+
tool_name: tc.name,
|
|
179
|
+
arguments: tc.arguments,
|
|
180
|
+
result: result.to_h
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Add tool result to conversation as a tool-role message
|
|
184
|
+
result_content = result.success? ? JSON.generate(result.value) : "Error: #{result.error}"
|
|
185
|
+
@state.add_message(
|
|
186
|
+
role: :tool,
|
|
187
|
+
content: result_content,
|
|
188
|
+
tool_call_id: tc.id,
|
|
189
|
+
name: tc.name
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Builds the messages array for the LLM, prepending the system prompt
|
|
195
|
+
# as the first message.
|
|
196
|
+
#
|
|
197
|
+
# @return [Array<Hash>] messages formatted for the LLM provider
|
|
198
|
+
def build_llm_messages
|
|
199
|
+
system_message = { role: :system, content: @state.system_prompt }
|
|
200
|
+
[system_message] + @state.messages
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Converts the tool registry into an array of tool definition hashes
|
|
204
|
+
# suitable for the LLM call. Returns an empty array if no tools are
|
|
205
|
+
# registered.
|
|
206
|
+
#
|
|
207
|
+
# @return [Array<Hash>] tool definitions
|
|
208
|
+
def build_tools_array
|
|
209
|
+
return [] unless @state.tools
|
|
210
|
+
|
|
211
|
+
@state.tools.all
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Accumulates token usage from a single LLM response into the running
|
|
215
|
+
# total.
|
|
216
|
+
#
|
|
217
|
+
# @param usage [Hash] usage from one response
|
|
218
|
+
# @return [void]
|
|
219
|
+
def accumulate_usage(usage)
|
|
220
|
+
return unless usage.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
@total_usage[:input_tokens] += (usage[:prompt_tokens] || usage[:input_tokens] || 0)
|
|
223
|
+
@total_usage[:output_tokens] += (usage[:completion_tokens] || usage[:output_tokens] || 0)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Triggers context compaction if a compaction strategy is configured
|
|
227
|
+
# and the estimated token count exceeds the threshold.
|
|
228
|
+
#
|
|
229
|
+
# @return [void]
|
|
230
|
+
def compact_if_needed!
|
|
231
|
+
return unless @compaction
|
|
232
|
+
|
|
233
|
+
compacted = @compaction.compact(@state.messages, @state.system_prompt)
|
|
234
|
+
return unless compacted # nil means no compaction was needed
|
|
235
|
+
|
|
236
|
+
@state.messages = compacted
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Extracts the last assistant message content from the conversation
|
|
240
|
+
# history. Used as the final content when max iterations are reached.
|
|
241
|
+
#
|
|
242
|
+
# @return [String, nil] the last assistant content or nil
|
|
243
|
+
def last_assistant_content
|
|
244
|
+
@state.messages
|
|
245
|
+
.select { |m| m[:role] == :assistant }
|
|
246
|
+
.last
|
|
247
|
+
&.dig(:content)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Constructs the final Agent::Result from the current state.
|
|
251
|
+
#
|
|
252
|
+
# @param content [String, nil] the final text content
|
|
253
|
+
# @return [RubyPi::Agent::Result]
|
|
254
|
+
def build_result(content:)
|
|
255
|
+
Result.new(
|
|
256
|
+
content: content,
|
|
257
|
+
messages: @state.messages,
|
|
258
|
+
tool_calls_made: @tool_calls_made,
|
|
259
|
+
usage: @total_usage,
|
|
260
|
+
turns: @state.iteration
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/agent/result.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Agent::Result — Immutable value object encapsulating the outcome of
|
|
6
|
+
# an agent run. Contains the final text content, full conversation history,
|
|
7
|
+
# tool calls that were executed, token usage, turn count, and any error that
|
|
8
|
+
# occurred. The agent loop returns a Result when it completes (either by
|
|
9
|
+
# receiving a stop signal from the LLM or hitting the max iteration limit).
|
|
10
|
+
|
|
11
|
+
module RubyPi
|
|
12
|
+
module Agent
|
|
13
|
+
# Value object returned by Agent::Core#run and Agent::Core#continue.
|
|
14
|
+
# Captures everything about the completed agent interaction in a single,
|
|
15
|
+
# inspectable object.
|
|
16
|
+
#
|
|
17
|
+
# @example Inspecting an agent result
|
|
18
|
+
# result = agent.run("Hello")
|
|
19
|
+
# if result.success?
|
|
20
|
+
# puts result.content
|
|
21
|
+
# puts "Used #{result.turns} turns"
|
|
22
|
+
# result.tool_calls_made.each { |tc| puts " Called: #{tc[:tool_name]}" }
|
|
23
|
+
# else
|
|
24
|
+
# puts "Error: #{result.error.message}"
|
|
25
|
+
# end
|
|
26
|
+
class Result
|
|
27
|
+
# @return [String, nil] the final text content from the assistant
|
|
28
|
+
attr_reader :content
|
|
29
|
+
|
|
30
|
+
# @return [Array<Hash>] the full conversation history (all messages)
|
|
31
|
+
attr_reader :messages
|
|
32
|
+
|
|
33
|
+
# @return [Array<Hash>] tool calls that were executed, each with
|
|
34
|
+
# :tool_name, :arguments, and :result keys
|
|
35
|
+
attr_reader :tool_calls_made
|
|
36
|
+
|
|
37
|
+
# @return [Hash] aggregate token usage with :input_tokens and
|
|
38
|
+
# :output_tokens keys
|
|
39
|
+
attr_reader :usage
|
|
40
|
+
|
|
41
|
+
# @return [Integer] the number of think-act-observe cycles completed
|
|
42
|
+
attr_reader :turns
|
|
43
|
+
|
|
44
|
+
# @return [RubyPi::Error, StandardError, nil] the error if the run failed
|
|
45
|
+
attr_reader :error
|
|
46
|
+
|
|
47
|
+
# Creates a new Result instance.
|
|
48
|
+
#
|
|
49
|
+
# @param content [String, nil] the final assistant text
|
|
50
|
+
# @param messages [Array<Hash>] full conversation history
|
|
51
|
+
# @param tool_calls_made [Array<Hash>] executed tool call records
|
|
52
|
+
# @param usage [Hash] token usage statistics
|
|
53
|
+
# @param turns [Integer] number of completed cycles
|
|
54
|
+
# @param error [Exception, nil] error if the run failed
|
|
55
|
+
def initialize(content: nil, messages: [], tool_calls_made: [], usage: {}, turns: 0, error: nil)
|
|
56
|
+
@content = content
|
|
57
|
+
@messages = Array(messages).freeze
|
|
58
|
+
@tool_calls_made = Array(tool_calls_made).freeze
|
|
59
|
+
@usage = usage
|
|
60
|
+
@turns = turns
|
|
61
|
+
@error = error
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true if the agent run completed without error.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean] true unless an error is present
|
|
67
|
+
def success?
|
|
68
|
+
@error.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns a hash representation of the result for serialization.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
content: @content,
|
|
77
|
+
messages: @messages,
|
|
78
|
+
tool_calls_made: @tool_calls_made,
|
|
79
|
+
usage: @usage,
|
|
80
|
+
turns: @turns,
|
|
81
|
+
error: @error&.message,
|
|
82
|
+
success: success?
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns a human-readable string representation of the result.
|
|
87
|
+
#
|
|
88
|
+
# @return [String]
|
|
89
|
+
def to_s
|
|
90
|
+
status = success? ? "success" : "error"
|
|
91
|
+
parts = ["status=#{status}", "turns=#{@turns}"]
|
|
92
|
+
parts << "tools=#{@tool_calls_made.size}" unless @tool_calls_made.empty?
|
|
93
|
+
parts << "content=#{@content&.slice(0, 80).inspect}" if @content
|
|
94
|
+
parts << "error=#{@error.class}: #{@error.message}" if @error
|
|
95
|
+
"#<RubyPi::Agent::Result #{parts.join(', ')}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias_method :inspect, :to_s
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/ruby_pi/agent/state.rb
|
|
4
|
+
#
|
|
5
|
+
# RubyPi::Agent::State — Mutable container for all agent runtime state.
|
|
6
|
+
#
|
|
7
|
+
# State holds the conversation history, system prompt, model reference, tool
|
|
8
|
+
# registry, iteration counter, lifecycle hooks (transform_context, before_tool_call,
|
|
9
|
+
# after_tool_call), and an arbitrary user_data hash for extension-provided context.
|
|
10
|
+
# The agent loop reads and mutates State as it progresses through think-act-observe
|
|
11
|
+
# cycles.
|
|
12
|
+
|
|
13
|
+
module RubyPi
|
|
14
|
+
module Agent
|
|
15
|
+
# Mutable state object threaded through the agent loop. Encapsulates the
|
|
16
|
+
# full conversation history, configuration, and hook callables so that
|
|
17
|
+
# the loop, compaction, and transforms all operate on a single shared
|
|
18
|
+
# object.
|
|
19
|
+
#
|
|
20
|
+
# @example Creating and using state
|
|
21
|
+
# state = RubyPi::Agent::State.new(
|
|
22
|
+
# system_prompt: "You are helpful.",
|
|
23
|
+
# model: RubyPi::LLM.model(:gemini, "gemini-2.0-flash"),
|
|
24
|
+
# tools: registry,
|
|
25
|
+
# max_iterations: 10
|
|
26
|
+
# )
|
|
27
|
+
# state.add_message(role: :user, content: "Hi!")
|
|
28
|
+
# state.messages # => [{ role: :user, content: "Hi!" }]
|
|
29
|
+
class State
|
|
30
|
+
# @return [String] the system prompt prepended to every LLM call
|
|
31
|
+
attr_accessor :system_prompt
|
|
32
|
+
|
|
33
|
+
# @return [RubyPi::LLM::BaseProvider] the LLM provider instance
|
|
34
|
+
attr_reader :model
|
|
35
|
+
|
|
36
|
+
# @return [RubyPi::Tools::Registry] the registry of available tools
|
|
37
|
+
attr_reader :tools
|
|
38
|
+
|
|
39
|
+
# @return [Integer] maximum think-act-observe iterations before halting
|
|
40
|
+
attr_reader :max_iterations
|
|
41
|
+
|
|
42
|
+
# @return [Proc, nil] callable invoked with state before each LLM call
|
|
43
|
+
# to transform context (system prompt, messages)
|
|
44
|
+
attr_accessor :transform_context
|
|
45
|
+
|
|
46
|
+
# @return [Proc, nil] callable invoked before each tool call; receives
|
|
47
|
+
# the RubyPi::LLM::ToolCall
|
|
48
|
+
attr_accessor :before_tool_call
|
|
49
|
+
|
|
50
|
+
# @return [Proc, nil] callable invoked after each tool call; receives
|
|
51
|
+
# the ToolCall and the RubyPi::Tools::Result
|
|
52
|
+
attr_accessor :after_tool_call
|
|
53
|
+
|
|
54
|
+
# @return [Hash] arbitrary user-provided data accessible by transforms
|
|
55
|
+
# and extensions
|
|
56
|
+
attr_accessor :user_data
|
|
57
|
+
|
|
58
|
+
# Creates a new State instance with the given configuration.
|
|
59
|
+
#
|
|
60
|
+
# @param system_prompt [String] the system-level instruction prompt
|
|
61
|
+
# @param model [RubyPi::LLM::BaseProvider] the LLM provider to use
|
|
62
|
+
# @param tools [RubyPi::Tools::Registry, nil] tool registry (nil for no tools)
|
|
63
|
+
# @param messages [Array<Hash>] initial conversation history
|
|
64
|
+
# @param max_iterations [Integer] max think-act-observe cycles (default: 10)
|
|
65
|
+
# @param transform_context [Proc, nil] context transform hook
|
|
66
|
+
# @param before_tool_call [Proc, nil] pre-tool-execution hook
|
|
67
|
+
# @param after_tool_call [Proc, nil] post-tool-execution hook
|
|
68
|
+
# @param user_data [Hash] arbitrary data bag for extensions/transforms
|
|
69
|
+
def initialize(
|
|
70
|
+
system_prompt:,
|
|
71
|
+
model:,
|
|
72
|
+
tools: nil,
|
|
73
|
+
messages: [],
|
|
74
|
+
max_iterations: 10,
|
|
75
|
+
transform_context: nil,
|
|
76
|
+
before_tool_call: nil,
|
|
77
|
+
after_tool_call: nil,
|
|
78
|
+
user_data: {}
|
|
79
|
+
)
|
|
80
|
+
@system_prompt = system_prompt
|
|
81
|
+
@model = model
|
|
82
|
+
@tools = tools
|
|
83
|
+
@messages = Array(messages).dup
|
|
84
|
+
@max_iterations = max_iterations
|
|
85
|
+
@transform_context = transform_context
|
|
86
|
+
@before_tool_call = before_tool_call
|
|
87
|
+
@after_tool_call = after_tool_call
|
|
88
|
+
@user_data = user_data
|
|
89
|
+
@iteration = 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Appends a message to the conversation history.
|
|
93
|
+
#
|
|
94
|
+
# @param role [Symbol, String] the message role (:user, :assistant, :system, :tool)
|
|
95
|
+
# @param content [String, nil] the text content of the message
|
|
96
|
+
# @param options [Hash] additional fields (e.g., :tool_call_id, :tool_calls)
|
|
97
|
+
# @return [Array<Hash>] the updated messages array
|
|
98
|
+
def add_message(role:, content: nil, **options)
|
|
99
|
+
message = { role: role.to_sym, content: content }.merge(options)
|
|
100
|
+
@messages << message
|
|
101
|
+
@messages
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns a frozen copy of the conversation history. Callers cannot
|
|
105
|
+
# accidentally mutate the internal array through this reference.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<Hash>] the full conversation history
|
|
108
|
+
def messages
|
|
109
|
+
@messages.dup.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Replaces the entire conversation history. Used by compaction to swap
|
|
113
|
+
# in a shortened message array.
|
|
114
|
+
#
|
|
115
|
+
# @param new_messages [Array<Hash>] the replacement message array
|
|
116
|
+
# @return [Array<Hash>] the new messages array
|
|
117
|
+
def messages=(new_messages)
|
|
118
|
+
@messages = Array(new_messages).dup
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns the current iteration count (number of completed think-act-observe
|
|
122
|
+
# cycles).
|
|
123
|
+
#
|
|
124
|
+
# @return [Integer] the iteration count
|
|
125
|
+
def iteration
|
|
126
|
+
@iteration
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Increments the iteration counter by one. Called by the agent loop at
|
|
130
|
+
# the end of each think-act-observe cycle.
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] the new iteration count
|
|
133
|
+
def increment_iteration!
|
|
134
|
+
@iteration += 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns true if the iteration count has reached or exceeded max_iterations.
|
|
138
|
+
#
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def max_iterations_reached?
|
|
141
|
+
@iteration >= @max_iterations
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Provides a human-readable summary of the current state for debugging.
|
|
145
|
+
#
|
|
146
|
+
# @return [String]
|
|
147
|
+
def inspect
|
|
148
|
+
"#<RubyPi::Agent::State " \
|
|
149
|
+
"iteration=#{@iteration}/#{@max_iterations} " \
|
|
150
|
+
"messages=#{@messages.size} " \
|
|
151
|
+
"tools=#{@tools&.size || 0}>"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|