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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/GUIDE.md +598 -0
  4. data/LICENSE +21 -0
  5. data/README.md +412 -0
  6. data/bin/verify_prism_parity +112 -0
  7. data/lib/ruby_llm/toolbox/base.rb +112 -0
  8. data/lib/ruby_llm/toolbox/configuration.rb +148 -0
  9. data/lib/ruby_llm/toolbox/data_path.rb +54 -0
  10. data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
  11. data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
  12. data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
  13. data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
  14. data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
  15. data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
  16. data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
  17. data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
  18. data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
  19. data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
  20. data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
  21. data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
  22. data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
  23. data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
  24. data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
  25. data/lib/ruby_llm/toolbox/toml.rb +409 -0
  26. data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
  27. data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
  28. data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
  29. data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
  30. data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
  31. data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
  32. data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
  33. data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
  34. data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
  35. data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
  36. data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
  37. data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
  38. data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
  39. data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
  40. data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
  41. data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
  42. data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
  43. data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
  44. data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
  45. data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
  46. data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
  47. data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
  48. data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
  49. data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
  50. data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
  51. data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
  52. data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
  53. data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
  54. data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
  55. data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
  56. data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
  57. data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
  58. data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
  59. data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
  60. data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
  61. data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
  62. data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
  63. data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
  64. data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
  65. data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
  66. data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
  67. data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
  68. data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
  69. data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
  70. data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
  71. data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
  72. data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
  73. data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
  74. data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
  75. data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
  76. data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
  77. data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
  78. data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
  79. data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
  80. data/lib/ruby_llm/toolbox/truncator.rb +68 -0
  81. data/lib/ruby_llm/toolbox/version.rb +7 -0
  82. data/lib/ruby_llm/toolbox.rb +161 -0
  83. 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