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,139 @@
|
|
|
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. Find-and-replace across the files in fs_root that match a glob.
|
|
10
|
+
# Literal by default; set regex: true for a pattern (with backreferences in
|
|
11
|
+
# the replacement). Binary files and ignored directories are skipped, the
|
|
12
|
+
# pattern runs under a ReDoS timeout, and every path is jailed to fs_root.
|
|
13
|
+
# Use dry_run: true to preview the impact without writing.
|
|
14
|
+
class ReplaceInFiles < Base
|
|
15
|
+
exec_tool!
|
|
16
|
+
|
|
17
|
+
description "Replace occurrences of a pattern with a replacement across files in fs_root " \
|
|
18
|
+
"matching a glob (default '**/*'). Literal by default; set regex true to use a " \
|
|
19
|
+
"regular expression (replacement may use \\1 backreferences). Set dry_run true to " \
|
|
20
|
+
"preview without writing. Skips binary files and ignored directories."
|
|
21
|
+
|
|
22
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
23
|
+
MAX_LISTED = 100
|
|
24
|
+
|
|
25
|
+
param :pattern, type: "string",
|
|
26
|
+
desc: "Text (or regex if regex=true) to find.",
|
|
27
|
+
required: true
|
|
28
|
+
param :replacement, type: "string",
|
|
29
|
+
desc: "Replacement text. With regex=true, \\1 etc. refer to capture groups.",
|
|
30
|
+
required: false
|
|
31
|
+
param :glob, type: "string",
|
|
32
|
+
desc: "Glob of files to search, relative to fs_root. Default '**/*'.",
|
|
33
|
+
required: false
|
|
34
|
+
param :regex, type: "boolean",
|
|
35
|
+
desc: "Treat pattern as a regular expression. Default false (literal).",
|
|
36
|
+
required: false
|
|
37
|
+
param :ignore_case, type: "boolean",
|
|
38
|
+
desc: "Case-insensitive matching. Default false.",
|
|
39
|
+
required: false
|
|
40
|
+
param :dry_run, type: "boolean",
|
|
41
|
+
desc: "Report what would change without writing. Default false.",
|
|
42
|
+
required: false
|
|
43
|
+
|
|
44
|
+
def execute(pattern:, replacement: "", glob: "**/*", regex: false, ignore_case: false, dry_run: false)
|
|
45
|
+
return error("pattern must not be empty", code: :empty_pattern) if pattern.to_s.empty?
|
|
46
|
+
|
|
47
|
+
matcher = build_matcher(pattern, regex, ignore_case)
|
|
48
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
49
|
+
root = File.realpath(config.fs_root)
|
|
50
|
+
|
|
51
|
+
changed = []
|
|
52
|
+
total = 0
|
|
53
|
+
candidate_files(glob, jail, root).each do |abs, rel|
|
|
54
|
+
content = File.read(abs)
|
|
55
|
+
next unless text?(content)
|
|
56
|
+
|
|
57
|
+
count, updated = substitute(content, matcher, replacement.to_s, regex)
|
|
58
|
+
next if count.zero?
|
|
59
|
+
|
|
60
|
+
total += count
|
|
61
|
+
changed << [rel, count]
|
|
62
|
+
File.write(abs, updated) unless dry_run
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
format_summary(changed, total, dry_run)
|
|
66
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
67
|
+
error(e.message, code: :path_denied)
|
|
68
|
+
rescue Regexp::TimeoutError
|
|
69
|
+
error("pattern timed out (possible ReDoS); narrow the pattern", code: :regex_timeout)
|
|
70
|
+
rescue RegexpError => e
|
|
71
|
+
error("invalid regular expression: #{e.message}", code: :bad_regex)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_matcher(pattern, regex, ignore_case)
|
|
77
|
+
flags = ignore_case ? Regexp::IGNORECASE : 0
|
|
78
|
+
timeout = config.regex_timeout
|
|
79
|
+
src = regex ? pattern.to_s : Regexp.escape(pattern.to_s)
|
|
80
|
+
Regexp.new(src, flags, timeout: timeout)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def candidate_files(glob, jail, root)
|
|
84
|
+
Dir.glob(File.join(config.fs_root, glob)).filter_map do |abs|
|
|
85
|
+
next unless File.file?(abs)
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
real = File.realpath(abs)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
next unless real == root || real.start_with?(root + File::SEPARATOR)
|
|
93
|
+
|
|
94
|
+
rel = real.delete_prefix(root + File::SEPARATOR)
|
|
95
|
+
next if ignored?(rel)
|
|
96
|
+
next if File.size(real) > MAX_BYTES
|
|
97
|
+
|
|
98
|
+
jail.resolve(rel) # raises Jailbreak if somehow outside
|
|
99
|
+
[real, rel]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ignored?(rel)
|
|
104
|
+
parts = rel.split(File::SEPARATOR)
|
|
105
|
+
Array(config.ignored_dirs).any? { |dir| parts.include?(dir) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def text?(content)
|
|
109
|
+
content.valid_encoding? && !content.include?("\u0000")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def substitute(content, matcher, replacement, regex)
|
|
113
|
+
if regex
|
|
114
|
+
count = content.scan(matcher).size
|
|
115
|
+
return [0, content] if count.zero?
|
|
116
|
+
|
|
117
|
+
[count, content.gsub(matcher, replacement)]
|
|
118
|
+
else
|
|
119
|
+
count = 0
|
|
120
|
+
updated = content.gsub(matcher) { count += 1; replacement }
|
|
121
|
+
return [0, content] if count.zero?
|
|
122
|
+
|
|
123
|
+
[count, updated]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def format_summary(changed, total, dry_run)
|
|
128
|
+
verb = dry_run ? "Would replace" : "Replaced"
|
|
129
|
+
return "#{verb} 0 occurrences (no matching files changed)." if changed.empty?
|
|
130
|
+
|
|
131
|
+
header = "#{verb} #{total} occurrence#{total == 1 ? '' : 's'} across #{changed.size} file#{changed.size == 1 ? '' : 's'}#{dry_run ? ' (dry run)' : ''}:"
|
|
132
|
+
lines = changed.first(MAX_LISTED).map { |rel, n| " #{rel} (#{n})" }
|
|
133
|
+
lines << " ... and #{changed.size - MAX_LISTED} more" if changed.size > MAX_LISTED
|
|
134
|
+
truncate(([header] + lines).join("\n"))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/sandbox_run"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Executes a Python snippet inside a hardened Docker container (no
|
|
10
|
+
# network, read-only filesystem, dropped capabilities, memory/CPU/pids
|
|
11
|
+
# limits, wall-clock timeout). Source is piped to the interpreter on
|
|
12
|
+
# stdin, so nothing from the host is mounted.
|
|
13
|
+
#
|
|
14
|
+
# Gated: requires config.enable_exec_tools AND Docker on the host. Uses
|
|
15
|
+
# config.python_image (default python:3.12-slim).
|
|
16
|
+
class RunPython < Base
|
|
17
|
+
include SandboxRun
|
|
18
|
+
exec_tool!
|
|
19
|
+
|
|
20
|
+
description "Execute a snippet of Python in an isolated Docker container and return its " \
|
|
21
|
+
"stdout, stderr, and exit status. The container has no network, a read-only " \
|
|
22
|
+
"filesystem, dropped capabilities, and memory/CPU/time limits. Print results " \
|
|
23
|
+
"with print(). Requires Docker on the host."
|
|
24
|
+
|
|
25
|
+
param :code, type: "string",
|
|
26
|
+
desc: "Python source to execute. Read on stdin by the interpreter; " \
|
|
27
|
+
"print output with print().",
|
|
28
|
+
required: true
|
|
29
|
+
|
|
30
|
+
def execute(code:)
|
|
31
|
+
return error("code must be provided", code: :empty_code) if code.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
run_in_sandbox(["python3", "-"], code, image: config.python_image)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/sandbox_run"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Executes a Ruby snippet inside a hardened Docker container (no
|
|
10
|
+
# network, read-only filesystem, dropped capabilities, memory/CPU/pids
|
|
11
|
+
# limits, wall-clock timeout). Source is piped to the interpreter on
|
|
12
|
+
# stdin, so nothing from the host is mounted.
|
|
13
|
+
#
|
|
14
|
+
# Gated: requires config.enable_exec_tools AND Docker on the host.
|
|
15
|
+
class RunRuby < Base
|
|
16
|
+
include SandboxRun
|
|
17
|
+
exec_tool!
|
|
18
|
+
|
|
19
|
+
description "Execute a snippet of Ruby in an isolated Docker container and return its " \
|
|
20
|
+
"stdout, stderr, and exit status. The container has no network, a read-only " \
|
|
21
|
+
"filesystem, dropped capabilities, and memory/CPU/time limits. Print results " \
|
|
22
|
+
"with puts or p. Requires Docker on the host."
|
|
23
|
+
|
|
24
|
+
param :code, type: "string",
|
|
25
|
+
desc: "Ruby source to execute. Read on stdin by the interpreter; " \
|
|
26
|
+
"print output with puts/p.",
|
|
27
|
+
required: true
|
|
28
|
+
|
|
29
|
+
def execute(code:)
|
|
30
|
+
return error("code must be provided", code: :empty_code) if code.to_s.strip.empty?
|
|
31
|
+
|
|
32
|
+
run_in_sandbox(["ruby"], code, image: config.docker_image)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/sandbox_run"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Compiles and runs a Rust snippet inside the hardened Docker
|
|
10
|
+
# sandbox (no network, read-only root with a tmpfs scratch, dropped
|
|
11
|
+
# capabilities, resource/time limits). The source is piped on stdin; a
|
|
12
|
+
# tiny shell step inside the (already isolated) container writes it to the
|
|
13
|
+
# tmpfs, compiles with rustc, and runs the binary.
|
|
14
|
+
#
|
|
15
|
+
# Gated: requires config.enable_exec_tools AND Docker on the host. Uses
|
|
16
|
+
# config.rust_image (default rust:1-slim).
|
|
17
|
+
class RunRust < Base
|
|
18
|
+
include SandboxRun
|
|
19
|
+
exec_tool!
|
|
20
|
+
|
|
21
|
+
# cat the piped source into the tmpfs, compile, then run.
|
|
22
|
+
COMPILE_AND_RUN = "cat > /tmp/m.rs && rustc -O /tmp/m.rs -o /tmp/m 2>&1 && /tmp/m"
|
|
23
|
+
|
|
24
|
+
description "Compile and run a self-contained Rust program in an isolated Docker container and " \
|
|
25
|
+
"return compiler output plus the program's stdout/stderr and exit status. The " \
|
|
26
|
+
"container has no network, a read-only filesystem (scratch in tmpfs), dropped " \
|
|
27
|
+
"capabilities, and memory/CPU/time limits. Provide a complete program with fn main(). " \
|
|
28
|
+
"Requires Docker on the host."
|
|
29
|
+
|
|
30
|
+
param :code, type: "string",
|
|
31
|
+
desc: "A complete Rust program (must include fn main). Read on stdin, compiled, and run.",
|
|
32
|
+
required: true
|
|
33
|
+
|
|
34
|
+
def execute(code:)
|
|
35
|
+
return error("code must be provided", code: :empty_code) if code.to_s.strip.empty?
|
|
36
|
+
|
|
37
|
+
run_in_sandbox(["sh", "-c", COMPILE_AND_RUN], code, image: config.rust_image)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
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 test suite (RSpec or Minitest) from fs_root and
|
|
10
|
+
# returns the output with a pass/fail headline. A failing suite is a
|
|
11
|
+
# normal result (the agent needs to see it), not a tool error.
|
|
12
|
+
class RunTests < Base
|
|
13
|
+
include ToolchainHelpers
|
|
14
|
+
exec_tool!
|
|
15
|
+
|
|
16
|
+
description "Run the project's test suite from fs_root and report results. Auto-detects " \
|
|
17
|
+
"RSpec (spec/ or .rspec) or Minitest (test/ via rake), or set framework " \
|
|
18
|
+
"explicitly. Optionally scope to a path. Uses `bundle exec` when a Gemfile exists."
|
|
19
|
+
|
|
20
|
+
FRAMEWORKS = %w[rspec minitest].freeze
|
|
21
|
+
|
|
22
|
+
param :path, type: "string",
|
|
23
|
+
desc: "Limit the run to this spec/test file or directory, relative to fs_root. Optional.",
|
|
24
|
+
required: false
|
|
25
|
+
param :framework, type: "string",
|
|
26
|
+
desc: "Force the framework: rspec or minitest. Default: auto-detect.",
|
|
27
|
+
required: false
|
|
28
|
+
|
|
29
|
+
def execute(path: nil, framework: nil)
|
|
30
|
+
fw = (framework || detect_framework).to_s
|
|
31
|
+
return error("could not detect a test framework (no spec/ or test/); set framework", code: :no_framework) if fw.empty?
|
|
32
|
+
return error("unknown framework: #{fw} (use #{FRAMEWORKS.join(', ')})", code: :bad_framework) unless FRAMEWORKS.include?(fw)
|
|
33
|
+
|
|
34
|
+
rel = jail_relative(path)
|
|
35
|
+
out, err, status = fw == "rspec" ? run_rspec(rel) : run_minitest(rel)
|
|
36
|
+
toolchain_output(out, err, status,
|
|
37
|
+
pass_label: summarize(out, err, "TESTS PASSED"),
|
|
38
|
+
fail_label: summarize(out, err, "TESTS FAILED"))
|
|
39
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
40
|
+
error(e.message, code: :path_denied)
|
|
41
|
+
rescue CommandMissing => e
|
|
42
|
+
error("#{e.message} is not available (is it in the bundle / installed?)", code: :unavailable)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def detect_framework
|
|
48
|
+
root = config.fs_root
|
|
49
|
+
return "rspec" if File.directory?(File.join(root, "spec")) || File.exist?(File.join(root, ".rspec"))
|
|
50
|
+
return "minitest" if File.directory?(File.join(root, "test"))
|
|
51
|
+
|
|
52
|
+
""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run_rspec(rel)
|
|
56
|
+
args = ["rspec"]
|
|
57
|
+
args << rel if rel
|
|
58
|
+
run_in_project(args, use_bundle: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_minitest(rel)
|
|
62
|
+
args = ["rake", "test"]
|
|
63
|
+
args << "TEST=#{rel}" if rel
|
|
64
|
+
run_in_project(args, use_bundle: true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Pull the framework's summary line into the headline when present.
|
|
68
|
+
def summarize(out, err, label)
|
|
69
|
+
text = "#{out}\n#{err}"
|
|
70
|
+
if (m = text.match(/(\d+) examples?, (\d+) failures?(?:, (\d+) errors?)?/))
|
|
71
|
+
"#{label} (#{m[0]})"
|
|
72
|
+
elsif (m = text.match(/(\d+) runs?, \d+ assertions?, (\d+) failures?, (\d+) errors?/))
|
|
73
|
+
"#{label} (#{m[0]})"
|
|
74
|
+
else
|
|
75
|
+
label
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/sandbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/sandbox/docker"
|
|
5
|
+
require "ruby_llm/toolbox/sandbox/bubblewrap"
|
|
6
|
+
require "ruby_llm/toolbox/sandbox/sandbox_exec"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module Toolbox
|
|
10
|
+
module Tools
|
|
11
|
+
# Shared behavior for the sandboxed code-execution tools (run_ruby,
|
|
12
|
+
# run_python, run_rust): pick the active sandbox backend for this host
|
|
13
|
+
# (Sandbox.build), run the command with the code piped on stdin, and
|
|
14
|
+
# format stdout/stderr/exit uniformly. The Docker backend uses `image`;
|
|
15
|
+
# the host-process backends ignore it and run the host's interpreters.
|
|
16
|
+
module SandboxRun
|
|
17
|
+
def run_in_sandbox(argv, code, image:)
|
|
18
|
+
backend = Sandbox.build(config)
|
|
19
|
+
out, err, status = backend.run(argv, stdin: code.to_s, image: image)
|
|
20
|
+
truncate(format_sandbox(out, err, status))
|
|
21
|
+
rescue Sandbox::Unavailable => e
|
|
22
|
+
error(e.message, code: :sandbox_unavailable)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def format_sandbox(out, err, status)
|
|
26
|
+
body = +""
|
|
27
|
+
body << if status == :timeout
|
|
28
|
+
"result: timed out after #{config.command_timeout}s (sandbox killed)\n"
|
|
29
|
+
else
|
|
30
|
+
"exit: #{status.exitstatus}\n"
|
|
31
|
+
end
|
|
32
|
+
body << "\n--- stdout ---\n#{out}" unless out.empty?
|
|
33
|
+
body << "\n--- stderr ---\n#{err}" unless err.empty?
|
|
34
|
+
body << "(no output)" if out.empty? && err.empty?
|
|
35
|
+
body
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Toolbox
|
|
7
|
+
module Tools
|
|
8
|
+
# SAFE. Tracks a task list for multi-step work. Each call replaces the
|
|
9
|
+
# whole list (the standard agent pattern) and renders it back. State lives
|
|
10
|
+
# on the tool instance, so it persists across calls within a single chat
|
|
11
|
+
# session (when one instance is registered with the chat).
|
|
12
|
+
class TodoWrite < Base
|
|
13
|
+
STATUSES = %w[pending in_progress completed].freeze
|
|
14
|
+
MARKS = { "pending" => "[ ]", "in_progress" => "[~]", "completed" => "[x]" }.freeze
|
|
15
|
+
|
|
16
|
+
description "Maintain a task list for multi-step work. Pass the full list of todos; each call " \
|
|
17
|
+
"replaces the previous list. Each todo has a content string and a status of " \
|
|
18
|
+
"pending, in_progress, or completed."
|
|
19
|
+
|
|
20
|
+
param :todos, type: "array",
|
|
21
|
+
desc: "The full task list: array of objects with 'content' and 'status' " \
|
|
22
|
+
"(pending|in_progress|completed).",
|
|
23
|
+
required: true
|
|
24
|
+
|
|
25
|
+
def execute(todos:)
|
|
26
|
+
return error("todos must be an array", code: :bad_todos) unless todos.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
normalized = todos.map { |todo| normalize(todo) }
|
|
29
|
+
invalid = normalized.find { |todo| !STATUSES.include?(todo[:status]) }
|
|
30
|
+
return error("invalid status: #{invalid[:status].inspect} (use #{STATUSES.join(', ')})", code: :bad_status) if invalid
|
|
31
|
+
|
|
32
|
+
@todos = normalized
|
|
33
|
+
render
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize(todo)
|
|
39
|
+
hash = todo.is_a?(Hash) ? todo : {}
|
|
40
|
+
{
|
|
41
|
+
content: (hash["content"] || hash[:content]).to_s,
|
|
42
|
+
status: (hash["status"] || hash[:status] || "pending").to_s.strip.downcase
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render
|
|
47
|
+
return "Task list is empty." if @todos.empty?
|
|
48
|
+
|
|
49
|
+
done = @todos.count { |todo| todo[:status] == "completed" }
|
|
50
|
+
lines = ["Tasks (#{done}/#{@todos.size} complete):"]
|
|
51
|
+
@todos.each { |todo| lines << "#{MARKS[todo[:status]]} #{todo[:content]}" }
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ruby_llm/toolbox/base"
|
|
5
|
+
require "ruby_llm/toolbox/toml"
|
|
6
|
+
require "ruby_llm/toolbox/data_path"
|
|
7
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
8
|
+
|
|
9
|
+
module RubyLLM
|
|
10
|
+
module Toolbox
|
|
11
|
+
module Tools
|
|
12
|
+
# SAFE. Parses TOML (from a file in fs_root or an inline string) and either
|
|
13
|
+
# pretty-prints it (as JSON, for readability) or extracts a value with a
|
|
14
|
+
# dot/bracket path. In-process, read-only — handy for Cargo.toml,
|
|
15
|
+
# pyproject.toml, and similar config.
|
|
16
|
+
#
|
|
17
|
+
# Path syntax matches json_query / yaml_query: dependencies.serde.version,
|
|
18
|
+
# products[0].name, products[].name
|
|
19
|
+
class TomlQuery < Base
|
|
20
|
+
description "Query TOML with a path expression, or pretty-print it. Provide either a file path " \
|
|
21
|
+
"(within fs_root) or an inline toml string. Path syntax: keys separated by dots, " \
|
|
22
|
+
"[n] for array index, [] to map over an array (e.g. products[].name)."
|
|
23
|
+
|
|
24
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
param :path, type: "string",
|
|
27
|
+
desc: "TOML file to read, relative to fs_root. Provide this or toml.",
|
|
28
|
+
required: false
|
|
29
|
+
param :toml, type: "string",
|
|
30
|
+
desc: "Inline TOML string. Provide this or path.",
|
|
31
|
+
required: false
|
|
32
|
+
param :query, type: "string",
|
|
33
|
+
desc: "Path expression to extract, e.g. 'dependencies.serde.version'. Omit to pretty-print. Optional.",
|
|
34
|
+
required: false
|
|
35
|
+
|
|
36
|
+
def execute(path: nil, toml: nil, query: nil)
|
|
37
|
+
source = load_source(path, toml)
|
|
38
|
+
return source if source.is_a?(Hash) # error
|
|
39
|
+
|
|
40
|
+
data = Toml.parse(source)
|
|
41
|
+
result = query.to_s.strip.empty? ? data : DataPath.query(data, query)
|
|
42
|
+
truncate(JSON.pretty_generate(result))
|
|
43
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
44
|
+
error(e.message, code: :path_denied)
|
|
45
|
+
rescue Toml::ParseError => e
|
|
46
|
+
error("invalid TOML: #{e.message}", code: :bad_toml)
|
|
47
|
+
rescue DataPath::Error => e
|
|
48
|
+
error(e.message, code: :bad_query)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_source(path, toml)
|
|
54
|
+
if path && !path.to_s.empty?
|
|
55
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
56
|
+
real = jail.resolve(path)
|
|
57
|
+
return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
|
|
58
|
+
return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
|
|
59
|
+
|
|
60
|
+
File.read(real).scrub
|
|
61
|
+
elsif toml && !toml.to_s.empty?
|
|
62
|
+
toml.to_s
|
|
63
|
+
else
|
|
64
|
+
error("provide either path or toml", code: :no_input)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "ruby_llm/toolbox/process_runner"
|
|
5
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# Shared behavior for the Ruby toolchain tools (run_tests, lint, bundle).
|
|
11
|
+
#
|
|
12
|
+
# Unlike BashTool, these run in fs_root and inherit the full host
|
|
13
|
+
# environment. That's deliberate: tools like bundler, rbenv/rvm, and the
|
|
14
|
+
# test/lint binaries rely on the Ruby environment (PATH shims, GEM_HOME,
|
|
15
|
+
# BUNDLE_*) to resolve correctly, and these are trusted dev commands the
|
|
16
|
+
# user opted into via enable_exec_tools — not arbitrary user input.
|
|
17
|
+
module ToolchainHelpers
|
|
18
|
+
class CommandMissing < StandardError; end
|
|
19
|
+
|
|
20
|
+
def gemfile?
|
|
21
|
+
File.file?(File.join(config.fs_root, "Gemfile"))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs argv in fs_root. With use_bundle, prefixes `bundle exec` when a
|
|
25
|
+
# Gemfile is present. Returns [out, err, status]; raises CommandMissing
|
|
26
|
+
# if the executable can't be found.
|
|
27
|
+
def run_in_project(argv, use_bundle: false)
|
|
28
|
+
command = use_bundle && gemfile? ? ["bundle", "exec", *argv] : argv
|
|
29
|
+
ProcessRunner.capture(
|
|
30
|
+
command,
|
|
31
|
+
env: {}, # inherit the full host environment
|
|
32
|
+
chdir: config.fs_root,
|
|
33
|
+
timeout: config.command_timeout,
|
|
34
|
+
unsetenv_others: false
|
|
35
|
+
)
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise CommandMissing, command.first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Maps a finished toolchain run onto a return value. Non-zero exit is
|
|
41
|
+
# NOT treated as a tool error here — failing tests / lint offenses are
|
|
42
|
+
# results the agent needs to see, with a headline prepended.
|
|
43
|
+
def toolchain_output(out, err, status, pass_label:, fail_label:)
|
|
44
|
+
return error("timed out after #{config.command_timeout}s", code: :timeout) if status == :timeout
|
|
45
|
+
|
|
46
|
+
combined = [out, err].map(&:to_s).reject(&:empty?).join("\n")
|
|
47
|
+
headline = status.exitstatus.zero? ? pass_label : fail_label
|
|
48
|
+
truncate("#{headline}\n\n#{combined}".strip)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve a path param to a project-relative path, rejecting escapes.
|
|
52
|
+
def jail_relative(path)
|
|
53
|
+
return nil if path.nil? || path.to_s.empty?
|
|
54
|
+
|
|
55
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
56
|
+
real = jail.resolve(path)
|
|
57
|
+
Pathname.new(real).relative_path_from(Pathname.new(jail.root)).to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
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. Renders a depth-limited directory tree under fs_root — a quick way
|
|
10
|
+
# to see project structure without walking it one level at a time with
|
|
11
|
+
# list_directory. Read-only; ignored directories are skipped, symlinks are
|
|
12
|
+
# not followed, and the listing is capped.
|
|
13
|
+
class Tree < Base
|
|
14
|
+
description "Show a directory tree under fs_root, to a limited depth. Directories are marked " \
|
|
15
|
+
"with a trailing slash. Skips ignored directories (node_modules, .git, ...) and " \
|
|
16
|
+
"hidden entries unless show_hidden is set."
|
|
17
|
+
|
|
18
|
+
DEFAULT_DEPTH = 3
|
|
19
|
+
MAX_ENTRIES = 500
|
|
20
|
+
|
|
21
|
+
param :path, type: "string",
|
|
22
|
+
desc: "Directory to start from, relative to fs_root. Defaults to fs_root.",
|
|
23
|
+
required: false
|
|
24
|
+
param :max_depth, type: "integer",
|
|
25
|
+
desc: "How many levels deep to descend (default 3).",
|
|
26
|
+
required: false
|
|
27
|
+
param :show_hidden, type: "boolean",
|
|
28
|
+
desc: "Include dot-files and dot-directories. Default false.",
|
|
29
|
+
required: false
|
|
30
|
+
|
|
31
|
+
def execute(path: nil, max_depth: DEFAULT_DEPTH, show_hidden: false)
|
|
32
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
33
|
+
root = jail.resolve(path.to_s.empty? ? "." : path)
|
|
34
|
+
return error("not a directory: #{path || '.'}", code: :not_a_directory) unless File.directory?(root)
|
|
35
|
+
|
|
36
|
+
depth = max_depth.to_i
|
|
37
|
+
depth = DEFAULT_DEPTH if depth <= 0
|
|
38
|
+
|
|
39
|
+
@count = 0
|
|
40
|
+
@truncated = false
|
|
41
|
+
lines = ["#{File.basename(root)}/"]
|
|
42
|
+
walk(root, depth, show_hidden, "", lines)
|
|
43
|
+
lines << "... (truncated at #{MAX_ENTRIES} entries)" if @truncated
|
|
44
|
+
|
|
45
|
+
truncate(lines.join("\n"))
|
|
46
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
47
|
+
error(e.message, code: :path_denied)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def walk(dir, depth_left, show_hidden, indent, lines)
|
|
53
|
+
return if depth_left <= 0 || @truncated
|
|
54
|
+
|
|
55
|
+
entries = children(dir, show_hidden)
|
|
56
|
+
entries.each_with_index do |entry, idx|
|
|
57
|
+
if @count >= MAX_ENTRIES
|
|
58
|
+
@truncated = true
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
full = File.join(dir, entry)
|
|
63
|
+
is_dir = File.directory?(full) && !File.symlink?(full)
|
|
64
|
+
connector = idx == entries.length - 1 ? "└── " : "├── "
|
|
65
|
+
lines << "#{indent}#{connector}#{entry}#{is_dir ? '/' : ''}"
|
|
66
|
+
@count += 1
|
|
67
|
+
|
|
68
|
+
next unless is_dir
|
|
69
|
+
|
|
70
|
+
child_indent = indent + (idx == entries.length - 1 ? " " : "│ ")
|
|
71
|
+
walk(full, depth_left - 1, show_hidden, child_indent, lines)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def children(dir, show_hidden)
|
|
76
|
+
entries = Dir.children(dir)
|
|
77
|
+
entries.reject! { |e| e.start_with?(".") } unless show_hidden
|
|
78
|
+
entries.reject! { |e| File.directory?(File.join(dir, e)) && Array(config.ignored_dirs).include?(e) }
|
|
79
|
+
# directories first, then files, each alphabetical
|
|
80
|
+
entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|