swarm-rb 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cae2fec017926fd78d147037ca44fa5f0d77118cf098e2dc47796f9d069f2dff
4
+ data.tar.gz: 64084bfbbb87381a25270868903338192f04e05df375286c11c2ad8dc2393c0a
5
+ SHA512:
6
+ metadata.gz: d93372e43f1600086779e286783a90c9048318d01b6ad96a60210c77e49e114685b58b5a4106c96a5cb91be8a3522ceaa6d2e845d21c302ebdd6c82991285ab3
7
+ data.tar.gz: d6ad7fece31eb2bbe265da75a21fc79e4b2349e097073c13f311f3894bbfab2d1bcffb6b21fe5353d88a0df9a52adaf5d2f7c71f716234174929244137971c2d
data/Readme.md ADDED
@@ -0,0 +1,36 @@
1
+ ![Swarm Logo](assets/logo.png)
2
+
3
+ # Swarm-RB (experimental, educational)
4
+
5
+ An educational framework exploring ergonomic, lightweight multi-agent orchestration in Ruby. It is a port of [Swarm](https://github.com/openai/swarm/tree/main/swarm) created by Open AI.
6
+
7
+ ## Install
8
+
9
+ ```shell
10
+ gem install swarm-rb
11
+ ```
12
+
13
+ Or, if using Bundler, add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'swarm-rb', git: 'https://github.com/openai/swarm-rb.git'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```shell
22
+ bundle install
23
+ ```
24
+
25
+ # Examples
26
+
27
+ Check out `/examples` for inspiration!
28
+
29
+ - [`basic`](examples/basic): Simple examples of fundamentals like setup, function calling, handoffs, and context variables.
30
+
31
+ ## Contributing
32
+
33
+ Everyone is welcome to contribute to this. This gem was created just because I wanted to use Open AI's swarm framework in Ruby. There is likly a ton of 🍌s code in this repo since I relied heavily on LLMs to create this gem in < 2 days.
34
+
35
+ It needs more testing, documentation clean up and expantion on the examples to parity the Open AI's [Swarm](https://github.com/openai/swarm) library.
36
+
data/lib/swarm/core.rb ADDED
@@ -0,0 +1,199 @@
1
+ require "openai"
2
+ require "json"
3
+ require_relative "util"
4
+ require_relative "types"
5
+
6
+ module Swarm
7
+ class Swarm
8
+ CTX_VARS_NAME = "context_variables"
9
+
10
+ def initialize(client = nil)
11
+ @client = client || OpenAI::Client.new
12
+ end
13
+
14
+ def get_chat_completion(
15
+ agent:,
16
+ history:,
17
+ context_variables:,
18
+ model_override: nil,
19
+ stream: false,
20
+ debug: false
21
+ )
22
+ context_variables = context_variables.dup
23
+ instructions = if agent.instructions.respond_to?(:call)
24
+ agent.instructions.call(context_variables)
25
+ else
26
+ agent.instructions
27
+ end
28
+
29
+ messages = [{"role" => "system", "content" => instructions}] + history
30
+ Util.debug_print(debug, "Getting chat completion for:", messages)
31
+
32
+ tools = agent.functions.map { |f| Util.function_to_json(f) }
33
+
34
+ # Hide context_variables from the model
35
+ tools.each do |tool|
36
+ puts tool
37
+ params = tool["parameters"]
38
+ params["properties"]&.delete(CTX_VARS_NAME)
39
+ params["required"]&.delete(CTX_VARS_NAME)
40
+ end
41
+
42
+ parameters = {
43
+ model: model_override || agent.model,
44
+ messages: messages
45
+ }
46
+
47
+ parameters[:functions] = tools unless tools.empty?
48
+ parameters[:function_call] = agent.tool_choice if agent.tool_choice
49
+ parameters[:stream] = stream
50
+
51
+ Util.debug_print(debug, "Chat parameters:", parameters)
52
+
53
+ begin
54
+ if stream
55
+ @client.chat(parameters: parameters) do |chunk|
56
+ # Handle streaming response if needed
57
+ end
58
+ else
59
+ response = @client.chat(parameters: parameters)
60
+ Util.debug_print(debug, "API Response:", response)
61
+ response
62
+ end
63
+ rescue OpenAI::Error => e
64
+ Util.debug_print(true, "OpenAI API Error:", e.message)
65
+ raise
66
+ end
67
+ end
68
+
69
+ def handle_function_result(result, debug)
70
+ case result
71
+ when Result
72
+ result
73
+ when Agent
74
+ Result.new(value: {"assistant" => result.name}.to_json, agent: result)
75
+ else
76
+ begin
77
+ Result.new(value: result.to_s)
78
+ rescue => e
79
+ error_message = "Failed to cast response to string: #{result}. Make sure agent functions return a string or Result object. Error: #{e.message}"
80
+ Util.debug_print(debug, error_message)
81
+ raise TypeError, error_message
82
+ end
83
+ end
84
+ end
85
+
86
+ def handle_tool_calls(tool_calls, functions, context_variables, debug)
87
+ function_map = functions.map { |f| [f.name.to_s, f] }.to_h
88
+ partial_response = Response.new(messages: [], agent: nil, context_variables: {})
89
+
90
+ tool_calls.each do |tool_call|
91
+ name = tool_call["name"]
92
+ unless function_map.key?(name)
93
+ Util.debug_print(debug, "Tool #{name} not found in function map.")
94
+ partial_response.messages << {
95
+ "role" => "function",
96
+ "name" => name,
97
+ "content" => "Error: Tool #{name} not found."
98
+ }
99
+ next
100
+ end
101
+
102
+ args = JSON.parse(tool_call["arguments"] || "{}")
103
+ Util.debug_print(debug, "Processing tool call: #{name} with arguments #{args}")
104
+
105
+ func = function_map[name]
106
+ # Pass context_variables to agent functions
107
+ if func.parameters.map(&:last).include?(CTX_VARS_NAME.to_sym)
108
+ args[CTX_VARS_NAME] = context_variables
109
+ end
110
+
111
+ raw_result = func.call(**args.transform_keys(&:to_sym))
112
+ result = handle_function_result(raw_result, debug)
113
+ partial_response.messages << {
114
+ "role" => "function",
115
+ "name" => name,
116
+ "content" => result.value
117
+ }
118
+ partial_response.context_variables.merge!(result.context_variables)
119
+ partial_response.agent ||= result.agent
120
+ end
121
+
122
+ partial_response
123
+ end
124
+
125
+ def run(
126
+ agent:,
127
+ messages:,
128
+ context_variables: {},
129
+ model_override: nil,
130
+ stream: false,
131
+ debug: false,
132
+ max_turns: Float::INFINITY,
133
+ execute_tools: true
134
+ )
135
+ if stream
136
+ run_and_stream(
137
+ agent: agent,
138
+ messages: messages,
139
+ context_variables: context_variables,
140
+ model_override: model_override,
141
+ debug: debug,
142
+ max_turns: max_turns,
143
+ execute_tools: execute_tools
144
+ )
145
+ else
146
+ active_agent = agent
147
+ context_variables = context_variables.dup
148
+ history = messages.dup
149
+ init_len = messages.length
150
+
151
+ while (history.length - init_len) < max_turns && active_agent
152
+ completion = get_chat_completion(
153
+ agent: active_agent,
154
+ history: history,
155
+ context_variables: context_variables,
156
+ model_override: model_override,
157
+ stream: stream,
158
+ debug: debug
159
+ )
160
+
161
+ message = completion["choices"][0]["message"]
162
+ Util.debug_print(debug, "Received completion:", message)
163
+ message["sender"] = active_agent.name
164
+ history << message
165
+
166
+ unless message["function_call"] && execute_tools
167
+ Util.debug_print(debug, "Ending turn.")
168
+ break
169
+ end
170
+
171
+ tool_calls = [message["function_call"]]
172
+ partial_response = handle_tool_calls(
173
+ tool_calls, active_agent.functions, context_variables, debug
174
+ )
175
+ history.concat(partial_response.messages)
176
+ context_variables.merge!(partial_response.context_variables)
177
+ active_agent = partial_response.agent if partial_response.agent
178
+ end
179
+
180
+ Response.new(
181
+ messages: history[init_len..],
182
+ agent: active_agent,
183
+ context_variables: context_variables
184
+ )
185
+ end
186
+ end
187
+
188
+ def run_and_stream(
189
+ agent:,
190
+ messages:,
191
+ context_variables: {},
192
+ model_override: nil,
193
+ debug: false,
194
+ max_turns: Float::INFINITY,
195
+ execute_tools: true
196
+ )
197
+ end
198
+ end
199
+ end
data/lib/swarm/repl.rb ADDED
@@ -0,0 +1,97 @@
1
+ require "json"
2
+ require_relative "core"
3
+ require "dotenv/load"
4
+
5
+ module Swarm
6
+ module Repl
7
+ def self.process_and_print_streaming_response(response)
8
+ content = ""
9
+ last_sender = ""
10
+
11
+ response.each do |chunk|
12
+ if chunk["sender"]
13
+ last_sender = chunk["sender"]
14
+ end
15
+
16
+ if chunk["content"]
17
+ if content.empty? && last_sender
18
+ print "\e[94m#{last_sender}:\e[0m "
19
+ last_sender = ""
20
+ end
21
+ print chunk["content"]
22
+ content += chunk["content"]
23
+ end
24
+
25
+ chunk["tool_calls"]&.each do |tool_call|
26
+ func = tool_call["function"]
27
+ name = func["name"]
28
+ next unless name
29
+ puts "\e[94m#{last_sender}: \e[95m#{name}\e[0m()"
30
+ end
31
+
32
+ if chunk["delim"] == "end" && !content.empty?
33
+ puts
34
+ content = ""
35
+ end
36
+
37
+ return chunk["response"] if chunk["response"]
38
+ end
39
+ end
40
+
41
+ def self.pretty_print_messages(messages)
42
+ messages.each do |message|
43
+ next unless message["role"] == "assistant"
44
+
45
+ # Print agent name in blue
46
+ print "\e[94m#{message["sender"]}\e[0m: "
47
+
48
+ puts message["content"] if message["content"]
49
+
50
+ tool_calls = message["tool_calls"] || []
51
+ puts if tool_calls.length > 1
52
+ tool_calls.each do |tool_call|
53
+ func = tool_call["function"]
54
+ name = func["name"]
55
+ args = JSON.parse(func["arguments"] || "{}").map { |k, v| "#{k}=#{v}" }.join(", ")
56
+ puts "\e[95m#{name}\e[0m(#{args})"
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.run_demo_loop(
62
+ starting_agent,
63
+ context_variables = nil,
64
+ stream = false,
65
+ debug = false
66
+ )
67
+ client = Swarm.new
68
+ puts "Starting Swarm CLI \u{1F41D}"
69
+
70
+ messages = []
71
+ agent = starting_agent
72
+
73
+ loop do
74
+ print "\e[90mUser\e[0m: "
75
+ user_input = gets.chomp
76
+ messages << {"role" => "user", "content" => user_input}
77
+
78
+ response = client.run(
79
+ agent: agent,
80
+ messages: messages,
81
+ context_variables: context_variables || {},
82
+ stream: stream,
83
+ debug: debug
84
+ )
85
+
86
+ if stream
87
+ response = process_and_print_streaming_response(response)
88
+ else
89
+ pretty_print_messages(response.messages)
90
+ end
91
+
92
+ messages.concat(response.messages)
93
+ agent = response.agent
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,41 @@
1
+ module Swarm
2
+ class Agent
3
+ attr_accessor :name, :model, :instructions, :functions, :tool_choice, :parallel_tool_calls
4
+
5
+ def initialize(
6
+ name: "Agent",
7
+ model: "gpt-4",
8
+ instructions: "You are a helpful agent.",
9
+ functions: [],
10
+ tool_choice: nil,
11
+ parallel_tool_calls: true
12
+ )
13
+ @name = name
14
+ @model = model
15
+ @instructions = instructions
16
+ @functions = functions
17
+ @tool_choice = tool_choice
18
+ @parallel_tool_calls = parallel_tool_calls
19
+ end
20
+ end
21
+
22
+ class Response
23
+ attr_accessor :messages, :agent, :context_variables
24
+
25
+ def initialize(messages: [], agent: nil, context_variables: {})
26
+ @messages = messages
27
+ @agent = agent
28
+ @context_variables = context_variables
29
+ end
30
+ end
31
+
32
+ class Result
33
+ attr_accessor :value, :agent, :context_variables
34
+
35
+ def initialize(value: "", agent: nil, context_variables: {})
36
+ @value = value
37
+ @agent = agent
38
+ @context_variables = context_variables
39
+ end
40
+ end
41
+ end
data/lib/swarm/util.rb ADDED
@@ -0,0 +1,71 @@
1
+ require "time"
2
+ require "json"
3
+
4
+ module Swarm
5
+ module Util
6
+ def self.debug_print(debug, *args)
7
+ return unless debug
8
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
9
+ message = args.join(" ")
10
+ puts "\e[97m[\e[90m#{timestamp}\e[97m]\e[90m #{message}\e[0m"
11
+ end
12
+
13
+ def self.merge_fields(target, source)
14
+ source.each do |key, value|
15
+ if value.is_a?(String)
16
+ target[key] += value
17
+ elsif value.is_a?(Hash)
18
+ merge_fields(target[key], value)
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.merge_chunk(final_response, delta)
24
+ delta.delete("role")
25
+ merge_fields(final_response, delta)
26
+
27
+ tool_calls = delta["tool_calls"]
28
+ if tool_calls && !tool_calls.empty?
29
+ index = tool_calls[0].delete("index")
30
+ merge_fields(final_response["tool_calls"][index], tool_calls[0])
31
+ end
32
+ end
33
+
34
+ def self.function_to_json(func)
35
+ type_map = {
36
+ String => "string",
37
+ Integer => "integer",
38
+ Float => "number",
39
+ TrueClass => "boolean",
40
+ FalseClass => "boolean",
41
+ Array => "array",
42
+ Hash => "object",
43
+ NilClass => "null"
44
+ }
45
+
46
+ parameters = {}
47
+ required = []
48
+
49
+ func.parameters.each do |type, name|
50
+ param_type = type_map[name.class] || "string" # Default to 'string' if type is unknown
51
+
52
+ if name.to_s == "context_variables" && type == :keyreq
53
+ param_type = "object"
54
+ end
55
+
56
+ parameters[name.to_s] = {"type" => param_type}
57
+ required << name.to_s if type == :req || type == :keyreq
58
+ end
59
+
60
+ {
61
+ "name" => func.name.to_s,
62
+ "description" => "",
63
+ "parameters" => {
64
+ "type" => "object",
65
+ "properties" => parameters,
66
+ "required" => required
67
+ }
68
+ }
69
+ end
70
+ end
71
+ end
data/lib/swarm.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "dotenv"
2
+ Dotenv.load
3
+
4
+ module Swarm
5
+ require_relative "swarm/core"
6
+ require_relative "swarm/types"
7
+ require_relative "swarm/util"
8
+ require_relative "swarm/repl"
9
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: swarm-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Landon gray
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ruby-openai
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-struct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: colorize
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: dotenv
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ description: A Ruby implementation of the Swarm library for managing AI agent interactions
98
+ email: landon.gray@hey.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - Readme.md
104
+ - lib/swarm.rb
105
+ - lib/swarm/core.rb
106
+ - lib/swarm/repl.rb
107
+ - lib/swarm/types.rb
108
+ - lib/swarm/util.rb
109
+ homepage: https://rubygems.org/gems/swarm-rb
110
+ licenses:
111
+ - MIT
112
+ metadata: {}
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.3.7
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: 'Swarm-rb: A Ruby gem for AI agent interactions'
132
+ test_files: []