ruby_llm-toolbox 0.1.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 +7 -0
- data/CHANGELOG.md +49 -0
- data/GUIDE.md +598 -0
- data/LICENSE +21 -0
- data/README.md +412 -0
- data/bin/verify_prism_parity +112 -0
- data/lib/ruby_llm/toolbox/base.rb +112 -0
- data/lib/ruby_llm/toolbox/configuration.rb +148 -0
- data/lib/ruby_llm/toolbox/data_path.rb +54 -0
- data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
- data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
- data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
- data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
- data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
- data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
- data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
- data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
- data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
- data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
- data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
- data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
- data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
- data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
- data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
- data/lib/ruby_llm/toolbox/toml.rb +409 -0
- data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
- data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
- data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
- data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
- data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
- data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
- data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
- data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
- data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
- data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
- data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
- data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
- data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
- data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
- data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
- data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
- data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
- data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
- data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
- data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
- data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
- data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
- data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
- data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
- data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
- data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
- data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
- data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
- data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
- data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
- data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
- data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
- data/lib/ruby_llm/toolbox/truncator.rb +68 -0
- data/lib/ruby_llm/toolbox/version.rb +7 -0
- data/lib/ruby_llm/toolbox.rb +161 -0
- metadata +194 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "ruby_llm/toolbox/base"
|
|
5
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# EXEC. Moves or renames a file or directory within fs_root. BOTH the
|
|
11
|
+
# source and destination are confined to the jail, so nothing can be moved
|
|
12
|
+
# in from or out to outside the root. Refuses to clobber an existing
|
|
13
|
+
# destination unless overwrite is set.
|
|
14
|
+
class MoveFile < Base
|
|
15
|
+
exec_tool!
|
|
16
|
+
|
|
17
|
+
description "Move or rename a file or directory within fs_root. Both source and destination " \
|
|
18
|
+
"must be inside the jail. Missing parent directories of the destination are " \
|
|
19
|
+
"created. Will not overwrite an existing destination unless overwrite is true."
|
|
20
|
+
|
|
21
|
+
param :source, type: "string",
|
|
22
|
+
desc: "Existing path to move, relative to fs_root.",
|
|
23
|
+
required: true
|
|
24
|
+
param :destination, type: "string",
|
|
25
|
+
desc: "Target path, relative to fs_root.",
|
|
26
|
+
required: true
|
|
27
|
+
param :overwrite, type: "boolean",
|
|
28
|
+
desc: "Replace the destination if it already exists. Default false.",
|
|
29
|
+
required: false
|
|
30
|
+
|
|
31
|
+
def execute(source:, destination:, overwrite: false)
|
|
32
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
33
|
+
from = jail.resolve(source)
|
|
34
|
+
to = jail.resolve(destination)
|
|
35
|
+
|
|
36
|
+
return error("source does not exist: #{source}", code: :not_found) unless File.exist?(from)
|
|
37
|
+
if File.directory?(to) && !File.directory?(from)
|
|
38
|
+
return error("destination is a directory: #{destination}", code: :is_a_directory)
|
|
39
|
+
end
|
|
40
|
+
if File.exist?(to) && !overwrite
|
|
41
|
+
return error("destination already exists: #{destination} (set overwrite to replace)",
|
|
42
|
+
code: :exists)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(to))
|
|
46
|
+
FileUtils.mv(from, to, force: overwrite)
|
|
47
|
+
"Moved #{source} -> #{destination}"
|
|
48
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
49
|
+
error(e.message, code: :path_denied)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Applies several string replacements to one file in a single,
|
|
10
|
+
# atomic operation. Edits are applied in order (a later edit sees the
|
|
11
|
+
# result of earlier ones), each following edit_file's rules: old_string
|
|
12
|
+
# must match exactly once unless replace_all is set. If any edit can't be
|
|
13
|
+
# applied, nothing is written and the failing edit is reported — so the
|
|
14
|
+
# file is never left half-edited.
|
|
15
|
+
class MultiEdit < Base
|
|
16
|
+
class BadEdit < StandardError; end
|
|
17
|
+
|
|
18
|
+
exec_tool!
|
|
19
|
+
|
|
20
|
+
description "Apply multiple find/replace edits to a single file atomically. Provide edits as an " \
|
|
21
|
+
"array of objects with old_string, new_string, and optional replace_all. Edits run " \
|
|
22
|
+
"in order; if any fails to apply cleanly, no changes are written."
|
|
23
|
+
|
|
24
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
param :path, type: "string",
|
|
27
|
+
desc: "File to edit, relative to fs_root.",
|
|
28
|
+
required: true
|
|
29
|
+
param :edits, type: "array",
|
|
30
|
+
desc: "Array of edits, each an object: { old_string, new_string, replace_all? }. " \
|
|
31
|
+
"Applied in order.",
|
|
32
|
+
required: true
|
|
33
|
+
|
|
34
|
+
def execute(path:, edits:)
|
|
35
|
+
return error("edits must be a non-empty array", code: :bad_edits) unless edits.is_a?(Array) && !edits.empty?
|
|
36
|
+
|
|
37
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
38
|
+
real = jail.resolve(path)
|
|
39
|
+
return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
|
|
40
|
+
return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
|
|
41
|
+
|
|
42
|
+
original = File.read(real).scrub
|
|
43
|
+
content = original
|
|
44
|
+
total = 0
|
|
45
|
+
|
|
46
|
+
edits.each_with_index do |edit, i|
|
|
47
|
+
content, n = apply_edit(content, edit, i + 1, path)
|
|
48
|
+
total += n
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
File.write(real, content)
|
|
52
|
+
"Applied #{edits.size} edit#{edits.size == 1 ? '' : 's'} to #{path} " \
|
|
53
|
+
"(#{total} replacement#{total == 1 ? '' : 's'}, #{original.bytesize} -> #{content.bytesize} bytes)"
|
|
54
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
55
|
+
error(e.message, code: :path_denied)
|
|
56
|
+
rescue BadEdit => e
|
|
57
|
+
error(e.message, code: :edit_failed)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def apply_edit(content, edit, index, path)
|
|
63
|
+
old_s = fetch(edit, "old_string").to_s
|
|
64
|
+
raise BadEdit, "edit #{index}: new_string is required" unless has_key?(edit, "new_string")
|
|
65
|
+
|
|
66
|
+
new_s = fetch(edit, "new_string").to_s
|
|
67
|
+
replace_all = [true, "true"].include?(fetch(edit, "replace_all"))
|
|
68
|
+
|
|
69
|
+
raise BadEdit, "edit #{index}: old_string must not be empty" if old_s.empty?
|
|
70
|
+
raise BadEdit, "edit #{index}: old_string and new_string are identical" if old_s == new_s
|
|
71
|
+
|
|
72
|
+
if replace_all
|
|
73
|
+
count = 0
|
|
74
|
+
updated = content.gsub(old_s) { count += 1; new_s }
|
|
75
|
+
raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
|
|
76
|
+
|
|
77
|
+
[updated, count]
|
|
78
|
+
else
|
|
79
|
+
count = content.scan(Regexp.new(Regexp.escape(old_s))).size
|
|
80
|
+
raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
|
|
81
|
+
if count > 1
|
|
82
|
+
raise BadEdit, "edit #{index}: old_string is ambiguous (#{count} matches in #{path}); " \
|
|
83
|
+
"add surrounding context or set replace_all"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
[content.sub(old_s) { new_s }, 1]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def indifferent_key(edit, key)
|
|
91
|
+
return nil unless edit.is_a?(Hash)
|
|
92
|
+
return key if edit.key?(key)
|
|
93
|
+
return key.to_sym if edit.key?(key.to_sym)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fetch(edit, key)
|
|
97
|
+
k = indifferent_key(edit, key)
|
|
98
|
+
k ? edit[k] : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def has_key?(edit, key)
|
|
102
|
+
!indifferent_key(edit, key).nil?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/ruby_outline"
|
|
5
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# SAFE. Produces a structural outline of a Ruby file (classes, modules,
|
|
11
|
+
# methods, constants) with line numbers, or finds where a name is defined.
|
|
12
|
+
# In-process via Ripper — it parses, never executes, the code.
|
|
13
|
+
class ParseRuby < Base
|
|
14
|
+
description "Outline a Ruby file's structure (classes, modules, methods, constants with line " \
|
|
15
|
+
"numbers), or locate definitions. Give a query to find definitions whose name " \
|
|
16
|
+
"matches, and/or a kind to filter. Parses in-process; does not run the code."
|
|
17
|
+
|
|
18
|
+
KINDS = %w[class module method constant].freeze
|
|
19
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
param :path, type: "string",
|
|
22
|
+
desc: "Ruby file to outline, relative to fs_root.",
|
|
23
|
+
required: true
|
|
24
|
+
param :query, type: "string",
|
|
25
|
+
desc: "Only show definitions whose (qualified) name matches this, case-insensitive. Optional.",
|
|
26
|
+
required: false
|
|
27
|
+
param :kind, type: "string",
|
|
28
|
+
desc: "Filter to a kind: class, module, method, or constant. Optional.",
|
|
29
|
+
required: false
|
|
30
|
+
|
|
31
|
+
def execute(path:, query: nil, kind: nil)
|
|
32
|
+
kind = kind.to_s.strip.downcase
|
|
33
|
+
kind = nil if kind.empty?
|
|
34
|
+
return error("unknown kind: #{kind} (use #{KINDS.join(', ')})", code: :bad_kind) if kind && !KINDS.include?(kind)
|
|
35
|
+
|
|
36
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
37
|
+
real = jail.resolve(path)
|
|
38
|
+
return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
|
|
39
|
+
return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
|
|
40
|
+
|
|
41
|
+
entries = RubyOutline.extract(File.read(real).scrub)
|
|
42
|
+
return "no definitions found in #{path}" if entries.empty?
|
|
43
|
+
|
|
44
|
+
truncate(render(entries, path, query, kind))
|
|
45
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
46
|
+
error(e.message, code: :path_denied)
|
|
47
|
+
rescue RubyOutline::ParseError => e
|
|
48
|
+
error("#{e.message} in #{path}", code: :parse_error)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def render(entries, path, query, kind)
|
|
54
|
+
if query || kind
|
|
55
|
+
flat_list(entries, path, query, kind)
|
|
56
|
+
else
|
|
57
|
+
"Outline of #{path}:\n#{tree(entries)}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tree(entries)
|
|
62
|
+
entries.map { |e| "#{' ' * e.depth}#{label(e)} (L#{e.line})" }.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def flat_list(entries, path, query, kind)
|
|
66
|
+
pairs = qualify(entries)
|
|
67
|
+
pairs = pairs.select { |e, _| e.kind.to_s == kind } if kind
|
|
68
|
+
if query
|
|
69
|
+
q = query.downcase
|
|
70
|
+
pairs = pairs.select { |e, qn| qn.downcase.include?(q) || e.name.downcase.include?(q) }
|
|
71
|
+
end
|
|
72
|
+
return "no matching definitions in #{path}" if pairs.empty?
|
|
73
|
+
|
|
74
|
+
desc = ["definitions", ("matching #{query.inspect}" if query), ("of kind #{kind}" if kind)].compact.join(" ")
|
|
75
|
+
header = "#{pairs.size} #{desc} in #{path}:"
|
|
76
|
+
"#{header}\n#{pairs.map { |e, qn| "#{qn} (L#{e.line}) [#{e.kind}]" }.join("\n")}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Pair each entry with its fully-qualified name using the depth stack.
|
|
80
|
+
def qualify(entries)
|
|
81
|
+
namespace = []
|
|
82
|
+
entries.map do |entry|
|
|
83
|
+
namespace = namespace.first(entry.depth)
|
|
84
|
+
ns = namespace.compact
|
|
85
|
+
qualified =
|
|
86
|
+
case entry.kind
|
|
87
|
+
when :class, :module, :singleton_class
|
|
88
|
+
full = (ns + [entry.name]).join("::")
|
|
89
|
+
namespace[entry.depth] = entry.name
|
|
90
|
+
full
|
|
91
|
+
when :method
|
|
92
|
+
ns.empty? ? entry.name : "#{ns.join('::')}##{entry.name}"
|
|
93
|
+
else # constant
|
|
94
|
+
ns.empty? ? entry.name : "#{ns.join('::')}::#{entry.name}"
|
|
95
|
+
end
|
|
96
|
+
[entry, qualified]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def label(entry)
|
|
101
|
+
case entry.kind
|
|
102
|
+
when :class, :singleton_class then "class #{entry.name}"
|
|
103
|
+
when :module then "module #{entry.name}"
|
|
104
|
+
when :method then "def #{entry.name}"
|
|
105
|
+
else entry.name
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/process_registry"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# SAFE. Stops a background process: SIGTERM to its process group, escalating
|
|
10
|
+
# to SIGKILL if it doesn't exit, then returns any final output. Always
|
|
11
|
+
# available (even if exec tools are later disabled) so a runaway can always
|
|
12
|
+
# be stopped. Removes the process from the registry.
|
|
13
|
+
class ProcessKill < Base
|
|
14
|
+
description "Stop a background process (started with process_start) and return any final output. " \
|
|
15
|
+
"Terminates the whole process group, escalating to SIGKILL if needed."
|
|
16
|
+
|
|
17
|
+
param :id, type: "string",
|
|
18
|
+
desc: "The process id to kill (e.g. 'proc_1').",
|
|
19
|
+
required: true
|
|
20
|
+
|
|
21
|
+
def execute(id:)
|
|
22
|
+
proc = ProcessRegistry.get(id)
|
|
23
|
+
return error("no such process: #{id}", code: :not_found) unless proc
|
|
24
|
+
|
|
25
|
+
was_running = proc.running?
|
|
26
|
+
proc.kill
|
|
27
|
+
final = proc.read_new
|
|
28
|
+
ProcessRegistry.delete(id)
|
|
29
|
+
|
|
30
|
+
body = +"#{id} #{was_running ? 'terminated' : 'was already exited'}"
|
|
31
|
+
body << " (exit #{proc.exit_code})" if proc.exit_code
|
|
32
|
+
unless final[:out].empty? && final[:err].empty?
|
|
33
|
+
body << "\n--- final stdout ---\n#{final[:out]}" unless final[:out].empty?
|
|
34
|
+
body << "\n--- final stderr ---\n#{final[:err]}" unless final[:err].empty?
|
|
35
|
+
end
|
|
36
|
+
truncate(body)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/process_registry"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# SAFE. Lists the background processes started this session, with their id,
|
|
10
|
+
# status, pid, age, and command — so an agent can rediscover ids and see
|
|
11
|
+
# what's still running.
|
|
12
|
+
class ProcessList < Base
|
|
13
|
+
description "List background processes (started with process_start): id, status, pid, age, and " \
|
|
14
|
+
"command. Read-only."
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
procs = ProcessRegistry.all
|
|
18
|
+
return "no background processes" if procs.empty?
|
|
19
|
+
|
|
20
|
+
lines = procs.map do |proc|
|
|
21
|
+
status = proc.status == :exited ? "exited(#{proc.exit_code})" : "running"
|
|
22
|
+
"#{proc.id} #{status.ljust(12)} pid=#{proc.pid} age=#{proc.age.round}s #{proc.name} #{proc.argv.inspect}"
|
|
23
|
+
end
|
|
24
|
+
truncate(([+"#{procs.size} process#{procs.size == 1 ? '' : 'es'}:"] + lines).join("\n"))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/process_registry"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# SAFE. Returns the output a background process has produced since the last
|
|
10
|
+
# read, plus its current status (running, or exited with a code). Reads are
|
|
11
|
+
# incremental, so polling in a loop streams the process's output without
|
|
12
|
+
# repeats.
|
|
13
|
+
class ProcessOutput < Base
|
|
14
|
+
description "Read new stdout/stderr from a background process (started with process_start) since " \
|
|
15
|
+
"the last read, along with its status and exit code if it has finished. Poll this to " \
|
|
16
|
+
"follow a process's output."
|
|
17
|
+
|
|
18
|
+
param :id, type: "string",
|
|
19
|
+
desc: "The process id returned by process_start (e.g. 'proc_1').",
|
|
20
|
+
required: true
|
|
21
|
+
|
|
22
|
+
def execute(id:)
|
|
23
|
+
proc = ProcessRegistry.get(id)
|
|
24
|
+
return error("no such process: #{id}", code: :not_found) unless proc
|
|
25
|
+
|
|
26
|
+
data = proc.read_new
|
|
27
|
+
truncate(format_output(proc, data))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def format_output(proc, data)
|
|
33
|
+
body = +"#{proc.id} (#{proc.name}): #{proc.status}"
|
|
34
|
+
body << " (exit #{proc.exit_code})" if proc.status == :exited && proc.exit_code
|
|
35
|
+
body << "\n"
|
|
36
|
+
|
|
37
|
+
if data[:out].empty? && data[:err].empty?
|
|
38
|
+
body << "(no new output)"
|
|
39
|
+
return body
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unless data[:out].empty?
|
|
43
|
+
body << "\n--- stdout#{data[:out_dropped] ? ' (earlier output dropped)' : ''} ---\n"
|
|
44
|
+
body << data[:out]
|
|
45
|
+
end
|
|
46
|
+
unless data[:err].empty?
|
|
47
|
+
body << "\n--- stderr#{data[:err_dropped] ? ' (earlier output dropped)' : ''} ---\n"
|
|
48
|
+
body << data[:err]
|
|
49
|
+
end
|
|
50
|
+
body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/process_registry"
|
|
5
|
+
require "ruby_llm/toolbox/safety/command_guard"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# EXEC. Starts a long-running background process (a dev server, a file
|
|
11
|
+
# watcher, a log tail) and returns its id immediately instead of blocking.
|
|
12
|
+
# Read its output later with process_output, see everything running with
|
|
13
|
+
# process_list, and stop it with process_kill.
|
|
14
|
+
#
|
|
15
|
+
# Same safety model as bash: one allowlisted executable, argv only (no
|
|
16
|
+
# shell), the minimal env_passthrough environment, run in fs_root, in its
|
|
17
|
+
# own process group with an address-space cap. Concurrency is bounded by
|
|
18
|
+
# config.max_processes.
|
|
19
|
+
class ProcessStart < Base
|
|
20
|
+
exec_tool!
|
|
21
|
+
|
|
22
|
+
description "Start a long-running command as a background process and return its id (does not " \
|
|
23
|
+
"block). Use for dev servers, watchers, or log tails. The executable must be on the " \
|
|
24
|
+
"allowlist; no shell is involved. Manage it with process_output / process_list / " \
|
|
25
|
+
"process_kill."
|
|
26
|
+
|
|
27
|
+
param :command, type: "string",
|
|
28
|
+
desc: "Executable name (must be on the allowlist). No path, no shell characters.",
|
|
29
|
+
required: true
|
|
30
|
+
param :args, type: "array",
|
|
31
|
+
desc: "Arguments passed verbatim to the program, one per element. Optional.",
|
|
32
|
+
required: false
|
|
33
|
+
param :name, type: "string",
|
|
34
|
+
desc: "A short label for this process, shown in listings. Optional.",
|
|
35
|
+
required: false
|
|
36
|
+
param :unsafe, type: "boolean",
|
|
37
|
+
desc: "Request bypassing the command allowlist (still argv only, no shell). Only " \
|
|
38
|
+
"takes effect if an operator enabled allow_unsafe; otherwise refused. Default false.",
|
|
39
|
+
required: false
|
|
40
|
+
|
|
41
|
+
def execute(command:, args: nil, name: nil, unsafe: false)
|
|
42
|
+
exe = resolve_command(command, unsafe)
|
|
43
|
+
argv = [exe, *sanitize_args(args)]
|
|
44
|
+
|
|
45
|
+
id = ProcessRegistry.start(
|
|
46
|
+
argv: argv,
|
|
47
|
+
env: clean_env,
|
|
48
|
+
chdir: config.fs_root,
|
|
49
|
+
name: (name.to_s.empty? ? exe : name.to_s),
|
|
50
|
+
rlimits: memory_rlimits,
|
|
51
|
+
max: config.max_processes
|
|
52
|
+
)
|
|
53
|
+
proc = ProcessRegistry.get(id)
|
|
54
|
+
"Started #{id} (pid #{proc.pid}): #{argv.inspect}\nUse process_output id:#{id.inspect} to read its output."
|
|
55
|
+
rescue Safety::CommandGuard::Blocked => e
|
|
56
|
+
error(e.message, code: :command_denied)
|
|
57
|
+
rescue ProcessRegistry::LimitError => e
|
|
58
|
+
error(e.message, code: :too_many_processes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def resolve_command(command, unsafe)
|
|
64
|
+
return Safety::CommandGuard.new(config.allowed_commands).check!(command) unless permit_unsafe!(unsafe, command)
|
|
65
|
+
|
|
66
|
+
cmd = command.to_s
|
|
67
|
+
raise Safety::CommandGuard::Blocked, "command is empty" if cmd.strip.empty?
|
|
68
|
+
raise Safety::CommandGuard::Blocked, "command contains a NUL byte" if cmd.include?("\u0000")
|
|
69
|
+
|
|
70
|
+
cmd
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sanitize_args(args)
|
|
74
|
+
Array(args).map do |arg|
|
|
75
|
+
str = arg.to_s
|
|
76
|
+
raise Safety::CommandGuard::Blocked, "argument contains a NUL byte" if str.include?("\u0000")
|
|
77
|
+
|
|
78
|
+
str
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clean_env
|
|
83
|
+
config.env_passthrough.each_with_object({}) do |key, env|
|
|
84
|
+
value = ENV[key]
|
|
85
|
+
env[key] = value unless value.nil?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An address-space cap so a runaway can't exhaust host memory. No CPU
|
|
90
|
+
# cap — background processes are expected to run indefinitely.
|
|
91
|
+
def memory_rlimits
|
|
92
|
+
return {} unless /linux/i.match?(RUBY_PLATFORM)
|
|
93
|
+
|
|
94
|
+
bytes = parse_memory_bytes(config.sandbox_memory)
|
|
95
|
+
bytes ? { rlimit_as: bytes } : {}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_memory_bytes(value)
|
|
99
|
+
str = value.to_s.strip.downcase
|
|
100
|
+
return nil if str.empty?
|
|
101
|
+
return nil unless (m = str.match(/\A(\d+)\s*([kmg])?b?\z/))
|
|
102
|
+
|
|
103
|
+
n = m[1].to_i
|
|
104
|
+
{ "k" => 1024, "m" => 1024**2, "g" => 1024**3 }.fetch(m[2], 1) * n
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/toolchain_helpers"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Runs the project's Python tests (pytest or unittest) from fs_root
|
|
10
|
+
# and returns the output with a pass/fail headline. A failing suite is a
|
|
11
|
+
# normal result, not a tool error. Runs in the project environment, so a
|
|
12
|
+
# virtualenv/installed deps resolve as they would in a shell.
|
|
13
|
+
class PythonTests < Base
|
|
14
|
+
include ToolchainHelpers
|
|
15
|
+
exec_tool!
|
|
16
|
+
|
|
17
|
+
description "Run the project's Python test suite from fs_root and report results. Uses pytest " \
|
|
18
|
+
"by default, or unittest (python -m unittest discover) when framework is unittest. " \
|
|
19
|
+
"Optionally scope to a path."
|
|
20
|
+
|
|
21
|
+
FRAMEWORKS = %w[pytest unittest].freeze
|
|
22
|
+
|
|
23
|
+
param :path, type: "string",
|
|
24
|
+
desc: "Limit the run to this test file or directory, relative to fs_root. Optional.",
|
|
25
|
+
required: false
|
|
26
|
+
param :framework, type: "string",
|
|
27
|
+
desc: "pytest (default) or unittest.",
|
|
28
|
+
required: false
|
|
29
|
+
|
|
30
|
+
def execute(path: nil, framework: "pytest")
|
|
31
|
+
fw = framework.to_s.strip.downcase
|
|
32
|
+
fw = "pytest" if fw.empty?
|
|
33
|
+
return error("unknown framework: #{fw} (use #{FRAMEWORKS.join(', ')})", code: :bad_framework) unless FRAMEWORKS.include?(fw)
|
|
34
|
+
|
|
35
|
+
rel = jail_relative(path)
|
|
36
|
+
out, err, status = run_in_project(args_for(fw, rel), use_bundle: false)
|
|
37
|
+
toolchain_output(out, err, status,
|
|
38
|
+
pass_label: summarize(out, err, "TESTS PASSED"),
|
|
39
|
+
fail_label: summarize(out, err, "TESTS FAILED"))
|
|
40
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
41
|
+
error(e.message, code: :path_denied)
|
|
42
|
+
rescue CommandMissing => e
|
|
43
|
+
error("#{e.message} is not available (is it installed in the project env?)", code: :unavailable)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def args_for(framework, rel)
|
|
49
|
+
if framework == "unittest"
|
|
50
|
+
args = ["python3", "-m", "unittest", "discover"]
|
|
51
|
+
args += ["-s", rel] if rel
|
|
52
|
+
else
|
|
53
|
+
args = ["pytest"]
|
|
54
|
+
args << rel if rel
|
|
55
|
+
end
|
|
56
|
+
args
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Surface pytest's or unittest's summary in the headline.
|
|
60
|
+
def summarize(out, err, label)
|
|
61
|
+
text = "#{out}\n#{err}"
|
|
62
|
+
counts = %w[passed failed error skipped].filter_map do |word|
|
|
63
|
+
m = text.match(/(\d+) #{word}/)
|
|
64
|
+
"#{m[1]} #{word}" if m
|
|
65
|
+
end
|
|
66
|
+
return "#{label} (#{counts.join(', ')})" unless counts.empty?
|
|
67
|
+
|
|
68
|
+
if (m = text.match(/Ran (\d+) tests?.*?(OK|FAILED)/m))
|
|
69
|
+
return "#{label} (ran #{m[1]}, #{m[2]})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
label
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# SAFE reference tool. Read-only, confined to fs_root, output budgeted.
|
|
10
|
+
# Loaded and usable by default.
|
|
11
|
+
class ReadFile < Base
|
|
12
|
+
description "Read a UTF-8 text file from within the configured fs_root. " \
|
|
13
|
+
"Optionally restrict to a 1-based line range, or return only the last N lines with tail. " \
|
|
14
|
+
"Large output is automatically truncated to fit a token budget."
|
|
15
|
+
|
|
16
|
+
param :path, type: "string",
|
|
17
|
+
desc: "File path, relative to fs_root (or an absolute path inside it).",
|
|
18
|
+
required: true
|
|
19
|
+
param :start_line, type: "integer",
|
|
20
|
+
desc: "1-based first line to include. Optional.",
|
|
21
|
+
required: false
|
|
22
|
+
param :end_line, type: "integer",
|
|
23
|
+
desc: "1-based last line to include, inclusive. Optional.",
|
|
24
|
+
required: false
|
|
25
|
+
param :tail, type: "integer",
|
|
26
|
+
desc: "Return only the last N lines (like `tail -n N`). Takes precedence over " \
|
|
27
|
+
"start_line/end_line. Optional.",
|
|
28
|
+
required: false
|
|
29
|
+
param :unsafe, type: "boolean",
|
|
30
|
+
desc: "Bypass the fs_root jail (read anywhere). Only honored if the operator " \
|
|
31
|
+
"enabled allow_unsafe; otherwise the call is refused. Default false.",
|
|
32
|
+
required: false
|
|
33
|
+
|
|
34
|
+
MAX_BYTES = 5 * 1024 * 1024 # refuse to slurp anything enormous
|
|
35
|
+
|
|
36
|
+
def execute(path:, start_line: nil, end_line: nil, tail: nil, unsafe: false)
|
|
37
|
+
jail = path_jail(unsafe: unsafe, detail: path)
|
|
38
|
+
real = jail.resolve(path)
|
|
39
|
+
|
|
40
|
+
return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
|
|
41
|
+
if File.size(real) > MAX_BYTES
|
|
42
|
+
return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
lines = File.readlines(real)
|
|
46
|
+
selected = if tail && tail.to_i.positive?
|
|
47
|
+
lines.last(tail.to_i)
|
|
48
|
+
else
|
|
49
|
+
slice_lines(lines, start_line, end_line)
|
|
50
|
+
end
|
|
51
|
+
return error("no lines in the given range", code: :empty_range) if selected.nil?
|
|
52
|
+
|
|
53
|
+
truncate(selected.join.scrub)
|
|
54
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
55
|
+
error(e.message, code: :path_denied)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def slice_lines(lines, start_line, end_line)
|
|
61
|
+
return lines if start_line.nil? && end_line.nil?
|
|
62
|
+
|
|
63
|
+
first = (start_line || 1).to_i
|
|
64
|
+
last = (end_line || lines.length).to_i
|
|
65
|
+
return nil if first < 1 || first > lines.length
|
|
66
|
+
|
|
67
|
+
last = lines.length if last > lines.length
|
|
68
|
+
return nil if last < first
|
|
69
|
+
|
|
70
|
+
lines[(first - 1)..(last - 1)]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|