diff --git a/examples/memory/memory_save.rb b/examples/memory/memory_save.rb new file mode 100644 index 0000000..fe2d676 --- /dev/null +++ b/examples/memory/memory_save.rb @@ -0,0 +1,71 @@ +require_relative "../bootstrap" + +client = OpenAISwarm.new +model = ENV['SWARM_AGENT_DEFAULT_MODEL'] || "gpt-4o-mini" + +memory = OpenAISwarm::Memory.new(memory_fields: ["language", "grade", "name", "sex"]) +memory.function + +def get_weather(location:) + puts "tool call: get_weather" + "{'temp':67, 'unit':'F'}" +end + +def get_news(category:) + puts "tool call: get_news" + [ + "Tech Company A Acquires Startup B", + "New AI Model Revolutionizes Industry", + "Breakthrough in Quantum Computing" + ].sample +end + +get_news_instance = OpenAISwarm::FunctionDescriptor.new( + target_method: :get_news, + description: 'Get the latest news headlines. The category of news, e.g., world, business, sports.' +) + +get_weather_instance = OpenAISwarm::FunctionDescriptor.new( + target_method: :get_weather, + description: 'Simulate fetching weather data' +) + + +system_prompt = "You are a helpful teaching assistant. Remember to save important information about the student using the core_memory_save function. Always greet the student by name if you know it." + +chatbot_agent = OpenAISwarm::Agent.new( + name: "teaching_assistant", + instructions: system_prompt, + model: model, + functions: [ + get_weather_instance, + get_news_instance + ], + memory: memory +) + +messages1 = [ + { + "role": "user", + "content": "Hi, I'm John. I speak Chinese and I'm in Senior Year. Get the current weather in a given location. Location MUST be a city." + } +] + +puts "first call, set memory" +puts "messages: #{messages1}" + +response1 = client.run(agent: chatbot_agent, messages: messages1, debug: env_debug) +puts "memory data: #{memory.entity_store.data}" +puts response1.messages.last['content'] + +puts "" +messages2 = [ + {"role": "user", "content": "what is my name"}, +] +puts "2nd call, get memory" +puts "memory data: #{memory.entity_store.data}" +puts "messages: #{messages2}" + +response2 = client.run(agent: chatbot_agent, messages: messages2, debug: env_debug) + +puts response2.messages.last['content'] diff --git a/lib/ruby-openai-swarm.rb b/lib/ruby-openai-swarm.rb index 4acee22..9ad1352 100644 --- a/lib/ruby-openai-swarm.rb +++ b/lib/ruby-openai-swarm.rb @@ -11,6 +11,11 @@ require 'ruby-openai-swarm/repl' require 'ruby-openai-swarm/configuration' require 'ruby-openai-swarm/logger' +require 'ruby-openai-swarm/memory' +require 'ruby-openai-swarm/memories/entity_store' +require 'ruby-openai-swarm/memories/core_memory_function' +require 'ruby-openai-swarm/memories/field' + module OpenAISwarm class Error < StandardError; diff --git a/lib/ruby-openai-swarm/agent.rb b/lib/ruby-openai-swarm/agent.rb index 217f82a..617926e 100644 --- a/lib/ruby-openai-swarm/agent.rb +++ b/lib/ruby-openai-swarm/agent.rb @@ -7,7 +7,8 @@ class Agent :noisy_tool_calls, :temperature, :current_tool_name, - :resource + :resource, + :memory # These attributes can be read and written externally. They include: # - name: The name of the agent. # - model: The model used, e.g., "gpt-4". @@ -27,7 +28,8 @@ def initialize( resource: nil, noisy_tool_calls: [], strategy: {}, - current_tool_name: nil + current_tool_name: nil, + memory: nil ) @name = name @model = model @@ -40,6 +42,13 @@ def initialize( @noisy_tool_calls = noisy_tool_calls @strategy = Agents::StrategyOptions.new(strategy) @current_tool_name = current_tool_name.nil? ? nil : current_tool_name.to_s + @memory = memory + end + + def functions + return @functions if memory&.function.nil? + + @functions.push(memory.function) end end end diff --git a/lib/ruby-openai-swarm/core.rb b/lib/ruby-openai-swarm/core.rb index f795078..d0b5d61 100644 --- a/lib/ruby-openai-swarm/core.rb +++ b/lib/ruby-openai-swarm/core.rb @@ -15,11 +15,28 @@ def initialize(client = nil) @logger = OpenAISwarm::Logger.instance.logger end + # TODO(Grayson) + # def create_agent(name:, model:, instructions:, **options) + # memory = Memory.new(@memory_fields) + # Agent.new( + # name: name, + # model: model, + # instructions: instructions, + # memory: memory, + # functions: functions, + # **options + # ) + # end + def get_chat_completion(agent_tracker, history, context_variables, model_override, stream, debug) agent = agent_tracker.current_agent context_variables = context_variables.dup instructions = agent.instructions.respond_to?(:call) ? agent.instructions.call(context_variables) : agent.instructions - messages = [{ role: 'system', content: instructions }] + history + + # Build a message history, including memories + messages = [{ role: 'system', content: instructions }] + messages << { role: 'system', content: agent.memory.prompt_content } unless agent&.memory&.prompt_content.nil? + messages += history # Util.debug_print(debug, "Getting chat completion for...:", messages) @@ -66,9 +83,10 @@ def get_chat_completion(agent_tracker, history, context_variables, model_overrid Util.debug_print(debug, "API Response:", response) response - rescue OpenAI::Error => e - log_message(:error, "OpenAI API Error: #{e.message}") - Util.debug_print(true, "OpenAI API Error:", e.message) + rescue OpenAI::Error, Faraday::BadRequestError => e + error_message = (e.response || {}).dig(:body) || e.inspect + log_message(:error, "OpenAI API Error: #{error_message}") + Util.debug_print(true, "OpenAI API Error:", error_message) raise end @@ -182,11 +200,11 @@ def run(agent:, messages:, context_variables: {}, model_override: nil, stream: f debug ) - message = completion.dig('choices', 0, 'message') + message = completion.dig('choices', 0, 'message') || {} Util.debug_print(debug, "Received completion:", message) log_message(:info, "Received completion:", message) - message['sender'] = active_agent.name + message['sender'] = active_agent&.name history << message if !message['tool_calls'] || !execute_tools diff --git a/lib/ruby-openai-swarm/memories/core_memory_function.rb b/lib/ruby-openai-swarm/memories/core_memory_function.rb new file mode 100644 index 0000000..5992480 --- /dev/null +++ b/lib/ruby-openai-swarm/memories/core_memory_function.rb @@ -0,0 +1,27 @@ +module OpenAISwarm + module Memories + class CoreMemoryFunction + def self.definition(memory_fields = []) + properties = {} + + memory_fields.each do |memory_field| + field = memory_field.field + description = "The #{field} to remember." + memory_field&.tool_call_description.to_s + properties[field] = { type: "string", description: description } + end + + { + type: "function", + function: { + name: "core_memory_save", + description: "Save important information about you, the agent or the human you are chatting with.", + parameters: { + type: "object", + properties: properties, + } + } + } + end + end + end +end diff --git a/lib/ruby-openai-swarm/memories/entity_store.rb b/lib/ruby-openai-swarm/memories/entity_store.rb new file mode 100644 index 0000000..966f3fe --- /dev/null +++ b/lib/ruby-openai-swarm/memories/entity_store.rb @@ -0,0 +1,54 @@ +module OpenAISwarm + module Memories + class EntityStore + attr_accessor :entity_store, + :data + + def initialize(entity_store = nil) + @entity_store = entity_store + @data = entity_store&.data || {} + end + + def entity_store_save + return unless entity_store.respond_to?(:update) + + entity_store.update(data: data) + end + + def add_entities(entities) + entities.each { |key, value| @data[key.to_sym] = value } + entity_store_save + end + + def memories + data&.to_json + end + + # def add(key, value) + # @data[key] = value + # entity_store_save + # @data + # end + + # def get(key) + # @data[key] + # end + + # def exists?(key) + # @data.key?(key) + # end + + # def remove(key) + # @data.delete(key) + # end + + # def clear + # @data = {} + # end + + # def all + # @data + # end + end + end +end diff --git a/lib/ruby-openai-swarm/memories/field.rb b/lib/ruby-openai-swarm/memories/field.rb new file mode 100644 index 0000000..c59e2af --- /dev/null +++ b/lib/ruby-openai-swarm/memories/field.rb @@ -0,0 +1,39 @@ +module OpenAISwarm + module Memories + class Field + attr_accessor :field, + :tool_call_description + + VALID_MEMORY_FIELDS = [:field, :tool_call_description].freeze + + def initialize(memory_field) + memory_field.is_a?(Hash) ? parse_hash(memory_field) : parse_string(memory_field) + end + + def parse_hash(memory_field) + validate_memory_field!(memory_field) + + @field = memory_field[:field] + @tool_call_description = memory_field[:tool_call_description] + end + + def parse_string(memory_field) + @field = memory_field + end + + private + + def validate_memory_field!(memory_field) + unless memory_field.include?(:field) + raise ArgumentError, "memory_field must include :field" + end + + invalid_fields = memory_field.keys - VALID_MEMORY_FIELDS + + unless invalid_fields.empty? + raise ArgumentError, "Invalid memory fields: #{invalid_fields.join(', ')}. Valid fields are: #{VALID_MEMORY_FIELDS.join(', ')}" + end + end + end + end +end diff --git a/lib/ruby-openai-swarm/memory.rb b/lib/ruby-openai-swarm/memory.rb new file mode 100644 index 0000000..a463748 --- /dev/null +++ b/lib/ruby-openai-swarm/memory.rb @@ -0,0 +1,49 @@ +module OpenAISwarm + class Memory + attr_reader :memories, + :entity_store + + def initialize(memory_fields: [], entity_store: nil) + @memory_fields = normalize_memory_fields(memory_fields) + @entity_store = Memories::EntityStore.new(entity_store) + end + + def normalize_memory_fields(memory_fields) + return [] if memory_fields.empty? + + memory_fields.map { |memory_field| Memories::Field.new(memory_field) } + end + + def core_memory_save(entities) + entity_store.add_entities(entities) + end + + def prompt_content + return nil if get_memories_data.nil? + + fields = @memory_fields.map(&:field).join(", ") + "You have a section of your context called [MEMORY] " \ + "that contains the following information: #{fields}. " \ + "Here are the relevant details: [MEMORY]\n" \ + "#{get_memories_data}" + end + + def function + return nil if @memory_fields.empty? + + OpenAISwarm::FunctionDescriptor.new( + target_method: method(:core_memory_save), + description: core_memory_save_metadata[:function][:description], + parameters: core_memory_save_metadata[:function][:parameters] + ) + end + + def core_memory_save_metadata + @core_memory_save_metadata ||= Memories::CoreMemoryFunction.definition(@memory_fields) + end + + def get_memories_data + entity_store&.memories + end + end +end diff --git a/lib/ruby-openai-swarm/version.rb b/lib/ruby-openai-swarm/version.rb index bc46c50..80b8916 100644 --- a/lib/ruby-openai-swarm/version.rb +++ b/lib/ruby-openai-swarm/version.rb @@ -1,3 +1,3 @@ module OpenAISwarm - VERSION = "0.4.0.2" + VERSION = "0.5.0" end diff --git a/spec/lib/ruby-openai-swarm/memories/core_memory_function_spec.rb b/spec/lib/ruby-openai-swarm/memories/core_memory_function_spec.rb new file mode 100644 index 0000000..35dbbee --- /dev/null +++ b/spec/lib/ruby-openai-swarm/memories/core_memory_function_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +RSpec.describe OpenAISwarm::Memories::CoreMemoryFunction do + describe '.definition' do + # Test with empty memory fields + context 'when no memory fields are provided' do + it 'returns a function definition with empty properties' do + result = described_class.definition([]) + + expect(result).to be_a(Hash) + expect(result[:type]).to eq('function') + expect(result[:function][:name]).to eq('core_memory_save') + expect(result[:function][:parameters][:properties]).to be_empty + end + end + + # Test with memory fields + context 'when memory fields are provided' do + let(:memory_field) do + field = OpenAISwarm::Memories::Field.new('name') + field.tool_call_description = ' This is used to store the name.' + field + end + + let(:memory_fields) { [memory_field] } + + it 'returns a function definition with correct properties' do + result = described_class.definition(memory_fields) + + expect(result).to be_a(Hash) + expect(result[:type]).to eq('function') + expect(result[:function]).to include( + name: 'core_memory_save', + description: 'Save important information about you, the agent or the human you are chatting with.' + ) + + # Check properties structure + properties = result[:function][:parameters][:properties] + expect(properties).to include('name') + expect(properties['name']).to include( + type: 'string', + description: 'The name to remember. This is used to store the name.' + ) + end + end + + # Test with multiple memory fields + context 'when multiple memory fields are provided' do + let(:memory_fields) do + [ + OpenAISwarm::Memories::Field.new('name'), + OpenAISwarm::Memories::Field.new('age') + ] + end + + it 'includes all fields in the properties' do + result = described_class.definition(memory_fields) + properties = result[:function][:parameters][:properties] + + expect(properties.keys).to contain_exactly('name', 'age') + expect(properties['name'][:type]).to eq('string') + expect(properties['age'][:type]).to eq('string') + end + end + end +end diff --git a/spec/lib/ruby-openai-swarm/memories/field_spec.rb b/spec/lib/ruby-openai-swarm/memories/field_spec.rb new file mode 100644 index 0000000..1cba03a --- /dev/null +++ b/spec/lib/ruby-openai-swarm/memories/field_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +RSpec.describe OpenAISwarm::Memories::Field do + # Test initialization with a string + describe '#initialize with string' do + it 'sets the field value when initialized with a string' do + field = described_class.new('test_field') + expect(field.field).to eq('test_field') + expect(field.tool_call_description).to be_nil + end + end + + # Test initialization with a valid hash + describe '#initialize with hash' do + it 'sets both field and tool_call_description when initialized with a valid hash' do + field = described_class.new( + field: 'test_field', + tool_call_description: 'test description' + ) + expect(field.field).to eq('test_field') + expect(field.tool_call_description).to eq('test description') + end + + it 'sets only field when tool_call_description is not provided' do + field = described_class.new(field: 'test_field') + expect(field.field).to eq('test_field') + expect(field.tool_call_description).to be_nil + end + end + + # Test validation errors + describe 'validation' do + it 'raises ArgumentError when :field is missing from hash' do + expect { + described_class.new(tool_call_description: 'test description') + }.to raise_error(ArgumentError, 'memory_field must include :field') + end + + it 'raises ArgumentError when invalid keys are present' do + expect { + described_class.new( + field: 'test_field', + invalid_key: 'value' + ) + }.to raise_error( + ArgumentError, + 'Invalid memory fields: invalid_key. Valid fields are: field, tool_call_description' + ) + end + end + + # Test parse_hash method + describe '#parse_hash' do + it 'correctly parses a valid hash' do + # Initialize with valid field to avoid validation error + field = described_class.new(field: 'initial') + + field.send(:parse_hash, { + field: 'test_field', + tool_call_description: 'test description' + }) + + expect(field.field).to eq('test_field') + expect(field.tool_call_description).to eq('test description') + end + end + + # Test parse_string method + describe '#parse_string' do + it 'correctly parses a string' do + field = described_class.new(field: 'initial') # Initialize with valid field + field.send(:parse_string, 'test_field') + expect(field.field).to eq('test_field') + end + end +end diff --git a/spec/lib/ruby-openai-swarm/memory_spec.rb b/spec/lib/ruby-openai-swarm/memory_spec.rb new file mode 100644 index 0000000..7ad84ba --- /dev/null +++ b/spec/lib/ruby-openai-swarm/memory_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +RSpec.describe OpenAISwarm::Memory do + # Test initialization and memory field normalization + describe '#initialize' do + context 'when initialized with memory fields' do + let(:memory_fields) { ['name', 'age'] } + let(:memory) { described_class.new(memory_fields: memory_fields) } + + it 'creates normalized memory fields' do + expect(memory.instance_variable_get(:@memory_fields).size).to eq(2) + expect(memory.instance_variable_get(:@memory_fields).first).to be_a(OpenAISwarm::Memories::Field) + end + end + + context 'when initialized without memory fields' do + let(:memory) { described_class.new } + + it 'creates empty memory fields array' do + expect(memory.instance_variable_get(:@memory_fields)).to be_empty + end + end + end + + # Test core memory save functionality + describe '#core_memory_save' do + let(:memory) { described_class.new(memory_fields: ['name']) } + let(:entities) { [{ 'name' => 'John Doe' }] } + + it 'delegates entity addition to entity store' do + expect(memory.entity_store).to receive(:add_entities).with(entities) + memory.core_memory_save(entities) + end + end + + # Test prompt content generation + describe '#prompt_content' do + context 'when memories exist' do + let(:memory_fields) { ['name', 'age'] } + let(:memory) { described_class.new(memory_fields: memory_fields) } + let(:memories_data) { "John Doe, 30" } + + before do + allow(memory).to receive(:get_memories_data).and_return(memories_data) + end + + it 'returns formatted prompt content with memories' do + expected_content = "You have a section of your context called [MEMORY] " \ + "that contains the following information: name, age. " \ + "Here are the relevant details: [MEMORY]\n" \ + "John Doe, 30" + expect(memory.prompt_content).to eq(expected_content) + end + end + + context 'when no memories exist' do + let(:memory) { described_class.new } + + before do + allow(memory).to receive(:get_memories_data).and_return(nil) + end + + it 'returns nil' do + expect(memory.prompt_content).to be_nil + end + end + end + + # Test function generation + describe '#function' do + context 'when memory fields exist' do + let(:memory) { described_class.new(memory_fields: ['name']) } + + it 'returns a FunctionDescriptor instance' do + expect(memory.function).to be_a(OpenAISwarm::FunctionDescriptor) + end + + it 'has correct target method' do + expect(memory.function.target_method).to eq(memory.method(:core_memory_save)) + end + end + + context 'when no memory fields exist' do + let(:memory) { described_class.new } + + it 'returns nil' do + expect(memory.function).to be_nil + end + end + end + + # Test memories data retrieval + describe '#get_memories_data' do + let(:memory) { described_class.new } + + it 'delegates to entity store' do + expect(memory.entity_store).to receive(:memories) + memory.get_memories_data + end + end +end diff --git a/spec/lib/ruby-openai-swarm/util/latest_role_user_message_spec.rb b/spec/lib/ruby-openai-swarm/util/latest_role_user_message_spec.rb index a0da95a..6d73b18 100644 --- a/spec/lib/ruby-openai-swarm/util/latest_role_user_message_spec.rb +++ b/spec/lib/ruby-openai-swarm/util/latest_role_user_message_spec.rb @@ -47,7 +47,7 @@ { "role"=>"user", "content"=>"Tell me the weather in London." - }, + }, ] end