elelem 0.12.0 → 0.13.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 +4 -4
- data/README.md +5 -5
- data/exe/elelem +0 -6
- data/lib/elelem/agent.rb +18 -55
- data/lib/elelem/builtins/terminal.rb +101 -0
- data/lib/elelem/builtins.rb +52 -0
- data/lib/elelem/cli.rb +23 -68
- data/lib/elelem/commands.rb +8 -13
- data/lib/elelem/conversation.rb +9 -5
- data/lib/elelem/input.rb +10 -17
- data/lib/elelem/null_output.rb +3 -1
- data/lib/elelem/plugins/builtins.rb +16 -0
- data/lib/elelem/plugins/context.rb +31 -0
- data/lib/elelem/plugins/execute.rb +24 -0
- data/lib/elelem/plugins/provider.rb +22 -0
- data/lib/elelem/plugins/read.rb +29 -0
- data/lib/elelem/plugins/reload.rb +9 -0
- data/lib/elelem/plugins/terminal.rb +6 -0
- data/lib/elelem/plugins/tools.rb +31 -0
- data/lib/elelem/plugins/write.rb +37 -0
- data/lib/elelem/plugins.rb +7 -2
- data/lib/elelem/registration.rb +7 -2
- data/lib/elelem/registry.rb +12 -7
- data/lib/elelem/repl.rb +9 -9
- data/lib/elelem/result.rb +0 -4
- data/lib/elelem/session.rb +5 -1
- data/lib/elelem/stub_provider.rb +67 -6
- data/lib/elelem/system_prompt.rb +1 -1
- data/lib/elelem/tool.rb +7 -20
- data/lib/elelem/toolbox.rb +19 -32
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +29 -23
- metadata +47 -12
- data/lib/elelem/null_tool.rb +0 -18
- data/lib/elelem/schema.rb +0 -20
- data/lib/elelem/slash_command.rb +0 -18
- data/lib/elelem/transcript.rb +0 -26
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:provider) do |session|
|
|
5
|
+
session.commands.register("provider", description: "Switch provider: /provider NAME [MODEL]", completions: lambda {
|
|
6
|
+
Elelem::Config.names
|
|
7
|
+
}) do |args|
|
|
8
|
+
name, model = args.to_s.split
|
|
9
|
+
next session.output.say(" -> available: #{Elelem::Config.names.join(", ")}") if name.nil?
|
|
10
|
+
|
|
11
|
+
# --model carries over only to the provider it was given for.
|
|
12
|
+
options = if model then { model: }
|
|
13
|
+
elsif name == session.provider_name then session.provider_options
|
|
14
|
+
else {}
|
|
15
|
+
end
|
|
16
|
+
session.provider = Elelem::Config.build_provider(name, **options)
|
|
17
|
+
session.provider_name = name
|
|
18
|
+
session.provider_options = options
|
|
19
|
+
session.output.say " -> switched to #{name}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:read) do |agent|
|
|
5
|
+
agent.toolbox.add("read",
|
|
6
|
+
description: "Read file",
|
|
7
|
+
params: { path: { type: "string" } },
|
|
8
|
+
required: ["path"],
|
|
9
|
+
aliases: ["open"]) do |a|
|
|
10
|
+
path = Pathname.new(a["path"]).expand_path
|
|
11
|
+
next { error: "not found" } unless path.exist?
|
|
12
|
+
|
|
13
|
+
content = path.binread
|
|
14
|
+
next { error: "binary file (#{content.bytesize} bytes)" } if content.include?("\0")
|
|
15
|
+
|
|
16
|
+
content = content.force_encoding(Encoding::UTF_8).scrub
|
|
17
|
+
|
|
18
|
+
{ content: Elelem::Builtins.truncate(content), path: a["path"] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
agent.toolbox.after("read") do |_, result|
|
|
22
|
+
if result[:error]
|
|
23
|
+
agent.output.say " ! #{result[:error]}"
|
|
24
|
+
else
|
|
25
|
+
agent.output.display_file(result[:path], fallback: result[:content])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:tools) do |agent|
|
|
5
|
+
describe = lambda do |tool|
|
|
6
|
+
parameters = tool.to_h.dig(:function, :parameters)
|
|
7
|
+
lines = ["## #{tool.name}", "", tool.description, "", "### Parameters", ""]
|
|
8
|
+
parameters[:properties].each do |param, spec|
|
|
9
|
+
req = parameters[:required].include?(param.to_s) ? ", required" : ""
|
|
10
|
+
desc = spec[:description] ? " - #{spec[:description]}" : ""
|
|
11
|
+
lines << "- `#{param}` (#{spec[:type]}#{req})#{desc}"
|
|
12
|
+
end
|
|
13
|
+
lines << "" << "*aliases: #{tool.aliases.join(", ")}*" if tool.aliases.any?
|
|
14
|
+
lines.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
completions = -> { agent.toolbox.tools.keys }
|
|
18
|
+
agent.commands.register("tools", description: "List available tools", completions: completions) do |arg|
|
|
19
|
+
tool = agent.toolbox.tools[arg] if arg && !arg.empty?
|
|
20
|
+
if tool
|
|
21
|
+
agent.output.say describe.call(tool), as: :markdown
|
|
22
|
+
elsif arg && !arg.empty?
|
|
23
|
+
agent.output.say "Unknown tool: #{arg}"
|
|
24
|
+
else
|
|
25
|
+
# values copies the list, so a plugin adding tools from a thread cannot break this loop.
|
|
26
|
+
rows = agent.toolbox.tools.values.map { |t| "| #{t.name} | #{t.description.lines.first.chomp} |" }
|
|
27
|
+
agent.output.say "| Tool | Description |\n|------|-------------|\n#{rows.join("\n")}", as: :markdown
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:write) do |agent|
|
|
5
|
+
agent.toolbox.add("write",
|
|
6
|
+
description: "Write file",
|
|
7
|
+
params: { path: { type: "string" }, content: { type: "string" } },
|
|
8
|
+
required: %w[path content],
|
|
9
|
+
aliases: ["write<|channel|>"]) do |a|
|
|
10
|
+
path = Pathname.new(a["path"]).expand_path
|
|
11
|
+
FileUtils.mkdir_p(path.dirname)
|
|
12
|
+
{ bytes: path.write(a["content"]), path: a["path"] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
agent.toolbox.before("write") do |args|
|
|
16
|
+
path = Pathname.new(args["path"]).expand_path
|
|
17
|
+
next unless path.exist? && agent.input.interactive?
|
|
18
|
+
|
|
19
|
+
Tempfile.create(["elelem", File.extname(path)]) do |t|
|
|
20
|
+
t.write(args["content"])
|
|
21
|
+
t.flush
|
|
22
|
+
diff, = Open3.capture2("diff", "--color=always", "-u", path.to_s, t.path)
|
|
23
|
+
agent.output.print(diff)
|
|
24
|
+
end
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
nil # no diff installed
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
agent.toolbox.after("write") do |_, result|
|
|
30
|
+
agent.output.display_file(result[:path], fallback: " -> #{result[:path]}")
|
|
31
|
+
# The model sees the checks in the write's own result.
|
|
32
|
+
if agent.toolbox.tools.key?("verify")
|
|
33
|
+
result[:verify] = agent.toolbox.run("verify", { "path" => result[:path] }).to_h
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/elelem/plugins.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Plugins
|
|
5
5
|
LOAD_PATHS = [
|
|
6
|
+
File.expand_path("plugins", __dir__),
|
|
6
7
|
"~/.agents/plugins",
|
|
7
8
|
".agents/plugins"
|
|
8
9
|
].freeze
|
|
@@ -15,10 +16,14 @@ module Elelem
|
|
|
15
16
|
def load!(force: false)
|
|
16
17
|
return if @loaded && !force
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
dirs.each { |dir| load_dir(dir) }
|
|
19
20
|
@loaded = true
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
def dirs
|
|
24
|
+
@load_paths.map { |path| File.expand_path(path) }
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
private
|
|
23
28
|
|
|
24
29
|
def load_dir(dir)
|
|
@@ -30,7 +35,7 @@ module Elelem
|
|
|
30
35
|
def load_plugin(file)
|
|
31
36
|
load(file)
|
|
32
37
|
rescue StandardError, ScriptError => e
|
|
33
|
-
|
|
38
|
+
$stderr.puts "elelem: failed to load plugin #{file}: #{e.message}"
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
end
|
data/lib/elelem/registration.rb
CHANGED
|
@@ -30,8 +30,13 @@ module Elelem
|
|
|
30
30
|
self
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# Drops setups defined in files under dirs. Gem setups are kept, since
|
|
34
|
+
# require will not run them again.
|
|
35
|
+
def clear_setups_from!(dirs)
|
|
36
|
+
@setups.reject! do |_, block|
|
|
37
|
+
file = block.source_location&.first.to_s
|
|
38
|
+
dirs.any? { |dir| file.start_with?("#{dir}/") }
|
|
39
|
+
end
|
|
35
40
|
end
|
|
36
41
|
end
|
|
37
42
|
end
|
data/lib/elelem/registry.rb
CHANGED
|
@@ -12,9 +12,9 @@ module Elelem
|
|
|
12
12
|
self
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def build_provider(name)
|
|
15
|
+
def build_provider(name, **)
|
|
16
16
|
@plugins.load!
|
|
17
|
-
@registration.providers.fetch(name.to_s) { require_gem(name) }.call
|
|
17
|
+
@registration.providers.fetch(name.to_s) { require_gem(name) }.call(**)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def apply(session)
|
|
@@ -23,7 +23,7 @@ module Elelem
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def reload(session)
|
|
26
|
-
@registration.
|
|
26
|
+
@registration.clear_setups_from!(@plugins.dirs)
|
|
27
27
|
session.toolbox.clear!
|
|
28
28
|
session.commands.clear!
|
|
29
29
|
@plugins.load!(force: true)
|
|
@@ -34,10 +34,13 @@ module Elelem
|
|
|
34
34
|
@registration.providers.keys
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def build_session(provider, toolbox: Toolbox.new, input: nil, output: nil
|
|
37
|
+
def build_session(provider, toolbox: Toolbox.new, input: nil, output: nil, provider_options: {},
|
|
38
|
+
template: nil, system_prompt: nil)
|
|
38
39
|
commands = Commands.new
|
|
39
|
-
agent = Agent.new(build_provider(provider), toolbox: toolbox, output: output || build_output
|
|
40
|
-
|
|
40
|
+
agent = Agent.new(build_provider(provider, **provider_options), toolbox: toolbox, output: output || build_output,
|
|
41
|
+
template: template, system_prompt: system_prompt)
|
|
42
|
+
session = Session.new(agent, input: input || build_input(commands: commands), commands: commands,
|
|
43
|
+
provider_name: provider, provider_options: provider_options)
|
|
41
44
|
apply(session)
|
|
42
45
|
session
|
|
43
46
|
end
|
|
@@ -57,7 +60,9 @@ module Elelem
|
|
|
57
60
|
def require_gem(name)
|
|
58
61
|
require "elelem/#{name}"
|
|
59
62
|
@registration.providers.fetch(name.to_s) { unregistered_provider(name) }
|
|
60
|
-
rescue LoadError
|
|
63
|
+
rescue LoadError => e
|
|
64
|
+
raise unless e.path == "elelem/#{name}"
|
|
65
|
+
|
|
61
66
|
raise "unknown provider: #{name.inspect}. Available: #{names.join(", ")}. Run: gem install elelem-#{name}"
|
|
62
67
|
end
|
|
63
68
|
|
data/lib/elelem/repl.rb
CHANGED
|
@@ -15,20 +15,20 @@ module Elelem
|
|
|
15
15
|
|
|
16
16
|
private
|
|
17
17
|
|
|
18
|
+
# Only lines typed at the prompt go in history, not answers to a tool.
|
|
19
|
+
# A line is a command only when its first word names one, so a path such
|
|
20
|
+
# as /etc/hosts reaches the model. One failed turn does not end the session.
|
|
18
21
|
def dispatch_line(line)
|
|
19
|
-
|
|
22
|
+
@session.input.remember(line) if @session.input.respond_to?(:remember)
|
|
23
|
+
command(line) || @session.turn(line)
|
|
24
|
+
rescue StandardError, ScriptError => e
|
|
25
|
+
Elelem.logger.debug("repl: #{e.message}")
|
|
26
|
+
@session.output.say(e.message, as: :error)
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
def command(line)
|
|
23
30
|
name, args = line.delete_prefix("/").split(" ", 2)
|
|
24
|
-
|
|
25
|
-
rescue StandardError, ScriptError => e
|
|
26
|
-
warn_and_say(e)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def warn_and_say(error)
|
|
30
|
-
Elelem.logger.warn("repl: #{error.message}")
|
|
31
|
-
@session.output.say(error.message, as: :error)
|
|
31
|
+
line.start_with?("/") && @session.commands.run(name, args)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
data/lib/elelem/result.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "forwardable"
|
|
4
|
-
|
|
5
3
|
module Elelem
|
|
6
4
|
class Result
|
|
7
5
|
extend Forwardable
|
|
@@ -21,7 +19,5 @@ module Elelem
|
|
|
21
19
|
end
|
|
22
20
|
|
|
23
21
|
def ok? = !!@payload[:ok]
|
|
24
|
-
def error? = !!@payload[:error]
|
|
25
|
-
def error = @payload[:error]
|
|
26
22
|
end
|
|
27
23
|
end
|
data/lib/elelem/session.rb
CHANGED
|
@@ -5,13 +5,17 @@ module Elelem
|
|
|
5
5
|
extend Forwardable
|
|
6
6
|
|
|
7
7
|
attr_reader :agent, :input, :commands
|
|
8
|
+
# The provider the session was built with, and its options (such as --model).
|
|
9
|
+
attr_accessor :provider_name, :provider_options
|
|
8
10
|
|
|
9
11
|
def_delegators :@agent, :provider, :provider=, :toolbox, :output, :conversation, :context, :turn
|
|
10
12
|
|
|
11
|
-
def initialize(agent, input: NullInput.new, commands: Commands.new)
|
|
13
|
+
def initialize(agent, input: NullInput.new, commands: Commands.new, provider_name: nil, provider_options: {})
|
|
12
14
|
@agent = agent
|
|
13
15
|
@input = input
|
|
14
16
|
@commands = commands
|
|
17
|
+
@provider_name = provider_name&.to_s
|
|
18
|
+
@provider_options = provider_options
|
|
15
19
|
end
|
|
16
20
|
end
|
|
17
21
|
end
|
data/lib/elelem/stub_provider.rb
CHANGED
|
@@ -1,15 +1,76 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
|
+
# No model. Understands a tool name and its arguments, and the few plain
|
|
5
|
+
# requests the docs show. Reads only the first line, so piped text never
|
|
6
|
+
# becomes arguments.
|
|
4
7
|
class StubProvider
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
REQUESTS = [
|
|
9
|
+
[/\Ashow me (\S+?)[.?!]?\z/i, "read", ->(m) { { "path" => m[1] } }],
|
|
10
|
+
[%r{\Acreate (\S*[./]\S*) with (.+)\z}i, "write", ->(m) { { "path" => m[1], "content" => m[2] } }],
|
|
11
|
+
[/\Awhat files are here\??\z/i, "execute", ->(_) { { "command" => "ls" } }],
|
|
12
|
+
[/\Arun (?:the )?tests?\z/i, "execute",
|
|
13
|
+
->(_) { { "command" => File.executable?("bin/test") ? "bin/test" : "rake" } }],
|
|
14
|
+
[/\A(?:run|execute) `?(.+?)`?\z/i, "execute", ->(m) { { "command" => m[1] } }]
|
|
15
|
+
].freeze
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
HELP = <<~TEXT
|
|
18
|
+
I need a model to understand that. Without one, type a tool and its
|
|
19
|
+
arguments (/tools lists them), or: show me README.md, create notes.txt
|
|
20
|
+
with hello, what files are here, run the tests.
|
|
21
|
+
For a model: gem install elelem-llama, then elelem -p llama
|
|
22
|
+
TEXT
|
|
11
23
|
|
|
12
|
-
|
|
24
|
+
def fetch(messages, tools = [], &)
|
|
25
|
+
return yield(type: "saying", text: summary(messages)) if messages.last[:role] == "tool"
|
|
26
|
+
|
|
27
|
+
name, args = understand(messages.last[:content], tools)
|
|
28
|
+
name ? act(name, args, messages.length, &) : yield(type: "saying", text: HELP)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def act(name, args, turn)
|
|
34
|
+
yield(type: "thinking", text: "I'll call #{name}.")
|
|
35
|
+
yield(type: "doing", id: "stub-#{turn}", name: name, arguments: args)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def understand(content, tools)
|
|
39
|
+
text = content.to_s.lines.first.to_s.strip
|
|
40
|
+
request(text, tools.map { |t| t.dig(:function, :name) }) || direct(text, tools)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request(text, names)
|
|
44
|
+
REQUESTS.each do |pattern, name, build|
|
|
45
|
+
match = pattern.match(text)
|
|
46
|
+
return [name, build.call(match)] if match && names.include?(name)
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def direct(text, tools)
|
|
52
|
+
name, rest = text.split(/\s+/, 2)
|
|
53
|
+
tool = tools.find { |t| t.dig(:function, :name) == name }
|
|
54
|
+
args = arguments(tool, rest.to_s) if tool
|
|
55
|
+
[name, args] if args && file_like?(name, args)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def arguments(tool, rest)
|
|
59
|
+
required = tool.dig(:function, :parameters, :required) || []
|
|
60
|
+
required.zip(rest.split(/\s+/, required.size)).to_h.compact.transform_keys(&:to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A model would not write to "a" when asked to "write a commit message".
|
|
64
|
+
def file_like?(name, args)
|
|
65
|
+
name != "write" || args["path"].to_s.match?(%r{[./]})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def summary(messages)
|
|
69
|
+
name = messages.rfind { |m| m[:tool_calls] }&.dig(:tool_calls, 0, :name)
|
|
70
|
+
result = JSON.parse(messages.last[:content].to_s).except("ok")
|
|
71
|
+
return "#{name} failed: #{result["error"]}." if result["error"]
|
|
72
|
+
|
|
73
|
+
result.empty? ? "#{name} is done." : result.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
13
74
|
end
|
|
14
75
|
end
|
|
15
76
|
end
|
data/lib/elelem/system_prompt.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Elelem
|
|
|
5
5
|
DEFAULT = File.read(File.expand_path("prompts/default.erb", __dir__)).freeze
|
|
6
6
|
USER_AGENTS_MD = Pathname.new(Dir.home).join(".agents/AGENTS.md").freeze
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
attr_reader :template
|
|
9
9
|
|
|
10
10
|
def initialize(template = nil)
|
|
11
11
|
@template = template || DEFAULT
|
data/lib/elelem/tool.rb
CHANGED
|
@@ -4,31 +4,18 @@ module Elelem
|
|
|
4
4
|
class Tool
|
|
5
5
|
attr_reader :name, :description, :aliases
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
# params and required are JSON Schema; every call is checked against them.
|
|
8
|
+
def initialize(name, description:, params: {}, required: [], aliases: [], &handler)
|
|
8
9
|
@name = name
|
|
9
10
|
@description = description
|
|
10
|
-
@schema = schema
|
|
11
11
|
@aliases = aliases.freeze
|
|
12
12
|
@handler = handler
|
|
13
|
+
@schema = { type: "object", properties: params.freeze, required: required.freeze }.freeze
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def call(args)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def validate(args)
|
|
20
|
-
@schema.validate(args)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def to_h
|
|
24
|
-
{
|
|
25
|
-
type: "function",
|
|
26
|
-
function: {
|
|
27
|
-
name: name,
|
|
28
|
-
description: description,
|
|
29
|
-
parameters: @schema.to_h
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
end
|
|
16
|
+
def call(args) = @handler.call(args)
|
|
17
|
+
def validate(args) = (@validator ||= JSONSchemer.schema(@schema)).validate(args || {}).map { |e| e["error"] }
|
|
18
|
+
def inspect = "#<#{self.class} #{name}>"
|
|
19
|
+
def to_h = { type: "function", function: { name: name, description: description, parameters: @schema } }
|
|
33
20
|
end
|
|
34
21
|
end
|
data/lib/elelem/toolbox.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Toolbox
|
|
5
|
-
attr_reader :tools
|
|
5
|
+
attr_reader :tools
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@tools = {}
|
|
@@ -11,17 +11,12 @@ module Elelem
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def clear!
|
|
14
|
-
@tools.clear
|
|
15
|
-
@aliases.clear
|
|
16
|
-
@hooks[:before].clear
|
|
17
|
-
@hooks[:after].clear
|
|
14
|
+
[@tools, @aliases, @hooks[:before], @hooks[:after]].each(&:clear)
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
def add(name, description:, params: {}, required: [], aliases: [], &handler)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@tools[name] = tool
|
|
24
|
-
tool.aliases.each { |a| @aliases[a] = name }
|
|
18
|
+
aliases.each { |a| @aliases[a] = name }
|
|
19
|
+
@tools[name] = Tool.new(name, description:, params:, required:, aliases:, &handler)
|
|
25
20
|
end
|
|
26
21
|
|
|
27
22
|
def before(tool_name = :*, &block)
|
|
@@ -32,15 +27,17 @@ module Elelem
|
|
|
32
27
|
@hooks[:after][tool_name] << block
|
|
33
28
|
end
|
|
34
29
|
|
|
30
|
+
# The tool by name, else by alias, so a real tool named like an alias wins.
|
|
35
31
|
def tool_for(name)
|
|
36
|
-
tools
|
|
32
|
+
tools[name] || tools[@aliases[name]]
|
|
37
33
|
end
|
|
38
34
|
|
|
39
35
|
def run(name, args)
|
|
40
36
|
tool = tool_for(name)
|
|
41
|
-
invalid(tool, args) || to_result(dispatch(tool, args))
|
|
37
|
+
invalid(name, tool, args) || to_result(dispatch(tool, args))
|
|
42
38
|
rescue StandardError, ScriptError => e
|
|
43
|
-
|
|
39
|
+
Elelem.logger.debug("toolbox: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
40
|
+
Result.failure(error: e.message, name: name, args: args)
|
|
44
41
|
end
|
|
45
42
|
|
|
46
43
|
def to_a
|
|
@@ -49,35 +46,25 @@ module Elelem
|
|
|
49
46
|
|
|
50
47
|
private
|
|
51
48
|
|
|
52
|
-
def invalid(tool, args)
|
|
53
|
-
errors = tool.validate(args)
|
|
49
|
+
def invalid(name, tool, args)
|
|
50
|
+
errors = tool ? tool.validate(args) : ["unknown tool: #{name.inspect}. Available: #{tools.keys.join(", ")}."]
|
|
54
51
|
Result.failure(error: errors.join(", ")) if errors.any?
|
|
55
52
|
end
|
|
56
53
|
|
|
54
|
+
# A tool fails by returning error:, or ok: false (as execute does for a
|
|
55
|
+
# non-zero exit).
|
|
57
56
|
def to_result(result)
|
|
58
|
-
result[:error] ? Result.failure(result) : Result.success(result)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def failure(name, args, error)
|
|
62
|
-
Elelem.logger.warn("toolbox: #{error.message}\n#{error.backtrace.join("\n")}")
|
|
63
|
-
Result.failure(error: error.message, name: name, args: args)
|
|
57
|
+
result[:error] || result[:ok] == false ? Result.failure(result) : Result.success(result)
|
|
64
58
|
end
|
|
65
59
|
|
|
66
60
|
def dispatch(tool, args)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
run_after_hooks(tool, args, result)
|
|
70
|
-
result
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def run_before_hooks(tool, args)
|
|
74
|
-
@hooks[:before][:*].each { |h| h.call(args, tool_name: tool.name) }
|
|
75
|
-
@hooks[:before][tool.name].each { |h| h.call(args) }
|
|
61
|
+
run_hooks(:before, tool, args)
|
|
62
|
+
tool.call(args).tap { |result| run_hooks(:after, tool, args, result) }
|
|
76
63
|
end
|
|
77
64
|
|
|
78
|
-
def
|
|
79
|
-
@hooks[
|
|
80
|
-
@hooks[
|
|
65
|
+
def run_hooks(type, tool, *)
|
|
66
|
+
@hooks[type][:*].each { |h| h.call(*, tool_name: tool.name) }
|
|
67
|
+
@hooks[type][tool.name].each { |h| h.call(*) }
|
|
81
68
|
end
|
|
82
69
|
end
|
|
83
70
|
end
|
data/lib/elelem/version.rb
CHANGED
data/lib/elelem.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "erb"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
require "forwardable"
|
|
5
6
|
require "json"
|
|
6
7
|
require "json_schemer"
|
|
@@ -8,42 +9,47 @@ require "logger"
|
|
|
8
9
|
require "open3"
|
|
9
10
|
require "pathname"
|
|
10
11
|
require "shellwords"
|
|
12
|
+
require "stringio"
|
|
13
|
+
require "tempfile"
|
|
11
14
|
|
|
12
|
-
require_relative "elelem/
|
|
13
|
-
require_relative "elelem/slash_command"
|
|
14
|
-
require_relative "elelem/commands"
|
|
15
|
-
require_relative "elelem/conversation"
|
|
16
|
-
require_relative "elelem/input"
|
|
17
|
-
require_relative "elelem/null_input"
|
|
15
|
+
require_relative "elelem/version"
|
|
18
16
|
require_relative "elelem/output"
|
|
19
17
|
require_relative "elelem/null_output"
|
|
18
|
+
require_relative "elelem/input"
|
|
19
|
+
require_relative "elelem/null_input"
|
|
20
|
+
require_relative "elelem/builtins"
|
|
21
|
+
require_relative "elelem/builtins/terminal"
|
|
22
|
+
require_relative "elelem/system_prompt"
|
|
23
|
+
require_relative "elelem/conversation"
|
|
24
|
+
require_relative "elelem/result"
|
|
25
|
+
require_relative "elelem/tool"
|
|
26
|
+
require_relative "elelem/toolbox"
|
|
27
|
+
require_relative "elelem/commands"
|
|
28
|
+
require_relative "elelem/agent"
|
|
29
|
+
require_relative "elelem/session"
|
|
30
|
+
require_relative "elelem/repl"
|
|
31
|
+
require_relative "elelem/stub_provider"
|
|
20
32
|
require_relative "elelem/plugins"
|
|
21
33
|
require_relative "elelem/registration"
|
|
22
34
|
require_relative "elelem/registry"
|
|
23
|
-
require_relative "elelem/repl"
|
|
24
35
|
require_relative "elelem/config"
|
|
25
|
-
require_relative "elelem/result"
|
|
26
|
-
require_relative "elelem/schema"
|
|
27
|
-
require_relative "elelem/session"
|
|
28
|
-
require_relative "elelem/stub_provider"
|
|
29
|
-
require_relative "elelem/system_prompt"
|
|
30
|
-
require_relative "elelem/tool"
|
|
31
|
-
require_relative "elelem/transcript"
|
|
32
|
-
require_relative "elelem/null_tool"
|
|
33
|
-
require_relative "elelem/toolbox"
|
|
34
|
-
require_relative "elelem/version"
|
|
35
36
|
|
|
36
37
|
module Elelem
|
|
37
|
-
def self.start(provider: "stub", toolbox: Toolbox.new)
|
|
38
|
-
Repl.new(build(provider: provider, toolbox: toolbox
|
|
38
|
+
def self.start(provider: "stub", toolbox: Toolbox.new, provider_options: {}, template: nil, system_prompt: nil)
|
|
39
|
+
Repl.new(build(provider: provider, toolbox: toolbox, provider_options: provider_options, template: template,
|
|
40
|
+
system_prompt: system_prompt)).run
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
def self.ask(prompt, provider: "stub", toolbox: Toolbox.new, output: NullOutput.new
|
|
42
|
-
|
|
43
|
+
def self.ask(prompt, provider: "stub", toolbox: Toolbox.new, output: NullOutput.new, provider_options: {},
|
|
44
|
+
template: nil, system_prompt: nil)
|
|
45
|
+
build(provider: provider, toolbox: toolbox, input: NullInput.new, output: output,
|
|
46
|
+
provider_options: provider_options, template: template, system_prompt: system_prompt).turn(prompt)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
def self.build(provider: "stub", toolbox: Toolbox.new, input: nil, output: nil
|
|
46
|
-
|
|
49
|
+
def self.build(provider: "stub", toolbox: Toolbox.new, input: nil, output: nil, provider_options: {},
|
|
50
|
+
template: nil, system_prompt: nil)
|
|
51
|
+
Config.build_session(provider, toolbox: toolbox, input: input, output: output,
|
|
52
|
+
provider_options: provider_options, template: template, system_prompt: system_prompt)
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def self.logger
|