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 +4 -4
- data/.rubocop.yml +58 -0
- data/Gemfile +13 -0
- data/README.md +68 -20
- data/Rakefile +40 -0
- data/examples/example1.rb +105 -0
- data/lib/ruby_agent/agent.rb +260 -0
- data/lib/ruby_agent/callback_support.rb +71 -0
- data/lib/ruby_agent/configuration.rb +12 -0
- data/lib/ruby_agent/event.rb +9 -0
- data/lib/ruby_agent/response.rb +24 -0
- data/lib/ruby_agent/version.rb +2 -2
- data/lib/ruby_agent.rb +13 -418
- metadata +18 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1cc2c50708573c25ab812c6e10096d6be6aaea4580562a19951c90c35f8c387
|
|
4
|
+
data.tar.gz: 7857009a269597f17bdb800d06fe638626e704f4a57995a24594f5c3223183b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
42
|
-
|
|
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,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
|
data/lib/ruby_agent/version.rb
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
VERSION = "0.2.
|
|
1
|
+
module RubyAgent
|
|
2
|
+
VERSION = "0.2.3".freeze
|
|
3
3
|
end
|
data/lib/ruby_agent.rb
CHANGED
|
@@ -1,427 +1,22 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
421
|
-
|
|
13
|
+
module RubyAgent
|
|
14
|
+
class << self
|
|
15
|
+
attr_accessor :configuration
|
|
422
16
|
end
|
|
423
17
|
|
|
424
|
-
def
|
|
425
|
-
|
|
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.
|
|
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
|
-
-
|
|
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/
|
|
33
|
+
homepage: https://github.com/AllYourBot/ruby-agent
|
|
24
34
|
licenses:
|
|
25
35
|
- MIT
|
|
26
36
|
metadata:
|
|
27
|
-
homepage_uri: https://github.com/
|
|
28
|
-
source_code_uri: https://github.com/
|
|
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.
|
|
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.
|
|
54
|
+
rubygems_version: 3.6.9
|
|
44
55
|
specification_version: 4
|
|
45
56
|
summary: Ruby agent framework
|
|
46
57
|
test_files: []
|