Skip to content

Commit

Permalink
Python: Add sequential planner to python (#1916)
Browse files Browse the repository at this point in the history
### 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.
-->
fixes #1915 

### Description
<!-- 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 SK Contribution Guidelines
(https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
- [ ] The code follows the .NET coding conventions
(https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
verified with `dotnet format`
- [ ] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄

---------

Co-authored-by: Abby Harrison <abby.harrison@microsoft.com>
Co-authored-by: Abby Harrison <54643756+awharrison-28@users.noreply.github.com>
Co-authored-by: Shawn Callegari <36091529+shawncal@users.noreply.github.com>
  • Loading branch information
4 people authored Jul 28, 2023
1 parent e687301 commit 484b39d
Show file tree
Hide file tree
Showing 23 changed files with 1,829 additions and 25 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@
"request": "attach"
},
{
"cwd":"${workspaceFolder}/python",
"cwd": "${workspaceFolder}/python",
"name": "Python: Test Module",
"type": "python",
"request": "launch",
"module": "pytest",
"args": ["${file}"],
"args": ["${file}"]
}
]
}
47 changes: 47 additions & 0 deletions python/samples/kernel-syntax-examples/sequential_planner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.

import semantic_kernel as sk
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.core_skills import FileIOSkill, MathSkill, TextSkill, TimeSkill
from semantic_kernel.planning import SequentialPlanner


async def main():
kernel = sk.Kernel()
api_key, org_id = sk.openai_settings_from_dot_env()

kernel.add_chat_service(
"gpt-3.5", OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id)
)
kernel.import_skill(MathSkill(), "math")
kernel.import_skill(FileIOSkill(), "fileIO")
kernel.import_skill(TimeSkill(), "time")
kernel.import_skill(TextSkill(), "text")

# create an instance of sequential planner.
planner = SequentialPlanner(kernel)

# the ask for which the sequential planner is going to find a relevant function.
ask = "What day of the week is today, all uppercase?"

# ask the sequential planner to identify a suitable function from the list of functions available.
plan = await planner.create_plan_async(goal=ask)

# ask the sequential planner to execute the identified function.
result = await plan.invoke_async()

for step in plan._steps:
print(step.description, ":", step._state.__dict__)

print("Expected Answer:")
print(result)
"""
Output:
1100
"""


if __name__ == "__main__":
import asyncio

asyncio.run(main())
1 change: 1 addition & 0 deletions python/semantic_kernel/core_skills/math_skill.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.


from semantic_kernel.orchestration.sk_context import SKContext
from semantic_kernel.skill_definition import sk_function, sk_function_context_parameter

