ruby_agent 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f16c4c8fdacecaee67784197c3a8fe8b0e00adc248115c3d8c8f873d954fafd
4
- data.tar.gz: c94d916ea812575d2227a822df90cdb5dbdfe9ee41794af1be0375cc4aa4d9ee
3
+ metadata.gz: b1cc2c50708573c25ab812c6e10096d6be6aaea4580562a19951c90c35f8c387
4
+ data.tar.gz: 7857009a269597f17bdb800d06fe638626e704f4a57995a24594f5c3223183b6
5
5
  SHA512:
6
- metadata.gz: 46cf5e8405ce2661346832274d770c2ccdc19c55e53797553a7f9794f402fe654134b44908c2b46a0bf02bda13dc73d96b688a6f8a7de300bdd7ae85fd228700
7
- data.tar.gz: 8fb706b0a8f0b88eb50fdea84081aa2ef8b3e581bdbf6de4f70d230c92ca99103f123a3781e8be7ff9ecd791977b42043a8eb04b05fffede4a1645914a074c45
6
+ metadata.gz: 3b39897b45ac0494ec5e559a402d1bb3deb49f6a48b218d5e483052c22143f96e2f28333cfa31763a7db3437feb7fee3170dc17953042dbe636ab2a7a6bad926
7
+ data.tar.gz: 97b30ea700b18198394bd02ba3d51aa3dcb127020a031506b42d5b4c23038c882c761622c8b9458538ccad1719743997f49a68ad66bf20643994111ed76b10ea
data/Gemfile CHANGED
@@ -2,6 +2,7 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec
4
4
 
5
+ gem "dotenv", "~> 2.7"
5
6
  gem "minitest", "~> 5.0"
6
7
  gem "minitest-reporters", "~> 1.6"
7
8
  gem "rake", "~> 13.0"
data/README.md CHANGED
@@ -37,28 +37,13 @@ gem 'ruby_agent'
37
37
  ```ruby
38
38
  require 'ruby_agent'
39
39
 
40
- agent = RubyAgent.new(
41
- system_prompt: "You are a helpful assistant",
42
- model: "claude-sonnet-4-5-20250929"
43
- )
44
-
45
- agent.on_assistant do |event, all_events|
46
- if event.dig("message", "content", 0, "type") == "text"
47
- text = event.dig("message", "content", 0, "text")
48
- puts "Assistant: #{text}"
49
- end
50
- end
51
-
52
- agent.on_result do |event, all_events|
53
- puts "Result: #{event['subtype']}"
54
- agent.exit if event["subtype"] == "success"
55
- end
56
-
57
- agent.connect do
58
- agent.ask("What is 1+1?", sender_name: "User")
59
- end
40
+ agent = RubyAgent.new
41
+ agent.on_result { |e, _| agent.exit if e["subtype"] == "success" }
42
+ agent.connect { agent.ask("What is 2+2?") }
60
43
  ```
61
44
 
45
+ That's it! Three lines to create an agent, ask Claude a question, and exit when done.
46
+
62
47
  ### Advanced Example with Callbacks
63
48
 
64
49
  ```ruby
@@ -219,9 +204,14 @@ end
219
204
  rake ci
220
205
 
221
206
  # Or run tasks individually:
222
- rake ci:test # Run test suite
223
- rake ci:lint # Run RuboCop linter
224
- rake ci:scan # Run security audit
207
+ rake ci:test # Run test suite
208
+ rake ci:lint # Run RuboCop linter
209
+ rake ci:lint:fix # Auto-fix linting issues
210
+ rake ci:scan # Run security audit
211
+
212
+ # To run manual examples build locally:
213
+ rake build
214
+ rake install
225
215
  ```
226
216
 
227
217
  5. Commit your changes: `git commit -am 'Add some feature'`
@@ -251,7 +241,7 @@ We use RuboCop for code linting:
251
241
  rake ci:lint
252
242
 
253
243
  # Auto-fix linting issues
