ruby_agent 0.2.1 → 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: '039e385b0c47944839ae33f1b8e4ed75e499803ae06538e9da4c3a9899dbe247'
4
- data.tar.gz: 6726c84e2a9fbb9f795300dbb8995fba831bb989540681ce35c3b6604c9c6b35
3
+ metadata.gz: b1cc2c50708573c25ab812c6e10096d6be6aaea4580562a19951c90c35f8c387
4
+ data.tar.gz: 7857009a269597f17bdb800d06fe638626e704f4a57995a24594f5c3223183b6
5
5
  SHA512:
6
- metadata.gz: 787bdc5703131d4169ca2fb53175ce3cc920330fd398e34c881ab2fbf4bfdf73a360a3459a60891fbdd05bb4891a7ff41f74efca715ef126164e63a128daf5ef
7
- data.tar.gz: 0c273eb33000ddc93e065a3b1d43483012012fa350e67bcae2f96def1d882d3f7e2846659fbca4287b5b0e280201023c0469cccfeec72b8282a0d218d831854c
6
+ metadata.gz: 3b39897b45ac0494ec5e559a402d1bb3deb49f6a48b218d5e483052c22143f96e2f28333cfa31763a7db3437feb7fee3170dc17953042dbe636ab2a7a6bad926
7
+ data.tar.gz: 97b30ea700b18198394bd02ba3d51aa3dcb127020a031506b42d5b4c23038c882c761622c8b9458538ccad1719743997f49a68ad66bf20643994111ed76b10ea
data/.rubocop.yml ADDED
@@ -0,0 +1,58 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.4
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - 'vendor/**/*'
7
+ - 'tmp/**/*'
8
+ - 'bin/**/*'
9
+
10
+ Style/Documentation:
11
+ Enabled: false
12
+
13
+ Style/StringLiterals:
14
+ EnforcedStyle: double_quotes
15
+
16
+ Style/FrozenStringLiteralComment:
17
+ Enabled: false
18
+
19
+ Layout/LineLength:
20
+ Max: 125
21
+
22
+ Metrics/MethodLength:
23
+ Max: 50
24
+
25
+ Metrics/AbcSize:
26
+ Max: 40
27
+
28
+ Metrics/CyclomaticComplexity:
29
+ Max: 25
30
+
31
+ Metrics/PerceivedComplexity:
32
+ Max: 25
33
+
34
+ Metrics/BlockNesting:
35
+ Max: 5
36
+
37
+ Metrics/BlockLength:
38
+ Exclude:
39
+ - 'test/**/*'
40
+ - 'Rakefile'
41
+
42
+ Metrics/ClassLength:
43
+ Max: 350
44
+
45
+ Metrics/ParameterLists:
46
+ Max: 10
47
+
48
+ Lint/UnusedMethodArgument:
49
+ AllowUnusedKeywordArguments: true
50
+
51
+ Lint/EmptyBlock:
52
+ Enabled: false
53
+
54
+ Lint/SuppressedException:
55
+ AllowComments: true
56
+
57
+ Gemspec/RequiredRubyVersion:
58
+ Enabled: false
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "dotenv", "~> 2.7"
6
+ gem "minitest", "~> 5.0"
7
+ gem "minitest-reporters", "~> 1.6"
8
+ gem "rake", "~> 13.0"
9
+
10
+ group :development do
11
+ gem "bundler-audit", "~> 0.9", require: false
12
+ gem "rubocop", "~> 1.50", require: false
13
+ end
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
@@ -205,6 +190,69 @@ rescue RubyAgent::AgentError => e
205
190
  end
