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