anima-core 0.0.1 → 0.2.0
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/.reek.yml +18 -0
- data/CHANGELOG.md +36 -0
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +167 -22
- data/Rakefile +20 -5
- data/anima-core.gemspec +40 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/channels/session_channel.rb +126 -0
- data/app/controllers/api/sessions_controller.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/jobs/agent_request_job.rb +59 -0
- data/app/jobs/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +114 -0
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +35 -0
- data/config/boot.rb +8 -0
- data/config/cable.yml +14 -0
- data/config/database.yml +45 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/event_subscribers.rb +11 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/puma.rb +13 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +12 -0
- data/config.ru +5 -0
- data/db/cable_schema.rb +11 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/agent_loop.rb +97 -0
- data/lib/anima/cli.rb +110 -0
- data/lib/anima/installer.rb +119 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +5 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/action_cable_bridge.rb +35 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +56 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +173 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +239 -0
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +321 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +203 -6
- data/BRAINSTORM.md +0 -466
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "pty"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
# Persistent shell session backed by a PTY with FIFO-based stderr separation.
|
|
9
|
+
# Commands share working directory, environment variables, and shell history
|
|
10
|
+
# within a conversation. Multiple tools share the same session.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# session = ShellSession.new(session_id: 42)
|
|
14
|
+
# session.run("cd /tmp")
|
|
15
|
+
# session.run("pwd")
|
|
16
|
+
# # => {stdout: "/tmp", stderr: "", exit_code: 0}
|
|
17
|
+
# session.finalize
|
|
18
|
+
class ShellSession
|
|
19
|
+
COMMAND_TIMEOUT = 30
|
|
20
|
+
MAX_OUTPUT_BYTES = 100_000
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] current working directory of the shell process
|
|
23
|
+
attr_reader :pwd
|
|
24
|
+
|
|
25
|
+
# @param session_id [Integer, String] unique identifier for logging/diagnostics
|
|
26
|
+
def initialize(session_id:)
|
|
27
|
+
@session_id = session_id
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
30
|
+
@alive = false
|
|
31
|
+
@pwd = nil
|
|
32
|
+
self.class.cleanup_orphans
|
|
33
|
+
start
|
|
34
|
+
self.class.register(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute a command in the persistent shell.
|
|
38
|
+
#
|
|
39
|
+
# @param command [String] bash command to execute
|
|
40
|
+
# @return [Hash] with :stdout, :stderr, :exit_code keys on success
|
|
41
|
+
# @return [Hash] with :error key on failure
|
|
42
|
+
def run(command)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
return {error: "Shell session is not running"} unless @alive
|
|
45
|
+
execute_in_pty(command)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Clean up PTY, FIFO, and child process.
|
|
50
|
+
def finalize
|
|
51
|
+
@mutex.synchronize { shutdown }
|
|
52
|
+
self.class.unregister(self)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether the shell process is still running
|
|
56
|
+
def alive?
|
|
57
|
+
@mutex.synchronize { @alive }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- Class-level session tracking for at_exit cleanup ---
|
|
61
|
+
|
|
62
|
+
@sessions = []
|
|
63
|
+
@sessions_mutex = Mutex.new
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# @api private
|
|
67
|
+
def register(session)
|
|
68
|
+
@sessions_mutex.synchronize { @sessions << session }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @api private
|
|
72
|
+
def unregister(session)
|
|
73
|
+
@sessions_mutex.synchronize { @sessions.delete(session) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Finalize all live sessions. Called automatically via at_exit.
|
|
77
|
+
def cleanup_all
|
|
78
|
+
@sessions_mutex.synchronize do
|
|
79
|
+
@sessions.each { |session| session.send(:shutdown) }
|
|
80
|
+
@sessions.clear
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Remove stale FIFO files left by crashed processes.
|
|
85
|
+
# FIFO naming format: anima-stderr-{pid}-{hex}
|
|
86
|
+
def cleanup_orphans
|
|
87
|
+
Dir.glob(File.join(Dir.tmpdir, "anima-stderr-*")).each do |path|
|
|
88
|
+
match = File.basename(path).match(/\Aanima-stderr-(\d+)-/)
|
|
89
|
+
next unless match
|
|
90
|
+
|
|
91
|
+
pid = match[1].to_i
|
|
92
|
+
next if pid <= 0
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
Process.kill(0, pid)
|
|
96
|
+
rescue Errno::ESRCH
|
|
97
|
+
begin
|
|
98
|
+
File.delete(path)
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
# Best-effort cleanup
|
|
101
|
+
end
|
|
102
|
+
rescue Errno::EPERM
|
|
103
|
+
# Process exists but we can't signal it — leave it
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
at_exit { ShellSession.cleanup_all }
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def start
|
|
114
|
+
create_fifo
|
|
115
|
+
spawn_shell
|
|
116
|
+
start_stderr_reader
|
|
117
|
+
init_shell
|
|
118
|
+
update_pwd
|
|
119
|
+
@alive = true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_fifo
|
|
123
|
+
File.mkfifo(@fifo_path)
|
|
124
|
+
rescue Errno::EEXIST
|
|
125
|
+
# FIFO already exists — reuse it
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def spawn_shell
|
|
129
|
+
@pty_stdout, @pty_stdin, @pid = PTY.spawn(
|
|
130
|
+
{"TERM" => "dumb"},
|
|
131
|
+
"bash", "--norc", "--noprofile"
|
|
132
|
+
)
|
|
133
|
+
# Disable terminal echo via termios before bash can echo our commands.
|
|
134
|
+
# This is instant (kernel-level), unlike stty -echo which races with input.
|
|
135
|
+
@pty_stdin.echo = false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def start_stderr_reader
|
|
139
|
+
@stderr_mutex = Mutex.new
|
|
140
|
+
@stderr_buffer = []
|
|
141
|
+
@stderr_bytes = 0
|
|
142
|
+
@stderr_truncated = false
|
|
143
|
+
@stderr_thread = Thread.new do
|
|
144
|
+
File.open(@fifo_path, "r") do |fifo|
|
|
145
|
+
while (line = fifo.gets)
|
|
146
|
+
cleaned = line.chomp.delete("\r")
|
|
147
|
+
@stderr_mutex.synchronize do
|
|
148
|
+
if @stderr_bytes < MAX_OUTPUT_BYTES
|
|
149
|
+
@stderr_buffer << cleaned
|
|
150
|
+
@stderr_bytes += cleaned.bytesize
|
|
151
|
+
else
|
|
152
|
+
@stderr_truncated = true
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
rescue Errno::ENOENT, IOError
|
|
158
|
+
# FIFO was cleaned up or closed
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# With echo already off (set in spawn_shell), only command output appears.
|
|
163
|
+
# The initial bash prompt merges with the marker output on one gets line.
|
|
164
|
+
def init_shell
|
|
165
|
+
marker = "__ANIMA_INIT_#{SecureRandom.hex(8)}__"
|
|
166
|
+
@pty_stdin.puts "PS1=''"
|
|
167
|
+
@pty_stdin.puts "exec 2>#{@fifo_path}"
|
|
168
|
+
@pty_stdin.puts "echo '#{marker}'"
|
|
169
|
+
consume_until(marker)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def execute_in_pty(command)
|
|
173
|
+
clear_stderr
|
|
174
|
+
marker = "__ANIMA_#{SecureRandom.hex(8)}__"
|
|
175
|
+
|
|
176
|
+
Timeout.timeout(COMMAND_TIMEOUT) do
|
|
177
|
+
# All on one line: run command, capture exit code, ensure newline
|
|
178
|
+
# before marker so output without trailing newline doesn't merge.
|
|
179
|
+
@pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
|
|
180
|
+
|
|
181
|
+
stdout, exit_code = read_until_marker(marker)
|
|
182
|
+
update_pwd
|
|
183
|
+
stderr = drain_stderr
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
stdout: truncate(stdout),
|
|
187
|
+
stderr: truncate(stderr),
|
|
188
|
+
exit_code: exit_code
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
rescue Timeout::Error
|
|
192
|
+
recover_from_timeout
|
|
193
|
+
{error: "Command timed out after #{COMMAND_TIMEOUT} seconds"}
|
|
194
|
+
rescue Errno::EIO
|
|
195
|
+
@alive = false
|
|
196
|
+
{error: "Shell session terminated unexpectedly"}
|
|
197
|
+
rescue => error
|
|
198
|
+
{error: "#{error.class}: #{error.message}"}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def read_until_marker(marker)
|
|
202
|
+
lines = []
|
|
203
|
+
exit_code = nil
|
|
204
|
+
|
|
205
|
+
loop do
|
|
206
|
+
line = @pty_stdout.gets
|
|
207
|
+
break if line.nil?
|
|
208
|
+
|
|
209
|
+
line = line.chomp.delete("\r")
|
|
210
|
+
|
|
211
|
+
if line.include?(marker)
|
|
212
|
+
exit_code = line.split.last.to_i
|
|
213
|
+
break
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
lines << line
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Strip trailing empty line added by our separator echo
|
|
220
|
+
lines.pop if lines.last == ""
|
|
221
|
+
|
|
222
|
+
[lines.join("\n"), exit_code || -1]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def consume_until(marker)
|
|
226
|
+
loop do
|
|
227
|
+
line = @pty_stdout.gets
|
|
228
|
+
break if line.nil?
|
|
229
|
+
break if line.chomp.delete("\r").include?(marker)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Sends Ctrl+C to interrupt the running command and drains leftover output.
|
|
234
|
+
# If recovery fails, marks the session as dead.
|
|
235
|
+
def recover_from_timeout
|
|
236
|
+
@pty_stdin.write("\x03")
|
|
237
|
+
sleep 0.1
|
|
238
|
+
marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
|
|
239
|
+
@pty_stdin.puts "echo '#{marker}'"
|
|
240
|
+
Timeout.timeout(3) { consume_until(marker) }
|
|
241
|
+
rescue Timeout::Error, Errno::EIO, IOError
|
|
242
|
+
@alive = false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def clear_stderr
|
|
246
|
+
@stderr_mutex.synchronize do
|
|
247
|
+
@stderr_buffer.clear
|
|
248
|
+
@stderr_bytes = 0
|
|
249
|
+
@stderr_truncated = false
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def drain_stderr
|
|
254
|
+
# Allow FIFO reader thread time to flush kernel buffers into @stderr_buffer.
|
|
255
|
+
# Without this, stderr arriving just before the marker may be missed.
|
|
256
|
+
sleep 0.01
|
|
257
|
+
@stderr_mutex.synchronize do
|
|
258
|
+
result = @stderr_buffer.join("\n")
|
|
259
|
+
truncated = @stderr_truncated
|
|
260
|
+
@stderr_buffer.clear
|
|
261
|
+
@stderr_bytes = 0
|
|
262
|
+
@stderr_truncated = false
|
|
263
|
+
truncated ? result + "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]" : result
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Reads the shell's current working directory via the /proc filesystem.
|
|
268
|
+
# @note Linux-only. Falls back silently on other platforms or if the
|
|
269
|
+
# process has exited.
|
|
270
|
+
def update_pwd
|
|
271
|
+
@pwd = File.readlink("/proc/#{@pid}/cwd")
|
|
272
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
273
|
+
# Process exited or no access — @pwd retains its previous value
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def truncate(output)
|
|
277
|
+
return output if output.bytesize <= MAX_OUTPUT_BYTES
|
|
278
|
+
|
|
279
|
+
output.byteslice(0, MAX_OUTPUT_BYTES)
|
|
280
|
+
.force_encoding("UTF-8")
|
|
281
|
+
.scrub +
|
|
282
|
+
"\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def shutdown
|
|
286
|
+
return unless @alive
|
|
287
|
+
@alive = false
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
pgid = Process.getpgid(@pid)
|
|
291
|
+
Process.kill("TERM", -pgid)
|
|
292
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
293
|
+
# Process group already gone
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
@pty_stdin&.close
|
|
298
|
+
rescue IOError
|
|
299
|
+
# Already closed
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
begin
|
|
303
|
+
@pty_stdout&.close
|
|
304
|
+
rescue IOError
|
|
305
|
+
# Already closed
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
begin
|
|
309
|
+
@stderr_thread&.kill
|
|
310
|
+
rescue ThreadError
|
|
311
|
+
# Thread already dead
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
File.delete(@fifo_path) if File.exist?(@fifo_path)
|
|
315
|
+
|
|
316
|
+
begin
|
|
317
|
+
# Non-blocking reap with SIGKILL fallback if process doesn't exit in time
|
|
318
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
319
|
+
loop do
|
|
320
|
+
_, status = Process.wait2(@pid, Process::WNOHANG)
|
|
321
|
+
break if status
|
|
322
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
323
|
+
Process.kill("KILL", @pid)
|
|
324
|
+
Process.wait(@pid)
|
|
325
|
+
break
|
|
326
|
+
end
|
|
327
|
+
sleep 0.05
|
|
328
|
+
end
|
|
329
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
330
|
+
# Already reaped
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
data/lib/tools/base.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Abstract base class for all Anima tools. Subclasses must implement
|
|
5
|
+
# the class-level schema methods and the instance-level {#execute} method.
|
|
6
|
+
#
|
|
7
|
+
# @abstract Subclass and implement {.tool_name}, {.description},
|
|
8
|
+
# {.input_schema}, and {#execute}
|
|
9
|
+
#
|
|
10
|
+
# @example Implementing a tool
|
|
11
|
+
# class Tools::Echo < Tools::Base
|
|
12
|
+
# def self.tool_name = "echo"
|
|
13
|
+
# def self.description = "Echoes input back"
|
|
14
|
+
# def self.input_schema
|
|
15
|
+
# {type: "object", properties: {text: {type: "string"}}, required: ["text"]}
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def execute(input)
|
|
19
|
+
# input["text"]
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
class Base
|
|
23
|
+
class << self
|
|
24
|
+
# @return [String] unique tool identifier sent to the LLM
|
|
25
|
+
def tool_name
|
|
26
|
+
raise NotImplementedError, "#{self} must implement .tool_name"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String] human-readable description for the LLM
|
|
30
|
+
def description
|
|
31
|
+
raise NotImplementedError, "#{self} must implement .description"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash] JSON Schema describing the tool's input parameters
|
|
35
|
+
def input_schema
|
|
36
|
+
raise NotImplementedError, "#{self} must implement .input_schema"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds the schema hash expected by the Anthropic tools API.
|
|
40
|
+
# @return [Hash] with :name, :description, and :input_schema keys
|
|
41
|
+
def schema
|
|
42
|
+
{name: tool_name, description: description, input_schema: input_schema}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Accepts and discards context keywords so that the Registry can pass
|
|
47
|
+
# shared dependencies (e.g. shell_session) to any tool uniformly.
|
|
48
|
+
# Subclasses that need specific context should override with named kwargs.
|
|
49
|
+
def initialize(**) = nil
|
|
50
|
+
|
|
51
|
+
# Execute the tool with the given input.
|
|
52
|
+
# @param input [Hash] parsed input matching {.input_schema}
|
|
53
|
+
# @return [String, Hash] result content; Hash with :error key signals failure
|
|
54
|
+
def execute(input)
|
|
55
|
+
raise NotImplementedError, "#{self.class} must implement #execute"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/tools/bash.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
# Executes bash commands in a persistent {ShellSession}. Commands share
|
|
5
|
+
# working directory, environment variables, and shell history within a
|
|
6
|
+
# conversation. Output is truncated and timeouts are enforced by the
|
|
7
|
+
# underlying session.
|
|
8
|
+
#
|
|
9
|
+
# @see ShellSession#run
|
|
10
|
+
class Bash < Base
|
|
11
|
+
def self.tool_name = "bash"
|
|
12
|
+
|
|
13
|
+
def self.description = "Execute a bash command. Working directory and environment persist across calls within a conversation."
|
|
14
|
+
|
|
15
|
+
def self.input_schema
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
command: {type: "string", description: "The bash command to execute"}
|
|
20
|
+
},
|
|
21
|
+
required: ["command"]
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param shell_session [ShellSession] persistent shell backing this tool
|
|
26
|
+
def initialize(shell_session:, **)
|
|
27
|
+
@shell_session = shell_session
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
31
|
+
# @return [String] formatted output with stdout, stderr, and exit code
|
|
32
|
+
# @return [Hash] with :error key on failure
|
|
33
|
+
def execute(input)
|
|
34
|
+
command = input["command"].to_s
|
|
35
|
+
return {error: "Command cannot be blank"} if command.strip.empty?
|
|
36
|
+
|
|
37
|
+
result = @shell_session.run(command)
|
|
38
|
+
return result if result.key?(:error)
|
|
39
|
+
|
|
40
|
+
format_result(result[:stdout], result[:stderr], result[:exit_code])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def format_result(stdout, stderr, exit_code)
|
|
46
|
+
parts = []
|
|
47
|
+
parts << "stdout:\n#{stdout}" unless stdout.empty?
|
|
48
|
+
parts << "stderr:\n#{stderr}" unless stderr.empty?
|
|
49
|
+
parts << "exit_code: #{exit_code}"
|
|
50
|
+
parts.join("\n\n")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tools
|
|
4
|
+
class UnknownToolError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Manages tool registration, schema export, and dispatch.
|
|
7
|
+
# Tools are registered by class and looked up by name at execution time.
|
|
8
|
+
# An optional context hash is passed to each tool's constructor, allowing
|
|
9
|
+
# shared dependencies (e.g. a {ShellSession}) to reach tools that need them.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# registry = Tools::Registry.new(context: {shell_session: my_shell})
|
|
13
|
+
# registry.register(Tools::Bash)
|
|
14
|
+
# registry.execute("bash", {"command" => "ls"})
|
|
15
|
+
class Registry
|
|
16
|
+
# @return [Hash{String => Class}] registered tool classes keyed by name
|
|
17
|
+
attr_reader :tools
|
|
18
|
+
|
|
19
|
+
# @param context [Hash] keyword arguments forwarded to every tool constructor
|
|
20
|
+
def initialize(context: {})
|
|
21
|
+
@tools = {}
|
|
22
|
+
@context = context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register a tool class. The class must respond to .tool_name.
|
|
26
|
+
# @param tool_class [Class<Tools::Base>] the tool class to register
|
|
27
|
+
# @return [void]
|
|
28
|
+
def register(tool_class)
|
|
29
|
+
@tools[tool_class.tool_name] = tool_class
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<Hash>] schema array for the Anthropic tools API parameter
|
|
33
|
+
def schemas
|
|
34
|
+
@tools.values.map(&:schema)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Instantiate and execute a tool by name. The registry's context is
|
|
38
|
+
# forwarded to the tool constructor as keyword arguments.
|
|
39
|
+
#
|
|
40
|
+
# @param name [String] registered tool name
|
|
41
|
+
# @param input [Hash] tool input parameters
|
|
42
|
+
# @return [String, Hash] tool execution result
|
|
43
|
+
# @raise [UnknownToolError] if no tool is registered with the given name
|
|
44
|
+
def execute(name, input)
|
|
45
|
+
tool_class = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
|
|
46
|
+
tool_class.new(**@context).execute(input)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param name [String] tool name to check
|
|
50
|
+
# @return [Boolean] whether a tool with the given name is registered
|
|
51
|
+
def registered?(name)
|
|
52
|
+
@tools.key?(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] whether any tools are registered
|
|
56
|
+
def any?
|
|
57
|
+
@tools.any?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module Tools
|
|
6
|
+
# Fetches content from a URL via HTTP GET. Returns the response body
|
|
7
|
+
# as plain text, truncated to {MAX_RESPONSE_BYTES} to prevent memory issues.
|
|
8
|
+
#
|
|
9
|
+
# Only http and https schemes are allowed.
|
|
10
|
+
class WebGet < Base
|
|
11
|
+
MAX_RESPONSE_BYTES = 100_000
|
|
12
|
+
REQUEST_TIMEOUT = 10
|
|
13
|
+
|
|
14
|
+
def self.tool_name = "web_get"
|
|
15
|
+
|
|
16
|
+
def self.description = "Fetch content from a URL via HTTP GET and return the response body"
|
|
17
|
+
|
|
18
|
+
def self.input_schema
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
url: {type: "string", description: "The URL to fetch (http or https)"}
|
|
23
|
+
},
|
|
24
|
+
required: ["url"]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
|
|
29
|
+
# @return [String] response body (possibly truncated)
|
|
30
|
+
# @return [Hash] with :error key on failure
|
|
31
|
+
def execute(input)
|
|
32
|
+
validate_and_fetch(input["url"].to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate_and_fetch(url)
|
|
38
|
+
scheme = URI.parse(url).scheme
|
|
39
|
+
|
|
40
|
+
unless %w[http https].include?(scheme)
|
|
41
|
+
return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
truncate_body(HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false).body.to_s)
|
|
45
|
+
rescue URI::InvalidURIError => error
|
|
46
|
+
{error: "Invalid URL: #{error.message}"}
|
|
47
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
48
|
+
{error: "Request timed out after #{REQUEST_TIMEOUT} seconds"}
|
|
49
|
+
rescue Errno::ECONNREFUSED
|
|
50
|
+
{error: "Connection refused: #{url}"}
|
|
51
|
+
rescue => error
|
|
52
|
+
{error: "#{error.class}: #{error.message}"}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def truncate_body(body)
|
|
56
|
+
return body if body.bytesize <= MAX_RESPONSE_BYTES
|
|
57
|
+
|
|
58
|
+
body.byteslice(0, MAX_RESPONSE_BYTES) +
|
|
59
|
+
"\n\n[Truncated: response exceeded #{MAX_RESPONSE_BYTES} bytes]"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|