From e666ea4d66a4ec0fae3c1cc8c4909e229eb8ca1e Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Thu, 13 Nov 2025 08:52:02 -0500 Subject: [PATCH 1/6] Initial module --- examples/example1.rb | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 examples/example1.rb diff --git a/examples/example1.rb b/examples/example1.rb new file mode 100644 index 0000000..9db7546 --- /dev/null +++ b/examples/example1.rb @@ -0,0 +1,85 @@ +require "dotenv/load" +require "reline" + +# Before running: +# start the hbt server: +# bundle exec hbt start --no-headless --be-human --single-session --session-id=amazon +# claude mcp add --transport http headless-browser http://localhost:4567/mcp +# claude --dangerously-skip-permissions + +# Load local development version instead of installed gem +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "ruby_agent" + +class MyAgent < RubyAgent::Agent + on_event :my_handler + + def my_handler(event) + puts "Event triggered" + puts "Received event: #{event.dig('message', 'id')}" + puts "Received event type: #{event['type']}" + end + + # Or using a block: + # + # on_event do |event| + # puts "Block event triggered" + # puts "Received event in block: #{event.dig("message", "id")}" + # 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..." + puts agent.ask(user_message) + end +rescue Interrupt + puts "\nExiting..." +ensure + agent&.close +end From 181177ab4d935adf13136a6581646a6b9a00542d Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Thu, 13 Nov 2025 12:05:12 -0500 Subject: [PATCH 2/6] Add response class --- Gemfile | 2 + README.md | 4 + examples/example1.rb | 9 +- lib/ruby_agent.rb | 432 +---------------------------- lib/ruby_agent/agent.rb | 276 ++++++++++++++++++ lib/ruby_agent/callback_support.rb | 32 +++ lib/ruby_agent/configuration.rb | 12 + lib/ruby_agent/event.rb | 10 + lib/ruby_agent/response.rb | 28 ++ lib/ruby_agent/version.rb | 4 +- 10 files changed, 384 insertions(+), 425 deletions(-) create mode 100644 lib/ruby_agent/agent.rb create mode 100644 lib/ruby_agent/callback_support.rb create mode 100644 lib/ruby_agent/configuration.rb create mode 100644 lib/ruby_agent/event.rb create mode 100644 lib/ruby_agent/response.rb diff --git a/Gemfile b/Gemfile index 10ffbb7..1a9d4d8 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,8 @@ gem "minitest", "~> 5.0" gem "minitest-reporters", "~> 1.6" gem "rake", "~> 13.0" +gem "headless_browser_tool", git: "https://github.com/krschacht/headless-browser-tool.git" + group :development do gem "bundler-audit", "~> 0.9", require: false gem "rubocop", "~> 1.50", require: false 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 index 9db7546..1cffb74 100644 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -1,12 +1,13 @@ require "dotenv/load" require "reline" -# Before running: -# start the hbt server: -# bundle exec hbt start --no-headless --be-human --single-session --session-id=amazon +# Register the mcp server # claude mcp add --transport http headless-browser http://localhost:4567/mcp # claude --dangerously-skip-permissions +# 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" @@ -16,8 +17,8 @@ class MyAgent < RubyAgent::Agent def my_handler(event) puts "Event triggered" - puts "Received event: #{event.dig('message', 'id')}" puts "Received event type: #{event['type']}" + puts "Received event: #{event.dig('message', 'id')}" end # Or using a block: 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..6a44107 --- /dev/null +++ b/lib/ruby_agent/agent.rb @@ -0,0 +1,276 @@ +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 + # + # def my_handler(event) + # text = event.dig("delta", "text") + # # Process the streaming text + # end + # end + # + # class MyAgent < RubyAgent::Agent + # # Using a block + # on_event do |event| + # text = event.dig("delta", "text") + # # Process the streaming text + # end + # end + + 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? + + inject_streaming_response({ + type: "system", + subtype: "prompt", + system_prompt: @system_prompt, + timestamp: Time.now.iso8601(6), + received_at: Time.now.iso8601(6) + }) + 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 + rescue StandardError + raise + 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 + + def inject_streaming_response(event_hash) + stringified_event = event_hash.transform_keys(&:to_s) + all_lines = nil + @parsed_lines_mutex.synchronize do + @parsed_lines << stringified_event + all_lines = @parsed_lines.dup + end + + # TODO: event handling(?) + # trigger_event(stringified_event, all_lines) + # trigger_dynamic_callbacks(stringified_event, all_lines) + # trigger_custom_event_callbacks(stringified_event, all_lines) + 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 + rescue StandardError + raise + end + + 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) + print 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 + + puts + response + end + end +end diff --git a/lib/ruby_agent/callback_support.rb b/lib/ruby_agent/callback_support.rb new file mode 100644 index 0000000..26a3656 --- /dev/null +++ b/lib/ruby_agent/callback_support.rb @@ -0,0 +1,32 @@ +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 + end + + def run_callbacks(event_data) + 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 + 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..571b48b --- /dev/null +++ b/lib/ruby_agent/event.rb @@ -0,0 +1,10 @@ +module RubyAgent + class Event + attr_reader :raw_event + + def initialize(raw_event) + @raw_event = raw_event + end + + end +end \ No newline at end of file diff --git a/lib/ruby_agent/response.rb b/lib/ruby_agent/response.rb new file mode 100644 index 0000000..b360054 --- /dev/null +++ b/lib/ruby_agent/response.rb @@ -0,0 +1,28 @@ +module RubyAgent + class Response + attr_reader :events, :text + + def initialize(text: "", events: []) + @text = text + @events = events + end + + def final + @text + end + + def to_s + @text + end + + def add_event(event) + @events << event + self + end + + def append_text(content) + @text += content + self + end + end +end \ No newline at end of file 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 From 37a26b77105b4257cf14310c669b280072f7b24a Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Thu, 13 Nov 2025 12:50:16 -0500 Subject: [PATCH 3/6] Add dynamic events --- examples/example1.rb | 28 ++++++++++++++++----- lib/ruby_agent/agent.rb | 22 +++++++++++------ lib/ruby_agent/callback_support.rb | 39 ++++++++++++++++++++++++++++++ lib/ruby_agent/response.rb | 4 --- 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/examples/example1.rb b/examples/example1.rb index 1cffb74..4836dc0 100644 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -13,6 +13,7 @@ require "ruby_agent" class MyAgent < RubyAgent::Agent + # 1. General event handler - fires for ALL events on_event :my_handler def my_handler(event) @@ -21,11 +22,24 @@ def my_handler(event) puts "Received event: #{event.dig('message', 'id')}" end - # Or using a block: - # - # on_event do |event| - # puts "Block event triggered" - # puts "Received event in block: #{event.dig("message", "id")}" + # 2. 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 + if event.dig("delta", "text") + print event["delta"]["text"] + end + end + + # 3. Another event-specific handler - fires only for assistant messages + on_event_assistant do |event| + puts "Assistant message received" + end + + # TODO: + # def on_event_result(event) + # puts "\nConversation complete!" # end end @@ -77,7 +91,9 @@ def prompt_for_message end puts "Asking Claude..." - puts agent.ask(user_message) + response = agent.ask(user_message) + puts # Newline after streaming output + puts response.final end rescue Interrupt puts "\nExiting..." diff --git a/lib/ruby_agent/agent.rb b/lib/ruby_agent/agent.rb index 6a44107..21d3b4f 100644 --- a/lib/ruby_agent/agent.rb +++ b/lib/ruby_agent/agent.rb @@ -24,22 +24,30 @@ class ConnectionError < StandardError; end # # class MyAgent < RubyAgent::Agent # # Using a method name - # on_event :my_handler + # on_event :my_handler # Fires for all events # # def my_handler(event) - # text = event.dig("delta", "text") - # # Process the streaming text # end # end # # class MyAgent < RubyAgent::Agent # # Using a block - # on_event do |event| - # text = event.dig("delta", "text") - # # Process the streaming text + # on_event do |event| # Fires for all events + # puts "Event received: #{event['type']}" # end # end + # You can now 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 continue to fire 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 @@ -254,7 +262,6 @@ def read_response if message.dig("delta", "text") text = message["delta"]["text"] response.append_text(text) - print text end when "result" break @@ -269,7 +276,6 @@ def read_response end end - puts response end end diff --git a/lib/ruby_agent/callback_support.rb b/lib/ruby_agent/callback_support.rb index 26a3656..29b52c1 100644 --- a/lib/ruby_agent/callback_support.rb +++ b/lib/ruby_agent/callback_support.rb @@ -18,9 +18,36 @@ def on_event_callbacks 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) @@ -28,5 +55,17 @@ def run_callbacks(event_data) 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/response.rb b/lib/ruby_agent/response.rb index b360054..c8d199b 100644 --- a/lib/ruby_agent/response.rb +++ b/lib/ruby_agent/response.rb @@ -11,10 +11,6 @@ def final @text end - def to_s - @text - end - def add_event(event) @events << event self From dddfb4ab08dffa8c48dca7992c84a13c3a021e59 Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Mon, 17 Nov 2025 12:51:29 -0500 Subject: [PATCH 4/6] Small cleanup --- examples/example1.rb | 14 +++++++------- lib/ruby_agent/agent.rb | 26 +++----------------------- lib/ruby_agent/response.rb | 2 +- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/examples/example1.rb b/examples/example1.rb index 4836dc0..7983ab6 100644 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -22,7 +22,12 @@ def my_handler(event) puts "Received event: #{event.dig('message', 'id')}" end - # 2. Event-specific handler - fires only for content_block_delta events + # 2. Another event-specific handler - fires only for assistant messages + on_event_assistant do |event| + puts "Assistant message received" + end + + # 3. Event-specific handler - fires only for content_block_delta events on_event_content_block_delta :streaming_handler def streaming_handler(event) @@ -32,11 +37,6 @@ def streaming_handler(event) end end - # 3. Another event-specific handler - fires only for assistant messages - on_event_assistant do |event| - puts "Assistant message received" - end - # TODO: # def on_event_result(event) # puts "\nConversation complete!" @@ -93,7 +93,7 @@ def prompt_for_message puts "Asking Claude..." response = agent.ask(user_message) puts # Newline after streaming output - puts response.final + response.final_text end rescue Interrupt puts "\nExiting..." diff --git a/lib/ruby_agent/agent.rb b/lib/ruby_agent/agent.rb index 21d3b4f..b2ca9a4 100644 --- a/lib/ruby_agent/agent.rb +++ b/lib/ruby_agent/agent.rb @@ -37,7 +37,7 @@ class ConnectionError < StandardError; end # end # end - # You can now register event-specific callbacks using the pattern + # You can register event-specific callbacks using the pattern # on_event_: # # on_event_content_block_delta :streaming_handler @@ -45,7 +45,7 @@ class ConnectionError < StandardError; end # on_event_assistant :assistant_handler # Each callback fires only for its specific event type, while on_event - # callbacks continue to fire for all events. This follows the Single + # 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) @@ -62,13 +62,7 @@ def initialize(name: "MyName", system_prompt: nil, model: nil, sandbox_dir: nil) return unless @session_key.nil? - inject_streaming_response({ - type: "system", - subtype: "prompt", - system_prompt: @system_prompt, - timestamp: Time.now.iso8601(6), - received_at: Time.now.iso8601(6) - }) + timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S") end def config @@ -132,20 +126,6 @@ def close @wait_thr = nil end - def inject_streaming_response(event_hash) - stringified_event = event_hash.transform_keys(&:to_s) - all_lines = nil - @parsed_lines_mutex.synchronize do - @parsed_lines << stringified_event - all_lines = @parsed_lines.dup - end - - # TODO: event handling(?) - # trigger_event(stringified_event, all_lines) - # trigger_dynamic_callbacks(stringified_event, all_lines) - # trigger_custom_event_callbacks(stringified_event, all_lines) - end - private def ensure_sandbox_exists diff --git a/lib/ruby_agent/response.rb b/lib/ruby_agent/response.rb index c8d199b..e0ded90 100644 --- a/lib/ruby_agent/response.rb +++ b/lib/ruby_agent/response.rb @@ -7,7 +7,7 @@ def initialize(text: "", events: []) @events = events end - def final + def final_text @text end From dad60b88b2309d950d27ef37a935cb327d01c5ff Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Mon, 17 Nov 2025 13:06:12 -0500 Subject: [PATCH 5/6] Fix example1 --- examples/example1.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/example1.rb b/examples/example1.rb index 7983ab6..b6ec270 100644 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -22,19 +22,19 @@ def my_handler(event) puts "Received event: #{event.dig('message', 'id')}" end - # 2. Another event-specific handler - fires only for assistant messages + # 2. Event-specific handler - fires only for assistant messages on_event_assistant do |event| puts "Assistant message received" end - # 3. Event-specific handler - fires only for content_block_delta events + # 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 - if event.dig("delta", "text") - print event["delta"]["text"] - end + return unless event.dig("delta", "text") + + print event["delta"]["text"] end # TODO: @@ -92,8 +92,8 @@ def prompt_for_message puts "Asking Claude..." response = agent.ask(user_message) - puts # Newline after streaming output - response.final_text + puts "\n\nFinal response:\n\n" + puts response.final_text end rescue Interrupt puts "\nExiting..." From ef9f175ed278efdcd9544e8ff298932443c3a9c4 Mon Sep 17 00:00:00 2001 From: Matt Lindsey Date: Thu, 20 Nov 2025 15:11:12 -0500 Subject: [PATCH 6/6] Add tests and fix lint --- Gemfile | 3 +- examples/example1.rb | 7 +- lib/ruby_agent/agent.rb | 8 +-- lib/ruby_agent/event.rb | 3 +- lib/ruby_agent/response.rb | 2 +- test/callback_support_test.rb | 116 +++++++++++++++++++++++++++++++ test/response_test.rb | 79 +++++++++++++++++++++ test/ruby_agent_test.rb | 125 ++++++++++++++-------------------- 8 files changed, 257 insertions(+), 86 deletions(-) create mode 100644 test/callback_support_test.rb create mode 100644 test/response_test.rb diff --git a/Gemfile b/Gemfile index 1a9d4d8..0836684 100644 --- a/Gemfile +++ b/Gemfile @@ -2,12 +2,11 @@ source "https://rubygems.org" gemspec +gem "dotenv", "~> 2.7" gem "minitest", "~> 5.0" gem "minitest-reporters", "~> 1.6" gem "rake", "~> 13.0" -gem "headless_browser_tool", git: "https://github.com/krschacht/headless-browser-tool.git" - group :development do gem "bundler-audit", "~> 0.9", require: false gem "rubocop", "~> 1.50", require: false diff --git a/examples/example1.rb b/examples/example1.rb index b6ec270..6813cc4 100644 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -5,6 +5,9 @@ # 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 @@ -23,7 +26,7 @@ def my_handler(event) end # 2. Event-specific handler - fires only for assistant messages - on_event_assistant do |event| + on_event_assistant do |_event| puts "Assistant message received" end @@ -37,7 +40,7 @@ def streaming_handler(event) print event["delta"]["text"] end - # TODO: + # TODO: Add this # def on_event_result(event) # puts "\nConversation complete!" # end diff --git a/lib/ruby_agent/agent.rb b/lib/ruby_agent/agent.rb index b2ca9a4..d64da21 100644 --- a/lib/ruby_agent/agent.rb +++ b/lib/ruby_agent/agent.rb @@ -62,7 +62,7 @@ def initialize(name: "MyName", system_prompt: nil, model: nil, sandbox_dir: nil) return unless @session_key.nil? - timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S") + Time.now.utc.strftime("%Y%m%d%H%M%S") end def config @@ -108,8 +108,6 @@ def ask(message) send_message(message) read_response - rescue StandardError - raise end def close @@ -187,10 +185,9 @@ def send_message(content, session_id = nil) @stdin.puts JSON.generate(message_json) @stdin.flush - rescue StandardError - raise end + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength def read_response response = RubyAgent::Response.new @@ -258,5 +255,6 @@ def read_response response end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength end end diff --git a/lib/ruby_agent/event.rb b/lib/ruby_agent/event.rb index 571b48b..cf9a43b 100644 --- a/lib/ruby_agent/event.rb +++ b/lib/ruby_agent/event.rb @@ -5,6 +5,5 @@ class Event def initialize(raw_event) @raw_event = raw_event end - end -end \ No newline at end of file +end diff --git a/lib/ruby_agent/response.rb b/lib/ruby_agent/response.rb index e0ded90..310647b 100644 --- a/lib/ruby_agent/response.rb +++ b/lib/ruby_agent/response.rb @@ -21,4 +21,4 @@ def append_text(content) self end end -end \ No newline at end of file +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