diff options
author | icebaker <113217272+icebaker@users.noreply.github.com> | 2023-11-29 07:53:19 -0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-29 07:53:19 -0300 |
commit | 9f79a161905f5af8e331930cc77c7be10703596f (patch) | |
tree | b2ab75665e42de79f22cca82bf03cfc49759f485 /components | |
parent | e1ab6853262b83f483060961f17bf895989a19c0 (diff) | |
parent | 154aa68caf50a18af5c0dff1d368fc639314e0ba (diff) |
Merge pull request #5 from icebaker/ib-tools
Adding support for Spec 1.0.0: Tools (Functions)
Diffstat (limited to 'components')
-rw-r--r-- | components/adapter.rb | 46 | ||||
-rw-r--r-- | components/embedding.rb | 89 | ||||
-rw-r--r-- | components/provider.rb | 2 | ||||
-rw-r--r-- | components/providers/openai.rb | 123 | ||||
-rw-r--r-- | components/providers/openai/tools.rb | 101 | ||||
-rw-r--r-- | components/storage.rb | 2 | ||||
-rw-r--r-- | components/stream.rb | 7 |
7 files changed, 317 insertions, 53 deletions
diff --git a/components/adapter.rb b/components/adapter.rb index a361437..32aa169 100644 --- a/components/adapter.rb +++ b/components/adapter.rb @@ -1,48 +1,34 @@ # frozen_string_literal: true -require 'sweet-moon' +require_relative 'embedding' +require_relative '../logic/cartridge/safety' module NanoBot module Components class Adapter - def self.apply(_direction, params) + def self.apply(params, cartridge) 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: { sandboxed: Logic::Cartridge::Safety.sandboxed?(cartridge) } + } 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..f08ff3d --- /dev/null +++ b/components/embedding.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'sweet-moon' + +require 'open3' +require 'json' +require 'tempfile' + +module NanoBot + module Components + class Embedding + def self.ensure_safety!(safety) + raise 'missing safety definitions' unless safety.key?(:sandboxed) + end + + def self.lua(source:, parameters:, values:, safety:) + ensure_safety!(safety) + + allowed = '' + allowed = ', {math=math,string=string,table=table}' if safety[:sandboxed] + + state = SweetMoon::State.new + code = "_, embedded = pcall(load([[\nreturn function(#{parameters.join(', ')})\n#{source}\nend\n]], nil, 't'#{allowed}))" + + state.eval(code) + embedded = state.get(:embedded) + embedded.call(values) + end + + def self.fennel(source:, parameters:, values:, safety:) + ensure_safety!(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[:sandboxed] ? { allowedGlobals: %w[math string table] } : nil + ) + embedded = state.get(:embedded) + embedded.call(values) + end + + def self.clojure(source:, parameters:, values:, safety:) + ensure_safety!(safety) + + raise 'TODO: sandboxed Clojure through Babashka not implemented' if safety[:sandboxed] + + 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..87f2bc5 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 @@ -32,17 +37,16 @@ module NanoBot @client = ::OpenAI::Client.new(uri_base:, access_token: @credentials[:'access-token']) end - def stream(input) - provider = @settings.key?(:stream) ? @settings[:stream] : true - interface = input[:interface].key?(:stream) ? input[:interface][:stream] : true - - provider && interface - end - - def evaluate(input, &block) + def evaluate(input, streaming, cartridge, &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].to_s, + 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,26 +66,105 @@ module NanoBot payload.delete(:logit_bias) if payload.key?(:logit_bias) && payload[:logit_bias].nil? - if stream(input) + payload[:tools] = input[:tools].map { |raw| NanoBot::Logic::OpenAI::Tools.adapt(raw) } if input[:tools] + + if streaming 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 - block.call({ who: 'AI', message: content }, true) if chunk.dig('choices', 0, 'finish_reason') + if partial_content + content += partial_content + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: partial_content } } + ) + end + + 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(cartridge, 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) + begin + @client.chat(parameters: payload) + rescue StandardError => e + raise e.class, e.response[:body] if e.response && e.response[:body] + + raise e + end else - result = @client.chat(parameters: payload) + begin + result = @client.chat(parameters: payload) + rescue StandardError => e + raise e.class, e.response[:body] if e.response && e.response[:body] + + raise e + end 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(cartridge, 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..cd35e80 --- /dev/null +++ b/components/providers/openai/tools.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require_relative '../../embedding' +require_relative '../../../logic/cartridge/safety' + +require 'concurrent' + +module NanoBot + module Components + module Providers + class OpenAI < Base + module Tools + def self.confirming(tool, feedback) + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: nil, meta: { + tool: { action: 'confirming', id: tool[:id], name: tool[:name], parameters: tool[:parameters] } + } } } + ) + end + + def self.apply(cartridge, function_cartridge, tools, feedback) + prepared_tools = NanoBot::Logic::OpenAI::Tools.prepare(function_cartridge, tools) + + if Logic::Cartridge::Safety.confirmable?(cartridge) + prepared_tools.each { |tool| tool[:allowed] = confirming(tool, feedback) } + else + prepared_tools.each { |tool| tool[:allowed] = true } + end + + futures = prepared_tools.map do |tool| + Concurrent::Promises.future do + if tool[:allowed] + process!(tool, feedback, function_cartridge, cartridge) + else + tool[:output] = + "We asked the user you're chatting with for permission, but the user did not allow you to run this tool or function." + tool + end + end + 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, _function_cartridge, cartridge) + feedback.call( + { should_be_stored: false, + interaction: { who: 'AI', message: nil, meta: { + tool: { action: 'executing', id: tool[:id], name: tool[:name], parameters: tool[:parameters] } + } } } + ) + + call = { + parameters: %w[parameters], + values: [tool[:parameters]], + safety: { sandboxed: Logic::Cartridge::Safety.sandboxed?(cartridge) } + } + + 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: 'responding', 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 |