206
191
  ```
207
192
 
193
+ ## Development
194
+
195
+ ### Contributing
196
+
197
+ 1. Fork the repository: https://github.com/AllYourBot/ruby-agent
198
+ 2. Create a feature branch: `git checkout -b my-new-feature`
199
+ 3. Make your changes
200
+ 4. Run the CI suite locally to ensure everything passes:
201
+
202
+ ```bash
203
+ # Run all CI tasks (linting + tests)
204
+ rake ci
205
+
206
+ # Or run tasks individually:
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
215
+ ```
216
+
217
+ 5. Commit your changes: `git commit -am 'Add some feature'`
218
+ 6. Push to your fork: `git push origin my-new-feature`
219
+ 7. Create a Pull Request against the `main` branch
220
+
221
+ ### Running Tests Locally
222
+
223
+ The test suite includes an integration test that runs Claude Code CLI locally:
224
+
225
+ ```bash
226
+ # Run all tests
227
+ rake test
228
+
229
+ # Run a specific test
230
+ ruby test/ruby_agent_test.rb --name test_simple_agent_query
231
+ ```
232
+
233
+ **Note**: Tests require Claude Code CLI to be installed on your machine (see Prerequisites section).
234
+
235
+ ### Linting
236
+
237
+ We use RuboCop for code linting:
238
+
239
+ ```bash
240
+ # Check for linting issues
241
+ rake ci:lint
242
+
243
+ # Auto-fix linting issues
244
+ rake ci:lint:fix
245
+ ```
246
+
247
+ ### Publishing
248
+
249
+ Publishing to RubyGems happens automatically via GitHub Actions when code is merged to `main`. The version number is read from `lib/ruby_agent/version.rb`.
250
+
251
+ **Before merging a PR**, make sure to bump the version number appropriately:
252
+ - Patch version (0.2.1 → 0.2.2) for bug fixes
253
+ - Minor version (0.2.1 → 0.3.0) for new features
254
+ - Major version (0.2.1 → 1.0.0) for breaking changes
255
+
208
256
  ## License
209
257
 
210
258
  MIT
data/Rakefile ADDED
@@ -0,0 +1,40 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :test
11
+
12
+ namespace :ci do
13
+ desc "Run tests"
14
+ task :test do
15
+ sh "bundle exec rake test"
16
+ end
17
+
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
28
+ end
29
+
30
+ desc "Run security scan"
31
+ task :scan do
32
+ sh "bundle exec bundler-audit check --update"
33
+ end
34
+
35
+ # alias ci:lint to ci:lint:default
36
+ task lint: "lint:default"
37
+ end
38
+
39
+ desc "Run all CI tasks"
40
+ task ci: ["ci:lint", "ci:test"]
@@ -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.1"
1
+ module RubyAgent
2
+ VERSION = "0.2.3".freeze
3
3
  end
data/lib/ruby_agent.rb CHANGED
@@ -1,427 +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
- unless @session_key
50
- inject_streaming_response({
51
- type: "system",
52
- subtype: "prompt",
53
- system_prompt: @system_prompt,
54
- timestamp: Time.now.utc.iso8601(6),
55
- received_at: Time.now.utc.iso8601(6)
56
- })
57
- end
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_method :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 => 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
- puts "→ Sending interrupt with request_id: #{request_id}, pending_ask: #{@pending_ask_after_interrupt ? true : false}" if DEBUG
206
-
207
- control_request = {
208
- type: "control_request",
209
- request_id: request_id,
210
- request: {
211
- subtype: "interrupt"
212
- }
213
- }
214
-
215
- inject_streaming_response({
216
- type: "control",
217
- subtype: "interrupt",
218
- timestamp: Time.now.utc.iso8601(6)
219
- })
220
-
221
- @stdin.puts JSON.generate(control_request)
222
- @stdin.flush
223
- rescue => e
224
- warn "Failed to send interrupt signal: #{e.message}"
225
- raise
226
- end
227
-
228
- def exit
229
- return unless @stdin
230
-
231
- if @pending_interrupt_request_id
232
- puts "→ Deferring exit - waiting for interrupt response (request_id: #{@pending_interrupt_request_id})" if DEBUG
233
- @deferred_exit = true
234
- return
235
- end
236
-
237
- puts "→ Exiting Claude (closing stdin)" if DEBUG
238
-
239
- begin
240
- @stdin.close unless @stdin.closed?
241
- puts "→ stdin closed" if DEBUG
242
- rescue => e
243
- warn "Error closing stdin during exit: #{e.message}"
244
- end
245
- end
246
-
247
- private
248
-
249
- def spawn_process(command, sandbox_dir, &)
250
- Open3.popen3("bash", "-lc", command, chdir: sandbox_dir, &)
251
- end
252
-
253
- def build_claude_command
254
- cmd = "claude -p --dangerously-skip-permissions --output-format=stream-json --input-format=stream-json --verbose"
255
- cmd += " --system-prompt #{Shellwords.escape(@system_prompt)}"
256
- cmd += " --model #{Shellwords.escape(@model)}"
257
-
258
- if @mcp_servers
259
- mcp_config = build_mcp_config(@mcp_servers)
260
- cmd += " --mcp-config #{Shellwords.escape(mcp_config.to_json)}"
261
- end
262
-
263
- cmd += " --setting-sources \"\""
264
- cmd += " --resume #{Shellwords.escape(@session_key)}" if @session_key
265
- cmd
266
- end
267
-
268
- def build_mcp_config(mcp_servers)
269
- servers = mcp_servers.transform_keys { |k| k.to_s.gsub("_", "-") }
270
- {mcpServers: servers}
271
- end
272
-
273
- def parse_system_prompt(template_content, context_vars)
274
- if Dir.exist?(@sandbox_dir)
275
- Dir.chdir(@sandbox_dir) do
276
- parse_system_prompt_in_context(template_content, context_vars)
277
- end
278
- else
279
- parse_system_prompt_in_context(template_content, context_vars)
280
- end
281
- end
282
-
283
- def parse_system_prompt_in_context(template_content, context_vars)
284
- erb = ERB.new(template_content)
285
- binding_context = create_binding_context(**context_vars)
286
- result = erb.result(binding_context)
287
-
288
- if result.include?("<%=") || result.include?("%>")
289
- raise ParseError, "There was an error parsing the system prompt."
290
- end
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 => 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
- content = message.dig("message", "content")
392
- return unless content.is_a?(Array)
393
-
394
- content.each do |content_item|
395
- next unless content_item.is_a?(Hash)
396
-
397
- nested_type = content_item["type"]
398
- next unless nested_type
399
-
400
- callback = @dynamic_callbacks[nested_type]
401
- if callback
402
- puts "→ Triggering callback for nested type: #{nested_type}" if DEBUG
403
- callback.call(message, all_messages)
404
- end
405
- end
406
- end
407
-
408
- def trigger_custom_message_callbacks(message, all_messages)
409
- @custom_message_callbacks.each do |name, config|
410
- processor = config[:processor]
411
- callback = config[:callback]
412
-
413
- next unless processor && callback
414
-
415
- result = processor.call(message, all_messages)
416
- callback.call(result) if result && !result.to_s.empty?
417
- end
418
- 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"
419
12
 
420
- def trigger_error(error)
421
- @on_error_callback&.call(error)
13
+ module RubyAgent
14
+ class << self
15
+ attr_accessor :configuration
422
16
  end
423
17
 
424
- def stringify_keys(hash)
425
- hash.transform_keys(&:to_s)
18
+ def self.configure
19
+ self.configuration ||= Configuration.new
20
+ yield(configuration)
426
21
  end
427
22
  end
metadata CHANGED
@@ -1,31 +1,42 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Schacht
8
+ - Matt Lindsey
8
9
  bindir: exe
9
10
  cert_chain: []
10
11
  date: 1980-01-02 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: A framework for building AI agents in Ruby
13
14
  email:
14
- - keith@keithschacht.com
15
+ - krschacht@gmail.com
15
16
  executables: []
16
17
  extensions: []
17
18
  extra_rdoc_files: []
18
19
  files:
20
+ - ".rubocop.yml"
21
+ - Gemfile
19
22
  - LICENSE
20
23
  - README.md
24
+ - Rakefile
25
+ - examples/example1.rb
21
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
22
32
  - lib/ruby_agent/version.rb
23
- homepage: https://github.com/keithschacht/ruby-agent
33
+ homepage: https://github.com/AllYourBot/ruby-agent
24
34
  licenses:
25
35
  - MIT
26
36
  metadata:
27
- homepage_uri: https://github.com/keithschacht/ruby-agent
28
- source_code_uri: https://github.com/keithschacht/ruby-agent
37
+ homepage_uri: https://github.com/AllYourBot/ruby-agent
38
+ source_code_uri: https://github.com/AllYourBot/ruby-agent
39
+ rubygems_mfa_required: 'true'
29
40
  rdoc_options: []
30
41
  require_paths:
31
42
  - lib
@@ -33,14 +44,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
33
44
  requirements:
34
45
  - - ">="
35
46
  - !ruby/object:Gem::Version
36
- version: 2.7.0
47
+ version: 3.2.0
37
48
  required_rubygems_version: !ruby/object:Gem::Requirement
38
49
  requirements:
39
50
  - - ">="
40
51
  - !ruby/object:Gem::Version
41
52
  version: '0'
42
53
  requirements: []
43
- rubygems_version: 3.6.7
54
+ rubygems_version: 3.6.9
44
55
  specification_version: 4
45
56
  summary: Ruby agent framework
46
57
  test_files: []