Files
semantic-kernel/python/semantic_kernel/planning/plan.py
T
Eduard van Valkenburg b91529a62e Python: Implement AI request settings (#4097)
### Motivation and Context

<!-- 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.
-->
This PR implements the new approach to AI Request settings as described
here:
https://github.com/microsoft/semantic-kernel/blob/main/docs/decisions/0008-support-generic-llm-request-settings.md
and completes #3312

### Description

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->
Does this:
- Creates AI Request Settings with service_id (referring to the
registered AI services in a kernel) and extension_data, a dict that can
hold any number of settings
- has several methods:
- `from_ai_request_settings` -> constructor to create a new
request_settings object based on another, supports creating specific
ai_request_settings from generic ones.
- `update_from_ai_request_settings` -> updating the fields of a request
setting from another ones.
- `prepare_settings_dict` -> method that is used when actually creating
the dict that get's passed to the handler of the AI service.
- Created subclasses like OpenAIChatRequestSettings which has all the
required fields of a OpenAI Chat completion, same for OpenAIText,
AzureChat, GooglePalm, HuggingFace, etc.
- added `get_request_settings_class` to ai_services to return the class
used for the settings for that service.

The goal is that when running, this settings object get's updated and a
single operations (called `prepare_settings_dict`) is used to return a
dict that can be passed to the api call directly, thereby reducing the
need for custom logic everywhere, this will also negate the need for
things like `complete_chat_with_functions_async` and others. So also
removed complete_chat_..._async methods from openai and azure openai.

It has also changed the prompt_template_config with the generic type for
the AIRequestSettings or subclasses, and some other changes.

### Contribution Checklist

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

- [x] The code builds clean without any errors or warnings
- [x] 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
- [x] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
2024-01-03 19:57:27 +00:00

463 lines
17 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import re
import threading
from typing import Any, Callable, ClassVar, List, Optional, Union
from pydantic import PrivateAttr
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai import AIRequestSettings
from semantic_kernel.connectors.ai.text_completion_client_base import (
TextCompletionClientBase,
)
from semantic_kernel.kernel_exception import KernelException
from semantic_kernel.memory.null_memory import NullMemory
from semantic_kernel.memory.semantic_text_memory_base import SemanticTextMemoryBase
from semantic_kernel.orchestration.context_variables import ContextVariables
from semantic_kernel.orchestration.sk_context import SKContext
from semantic_kernel.orchestration.sk_function_base import SKFunctionBase
from semantic_kernel.skill_definition.function_view import FunctionView
from semantic_kernel.skill_definition.read_only_skill_collection import (
ReadOnlySkillCollection,
)
from semantic_kernel.skill_definition.read_only_skill_collection_base import (
ReadOnlySkillCollectionBase,
)
logger: logging.Logger = logging.getLogger(__name__)
class Plan(SKFunctionBase):
_state: ContextVariables = PrivateAttr()
_steps: List["Plan"] = PrivateAttr()
_function: SKFunctionBase = PrivateAttr()
_parameters: ContextVariables = PrivateAttr()
_outputs: List[str] = PrivateAttr()
_has_next_step: bool = PrivateAttr()
_next_step_index: int = PrivateAttr()
_name: str = PrivateAttr()
_skill_name: str = PrivateAttr()
_description: str = PrivateAttr()
_is_semantic: bool = PrivateAttr()
_request_settings: AIRequestSettings = PrivateAttr()
DEFAULT_RESULT_KEY: ClassVar[str] = "PLAN.RESULT"
@property
def name(self) -> str:
return self._name
@property
def state(self) -> ContextVariables:
return self._state
@property
def skill_name(self) -> str:
return self._skill_name
@property
def description(self) -> str:
return self._description
@property
def function(self) -> Callable[..., Any]:
return self._function
@property
def parameters(self) -> ContextVariables:
return self._parameters
@property
def is_semantic(self) -> bool:
return self._is_semantic
@property
def is_native(self) -> bool:
if self._is_semantic is None:
return None
else:
return not self._is_semantic
@property
def request_settings(self) -> AIRequestSettings:
return self._request_settings
@property
def has_next_step(self) -> bool:
return self._next_step_index < len(self._steps)
@property
def next_step_index(self) -> int:
return self._next_step_index
def __init__(
self,
name: Optional[str] = None,
skill_name: Optional[str] = None,
description: Optional[str] = None,
next_step_index: Optional[int] = None,
state: Optional[ContextVariables] = None,
parameters: Optional[ContextVariables] = None,
outputs: Optional[List[str]] = None,
steps: Optional[List["Plan"]] = None,
function: Optional[SKFunctionBase] = None,
) -> None:
super().__init__()
self._name = "" if name is None else name
self._skill_name = "" if skill_name is None else skill_name
self._description = "" if description is None else description
self._next_step_index = 0 if next_step_index is None else next_step_index
self._state = ContextVariables() if state is None else state
self._parameters = ContextVariables() if parameters is None else parameters
self._outputs = [] if outputs is None else outputs
self._steps = [] if steps is None else steps
self._has_next_step = len(self._steps) > 0
self._is_semantic = None
self._function = None if function is None else function
self._request_settings = None
if function is not None:
self.set_function(function)
@classmethod
def from_goal(cls, goal: str) -> "Plan":
return cls(description=goal, skill_name=cls.__name__)
@classmethod
def from_function(cls, function: SKFunctionBase) -> "Plan":
plan = cls()
plan.set_function(function)
return plan
async def invoke_async(
self,
input: Optional[str] = None,
context: Optional[SKContext] = None,
settings: Optional[AIRequestSettings] = None,
memory: Optional[SemanticTextMemoryBase] = None,
**kwargs,
# TODO: cancellation_token: CancellationToken,
) -> SKContext:
if kwargs.get("logger"):
logger.warning(
"The `logger` parameter is deprecated. Please use the `logging` module instead."
)
if input is not None and input != "":
self._state.update(input)
if context is None:
context = SKContext(
variables=self._state,
skill_collection=ReadOnlySkillCollection(),
memory=memory or NullMemory(),
)
if self._function is not None:
result = await self._function.invoke_async(
context=context, settings=settings
)
if result.error_occurred:
logger.error(
"Something went wrong in plan step {0}.{1}:'{2}'".format(
self._skill_name, self._name, result.last_error_description
)
)
return result
context.variables.update(result.result)
else:
# loop through steps until completion
while self.has_next_step:
function_context = context
self.add_variables_to_context(self._state, function_context)
await self.invoke_next_step(function_context)
self.update_context_with_outputs(context)
return context
def invoke(
self,
input: Optional[str] = None,
context: Optional[SKContext] = None,
settings: Optional[AIRequestSettings] = None,
memory: Optional[SemanticTextMemoryBase] = None,
**kwargs,
) -> SKContext:
if kwargs.get("logger"):
logger.warning(
"The `logger` parameter is deprecated. Please use the `logging` module instead."
)
if input is not None and input != "":
self._state.update(input)
if context is None:
context = SKContext(
variables=self._state,
skill_collection=ReadOnlySkillCollection(),
memory=memory or NullMemory(),
)
if self._function is not None:
result = self._function.invoke(context=context, settings=settings)
if result.error_occurred:
logger.error(
result.last_exception,
"Something went wrong in plan step {0}.{1}:'{2}'".format(
self.skill_name, self.name, context.last_error_description
),
)
return result
context.variables.update(result.result)
else:
# loop through steps until completion
while self.has_next_step:
# Check if there is an event loop
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
function_context = context
self.add_variables_to_context(self._state, function_context)
# Handle "asyncio.run() cannot be called from a running event loop"
if loop and loop.is_running():
self._runThread(self.invoke_next_step(function_context))
else:
asyncio.run(self.invoke_next_step(function_context))
self.update_context_with_outputs(context)
return context
def set_ai_configuration(
self,
settings: AIRequestSettings,
) -> SKFunctionBase:
if self._function is not None:
self._function.set_ai_configuration(settings)
def set_ai_service(
self, service: Callable[[], TextCompletionClientBase]
) -> SKFunctionBase:
if self._function is not None:
self._function.set_ai_service(service)
def set_default_skill_collection(
self,
skills: ReadOnlySkillCollectionBase,
) -> SKFunctionBase:
if self._function is not None:
self._function.set_default_skill_collection(skills)
def describe(self) -> FunctionView:
return self._function.describe()
def set_available_functions(self, plan: "Plan", context: SKContext) -> "Plan":
if len(plan.steps) == 0:
if context.skills is None:
raise KernelException(
KernelException.ErrorCodes.SkillCollectionNotSet,
"Skill collection not found in the context",
)
try:
skillFunction = context.skills.get_function(plan.skill_name, plan.name)
plan.set_function(skillFunction)
except Exception:
pass
else:
for step in plan.steps:
step = self.set_available_functions(step, context)
return plan
def add_steps(self, steps: Union[List["Plan"], List[SKFunctionBase]]) -> None:
for step in steps:
if type(step) is Plan:
self._steps.append(step)
else:
new_step = Plan(
name=step.name,
skill_name=step.skill_name,
description=step.description,
next_step_index=0,
state=ContextVariables(),
parameters=ContextVariables(),
outputs=[],
steps=[],
)
new_step.set_function(step)
self._steps.append(new_step)
def set_function(self, function: SKFunctionBase) -> None:
self._function = function
self._name = function.name
self._skill_name = function.skill_name
self._description = function.description
self._is_semantic = function.is_semantic
self._request_settings = function.request_settings
async def run_next_step_async(
self,
kernel: Kernel,
variables: ContextVariables,
) -> "Plan":
context = kernel.create_new_context(variables)
return await self.invoke_next_step(context)
async def invoke_next_step(self, context: SKContext) -> "Plan":
if self.has_next_step:
step = self._steps[self._next_step_index]
# merge the state with the current context variables for step execution
variables = self.get_next_step_variables(context.variables, step)
# Invoke the step
func_context = SKContext(
variables=variables,
memory=context.memory,
skill_collection=context.skills,
)
result = await step.invoke_async(context=func_context)
result_value = result.result
if result.error_occurred:
raise KernelException(
KernelException.ErrorCodes.FunctionInvokeError,
"Error occurred while running plan step: "
+ result.last_error_description,
result.last_exception,
)
# Update state with result
self.state.update(result_value)
# Update plan result in state with matching outputs (if any)
if set(self._outputs).intersection(set(step._outputs)):
current_plan_result = ""
if Plan.DEFAULT_RESULT_KEY in self._state.variables:
current_plan_result = self._state[Plan.DEFAULT_RESULT_KEY]
self._state.set(
Plan.DEFAULT_RESULT_KEY, current_plan_result.strip() + result_value
)
# Update state with outputs (if any)
for output in step._outputs:
if output in result.variables.variables:
self._state.set(output, result.variables[output])
else:
self._state.set(output, result_value)
# Increment the step
self._next_step_index += 1
return self
def add_variables_to_context(
self, variables: ContextVariables, context: SKContext
) -> None:
for key in variables.variables:
if key not in context.variables:
context.variables.set(key, variables[key])
def update_context_with_outputs(self, context: SKContext) -> None:
result_string = ""
if Plan.DEFAULT_RESULT_KEY in self._state.variables:
result_string = self._state[Plan.DEFAULT_RESULT_KEY]
else:
result_string = str(self._state)
context.variables.update(result_string)
for item in self._steps[self._next_step_index - 1]._outputs:
if item in self._state:
context.variables.set(item, self._state[item])
else:
context.variables.set(item, result_string)
return context
def get_next_step_variables(
self, variables: ContextVariables, step: "Plan"
) -> ContextVariables:
# Priority for Input
# - Parameters (expand from variables if needed)
# - SKContext.Variables
# - Plan.State
# - Empty if sending to another plan
# - Plan.Description
input_string = ""
step_input_value = step._parameters.get("input")
variables_input_value = variables.get("input")
state_input_value = self._state.get("input")
if step_input_value and step_input_value != "":
input_string = self.expand_from_variables(variables, step_input_value)
elif variables_input_value and variables_input_value != "":
input_string = variables_input_value
elif state_input_value and state_input_value != "":
input_string = state_input_value
elif len(step._steps) > 0:
input_string = ""
elif self._description is not None and self._description != "":
input_string = self._description
step_variables = ContextVariables(input_string)
# Priority for remaining stepVariables is:
# - Function Parameters (pull from variables or state by a key value)
# - Step Parameters (pull from variables or state by a key value)
# - All other variables. These are carried over in case the function wants access to the ambient content.
function_params = step.describe()
for param in function_params.parameters:
if param.name.lower() == variables._main_key.lower():
continue
if param.name in variables:
step_variables.set(param.name, variables[param.name])
elif param.name in self._state and (
self._state[param.name] is not None and self._state[param.name] != ""
):
step_variables.set(param.name, self._state[param.name])
for param_var in step.parameters.variables:
if param_var in step_variables:
continue
expanded_value = self.expand_from_variables(variables, param_var)
if expanded_value.lower() == param_var.lower():
step_variables.set(param_var, step.parameters.variables[param_var])
elif param_var in variables:
step_variables.set(param_var, variables[param_var])
elif param_var in self._state:
step_variables.set(param_var, self._state[param_var])
else:
step_variables.set(param_var, expanded_value)
for item in variables.variables:
if item not in step_variables:
step_variables.set(item, variables[item])
return step_variables
def expand_from_variables(
self, variables: ContextVariables, input_string: str
) -> str:
result = input_string
variables_regex = r"\$(?P<var>\w+)"
matches = [m for m in re.finditer(variables_regex, input_string)]
ordered_matches = sorted(
matches, key=lambda m: len(m.group("var")), reverse=True
)
for match in ordered_matches:
var_name = match.group("var")
if var_name in variables:
result = result.replace(f"${var_name}", variables[var_name])
return result
def _runThread(self, code: Callable):
result = []
thread = threading.Thread(target=self._runCode, args=(code, result))
thread.start()
thread.join()
return result[0]