diff options
author | icebaker <icebaker@proton.me> | 2023-11-18 19:07:10 -0300 |
---|---|---|
committer | icebaker <icebaker@proton.me> | 2023-11-18 19:07:10 -0300 |
commit | 8ae78b954350755a47a13133668dba93bac15f37 (patch) | |
tree | 9cdc3bb770d778bd8d00675fdbc1f27a6e27e37c | |
parent | ab22d1bbe37093912cb7418b3c945153a15f4255 (diff) |
adding support for tools
29 files changed, 889 insertions, 103 deletions
diff --git a/components/adapter.rb b/components/adapter.rb index a361437..a79fee6 100644 --- a/components/adapter.rb +++ b/components/adapter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'sweet-moon' +require_relative 'embedding' module NanoBot module Components @@ -8,41 +8,23 @@ module NanoBot def self.apply(_direction, params) content = params[:content] - if params[:fennel] && params[:lua] - raise StandardError, 'Adapter conflict: You can only use either Lua or Fennel, not both.' - end + raise StandardError, 'conflicting adapters' if %i[fennel lua clojure].count { |key| !params[key].nil? } > 1 + + call = { parameters: %w[content], values: [content], safety: false } if params[:fennel] - content = fennel(content, params[:fennel]) + call[:source] = params[:fennel] + content = Components::Embedding.fennel(**call) + elsif params[:clojure] + call[:source] = params[:clojure] + content = Components::Embedding.clojure(**call) elsif params[:lua] - content = lua(content, params[:lua]) + call[:source] = params[:lua] + content = Components::Embedding.lua(**call) end "#{params[:prefix]}#{content}#{params[:suffix]}" end - - def self.fennel(content, expression) - path = "#{File.expand_path('../static/fennel', __dir__)}/?.lua" - state = SweetMoon::State.new(package_path: path).fennel - # TODO: global is deprecated... - state.fennel.eval( - "(global adapter (fn [content] #{expression}))", 1, - { allowedGlobals: %w[math string table] } - ) - adapter = state.get(:adapter) - adapter.call([content]) - end - - def self.lua(content, expression) - state = SweetMoon::State.new - code = "_, adapter = pcall(load('return function(content) return #{ - expression.gsub("'", "\\\\'") - }; end', nil, 't', {math=math,string=string,table=table}))" - - state.eval(code) - adapter = state.get(:adapter) - adapter.call([content]) - end end end end diff --git a/components/embedding.rb b/components/embedding.rb new file mode 100644 index 0000000..c464244 --- /dev/null +++ b/components/embedding.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'sweet-moon' + +require 'open3' +require 'json' +require 'tempfile' + +module NanoBot + module Components + class Embedding + def self.lua(source:, parameters:, values:, safety:) + state = SweetMoon::State.new + # code = "_, embedded = pcall(load([[\nreturn function(#{parameters.join(', ')})\nreturn #{source}\nend\n]], nil, 't', {math=math,string=string,table=table}))" + code = "_, embedded = pcall(load([[\nreturn function(#{parameters.join(', ')})\n#{source}\nend\n]], nil, 't'))" + + state.eval(code) + embedded = state.get(:embedded) + embedded.call(values) + end + + def self.fennel(source:, parameters:, values:, safety:) + path = "#{File.expand_path('../static/fennel', __dir__)}/?.lua" + state = SweetMoon::State.new(package_path: path).fennel + + # TODO: global is deprecated... + state.fennel.eval( + "(global embedded (fn [#{parameters.join(' ')}] #{source}))", 1, + safety ? { allowedGlobals: %w[math string table] } : nil + ) + embedded = state.get(:embedded) + embedded.call(values) + end + + def self.clojure(source:, parameters:, values:, safety:) + raise 'invalid Clojure parameter name' if parameters.include?('injected-parameters') + + key_value = {} + + parameters.each_with_index { |key, index| key_value[key] = values[index] } + + parameters_json = key_value.to_json + + json_file = Tempfile.new(['nano-bot', '.json']) + clojure_file = Tempfile.new(['nano-bot', '.clj']) + + begin + json_file.write(parameters_json) + json_file.close + + clojure_source = <<~CLOJURE + (require '[cheshire.core :as json]) + (def injected-parameters (json/parse-string (slurp (java.io.FileReader. "#{json_file.path}")))) + + #{parameters.map { |p| "(def #{p} (get injected-parameters \"#{p}\"))" }.join("\n")} + + #{source} + CLOJURE + + clojure_file.write(clojure_source) + clojure_file.close + + bb_command = "bb --prn #{clojure_file.path} | bb -e \"(->> *in* slurp read-string print)\"" + + stdout, stderr, status = Open3.capture3(bb_command) + + status.success? ? stdout : stderr + ensure + json_file&.unlink + clojure_file&.unlink + end + end + end + end +end diff --git a/components/provider.rb b/components/provider.rb index 163b099..3414009 100644 --- a/components/provider.rb +++ b/components/provider.rb @@ -2,7 +2,7 @@ require 'openai' -require_relative './providers/openai' +require_relative 'providers/openai' module NanoBot module Components diff --git a/components/providers/openai.rb b/components/providers/openai.rb index ce6fb33..437114c 100644 --- a/components/providers/openai.rb +++ b/components/providers/openai.rb @@ -2,9 +2,14 @@ require 'openai' -require_relative './base' +require_relative 'base' require_relative '../crypto' +require_relative '../../logic/providers/openai/tools' +require_relative '../../controllers/interfaces/tools' + +require_relative 'openai/tools' + module NanoBot module Components module Providers @@ -34,15 +39,23 @@ module NanoBot def stream(input) provider = @settings.key?(:stream) ? @settings[:stream] : true + + # TODO: There's a bug here... interface = input[:interface].key?(:stream) ? input[:interface][:stream] : true provider && interface end - def evaluate(input, &block) + def evaluate(input, &feedback) messages = input[:history].map do |event| - { role: event[:who] == 'user' ? 'user' : 'assistant', - content: event[:message] } + if event[:message].nil? && event[:meta] && event[:meta][:tool_calls] + { role: 'assistant', content: nil, tool_calls: event[:meta][:tool_calls] } + elsif event[:who] == 'tool' + { role: event[:who], content: event[:message], + tool_call_id: event[:meta][:id], name: event[:meta][:name] } + else + { role: event[:who] == 'user' ? 'user' : 'assistant', content: event[:message] } + end end %i[instruction backdrop directive].each do |key| @@ -62,17 +75,65 @@ module NanoBot payload.delete(:logit_bias) if payload.key?(:logit_bias) && payload[:logit_bias].nil? + payload[:tools] = input[:tools].map { |raw| NanoBot::Logic::OpenAI::Tools.adapt(raw) } if input[:tools] + if stream(input) content = '' + tools = [] payload[:stream] = proc do |chunk, _bytesize| - partial = chunk.dig('choices', 0, 'delta', 'content') - if partial - content += partial - block.call({ who: 'AI', message: partial }, false) + partial_content = chunk.dig('choices', 0, 'delta', 'content') + partial_tools = chunk.dig('choices', 0, 'delta', 'tool_calls') + + if partial_tools + partial_tools.each do |partial_tool| + tools[partial_tool['index']] = {} if tools[partial_tool['index']].nil? + + partial_tool.keys.reject { |key| ['index'].include?(key) }.each do |key| + target = tools[partial_tool['index']] + + if partial_tool[key].is_a?(Hash) + target[key] = {} if target[key].nil? + partial_tool[key].each_key do |sub_key| + target[key][sub_key] = '' if target[key][sub_key].nil? + + target[key][sub_key] += partial_tool[key][sub_key] + end + else + target[key] = '' if target[key].nil? + + target[key] += partial_tool[key] + end + end + end + end + + if partial_content + content += partial_content + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: partial_content } } + ) end - block.call({ who: 'AI', message: content }, true) if chunk.dig('choices', 0, 'finish_reason') + if chunk.dig('choices', 0, 'finish_reason') + if tools&.size&.positive? + feedback.call( + { should_be_stored: true, + needs_another_round: true, + interaction: { who: 'AI', message: nil, meta: { tool_calls: tools } } } + ) + Tools.apply(input[:tools], tools, feedback).each do |interaction| + feedback.call({ should_be_stored: true, needs_another_round: true, interaction: }) + end + end + + feedback.call( + { should_be_stored: !(content.nil? || content == ''), + interaction: content.nil? || content == '' ? nil : { who: 'AI', message: content }, + finished: true } + ) + end end @client.chat(parameters: payload) @@ -81,7 +142,26 @@ module NanoBot raise StandardError, result['error'] if result['error'] - block.call({ who: 'AI', message: result.dig('choices', 0, 'message', 'content') }, true) + tools = result.dig('choices', 0, 'message', 'tool_calls') + + if tools&.size&.positive? + feedback.call( + { should_be_stored: true, + needs_another_round: true, + interaction: { who: 'AI', message: nil, meta: { tool_calls: tools } } } + ) + Tools.apply(input[:tools], tools, feedback).each do |interaction| + feedback.call({ should_be_stored: true, needs_another_round: true, interaction: }) + end + end + + content = result.dig('choices', 0, 'message', 'content') + + feedback.call( + { should_be_stored: !(content.nil? || content == ''), + interaction: content.nil? || content == '' ? nil : { who: 'AI', message: content }, + finished: true } + ) end end diff --git a/components/providers/openai/tools.rb b/components/providers/openai/tools.rb new file mode 100644 index 0000000..ea34ae6 --- /dev/null +++ b/components/providers/openai/tools.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative '../../embedding' + +require 'concurrent' + +module NanoBot + module Components + module Providers + class OpenAI < Base + module Tools + def self.apply(cartridge, tools, feedback) + prepared_tools = NanoBot::Logic::OpenAI::Tools.prepare(cartridge, tools) + + futures = prepared_tools.map do |tool| + Concurrent::Promises.future { process!(tool, feedback) } + end + + results = Concurrent::Promises.zip(*futures).value! + + results.map do |applied_tool| + { + who: 'tool', + message: applied_tool[:output], + meta: { id: applied_tool[:id], name: applied_tool[:name] } + } + end + end + + def self.process!(tool, feedback) + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: nil, meta: { + tool: { action: 'call', id: tool[:id], name: tool[:name], parameters: tool[:parameters] } + } } } + ) + + call = { parameters: %w[parameters], values: [tool[:parameters]], safety: false } + + if %i[fennel lua clojure].count { |key| !tool[:source][key].nil? } > 1 + raise StandardError, 'conflicting tools' + end + + if !tool[:source][:fennel].nil? + call[:source] = tool[:source][:fennel] + tool[:output] = Components::Embedding.fennel(**call) + elsif !tool[:source][:clojure].nil? + call[:source] = tool[:source][:clojure] + tool[:output] = Components::Embedding.clojure(**call) + elsif !tool[:source][:lua].nil? + call[:source] = tool[:source][:lua] + tool[:output] = Components::Embedding.lua(**call) + else + raise 'missing source code' + end + + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: nil, meta: { + tool: { + action: 'response', id: tool[:id], name: tool[:name], + parameters: tool[:parameters], output: tool[:output] + } + } } } + ) + + tool + end + end + end + end + end +end diff --git a/components/storage.rb b/components/storage.rb index 577ce67..6a3fe13 100644 --- a/components/storage.rb +++ b/components/storage.rb @@ -3,7 +3,7 @@ require 'babosa' require_relative '../logic/helpers/hash' -require_relative './crypto' +require_relative 'crypto' module NanoBot module Components diff --git a/components/stream.rb b/components/stream.rb index 45c4a2b..347eb87 100644 --- a/components/stream.rb +++ b/components/stream.rb @@ -7,7 +7,12 @@ module NanoBot class Stream < StringIO def write(*args) if @callback - @accumulated += args.first + begin + @accumulated += args.first + rescue StandardError => _e + @accumulated = "#{@accumulated.force_encoding('UTF-8')}#{args.first.force_encoding('UTF-8')}" + end + @callback.call(@accumulated, args.first, false) end super diff --git a/controllers/cartridges.rb b/controllers/cartridges.rb index fe0d56e..df474a9 100644 --- a/controllers/cartridges.rb +++ b/controllers/cartridges.rb @@ -23,7 +23,7 @@ module NanoBot files.values.uniq.map do |file| cartridge = Logic::Helpers::Hash.symbolize_keys( - YAML.safe_load(File.read(file[:path]), permitted_classes: [Symbol]) + YAML.safe_load_file(file[:path], permitted_classes: [Symbol]) ).merge({ system: { id: file[:path].to_s.sub(/^#{Regexp.escape(file[:base])}/, '').sub(%r{^/}, '').sub(/\.[^.]+\z/, diff --git a/controllers/instance.rb b/controllers/instance.rb index d4e0c1b..259a548 100644 --- a/controllers/instance.rb +++ b/controllers/instance.rb @@ -6,9 +6,9 @@ require_relative '../logic/helpers/hash' require_relative '../components/provider' require_relative '../components/storage' require_relative '../components/stream' -require_relative './interfaces/repl' -require_relative './interfaces/eval' -require_relative './session' +require_relative 'interfaces/repl' +require_relative 'interfaces/eval' +require_relative 'session' module NanoBot module Controllers @@ -83,7 +83,7 @@ module NanoBot raise StandardError, "Cartridge file not found: \"#{path}\"" end - @cartridge = YAML.safe_load(File.read(elected_path), permitted_classes: [Symbol]) + @cartridge = YAML.safe_load_file(elected_path, permitted_classes: [Symbol]) end @safe_cartridge = Marshal.load(Marshal.dump(@cartridge)) diff --git a/controllers/interfaces/repl.rb b/controllers/interfaces/repl.rb index fd16ea6..d4191b1 100644 --- a/controllers/interfaces/repl.rb +++ b/controllers/interfaces/repl.rb @@ -37,6 +37,8 @@ module NanoBot [proc { prompt }, proc { 'MISSING INPUT' }] ) + Logic::Cartridge::Streaming.enabled?(cartridge, :repl) + Pry.commands.block_command(/(.*)/, 'handler') do |line| session.print(prefix) unless prefix.nil? session.evaluate_and_print(line, mode: 'repl') diff --git a/controllers/interfaces/tools.rb b/controllers/interfaces/tools.rb new file mode 100644 index 0000000..5105da1 --- /dev/null +++ b/controllers/interfaces/tools.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rainbow' + +require_relative '../../logic/cartridge/tools' +require_relative '../../components/embedding' + +module NanoBot + module Controllers + module Interfaces + module Tool + def self.adapt(feedback, adapter) + call = { + parameters: %w[id name parameters parameters-as-json output], + values: [ + feedback[:id], feedback[:name], feedback[:parameters], + feedback[:parameters].to_json, + feedback[:output] + ], + safety: false + } + + raise StandardError, 'conflicting adapters' if %i[fennel lua clojure].count { |key| !adapter[key].nil? } > 1 + + if adapter[:fennel] + call[:source] = adapter[:fennel] + Components::Embedding.fennel(**call) + elsif adapter[:clojure] + call[:source] = adapter[:clojure] + Components::Embedding.clojure(**call) + elsif adapter[:lua] + call[:parameters] = %w[id name parameters parameters_as_json output] + call[:source] = adapter[:lua] + Components::Embedding.lua(**call) + else + raise 'missing handler for adapter' + end + end + + def self.dispatch_feedback(session, cartridge, mode, feedback) + enabled = Logic::Cartridge::Tools.feedback?(cartridge, mode.to_sym, feedback[:action].to_sym) + + return unless enabled + + color = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, [:color] + ) + + adapter = Tool.adapter(cartridge, mode, feedback) + + if %i[fennel lua clojure].any? { |key| !adapter[key].nil? } + message = adapt(feedback, adapter) + else + message = "(#{feedback[:name]} #{feedback[:parameters].to_json})" + + message += " =>\n#{feedback[:output]}" if feedback[:action].to_sym == :response + end + + message = "#{adapter[:prefix]}#{message}#{adapter[:suffix]}" + + session.print(color.nil? ? message : Rainbow(message).send(color)) + end + + def self.adapter(cartridge, mode, feedback) + prefix = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, [:prefix] + ) + + suffix = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, [:suffix] + ) + + fennel = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, %i[adapter fennel] + ) + + lua = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, %i[adapter lua] + ) + + clojure = Logic::Cartridge::Tools.fetch_from_interface( + cartridge, mode.to_sym, feedback[:action].to_sym, %i[adapter clojure] + ) + + { prefix:, suffix:, fennel:, lua:, clojure: } + end + end + end + end +end diff --git a/controllers/session.rb b/controllers/session.rb index 4694911..d20deb2 100644 --- a/controllers/session.rb +++ b/controllers/session.rb @@ -3,10 +3,13 @@ require 'babosa' require 'fileutils' +require 'rainbow' require_relative '../logic/helpers/hash' require_relative '../logic/cartridge/streaming' require_relative '../logic/cartridge/interaction' +require_relative '../logic/cartridge/fetch' +require_relative 'interfaces/tools' require_relative '../components/storage' require_relative '../components/adapter' require_relative '../components/crypto' @@ -41,9 +44,9 @@ module NanoBot end def load_state - @state = Logic::Helpers::Hash.symbolize_keys(JSON.parse( - Components::Crypto.decrypt(File.read(@state_path)) - )) + @state = Logic::Helpers::Hash.symbolize_keys( + JSON.parse(Components::Crypto.decrypt(File.read(@state_path))) + ) end def store_state! @@ -78,42 +81,78 @@ module NanoBot end def process(input, mode:) + interface = Logic::Helpers::Hash.fetch(@cartridge, [:interfaces, mode.to_sym]) || {} + + input[:interface] = interface + input[:tools] = @cartridge[:tools] + + needs_another_round = true + + # TODO: Improve infinite loop prevention. + needs_another_round = process_interaction(input, mode:) while needs_another_round + end + + def process_interaction(input, mode:) prefix = Logic::Cartridge::Affixes.get(@cartridge, mode.to_sym, :output, :prefix) suffix = Logic::Cartridge::Affixes.get(@cartridge, mode.to_sym, :output, :suffix) - interface = Logic::Helpers::Hash.fetch(@cartridge, [:interfaces, mode.to_sym]) || {} + color = Logic::Cartridge::Fetch.cascate(@cartridge, [ + [:interfaces, mode.to_sym, :output, :color], + %i[interfaces output color] + ]) - streaming = Logic::Cartridge::Streaming.enabled?(@cartridge, mode.to_sym) + color = color.to_sym if color - input[:interface] = interface + streaming = Logic::Cartridge::Streaming.enabled?(@cartridge, mode.to_sym) updated_at = Time.now ready = false - @provider.evaluate(input) do |output, finished| - updated_at = Time.now - - if finished - event = Marshal.load(Marshal.dump(output)) - output = Logic::Cartridge::Interaction.output( - @cartridge, mode.to_sym, output, streaming, finished - ) + needs_another_round = false - output[:message] = Components::Adapter.apply(:output, output[:message]) - - event[:mode] = mode.to_s - event[:output] = "#{prefix}#{output[:message]}#{suffix}" + @provider.evaluate(input) do |feedback| + updated_at = Time.now - @state[:history] << event + needs_another_round = true if feedback[:needs_another_round] - self.print(output[:message]) unless streaming + if feedback[:interaction] && feedback.dig(:interaction, :meta, :tool, :action) + Interfaces::Tool.dispatch_feedback(self, @cartridge, mode, feedback[:interaction][:meta][:tool]) + end - ready = true - flush - elsif streaming - self.print(output[:message]) + if feedback[:interaction] + event = Marshal.load(Marshal.dump(feedback[:interaction])) + event[:mode] = mode.to_s + event[:output] = nil + + if feedback[:interaction][:who] == 'AI' && feedback[:interaction][:message] + event[:output] = feedback[:interaction][:message] + unless streaming + output = Logic::Cartridge::Interaction.output( + @cartridge, mode.to_sym, feedback[:interaction], streaming, feedback[:finished] + ) + output[:message] = Components::Adapter.apply(:output, output[:message]) + event[:output] = (output[:message]).to_s + end + end + + @state[:history] << event if feedback[:should_be_stored] + if event[:output] && ((!feedback[:finished] && streaming) || (!streaming && feedback[:finished])) + # TODO: Color? + if color + self.print(Rainbow(event[:output]).send(color)) + else + self.print(event[:output]) + end + + flush if feedback[:finished] + end + + # `.print` already adds a prefix and suffix, so we add them after printing to avoid duplications. + event[:output] = "#{prefix}#{event[:output]}#{suffix}" end + + ready = true if feedback[:finished] end until ready @@ -122,6 +161,8 @@ module NanoBot end store_state! unless @stateless + + needs_another_round end def flush diff --git a/logic/cartridge/adapters.rb b/logic/cartridge/adapters.rb index bc8ddaf..4a3fadf 100644 --- a/logic/cartridge/adapters.rb +++ b/logic/cartridge/adapters.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative '../helpers/hash' -require_relative './default' +require_relative 'default' module NanoBot module Logic diff --git a/logic/cartridge/affixes.rb b/logic/cartridge/affixes.rb index 0b40fa2..bf94d92 100644 --- a/logic/cartridge/affixes.rb +++ b/logic/cartridge/affixes.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative '../helpers/hash' -require_relative './default' +require_relative 'default' module NanoBot module Logic diff --git a/logic/cartridge/default.rb b/logic/cartridge/default.rb index 43d45d4..24a8e7c 100644 --- a/logic/cartridge/default.rb +++ b/logic/cartridge/default.rb @@ -15,7 +15,7 @@ module NanoBot return @values if @values path = File.expand_path('../../static/cartridges/default.yml', __dir__) - cartridge = YAML.safe_load(File.read(path), permitted_classes: [Symbol]) + cartridge = YAML.safe_load_file(path, permitted_classes: [Symbol]) @values = Logic::Helpers::Hash.symbolize_keys(cartridge) @values end @@ -24,7 +24,7 @@ module NanoBot return @baseline if @baseline path = File.expand_path('../../static/cartridges/baseline.yml', __dir__) - cartridge = YAML.safe_load(File.read(path), permitted_classes: [Symbol]) + cartridge = YAML.safe_load_file(path, permitted_classes: [Symbol]) @baseline = Logic::Helpers::Hash.symbolize_keys(cartridge) @baseline end diff --git a/logic/cartridge/fetch.rb b/logic/cartridge/fetch.rb new file mode 100644 index 0000000..2335358 --- /dev/null +++ b/logic/cartridge/fetch.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative 'default' +require_relative '../helpers/hash' + +module NanoBot + module Logic + module Cartridge + module Fetch + def self.cascate(cartridge, paths) + results = paths.map { |path| Helpers::Hash.fetch(cartridge, path) } + result = results.find { |candidate| !candidate.nil? } + return result unless result.nil? + + results = paths.map { |path| Helpers::Hash.fetch(Default.instance.values, path) } + result = results.find { |candidate| !candidate.nil? } + return result unless result.nil? + + nil + end + end + end + end +end diff --git a/logic/cartridge/interaction.rb b/logic/cartridge/interaction.rb index 160d818..5e8264e 100644 --- a/logic/cartridge/interaction.rb +++ b/logic/cartridge/interaction.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require 'sweet-moon' - -require_relative './affixes' -require_relative './adapters' +require_relative 'affixes' +require_relative 'adapters' module NanoBot module Logic @@ -12,23 +10,25 @@ module NanoBot def self.input(cartridge, interface, content) lua = Adapter.expression(cartridge, interface, :input, :lua) fennel = Adapter.expression(cartridge, interface, :input, :fennel) + clojure = Adapter.expression(cartridge, interface, :input, :clojure) prefix = Affixes.get(cartridge, interface, :input, :prefix) suffix = Affixes.get(cartridge, interface, :input, :suffix) - { content:, prefix:, suffix:, lua:, fennel: } + { content:, prefix:, suffix:, lua:, fennel:, clojure: } end def self.output(cartridge, interface, result, streaming, _finished) if streaming - result[:message] = { content: result[:message], lua: nil, fennel: nil } + result[:message] = { content: result[:message], lua: nil, fennel: nil, clojure: nil } return result end lua = Adapter.expression(cartridge, interface, :output, :lua) fennel = Adapter.expression(cartridge, interface, :output, :fennel) + clojure = Adapter.expression(cartridge, interface, :output, :clojure) - result[:message] = { content: result[:message], lua:, fennel: } + result[:message] = { content: result[:message], lua:, fennel:, clojure: } result end diff --git a/logic/cartridge/tools.rb b/logic/cartridge/tools.rb new file mode 100644 index 0000000..6a45bf0 --- /dev/null +++ b/logic/cartridge/tools.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative 'fetch' +require_relative 'affixes' +require_relative 'adapters' + +module NanoBot + module Logic + module Cartridge + module Tools + def self.fetch_from_interface(cartridge, interface, action, path) + Fetch.cascate(cartridge, [ + [:interfaces, interface, :tools, action].concat(path), + [:interfaces, :tools, action].concat(path), + %i[interfaces tools].concat(path) + ]) + end + + def self.feedback?(cartridge, interface, action) + Fetch.cascate(cartridge, [ + [:interfaces, interface, :tools, action, :feedback], + [:interfaces, :tools, action, :feedback], + %i[interfaces tools feedback] + ]) + end + + def self.input(cartridge, interface, content) + lua = Adapter.expression(cartridge, interface, :input, :lua) + fennel = Adapter.expression(cartridge, interface, :input, :fennel) + + prefix = Affixes.get(cartridge, interface, :input, :prefix) + suffix = Affixes.get(cartridge, interface, :input, :suffix) + + { content:, prefix:, suffix:, lua:, fennel: } + end + + def self.output(cartridge, interface, result, streaming, _finished) + if streaming + result[:message] = { content: result[:message], lua: nil, fennel: nil } + return result + end + + lua = Adapter.expression(cartridge, interface, :output, :lua) + fennel = Adapter.expression(cartridge, interface, :output, :fennel) + + result[:message] = { content: result[:message], lua:, fennel: } + + result + end + end + end + end +end diff --git a/logic/providers/openai.rb b/logic/providers/openai.rb new file mode 100644 index 0000000..00e7a43 --- /dev/null +++ b/logic/providers/openai.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'json' + +module NanoBot + module Logic + module OpenAI + def self.prepare_tools(cartridge, tools) + applies = [] + tools.each do |tool| + cartridge.each do |candidate| + next unless candidate[:type] == 'function' && + tool[:type] == candidate[:type] && + tool[:function][:name] == candidate[:name] + + source = {} + + source[:fennel] = candidate[:fennel] if candidate[:fennel] + source[:lua] = candidate[:lua] if candidate[:lua] + + applies << { + name: tool[:function][:name], + type: candidate[:type], + parameters: JSON.parse(tool[:function][:arguments]), + source: + } + end + end + + applies + end + + def self.adapt_tool(cartridge) + raise 'unsupported tool' if cartridge[:type] != 'function' + + adapted = { + type: 'function', + function: { + name: cartridge[:name], description: cartridge[:description], + parameters: { type: 'object', properties: {} } + } + } + + properties = adapted[:function][:parameters][:properties] + + cartridge[:parameters].each do |parameter| + key = parameter[:name].to_sym + properties[key] = {} + properties[key][:type] = parameter[:type] || 'string' + properties[key][:description] = parameter[:description] if parameter[:description] + end + + adapted + end + end + end +end diff --git a/logic/providers/openai/tools.rb b/logic/providers/openai/tools.rb new file mode 100644 index 0000000..080d81e --- /dev/null +++ b/logic/providers/openai/tools.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'json' + +require_relative '../../helpers/hash' + +module NanoBot + module Logic + module OpenAI + module Tools + def self.prepare(cartridge, tools) + applies = [] + tools.each do |tool| + # TODO: Does this mutate the hash? + tool = Helpers::Hash.symbolize_keys(tool) + + cartridge.each do |candidate| + next unless ( + candidate[:type].nil? || + (candidate[:type] == 'function' && tool[:type] == candidate[:type]) + ) && tool[:function][:name] == candidate[:name] + + source = {} + + source[:clojure] = candidate[:clojure] if candidate[:clojure] + source[:fennel] = candidate[:fennel] if candidate[:fennel] + source[:lua] = candidate[:lua] if candidate[:lua] + + applies << { + id: tool[:id], + name: tool[:function][:name], + type: candidate[:type] || 'function', + parameters: JSON.parse(tool[:function][:arguments]), + source: + } + end + end + + applies + end + + def self.adapt(cartridge) + raise 'unsupported tool' if cartridge[:type] != 'function' && !cartridge[:type].nil? + + adapted = { + type: cartridge[:type] || 'function', + function: { + name: cartridge[:name], description: cartridge[:description], + parameters: { type: 'object', properties: {} } + } + } + + properties = adapted[:function][:parameters][:properties] + + adapted[:function][:parameters][:required] = cartridge[:required] if cartridge[:required] + + cartridge[:parameters]&.each do |parameter| + key = parameter[:name].to_sym + properties[key] = {} + properties[key][:type] = parameter[:type] || 'string' + properties[key][:description] = parameter[:description] if parameter[:description] + properties[key][:items] = parameter[:items].slice(:type) if parameter[:items] + end + + adapted + end + end + end + end +end diff --git a/spec/data/cartridges/tools.yml b/spec/data/cartridges/tools.yml new file mode 100644 index 0000000..0c2a30b --- /dev/null +++ b/spec/data/cartridges/tools.yml @@ -0,0 +1,28 @@ +--- +tools: + - name: get-current-weather + type: function + description: Get the current weather in a given location. + parameters: + - name: location + - name: unit + fennel: | + (let [{:location location :unit unit} parameters] + (.. "Here is the weather in " location ", in " unit ": 35.8°C.")) + + - name: what-time-is-it + description: Returns the current date and time. + fennel: | + (os.date) + + - name: sh + description: It has access to computer users' data and can be used to run shell commands, similar to those in a Linux terminal, to extract information. Please be mindful and careful to avoid running dangerous commands on users' computers. + parameters: + - name: command + type: array + items: + type: string + description: An array of strings that represents a shell command along with its arguments or options. For instance, `["df", "-h"]` executes the `df -h` command, where each array element specifies either the command itself or an associated argument/option. + clojure: | + (require '[clojure.java.shell :refer [sh]]) + (println (apply sh (get parameters "command"))) diff --git a/spec/data/providers/openai/tools.yml b/spec/data/providers/openai/tools.yml new file mode 100644 index 0000000..681fb2c --- /dev/null +++ b/spec/data/providers/openai/tools.yml @@ -0,0 +1,11 @@ +--- +- id: call_XYZ + type: function + function: + name: get-current-weather + arguments: '{"location":"Tokyo, Japan"}' +- id: call_ZYX + type: function + function: + name: what-time-is-it + arguments: "{}" diff --git a/spec/logic/cartridge/affixes_spec.rb b/spec/logic/cartridge/affixes_spec.rb index 8f08e1d..7263008 100644 --- a/spec/logic/cartridge/affixes_spec.rb +++ b/spec/logic/cartridge/affixes_spec.rb @@ -6,7 +6,7 @@ require_relative '../../../logic/cartridge/affixes' RSpec.describe NanoBot::Logic::Cartridge::Affixes do context 'interfaces' do - let(:cartridge) { load_cartridge('affixes.yml') } + let(:cartridge) { load_symbolized('cartridges/affixes.yml') } it 'gets the expected affixes' do expect(described_class.get(cartridge, :repl, :input, :prefix)).to eq('E') @@ -22,7 +22,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Affixes do end context 'interfaces fallback' do - let(:cartridge) { load_cartridge('affixes.yml') } + let(:cartridge) { load_symbolized('cartridges/affixes.yml') } it 'gets the expected affixes' do cartridge[:interfaces][:repl][:input].delete(:prefix) @@ -48,7 +48,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Affixes do end context 'interfaces nil' do - let(:cartridge) { load_cartridge('affixes.yml') } + let(:cartridge) { load_symbolized('cartridges/affixes.yml') } it 'gets the expected affixes' do cartridge[:interfaces][:repl][:input][:prefix] = nil diff --git a/spec/logic/cartridge/interaction_spec.rb b/spec/logic/cartridge/interaction_spec.rb index 347ac45..f3ba46e 100644 --- a/spec/logic/cartridge/interaction_spec.rb +++ b/spec/logic/cartridge/interaction_spec.rb @@ -6,41 +6,41 @@ require_relative '../../../logic/cartridge/interaction' RSpec.describe NanoBot::Logic::Cartridge::Interaction do context 'input' do - let(:cartridge) { load_cartridge('affixes.yml') } + let(:cartridge) { load_symbolized('cartridges/affixes.yml') } it 'prepares the input' do expect(described_class.input(cartridge, :repl, 'hello')).to eq( - { content: 'hello', fennel: nil, lua: nil, prefix: 'E', suffix: 'F' } + { content: 'hello', fennel: nil, lua: nil, clojure: nil, prefix: 'E', suffix: 'F' } ) expect(described_class.input({}, :repl, 'hello')).to eq( - { content: 'hello', fennel: nil, lua: nil, prefix: nil, suffix: nil } + { content: 'hello', fennel: nil, lua: nil, clojure: nil, prefix: nil, suffix: nil } ) expect(described_class.input(cartridge, :eval, 'hello')).to eq( - { content: 'hello', fennel: nil, lua: nil, prefix: 'I', suffix: 'J' } + { content: 'hello', fennel: nil, lua: nil, clojure: nil, prefix: 'I', suffix: 'J' } ) expect(described_class.input({}, :eval, 'hello')).to eq( - { content: 'hello', fennel: nil, lua: nil, prefix: nil, suffix: nil } + { content: 'hello', fennel: nil, lua: nil, clojure: nil, prefix: nil, suffix: nil } ) end it 'prepares the non-streamming output' do expect(described_class.output(cartridge, :repl, { message: 'hello' }, false, true)).to eq( - { message: { content: 'hello', fennel: nil, lua: nil } } + { message: { content: 'hello', fennel: nil, lua: nil, clojure: nil } } ) expect(described_class.output({}, :repl, { message: 'hello' }, false, true)).to eq( - { message: { content: 'hello', fennel: nil, lua: nil } } + { message: { content: 'hello', fennel: nil, lua: nil, clojure: nil } } ) expect(described_class.output(cartridge, :eval, { message: 'hello' }, false, true)).to eq( - { message: { content: 'hello', fennel: nil, lua: nil } } + { message: { content: 'hello', fennel: nil, lua: nil, clojure: nil } } ) expect(described_class.output({}, :eval, { message: 'hello' }, false, true)).to eq( - { message: { content: 'hello', fennel: nil, lua: nil } } + { message: { content: 'hello', fennel: nil, lua: nil, clojure: nil } } ) end end diff --git a/spec/logic/cartridge/streaming_spec.rb b/spec/logic/cartridge/streaming_spec.rb index e5ad012..466dd0b 100644 --- a/spec/logic/cartridge/streaming_spec.rb +++ b/spec/logic/cartridge/streaming_spec.rb @@ -5,8 +5,28 @@ require 'yaml' require_relative '../../../logic/cartridge/streaming' RSpec.describe NanoBot::Logic::Cartridge::Streaming do + context 'interfaces override' do + context 'defaults' do + let(:cartridge) { {} } + + it 'uses default values when appropriate' do + expect(described_class.enabled?(cartridge, :repl)).to be(true) + expect(described_class.enabled?(cartridge, :eval)).to be(true) + end + end + + context 'top-level overrides' do + let(:cartridge) { { interfaces: { output: { stream: false } } } } + + it 'overrides default values when appropriate' do + expect(described_class.enabled?(cartridge, :repl)).to be(false) + expect(described_class.enabled?(cartridge, :eval)).to be(false) + end + end + end + context 'provider' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:provider][:settings][:stream] = false @@ -15,7 +35,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context 'repl' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:interfaces][:repl][:output][:stream] = false @@ -24,7 +44,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context 'interface + repl' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:interfaces][:output][:stream] = false @@ -34,7 +54,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context 'interface' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:interfaces][:output][:stream] = false @@ -44,7 +64,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context '- repl' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:interfaces][:repl][:output].delete(:stream) @@ -53,7 +73,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context '- interface' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:interfaces][:output].delete(:stream) @@ -63,7 +83,7 @@ RSpec.describe NanoBot::Logic::Cartridge::Streaming do end context '- provider' do - let(:cartridge) { load_cartridge('streaming.yml') } + let(:cartridge) { load_symbolized('cartridges/streaming.yml') } it 'checks if stream is enabled' do cartridge[:provider][:settings].delete(:stream) diff --git a/spec/logic/cartridge/tools_spec.rb b/spec/logic/cartridge/tools_spec.rb new file mode 100644 index 0000000..42b8c57 --- /dev/null +++ b/spec/logic/cartridge/tools_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'yaml' + +require_relative '../../../logic/cartridge/tools' + +RSpec.describe NanoBot::Logic::Cartridge::Tools do + context 'interfaces override' do + context 'defaults' do + let(:cartridge) { {} } + + it 'uses default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(true) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(false) + end + end + + context 'top-level overrides' do + let(:cartridge) do + { interfaces: { tools: { feedback: false } } } + end + + it 'overrides default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(false) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(false) + end + end + + context 'top-level overrides' do + let(:cartridge) do + { interfaces: { tools: { feedback: true } } } + end + + it 'overrides default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(true) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(true) + end + end + + context 'top-level-specific overrides' do + let(:cartridge) do + { interfaces: { tools: { call: { feedback: false }, response: { feedback: true } } } } + end + + it 'overrides default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(false) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(true) + end + end + + context 'repl interface overrides' do + let(:cartridge) do + { interfaces: { + tools: { call: { feedback: false }, response: { feedback: true } }, + repl: { tools: { call: { feedback: true }, response: { feedback: false } } } + } } + end + + it 'overrides default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(false) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(true) + end + end + + context 'eval interface overrides' do + let(:cartridge) do + { interfaces: { + tools: { call: { feedback: false }, response: { feedback: true } }, + eval: { tools: { call: { feedback: true }, response: { feedback: false } } } + } } + end + + it 'overrides default values when appropriate' do + expect(described_class.feedback?(cartridge, :repl, :call)).to be(false) + expect(described_class.feedback?(cartridge, :eval, :call)).to be(true) + + expect(described_class.feedback?(cartridge, :repl, :response)).to be(true) + expect(described_class.feedback?(cartridge, :eval, :response)).to be(false) + end + end + end +end diff --git a/spec/logic/providers/openai/tools_spec.rb b/spec/logic/providers/openai/tools_spec.rb new file mode 100644 index 0000000..c92c374 --- /dev/null +++ b/spec/logic/providers/openai/tools_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'yaml' + +require_relative '../../../../logic/providers/openai/tools' + +RSpec.describe NanoBot::Logic::OpenAI::Tools do + context 'tools' do + let(:cartridge) { load_symbolized('cartridges/tools.yml') } + + context 'adapt' do + it 'adapts to OpenAI expected format' do + expect(described_class.adapt(cartridge[:tools][0])).to eq( + { type: 'function', + function: { + name: 'get-current-weather', + description: 'Get the current weather in a given location.', + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + unit: { type: 'string' } + } + } + } } + ) + + expect(described_class.adapt(cartridge[:tools][1])).to eq( + { type: 'function', + function: { + name: 'what-time-is-it', + description: 'Returns the current date and time.', + parameters: { properties: {}, type: 'object' } + } } + ) + + expect(described_class.adapt(cartridge[:tools][2])).to eq( + { type: 'function', + function: { + name: 'sh', + description: "It has access to computer users' data and can be used to run shell commands, similar to those in a Linux terminal, to extract information. Please be mindful and careful to avoid running dangerous commands on users' computers.", + parameters: { + type: 'object', + properties: { + command: { + type: 'array', + description: 'An array of strings that represents a shell command along with its arguments or options. For instance, `["df", "-h"]` executes the `df -h` command, where each array element specifies either the command itself or an associated argument/option.', + items: { type: 'string' } + } + } + } + } } + ) + end + end + + context 'prepare' do + let(:tools) { load_symbolized('providers/openai/tools.yml') } + + it 'prepare tools to be executed' do + expect(described_class.prepare(cartridge[:tools], tools)).to eq( + [{ id: 'call_XYZ', + name: 'get-current-weather', + type: 'function', + parameters: { 'location' => 'Tokyo, Japan' }, + source: { fennel: "(let [{:location location :unit unit} parameters]\n (.. \"Here is the weather in \" location \", in \" unit \": 35.8°C.\"))\n" } }, + { id: 'call_ZYX', name: 'what-time-is-it', type: 'function', parameters: {}, + source: { fennel: "(os.date)\n" } }] + ) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ad9038d..cb46554 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,8 +16,8 @@ RSpec.configure do |config| config.shared_context_metadata_behavior = :apply_to_host_groups end -def load_cartridge(path) - cartridge = YAML.safe_load(File.read("spec/data/cartridges/#{path}"), permitted_classes: [Symbol]) +def load_symbolized(path) + cartridge = YAML.safe_load_file("spec/data/#{path}", permitted_classes: [Symbol]) NanoBot::Logic::Helpers::Hash.symbolize_keys(cartridge) end diff --git a/static/cartridges/default.yml b/static/cartridges/default.yml index 2cadc8c..167fa83 100644 --- a/static/cartridges/default.yml +++ b/static/cartridges/default.yml @@ -12,6 +12,11 @@ interfaces: output: stream: true suffix: "\n" + tools: + call: + feedback: true + response: + feedback: false provider: settings: |