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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem.configure do |config|
4
+ config.setup(:reload) do |agent|
5
+ agent.commands.register("reload", description: "Reload plugin code from disk") do
6
+ Elelem::Config.reload(agent)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The default output. A gem that registers its own output first keeps it.
4
+ Elelem.configure do |config|
5
+ config.output { Elelem::Builtins::Terminal.new } unless config.factories.key?(:output)
6
+ 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
@@ -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
- @load_paths.each do |path|
19
- dir = File.expand_path(path)
20
- next unless File.directory?(dir)
19
+ dirs.each { |dir| load_dir(dir) }
20
+ @loaded = true
21
+ end
21
22
 
22
- Dir["#{dir}/*.rb"].sort.each do |file|
23
- load(file)
24
- rescue => e
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
- @loaded = true
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
@@ -30,8 +30,13 @@ module Elelem
30
30
  self
31
31
  end
32
32
 
33
- def clear_setups!
34
- @setups.clear
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
@@ -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(agent)
20
+ def apply(session)
21
21
  @plugins.load!
22
- @registration.setups.each_value { |block| block.call(agent) }
22
+ @registration.setups.each_value { |block| block.call(session) }
23
23
  end
24
24
 
25
- def reload(agent)
26
- @registration.clear_setups!
27
- agent.toolbox.clear!
28
- agent.commands.clear!
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(agent)
30
+ apply(session)
31
31
  end
32
32
 
33
33
  def names
34
34
  @registration.providers.keys
35
35
  end
36
36
 
37
- def build_agent(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(
40
- build_provider(provider),
41
- toolbox: toolbox,
42
- commands: commands,
43
- input: input || build_input(commands: commands),
44
- output: output || build_output,
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(**opts)
50
+ def build_input(**)
53
51
  @plugins.load!
54
- (@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**opts)
52
+ (@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**)
55
53
  end
56
54
 
57
- def build_output(**opts)
55
+ def build_output(**)
58
56
  @plugins.load!
59
- (@registration.factories[:output] || ->(**o) { Output.new(**o) }).call(**opts)
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) { raise "elelem-#{name} did not register a provider named #{name.inspect}" }
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
@@ -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
@@ -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
- def fetch(messages, tools = [], &block)
6
- block.call(type: "thinking", text: "considering the request")
7
- return block.call(type: "saying", text: "done.") if messages.last[:role] == "tool"
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
- block.call(type: "saying", text: "let me check.")
10
- return unless (tool = tools.first)
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
- block.call(type: "doing", id: "stub-1", name: tool[:function][:name], arguments: {})
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
@@ -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
- attr_accessor :template
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, :params, :required, :aliases
5
+ attr_reader :name, :description, :aliases
6
6
 
7
- def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
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
- @fn = fn
14
- @schema_hash = { type: "object", properties: @params, required: @required }.freeze
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