diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cd92a01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RubyAgent is a Ruby gem framework for building AI agents powered by Claude Code CLI. It provides an event-driven interface to interact with Claude through the Claude Code CLI using stream-json format for bidirectional communication. + +## Development Commands + +### Testing +```bash +# Run all tests +rake test + +# Run specific test +ruby test/ruby_agent_test.rb --name test_simple_agent_query + +# Run full CI suite (linting + tests) +rake ci +``` + +### Linting +```bash +# Check for linting issues +rake ci:lint + +# Auto-fix linting issues +rake ci:lint:fix +``` + +### Security +```bash +# Run security audit +rake ci:scan +``` + +### Build & Install +```bash +# Build gem locally +rake build + +# Install locally built gem +rake install +``` + +### Running Examples +```bash +# Run example (requires local build/install first) +ruby examples/example1.rb +``` + +## Architecture + +### Core Components + +**RubyAgent::Agent** (`lib/ruby_agent/agent.rb`) +- Main agent class that manages Claude Code CLI subprocess communication +- Spawns Claude CLI process using `Open3.popen3` with stream-json I/O format +- Handles bidirectional communication: sends messages via stdin, reads responses via stdout +- Manages connection lifecycle (connect, ask, close) +- Supports session resumption via `session_key` parameter +- Integrates `CallbackSupport` module for event-driven programming model + +**RubyAgent::Configuration** (`lib/ruby_agent/configuration.rb`) +- Global configuration object for default settings +- Accessible via `RubyAgent.configuration` or `RubyAgent.configure` block +- Default model: `claude-sonnet-4-5-20250929` +- Default sandbox: `./sandbox` + +**CallbackSupport** (`lib/ruby_agent/callback_support.rb`) +- Mixin module providing class-level callback registration +- Supports two callback registration styles: + 1. Method name: `on_event :my_handler` + 2. Block: `on_event do |event| ... end` +- Callbacks are inherited through class ancestry chain +- Events are dispatched during message streaming + +### Communication Flow + +1. **Connection**: Agent spawns Claude CLI subprocess with specific flags: + - `--dangerously-skip-permissions`: Skips permission prompts + - `--output-format=stream-json`: JSON streaming output + - `--input-format=stream-json`: JSON streaming input + - `--system-prompt`: Custom system prompt + - `--model`: Specific Claude model + - `--mcp-config`: Optional MCP server configuration + +2. **Message Sending**: JSON messages sent to stdin with structure: + ```ruby + { type: "user", message: { role: "user", content: "..." }, session_id: "..." } + ``` + +3. **Response Reading**: Agent reads streaming JSON events from stdout: + - `type: "system"`: System messages (ignored) + - `type: "assistant"`: Full assistant messages + - `type: "content_block_delta"`: Streaming text deltas + - `type: "result"`: End of response + - `type: "error"`: Error messages + +4. **Callbacks**: Events trigger registered callbacks during streaming + +### Callback System Design + +The callback system uses Ruby's class-level registration pattern: +- Callbacks registered at class definition time via `on_event` +- Stored in class instance variable `@on_event_callbacks` +- Inherited through ancestor chain for subclass support +- Executed in order during message streaming +- Can be method names (symbols) or blocks (Procs) + +### Subprocess Management + +The agent uses `Open3.popen3` to spawn Claude CLI: +- Runs in bash login shell (`bash -lc`) to inherit environment +- Changes to sandbox directory before execution +- Optional TTY mode pipes output through `stream.rb` for debugging +- Process health checked via `wait_thr.alive?` before operations + +### MCP Server Support + +Agents can connect to MCP (Model Context Protocol) servers: +- Configuration passed as JSON via `--mcp-config` flag +- Server keys transformed from snake_case to kebab-case +- Example: `headless_browser` becomes `headless-browser` in config +- MCP servers extend Claude's capabilities with external tools + +## Code Style + +- Follow Ruby on Rails conventions (Sandi Metz / DHH style) +- Use RuboCop for linting enforcement +- Prefer clear, intention-revealing method names +- Use private methods to hide implementation details +- Follow POODR principles for object design + +## Version Management + +Version is defined in `lib/ruby_agent/version.rb`. Bump appropriately: +- Patch (0.2.1 → 0.2.2): Bug fixes +- Minor (0.2.1 → 0.3.0): New features +- Major (0.2.1 → 1.0.0): Breaking changes + +Publishing to RubyGems happens automatically via GitHub Actions on merge to `main`. + +## Testing Notes + +- Tests require Claude Code CLI installed locally +- Integration test (`test_simple_agent_query`) spawns real Claude CLI subprocess +- Tests verify callback registration, initialization, ERB template parsing +- Use Minitest framework with minitest-reporters for output + +## Requirements + +- Ruby >= 3.2.0 +- Claude Code CLI installed (see README.md for installation) +- Required gems: dotenv, shellwords (standard library), open3 (standard library), json (standard library), securerandom (standard library) 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..d5d89bd 100644 --- a/README.md +++ b/README.md @@ -37,111 +37,173 @@ gem 'ruby_agent' ```ruby require 'ruby_agent' -agent = RubyAgent.new -agent.on_result { |e, _| agent.exit if e["subtype"] == "success" } -agent.connect { agent.ask("What is 2+2?") } -``` +# Option 1: Use global configuration +RubyAgent.configure do |config| + config.system_prompt = "You are a helpful assistant." + config.model = "claude-sonnet-4-5-20250929" + config.sandbox_dir = "./sandbox" +end + +agent = RubyAgent::Agent.new(name: "MyAgent") +agent.connect(verbose: false) -That's it! Three lines to create an agent, ask Claude a question, and exit when done. +response = agent.ask("What is 1+1?") +puts response -### Advanced Example with Callbacks +agent.close +``` + +### Custom Agent with Callbacks ```ruby require 'ruby_agent' -agent = RubyAgent.new( - sandbox_dir: Dir.pwd, +# Create custom agent class with event callbacks +class MyAgent < RubyAgent::Agent + # Register callback using method name + on_event :handle_event + + def handle_event(event) + case event['type'] + # TBD + end + end +end + +# Initialize and connect +agent = MyAgent.new( + name: "CustomAgent", system_prompt: "You are a helpful coding assistant", model: "claude-sonnet-4-5-20250929", + sandbox_dir: "./my_sandbox" +) + +agent.connect( + timezone: "Eastern Time (US & Canada)", + skip_permissions: true, verbose: true ) -agent.create_message_callback :assistant_text do |event, all_events| - if event["type"] == "assistant" && event.dig("message", "content", 0, "type") == "text" - event.dig("message", "content", 0, "text") - end -end +# Send messages +agent.ask("Help me write a Ruby function") +agent.close +``` -agent.on_system_init do |event, _| - puts "Session started: #{event['session_id']}" -end +### Using Block Callbacks -agent.on_assistant_text do |text| - puts "Claude says: #{text}" -end +```ruby +require 'ruby_agent' -agent.on_result do |event, all_events| - if event["subtype"] == "success" - puts "Task completed successfully!" - agent.exit - elsif event["subtype"] == "error_occurred" - puts "Error: #{event['result']}" - agent.exit +class MyAgent < RubyAgent::Agent + # Register callback using block + on_event do |event| + # TBD end end -agent.on_error do |error| - puts "Error occurred: #{error.message}" -end - -agent.connect do - agent.ask("Write a simple Hello World function in Ruby", sender_name: "User") -end +agent = MyAgent.new(name: "BlockAgent") +agent.connect +agent.ask("Tell me a joke") +agent.close ``` ### Resuming Sessions ```ruby -agent = RubyAgent.new( - session_key: "existing_session_123", +require 'ruby_agent' + +# First session +agent = RubyAgent::Agent.new( + name: "MyAgent", system_prompt: "You are a helpful assistant" ) -agent.connect do - agent.ask("Continue from where we left off", sender_name: "User") -end +agent.connect(session_key: "my_session_123") +agent.ask("Remember that my name is Alice") +agent.close + +# Resume later +agent = RubyAgent::Agent.new( + name: "MyAgent", + system_prompt: "You are a helpful assistant" +) + +agent.connect( + session_key: "my_session_123", + resume_session: true +) +agent.ask("What is my name?") +agent.close ``` -### Using ERB in System Prompts +### Using MCP Servers ```ruby -agent = RubyAgent.new( - system_prompt: "You are <%= role %> working on <%= project_name %>", - role: "a senior developer", - project_name: "RubyAgent" +require 'ruby_agent' + +agent = RubyAgent::Agent.new( + name: "MCPAgent", + system_prompt: "You are a helpful assistant with web browsing capabilities" +) + +# Connect with MCP server configuration +agent.connect( + mcp_servers: { + headless_browser: { + type: :http, + url: "http://localhost:4567/mcp" + } + } ) + +agent.ask("Browse to example.com and summarize the page") +agent.close ``` -## Event Callbacks +### Interactive Example + +See `examples/example1.rb` for a complete interactive example with multiline input: -RubyAgent supports dynamic event callbacks using `method_missing`. You can create callbacks for any event type: +```bash +# Start MCP server (if using browser tool) +bundle exec hbt start --no-headless --be-human --single-session + +# Run example +ruby examples/example1.rb +``` -- `on_message` (alias: `on_event`) - Triggered for every message -- `on_assistant` - Triggered when Claude responds -- `on_system_init` - Triggered when a session starts -- `on_result` - Triggered when a task completes -- `on_error` - Triggered when an error occurs -- `on_tool_use` - Triggered when Claude uses a tool -- `on_tool_result` - Triggered when a tool returns results +## Event Callbacks + +The callback system allows you to react to events during Claude's response streaming: -You can also create custom callbacks with specific subtypes like `on_system_init`, `on_error_timeout`, etc. +### Event Types -## Custom Message Callbacks +- `type: "system"` - System messages +- `type: "assistant"` - Complete assistant messages +- `type: "content_block_delta"` - Streaming text chunks (contains `delta.text`) +- `type: "result"` - Conversation completion +- `type: "error"` - Error messages -Create custom message processors that filter and transform events: +### Callback Registration ```ruby -agent.create_message_callback :important_messages do |message, all_messages| - if message["type"] == "assistant" - message.dig("message", "content", 0, "text") +class MyAgent < RubyAgent::Agent + # Method 1: Using method name + on_event :my_handler + + def my_handler(event) + # Process event end -end -agent.on_important_messages do |text| - puts "Important: #{text}" + # Method 2: Using block + on_event do |event| + # Process event + end end ``` +Callbacks are executed in registration order and inherited through subclasses + ## API ### RubyAgent.new(options) @@ -149,26 +211,14 @@ end Creates a new RubyAgent instance. **Options:** -- `sandbox_dir` (String) - Working directory for the agent (default: `Dir.pwd`) -- `timezone` (String) - Timezone for the agent (default: `"UTC"`) -- `skip_permissions` (Boolean) - Skip permission prompts (default: `true`) -- `verbose` (Boolean) - Enable verbose output (default: `false`) -- `system_prompt` (String) - System prompt for Claude (default: `"You are a helpful assistant"`) -- `model` (String) - Claude model to use (default: `"claude-sonnet-4-5-20250929"`) -- `mcp_servers` (Hash) - MCP server configuration (default: `nil`) -- `session_key` (String) - Resume an existing session (default: `nil`) -- Additional keyword arguments are passed to the ERB template in `system_prompt` + ### Instance Methods -- `connect(&block)` - Connect to Claude and execute the block +- `connect()` - Connect to Claude - `ask(text, sender_name: "User", additional: [])` - Send a message to Claude -- `send_system_message(text)` - Send a system message -- `interrupt` - Interrupt Claude's current operation -- `exit` - Close the connection to Claude -- `on_message(&block)` - Register a callback for all messages +- `on_event(&block)` - Register a callback for all messages - `on_error(&block)` - Register a callback for errors -- Dynamic `on_*` methods for specific event types ## Error Handling @@ -180,9 +230,8 @@ RubyAgent defines three error types: ```ruby begin - agent.connect do - agent.ask("Hello", sender_name: "User") - end + agent.connect + agent.ask("Hello", sender_name: "User") rescue RubyAgent::ConnectionError => e puts "Connection failed: #{e.message}" rescue RubyAgent::AgentError => e @@ -208,8 +257,9 @@ 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 +rake build # Add locally to run examples +rake install ``` - 5. Commit your changes: `git commit -am 'Add some feature'` 6. Push to your fork: `git push origin my-new-feature` 7. Create a Pull Request against the `main` branch 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 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..32c5d61 --- /dev/null +++ b/lib/ruby_agent/agent.rb @@ -0,0 +1,274 @@ +require_relative "callback_support" + +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_text = "" + + 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) + + 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_text += text + end + end + elsif content.is_a?(String) + response_text += content + end + end + when "content_block_delta" + if message.dig("delta", "text") + text = message["delta"]["text"] + response_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_text + 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/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