Commit bc67200
Changed files (8)
lib
spec
elelem
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)