Commit a31d3c6
Changed files (10)
bin
exe
bin/run
@@ -5,4 +5,4 @@ set -e
cd "$(dirname "$0")/.."
-bundle exec ./exe/elelem
+bundle exec ./exe/elelem $@
exe/elelem
@@ -3,4 +3,8 @@
require "elelem"
-Elelem::Agent.new.repl
+Signal.trap('INT') do
+ exit(1)
+end
+
+Elelem::Application.start
lib/elelem/agent.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Agent
+ attr_reader :tools, :http, :configuration
+
+ def initialize(configuration)
+ @configuration = configuration
+
+ @logger = configuration.logger
+ @http = configuration.http
+ @conversation = configuration.conversation
+ @tools = configuration.tools
+ end
+
+ def protocol(host)
+ host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
+ end
+
+ def system_message
+ <<~SYS
+ You are ChatGPT, a helpful assistant with reasoning capabilities.
+ Current date: #{Time.now.strftime('%Y-%m-%d')}.
+ System info: `uname -a` output:
+ #{`uname -a`.strip}
+ Reasoning: high
+ SYS
+ end
+
+ def repl
+ puts "Ollama Agent (#{configuration.model})"
+ puts "Tools:\n #{@tools.banner}"
+
+ loop do
+ print "\n> "
+ user = STDIN.gets&.chomp
+ break if user.nil? || user.empty? || user == 'exit'
+ process_input(user)
+ puts("\u001b[32mDone!\u001b[0m")
+ end
+ end
+
+ private
+
+ def process_input(text)
+ @conversation.add(role: 'user', content: text)
+
+ # ::TODO state machine
+ done = false
+ loop do
+ call_api(@conversation.history) do |chunk|
+ debug_print(chunk)
+
+ response = JSON.parse(chunk)
+ message = response['message'] || {}
+ if message['thinking']
+ print("\u001b[90m#{message['thinking']}\u001b[0m")
+ elsif message['tool_calls']&.any?
+ message['tool_calls'].each do |t|
+ @conversation.add(
+ role: 'tool',
+ content: execute_tool(t.dig('function', 'name'), t.dig('function', 'arguments'))
+ )
+ end
+ elsif message['content'].to_s.strip
+ print message['content'].to_s.strip
+ else
+ raise chunk.inspect
+ end
+
+ done = response['done']
+ end
+
+ break if done
+ end
+ end
+
+ def call_api(messages)
+ body = {
+ messages: messages,
+ model: @model,
+ stream: true,
+ keep_alive: '5m',
+ options: { temperature: 0.1 },
+ tools: tools
+ }
+ json_body = body.to_json
+ debug_print(json_body)
+
+ req = Net::HTTP::Post.new(configuration.uri)
+ req['Content-Type'] = 'application/json'
+ req.body = json_body
+ req['Authorization'] = "Bearer #{@token}" if @token
+
+ http.request(req) do |response|
+ response.read_body do |chunk|
+ block_given? ? yield(chunk) : debug_print(chunk)
+ $stdout.flush
+ end
+ end
+ end
+
+ def execute_tool(name, args)
+ case name
+ when 'execute_command'
+ result = run_cmd(args['command'])
+ debug_print(result) unless result[:ok]
+ result[:output]
+ when 'ask_user'
+ puts("\u001b[35m#{args['question']}\u001b[0m")
+ print "> "
+ "User: #{STDIN.gets&.chomp}"
+ end
+ end
+
+ def run_cmd(command)
+ stdout, stderr, status = Open3.capture3('/bin/sh', '-c', command)
+ { output: stdout + stderr, ok: status.success? }
+ end
+
+ def debug_print(body = nil)
+ @logger.debug(body) if @debug && body
+ end
+ end
+end
lib/elelem/application.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Application < Thor
+ desc 'chat', 'Start the REPL'
+ method_option :help, aliases: '-h', type: :boolean, desc: 'Display usage information'
+ method_option :host, aliases: '--host', type: :string, desc: 'Ollama host', default: ENV.fetch('OLLAMA_HOST', 'localhost:11434')
+ method_option :model, aliases: '--model', type: :string, desc: 'Ollama model', default: ENV.fetch('OLLAMA_MODEL', 'gpt-oss')
+ method_option :token, aliases: '--token', type: :string, desc: 'Ollama token', default: ENV.fetch('OLLAMA_API_KEY', nil)
+ method_option :debug, aliases: '--debug', type: :boolean, desc: 'Debug mode', default: ENV.fetch('DEBUG', '0') == '1'
+ def chat(*)
+ if options[:help]
+ invoke :help, ['chat']
+ else
+ agent = Agent.new(Configuration.new(
+ host: options[:host],
+ model: options[:model],
+ token: options[:token],
+ debug: options[:debug],
+ ))
+ agent.repl
+ end
+ end
+
+ desc 'version', 'spandx version'
+ def version
+ puts "v#{Spandx::VERSION}"
+ end
+ map %w[--version -v] => :version
+ end
+end
lib/elelem/configuration.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Configuration
+ attr_reader :host, :model, :token, :debug
+
+ def initialize(host:, model:, token:, debug: false)
+ @host = host
+ @model = model
+ @token = token
+ @debug = debug
+ end
+
+ def http
+ Net::HTTP.new(uri.host, uri.port).tap do |h|
+ h.read_timeout = 3_600
+ h.open_timeout = 10
+ end
+ end
+
+ def logger
+ @logger ||= begin
+ Logger.new(debug ? $stderr : "/dev/null").tap do |logger|
+ logger.formatter = ->(_, _, _, msg) { msg }
+ end
+ end
+ end
+
+ def uri
+ @uri ||= URI("#{scheme}://#{host}/api/chat")
+ end
+
+ def conversation
+ @conversation ||= Conversation.new
+ end
+
+ def tools
+ @tools ||= Tools.new
+ end
+
+ private
+
+ def scheme
+ host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
+ end
+ end
+end
lib/elelem/conversation.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Conversation
+ SYSTEM_MESSAGE = <<~SYS
+ You are ChatGPT, a helpful assistant with reasoning capabilities.
+ Current date: #{Time.now.strftime('%Y-%m-%d')}.
+ System info: `uname -a` output:
+ #{`uname -a`.strip}
+ Reasoning: high
+ SYS
+
+ ROLES = ['system', 'user', 'tool'].freeze
+
+ def initialize(items = [{ role: 'system', content: SYSTEM_MESSAGE }])
+ @items = items
+ end
+
+ def history
+ @items
+ end
+
+ # :TODO truncate conversation history
+ def add(role: 'user', content: '')
+ raise "unknown role: #{role}" unless ROLES.include?(role)
+
+ @items << { role: role, content: content }
+ end
+ end
+end
lib/elelem/tools.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Tools
+ DEFAULT_TOOLS = [
+ {
+ type: 'function',
+ function: {
+ name: 'execute_command',
+ description: 'Execute a shell command.',
+ parameters: {
+ type: 'object',
+ properties: { command: { type: 'string' } },
+ required: ['command']
+ }
+ }
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'ask_user',
+ description: 'Ask the user to answer a question.',
+ parameters: {
+ type: 'object',
+ properties: { question: { type: 'string' } },
+ required: ['question']
+ }
+ }
+ }
+ ]
+
+ def initialize(tools = DEFAULT_TOOLS)
+ @tools = tools
+ end
+
+ def banner
+ @tools.map do |h|
+ [
+ h.dig(:function, :name),
+ h.dig(:function, :description)
+ ].join(": ")
+ end.sort.join("\n ")
+ end
+ end
+end
lib/elelem.rb
@@ -4,166 +4,16 @@ require "json"
require "logger"
require "net/http"
require "open3"
+require "thor"
require "uri"
+require_relative "elelem/agent"
+require_relative "elelem/application"
+require_relative "elelem/configuration"
+require_relative "elelem/conversation"
+require_relative "elelem/tools"
require_relative "elelem/version"
module Elelem
class Error < StandardError; end
-
- class Agent
- attr_reader :tools, :http
-
- def initialize
- @host = ENV.fetch('OLLAMA_HOST', 'localhost:11434')
- @model = ENV.fetch('OLLAMA_MODEL', 'gpt-oss')
- @token = ENV.fetch('OLLAMA_API_KEY', nil)
- @debug = ENV.fetch('DEBUG', '0') == '1'
- @logger = Logger.new(@debug ? $stderr : "/dev/null")
- @logger.formatter = ->(_, _, _, msg) { msg }
-
- @uri = URI("#{protocol(@host)}://#{@host}/api/chat")
- @http = Net::HTTP.new(@uri.host, @uri.port).tap do |h|
- h.read_timeout = 3_600
- h.open_timeout = 10
- end
-
- @conversation = [{ role: 'system', content: system_message }]
-
- @tools = [
- {
- type: 'function',
- function: {
- name: 'execute_command',
- description: 'Execute a shell command.',
- parameters: {
- type: 'object',
- properties: { command: { type: 'string' } },
- required: ['command']
- }
- }
- },
- {
- type: 'function',
- function: {
- name: 'ask_user',
- description: 'Ask the user to answer a question.',
- parameters: {
- type: 'object',
- properties: { question: { type: 'string' } },
- required: ['question']
- }
- }
- }
- ]
- end
-
- def protocol(host)
- host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
- end
-
- def system_message
- <<~SYS
- You are ChatGPT, a helpful assistant with reasoning capabilities.
- Current date: #{Time.now.strftime('%Y-%m-%d')}.
- System info: `uname -a` output:
- #{`uname -a`.strip}
- Reasoning: high
- SYS
- end
-
- def repl
- puts "Ollama Agent (#{@model})"
- puts " tools:\n #{tools.map { |h| [h.dig(:function, :name), h.dig(:function, :description)].sort.join(": ") }.join("\n ")}"
-
- loop do
- print "\n> "
- user = STDIN.gets&.chomp
- break if user.nil? || user.empty? || user == 'exit'
- process_input(user)
- puts("\u001b[32mDone!\u001b[0m")
- end
- end
-
- private
-
- def process_input(text)
- @conversation << { role: 'user', content: text }
-
- # ::TODO state machine
- done = false
- loop do
- call_api(@conversation) do |chunk|
- debug_print(chunk)
-
- response = JSON.parse(chunk)
- message = response['message'] || {}
- if message['thinking']
- print("\u001b[90m#{message['thinking']}\u001b[0m")
- elsif message['tool_calls']&.any?
- message['tool_calls'].each do |t|
- result = execute_tool(t.dig('function', 'name'), t.dig('function', 'arguments'))
- puts result
- @conversation << { role: 'tool', content: result }
- end
- elsif message['content'].to_s.strip
- print message['content'].to_s.strip
- else
- raise chunk.inspect
- end
-
- done = response['done']
- end
-
- break if done
- end
- end
-
- def call_api(messages)
- body = {
- messages: messages,
- model: @model,
- stream: true,
- keep_alive: '5m',
- options: { temperature: 0.1 },
- tools: tools
- }
- json_body = body.to_json
- debug_print(json_body)
-
- req = Net::HTTP::Post.new(@uri)
- req['Content-Type'] = 'application/json'
- req.body = json_body
- req['Authorization'] = "Bearer #{@token}" if @token
-
- http.request(req) do |response|
- response.read_body do |chunk|
- block_given? ? yield(chunk) : debug_print(chunk)
- $stdout.flush
- end
- end
- end
-
- def execute_tool(name, args)
- case name
- when 'execute_command'
- result = run_cmd(args['command'])
- debug_print(result) unless result[:ok]
- result[:output]
- when 'ask_user'
- puts("\u001b[35m#{args['question']}\u001b[0m")
- print "> "
- "User: #{STDIN.gets&.chomp}"
- end
- end
-
- def run_cmd(command)
- stdout, stderr, status = Open3.capture3('/bin/sh', '-c', command)
- { output: stdout + stderr, ok: status.success? }
- end
-
- def debug_print(body = nil)
- @logger.debug(body) if @debug && body
- end
- end
end
elelem.gemspec
@@ -37,5 +37,6 @@ Gem::Specification.new do |spec|
spec.add_dependency 'logger'
spec.add_dependency 'net-http'
spec.add_dependency 'open3'
+ spec.add_dependency 'thor'
spec.add_dependency 'uri'
end
Gemfile.lock
@@ -6,6 +6,7 @@ PATH
logger
net-http
open3
+ thor
uri
GEM
@@ -41,8 +42,6 @@ GEM
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.0)
- rake-compiler (1.3.0)
- rake
rdoc (6.14.2)
erb
psych (>= 4.0.0)
@@ -78,6 +77,7 @@ GEM
prism (~> 1.4)
ruby-progressbar (1.13.0)
stringio (3.1.7)
+ thor (1.3.2)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -91,7 +91,6 @@ DEPENDENCIES
elelem!
irb
rake (~> 13.0)
- rake-compiler
rspec (~> 3.0)
rubocop (~> 1.21)