diff --git a/Gemfile b/Gemfile index 10ffbb7..0836684 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" gemspec +gem "dotenv", "~> 2.7" gem "minitest", "~> 5.0" gem "minitest-reporters", "~> 1.6" gem "rake", "~> 13.0" diff --git a/README.md b/README.md index b9c1f4a..dc94340 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ rake ci:test # Run test suite rake ci:lint # Run RuboCop linter rake ci:lint:fix # Auto-fix linting issues rake ci:scan # Run security audit + +# To run manual examples build locally: +rake build +rake install ``` 5. Commit your changes: `git commit -am 'Add some feature'` diff --git a/examples/example1.rb b/examples/example1.rb new file mode 100644 index 0000000..6813cc4 --- /dev/null +++ b/examples/example1.rb @@ -0,0 +1,105 @@ +require "dotenv/load" +require "reline" + +# Register the mcp server +# claude mcp add --transport http headless-browser http://localhost:4567/mcp +# claude --dangerously-skip-permissions + +# Install the headless_browser_tool gem +# gem install headless_browser_tool --source https://github.com/krschacht/headless-browser-tool.git + +# Before running start the hbt server in a separate terminal: +# bundle exec hbt start --no-headless --be-human --single-session --session-id=amazon + +# Load local development version instead of installed gem +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "ruby_agent" + +class MyAgent < RubyAgent::Agent + # 1. General event handler - fires for ALL events + on_event :my_handler + + def my_handler(event) + puts "Event triggered" + puts "Received event type: #{event['type']}" + puts "Received event: #{event.dig('message', 'id')}" + end + + # 2. Event-specific handler - fires only for assistant messages + on_event_assistant do |_event| + puts "Assistant message received" + end + + # 3. Another event-specific handler - fires only for content_block_delta events + on_event_content_block_delta :streaming_handler + + def streaming_handler(event) + # Handle streaming text output for content_block_delta events only + return unless event.dig("delta", "text") + + print event["delta"]["text"] + end + + # TODO: Add this + # def on_event_result(event) + # puts "\nConversation complete!" + # end +end + +DONE = %w[done end eof exit].freeze + +def prompt_for_message + puts "\n(multiline input; type 'end' on its own line when done. or 'exit' to exit)\n\n" + + user_message = Reline.readmultiline("User message: ", true) do |multiline_input| + last = multiline_input.split.last + DONE.include?(last) + end + + return :noop unless user_message + + lines = user_message.split("\n") + + if lines.size > 1 && DONE.include?(lines.last) + # remove the "done" from the message + user_message = lines[0..-2].join("\n") + end + + return :exit if DONE.include?(user_message.downcase) + + user_message +end + +begin + RubyAgent.configure do |config| + config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil) # Not strictly necessary with claude installed + config.system_prompt = "You are a helpful AI news assistant." + config.model = "claude-sonnet-4-5-20250929" + config.sandbox_dir = "./news_sandbox" + end + + agent = MyAgent.new(name: "News-Agent").connect(mcp_servers: { headless_browser: { type: :http, + url: "http://0.0.0.0:4567/mcp" } }) + + puts "Welcome to your Claude assistant!" + + loop do + user_message = prompt_for_message + + case user_message + when :noop + next + when :exit + break + end + + puts "Asking Claude..." + response = agent.ask(user_message) + puts "\n\nFinal response:\n\n" + puts response.final_text + end +rescue Interrupt + puts "\nExiting..." +ensure + agent&.close +end diff --git a/lib/ruby_agent.rb b/lib/ruby_agent.rb index cdab81c..8e9b2a4 100644 --- a/lib/ruby_agent.rb +++ b/lib/ruby_agent.rb @@ -1,428 +1,22 @@ -require_relative "ruby_agent/version" +require "dotenv/load" require "shellwords" require "open3" -require "erb" require "json" +require "fileutils" +require "securerandom" -class RubyAgent - class AgentError < StandardError; end - class ConnectionError < AgentError; end - class ParseError < AgentError; end - - DEBUG = false - - attr_reader :sandbox_dir, :timezone, :skip_permissions, :verbose, :system_prompt, :model, :mcp_servers - - def initialize( - sandbox_dir: Dir.pwd, - timezone: "UTC", - skip_permissions: true, - verbose: false, - system_prompt: "You are a helpful assistant", - model: "claude-sonnet-4-5-20250929", - mcp_servers: nil, - session_key: nil, - **additional_context - ) - @sandbox_dir = sandbox_dir - @timezone = timezone - @skip_permissions = skip_permissions - @verbose = verbose - @model = model - @mcp_servers = mcp_servers - @session_key = session_key - @system_prompt = parse_system_prompt(system_prompt, additional_context) - @on_message_callback = nil - @on_error_callback = nil - @dynamic_callbacks = {} - @custom_message_callbacks = {} - @stdin = nil - @stdout = nil - @stderr = nil - @wait_thr = nil - @parsed_lines = [] - @parsed_lines_mutex = Mutex.new - @pending_ask_after_interrupt = nil - @pending_interrupt_request_id = nil - @deferred_exit = false - - return if @session_key - - inject_streaming_response({ - type: "system", - subtype: "prompt", - system_prompt: @system_prompt, - timestamp: Time.now.utc.iso8601(6), - received_at: Time.now.utc.iso8601(6) - }) - end - - def create_message_callback(name, &processor) - @custom_message_callbacks[name.to_s] = { - processor: processor, - callback: nil - } - end - - def on_message(&block) - @on_message_callback = block - end - - alias on_event on_message - - def on_error(&block) - @on_error_callback = block - end - - def method_missing(method_name, *args, &block) - if method_name.to_s.start_with?("on_") && block_given? - callback_name = method_name.to_s.sub(/^on_/, "") - - if @custom_message_callbacks.key?(callback_name) - @custom_message_callbacks[callback_name][:callback] = block - else - @dynamic_callbacks[callback_name] = block - end - else - super - end - end - - def respond_to_missing?(method_name, include_private = false) - method_name.to_s.start_with?("on_") || super - end - - def connect(&block) - command = build_claude_command - - spawn_process(command, @sandbox_dir) do |stdin, stdout, stderr, wait_thr| - @stdin = stdin - @stdout = stdout - @stderr = stderr - @wait_thr = wait_thr - - begin - block.call if block_given? - receive_streaming_responses - ensure - @stdin = nil - @stdout = nil - @stderr = nil - @wait_thr = nil - end - end - rescue StandardError => e - trigger_error(e) - raise - end - - def ask(text, sender_name: "User", additional: []) - formatted_text = if sender_name.downcase == "system" - <<~TEXT.strip - - #{text} - - TEXT - else - "#{sender_name}: #{text}" - end - formatted_text += extra_context(additional, sender_name:) - - inject_streaming_response({ - type: "user", - subtype: "new_message", - sender_name:, - text:, - formatted_text:, - timestamp: Time.now.utc.iso8601(6) - }) - - send_message(formatted_text) - end - - def ask_after_interrupt(text, sender_name: "User", additional: []) - @pending_ask_after_interrupt = { text:, sender_name:, additional: } - end - - def send_system_message(text) - ask(text, sender_name: "system") - end - - def receive_streaming_responses - @stdout.each_line do |line| - next if line.strip.empty? - - begin - json = JSON.parse(line) - - all_lines = nil - @parsed_lines_mutex.synchronize do - @parsed_lines << json - all_lines = @parsed_lines.dup - end - - trigger_message(json, all_lines) - trigger_dynamic_callbacks(json, all_lines) - trigger_custom_message_callbacks(json, all_lines) - rescue JSON::ParserError - warn "Failed to parse line: #{line}" if DEBUG - end - end - - puts "→ stdout closed, waiting for process to exit..." if DEBUG - exit_status = @wait_thr.value - puts "→ Process exited with status: #{exit_status.success? ? 'success' : 'failure'}" if DEBUG - unless exit_status.success? - stderr_output = @stderr.read - raise ConnectionError, "Claude command failed: #{stderr_output}" - end - - @parsed_lines - end - - def inject_streaming_response(event_hash) - stringified_event = stringify_keys(event_hash) - all_lines = nil - @parsed_lines_mutex.synchronize do - @parsed_lines << stringified_event - all_lines = @parsed_lines.dup - end - - trigger_message(stringified_event, all_lines) - trigger_dynamic_callbacks(stringified_event, all_lines) - trigger_custom_message_callbacks(stringified_event, all_lines) - end - - def interrupt - raise ConnectionError, "Not connected to Claude" unless @stdin - raise ConnectionError, "Cannot interrupt - stdin is closed" if @stdin.closed? - - @request_counter ||= 0 - @request_counter += 1 - request_id = "req_#{@request_counter}_#{SecureRandom.hex(4)}" - - @pending_interrupt_request_id = request_id if @pending_ask_after_interrupt - if DEBUG - puts "→ Sending interrupt with request_id: #{request_id}, pending_ask: #{@pending_ask_after_interrupt ? true : false}" - end - - control_request = { - type: "control_request", - request_id: request_id, - request: { - subtype: "interrupt" - } - } - - inject_streaming_response({ - type: "control", - subtype: "interrupt", - timestamp: Time.now.utc.iso8601(6) - }) - - @stdin.puts JSON.generate(control_request) - @stdin.flush - rescue StandardError => e - warn "Failed to send interrupt signal: #{e.message}" - raise - end - - def exit - return unless @stdin - - if @pending_interrupt_request_id - puts "→ Deferring exit - waiting for interrupt response (request_id: #{@pending_interrupt_request_id})" if DEBUG - @deferred_exit = true - return - end - - puts "→ Exiting Claude (closing stdin)" if DEBUG - - begin - @stdin.close unless @stdin.closed? - puts "→ stdin closed" if DEBUG - rescue StandardError => e - warn "Error closing stdin during exit: #{e.message}" - end - end - - private - - def spawn_process(command, sandbox_dir, &) - Open3.popen3("bash", "-lc", command, chdir: sandbox_dir, &) - end - - def build_claude_command - cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json --verbose" - cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}" - cmd += " --model #{Shellwords.escape(@model)}" - - if @mcp_servers - mcp_config = build_mcp_config(@mcp_servers) - cmd += " --mcp-config #{Shellwords.escape(mcp_config.to_json)}" - end - - cmd += " --setting-sources \"\"" - cmd += " --resume #{Shellwords.escape(@session_key)}" if @session_key - cmd - end - - def build_mcp_config(mcp_servers) - servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") } - { mcpServers: servers } - end - - def parse_system_prompt(template_content, context_vars) - if Dir.exist?(@sandbox_dir) - Dir.chdir(@sandbox_dir) do - parse_system_prompt_in_context(template_content, context_vars) - end - else - parse_system_prompt_in_context(template_content, context_vars) - end - end - - def parse_system_prompt_in_context(template_content, context_vars) - erb = ERB.new(template_content) - binding_context = create_binding_context(**context_vars) - result = erb.result(binding_context) - - raise ParseError, "There was an error parsing the system prompt." if result.include?("<%=") || result.include?("%>") - - result - end - - def create_binding_context(**vars) - context = Object.new - vars.each do |key, value| - context.instance_variable_set("@#{key}", value) - context.define_singleton_method(key) { instance_variable_get("@#{key}") } - end - context.instance_eval { binding } - end - - def extra_context(additional = [], sender_name:) - raise "additional is not an array" unless additional.is_a?(Array) - - return "" if additional.empty? - - <<~CONTEXT - - - #{additional.join("\n\n")} - - CONTEXT - end - - def send_message(content, session_id = nil) - raise ConnectionError, "Not connected to Claude" unless @stdin - - message_json = { - type: "user", - message: { role: "user", content: content }, - session_id: session_id - }.compact - - @stdin.puts JSON.generate(message_json) - @stdin.flush - rescue StandardError => e - trigger_error(e) - raise - end - - def trigger_message(message, all_messages) - @on_message_callback&.call(message, all_messages) - end - - def trigger_dynamic_callbacks(message, all_messages) - type = message["type"] - subtype = message["subtype"] - - return unless type - - if type == "control_response" - puts "→ Received control_response: #{message.inspect}" if DEBUG || @pending_interrupt_request_id - if @pending_interrupt_request_id - response = message["response"] - if response&.dig("subtype") == "success" && response&.dig("request_id") == @pending_interrupt_request_id - puts "→ Interrupt confirmed, executing queued ask" if DEBUG - @pending_interrupt_request_id = nil - if @pending_ask_after_interrupt - pending = @pending_ask_after_interrupt - @pending_ask_after_interrupt = nil - begin - ask(pending[:text], sender_name: pending[:sender_name], additional: pending[:additional]) - rescue IOError, Errno::EPIPE => e - warn "Failed to send queued ask after interrupt (stream closed): #{e.message}" - end - end - - if @deferred_exit - puts "→ Executing deferred exit" if DEBUG - @deferred_exit = false - exit - end - elsif DEBUG - puts "→ Control response didn't match pending interrupt: #{response.inspect}" - end - end - end - - if subtype - specific_callback_key = "#{type}_#{subtype}" - specific_callback = @dynamic_callbacks[specific_callback_key] - if specific_callback - puts "→ Triggering callback for: #{specific_callback_key}" if DEBUG - specific_callback.call(message, all_messages) - end - end - - general_callback = @dynamic_callbacks[type] - if general_callback - puts "→ Triggering callback for: #{type}" if DEBUG - general_callback.call(message, all_messages) - end - - check_nested_content_types(message, all_messages) - end - - def check_nested_content_types(message, all_messages) - return unless message["message"].is_a?(Hash) - - content = message.dig("message", "content") - return unless content.is_a?(Array) - - content.each do |content_item| - next unless content_item.is_a?(Hash) - - nested_type = content_item["type"] - next unless nested_type - - callback = @dynamic_callbacks[nested_type] - if callback - puts "→ Triggering callback for nested type: #{nested_type}" if DEBUG - callback.call(message, all_messages) - end - end - end - - def trigger_custom_message_callbacks(message, all_messages) - @custom_message_callbacks.each_value do |config| - processor = config[:processor] - callback = config[:callback] - - next unless processor && callback - - result = processor.call(message, all_messages) - callback.call(result) if result && !result.to_s.empty? - end - end +require_relative "ruby_agent/version" +require_relative "ruby_agent/configuration" +require_relative "ruby_agent/agent" +require_relative "ruby_agent/callback_support" - def trigger_error(error) - @on_error_callback&.call(error) +module RubyAgent + class << self + attr_accessor :configuration end - def stringify_keys(hash) - hash.transform_keys(&:to_s) + def self.configure + self.configuration ||= Configuration.new + yield(configuration) end end diff --git a/lib/ruby_agent/agent.rb b/lib/ruby_agent/agent.rb new file mode 100644 index 0000000..d64da21 --- /dev/null +++ b/lib/ruby_agent/agent.rb @@ -0,0 +1,260 @@ +require_relative "callback_support" +require_relative "response" + +module RubyAgent + class Agent + include CallbackSupport + + class ConnectionError < StandardError; end + + attr_reader :name, :sandbox_dir, :timezone, :skip_permissions, :verbose, + :system_prompt, :mcp_servers, :model, :session_key, + :context, :conversation_history + + # Configure parameters for the Agent(s) like this or when initializing: + # + # RubyAgent.configure do |config| + # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] # Not strictly necessary with Claude SDK + # config.system_prompt = "You are a helpful AI human resources assistant." + # config.model = "claude-sonnet-4-5-20250929" + # config.sandbox_dir = "./hr_sandbox" + # end + + # Users can register callbacks in two ways: + # + # class MyAgent < RubyAgent::Agent + # # Using a method name + # on_event :my_handler # Fires for all events + # + # def my_handler(event) + # end + # end + # + # class MyAgent < RubyAgent::Agent + # # Using a block + # on_event do |event| # Fires for all events + # puts "Event received: #{event['type']}" + # end + # end + + # You can register event-specific callbacks using the pattern + # on_event_: + # + # on_event_content_block_delta :streaming_handler + # on_event_result :completion_handler + # on_event_assistant :assistant_handler + + # Each callback fires only for its specific event type, while on_event + # callbacks fires for all events. This follows the Single + # Responsibility Principle and makes the code more maintainable. + + def initialize(name: "MyName", system_prompt: nil, model: nil, sandbox_dir: nil) + @name = name + @system_prompt = system_prompt || config.system_prompt + @model = model || config.model + @sandbox_dir = sandbox_dir || config.sandbox_dir + @stdin = nil + @stdout = nil + @stderr = nil + @wait_thr = nil + @parsed_lines = [] + @parsed_lines_mutex = Mutex.new + + return unless @session_key.nil? + + Time.now.utc.strftime("%Y%m%d%H%M%S") + end + + def config + RubyAgent.configuration ||= RubyAgent::Configuration.new + end + + def connect( + timezone: "Eastern Time (US & Canada)", + skip_permissions: true, + verbose: true, + mcp_servers: nil, + session_key: nil, + resume_session: false, + **additional_context + ) + @timezone = timezone + @skip_permissions = skip_permissions + @verbose = verbose + @mcp_servers = mcp_servers + @session_key = session_key + @resume_session = resume_session + @context = additional_context + @conversation_history = [] + + ensure_sandbox_exists + + command = build_claude_command + + @stdin, @stdout, @stderr, @wait_thr = spawn_process(command, @sandbox_dir) + + sleep 0.5 + unless @wait_thr.alive? + error_output = @stderr.read + raise ConnectionError, "Claude process failed to start. Error: #{error_output}" + end + + puts "Claude process started successfully (PID: #{@wait_thr.pid})" + self + end + + def ask(message) + return if message.nil? || message.strip.empty? + + send_message(message) + read_response + end + + def close + return unless @stdin + + @stdin.close unless @stdin.closed? + @stdout.close unless @stdout.closed? + @stderr.close unless @stderr.closed? + @wait_thr&.join + ensure + @stdin = nil + @stdout = nil + @stderr = nil + @wait_thr = nil + end + + private + + def ensure_sandbox_exists + return if File.directory?(@sandbox_dir) + + puts "Creating sandbox directory: #{@sandbox_dir}" + FileUtils.mkdir_p(@sandbox_dir) + end + + def build_claude_command + puts "Building Claude command..." + + cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json" + cmd += " --verbose" if @verbose + cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}" + cmd += " --model #{Shellwords.escape(@model)}" + + if @mcp_servers + mcp_config_json = build_mcp_config(@mcp_servers).to_json + cmd += " --mcp-config #{Shellwords.escape(mcp_config_json)}" + end + + cmd += ' --setting-sources ""' + cmd += " --resume #{Shellwords.escape(@session_key)}" if @resume_session && @session_key + cmd + end + + def build_mcp_config(mcp_servers) + servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") } + { mcpServers: servers } + end + + def spawn_process(command, sandbox_dir) + puts "Spawning process with command: #{command}" + + command_to_run = if $stdout.tty? && File.exist?("./stream.rb") + "#{command} | tee >(ruby ./stream.rb >/dev/tty)" + else + command + end + + stdin, stdout, stderr, wait_thr = Open3.popen3("bash", "-lc", command_to_run, chdir: sandbox_dir) + [stdin, stdout, stderr, wait_thr] + end + + def send_message(content, session_id = nil) + raise ConnectionError, "Not connected to Claude" unless @stdin + + unless @wait_thr&.alive? + error_output = @stderr&.read || "Unknown error" + raise ConnectionError, "Claude process has died. Error: #{error_output}" + end + + message_json = { + type: "user", + message: { role: "user", content: content }, + session_id: session_id + }.compact + + @stdin.puts JSON.generate(message_json) + @stdin.flush + end + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + def read_response + response = RubyAgent::Response.new + + loop do + unless @wait_thr.alive? + error_output = @stderr.read + raise ConnectionError, "Claude process died while reading response. Error: #{error_output}" + end + + ready = IO.select([@stdout, @stderr], nil, nil, 0.1) + + next unless ready + + if ready[0].include?(@stderr) + error_line = @stderr.gets + warn error_line if error_line + end + + next unless ready[0].include?(@stdout) + + line = @stdout.gets + break unless line + + line = line.strip + next if line.empty? + + begin + message = JSON.parse(line) + response.add_event(message) + + case message["type"] + when "system" + next + when "assistant" + if message.dig("message", "content") + content = message["message"]["content"] + if content.is_a?(Array) + content.each do |block| + if block["type"] == "text" && block["text"] + text = block["text"] + response.append_text(text) + end + end + elsif content.is_a?(String) + response.append_text(content) + end + end + when "content_block_delta" + if message.dig("delta", "text") + text = message["delta"]["text"] + response.append_text(text) + end + when "result" + break + when "error" + puts "[ERROR] #{message['message']}" + break + end + run_callbacks(message) + rescue JSON::ParserError + warn "Failed to parse JSON: #{line[0..100]}" + next + end + end + + response + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + end +end diff --git a/lib/ruby_agent/callback_support.rb b/lib/ruby_agent/callback_support.rb new file mode 100644 index 0000000..29b52c1 --- /dev/null +++ b/lib/ruby_agent/callback_support.rb @@ -0,0 +1,71 @@ +module CallbackSupport + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + def on_event(method_name = nil, &block) + @on_event_callbacks ||= [] + @on_event_callbacks << (method_name || block) + end + + def on_event_callbacks + callbacks = [] + ancestors.each do |ancestor| + if ancestor.instance_variable_defined?(:@on_event_callbacks) + callbacks.concat(ancestor.instance_variable_get(:@on_event_callbacks)) + end + end + callbacks + end + + def method_missing(method_name, *args, &block) + if method_name.to_s.start_with?("on_event_") + event_type = method_name.to_s.sub(/^on_event_/, "") + @specific_event_callbacks ||= {} + @specific_event_callbacks[event_type] ||= [] + @specific_event_callbacks[event_type] << (args.first || block) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.start_with?("on_event_") || super + end + + def specific_event_callbacks(event_type) + callbacks = [] + ancestors.each do |ancestor| + if ancestor.instance_variable_defined?(:@specific_event_callbacks) + specific_callbacks = ancestor.instance_variable_get(:@specific_event_callbacks) + callbacks.concat(specific_callbacks[event_type]) if specific_callbacks[event_type] + end + end + callbacks + end + end + + def run_callbacks(event_data) + # Run general on_event callbacks + self.class.on_event_callbacks.each do |callback| + if callback.is_a?(Proc) + instance_exec(event_data, &callback) + else + send(callback, event_data) + end + end + + # Run event-specific callbacks + event_type = event_data["type"] + return unless event_type + + self.class.specific_event_callbacks(event_type).each do |callback| + if callback.is_a?(Proc) + instance_exec(event_data, &callback) + else + send(callback, event_data) + end + end + end +end diff --git a/lib/ruby_agent/configuration.rb b/lib/ruby_agent/configuration.rb new file mode 100644 index 0000000..1085d94 --- /dev/null +++ b/lib/ruby_agent/configuration.rb @@ -0,0 +1,12 @@ +module RubyAgent + class Configuration + attr_accessor :anthropic_api_key, :system_prompt, :model, :sandbox_dir + + def initialize + @anthropic_api_key = nil # Not necessarily required with Claude SDK + @system_prompt = "You are a helpful AI assistant." + @model = "claude-sonnet-4-5-20250929" + @sandbox_dir = "./sandbox" + end + end +end diff --git a/lib/ruby_agent/event.rb b/lib/ruby_agent/event.rb new file mode 100644 index 0000000..cf9a43b --- /dev/null +++ b/lib/ruby_agent/event.rb @@ -0,0 +1,9 @@ +module RubyAgent + class Event + attr_reader :raw_event + + def initialize(raw_event) + @raw_event = raw_event + end + end +end diff --git a/lib/ruby_agent/response.rb b/lib/ruby_agent/response.rb new file mode 100644 index 0000000..310647b --- /dev/null +++ b/lib/ruby_agent/response.rb @@ -0,0 +1,24 @@ +module RubyAgent + class Response + attr_reader :events, :text + + def initialize(text: "", events: []) + @text = text + @events = events + end + + def final_text + @text + end + + def add_event(event) + @events << event + self + end + + def append_text(content) + @text += content + self + end + end +end diff --git a/lib/ruby_agent/version.rb b/lib/ruby_agent/version.rb index 080916d..a6efcbe 100644 --- a/lib/ruby_agent/version.rb +++ b/lib/ruby_agent/version.rb @@ -1,3 +1,3 @@ -class RubyAgent - VERSION = "0.2.2".freeze +module RubyAgent + VERSION = "0.2.3".freeze end diff --git a/test/callback_support_test.rb b/test/callback_support_test.rb new file mode 100644 index 0000000..61f33c9 --- /dev/null +++ b/test/callback_support_test.rb @@ -0,0 +1,116 @@ +require_relative "test_helper" + +class TestAgentWithCallbacks < RubyAgent::Agent + attr_reader :events_received, :assistant_events, :result_events + + def initialize(*args, **kwargs) + super + @events_received = [] + @assistant_events = [] + @result_events = [] + end + + on_event :handle_all_events + + on_event_assistant :handle_assistant_event + + on_event_result :handle_result_event + + def handle_all_events(event) + @events_received << event + end + + def handle_assistant_event(event) + @assistant_events << event + end + + def handle_result_event(event) + @result_events << event + end +end + +class CallbackSupportTest < Minitest::Test + def test_class_level_on_event_callback_registration + callbacks = TestAgentWithCallbacks.on_event_callbacks + + assert_includes callbacks, :handle_all_events + end + + def test_class_level_specific_event_callback_registration + assistant_callbacks = TestAgentWithCallbacks.specific_event_callbacks("assistant") + result_callbacks = TestAgentWithCallbacks.specific_event_callbacks("result") + + assert_includes assistant_callbacks, :handle_assistant_event + assert_includes result_callbacks, :handle_result_event + end + + def test_run_callbacks_executes_general_and_specific_callbacks + agent = TestAgentWithCallbacks.new + + agent.send(:run_callbacks, { "type" => "assistant", "data" => "test" }) + + # General callback should receive the event + assert_equal 1, agent.events_received.length + assert_equal "assistant", agent.events_received.first["type"] + + # Specific assistant callback should also receive it + assert_equal 1, agent.assistant_events.length + assert_equal "assistant", agent.assistant_events.first["type"] + + # Result callback should not receive it + assert_equal 0, agent.result_events.length + end + + def test_run_callbacks_with_result_event + agent = TestAgentWithCallbacks.new + + agent.send(:run_callbacks, { "type" => "result", "status" => "success" }) + + # General callback should receive the event + assert_equal 1, agent.events_received.length + assert_equal "result", agent.events_received.first["type"] + + # Result callback should receive it + assert_equal 1, agent.result_events.length + assert_equal "result", agent.result_events.first["type"] + + # Assistant callback should not receive it + assert_equal 0, agent.assistant_events.length + end + + def test_on_event_with_block + received_events = [] + + test_class = Class.new(RubyAgent::Agent) do + on_event do |event| + received_events << event + end + + define_method(:get_received_events) { received_events } + end + + agent = test_class.new + agent.send(:run_callbacks, { "type" => "test", "data" => "block test" }) + + assert_equal 1, received_events.length + assert_equal "test", received_events.first["type"] + end + + def test_on_event_with_dynamic_event_type + custom_events = [] + + test_class = Class.new(RubyAgent::Agent) do + on_event_custom do |event| + custom_events << event + end + + define_method(:get_custom_events) { custom_events } + end + + agent = test_class.new + agent.send(:run_callbacks, { "type" => "custom", "message" => "dynamic" }) + + assert_equal 1, custom_events.length + assert_equal "custom", custom_events.first["type"] + end +end diff --git a/test/response_test.rb b/test/response_test.rb new file mode 100644 index 0000000..5c29a2c --- /dev/null +++ b/test/response_test.rb @@ -0,0 +1,79 @@ +require_relative "test_helper" + +class ResponseTest < Minitest::Test + def test_response_initialization_with_defaults + response = RubyAgent::Response.new + + assert_equal "", response.text + assert_equal [], response.events + end + + def test_response_initialization_with_text_and_events + events = [{ "type" => "test" }] + response = RubyAgent::Response.new(text: "Hello", events: events) + + assert_equal "Hello", response.text + assert_equal events, response.events + end + + def test_append_text_concatenates_content + response = RubyAgent::Response.new + + response.append_text("Hello") + assert_equal "Hello", response.text + + response.append_text(" World") + assert_equal "Hello World", response.text + end + + def test_append_text_returns_self_for_chaining + response = RubyAgent::Response.new + + result = response.append_text("test") + assert_same response, result + end + + def test_add_event_adds_to_events_array + response = RubyAgent::Response.new + + event1 = { "type" => "assistant", "data" => "test" } + event2 = { "type" => "result", "status" => "success" } + + response.add_event(event1) + response.add_event(event2) + + assert_equal 2, response.events.length + assert_equal event1, response.events[0] + assert_equal event2, response.events[1] + end + + def test_add_event_returns_self_for_chaining + response = RubyAgent::Response.new + + result = response.add_event({ "type" => "test" }) + assert_same response, result + end + + def test_final_text_returns_accumulated_text + response = RubyAgent::Response.new + + response.append_text("Hello") + response.append_text(" ") + response.append_text("World") + + assert_equal "Hello World", response.final_text + end + + def test_chaining_methods + response = RubyAgent::Response.new + + response + .add_event({ "type" => "start" }) + .append_text("Hello") + .append_text(" World") + .add_event({ "type" => "end" }) + + assert_equal "Hello World", response.text + assert_equal 2, response.events.length + end +end diff --git a/test/ruby_agent_test.rb b/test/ruby_agent_test.rb index cc088d8..999b237 100644 --- a/test/ruby_agent_test.rb +++ b/test/ruby_agent_test.rb @@ -1,110 +1,87 @@ require_relative "test_helper" class RubyAgentTest < Minitest::Test - def test_simple_agent_query - agent = RubyAgent.new( - system_prompt: "You are a helpful assistant. Be very concise.", - model: "claude-sonnet-4-5-20250929", - verbose: false - ) - - assistant_responses = [] - result_received = false - - agent.on_assistant do |event, _all_events| - if event.dig("message", "content", 0, "type") == "text" - text = event.dig("message", "content", 0, "text") - assistant_responses << text - end - end - - agent.on_result do |event, _all_events| - result_received = true - agent.exit if event["subtype"] == "success" - end - - agent.connect do - agent.ask("What is 1+1? Just give me the number.", sender_name: "User") - end - - assert result_received, "Expected to receive a result event" - assert assistant_responses.any? { |r| r.include?("2") }, "Expected assistant to answer '2'" - end - def test_initialization_with_defaults - agent = RubyAgent.new + agent = RubyAgent::Agent.new - assert_equal Dir.pwd, agent.sandbox_dir - assert_equal "UTC", agent.timezone - assert agent.skip_permissions - refute agent.verbose - assert_equal "You are a helpful assistant", agent.system_prompt + assert_equal "MyName", agent.name + assert_equal "You are a helpful AI assistant.", agent.system_prompt assert_equal "claude-sonnet-4-5-20250929", agent.model + assert_equal "./sandbox", agent.sandbox_dir end def test_initialization_with_custom_options - agent = RubyAgent.new( + agent = RubyAgent::Agent.new( + name: "TestAgent", sandbox_dir: "/tmp", - timezone: "America/New_York", - skip_permissions: false, - verbose: true, system_prompt: "Custom prompt", model: "claude-opus-4" ) + assert_equal "TestAgent", agent.name assert_equal "/tmp", agent.sandbox_dir - assert_equal "America/New_York", agent.timezone - refute agent.skip_permissions - assert agent.verbose assert_equal "Custom prompt", agent.system_prompt assert_equal "claude-opus-4", agent.model end - def test_erb_system_prompt_parsing - agent = RubyAgent.new( - system_prompt: "Hello <%= name %>, you are <%= role %>.", - name: "Claude", - role: "assistant" - ) + def test_configuration_integration + RubyAgent.configure do |config| + config.system_prompt = "Configured prompt" + config.model = "claude-opus-4" + config.sandbox_dir = "/tmp/test" + end - assert_equal "Hello Claude, you are assistant.", agent.system_prompt - end + agent = RubyAgent::Agent.new - def test_erb_system_prompt_raises_on_undefined_variable - assert_raises(NameError) do - RubyAgent.new( - system_prompt: "Hello <%= undefined_var %>" - ) - end + assert_equal "Configured prompt", agent.system_prompt + assert_equal "claude-opus-4", agent.model + assert_equal "/tmp/test", agent.sandbox_dir + ensure + # Reset configuration + RubyAgent.configuration = nil end - def test_on_message_callback_registration - agent = RubyAgent.new - - agent.on_message do |message, all_messages| + def test_configuration_can_be_overridden_at_initialization + RubyAgent.configure do |config| + config.system_prompt = "Configured prompt" + config.model = "claude-opus-4" end - assert agent.instance_variable_get(:@on_message_callback), "Expected on_message callback to be registered" + agent = RubyAgent::Agent.new( + system_prompt: "Override prompt", + model: "claude-sonnet-4-5-20250929" + ) + + assert_equal "Override prompt", agent.system_prompt + assert_equal "claude-sonnet-4-5-20250929", agent.model + ensure + # Reset configuration + RubyAgent.configuration = nil end - def test_dynamic_callbacks_via_method_missing - agent = RubyAgent.new + def test_ask_raises_when_not_connected + agent = RubyAgent::Agent.new - agent.on_custom_event do |message, all_messages| + error = assert_raises(RubyAgent::Agent::ConnectionError) do + agent.ask("Hello") end - dynamic_callbacks = agent.instance_variable_get(:@dynamic_callbacks) - assert dynamic_callbacks.key?("custom_event"), "Expected custom_event callback to be registered" + assert_match(/Not connected/, error.message) end - def test_create_message_callback - agent = RubyAgent.new + def test_ask_ignores_nil_or_empty_messages + agent = RubyAgent::Agent.new - agent.create_message_callback :test_callback do |_message, _all_messages| - "processed" - end + # These should return early without raising errors + assert_nil agent.ask(nil) + assert_nil agent.ask("") + assert_nil agent.ask(" ") + end + + def test_close_is_safe_when_not_connected + agent = RubyAgent::Agent.new - custom_callbacks = agent.instance_variable_get(:@custom_message_callbacks) - assert custom_callbacks.key?("test_callback"), "Expected test_callback to be registered" + # Should not raise an error + assert_nil agent.close end end