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.
@@ -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