254
- bundle exec rubocop -a
244
+ rake ci:lint:fix
255
245
  ```
256
246
 
257
247
  ### Publishing
data/Rakefile CHANGED
@@ -15,19 +15,25 @@ namespace :ci do
15
15
  sh "bundle exec rake test"
16
16
  end
17
17
 
18
- desc "Run linter"
19
- task :lint do
20
- sh "bundle exec rubocop"
21
- rescue StandardError
22
- puts "Rubocop not configured yet, skipping..."
18
+ namespace :lint do
19
+ desc "Run linter"
20
+ task :default do
21
+ sh "bundle exec rubocop"
22
+ end
23
+
24
+ desc "Auto-fix linting issues"
25
+ task :fix do
26
+ sh "bundle exec rubocop -a"
27
+ end
23
28
  end
24
29
 
25
30
  desc "Run security scan"
26
31
  task :scan do
27
32
  sh "bundle exec bundler-audit check --update"
28
- rescue StandardError
29
- puts "Bundler-audit not installed, skipping..."
30
33
  end
34
+
35
+ # alias ci:lint to ci:lint:default
36
+ task lint: "lint:default"
31
37
  end
32
38
 
33
39
  desc "Run all CI tasks"
@@ -0,0 +1,105 @@
1
+ require "dotenv/load"
2
+ require "reline"
3
+
4
+ # Register the mcp server
5
+ # claude mcp add --transport http headless-browser http://localhost:4567/mcp
6
+ # claude --dangerously-skip-permissions
7
+
8
+ # Install the headless_browser_tool gem
9
+ # gem install headless_browser_tool --source https://github.com/krschacht/headless-browser-tool.git
10
+
11
+ # Before running start the hbt server in a separate terminal:
12
+ # bundle exec hbt start --no-headless --be-human --single-session --session-id=amazon
13
+
14
+ # Load local development version instead of installed gem
15
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
16
+ require "ruby_agent"
17
+
18
+ class MyAgent < RubyAgent::Agent
19
+ # 1. General event handler - fires for ALL events
20
+ on_event :my_handler
21
+
22
+ def my_handler(event)
23
+ puts "Event triggered"
24
+ puts "Received event type: #{event['type']}"
25
+ puts "Received event: #{event.dig('message', 'id')}"
26
+ end
27
+
28
+ # 2. Event-specific handler - fires only for assistant messages
29
+ on_event_assistant do |_event|
30
+ puts "Assistant message received"
31
+ end
32
+
33
+ # 3. Another event-specific handler - fires only for content_block_delta events
34
+ on_event_content_block_delta :streaming_handler
35
+
36
+ def streaming_handler(event)
37
+ # Handle streaming text output for content_block_delta events only
38
+ return unless event.dig("delta", "text")
39
+
40
+ print event["delta"]["text"]
41
+ end
42
+
43
+ # TODO: Add this
44
+ # def on_event_result(event)
45
+ # puts "\nConversation complete!"
46
+ # end
47
+ end
48
+
49
+ DONE = %w[done end eof exit].freeze
50
+
51
+ def prompt_for_message
52
+ puts "\n(multiline input; type 'end' on its own line when done. or 'exit' to exit)\n\n"
53
+
54
+ user_message = Reline.readmultiline("User message: ", true) do |multiline_input|
55
+ last = multiline_input.split.last
56
+ DONE.include?(last)
57
+ end
58
+
59
+ return :noop unless user_message
60
+
61
+ lines = user_message.split("\n")
62
+
63
+ if lines.size > 1 && DONE.include?(lines.last)
64
+ # remove the "done" from the message
65
+ user_message = lines[0..-2].join("\n")
66
+ end
67
+
68
+ return :exit if DONE.include?(user_message.downcase)
69
+
70
+ user_message
71
+ end
72
+
73
+ begin
74
+ RubyAgent.configure do |config|
75
+ config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil) # Not strictly necessary with claude installed
76
+ config.system_prompt = "You are a helpful AI news assistant."
77
+ config.model = "claude-sonnet-4-5-20250929"
78
+ config.sandbox_dir = "./news_sandbox"
79
+ end
80
+
81
+ agent = MyAgent.new(name: "News-Agent").connect(mcp_servers: { headless_browser: { type: :http,
82
+ url: "http://0.0.0.0:4567/mcp" } })
83
+
84
+ puts "Welcome to your Claude assistant!"
85
+
86
+ loop do
87
+ user_message = prompt_for_message
88
+
89
+ case user_message
90
+ when :noop
91
+ next
92
+ when :exit
93
+ break
94
+ end
95
+
96
+ puts "Asking Claude..."
97
+ response = agent.ask(user_message)
98
+ puts "\n\nFinal response:\n\n"
99
+ puts response.final_text
100
+ end
101
+ rescue Interrupt
102
+ puts "\nExiting..."
103
+ ensure
104
+ agent&.close
105
+ end
@@ -0,0 +1,260 @@
1
+ require_relative "callback_support"
2
+ require_relative "response"
3
+
4
+ module RubyAgent
5
+ class Agent
6
+ include CallbackSupport
7
+
8
+ class ConnectionError < StandardError; end
9
+
10
+ attr_reader :name, :sandbox_dir, :timezone, :skip_permissions, :verbose,
11
+ :system_prompt, :mcp_servers, :model, :session_key,
12
+ :context, :conversation_history
13
+
14
+ # Configure parameters for the Agent(s) like this or when initializing:
15
+ #
16
+ # RubyAgent.configure do |config|
17
+ # config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] # Not strictly necessary with Claude SDK
18
+ # config.system_prompt = "You are a helpful AI human resources assistant."
19
+ # config.model = "claude-sonnet-4-5-20250929"
20
+ # config.sandbox_dir = "./hr_sandbox"
21
+ # end
22
+
23
+ # Users can register callbacks in two ways:
24
+ #
25
+ # class MyAgent < RubyAgent::Agent
26
+ # # Using a method name
27
+ # on_event :my_handler # Fires for all events
28
+ #
29
+ # def my_handler(event)
30
+ # end
31
+ # end
32
+ #
33
+ # class MyAgent < RubyAgent::Agent
34
+ # # Using a block
35
+ # on_event do |event| # Fires for all events
36
+ # puts "Event received: #{event['type']}"
37
+ # end
38
+ # end
39
+
40
+ # You can register event-specific callbacks using the pattern
41
+ # on_event_<event_type>:
42
+ #
43
+ # on_event_content_block_delta :streaming_handler
44
+ # on_event_result :completion_handler
45
+ # on_event_assistant :assistant_handler
46
+
47
+ # Each callback fires only for its specific event type, while on_event
48
+ # callbacks fires for all events. This follows the Single
49
+ # Responsibility Principle and makes the code more maintainable.
50
+
51
+ def initialize(name: "MyName", system_prompt: nil, model: nil, sandbox_dir: nil)
52
+ @name = name
53
+ @system_prompt = system_prompt || config.system_prompt
54
+ @model = model || config.model
55
+ @sandbox_dir = sandbox_dir || config.sandbox_dir
56
+ @stdin = nil
57
+ @stdout = nil
58
+ @stderr = nil
59
+ @wait_thr = nil
60
+ @parsed_lines = []
61
+ @parsed_lines_mutex = Mutex.new
62
+
63
+ return unless @session_key.nil?
64
+
65
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
66
+ end
67
+
68
+ def config
69
+ RubyAgent.configuration ||= RubyAgent::Configuration.new
70
+ end
71
+
72
+ def connect(
73
+ timezone: "Eastern Time (US & Canada)",
74
+ skip_permissions: true,
75
+ verbose: true,
76
+ mcp_servers: nil,
77
+ session_key: nil,
78
+ resume_session: false,
79
+ **additional_context
80
+ )
81
+ @timezone = timezone
82
+ @skip_permissions = skip_permissions
83
+ @verbose = verbose
84
+ @mcp_servers = mcp_servers
85
+ @session_key = session_key
86
+ @resume_session = resume_session
87
+ @context = additional_context
88
+ @conversation_history = []
89
+
90
+ ensure_sandbox_exists
91
+
92
+ command = build_claude_command
93
+
94
+ @stdin, @stdout, @stderr, @wait_thr = spawn_process(command, @sandbox_dir)
95
+
96
+ sleep 0.5
97
+ unless @wait_thr.alive?
98
+ error_output = @stderr.read
99
+ raise ConnectionError, "Claude process failed to start. Error: #{error_output}"
100
+ end
101
+
102
+ puts "Claude process started successfully (PID: #{@wait_thr.pid})"
103
+ self
104
+ end
105
+
106
+ def ask(message)
107
+ return if message.nil? || message.strip.empty?
108
+
109
+ send_message(message)
110
+ read_response
111
+ end
112
+
113
+ def close
114
+ return unless @stdin
115
+
116
+ @stdin.close unless @stdin.closed?
117
+ @stdout.close unless @stdout.closed?
118
+ @stderr.close unless @stderr.closed?
119
+ @wait_thr&.join
120
+ ensure
121
+ @stdin = nil
122
+ @stdout = nil
123
+ @stderr = nil
124
+ @wait_thr = nil
125
+ end
126
+
127
+ private
128
+
129
+ def ensure_sandbox_exists
130
+ return if File.directory?(@sandbox_dir)
131
+
132
+ puts "Creating sandbox directory: #{@sandbox_dir}"
133
+ FileUtils.mkdir_p(@sandbox_dir)
134
+ end
135
+
136
+ def build_claude_command
137
+ puts "Building Claude command..."
138
+
139
+ cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json"
140
+ cmd += " --verbose" if @verbose
141
+ cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}"
142
+ cmd += " --model #{Shellwords.escape(@model)}"
143
+
144
+ if @mcp_servers
145
+ mcp_config_json = build_mcp_config(@mcp_servers).to_json
146
+ cmd += " --mcp-config #{Shellwords.escape(mcp_config_json)}"
147
+ end
148
+
149
+ cmd += ' --setting-sources ""'
150
+ cmd += " --resume #{Shellwords.escape(@session_key)}" if @resume_session && @session_key
151
+ cmd
152
+ end
153
+
154
+ def build_mcp_config(mcp_servers)
155
+ servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") }
156
+ { mcpServers: servers }
157
+ end
158
+
159
+ def spawn_process(command, sandbox_dir)
160
+ puts "Spawning process with command: #{command}"
161
+
162
+ command_to_run = if $stdout.tty? && File.exist?("./stream.rb")
163
+ "#{command} | tee >(ruby ./stream.rb >/dev/tty)"
164
+ else
165
+ command
166
+ end
167
+
168
+ stdin, stdout, stderr, wait_thr = Open3.popen3("bash", "-lc", command_to_run, chdir: sandbox_dir)
169
+ [stdin, stdout, stderr, wait_thr]
170
+ end
171
+
172
+ def send_message(content, session_id = nil)
173
+ raise ConnectionError, "Not connected to Claude" unless @stdin
174
+
175
+ unless @wait_thr&.alive?
176
+ error_output = @stderr&.read || "Unknown error"
177
+ raise ConnectionError, "Claude process has died. Error: #{error_output}"
178
+ end
179
+
180
+ message_json = {
181
+ type: "user",
182
+ message: { role: "user", content: content },
183
+ session_id: session_id
184
+ }.compact
185
+
186
+ @stdin.puts JSON.generate(message_json)
187
+ @stdin.flush
188
+ end
189
+
190
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength
191
+ def read_response
192
+ response = RubyAgent::Response.new
193
+
194
+ loop do
195
+ unless @wait_thr.alive?
196
+ error_output = @stderr.read
197
+ raise ConnectionError, "Claude process died while reading response. Error: #{error_output}"
198
+ end
199
+
200
+ ready = IO.select([@stdout, @stderr], nil, nil, 0.1)
201
+
202
+ next unless ready
203
+
204
+ if ready[0].include?(@stderr)
205
+ error_line = @stderr.gets
206
+ warn error_line if error_line
207
+ end
208
+
209
+ next unless ready[0].include?(@stdout)
210
+
211
+ line = @stdout.gets
212
+ break unless line
213
+
214
+ line = line.strip
215
+ next if line.empty?
216
+
217
+ begin
218
+ message = JSON.parse(line)
219
+ response.add_event(message)
220
+
221
+ case message["type"]
222
+ when "system"
223
+ next
224
+ when "assistant"
225
+ if message.dig("message", "content")
226
+ content = message["message"]["content"]
227
+ if content.is_a?(Array)
228
+ content.each do |block|
229
+ if block["type"] == "text" && block["text"]
230
+ text = block["text"]
231
+ response.append_text(text)
232
+ end
233
+ end
234
+ elsif content.is_a?(String)
235
+ response.append_text(content)
236
+ end
237
+ end
238
+ when "content_block_delta"
239
+ if message.dig("delta", "text")
240
+ text = message["delta"]["text"]
241
+ response.append_text(text)
242
+ end
243
+ when "result"
244
+ break
245
+ when "error"
246
+ puts "[ERROR] #{message['message']}"
247
+ break
248
+ end
249
+ run_callbacks(message)
250
+ rescue JSON::ParserError
251
+ warn "Failed to parse JSON: #{line[0..100]}"
252
+ next
253
+ end
254
+ end
255
+
256
+ response
257
+ end
258
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength
259
+ end
260
+ end
@@ -0,0 +1,71 @@
1
+ module CallbackSupport
2
+ def self.included(base)
3
+ base.extend ClassMethods
4
+ end
5
+
6
+ module ClassMethods
7
+ def on_event(method_name = nil, &block)
8
+ @on_event_callbacks ||= []
9
+ @on_event_callbacks << (method_name || block)
10
+ end
11
+
12
+ def on_event_callbacks
13
+ callbacks = []
14
+ ancestors.each do |ancestor|
15
+ if ancestor.instance_variable_defined?(:@on_event_callbacks)
16
+ callbacks.concat(ancestor.instance_variable_get(:@on_event_callbacks))
17
+ end
18
+ end
19
+ callbacks
20
+ end
21
+
22
+ def method_missing(method_name, *args, &block)
23
+ if method_name.to_s.start_with?("on_event_")
24
+ event_type = method_name.to_s.sub(/^on_event_/, "")
25
+ @specific_event_callbacks ||= {}
26
+ @specific_event_callbacks[event_type] ||= []
27
+ @specific_event_callbacks[event_type] << (args.first || block)
28
+ else
29
+ super
30
+ end
31
+ end
32
+
33
+ def respond_to_missing?(method_name, include_private = false)
34
+ method_name.to_s.start_with?("on_event_") || super
35
+ end
36
+
37
+ def specific_event_callbacks(event_type)
38
+ callbacks = []
39
+ ancestors.each do |ancestor|
40
+ if ancestor.instance_variable_defined?(:@specific_event_callbacks)
41
+ specific_callbacks = ancestor.instance_variable_get(:@specific_event_callbacks)
42
+ callbacks.concat(specific_callbacks[event_type]) if specific_callbacks[event_type]
43
+ end
44
+ end
45
+ callbacks
46
+ end
47
+ end
48
+
49
+ def run_callbacks(event_data)
50
+ # Run general on_event callbacks
51
+ self.class.on_event_callbacks.each do |callback|
52
+ if callback.is_a?(Proc)
53
+ instance_exec(event_data, &callback)
54
+ else
55
+ send(callback, event_data)
56
+ end
57
+ end
58
+
59
+ # Run event-specific callbacks
60
+ event_type = event_data["type"]
61
+ return unless event_type
62
+
63
+ self.class.specific_event_callbacks(event_type).each do |callback|
64
+ if callback.is_a?(Proc)
65
+ instance_exec(event_data, &callback)
66
+ else
67
+ send(callback, event_data)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ module RubyAgent
2
+ class Configuration
3
+ attr_accessor :anthropic_api_key, :system_prompt, :model, :sandbox_dir
4
+
5
+ def initialize
6
+ @anthropic_api_key = nil # Not necessarily required with Claude SDK
7
+ @system_prompt = "You are a helpful AI assistant."
8
+ @model = "claude-sonnet-4-5-20250929"
9
+ @sandbox_dir = "./sandbox"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ module RubyAgent
2
+ class Event
3
+ attr_reader :raw_event
4
+
5
+ def initialize(raw_event)
6
+ @raw_event = raw_event
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ module RubyAgent
2
+ class Response
3
+ attr_reader :events, :text
4
+
5
+ def initialize(text: "", events: [])
6
+ @text = text
7
+ @events = events
8
+ end
9
+
10
+ def final_text
11
+ @text
12
+ end
13
+
14
+ def add_event(event)
15
+ @events << event
16
+ self
17
+ end
18
+
19
+ def append_text(content)
20
+ @text += content
21
+ self
22
+ end
23
+ end
24
+ end
@@ -1,3 +1,3 @@
1
- class RubyAgent
2
- VERSION = "0.2.2".freeze
1
+ module RubyAgent
2
+ VERSION = "0.2.3".freeze
3
3
  end
data/lib/ruby_agent.rb CHANGED
@@ -1,428 +1,22 @@
1
- require_relative "ruby_agent/version"
1
+ require "dotenv/load"
2
2
  require "shellwords"
3
3
  require "open3"
4
- require "erb"
5
4
  require "json"
5
+ require "fileutils"
6
+ require "securerandom"
6
7
 
7
- class RubyAgent
8
- class AgentError < StandardError; end
9
- class ConnectionError < AgentError; end
10
- class ParseError < AgentError; end
11
-
12
- DEBUG = false
13
-
14
- attr_reader :sandbox_dir, :timezone, :skip_permissions, :verbose, :system_prompt, :model, :mcp_servers
15
-
16
- def initialize(
17
- sandbox_dir: Dir.pwd,
18
- timezone: "UTC",
19
- skip_permissions: true,
20
- verbose: false,
21
- system_prompt: "You are a helpful assistant",
22
- model: "claude-sonnet-4-5-20250929",
23
- mcp_servers: nil,
24
- session_key: nil,
25
- **additional_context
26
- )
27
- @sandbox_dir = sandbox_dir
28
- @timezone = timezone
29
- @skip_permissions = skip_permissions
30
- @verbose = verbose
31
- @model = model
32
- @mcp_servers = mcp_servers
33
- @session_key = session_key
34
- @system_prompt = parse_system_prompt(system_prompt, additional_context)
35
- @on_message_callback = nil
36
- @on_error_callback = nil
37
- @dynamic_callbacks = {}
38
- @custom_message_callbacks = {}
39
- @stdin = nil
40
- @stdout = nil
41
- @stderr = nil
42
- @wait_thr = nil
43
- @parsed_lines = []
44
- @parsed_lines_mutex = Mutex.new
45
- @pending_ask_after_interrupt = nil
46
- @pending_interrupt_request_id = nil
47
- @deferred_exit = false
48
-
49
- return if @session_key
50
-
51
- inject_streaming_response({
52
- type: "system",
53
- subtype: "prompt",
54
- system_prompt: @system_prompt,
55
- timestamp: Time.now.utc.iso8601(6),
56
- received_at: Time.now.utc.iso8601(6)
57
- })
58
- end
59
-
60
- def create_message_callback(name, &processor)
61
- @custom_message_callbacks[name.to_s] = {
62
- processor: processor,
63
- callback: nil
64
- }
65
- end
66
-
67
- def on_message(&block)
68
- @on_message_callback = block
69
- end
70
-
71
- alias on_event on_message
72
-
73
- def on_error(&block)
74
- @on_error_callback = block
75
- end
76
-
77
- def method_missing(method_name, *args, &block)
78
- if method_name.to_s.start_with?("on_") && block_given?
79
- callback_name = method_name.to_s.sub(/^on_/, "")
80
-
81
- if @custom_message_callbacks.key?(callback_name)
82
- @custom_message_callbacks[callback_name][:callback] = block
83
- else
84
- @dynamic_callbacks[callback_name] = block
85
- end
86
- else
87
- super
88
- end
89
- end
90
-
91
- def respond_to_missing?(method_name, include_private = false)
92
- method_name.to_s.start_with?("on_") || super
93
- end
94
-
95
- def connect(&block)
96
- command = build_claude_command
97
-
98
- spawn_process(command, @sandbox_dir) do |stdin, stdout, stderr, wait_thr|
99
- @stdin = stdin
100
- @stdout = stdout
101
- @stderr = stderr
102
- @wait_thr = wait_thr
103
-
104
- begin
105
- block.call if block_given?
106
- receive_streaming_responses
107
- ensure
108
- @stdin = nil
109
- @stdout = nil
110
- @stderr = nil
111
- @wait_thr = nil
112
- end
113
- end
114
- rescue StandardError => e
115
- trigger_error(e)
116
- raise
117
- end
118
-
119
- def ask(text, sender_name: "User", additional: [])
120
- formatted_text = if sender_name.downcase == "system"
121
- <<~TEXT.strip
122
- <system>
123
- #{text}
124
- </system>
125
- TEXT
126
- else
127
- "#{sender_name}: #{text}"
128
- end
129
- formatted_text += extra_context(additional, sender_name:)
130
-
131
- inject_streaming_response({
132
- type: "user",
133
- subtype: "new_message",
134
- sender_name:,
135
- text:,
136
- formatted_text:,
137
- timestamp: Time.now.utc.iso8601(6)
138
- })
139
-
140
- send_message(formatted_text)
141
- end
142
-
143
- def ask_after_interrupt(text, sender_name: "User", additional: [])
144
- @pending_ask_after_interrupt = { text:, sender_name:, additional: }
145
- end
146
-
147
- def send_system_message(text)
148
- ask(text, sender_name: "system")
149
- end
150
-
151
- def receive_streaming_responses
152
- @stdout.each_line do |line|
153
- next if line.strip.empty?
154
-
155
- begin
156
- json = JSON.parse(line)
157
-
158
- all_lines = nil
159
- @parsed_lines_mutex.synchronize do
160
- @parsed_lines << json
161
- all_lines = @parsed_lines.dup
162
- end
163
-
164
- trigger_message(json, all_lines)
165
- trigger_dynamic_callbacks(json, all_lines)
166
- trigger_custom_message_callbacks(json, all_lines)
167
- rescue JSON::ParserError
168
- warn "Failed to parse line: #{line}" if DEBUG
169
- end
170
- end
171
-
172
- puts "→ stdout closed, waiting for process to exit..." if DEBUG
173
- exit_status = @wait_thr.value
174
- puts "→ Process exited with status: #{exit_status.success? ? 'success' : 'failure'}" if DEBUG
175
- unless exit_status.success?
176
- stderr_output = @stderr.read
177
- raise ConnectionError, "Claude command failed: #{stderr_output}"
178
- end
179
-
180
- @parsed_lines
181
- end
182
-
183
- def inject_streaming_response(event_hash)
184
- stringified_event = stringify_keys(event_hash)
185
- all_lines = nil
186
- @parsed_lines_mutex.synchronize do
187
- @parsed_lines << stringified_event
188
- all_lines = @parsed_lines.dup
189
- end
190
-
191
- trigger_message(stringified_event, all_lines)
192
- trigger_dynamic_callbacks(stringified_event, all_lines)
193
- trigger_custom_message_callbacks(stringified_event, all_lines)
194
- end
195
-
196
- def interrupt
197
- raise ConnectionError, "Not connected to Claude" unless @stdin
198
- raise ConnectionError, "Cannot interrupt - stdin is closed" if @stdin.closed?
199
-
200
- @request_counter ||= 0
201
- @request_counter += 1
202
- request_id = "req_#{@request_counter}_#{SecureRandom.hex(4)}"
203
-
204
- @pending_interrupt_request_id = request_id if @pending_ask_after_interrupt
205
- if DEBUG
206
- puts "→ Sending interrupt with request_id: #{request_id}, pending_ask: #{@pending_ask_after_interrupt ? true : false}"
207
- end
208
-
209
- control_request = {
210
- type: "control_request",
211
- request_id: request_id,
212
- request: {
213
- subtype: "interrupt"
214
- }
215
- }
216
-
217
- inject_streaming_response({
218
- type: "control",
219
- subtype: "interrupt",
220
- timestamp: Time.now.utc.iso8601(6)
221
- })
222
-
223
- @stdin.puts JSON.generate(control_request)
224
- @stdin.flush
225
- rescue StandardError => e
226
- warn "Failed to send interrupt signal: #{e.message}"
227
- raise
228
- end
229
-
230
- def exit
231
- return unless @stdin
232
-
233
- if @pending_interrupt_request_id
234
- puts "→ Deferring exit - waiting for interrupt response (request_id: #{@pending_interrupt_request_id})" if DEBUG
235
- @deferred_exit = true
236
- return
237
- end
238
-
239
- puts "→ Exiting Claude (closing stdin)" if DEBUG
240
-
241
- begin
242
- @stdin.close unless @stdin.closed?
243
- puts "→ stdin closed" if DEBUG
244
- rescue StandardError => e
245
- warn "Error closing stdin during exit: #{e.message}"
246
- end
247
- end
248
-
249
- private
250
-
251
- def spawn_process(command, sandbox_dir, &)
252
- Open3.popen3("bash", "-lc", command, chdir: sandbox_dir, &)
253
- end
254
-
255
- def build_claude_command
256
- cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json --verbose"
257
- cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}"
258
- cmd += " --model #{Shellwords.escape(@model)}"
259
-
260
- if @mcp_servers
261
- mcp_config = build_mcp_config(@mcp_servers)
262
- cmd += " --mcp-config #{Shellwords.escape(mcp_config.to_json)}"
263
- end
264
-
265
- cmd += " --setting-sources \"\""
266
- cmd += " --resume #{Shellwords.escape(@session_key)}" if @session_key
267
- cmd
268
- end
269
-
270
- def build_mcp_config(mcp_servers)
271
- servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") }
272
- { mcpServers: servers }
273
- end
274
-
275
- def parse_system_prompt(template_content, context_vars)
276
- if Dir.exist?(@sandbox_dir)
277
- Dir.chdir(@sandbox_dir) do
278
- parse_system_prompt_in_context(template_content, context_vars)
279
- end
280
- else
281
- parse_system_prompt_in_context(template_content, context_vars)
282
- end
283
- end
284
-
285
- def parse_system_prompt_in_context(template_content, context_vars)
286
- erb = ERB.new(template_content)
287
- binding_context = create_binding_context(**context_vars)
288
- result = erb.result(binding_context)
289
-
290
- raise ParseError, "There was an error parsing the system prompt." if result.include?("<%=") || result.include?("%>")
291
-
292
- result
293
- end
294
-
295
- def create_binding_context(**vars)
296
- context = Object.new
297
- vars.each do |key, value|
298
- context.instance_variable_set("@#{key}", value)
299
- context.define_singleton_method(key) { instance_variable_get("@#{key}") }
300
- end
301
- context.instance_eval { binding }
302
- end
303
-
304
- def extra_context(additional = [], sender_name:)
305
- raise "additional is not an array" unless additional.is_a?(Array)
306
-
307
- return "" if additional.empty?
308
-
309
- <<~CONTEXT
310
-
311
- <extra-context>
312
- #{additional.join("\n\n")}
313
- </extra-context>
314
- CONTEXT
315
- end
316
-
317
- def send_message(content, session_id = nil)
318
- raise ConnectionError, "Not connected to Claude" unless @stdin
319
-
320
- message_json = {
321
- type: "user",
322
- message: { role: "user", content: content },
323
- session_id: session_id
324
- }.compact
325
-
326
- @stdin.puts JSON.generate(message_json)
327
- @stdin.flush
328
- rescue StandardError => e
329
- trigger_error(e)
330
- raise
331
- end
332
-
333
- def trigger_message(message, all_messages)
334
- @on_message_callback&.call(message, all_messages)
335
- end
336
-
337
- def trigger_dynamic_callbacks(message, all_messages)
338
- type = message["type"]
339
- subtype = message["subtype"]
340
-
341
- return unless type
342
-
343
- if type == "control_response"
344
- puts "→ Received control_response: #{message.inspect}" if DEBUG || @pending_interrupt_request_id
345
- if @pending_interrupt_request_id
346
- response = message["response"]
347
- if response&.dig("subtype") == "success" && response&.dig("request_id") == @pending_interrupt_request_id
348
- puts "→ Interrupt confirmed, executing queued ask" if DEBUG
349
- @pending_interrupt_request_id = nil
350
- if @pending_ask_after_interrupt
351
- pending = @pending_ask_after_interrupt
352
- @pending_ask_after_interrupt = nil
353
- begin
354
- ask(pending[:text], sender_name: pending[:sender_name], additional: pending[:additional])
355
- rescue IOError, Errno::EPIPE => e
356
- warn "Failed to send queued ask after interrupt (stream closed): #{e.message}"
357
- end
358
- end
359
-
360
- if @deferred_exit
361
- puts "→ Executing deferred exit" if DEBUG
362
- @deferred_exit = false
363
- exit
364
- end
365
- elsif DEBUG
366
- puts "→ Control response didn't match pending interrupt: #{response.inspect}"
367
- end
368
- end
369
- end
370
-
371
- if subtype
372
- specific_callback_key = "#{type}_#{subtype}"
373
- specific_callback = @dynamic_callbacks[specific_callback_key]
374
- if specific_callback
375
- puts "→ Triggering callback for: #{specific_callback_key}" if DEBUG
376
- specific_callback.call(message, all_messages)
377
- end
378
- end
379
-
380
- general_callback = @dynamic_callbacks[type]
381
- if general_callback
382
- puts "→ Triggering callback for: #{type}" if DEBUG
383
- general_callback.call(message, all_messages)
384
- end
385
-
386
- check_nested_content_types(message, all_messages)
387
- end
388
-
389
- def check_nested_content_types(message, all_messages)
390
- return unless message["message"].is_a?(Hash)
391
-
392
- content = message.dig("message", "content")
393
- return unless content.is_a?(Array)
394
-
395
- content.each do |content_item|
396
- next unless content_item.is_a?(Hash)
397
-
398
- nested_type = content_item["type"]
399
- next unless nested_type
400
-
401
- callback = @dynamic_callbacks[nested_type]
402
- if callback
403
- puts "→ Triggering callback for nested type: #{nested_type}" if DEBUG
404
- callback.call(message, all_messages)
405
- end
406
- end
407
- end
408
-
409
- def trigger_custom_message_callbacks(message, all_messages)
410
- @custom_message_callbacks.each_value do |config|
411
- processor = config[:processor]
412
- callback = config[:callback]
413
-
414
- next unless processor && callback
415
-
416
- result = processor.call(message, all_messages)
417
- callback.call(result) if result && !result.to_s.empty?
418
- end
419
- end
8
+ require_relative "ruby_agent/version"
9
+ require_relative "ruby_agent/configuration"
10
+ require_relative "ruby_agent/agent"
11
+ require_relative "ruby_agent/callback_support"
420
12
 
421
- def trigger_error(error)
422
- @on_error_callback&.call(error)
13
+ module RubyAgent
14
+ class << self
15
+ attr_accessor :configuration
423
16
  end
424
17
 
425
- def stringify_keys(hash)
426
- hash.transform_keys(&:to_s)
18
+ def self.configure
19
+ self.configuration ||= Configuration.new
20
+ yield(configuration)
427
21
  end
428
22
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Schacht
@@ -22,7 +22,13 @@ files:
22
22
  - LICENSE
23
23
  - README.md
24
24
  - Rakefile
25
+ - examples/example1.rb
25
26
  - lib/ruby_agent.rb
27
+ - lib/ruby_agent/agent.rb
28
+ - lib/ruby_agent/callback_support.rb
29
+ - lib/ruby_agent/configuration.rb
30
+ - lib/ruby_agent/event.rb
31
+ - lib/ruby_agent/response.rb
26
32
  - lib/ruby_agent/version.rb
27
33
  homepage: https://github.com/AllYourBot/ruby-agent
28
34
  licenses: