Commit bc67200

mo khan <mo@mokhan.ca>
2025-11-06 19:23:19
refactor: add a Toolbox class
1 parent b0eb9b4
lib/elelem/agent.rb
@@ -2,16 +2,16 @@
 
 module Elelem
   class Agent
-    attr_reader :conversation, :client, :tools
+    attr_reader :conversation, :client, :toolbox
 
-    def initialize(client)
+    def initialize(client, toolbox)
       @conversation = Conversation.new
       @client = client
-      @tools = {
-        read: [grep_tool, list_tool, read_tool],
-        write: [patch_tool, write_tool],
-        execute: [exec_tool]
-      }
+      @toolbox = toolbox
+    end
+
+    def tools
+      toolbox.all
     end
 
     def repl
@@ -36,7 +36,7 @@ module Elelem
             puts "  → Mode: verify (read + execute)"
           when "/mode"
             puts "  Mode: #{mode.to_a.inspect}"
-            puts "  Tools: #{tools_for(mode).map { |t| t.dig(:function, :name) }}"
+            puts "  Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
           when "/exit" then exit
           when "/clear"
             conversation.clear
@@ -47,7 +47,7 @@ module Elelem
           end
         else
           conversation.add(role: :user, content: input)
-          result = execute_turn(conversation.history_for(mode), tools: tools_for(mode))
+          result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
           conversation.add(role: result[:role], content: result[:content])
         end
       end
@@ -69,10 +69,6 @@ module Elelem
       HELP
     end
 
-    def tools_for(modes)
-      modes.map { |mode| tools[mode] }.flatten
-    end
-
     def format_tool_call(name, args)
       case name
       when "execute"
@@ -119,7 +115,7 @@ module Elelem
             args = call.dig("function", "arguments")
 
             puts "Tool> #{format_tool_call(name, args)}"
-            result = run_tool(name, args)
+            result = toolbox.run_tool(name, args)
             turn_context << { role: "tool", content: JSON.dump(result) }
           end
 
@@ -130,120 +126,5 @@ module Elelem
         return { role: "assistant", content: content }
       end
     end
-
-    def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
-      cmd = command.is_a?(Array) ? command.first : command
-      cmd_args = command.is_a?(Array) ? command[1..] + args : args
-      stdout, stderr, status = Open3.capture3(env, cmd, *cmd_args, chdir: cwd, stdin_data: stdin)
-      {
-        "exit_status" => status.exitstatus,
-        "stdout" => stdout.to_s,
-        "stderr" => stderr.to_s
-      }
-    end
-
-    def expand_path(path)
-      Pathname.new(path).expand_path
-    end
-
-    def read_file(path)
-      full_path = expand_path(path)
-      full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
-    end
-
-    def write_file(path, content)
-      full_path = expand_path(path)
-      FileUtils.mkdir_p(full_path.dirname)
-      { bytes_written: full_path.write(content) }
-    end
-
-    def run_tool(name, args)
-      case name
-      when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"], stdin: args["stdin"])
-      when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
-      when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
-      when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
-      when "read" then read_file(args["path"])
-      when "write" then write_file(args["path"], args["content"])
-      else
-        { error: "Unknown tool", name: name, args: args }
-      end
-    rescue => error
-      { error: error.message, name: name, args: args }
-    end
-
-    def exec_tool
-      build_tool(
-        "execute",
-        "Execute shell commands directly. Commands run in a shell context. Examples: 'date', 'git status'.",
-        {
-          cmd: { type: "string" },
-          args: { type: "array", items: { type: "string" } },
-          env: { type: "object", additionalProperties: { type: "string" } },
-          cwd: { type: "string", description: "Working directory (defaults to current)" },
-          stdin: { type: "string" }
-        },
-        ["cmd"]
-      )
-    end
-
-    def grep_tool
-      build_tool(
-        "grep",
-        "Search all git-tracked files using git grep. Returns file paths with matching line numbers.",
-        { query: { type: "string" } },
-        ["query"]
-      )
-    end
-
-    def list_tool
-      build_tool(
-        "list",
-        "List all git-tracked files in the repository, optionally filtered by path.",
-        { path: { type: "string" } }
-      )
-    end
-
-    def patch_tool
-      build_tool(
-        "patch",
-        "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.",
-        { diff: { type: "string" } },
-        ["diff"]
-      )
-    end
-
-    def read_tool
-      build_tool(
-        "read",
-        "Read complete contents of a file. Requires exact file path.",
-        { path: { type: "string" } },
-        ["path"]
-      )
-    end
-
-    def write_tool
-      build_tool(
-        "write",
-        "Write complete file contents (overwrites existing files). Creates parent directories automatically.",
-        { path: { type: "string" }, content: { type: "string" } },
-        ["path", "content"]
-      )
-    end
-
-    def build_tool(name, description, properties, required = [])
-      {
-        type: "function",
-        function: {
-          name: name,
-          description: description,
-          parameters: {
-            type: "object",
-            properties: properties,
-            required: required
-          }
-        }
-      }
-    end
   end
 end
lib/elelem/application.rb
@@ -20,8 +20,7 @@ module Elelem
         model: options[:model],
       )
       say "Agent (#{options[:model]})", :green
-      agent = Agent.new(client)
-
+      agent = Agent.new(client, Toolbox.new)
       agent.repl
     end
 
lib/elelem/toolbox.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Toolbox
+    attr_reader :tools
+
+    def initialize()
+      @tools = {
+        read: [grep_tool, list_tool, read_tool],
+        write: [patch_tool, write_tool],
+        execute: [exec_tool]
+      }
+    end
+
+    def tools_for(modes)
+      modes.map { |mode| tools[mode] }.flatten
+    end
+
+    def run_tool(name, args)
+      case name
+      when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"], stdin: args["stdin"])
+      when "grep" then run_grep(args)
+      when "list" then run_list(args)
+      when "patch" then run_patch(args)
+      when "read" then read_file(args["path"])
+      when "write" then write_file(args["path"], args["content"])
+      else
+        { error: "Unknown tool", name: name, args: args }
+      end
+    rescue => error
+      { error: error.message, name: name, args: args }
+    end
+
+    private
+
+    def expand_path(path)
+      Pathname.new(path).expand_path
+    end
+
+    def exec_tool
+      build_tool(
+        "execute",
+        "Execute shell commands directly. Commands run in a shell context. Examples: 'date', 'git status'.",
+        {
+          cmd: { type: "string" },
+          args: { type: "array", items: { type: "string" } },
+          env: { type: "object", additionalProperties: { type: "string" } },
+          cwd: { type: "string", description: "Working directory (defaults to current)" },
+          stdin: { type: "string" }
+        },
+        ["cmd"]
+      )
+    end
+
+    def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
+      cmd = command.is_a?(Array) ? command.first : command
+      cmd_args = command.is_a?(Array) ? command[1..] + args : args
+      stdout, stderr, status = Open3.capture3(env, cmd, *cmd_args, chdir: cwd, stdin_data: stdin)
+      {
+        "exit_status" => status.exitstatus,
+        "stdout" => stdout.to_s,
+        "stderr" => stderr.to_s
+      }
+    end
+
+    def grep_tool
+      build_tool(
+        "grep",
+        "Search all git-tracked files using git grep. Returns file paths with matching line numbers.",
+        { query: { type: "string" } },
+        ["query"]
+      )
+    end
+
+    def run_grep(args)
+      run_exec("git", args: ["grep", "-nI", args["query"]])
+    end
+
+    def list_tool
+      build_tool(
+        "list",
+        "List all git-tracked files in the repository, optionally filtered by path.",
+        { path: { type: "string" } }
+      )
+    end
+
+    def run_list(args)
+      run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
+    end
+
+    def patch_tool
+      build_tool(
+        "patch",
+        "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.",
+        { diff: { type: "string" } },
+        ["diff"]
+      )
+    end
+
+    def run_patch(args)
+      run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
+    end
+
+    def read_tool
+      build_tool(
+        "read",
+        "Read complete contents of a file. Requires exact file path.",
+        { path: { type: "string" } },
+        ["path"]
+      )
+    end
+
+    def read_file(path)
+      full_path = expand_path(path)
+      full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
+    end
+
+
+    def write_tool
+      build_tool(
+        "write",
+        "Write complete file contents (overwrites existing files). Creates parent directories automatically.",
+        { path: { type: "string" }, content: { type: "string" } },
+        ["path", "content"]
+      )
+    end
+
+    def write_file(path, content)
+      full_path = expand_path(path)
+      FileUtils.mkdir_p(full_path.dirname)
+      { bytes_written: full_path.write(content) }
+    end
+
+    def build_tool(name, description, properties, required = [])
+      {
+        type: "function",
+        function: {
+          name: name,
+          description: description,
+          parameters: {
+            type: "object",
+            properties: properties,
+            required: required
+          }
+        }
+      }
+    end
+  end
+end
lib/elelem.rb
@@ -16,6 +16,7 @@ require "timeout"
 require_relative "elelem/agent"
 require_relative "elelem/application"
 require_relative "elelem/conversation"
+require_relative "elelem/toolbox"
 require_relative "elelem/version"
 
 Reline.input = $stdin
spec/elelem/agent_spec.rb
@@ -2,7 +2,7 @@
 
 RSpec.describe Elelem::Agent do
   let(:mock_client) { double("client") }
-  let(:agent) { described_class.new(mock_client) }
+  let(:agent) { described_class.new(mock_client, Elelem::Toolbox.new) }
 
   describe "#initialize" do
     it "creates a new conversation" do
@@ -14,55 +14,9 @@ RSpec.describe Elelem::Agent do
     end
 
     it "initializes tools for all modes" do
