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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/http_helpers"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# SAFE. Fetches a URL over http/https and returns its readable text (HTML
|
|
10
|
+
# is stripped to text). Every request and redirect hop passes through
|
|
11
|
+
# UrlGuard, so it can't be pointed at internal/loopback/metadata addresses.
|
|
12
|
+
# Output is token-budgeted.
|
|
13
|
+
class WebFetch < Base
|
|
14
|
+
include HttpHelpers
|
|
15
|
+
|
|
16
|
+
description "Fetch a web page or text/JSON resource over http/https and return its readable " \
|
|
17
|
+
"content (HTML is converted to text). Follows redirects safely. Cannot reach " \
|
|
18
|
+
"private, loopback, or cloud-metadata addresses."
|
|
19
|
+
|
|
20
|
+
param :url, type: "string",
|
|
21
|
+
desc: "The http/https URL to fetch.",
|
|
22
|
+
required: true
|
|
23
|
+
param :unsafe, type: "boolean",
|
|
24
|
+
desc: "Request bypassing SSRF protection (UrlGuard). Only takes effect if an " \
|
|
25
|
+
"operator enabled allow_unsafe; otherwise the call is refused. Default false.",
|
|
26
|
+
required: false
|
|
27
|
+
|
|
28
|
+
def execute(url:, unsafe: false)
|
|
29
|
+
bypass = permit_unsafe!(unsafe, url)
|
|
30
|
+
response = guarded_get(url, guard: !bypass)
|
|
31
|
+
|
|
32
|
+
if response.status >= 400
|
|
33
|
+
return error("HTTP #{response.status} from #{response.final_url}", code: :http_error)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
content_type = content_type_of(response)
|
|
37
|
+
text = extract_text(response.body, content_type)
|
|
38
|
+
|
|
39
|
+
header = "GET #{response.final_url} -> #{response.status} (#{content_type})"
|
|
40
|
+
truncate("#{header}\n\n#{text}".strip)
|
|
41
|
+
rescue Safety::UrlGuard::Blocked => e
|
|
42
|
+
error(e.message, code: :url_blocked)
|
|
43
|
+
rescue HttpHelpers::FetchError => e
|
|
44
|
+
error("fetch failed: #{e.message}", code: :fetch_failed)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def content_type_of(response)
|
|
50
|
+
Array(response.headers["content-type"]).first.to_s.split(";").first.to_s.strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_text(body, content_type)
|
|
54
|
+
text = body.to_s.scrub
|
|
55
|
+
return collapse(text) unless content_type =~ /html|xml/i
|
|
56
|
+
|
|
57
|
+
text = text.dup
|
|
58
|
+
text.gsub!(%r{<script\b[^>]*>.*?</script>}mi, " ")
|
|
59
|
+
text.gsub!(%r{<style\b[^>]*>.*?</style>}mi, " ")
|
|
60
|
+
text.gsub!(/<!--.*?-->/m, " ")
|
|
61
|
+
text.gsub!(%r{</(?:p|div|br|li|h[1-6]|tr|section|article)\s*/?>}i, "\n")
|
|
62
|
+
text.gsub!(/<[^>]+>/, " ")
|
|
63
|
+
collapse(decode_entities(text))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def decode_entities(str)
|
|
67
|
+
str.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
68
|
+
.gsub(""", '"').gsub("'", "'").gsub(" ", " ")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def collapse(str)
|
|
72
|
+
str.gsub(/[ \t]+/, " ").gsub(/\n[ \t]*/, "\n").gsub(/\n{3,}/, "\n\n").strip
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/search/tavily"
|
|
5
|
+
require "ruby_llm/toolbox/search/brave"
|
|
6
|
+
require "ruby_llm/toolbox/search/searxng"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module Toolbox
|
|
10
|
+
module Tools
|
|
11
|
+
# SAFE. Searches the web through the configured adapter (Tavily by
|
|
12
|
+
# default) and returns ranked results plus an optional synthesized answer.
|
|
13
|
+
# Requires an API key for the default Tavily adapter (config.tavily_api_key).
|
|
14
|
+
class WebSearch < Base
|
|
15
|
+
description "Search the web and return ranked results (title, URL, snippet) with an optional " \
|
|
16
|
+
"synthesized answer. Uses Tavily by default (needs config.tavily_api_key); switch " \
|
|
17
|
+
"providers with config.search_adapter set to :brave, :searxng, or a custom adapter."
|
|
18
|
+
|
|
19
|
+
param :query, type: "string",
|
|
20
|
+
desc: "The search query.",
|
|
21
|
+
required: true
|
|
22
|
+
param :max_results, type: "integer",
|
|
23
|
+
desc: "Number of results to return (1-10, default 5).",
|
|
24
|
+
required: false
|
|
25
|
+
|
|
26
|
+
def execute(query:, max_results: 5)
|
|
27
|
+
q = query.to_s.strip
|
|
28
|
+
return error("query must not be empty", code: :bad_query) if q.empty?
|
|
29
|
+
|
|
30
|
+
n = max_results.to_i
|
|
31
|
+
n = 5 if n <= 0
|
|
32
|
+
n = 10 if n > 10
|
|
33
|
+
|
|
34
|
+
result = adapter.search(q, max_results: n)
|
|
35
|
+
truncate(format_results(q, result))
|
|
36
|
+
rescue Search::Error => e
|
|
37
|
+
code = e.message.match?(/missing .*(api key|url)/i) ? :no_api_key : :search_failed
|
|
38
|
+
error(e.message, code: code)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# config.search_adapter may be: nil (→ Tavily), a Symbol naming a built-in
|
|
44
|
+
# adapter, or any object that responds to #search(query, max_results:).
|
|
45
|
+
def adapter
|
|
46
|
+
case config.search_adapter
|
|
47
|
+
when nil, :tavily
|
|
48
|
+
Search::Tavily.new(api_key: config.tavily_api_key, user_agent: config.user_agent,
|
|
49
|
+
timeout: config.http_timeout)
|
|
50
|
+
when :brave
|
|
51
|
+
Search::Brave.new(api_key: config.brave_api_key, user_agent: config.user_agent,
|
|
52
|
+
timeout: config.http_timeout)
|
|
53
|
+
when :searxng
|
|
54
|
+
Search::SearXNG.new(base_url: config.searxng_url, user_agent: config.user_agent,
|
|
55
|
+
timeout: config.http_timeout)
|
|
56
|
+
when Symbol
|
|
57
|
+
raise Search::Error, "unknown search adapter #{config.search_adapter.inspect} " \
|
|
58
|
+
"(expected :tavily, :brave, :searxng, or an adapter object)"
|
|
59
|
+
else
|
|
60
|
+
config.search_adapter
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_results(query, result)
|
|
65
|
+
results = Array(result[:results])
|
|
66
|
+
return "no results for #{query.inspect}" if results.empty?
|
|
67
|
+
|
|
68
|
+
body = +"Results for #{query.inspect}:\n"
|
|
69
|
+
results.each_with_index do |r, i|
|
|
70
|
+
body << "\n#{i + 1}. #{r[:title]}\n #{r[:url]}\n"
|
|
71
|
+
snippet = r[:content].to_s.strip
|
|
72
|
+
body << " #{snippet}\n" unless snippet.empty?
|
|
73
|
+
end
|
|
74
|
+
answer = result[:answer].to_s.strip
|
|
75
|
+
body << "\nAnswer: #{answer}\n" unless answer.empty?
|
|
76
|
+
body
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
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. Creates or overwrites a text file within fs_root. Missing parent
|
|
11
|
+
# directories are created (inside the jail). Path traversal and symlink
|
|
12
|
+
# escapes are rejected by PathJail.
|
|
13
|
+
class WriteFile < Base
|
|
14
|
+
exec_tool!
|
|
15
|
+
|
|
16
|
+
description "Create or overwrite a text file within fs_root. Missing parent directories " \
|
|
17
|
+
"are created automatically. Overwrites an existing file at the path."
|
|
18
|
+
|
|
19
|
+
param :path, type: "string",
|
|
20
|
+
desc: "File path relative to fs_root (or an absolute path inside it).",
|
|
21
|
+
required: true
|
|
22
|
+
param :content, type: "string",
|
|
23
|
+
desc: "Full contents to write. Replaces the file entirely.",
|
|
24
|
+
required: true
|
|
25
|
+
param :unsafe, type: "boolean",
|
|
26
|
+
desc: "Request writing outside fs_root (bypass the path jail). Only takes effect " \
|
|
27
|
+
"if an operator enabled allow_unsafe; otherwise the call is refused. Default false.",
|
|
28
|
+
required: false
|
|
29
|
+
|
|
30
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
def execute(path:, content:, unsafe: false)
|
|
33
|
+
body = content.to_s
|
|
34
|
+
return error("content too large (> #{MAX_BYTES} bytes)", code: :too_large) if body.bytesize > MAX_BYTES
|
|
35
|
+
|
|
36
|
+
jail = path_jail(unsafe: unsafe, detail: path)
|
|
37
|
+
real = jail.resolve(path)
|
|
38
|
+
return error("path is a directory: #{path}", code: :is_a_directory) if File.directory?(real)
|
|
39
|
+
|
|
40
|
+
existed = File.exist?(real)
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(real))
|
|
42
|
+
File.write(real, body)
|
|
43
|
+
|
|
44
|
+
verb = existed ? "Overwrote" : "Created"
|
|
45
|
+
"#{verb} #{path} (#{body.bytesize} bytes)"
|
|
46
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
47
|
+
error(e.message, code: :path_denied)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "date"
|
|
6
|
+
require "ruby_llm/toolbox/base"
|
|
7
|
+
require "ruby_llm/toolbox/data_path"
|
|
8
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
9
|
+
|
|
10
|
+
module RubyLLM
|
|
11
|
+
module Toolbox
|
|
12
|
+
module Tools
|
|
13
|
+
# SAFE. Parses YAML (from a file in fs_root or an inline string) and either
|
|
14
|
+
# pretty-prints it (as JSON, for readability) or extracts a value with a
|
|
15
|
+
# dot/bracket path. Uses safe_load — no arbitrary Ruby objects are
|
|
16
|
+
# instantiated from the document.
|
|
17
|
+
#
|
|
18
|
+
# Path syntax matches json_query: users[0].name, users[].email, config.port
|
|
19
|
+
class YamlQuery < Base
|
|
20
|
+
description "Query YAML with a path expression, or pretty-print it. Provide either a file path " \
|
|
21
|
+
"(within fs_root) or an inline yaml string. Loaded safely (no object deserialization). " \
|
|
22
|
+
"Path syntax: keys separated by dots, [n] for array index, [] to map over an array."
|
|
23
|
+
|
|
24
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
25
|
+
PERMITTED = [Date, Time, Symbol].freeze
|
|
26
|
+
|
|
27
|
+
param :path, type: "string",
|
|
28
|
+
desc: "YAML file to read, relative to fs_root. Provide this or yaml.",
|
|
29
|
+
required: false
|
|
30
|
+
param :yaml, type: "string",
|
|
31
|
+
desc: "Inline YAML string. Provide this or path.",
|
|
32
|
+
required: false
|
|
33
|
+
param :query, type: "string",
|
|
34
|
+
desc: "Path expression to extract, e.g. 'services[0].image'. Omit to pretty-print. Optional.",
|
|
35
|
+
required: false
|
|
36
|
+
|
|
37
|
+
def execute(path: nil, yaml: nil, query: nil)
|
|
38
|
+
source = load_source(path, yaml)
|
|
39
|
+
return source if source.is_a?(Hash) # error
|
|
40
|
+
|
|
41
|
+
data = YAML.safe_load(source, permitted_classes: PERMITTED, aliases: true)
|
|
42
|
+
result = query.to_s.strip.empty? ? data : DataPath.query(data, query)
|
|
43
|
+
truncate(JSON.pretty_generate(result))
|
|
44
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
45
|
+
error(e.message, code: :path_denied)
|
|
46
|
+
rescue Psych::DisallowedClass => e
|
|
47
|
+
error("YAML contains a disallowed type: #{e.message}", code: :unsafe_yaml)
|
|
48
|
+
rescue Psych::SyntaxError => e
|
|
49
|
+
error("invalid YAML: #{e.message}", code: :bad_yaml)
|
|
50
|
+
rescue DataPath::Error => e
|
|
51
|
+
error(e.message, code: :bad_query)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_source(path, yaml)
|
|
57
|
+
if path && !path.to_s.empty?
|
|
58
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
59
|
+
real = jail.resolve(path)
|
|
60
|
+
return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
|
|
61
|
+
return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
|
|
62
|
+
|
|
63
|
+
File.read(real).scrub
|
|
64
|
+
elsif yaml && !yaml.to_s.empty?
|
|
65
|
+
yaml.to_s
|
|
66
|
+
else
|
|
67
|
+
error("provide either path or yaml", code: :no_input)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/tokenizer"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Toolbox
|
|
7
|
+
# Token-budgets a string so a single tool result can never blow up the
|
|
8
|
+
# context window. Keeps the head and the tail (the two ends an agent most
|
|
9
|
+
# often needs) and elides the middle. Counting goes through
|
|
10
|
+
# ruby_llm-tokenizer; if no tokenizer backend is available for the model,
|
|
11
|
+
# it falls back to a ~4-chars-per-token heuristic so a tool never crashes
|
|
12
|
+
# just because a tokenizer gem isn't installed.
|
|
13
|
+
class Truncator
|
|
14
|
+
MARKER_RESERVE = 16 # tokens held back for the elision marker
|
|
15
|
+
|
|
16
|
+
def initialize(model:, max_tokens:)
|
|
17
|
+
@model = model
|
|
18
|
+
@max_tokens = [max_tokens.to_i, 1].max
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(text)
|
|
22
|
+
text = text.to_s
|
|
23
|
+
total = count(text)
|
|
24
|
+
return text if total <= @max_tokens
|
|
25
|
+
|
|
26
|
+
budget = [@max_tokens - MARKER_RESERVE, 2].max
|
|
27
|
+
head_budget = (budget * 0.6).floor
|
|
28
|
+
tail_budget = budget - head_budget
|
|
29
|
+
|
|
30
|
+
head = take(text, head_budget, from: :head)
|
|
31
|
+
tail = take(text, tail_budget, from: :tail)
|
|
32
|
+
cut = total - count(head) - count(tail)
|
|
33
|
+
|
|
34
|
+
"#{head}\n\n…[truncated ~#{cut} tokens]…\n\n#{tail}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def count(text)
|
|
40
|
+
RubyLLM::Tokenizer.count(text, model: @model)
|
|
41
|
+
rescue StandardError
|
|
42
|
+
(text.to_s.length / 4.0).ceil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Longest prefix (or suffix) of `text` whose token count fits `max_tokens`.
|
|
46
|
+
# Binary search keeps this O(log n) tokenizer calls instead of O(n).
|
|
47
|
+
def take(text, max_tokens, from:)
|
|
48
|
+
return +"" if max_tokens <= 0
|
|
49
|
+
return text if count(text) <= max_tokens
|
|
50
|
+
|
|
51
|
+
lo = 0
|
|
52
|
+
hi = text.length
|
|
53
|
+
best = +""
|
|
54
|
+
while lo <= hi
|
|
55
|
+
mid = (lo + hi) / 2
|
|
56
|
+
slice = (from == :head ? text[0, mid] : text[text.length - mid, mid]).to_s
|
|
57
|
+
if count(slice) <= max_tokens
|
|
58
|
+
best = slice
|
|
59
|
+
lo = mid + 1
|
|
60
|
+
else
|
|
61
|
+
hi = mid - 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
best
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
require_relative "toolbox/version"
|
|
6
|
+
require_relative "toolbox/configuration"
|
|
7
|
+
require_relative "toolbox/truncator"
|
|
8
|
+
require_relative "toolbox/base"
|
|
9
|
+
require_relative "toolbox/process_runner"
|
|
10
|
+
require_relative "toolbox/process_registry"
|
|
11
|
+
require_relative "toolbox/ruby_outline"
|
|
12
|
+
require_relative "toolbox/safety/path_jail"
|
|
13
|
+
require_relative "toolbox/safety/command_guard"
|
|
14
|
+
require_relative "toolbox/safety/url_guard"
|
|
15
|
+
require_relative "toolbox/sandbox/base"
|
|
16
|
+
require_relative "toolbox/sandbox/docker"
|
|
17
|
+
require_relative "toolbox/sandbox/bubblewrap"
|
|
18
|
+
require_relative "toolbox/sandbox/sandbox_exec"
|
|
19
|
+
require_relative "toolbox/tools/read_file"
|
|
20
|
+
require_relative "toolbox/tools/list_directory"
|
|
21
|
+
require_relative "toolbox/tools/tree"
|
|
22
|
+
require_relative "toolbox/tools/glob"
|
|
23
|
+
require_relative "toolbox/tools/grep_files"
|
|
24
|
+
require_relative "toolbox/tools/gem_tool"
|
|
25
|
+
require_relative "toolbox/tools/parse_ruby"
|
|
26
|
+
require_relative "toolbox/tools/web_fetch"
|
|
27
|
+
require_relative "toolbox/tools/web_search"
|
|
28
|
+
require_relative "toolbox/tools/http_request"
|
|
29
|
+
require_relative "toolbox/tools/json_query"
|
|
30
|
+
require_relative "toolbox/tools/yaml_query"
|
|
31
|
+
require_relative "toolbox/tools/toml_query"
|
|
32
|
+
require_relative "toolbox/tools/csv_read"
|
|
33
|
+
require_relative "toolbox/tools/calculator"
|
|
34
|
+
require_relative "toolbox/tools/date_time"
|
|
35
|
+
require_relative "toolbox/tools/diff"
|
|
36
|
+
require_relative "toolbox/tools/todo_write"
|
|
37
|
+
require_relative "toolbox/tools/process_output"
|
|
38
|
+
require_relative "toolbox/tools/process_list"
|
|
39
|
+
require_relative "toolbox/tools/process_kill"
|
|
40
|
+
require_relative "toolbox/tools/git_helpers"
|
|
41
|
+
require_relative "toolbox/tools/git_status"
|
|
42
|
+
require_relative "toolbox/tools/git_diff"
|
|
43
|
+
require_relative "toolbox/tools/git_log"
|
|
44
|
+
require_relative "toolbox/tools/git_show"
|
|
45
|
+
require_relative "toolbox/tools/git_blame"
|
|
46
|
+
require_relative "toolbox/tools/git_grep"
|
|
47
|
+
require_relative "toolbox/tools/git_branch"
|
|
48
|
+
require_relative "toolbox/tools/write_file"
|
|
49
|
+
require_relative "toolbox/tools/edit_file"
|
|
50
|
+
require_relative "toolbox/tools/multi_edit"
|
|
51
|
+
require_relative "toolbox/tools/create_directory"
|
|
52
|
+
require_relative "toolbox/tools/move_file"
|
|
53
|
+
require_relative "toolbox/tools/delete_file"
|
|
54
|
+
require_relative "toolbox/tools/git_add"
|
|
55
|
+
require_relative "toolbox/tools/git_commit"
|
|
56
|
+
require_relative "toolbox/tools/git_checkout"
|
|
57
|
+
require_relative "toolbox/tools/apply_patch"
|
|
58
|
+
require_relative "toolbox/tools/csv_write"
|
|
59
|
+
require_relative "toolbox/tools/replace_in_files"
|
|
60
|
+
require_relative "toolbox/tools/download_file"
|
|
61
|
+
require_relative "toolbox/tools/toolchain_helpers"
|
|
62
|
+
require_relative "toolbox/tools/run_tests"
|
|
63
|
+
require_relative "toolbox/tools/python_tests"
|
|
64
|
+
require_relative "toolbox/tools/lint"
|
|
65
|
+
require_relative "toolbox/tools/bundle"
|
|
66
|
+
require_relative "toolbox/tools/sandbox_run"
|
|
67
|
+
require_relative "toolbox/tools/bash_tool"
|
|
68
|
+
require_relative "toolbox/tools/process_start"
|
|
69
|
+
require_relative "toolbox/tools/run_ruby"
|
|
70
|
+
require_relative "toolbox/tools/run_python"
|
|
71
|
+
require_relative "toolbox/tools/run_rust"
|
|
72
|
+
|
|
73
|
+
module RubyLLM
|
|
74
|
+
# A safe-by-default bundle of RubyLLM::Tool classes covering the skills common
|
|
75
|
+
# to most LLM harnesses. One require loads everything; the dangerous tools are
|
|
76
|
+
# inert until you flip config.enable_exec_tools.
|
|
77
|
+
module Toolbox
|
|
78
|
+
class << self
|
|
79
|
+
def config
|
|
80
|
+
@config ||= Configuration.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def configure
|
|
84
|
+
yield config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resets global config. Primarily for tests.
|
|
88
|
+
def reset!
|
|
89
|
+
@config = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Always-on, read-only tools.
|
|
93
|
+
def safe_tools(**overrides)
|
|
94
|
+
[
|
|
95
|
+
Tools::ReadFile.new(**overrides),
|
|
96
|
+
Tools::ListDirectory.new(**overrides),
|
|
97
|
+
Tools::Tree.new(**overrides),
|
|
98
|
+
Tools::Glob.new(**overrides),
|
|
99
|
+
Tools::GrepFiles.new(**overrides),
|
|
100
|
+
Tools::GemTool.new(**overrides),
|
|
101
|
+
Tools::ParseRuby.new(**overrides),
|
|
102
|
+
Tools::WebFetch.new(**overrides),
|
|
103
|
+
Tools::WebSearch.new(**overrides),
|
|
104
|
+
Tools::HttpRequest.new(**overrides),
|
|
105
|
+
Tools::GitStatus.new(**overrides),
|
|
106
|
+
Tools::GitDiff.new(**overrides),
|
|
107
|
+
Tools::GitLog.new(**overrides),
|
|
108
|
+
Tools::GitShow.new(**overrides),
|
|
109
|
+
Tools::GitBlame.new(**overrides),
|
|
110
|
+
Tools::GitGrep.new(**overrides),
|
|
111
|
+
Tools::GitBranch.new(**overrides),
|
|
112
|
+
Tools::JsonQuery.new(**overrides),
|
|
113
|
+
Tools::YamlQuery.new(**overrides),
|
|
114
|
+
Tools::TomlQuery.new(**overrides),
|
|
115
|
+
Tools::CsvRead.new(**overrides),
|
|
116
|
+
Tools::Calculator.new(**overrides),
|
|
117
|
+
Tools::DateTime.new(**overrides),
|
|
118
|
+
Tools::Diff.new(**overrides),
|
|
119
|
+
Tools::TodoWrite.new(**overrides),
|
|
120
|
+
Tools::ProcessOutput.new(**overrides),
|
|
121
|
+
Tools::ProcessList.new(**overrides),
|
|
122
|
+
Tools::ProcessKill.new(**overrides)
|
|
123
|
+
]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Tools whose effects are gated behind enable_exec_tools.
|
|
127
|
+
def exec_tools(**overrides)
|
|
128
|
+
[
|
|
129
|
+
Tools::WriteFile.new(**overrides),
|
|
130
|
+
Tools::EditFile.new(**overrides),
|
|
131
|
+
Tools::MultiEdit.new(**overrides),
|
|
132
|
+
Tools::CreateDirectory.new(**overrides),
|
|
133
|
+
Tools::MoveFile.new(**overrides),
|
|
134
|
+
Tools::DeleteFile.new(**overrides),
|
|
135
|
+
Tools::GitAdd.new(**overrides),
|
|
136
|
+
Tools::GitCommit.new(**overrides),
|
|
137
|
+
Tools::GitCheckout.new(**overrides),
|
|
138
|
+
Tools::ApplyPatch.new(**overrides),
|
|
139
|
+
Tools::CsvWrite.new(**overrides),
|
|
140
|
+
Tools::ReplaceInFiles.new(**overrides),
|
|
141
|
+
Tools::DownloadFile.new(**overrides),
|
|
142
|
+
Tools::RunTests.new(**overrides),
|
|
143
|
+
Tools::PythonTests.new(**overrides),
|
|
144
|
+
Tools::Lint.new(**overrides),
|
|
145
|
+
Tools::Bundle.new(**overrides),
|
|
146
|
+
Tools::BashTool.new(**overrides),
|
|
147
|
+
Tools::ProcessStart.new(**overrides),
|
|
148
|
+
Tools::RunRuby.new(**overrides),
|
|
149
|
+
Tools::RunPython.new(**overrides),
|
|
150
|
+
Tools::RunRust.new(**overrides)
|
|
151
|
+
]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Everything. Exec tools still honor the gate at call time, so handing the
|
|
155
|
+
# full set to a chat is safe by default.
|
|
156
|
+
def all_tools(**overrides)
|
|
157
|
+
safe_tools(**overrides) + exec_tools(**overrides)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|