elelem 0.11.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 +33 -64
- data/lib/elelem/builtins/terminal.rb +101 -0
- data/lib/elelem/builtins.rb +52 -0
- data/lib/elelem/cli.rb +39 -44
- data/lib/elelem/commands.rb +8 -13
- data/lib/elelem/config.rb +3 -3
- data/lib/elelem/conversation.rb +10 -5
- data/lib/elelem/input.rb +10 -19
- data/lib/elelem/null_output.rb +3 -1
- data/lib/elelem/output.rb +8 -10
- 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 +19 -10
- data/lib/elelem/registration.rb +7 -2
- data/lib/elelem/registry.rb +29 -25
- data/lib/elelem/repl.rb +34 -0
- data/lib/elelem/result.rb +1 -4
- data/lib/elelem/session.rb +21 -0
- data/lib/elelem/stub_provider.rb +67 -6
- data/lib/elelem/system_prompt.rb +10 -1
- data/lib/elelem/tool.rb +9 -28
- data/lib/elelem/toolbox.rb +28 -23
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +29 -19
- metadata +50 -10
- data/lib/elelem/null_tool.rb +0 -18
- data/lib/elelem/slash_command.rb +0 -18
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:context) do |agent|
|
|
5
|
+
agent.commands.register("context", description: "Show conversation context") do |args|
|
|
6
|
+
messages = agent.context
|
|
7
|
+
|
|
8
|
+
case args
|
|
9
|
+
when nil, ""
|
|
10
|
+
messages.each_with_index do |msg, i|
|
|
11
|
+
role = msg[:role]
|
|
12
|
+
preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
|
|
13
|
+
preview += "..." if msg[:content].to_s.length > 60
|
|
14
|
+
agent.output.say " #{i + 1}. #{role}: #{preview}"
|
|
15
|
+
end
|
|
16
|
+
when "json"
|
|
17
|
+
agent.output.say JSON.pretty_generate(messages)
|
|
18
|
+
when /^\d+$/
|
|
19
|
+
index = args.to_i - 1
|
|
20
|
+
if index >= 0 && index < messages.length
|
|
21
|
+
content = messages[index][:content].to_s
|
|
22
|
+
agent.output.say(content, as: :markdown)
|
|
23
|
+
else
|
|
24
|
+
agent.output.say " Invalid index: #{args}"
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
agent.output.say " Usage: /context [json|<number>]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem.configure do |config|
|
|
4
|
+
config.setup(:execute) do |agent|
|
|
5
|
+
agent.toolbox.add("execute",
|
|
6
|
+
description: "Run shell command (supports pipes and redirections)",
|
|
7
|
+
params: { command: { type: "string" } },
|
|
8
|
+
required: ["command"],
|
|
9
|
+
aliases: ["bash", "sh", "exec", "execute<|channel|>"]) do |a|
|
|
10
|
+
# The timeout kills the whole process group, so `npm start &` cannot hang the session.
|
|
11
|
+
value = ENV.fetch("ELELEM_CMD_TIMEOUT", "")
|
|
12
|
+
timeout = value.empty? ? 300 : Integer(value)
|
|
13
|
+
timeout = nil if timeout.zero?
|
|
14
|
+
result = Elelem::Builtins.sh("bash", args: ["-c", a["command"]], timeout: timeout) { |x| agent.output.print(x) }
|
|
15
|
+
result.merge(ok: result[:exit_status].zero?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
agent.toolbox.after("execute") do |args, result|
|
|
19
|
+
next if result[:exit_status].zero?
|
|
20
|
+
|
|
21
|
+
agent.output.doing("execute", args, state: "x")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -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,18 +16,26 @@ module Elelem
|
|
|
15
16
|
def load!(force: false)
|
|
16
17
|
return if @loaded && !force
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
dirs.each { |dir| load_dir(dir) }
|
|
20
|
+
@loaded = true
|
|
21
|
+
end
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
warn "elelem: failed to load plugin #{file}: #{e.message}"
|
|
26
|
-
end
|
|
27
|
-
end
|
|
23
|
+
def dirs
|
|
24
|
+
@load_paths.map { |path| File.expand_path(path) }
|
|
25
|
+
end
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_dir(dir)
|
|
30
|
+
return unless File.directory?(dir)
|
|
31
|
+
|
|
32
|
+
Dir["#{dir}/*.rb"].each { |file| load_plugin(file) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load_plugin(file)
|
|
36
|
+
load(file)
|
|
37
|
+
rescue StandardError, ScriptError => e
|
|
38
|
+
$stderr.puts "elelem: failed to load plugin #{file}: #{e.message}"
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
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,58 +12,62 @@ 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
|
-
def apply(
|
|
20
|
+
def apply(session)
|
|
21
21
|
@plugins.load!
|
|
22
|
-
@registration.setups.each_value { |block| block.call(
|
|
22
|
+
@registration.setups.each_value { |block| block.call(session) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def reload(
|
|
26
|
-
@registration.
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
def reload(session)
|
|
26
|
+
@registration.clear_setups_from!(@plugins.dirs)
|
|
27
|
+
session.toolbox.clear!
|
|
28
|
+
session.commands.clear!
|
|
29
29
|
@plugins.load!(force: true)
|
|
30
|
-
apply(
|
|
30
|
+
apply(session)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def names
|
|
34
34
|
@registration.providers.keys
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
46
|
-
apply(agent)
|
|
47
|
-
agent
|
|
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)
|
|
44
|
+
apply(session)
|
|
45
|
+
session
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
private
|
|
51
49
|
|
|
52
|
-
def build_input(**
|
|
50
|
+
def build_input(**)
|
|
53
51
|
@plugins.load!
|
|
54
|
-
(@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**
|
|
52
|
+
(@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**)
|
|
55
53
|
end
|
|
56
54
|
|
|
57
|
-
def build_output(**
|
|
55
|
+
def build_output(**)
|
|
58
56
|
@plugins.load!
|
|
59
|
-
(@registration.factories[:output] || ->(**o) { Output.new(**o) }).call(**
|
|
57
|
+
(@registration.factories[:output] || ->(**o) { Output.new(**o) }).call(**)
|
|
60
58
|
end
|
|
61
59
|
|
|
62
60
|
def require_gem(name)
|
|
63
61
|
require "elelem/#{name}"
|
|
64
|
-
@registration.providers.fetch(name.to_s) {
|
|
65
|
-
rescue LoadError
|
|
62
|
+
@registration.providers.fetch(name.to_s) { unregistered_provider(name) }
|
|
63
|
+
rescue LoadError => e
|
|
64
|
+
raise unless e.path == "elelem/#{name}"
|
|
65
|
+
|
|
66
66
|
raise "unknown provider: #{name.inspect}. Available: #{names.join(", ")}. Run: gem install elelem-#{name}"
|
|
67
67
|
end
|
|
68
|
+
|
|
69
|
+
def unregistered_provider(name)
|
|
70
|
+
raise "elelem-#{name} did not register a provider named #{name.inspect}"
|
|
71
|
+
end
|
|
68
72
|
end
|
|
69
73
|
end
|
data/lib/elelem/repl.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class Repl
|
|
5
|
+
def initialize(session)
|
|
6
|
+
@session = session
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def run
|
|
10
|
+
@session.output.say "elelem v#{VERSION}"
|
|
11
|
+
while (line = @session.input.ask("> "))
|
|
12
|
+
dispatch_line(line) unless line.empty?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
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.
|
|
21
|
+
def dispatch_line(line)
|
|
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)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def command(line)
|
|
30
|
+
name, args = line.delete_prefix("/").split(" ", 2)
|
|
31
|
+
line.start_with?("/") && @session.commands.run(name, args)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/elelem/result.rb
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "forwardable"
|
|
4
|
-
|
|
5
3
|
module Elelem
|
|
6
4
|
class Result
|
|
7
5
|
extend Forwardable
|
|
6
|
+
|
|
8
7
|
def_delegators :@payload, :[], :each, :to_json, :to_h, :key?, :merge
|
|
9
8
|
|
|
10
9
|
def self.success(payload)
|
|
@@ -20,7 +19,5 @@ module Elelem
|
|
|
20
19
|
end
|
|
21
20
|
|
|
22
21
|
def ok? = !!@payload[:ok]
|
|
23
|
-
def error? = !!@payload[:error]
|
|
24
|
-
def error = @payload[:error]
|
|
25
22
|
end
|
|
26
23
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class Session
|
|
5
|
+
extend Forwardable
|
|
6
|
+
|
|
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
|
|
10
|
+
|
|
11
|
+
def_delegators :@agent, :provider, :provider=, :toolbox, :output, :conversation, :context, :turn
|
|
12
|
+
|
|
13
|
+
def initialize(agent, input: NullInput.new, commands: Commands.new, provider_name: nil, provider_options: {})
|
|
14
|
+
@agent = agent
|
|
15
|
+
@input = input
|
|
16
|
+
@commands = commands
|
|
17
|
+
@provider_name = provider_name&.to_s
|
|
18
|
+
@provider_options = provider_options
|
|
19
|
+
end
|
|
20
|
+
end
|
|
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
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class SystemPrompt
|
|
5
5
|
DEFAULT = File.read(File.expand_path("prompts/default.erb", __dir__)).freeze
|
|
6
|
+
USER_AGENTS_MD = Pathname.new(Dir.home).join(".agents/AGENTS.md").freeze
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
attr_reader :template
|
|
8
9
|
|
|
9
10
|
def initialize(template = nil)
|
|
10
11
|
@template = template || DEFAULT
|
|
@@ -17,6 +18,14 @@ module Elelem
|
|
|
17
18
|
private
|
|
18
19
|
|
|
19
20
|
def agents_md
|
|
21
|
+
[global_agents_md, project_agents_md].compact.then { |parts| parts.empty? ? nil : parts.join("\n\n") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def global_agents_md
|
|
25
|
+
USER_AGENTS_MD.read if USER_AGENTS_MD.exist?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def project_agents_md
|
|
20
29
|
Pathname.pwd.ascend.each do |dir|
|
|
21
30
|
file = dir / "AGENTS.md"
|
|
22
31
|
return file.read if file.exist?
|
data/lib/elelem/tool.rb
CHANGED
|
@@ -2,39 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Tool
|
|
5
|
-
attr_reader :name, :description, :
|
|
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
|
-
@params = params.freeze
|
|
11
|
-
@required = required.freeze
|
|
12
11
|
@aliases = aliases.freeze
|
|
13
|
-
@
|
|
14
|
-
@
|
|
15
|
-
@schema = JSONSchemer.schema(@schema_hash)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def call(args)
|
|
19
|
-
@fn.call(args)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def validate(args)
|
|
23
|
-
@schema.validate(args || {}).map do |error|
|
|
24
|
-
error["error"]
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def to_h
|
|
29
|
-
{
|
|
30
|
-
type: "function",
|
|
31
|
-
function: {
|
|
32
|
-
name: name,
|
|
33
|
-
description: description,
|
|
34
|
-
parameters: @schema_hash
|
|
35
|
-
}
|
|
36
|
-
}
|
|
12
|
+
@handler = handler
|
|
13
|
+
@schema = { type: "object", properties: params.freeze, required: required.freeze }.freeze
|
|
37
14
|
end
|
|
38
15
|
|
|
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 } }
|
|
39
20
|
end
|
|
40
21
|
end
|