ruby_agent 0.2.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +210 -0
- data/lib/ruby_agent/version.rb +3 -0
- data/lib/ruby_agent.rb +427 -0
- metadata +46 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '039e385b0c47944839ae33f1b8e4ed75e499803ae06538e9da4c3a9899dbe247'
|
|
4
|
+
data.tar.gz: 6726c84e2a9fbb9f795300dbb8995fba831bb989540681ce35c3b6604c9c6b35
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 787bdc5703131d4169ca2fb53175ce3cc920330fd398e34c881ab2fbf4bfdf73a360a3459a60891fbdd05bb4891a7ff41f74efca715ef126164e63a128daf5ef
|
|
7
|
+
data.tar.gz: 0c273eb33000ddc93e065a3b1d43483012012fa350e67bcae2f96def1d882d3f7e2846659fbca4287b5b0e280201023c0469cccfeec72b8282a0d218d831854c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Keith Schacht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# RubyAgent
|
|
2
|
+
|
|
3
|
+
A Ruby framework for building AI agents powered by Claude Code. This gem provides a simple, event-driven interface to interact with Claude through the Claude Code CLI.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Before using RubyAgent, you need to install Claude Code CLI:
|
|
8
|
+
|
|
9
|
+
### macOS
|
|
10
|
+
```bash
|
|
11
|
+
curl -fsSL https://claude.ai/install.sh | bash
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Windows
|
|
15
|
+
```powershell
|
|
16
|
+
irm https://claude.ai/install.ps1 | iex
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For more information, visit the [Claude Code documentation](https://www.claude.com/product/claude-code).
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install ruby_agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or add to your Gemfile:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
gem 'ruby_agent'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Basic Example
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require 'ruby_agent'
|
|
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
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Advanced Example with Callbacks
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
require 'ruby_agent'
|
|
66
|
+
|
|
67
|
+
agent = RubyAgent.new(
|
|
68
|
+
sandbox_dir: Dir.pwd,
|
|
69
|
+
system_prompt: "You are a helpful coding assistant",
|
|
70
|
+
model: "claude-sonnet-4-5-20250929",
|
|
71
|
+
verbose: true
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
agent.create_message_callback :assistant_text do |event, all_events|
|
|
75
|
+
if event["type"] == "assistant" && event.dig("message", "content", 0, "type") == "text"
|
|
76
|
+
event.dig("message", "content", 0, "text")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
agent.on_system_init do |event, _|
|
|
81
|
+
puts "Session started: #{event['session_id']}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
agent.on_assistant_text do |text|
|
|
85
|
+
puts "Claude says: #{text}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
agent.on_result do |event, all_events|
|
|
89
|
+
if event["subtype"] == "success"
|
|
90
|
+
puts "Task completed successfully!"
|
|
91
|
+
agent.exit
|
|
92
|
+
elsif event["subtype"] == "error_occurred"
|
|
93
|
+
puts "Error: #{event['result']}"
|
|
94
|
+
agent.exit
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
agent.on_error do |error|
|
|
99
|
+
puts "Error occurred: #{error.message}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
agent.connect do
|
|
103
|
+
agent.ask("Write a simple Hello World function in Ruby", sender_name: "User")
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Resuming Sessions
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
agent = RubyAgent.new(
|
|
111
|
+
session_key: "existing_session_123",
|
|
112
|
+
system_prompt: "You are a helpful assistant"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
agent.connect do
|
|
116
|
+
agent.ask("Continue from where we left off", sender_name: "User")
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Using ERB in System Prompts
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
agent = RubyAgent.new(
|
|
124
|
+
system_prompt: "You are <%= role %> working on <%= project_name %>",
|
|
125
|
+
role: "a senior developer",
|
|
126
|
+
project_name: "RubyAgent"
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Event Callbacks
|
|
131
|
+
|
|
132
|
+
RubyAgent supports dynamic event callbacks using `method_missing`. You can create callbacks for any event type:
|
|
133
|
+
|
|
134
|
+
- `on_message` (alias: `on_event`) - Triggered for every message
|
|
135
|
+
- `on_assistant` - Triggered when Claude responds
|
|
136
|
+
- `on_system_init` - Triggered when a session starts
|
|
137
|
+
- `on_result` - Triggered when a task completes
|
|
138
|
+
- `on_error` - Triggered when an error occurs
|
|
139
|
+
- `on_tool_use` - Triggered when Claude uses a tool
|
|
140
|
+
- `on_tool_result` - Triggered when a tool returns results
|
|
141
|
+
|
|
142
|
+
You can also create custom callbacks with specific subtypes like `on_system_init`, `on_error_timeout`, etc.
|
|
143
|
+
|
|
144
|
+
## Custom Message Callbacks
|
|
145
|
+
|
|
146
|
+
Create custom message processors that filter and transform events:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
agent.create_message_callback :important_messages do |message, all_messages|
|
|
150
|
+
if message["type"] == "assistant"
|
|
151
|
+
message.dig("message", "content", 0, "text")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
agent.on_important_messages do |text|
|
|
156
|
+
puts "Important: #{text}"
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
### RubyAgent.new(options)
|
|
163
|
+
|
|
164
|
+
Creates a new RubyAgent instance.
|
|
165
|
+
|
|
166
|
+
**Options:**
|
|
167
|
+
- `sandbox_dir` (String) - Working directory for the agent (default: `Dir.pwd`)
|
|
168
|
+
- `timezone` (String) - Timezone for the agent (default: `"UTC"`)
|
|
169
|
+
- `skip_permissions` (Boolean) - Skip permission prompts (default: `true`)
|
|
170
|
+
- `verbose` (Boolean) - Enable verbose output (default: `false`)
|
|
171
|
+
- `system_prompt` (String) - System prompt for Claude (default: `"You are a helpful assistant"`)
|
|
172
|
+
- `model` (String) - Claude model to use (default: `"claude-sonnet-4-5-20250929"`)
|
|
173
|
+
- `mcp_servers` (Hash) - MCP server configuration (default: `nil`)
|
|
174
|
+
- `session_key` (String) - Resume an existing session (default: `nil`)
|
|
175
|
+
- Additional keyword arguments are passed to the ERB template in `system_prompt`
|
|
176
|
+
|
|
177
|
+
### Instance Methods
|
|
178
|
+
|
|
179
|
+
- `connect(&block)` - Connect to Claude and execute the block
|
|
180
|
+
- `ask(text, sender_name: "User", additional: [])` - Send a message to Claude
|
|
181
|
+
- `send_system_message(text)` - Send a system message
|
|
182
|
+
- `interrupt` - Interrupt Claude's current operation
|
|
183
|
+
- `exit` - Close the connection to Claude
|
|
184
|
+
- `on_message(&block)` - Register a callback for all messages
|
|
185
|
+
- `on_error(&block)` - Register a callback for errors
|
|
186
|
+
- Dynamic `on_*` methods for specific event types
|
|
187
|
+
|
|
188
|
+
## Error Handling
|
|
189
|
+
|
|
190
|
+
RubyAgent defines three error types:
|
|
191
|
+
|
|
192
|
+
- `RubyAgent::AgentError` - Base error class
|
|
193
|
+
- `RubyAgent::ConnectionError` - Connection-related errors
|
|
194
|
+
- `RubyAgent::ParseError` - System prompt parsing errors
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
begin
|
|
198
|
+
agent.connect do
|
|
199
|
+
agent.ask("Hello", sender_name: "User")
|
|
200
|
+
end
|
|
201
|
+
rescue RubyAgent::ConnectionError => e
|
|
202
|
+
puts "Connection failed: #{e.message}"
|
|
203
|
+
rescue RubyAgent::AgentError => e
|
|
204
|
+
puts "Agent error: #{e.message}"
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
data/lib/ruby_agent.rb
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
require_relative "ruby_agent/version"
|
|
2
|
+
require "shellwords"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
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
|
|
419
|
+
|
|
420
|
+
def trigger_error(error)
|
|
421
|
+
@on_error_callback&.call(error)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def stringify_keys(hash)
|
|
425
|
+
hash.transform_keys(&:to_s)
|
|
426
|
+
end
|
|
427
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_agent
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Keith Schacht
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A framework for building AI agents in Ruby
|
|
13
|
+
email:
|
|
14
|
+
- keith@keithschacht.com
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/ruby_agent.rb
|
|
22
|
+
- lib/ruby_agent/version.rb
|
|
23
|
+
homepage: https://github.com/keithschacht/ruby-agent
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
homepage_uri: https://github.com/keithschacht/ruby-agent
|
|
28
|
+
source_code_uri: https://github.com/keithschacht/ruby-agent
|
|
29
|
+
rdoc_options: []
|
|
30
|
+
require_paths:
|
|
31
|
+
- lib
|
|
32
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
33
|
+
requirements:
|
|
34
|
+
- - ">="
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: 2.7.0
|
|
37
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '0'
|
|
42
|
+
requirements: []
|
|
43
|
+
rubygems_version: 3.6.7
|
|
44
|
+
specification_version: 4
|
|
45
|
+
summary: Ruby agent framework
|
|
46
|
+
test_files: []
|