Files
semantic-kernel/python/semantic_kernel/functions/kernel_plugin_collection.py
T
Evan Mattson 0636b17061 Python: Introduce the function calling stepwise planner (#5350)
### Motivation and Context

To further align with the SK dotnet SDK and its functionality, the
Python SDK needs a function calling stepwise planner. Now that we have
auto tool calling available, it makes sense to introduce this planner.

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

### Description

This PR:
- Introduces the function calling stepwise planner, its planner options,
and planner result. Closes #4642
- Introduces a kernel example `function_calling_stepwise_planner.py` to
exercise the planner and show how it works.
- Adds unit tests and an integration test.

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [ ] The code builds clean without any errors or warnings
- [ ] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [ ] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
2024-03-12 15:52:12 +00:00

232 lines
8.2 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, TypeVar, Union
from semantic_kernel.exceptions import (
PluginInitializationError,
PluginInvalidNameError,
)
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel_pydantic import KernelBaseModel
# To support Python 3.8, need to use TypeVar since Iterable is not scriptable
KernelPluginType = TypeVar("KernelPluginType", bound=KernelPlugin)
if TYPE_CHECKING:
from semantic_kernel.functions.kernel_function import KernelFunction
logger = logging.getLogger(__name__)
class KernelPluginCollection(KernelBaseModel):
"""
The Kernel Plugin Collection class. This class is used to store a collection of plugins.
Attributes:
plugins (Dict[str, KernelPlugin]): The plugins in the collection, indexed by their name.
"""
plugins: Dict[str, "KernelPlugin"]
def __init__(self, plugins: Union[None, "KernelPluginCollection", Iterable[KernelPluginType]] = None):
"""
Initialize a new instance of the KernelPluginCollection class
Args:
plugins (Union[None, KernelPluginCollection, Iterable[KernelPlugin]]): The plugins to add
to the collection. If None, an empty collection is created. If a KernelPluginCollection,
the plugins are copied from the other collection. If an iterable of KernelPlugin,
the plugins are added to the collection.
Raises:
ValueError: If the plugins is not None, a KernelPluginCollection, or an iterable of KernelPlugin.
"""
if plugins is None:
plugins = {}
elif isinstance(plugins, KernelPluginCollection):
# Extract plugins from another KernelPluginCollection instance
plugins = {plugin.name: plugin for plugin in plugins.plugins.values()}
elif isinstance(plugins, Iterable):
# Process an iterable of plugins
plugins = self._process_plugins_iterable(plugins)
else:
raise PluginInitializationError("Invalid type for plugins")
super().__init__(plugins=plugins)
@staticmethod
def _process_plugins_iterable(plugins_input: Iterable[KernelPlugin]) -> Dict[str, "KernelPlugin"]:
plugins_dict = {}
for plugin in plugins_input:
if plugin is None:
raise PluginInvalidNameError("Plugin and plugin.name must not be None")
if plugin.name in plugins_dict:
raise PluginInvalidNameError(f"Duplicate plugin name detected: {plugin.name}")
plugins_dict[plugin.name] = plugin
return plugins_dict
def add(self, plugin: "KernelPlugin") -> None:
"""
Add a single plugin to the collection
Args:
plugin (KernelPlugin): The plugin to add to the collection.
Raises:
ValueError: If the plugin or plugin.name is None.
"""
if plugin.name in self.plugins.keys():
logger.warning(f'Overwriting plugin "{plugin.name}" in collection')
self.plugins[plugin.name] = plugin
def add_plugin_from_functions(self, plugin_name: str, functions: List["KernelFunction"]) -> None:
"""
Add a function to a new plugin in the collection
Args:
plugin_name (str): The name of the plugin to create.
functions (List[KernelFunction]): The functions to add to the plugin.
Raises:
ValueError: If the function or plugin_name is None or invalid.
"""
if not functions or not plugin_name:
raise PluginInitializationError("Functions or plugin_name must not be None or empty")
plugin = KernelPlugin.from_functions(plugin_name=plugin_name, functions=functions)
self.plugins[plugin_name] = plugin
def add_functions_to_plugin(self, functions: List["KernelFunction"], plugin_name: str) -> None:
"""
Add functions to a plugin in the collection
Args:
functions (List[KernelFunction]): The function to add to the plugin.
plugin_name (str): The name of the plugin to add the function to.
Raises:
ValueError: If the functions or plugin_name is None or invalid.
ValueError: if the function already exists in the plugin.
"""
if not functions or not plugin_name:
raise PluginInitializationError("Functions and plugin_name must not be None or empty")
if plugin_name not in self.plugins:
self.plugins[plugin_name] = KernelPlugin(name=plugin_name, functions=functions)
return
for func in functions:
if func.name in self.plugins[plugin_name].functions:
logger.warning(f'Overwriting function "{func.name}" in collection')
self.plugins[plugin_name].functions[func.name] = func
def add_list_of_plugins(self, plugins: List["KernelPlugin"]) -> None:
"""
Add a list of plugins to the collection
Args:
plugins (List[KernelPlugin]): The plugins to add to the collection.
Raises:
ValueError: If the plugins list is None.
"""
if plugins is None:
raise PluginInitializationError("Plugins must not be None")
for plugin in plugins:
self.add(plugin)
def remove(self, plugin: "KernelPlugin") -> bool:
"""
Remove a plugin from the collection
Args:
plugin (KernelPlugin): The plugin to remove from the collection.
Returns:
True if the plugin was removed, False otherwise.
"""
if plugin is None or plugin.name is None:
return False
return self.plugins.pop(plugin.name, None) is not None
def remove_by_name(self, plugin_name: str) -> bool:
"""
Remove a plugin from the collection by name
Args:
plugin_name (str): The name of the plugin to remove from the collection.
Returns:
True if the plugin was removed, False otherwise.
"""
if plugin_name is None:
return False
return self.plugins.pop(plugin_name, None) is not None
def __getitem__(self, name):
"""Define the [] operator for the collection
Args:
name (str): The name of the plugin to retrieve.
Returns:
The plugin if it exists, None otherwise.
Raises:
KeyError: If the plugin does not exist.
"""
if name not in self.plugins:
raise KeyError(f"Plugin {name} not found.")
return self.plugins[name]
def clear(self):
"""Clear the collection of all plugins"""
self.plugins.clear()
def get_list_of_function_metadata(
self, include_prompt: bool = True, include_native: bool = True
) -> List[KernelFunctionMetadata]:
"""
Get a list of the function metadata in the plugin collection
Args:
include_prompt (bool): Whether to include semantic functions in the list.
include_native (bool): Whether to include native functions in the list.
Returns:
A list of KernelFunctionMetadata objects in the collection.
"""
if not self.plugins:
return []
return [
func.metadata
for plugin in self.plugins.values()
for func in plugin.functions.values()
if (include_prompt and func.is_prompt) or (include_native and not func.is_prompt)
]
def __iter__(self) -> Any:
"""Define an iterator for the collection"""
return iter(self.plugins.values())
def __len__(self) -> int:
"""Define the length of the collection"""
return len(self.plugins)
def __contains__(self, plugin_name: str) -> bool:
"""
Check if the collection contains a plugin
Args:
plugin_name (str): The name of the plugin to check for.
Returns:
True if the collection contains the plugin, False otherwise.
"""
if not plugin_name:
return False
return self.plugins.get(plugin_name) is not None