Expand Down
1 change: 1 addition & 0 deletions python/semantic_kernel/memory/semantic_text_memory_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def get_async(
self,
collection: str,
query: str,
# TODO: with_embedding: bool,
) -> Optional[MemoryQueryResult]:
"""Get information from the memory (calls the memory store's get method).
Expand Down
2 changes: 2 additions & 0 deletions python/semantic_kernel/planning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from semantic_kernel.planning.basic_planner import BasicPlanner
from semantic_kernel.planning.plan import Plan
from semantic_kernel.planning.sequential_planner import SequentialPlanner

__all__ = [
"SequentialPlanner",
"BasicPlanner",
"Plan",
]
63 changes: 40 additions & 23 deletions python/semantic_kernel/planning/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
import threading
from logging import Logger
from typing import Any, Callable, List, Optional
from typing import Any, Callable, List, Optional, Union

from semantic_kernel import Kernel
from semantic_kernel.connectors.ai import CompleteRequestSettings
Expand All @@ -20,6 +20,7 @@
from semantic_kernel.skill_definition.read_only_skill_collection_base import (
ReadOnlySkillCollectionBase,
)
from semantic_kernel.utils.null_logger import NullLogger


class Plan(SKFunctionBase):
Expand Down Expand Up @@ -112,6 +113,16 @@ def __init__(
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,
Expand All @@ -121,15 +132,15 @@ async def invoke_async(
logger: Optional[Logger] = None,
# TODO: cancellation_token: CancellationToken,
) -> SKContext:
if input is not None:
if input is not None and input != "":
self._state.update(input)

if context is None:
context = SKContext(
variables=self._state,
skill_collection=None,
memory=memory,
logger=logger,
logger=logger if logger is not None else NullLogger(),
)

if self._function is not None:
Expand All @@ -138,8 +149,8 @@ async def invoke_async(
)
if result.error_occurred:
result.log.error(
msg="Something went wrong in plan step {0}.{1}:'{2}'".format(
self._skill_name, self._name, context.last_error_description
"Something went wrong in plan step {0}.{1}:'{2}'".format(
self._skill_name, self._name, result.last_error_description
)
)
return result
Expand All @@ -162,7 +173,7 @@ def invoke(
memory: Optional[SemanticTextMemoryBase] = None,
logger: Optional[Logger] = None,
) -> SKContext:
if input is not None:
if input is not None and input != "":
self._state.update(input)

if context is None:
Expand Down Expand Up @@ -244,7 +255,7 @@ def set_available_functions(self, plan: "Plan", context: SKContext) -> "Plan":

return plan

def add_steps(self, steps: Optional[List[SKFunctionBase]]) -> None:
def add_steps(self, steps: Union[List["Plan"], List[SKFunctionBase]]) -> None:
for step in steps:
if type(step) is Plan:
self._steps.append(step)
Expand Down Expand Up @@ -344,7 +355,7 @@ def update_context_with_outputs(self, context: SKContext) -> None:
context.variables.update(result_string)

for item in self._steps[self._next_step_index - 1]._outputs:
if item in self._state:
if self._state.contains_key(item):
context.variables.set(item, self._state[item])
else:
context.variables.set(item, result_string)
Expand All @@ -361,33 +372,35 @@ def get_next_step_variables(
# - Empty if sending to another plan
# - Plan.Description
input_string = ""
if step._parameters["input"] is not None:
input_string = self.expand_from_variables(
variables, step._parameters["input"]
)
elif variables["input"] is not None:
input_string = variables["input"]
elif self._state["input"] is not None:
input_string = self._state["input"]
step_input_exists, step_input_value = step._parameters.get("input")
variables_input_exists, variables_input_value = variables.get("input")
state_input_exists, state_input_value = self._state.get("input")
if step_input_exists and step_input_value != "":
input_string = self.expand_from_variables(variables, step_input_value)
elif variables_input_exists and variables_input_value != "":
input_string = variables_input_value
elif state_input_exists and state_input_value != "":
input_string = state_input_value
elif len(step._steps) > 0:
input_string = ""
elif self._description is not None:
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 == "input":
if param.name.lower() == variables._main_key.lower():
continue
if step_variables.contains_key(param.name):

if variables.contains_key(param.name):
step_variables.set(param.name, variables[param.name])
elif (
self._state.contains_key(param.name)
and self._state[param.name] is not None
elif self._state.contains_key(param.name) and (
self._state[param.name] is not None and self._state[param.name] != ""
):
step_variables.set(param.name, self._state[param.name])

Expand All @@ -405,14 +418,18 @@ def get_next_step_variables(
else:
step_variables.set(param_var, expanded_value)

for item in variables._variables:
if not step_variables.contains_key(item):
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 = re.findall(variables_regex, input_string)
matches = [m for m in re.finditer(variables_regex, input_string)]
ordered_matches = sorted(
matches, key=lambda m: len(m.group("var")), reverse=True
)
Expand Down
46 changes: 46 additions & 0 deletions python/semantic_kernel/planning/planning_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.

from enum import Enum
from typing import Optional


class PlanningException(Exception):
class ErrorCodes(Enum):
# Unknown error.
UnknownError = -1
# Invalid goal.
InvalidGoal = 0
# Invalid plan.
InvalidPlan = 1
# Invalid configuration.
InvalidConfiguration = 2
# Create plan error.
CreatePlanError = 3

# The error code.
_error_code: ErrorCodes

def __init__(
self,
error_code: ErrorCodes,
message: str,
inner_exception: Optional[Exception] = None,
) -> None:
"""Initializes a new instance of the PlanningError class.
Arguments:
error_code {ErrorCodes} -- The error code.
message {str} -- The error message.
inner_exception {Exception} -- The inner exception.
"""
super().__init__(error_code, message, inner_exception)
self._error_code = error_code

@property
def error_code(self) -> ErrorCodes:
"""Gets the error code.
Returns:
ErrorCodes -- The error code.
"""
return self._error_code
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"schema": 1,
"description": "Given a request or command or goal generate a step by step plan to fulfill the request using functions. This ability is also known as decision making and function flow",
"type": "completion",
"completion": {
"max_tokens": 1024,
"temperature": 0,
"top_p": 0,
"presence_penalty": 0,
"frequency_penalty": 0,
"stop_sequences": ["<!-- END -->"]
},
"input": {
"parameters": [
{
"name": "input",
"description": "The question to answer",
"defaultValue": ""
},
{
"name": "available_functions",
"description": "The list of the agent's available_functions",
"defaultValue": ""
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
Create an XML plan step by step, to satisfy the goal given, with the available functions.

[AVAILABLE FUNCTIONS]

{{$available_functions}}

[END AVAILABLE FUNCTIONS]

To create a plan, follow these steps:
0. The plan should be as short as possible.
1. From a <goal> create a <plan> as a series of <functions>.
2. A plan has 'INPUT' available in context variables by default.
3. Before using any function in a plan, check that it is present in the [AVAILABLE FUNCTIONS] list. If it is not, do not use it.
4. Only use functions that are required for the given goal.
5. Append an "END" XML comment at the end of the plan after the final closing </plan> tag.
6. Always output valid XML that can be parsed by an XML parser.
7. If a plan cannot be created with the [AVAILABLE FUNCTIONS], return <plan />.

All plans take the form of:
<plan>
<!-- ... reason for taking step ... -->
<function.{FullyQualifiedFunctionName} ... />
<!-- ... reason for taking step ... -->
<function.{FullyQualifiedFunctionName} ... />
<!-- ... reason for taking step ... -->
<function.{FullyQualifiedFunctionName} ... />
(... etc ...)
</plan>
<!-- END -->

To call a function, follow these steps:
1. A function has one or more named parameters and a single 'output' which are all strings. Parameter values should be xml escaped.
2. To save an 'output' from a <function>, to pass into a future <function>, use <function.{FullyQualifiedFunctionName} ... setContextVariable="<UNIQUE_VARIABLE_KEY>"/>
3. To save an 'output' from a <function>, to return as part of a plan result, use <function.{FullyQualifiedFunctionName} ... appendToResult="RESULT__<UNIQUE_RESULT_KEY>"/>
4. Use a '$' to reference a context variable in a parameter, e.g. when `INPUT='world'` the parameter 'Hello $INPUT' will evaluate to `Hello world`.
5. Functions do not have access to the context variables of other functions. Do not attempt to use context variables as arrays or objects. Instead, use available functions to extract specific elements or properties from context variables.

DO NOT DO THIS, THE PARAMETER VALUE IS NOT XML ESCAPED:
<function.Name4 input="$SOME_PREVIOUS_OUTPUT" parameter_name="some value with a <!-- comment in it-->"/>

DO NOT DO THIS, THE PARAMETER VALUE IS ATTEMPTING TO USE A CONTEXT VARIABLE AS AN ARRAY/OBJECT:
<function.CallFunction input="$OTHER_OUTPUT[1]"/>

Here is a valid example of how to call a function "_Function_.Name" with a single input and save its output:
<function._Function_.Name input="this is my input" setContextVariable="SOME_KEY"/>

Here is a valid example of how to call a function "FunctionName2" with a single input and return its output as part of the plan result:
<function.FunctionName2 input="Hello $INPUT" appendToResult="RESULT__FINAL_ANSWER"/>

Here is a valid example of how to call a function "Name3" with multiple inputs:
<function.Name3 input="$SOME_PREVIOUS_OUTPUT" parameter_name="some value with a &lt;!-- comment in it--&gt;"/>

Begin!

<goal>{{$input}}</goal>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from semantic_kernel.planning.sequential_planner.sequential_planner import (
SequentialPlanner,
)

__all__ = [
"SequentialPlanner",
]
Loading

0 comments on commit 484b39d

Please sign in to comment.