-
Notifications
You must be signed in to change notification settings - Fork 3.4k
/
kernel_plugin.py
544 lines (469 loc) · 23.3 KB
/
kernel_plugin.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# Copyright (c) Microsoft. All rights reserved.
import importlib
import inspect
import json
import logging
import os
from collections.abc import Generator, ItemsView
from functools import singledispatchmethod
from glob import glob
from types import MethodType
from typing import TYPE_CHECKING, Annotated, Any
import httpx
from pydantic import Field, StringConstraints
from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationConfig
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
OpenAIFunctionExecutionParameters,
)
from semantic_kernel.connectors.openai_plugin.openai_utils import OpenAIUtils
from semantic_kernel.connectors.openapi_plugin.openapi_manager import create_functions_from_openapi
from semantic_kernel.connectors.utils.document_loader import DocumentLoader
from semantic_kernel.exceptions import PluginInitializationError
from semantic_kernel.exceptions.function_exceptions import FunctionInitializationError
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt
from semantic_kernel.functions.types import KERNEL_FUNCTION_TYPE
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.validation import PLUGIN_NAME_REGEX
if TYPE_CHECKING:
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
OpenAPIFunctionExecutionParameters,
)
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
logger = logging.getLogger(__name__)
class KernelPlugin(KernelBaseModel):
"""Represents a Kernel Plugin with functions.
This class behaves mostly like a dictionary, with functions as values and their names as keys.
When you add a function, through `.set` or `__setitem__`, the function is copied, the metadata is deep-copied
and the name of the plugin is set in the metadata and added to the dict of functions.
This is done in the same way as a normal dict, so a existing key will be overwritten.
Attributes:
name (str): The name of the plugin. The name can be upper/lower
case letters and underscores.
description (str): The description of the plugin.
functions (Dict[str, KernelFunction]): The functions in the plugin,
indexed by their name.
Methods:
set: Set a function in the plugin.
__setitem__: Set a function in the plugin.
get: Get a function from the plugin.
__getitem__: Get a function from the plugin.
__contains__: Check if a function is in the plugin.
__iter__: Iterate over the functions in the plugin.
update: Update the plugin with the functions from another.
setdefault: Set a default value for a key.
get_functions_metadata: Get the metadata for the functions in the plugin.
Class methods:
from_object(plugin_name: str, plugin_instance: Any | dict[str, Any], description: str | None = None):
Create a plugin from a existing object, like a custom class with annotated functions.
from_directory(plugin_name: str, parent_directory: str, description: str | None = None):
Create a plugin from a directory, parsing:
.py files, .yaml files and directories with skprompt.txt and config.json files.
from_openapi(
plugin_name: str,
openapi_document_path: str,
execution_settings: OpenAPIFunctionExecutionParameters | None = None,
description: str | None = None):
Create a plugin from an OpenAPI document.
from_openai(
plugin_name: str,
plugin_url: str | None = None,
plugin_str: str | None = None,
execution_parameters: OpenAIFunctionExecutionParameters | None = None,
description: str | None = None):
Create a plugin from the Open AI manifest.
"""
name: Annotated[str, StringConstraints(pattern=PLUGIN_NAME_REGEX, min_length=1)]
description: str | None = None
functions: dict[str, KernelFunction] = Field(default_factory=dict)
def __init__(
self,
name: str,
description: str | None = None,
functions: (
KERNEL_FUNCTION_TYPE
| "KernelPlugin"
| list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]
| dict[str, KERNEL_FUNCTION_TYPE]
| None
) = None,
):
"""Create a KernelPlugin.
Args:
name: The name of the plugin. The name can be upper/lower
case letters and underscores.
description: The description of the plugin.
functions:
The functions in the plugin, will be rewritten to a dictionary of functions.
Raises:
ValueError: If the functions are not of the correct type.
PydanticError: If the name is not a valid plugin name.
"""
super().__init__(
name=name,
description=description,
functions=self._validate_functions(functions=functions, plugin_name=name),
)
# region Dict-like methods
def __setitem__(self, key: str, value: KERNEL_FUNCTION_TYPE) -> None:
"""Sets a function in the plugin.
This function uses plugin[function_name] = function syntax.
Args:
key (str): The name of the function.
value (KernelFunction): The function to set.
"""
self.functions[key] = KernelPlugin._parse_or_copy(value, self.name)
def set(self, key: str, value: KERNEL_FUNCTION_TYPE) -> None:
"""Set a function in the plugin.
This function uses plugin.set(function_name, function) syntax.
Args:
key (str): The name of the function.
value (KernelFunction): The function to set.
"""
self[key] = value
def __getitem__(self, key: str) -> KernelFunction:
"""Get a function from the plugin.
Using plugin[function_name] syntax.
"""
return self.functions[key]
def get(self, key: str, default: KernelFunction | None = None) -> KernelFunction | None:
"""Get a function from the plugin.
Args:
key (str): The name of the function.
default (KernelFunction, optional): The default function to return if the key is not found.
"""
return self.functions.get(key, default)
def update(self, *args: Any, **kwargs: KernelFunction) -> None:
"""Update the plugin with the functions from another.
Args:
*args: The functions to update the plugin with, can be a dict, list or KernelPlugin.
**kwargs: The kernel functions to update the plugin with.
"""
if len(args) > 1:
raise TypeError("update expected at most 1 arguments, got %d" % len(args))
if args:
if isinstance(args[0], KernelPlugin):
self.add(args[0].functions)
else:
self.add(args[0])
self.add(kwargs)
@singledispatchmethod
def add(self, functions: Any) -> None:
"""Add functions to the plugin."""
raise TypeError(f"Unknown type being added, type was {type(functions)}")
@add.register(list)
def add_list(self, functions: list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]) -> None:
"""Add a list of functions to the plugin."""
for function in functions:
if isinstance(function, KernelPlugin):
self.add(function.functions)
continue
function = KernelPlugin._parse_or_copy(function, self.name)
self[function.name] = function
@add.register(dict)
def add_dict(self, functions: dict[str, KERNEL_FUNCTION_TYPE]) -> None:
"""Add a dictionary of functions to the plugin."""
for name, function in functions.items():
self[name] = function
def setdefault(self, key: str, value: KernelFunction | None = None):
"""Set a default value for a key."""
if key not in self.functions:
if value is None:
raise ValueError("Value must be provided for new key.")
self[key] = value
return self[key]
def __iter__(self) -> Generator[KernelFunction, None, None]: # type: ignore
"""Iterate over the functions in the plugin."""
yield from self.functions.values()
def __contains__(self, key: str) -> bool:
"""Check if a function is in the plugin."""
return key in self.functions
# endregion
# region Properties
def get_functions_metadata(self) -> list["KernelFunctionMetadata"]:
"""Get the metadata for the functions in the plugin.
Returns:
A list of KernelFunctionMetadata instances.
"""
return [func.metadata for func in self]
# endregion
# region Class Methods
@classmethod
def from_object(
cls,
plugin_name: str,
plugin_instance: Any | dict[str, Any],
description: str | None = None,
) -> "KernelPlugin":
"""Creates a plugin that wraps the specified target object and imports it into the kernel's plugin collection.
Args:
plugin_name (str): The name of the plugin. Allows chars: upper, lower ASCII and underscores.
plugin_instance (Any | dict[str, Any]): The plugin instance. This can be a custom class or a
dictionary of classes that contains methods with the kernel_function decorator for one or
several methods. See `TextMemoryPlugin` as an example.
description (str | None): The description of the plugin.
Returns:
KernelPlugin: The imported plugin of type KernelPlugin.
"""
functions: list[KernelFunction] = []
candidates: list[tuple[str, MethodType]] | ItemsView[str, Any] = []
if isinstance(plugin_instance, dict):
candidates = plugin_instance.items()
else:
candidates = inspect.getmembers(plugin_instance, inspect.ismethod)
# Read every method from the plugin instance
functions = [
KernelFunctionFromMethod(method=candidate, plugin_name=plugin_name)
for _, candidate in candidates
if hasattr(candidate, "__kernel_function__")
]
return cls(name=plugin_name, description=description, functions=functions) # type: ignore
@classmethod
def from_directory(
cls,
plugin_name: str,
parent_directory: str,
description: str | None = None,
class_init_arguments: dict[str, dict[str, Any]] | None = None,
) -> "KernelPlugin":
"""Create a plugin from a specified directory.
This method does not recurse into subdirectories beyond one level deep from the specified plugin directory.
For YAML files, function names are extracted from the content of the YAML files themselves (the name property).
For directories, the function name is assumed to be the name of the directory. Each KernelFunction object is
initialized with data parsed from the associated files and added to a list of functions that are then assigned
to the created KernelPlugin object.
A .py file is parsed and a plugin created,
the functions within as then combined with any other functions found.
The python file needs to contain a class with one or more kernel_function decorated methods.
If this class has a `__init__` method, it will be called with the arguments provided in the
`class_init_arguments` dictionary, the key needs to be the same as the name of the class,
with the value being a dictionary of arguments to pass to the class (using kwargs).
Example:
Assuming a plugin directory structure as follows:
MyPlugins/
|--- pluginA.yaml
|--- pluginB.yaml
|--- native_function.py
|--- Directory1/
|--- skprompt.txt
|--- config.json
|--- Directory2/
|--- skprompt.txt
|--- config.json
Calling `KernelPlugin.from_directory("MyPlugins", "/path/to")` will create a KernelPlugin object named
"MyPlugins", containing KernelFunction objects for `pluginA.yaml`, `pluginB.yaml`,
`Directory1`, and `Directory2`, each initialized with their respective configurations.
And functions for anything within native_function.py.
Args:
plugin_name (str): The name of the plugin, this is the name of the directory within the parent directory
parent_directory (str): The parent directory path where the plugin directory resides
description (str | None): The description of the plugin
class_init_arguments (dict[str, dict[str, Any]] | None): The class initialization arguments
Returns:
KernelPlugin: The created plugin of type KernelPlugin.
Raises:
PluginInitializationError: If the plugin directory does not exist.
PluginInvalidNameError: If the plugin name is invalid.
"""
plugin_directory = os.path.abspath(os.path.join(parent_directory, plugin_name))
if not os.path.exists(plugin_directory):
raise PluginInitializationError(f"Plugin directory does not exist: {plugin_name}")
functions: list[KernelFunction] = []
for object in glob(os.path.join(plugin_directory, "*")):
logger.debug(f"Found object: {object}")
if os.path.isdir(object):
if os.path.basename(object).startswith("__"):
continue
try:
functions.append(KernelFunctionFromPrompt.from_directory(path=object))
except FunctionInitializationError:
logger.warning(f"Failed to create function from directory: {object}")
elif object.endswith(".yaml") or object.endswith(".yml"):
with open(object) as file:
try:
functions.append(KernelFunctionFromPrompt.from_yaml(file.read()))
except FunctionInitializationError:
logger.warning(f"Failed to create function from YAML file: {object}")
elif object.endswith(".py"):
try:
functions.extend(
cls.from_python_file(
plugin_name=plugin_name,
py_file=object,
description=description,
class_init_arguments=class_init_arguments,
)
)
except PluginInitializationError:
logger.warning(f"Failed to create function from Python file: {object}")
else:
logger.warning(f"Unknown file found: {object}")
if not functions:
raise PluginInitializationError(f"No functions found in folder: {parent_directory}/{plugin_name}")
return cls(name=plugin_name, description=description, functions=functions) # type: ignore
@classmethod
def from_openapi(
cls,
plugin_name: str,
openapi_document_path: str,
execution_settings: "OpenAPIFunctionExecutionParameters | None" = None,
description: str | None = None,
) -> "KernelPlugin":
"""Create a plugin from an OpenAPI document.
Args:
plugin_name (str): The name of the plugin
openapi_document_path (str): The path to the OpenAPI document
execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters
description (str | None): The description of the plugin
Returns:
KernelPlugin: The created plugin
Raises:
PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided
"""
if not openapi_document_path:
raise PluginInitializationError("OpenAPI document path is required.")
return cls( # type: ignore
name=plugin_name,
description=description,
functions=create_functions_from_openapi( # type: ignore
plugin_name=plugin_name,
openapi_document_path=openapi_document_path,
execution_settings=execution_settings,
),
)
@classmethod
async def from_openai(
cls,
plugin_name: str,
plugin_url: str | None = None,
plugin_str: str | None = None,
execution_parameters: "OpenAIFunctionExecutionParameters | None" = None,
description: str | None = None,
) -> "KernelPlugin":
"""Create a plugin from the Open AI manifest.
Args:
plugin_name (str): The name of the plugin
plugin_url (str | None): The URL of the plugin
plugin_str (str | None): The JSON string of the plugin
execution_parameters (OpenAIFunctionExecutionParameters | None): The execution parameters
description (str | None): The description of the plugin
Returns:
KernelPlugin: The created plugin
Raises:
PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided
"""
if execution_parameters is None:
execution_parameters = OpenAIFunctionExecutionParameters()
if plugin_str is not None:
# Load plugin from the provided JSON string/YAML string
openai_manifest = plugin_str
elif plugin_url is not None:
# Load plugin from the URL
http_client = execution_parameters.http_client if execution_parameters.http_client else httpx.AsyncClient()
openai_manifest = await DocumentLoader.from_uri(
url=plugin_url, http_client=http_client, auth_callback=None, user_agent=execution_parameters.user_agent
)
else:
raise PluginInitializationError("Either plugin_url or plugin_json must be provided.")
try:
plugin_json = json.loads(openai_manifest)
except json.JSONDecodeError as ex:
raise PluginInitializationError("Parsing of Open AI manifest for auth config failed.") from ex
openai_auth_config = OpenAIAuthenticationConfig(**plugin_json["auth"])
openapi_spec_url = OpenAIUtils.parse_openai_manifest_for_openapi_spec_url(plugin_json=plugin_json)
# Modify the auth callback in execution parameters if it's provided
if execution_parameters and execution_parameters.auth_callback:
initial_auth_callback = execution_parameters.auth_callback
async def custom_auth_callback(**kwargs: Any):
return await initial_auth_callback(plugin_name, openai_auth_config, **kwargs) # pragma: no cover
execution_parameters.auth_callback = custom_auth_callback
return cls(
name=plugin_name,
description=description,
functions=create_functions_from_openapi( # type: ignore
plugin_name=plugin_name,
openapi_document_path=openapi_spec_url,
execution_settings=execution_parameters,
),
)
@classmethod
def from_python_file(
cls,
plugin_name: str,
py_file: str,
description: str | None = None,
class_init_arguments: dict[str, dict[str, Any]] | None = None,
) -> "KernelPlugin":
"""Create a plugin from a Python file."""
module_name = os.path.basename(py_file).replace(".py", "")
spec = importlib.util.spec_from_file_location(module_name, py_file)
if not spec:
raise PluginInitializationError(f"Could not load spec from file {py_file}")
module = importlib.util.module_from_spec(spec)
if not module or not spec.loader:
raise PluginInitializationError(f"No module found in file {py_file}")
spec.loader.exec_module(module)
for name, cls_instance in inspect.getmembers(module, inspect.isclass):
if cls_instance.__module__ != module_name:
continue
instance = getattr(module, name)(**class_init_arguments.get(name, {}) if class_init_arguments else {})
return cls.from_object(plugin_name=plugin_name, description=description, plugin_instance=instance)
raise PluginInitializationError(f"No class found in file: {py_file}")
# endregion
# region Internal Static Methods
@staticmethod
def _validate_functions(
functions: (
KERNEL_FUNCTION_TYPE
| list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]
| dict[str, KERNEL_FUNCTION_TYPE]
| "KernelPlugin"
| None
),
plugin_name: str,
) -> dict[str, "KernelFunction"]:
"""Validates the functions and returns a dictionary of functions."""
if not functions or not plugin_name:
# if the plugin_name is not present, the validation will fail, so no point in parsing.
return {}
if isinstance(functions, dict):
return {
name: KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
for name, function in functions.items()
}
if isinstance(functions, KernelPlugin):
return {
name: function.function_copy(plugin_name=plugin_name) for name, function in functions.functions.items()
}
if isinstance(functions, KernelFunction):
return {functions.name: KernelPlugin._parse_or_copy(function=functions, plugin_name=plugin_name)}
if callable(functions):
function = KernelPlugin._parse_or_copy(function=functions, plugin_name=plugin_name)
return {function.name: function}
if isinstance(functions, list):
functions_dict: dict[str, KernelFunction] = {}
for function in functions: # type: ignore
if isinstance(function, KernelFunction) or callable(function):
function = KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
functions_dict[function.name] = function
elif isinstance(function, KernelPlugin): # type: ignore
functions_dict.update(
{
name: KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
for name, function in function.functions.items()
}
)
else:
raise ValueError(f"Invalid type for functions in list: {function} (type: {type(function)})")
return functions_dict
raise ValueError(f"Invalid type for supplied functions: {functions} (type: {type(functions)})")
@staticmethod
def _parse_or_copy(function: KERNEL_FUNCTION_TYPE, plugin_name: str) -> "KernelFunction":
"""Handle the function and return a KernelFunction instance."""
if isinstance(function, KernelFunction):
return function.function_copy(plugin_name=plugin_name)
if callable(function):
return KernelFunctionFromMethod(method=function, plugin_name=plugin_name)
raise ValueError(f"Invalid type for function: {function} (type: {type(function)})")
# endregion