-      expect(agent.tools[:read]).to be_an(Array)
-      expect(agent.tools[:write]).to be_an(Array)
-      expect(agent.tools[:execute]).to be_an(Array)
-    end
-  end
-
-  describe "#tools_for" do
-    it "returns read tools for read mode" do
-      mode = Set[:read]
-      tools = agent.send(:tools_for, mode)
-
-      tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("grep", "list", "read")
-      expect(tool_names).not_to include("write", "patch", "execute")
-    end
-
-    it "returns write tools for write mode" do
-      mode = Set[:write]
-      tools = agent.send(:tools_for, mode)
-
-      tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("patch", "write")
-      expect(tool_names).not_to include("grep", "execute")
-    end
-
-    it "returns execute tools for execute mode" do
-      mode = Set[:execute]
-      tools = agent.send(:tools_for, mode)
-
-      tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("execute")
-      expect(tool_names).not_to include("grep", "write")
-    end
-
-    it "returns all tools for auto mode" do
-      mode = Set[:read, :write, :execute]
-      tools = agent.send(:tools_for, mode)
-
-      tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("grep", "list", "read", "patch", "write", "execute")
-    end
-
-    it "returns combined tools for build mode" do
-      mode = Set[:read, :write]
-      tools = agent.send(:tools_for, mode)
-
-      tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("grep", "read", "write", "patch")
-      expect(tool_names).not_to include("execute")
+      expect(agent.toolbox.tools[:read]).to be_an(Array)
+      expect(agent.toolbox.tools[:write]).to be_an(Array)
+      expect(agent.toolbox.tools[:execute]).to be_an(Array)
     end
   end
 
spec/elelem/toolbox_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+#
+RSpec.describe Elelem::Toolbox do
+  subject { described_class.new }
+
+  describe "#tools_for" do
+    it "returns read tools for read mode" do
+      mode = Set[:read]
+      tools = subject.tools_for(mode)
+
+      tool_names = tools.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("grep", "list", "read")
+      expect(tool_names).not_to include("write", "patch", "execute")
+    end
+
+    it "returns write tools for write mode" do
+      mode = Set[:write]
+      tools = subject.tools_for(mode)
+
+      tool_names = tools.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("patch", "write")
+      expect(tool_names).not_to include("grep", "execute")
+    end
+
+    it "returns execute tools for execute mode" do
+      mode = Set[:execute]
+      tools = subject.tools_for(mode)
+
+      tool_names = tools.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("execute")
+      expect(tool_names).not_to include("grep", "write")
+    end
+
+    it "returns all tools for auto mode" do
+      mode = Set[:read, :write, :execute]
+      tools = subject.tools_for(mode)
+
+      tool_names = tools.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("grep", "list", "read", "patch", "write", "execute")
+    end
+
+    it "returns combined tools for build mode" do
+      mode = Set[:read, :write]
+      tools = subject.tools_for(mode)
+
+      tool_names = tools.map { |t| t.dig(:function, :name) }
+      expect(tool_names).to include("grep", "read", "write", "patch")
+      expect(tool_names).not_to include("execute")
+    end
+  end
+end
elelem.gemspec
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
     "lib/elelem/application.rb",
     "lib/elelem/conversation.rb",
     "lib/elelem/system_prompt.erb",
+    "lib/elelem/toolbox.rb",
     "lib/elelem/version.rb",
   ]
   spec.bindir = "exe"
@@ -45,12 +46,15 @@ Gem::Specification.new do |spec|
   spec.require_paths = ["lib"]
 
   spec.add_dependency "erb"
+  spec.add_dependency "fileutils"
   spec.add_dependency "json"
   spec.add_dependency "json-schema"
   spec.add_dependency "logger"
   spec.add_dependency "net-llm"
   spec.add_dependency "open3"
+  spec.add_dependency "pathname"
   spec.add_dependency "reline"
+  spec.add_dependency "set"
   spec.add_dependency "thor"
   spec.add_dependency "timeout"
 end
Gemfile.lock
@@ -3,12 +3,15 @@ PATH
   specs:
     elelem (0.3.0)
       erb
+      fileutils
       json
       json-schema
       logger
       net-llm
       open3
+      pathname
       reline
+      set
       thor
       timeout
 
@@ -22,6 +25,7 @@ GEM
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (5.0.2)
+    fileutils (1.8.0)
     io-console (0.8.1)
     irb (1.15.2)
       pp (>= 0.6.0)
@@ -46,6 +50,7 @@ GEM
       uri (~> 1.0)
     open3 (0.2.1)
     openssl (3.3.1)
+    pathname (0.4.0)
     pp (0.6.2)
       prettyprint
     prettyprint (0.2.0)
@@ -72,6 +77,7 @@ GEM
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.13.0)
     rspec-support (3.13.4)
+    set (1.1.2)
     stringio (3.1.7)
     thor (1.3.2)
     timeout (0.4.3)