chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, ValidationError
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.functions import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
from guided_conversation.utils.base_model_llm import BaseModelLLM
|
||||
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
|
||||
from guided_conversation.utils.openai_tool_calling import ToolValidationResult
|
||||
from guided_conversation.utils.plugin_helpers import PluginOutput, fix_error, update_attempts
|
||||
from guided_conversation.utils.resources import ResourceConstraintMode, ResourceConstraintUnit, format_resource
|
||||
|
||||
AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
|
||||
You are conducting a conversation with a user. You tried to update the agenda, but the update was invalid.
|
||||
You will be provided the history of your conversation with the user, \
|
||||
your previous attempt(s) at updating the agenda, and the error message(s) that resulted from your attempt(s).
|
||||
Your task is to correct the update so that it is valid. \
|
||||
Your changes should be as minimal as possible - you are focused on fixing the error(s) that caused the update to be invalid.
|
||||
Note that if the resource allocation is invalid, you must follow these rules:
|
||||
1. You should not change the description of the first item (since it has already been executed), but you can change its resource allocation
|
||||
2. For all other items, you can combine or split them, or assign them fewer or more resources, \
|
||||
but the content they cover collectively should not change (i.e. don't eliminate or add new topics).
|
||||
For example, the invalid attempt was "item 1 = ask for date of birth (1 turn), item 2 = ask for phone number (1 turn), \
|
||||
item 3 = ask for phone type (1 turn), item 4 = explore treatment history (6 turns)", \
|
||||
and the error says you need to correct the total resource allocation to 7 turns. \
|
||||
A bad solution is "item 1 = ask for date of birth (1 turn), \
|
||||
item 2 = explore treatment history (6 turns)" because it eliminates the phone number and phone type topics. \
|
||||
A good solution is "item 1 = ask for date of birth (2 turns), item 2 = ask for phone number, phone type,
|
||||
and treatment history (2 turns), item 3 = explore treatment history (3 turns)."</message>
|
||||
|
||||
<message role="user">Conversation history:
|
||||
{{ conversation_history }}
|
||||
|
||||
Previous attempts to update the agenda:
|
||||
{{ previous_attempts }}</message>"""
|
||||
|
||||
UPDATE_AGENDA_TOOL = "update_agenda"
|
||||
|
||||
|
||||
class _BaseAgendaItem(BaseModelLLM):
|
||||
title: str = Field(description="Brief description of the item")
|
||||
resource: int = Field(description="Number of turns required for the item")
|
||||
|
||||
|
||||
class _BaseAgenda(BaseModelLLM):
|
||||
items: list[_BaseAgendaItem] = Field(
|
||||
description="Ordered list of items to be completed in the remainder of the conversation",
|
||||
default_factory=list,
|
||||
)
|
||||
|
||||
|
||||
class Agenda:
|
||||
"""An abstraction to manage a conversation agenda. The expected use case is that another agent will generate an agenda.
|
||||
This class will validate if it is valid, and help correct it if it is not.
|
||||
|
||||
Args:
|
||||
kernel (Kernel): The Semantic Kernel instance to use for calling the LLM. Don't forget to set your
|
||||
req_settings since this class uses tool calling functionality from the Semantic Kernel.
|
||||
service_id (str): The service ID to use for the Semantic Kernel tool calling. One kernel can have multiple
|
||||
services. The service ID is used to identify which service to use for LLM calls. The Agenda object
|
||||
assumes that the service has tool calling capabilities and is some flavor of chat completion.
|
||||
resource_constraint_mode (ResourceConstraintMode): The mode for resource constraints.
|
||||
max_agenda_retries (int): The maximum number of retries for updating the agenda.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kernel: Kernel,
|
||||
service_id: str,
|
||||
resource_constraint_mode: ResourceConstraintMode | None,
|
||||
max_agenda_retries: int = 2,
|
||||
) -> None:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
self.id = "agenda_plugin"
|
||||
self.kernel = Kernel()
|
||||
self.logger = logger
|
||||
self.kernel = kernel
|
||||
self.service_id = service_id
|
||||
|
||||
self.resource_constraint_mode = resource_constraint_mode
|
||||
self.max_agenda_retries = max_agenda_retries
|
||||
|
||||
self.agenda = _BaseAgenda()
|
||||
|
||||
async def update_agenda(
|
||||
self,
|
||||
items: list[dict[str, str]],
|
||||
remaining_turns: int,
|
||||
conversation: Conversation,
|
||||
) -> PluginOutput:
|
||||
"""Updates the agenda model with the given items (generally generated by an LLM) and validates if the update is valid.
|
||||
The agenda update reasons in terms of turns for validating the if the proposed agenda is valid.
|
||||
If you wish to use a different resource unit, convert the value to turns in some way because
|
||||
we found that LLMs do much better at reasoning in terms of turns.
|
||||
|
||||
Args:
|
||||
items (list[dict[str, str]]): A list of agenda items.
|
||||
Each item should have the following keys:
|
||||
- title (str): A brief description of the item.
|
||||
- resource (int): The number of turns required for the item.
|
||||
remaining_turns (int): The number of remaining turns.
|
||||
conversation (Conversation): The conversation object.
|
||||
|
||||
Returns:
|
||||
PluginOutput: A PluginOutput object with the success status. Does not generate any messages.
|
||||
"""
|
||||
previous_attempts = []
|
||||
while True:
|
||||
try:
|
||||
# Try to update the agenda, and do extra validation checks
|
||||
self.agenda.items = items
|
||||
self._validate_agenda_update(items, remaining_turns)
|
||||
self.logger.info(f"Agenda updated successfully: {self.get_agenda_for_prompt()}")
|
||||
return PluginOutput(True, [])
|
||||
except (ValidationError, ValueError) as e:
|
||||
# Update the previous attempts and get instructions for the LLM
|
||||
previous_attempts, llm_formatted_attempts = update_attempts(
|
||||
error=e, attempt_id=str(items), previous_attempts=previous_attempts
|
||||
)
|
||||
|
||||
# If we have reached the maximum number of retries return a failure
|
||||
if len(previous_attempts) > self.max_agenda_retries:
|
||||
self.logger.warning(f"Failed to update agenda after {self.max_agenda_retries} attempts.")
|
||||
return PluginOutput(False, [])
|
||||
else:
|
||||
self.logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.")
|
||||
response = await self._fix_agenda_error(llm_formatted_attempts, conversation)
|
||||
if response["validation_result"] != ToolValidationResult.SUCCESS:
|
||||
self.logger.warning(
|
||||
f"Failed to fix the agenda error due to a failure in the LLM tool call: {response['validation_result']}"
|
||||
)
|
||||
return PluginOutput(False, [])
|
||||
else:
|
||||
# Use the result of the first tool call to try the update again
|
||||
items = response["tool_args_list"][0]["items"]
|
||||
|
||||
def get_agenda_for_prompt(self) -> str:
|
||||
"""Gets a string representation of the agenda for use in an LLM prompt.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the agenda.
|
||||
"""
|
||||
agenda_json = self.agenda.model_dump()
|
||||
agenda_items = agenda_json.get("items", [])
|
||||
if len(agenda_items) == 0:
|
||||
return "None"
|
||||
agenda_str = "\n".join(
|
||||
[
|
||||
f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}"
|
||||
for i, item in enumerate(agenda_items)
|
||||
]
|
||||
)
|
||||
total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS)
|
||||
agenda_str += f"\nTotal = {total_resource}"
|
||||
return agenda_str
|
||||
|
||||
# The following is the kernel function that will be provided to the LLM call
|
||||
class Items:
|
||||
title: Annotated[str, "Description of the item"]
|
||||
resource: Annotated[int, "Number of turns required for the item"]
|
||||
|
||||
@kernel_function(
|
||||
name=UPDATE_AGENDA_TOOL,
|
||||
description="Updates the agenda.",
|
||||
)
|
||||
def update_agenda_items(
|
||||
self,
|
||||
items: Annotated[list[Items], "Ordered list of items to be completed in the remainder of the conversation"],
|
||||
):
|
||||
pass
|
||||
|
||||
async def _fix_agenda_error(self, previous_attempts: str, conversation: Conversation) -> None:
|
||||
"""Calls an LLM to try and fix an error in the agenda update."""
|
||||
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
|
||||
req_settings.max_tokens = 2000
|
||||
|
||||
self.kernel.add_function(plugin_name=self.id, function=self.update_agenda_items)
|
||||
filter = {"included_plugins": [self.id]}
|
||||
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter)
|
||||
|
||||
arguments = KernelArguments(
|
||||
conversation_history=conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING]),
|
||||
previous_attempts=previous_attempts,
|
||||
)
|
||||
|
||||
return await fix_error(
|
||||
kernel=self.kernel,
|
||||
prompt_template=AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE,
|
||||
req_settings=req_settings,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
def _validate_agenda_update(self, items: list[dict[str, str]], remaining_turns: int) -> None:
|
||||
"""Validates if any constraints were violated while performing the agenda update.
|
||||
|
||||
Args:
|
||||
items (list[dict[str, str]]): A list of agenda items.
|
||||
remaining_turns (int): The number of remaining turns.
|
||||
|
||||
Raises:
|
||||
ValueError: If any validation checks fail.
|
||||
"""
|
||||
# The total, proposed allocation of resources.
|
||||
total_resources = sum([item["resource"] for item in items])
|
||||
|
||||
violations = []
|
||||
# In maximum mode, the total resources should not exceed the remaining turns
|
||||
if (self.resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns):
|
||||
total_resource_instruction = (
|
||||
f"The total turns allocated in the agenda must not exceed the remaining amount ({remaining_turns})"
|
||||
)
|
||||
violations.append(f"{total_resource_instruction}; but the current total is {total_resources}.")
|
||||
|
||||
# In exact mode if the total resources were not exactly equal to the remaining turns
|
||||
if (self.resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns):
|
||||
total_resource_instruction = (
|
||||
f"The total turns allocated in the agenda must equal the remaining amount ({remaining_turns})"
|
||||
)
|
||||
violations.append(f"{total_resource_instruction}; but the current total is {total_resources}.")
|
||||
|
||||
# Check if any item has a resource value of 0
|
||||
if any(item["resource"] <= 0 for item in items):
|
||||
violations.append("All items must have a resource value greater than 0.")
|
||||
|
||||
# Raise an error if any violations were found
|
||||
if len(violations) > 0:
|
||||
self.logger.debug(f"Agenda update failed due to the following violations: {violations}.")
|
||||
raise ValueError(" ".join(violations))
|
||||
|
||||
def to_json(self) -> dict:
|
||||
agenda_dict = self.agenda.model_dump()
|
||||
return {
|
||||
"agenda": agenda_dict,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(
|
||||
cls,
|
||||
json_data: dict,
|
||||
kernel: Kernel,
|
||||
service_id: str,
|
||||
resource_constraint_mode: ResourceConstraintMode | None,
|
||||
max_agenda_retries: int = 2,
|
||||
) -> "Agenda":
|
||||
agenda = cls(kernel, service_id, resource_constraint_mode, max_agenda_retries)
|
||||
agenda.agenda.items = json_data["agenda"]["items"]
|
||||
return agenda
|
||||
@@ -0,0 +1,480 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints
|
||||
|
||||
from pydantic import BaseModel, create_model
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents import AuthorRole, ChatMessageContent
|
||||
from semantic_kernel.functions import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
from guided_conversation.utils.base_model_llm import BaseModelLLM
|
||||
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
|
||||
from guided_conversation.utils.openai_tool_calling import ToolValidationResult
|
||||
from guided_conversation.utils.plugin_helpers import PluginOutput, fix_error, update_attempts
|
||||
|
||||
ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE = """<message role="system">You are a helpful, thoughtful, and meticulous assistant.
|
||||
You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation.
|
||||
You have tried to update a field in the artifact, but the value you provided did not adhere \
|
||||
to the constraints of the field as specified in the artifact schema.
|
||||
You will be provided the history of your conversation with the user, the schema for the field, \
|
||||
your previous attempt(s) at updating the field, and the error message(s) that resulted from your attempt(s).
|
||||
Your task is to select the best possible action to take next:
|
||||
1. Update artifact
|
||||
- You should pick this action if you have a valid value to submit for the field in question.
|
||||
2. Resume conversation
|
||||
- You should pick this action if: (a) you do NOT have a valid value to submit for the field in question, and \
|
||||
(b) you need to ask the user for more information in order to obtain a valid value. \
|
||||
For example, if the user stated that their date of birth is June 2000, but the artifact field asks for the date of birth in the format \
|
||||
"YYYY-MM-DD", you should resume the conversation and ask the user for the day.</message>
|
||||
|
||||
<message role="user">Conversation history:
|
||||
{{ conversation_history }}
|
||||
|
||||
Schema:
|
||||
{{ artifact_schema }}
|
||||
|
||||
Previous attempts to update the field "{{ field_name }}" in the artifact:
|
||||
{{ previous_attempts }}</message>"""
|
||||
|
||||
UPDATE_ARTIFACT_TOOL = "update_artifact_field"
|
||||
RESUME_CONV_TOOL = "resume_conversation"
|
||||
|
||||
|
||||
class Artifact:
|
||||
"""The Artifact plugin takes in a Pydantic base model, and robustly handles updating the fields of the model
|
||||
A typical use case is as a form an agent must complete throughout a conversation.
|
||||
Another use case is as a working memory for the agent.
|
||||
|
||||
The primary interface is update_artifact, which takes in the field_name to update and its new value.
|
||||
Additionally, the chat_history is passed in to help the agent make informed decisions in case an error occurs.
|
||||
|
||||
The Artifact also exposes several functions to access internal state:
|
||||
get_artifact_for_prompt, get_schema_for_prompt, and get_failed_fields.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, kernel: Kernel, service_id: str, input_artifact: BaseModel, max_artifact_field_retries: int = 2
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Artifact plugin with the given Pydantic base model.
|
||||
|
||||
Args:
|
||||
kernel (Kernel): The Semantic Kernel instance to use for calling the LLM. Don't forget to set your
|
||||
req_settings since this class uses tool calling functionality from the Semantic Kernel.
|
||||
service_id (str): The service ID to use for the Semantic Kernel tool calling. One kernel can have multiple
|
||||
services. The service ID is used to identify which service to use for LLM calls. The Artifact object
|
||||
assumes that the service has tool calling capabilities and is some flavor of chat completion.
|
||||
input_artifact (BaseModel): The Pydantic base model to use as the artifact
|
||||
max_artifact_field_retries (int): The maximum number of times to retry updating a field in the artifact
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
self.logger = logger
|
||||
|
||||
self.id = "artifact_plugin"
|
||||
self.kernel = kernel
|
||||
self.service_id = service_id
|
||||
self.max_artifact_field_retries = max_artifact_field_retries
|
||||
|
||||
self.original_schema = input_artifact.model_json_schema()
|
||||
self.artifact = self._initialize_artifact(input_artifact)
|
||||
|
||||
# failed_artifact_fields maps a field name to a list of the history of the failed attempts to update it
|
||||
# dict: key = field, value = list of tuple[attempt, error message]
|
||||
self.failed_artifact_fields: dict[str, list[tuple[str, str]]] = {}
|
||||
|
||||
# The following are the kernel functions that will be provided to the LLM call
|
||||
@kernel_function(
|
||||
name=UPDATE_ARTIFACT_TOOL,
|
||||
description="Sets the value of a field in the artifact",
|
||||
)
|
||||
def update_artifact_field(
|
||||
self,
|
||||
field: Annotated[str, "The name of the field to update in the artifact"],
|
||||
value: Annotated[str, "The value to set the field to"],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@kernel_function(
|
||||
name=RESUME_CONV_TOOL,
|
||||
description="Resumes conversation to get more information from the user ",
|
||||
)
|
||||
def resume_conversation(self):
|
||||
pass
|
||||
|
||||
async def update_artifact(self, field_name: str, field_value: Any, conversation: Conversation) -> PluginOutput:
|
||||
"""The core interface for the Artifact plugin.
|
||||
This function will attempt to update the given field_name to the given field_value.
|
||||
If the field_value fails Pydantic validation, an LLM will determine one of two actions to take.
|
||||
Given the conversation as additional context the two actions are:
|
||||
- Retry the update the artifact by fixing the formatting using the previous failed attempts as guidance
|
||||
- Take no action or in other words, resume the conversation to ask the user for more information because the user gave incomplete or incorrect information
|
||||
|
||||
Args:
|
||||
field_name (str): The name of the field to update in the artifact
|
||||
field_value (Any): The value to set the field to
|
||||
conversation (Conversation): The conversation object that contains the history of the conversation
|
||||
|
||||
Returns:
|
||||
PluginOutput: An object with two fields: a boolean indicating success
|
||||
and a list of conversation messages that may have been generated.
|
||||
|
||||
Several outcomes can happen:
|
||||
- The update may have failed due to
|
||||
- A field_name that is not valid in the artifact.
|
||||
- The field_value failing Pydantic validation and all retries failed.
|
||||
- The model failed to correctly call a tool.
|
||||
In this case, the boolean will be False and the list may contain a message indicating the failure.
|
||||
|
||||
- The agent may have successfully updated the artifact or fixed it.
|
||||
In this case, the boolean will be True and the list will contain a message indicating the update and possibly intermediate messages.
|
||||
|
||||
- The agent may have decided to resume the conversation.
|
||||
In this case, the boolean will be True and the messages may only contain messages indicated previous errors.
|
||||
"""
|
||||
|
||||
conversation_messages: list[ChatMessageContent] = []
|
||||
|
||||
# Check if the field name is valid, and return with a failure message if not
|
||||
is_valid_field, msg = self._is_valid_field(field_name)
|
||||
if not is_valid_field:
|
||||
conversation_messages.append(msg)
|
||||
return PluginOutput(update_successful=False, messages=conversation_messages)
|
||||
|
||||
# Try to update the field, and handle any errors that occur until the field is
|
||||
# successfully updated or skipped according to max_artifact_field_retries
|
||||
while True:
|
||||
try:
|
||||
# Check if there have been too many previous failed attempts to update the field
|
||||
if len(self.failed_artifact_fields.get(field_name, [])) >= self.max_artifact_field_retries:
|
||||
self.logger.warning(f"Updating field {field_name} has failed too many times. Skipping.")
|
||||
return False, conversation_messages
|
||||
|
||||
# Attempt to update the artifact
|
||||
msg = self._execute_update_artifact(field_name, field_value)
|
||||
conversation_messages.append(msg)
|
||||
return PluginOutput(True, conversation_messages)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error updating field {field_name}: {e}. Retrying...")
|
||||
# Handle update error will increment failed_artifact_fields, once it has failed
|
||||
# greater than self.max_artifact_field_retries the field will be skipped and the loop will break
|
||||
success, new_field_value = await self._handle_update_error(field_name, field_value, conversation, e)
|
||||
|
||||
# The agent has successfully fixed the field.
|
||||
if success and new_field_value is not None:
|
||||
self.logger.info(f"Agent successfully fixed field {field_name}. New value: {new_field_value}")
|
||||
field_value = new_field_value
|
||||
# This is the case where the agent has decided to resume the conversation.
|
||||
elif success:
|
||||
self.logger.info(
|
||||
f"Agent could not fix the field itself & decided to resume conversation to fix field {field_name}"
|
||||
)
|
||||
return PluginOutput(True, conversation_messages)
|
||||
self.logger.warning(f"Agent failed to fix field {field_name}. Retrying...")
|
||||
# Otherwise, the agent has failed and we will go through the loop again
|
||||
|
||||
def get_artifact_for_prompt(self) -> str:
|
||||
"""Returns a formatted JSON-like representation of the current state of the fields artifact.
|
||||
Any fields that were failed are completely omitted.
|
||||
|
||||
Returns:
|
||||
str: The string representation of the artifact.
|
||||
"""
|
||||
failed_fields = self.get_failed_fields()
|
||||
return {k: v for k, v in self.artifact.model_dump().items() if k not in failed_fields}
|
||||
|
||||
def get_schema_for_prompt(self, filter_one_field: str | None = None) -> str:
|
||||
"""Gets a clean version of the original artifact schema, optimized for use in an LLM prompt.
|
||||
|
||||
Args:
|
||||
filter_one_field (str | None): If this is provided, only the schema for this one field will be returned.
|
||||
|
||||
Returns:
|
||||
str: The cleaned schema
|
||||
"""
|
||||
|
||||
def _clean_properties(schema: dict, failed_fields: list[str]) -> str:
|
||||
properties = schema.get("properties", {})
|
||||
clean_properties = {}
|
||||
for name, property_dict in properties.items():
|
||||
if name not in failed_fields:
|
||||
cleaned_property = {}
|
||||
for k, v in property_dict.items():
|
||||
if k in ["title", "default"]:
|
||||
continue
|
||||
cleaned_property[k] = v
|
||||
clean_properties[name] = cleaned_property
|
||||
|
||||
clean_properties_str = str(clean_properties)
|
||||
clean_properties_str = clean_properties_str.replace("$ref", "type")
|
||||
clean_properties_str = clean_properties_str.replace("#/$defs/", "")
|
||||
return clean_properties_str
|
||||
|
||||
# If filter_one_field is provided, only get the schema for that one field
|
||||
if filter_one_field:
|
||||
if not self._is_valid_field(filter_one_field):
|
||||
self.logger.error(f'Field "{filter_one_field}" is not a valid field in the artifact.')
|
||||
raise ValueError(f'Field "{filter_one_field}" is not a valid field in the artifact.')
|
||||
filtered_schema = {"properties": {filter_one_field: self.original_schema["properties"][filter_one_field]}}
|
||||
filtered_schema.update((k, v) for k, v in self.original_schema.items() if k != "properties")
|
||||
schema = filtered_schema
|
||||
else:
|
||||
schema = self.original_schema
|
||||
|
||||
failed_fields = self.get_failed_fields()
|
||||
properties = _clean_properties(schema, failed_fields)
|
||||
if not properties:
|
||||
self.logger.error("No properties found in the schema.")
|
||||
raise ValueError("No properties found in the schema.")
|
||||
|
||||
types_schema = schema.get("$defs", {})
|
||||
custom_types = []
|
||||
for type_name, type_info in types_schema.items():
|
||||
if f"'type': '{type_name}'" in properties:
|
||||
clean_schema = _clean_properties(type_info, [])
|
||||
if clean_schema != "{}":
|
||||
custom_types.append(f"{type_name} = {clean_schema}")
|
||||
|
||||
if custom_types:
|
||||
explanation = f"If you wanted to create a {type_name} object, for example, you would make a JSON object \
|
||||
with the following keys: {', '.join(types_schema[type_name]['properties'].keys())}."
|
||||
custom_types_str = "\n".join(custom_types)
|
||||
return f"""{properties}
|
||||
|
||||
Here are the definitions for the custom types referenced in the artifact schema:
|
||||
{custom_types_str}
|
||||
|
||||
{explanation}
|
||||
Remember that when updating the artifact, the field will be the original field name in the artifact and the JSON object(s) will be the value."""
|
||||
else:
|
||||
return properties
|
||||
|
||||
def get_failed_fields(self) -> list[str]:
|
||||
"""Get a list of fields that have failed all attempts to update.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of field names that have failed all attempts to update.
|
||||
"""
|
||||
fields = []
|
||||
for field, attempts in self.failed_artifact_fields.items():
|
||||
if len(attempts) >= self.max_artifact_field_retries:
|
||||
fields.append(field)
|
||||
return fields
|
||||
|
||||
def _initialize_artifact(self, artifact_model: BaseModel) -> BaseModelLLM:
|
||||
"""Create a new artifact model based on the one provided by the user
|
||||
with "Unanswered" set for all fields.
|
||||
|
||||
Args:
|
||||
artifact_model (BaseModel): The Pydantic class provided by the user
|
||||
|
||||
Returns:
|
||||
BaseModelLLM: The new artifact model with "Unanswered" set for all fields
|
||||
"""
|
||||
modified_classes = self._modify_classes(artifact_model)
|
||||
artifact = self._modify_base_artifact(artifact_model, modified_classes)
|
||||
return artifact()
|
||||
|
||||
def _get_type_if_subtype(self, target_type: type[Any], base_type: type[Any]) -> type[Any] | None:
|
||||
"""Recursively checks the target_type to see if it is a subclass of base_type or a generic including base_type.
|
||||
|
||||
Args:
|
||||
target_type: The type to check.
|
||||
base_type: The type to check against.
|
||||
|
||||
Returns:
|
||||
The class type if target_type is base_type, a subclass of base_type, or a generic including base_type; otherwise, None.
|
||||
"""
|
||||
origin = get_origin(target_type)
|
||||
if origin is None:
|
||||
if issubclass(target_type, base_type):
|
||||
return target_type
|
||||
else:
|
||||
# Recursively check if any of the arguments are the target type
|
||||
for arg in get_args(target_type):
|
||||
result = self._get_type_if_subtype(arg, base_type)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
def _modify_classes(self, artifact_class: BaseModel) -> dict[str, type[BaseModelLLM]]:
|
||||
"""Find all classes used as type hints in the artifact, and modify them to set 'Unanswered' as a default and valid value for all fields."""
|
||||
modified_classes = {}
|
||||
# Find any instances of BaseModel in the artifact class in the first "level" of type hints
|
||||
for field_name, field_type in get_type_hints(artifact_class).items():
|
||||
is_base_model = self._get_type_if_subtype(field_type, BaseModel)
|
||||
if is_base_model is not None:
|
||||
modified_classes[field_name] = self._modify_base_artifact(is_base_model)
|
||||
|
||||
return modified_classes
|
||||
|
||||
def _replace_type_annotations(
|
||||
self, field_annotation: type[Any] | None, modified_classes: dict[str, type[BaseModelLLM]]
|
||||
) -> type:
|
||||
"""Recursively replace type annotations with modified classes where applicable."""
|
||||
# Get the origin of the field annotation, which is the base type for generic types (e.g., List[str] -> list, Dict[str, int] -> dict)
|
||||
origin = get_origin(field_annotation)
|
||||
# Get the type arguments of the generic type (e.g., List[str] -> str, Dict[str, int] -> str, int)
|
||||
args = get_args(field_annotation)
|
||||
|
||||
if origin is None:
|
||||
# The type is not generic; check if it's a subclass that needs to be replaced
|
||||
if isinstance(field_annotation, type) and issubclass(field_annotation, BaseModelLLM):
|
||||
return modified_classes.get(field_annotation.__name__, field_annotation)
|
||||
return field_annotation
|
||||
else:
|
||||
# The type is generic; recursively replace the type annotations of the arguments
|
||||
new_args = tuple(self._replace_type_annotations(arg, modified_classes) for arg in args)
|
||||
return origin[new_args]
|
||||
|
||||
def _modify_base_artifact(
|
||||
self, artifact_model: type[BaseModelLLM], modified_classes: dict[str, type[BaseModelLLM]] | None = None
|
||||
) -> type[BaseModelLLM]:
|
||||
"""Create a new artifact model with 'Unanswered' as a default and valid value for all fields."""
|
||||
for _, field_info in artifact_model.model_fields.items():
|
||||
# Replace original classes with modified version
|
||||
if modified_classes is not None:
|
||||
field_info.annotation = self._replace_type_annotations(field_info.annotation, modified_classes)
|
||||
# This makes it possible to always set a field to "Unanswered"
|
||||
field_info.annotation = field_info.annotation | Literal["Unanswered"]
|
||||
# This sets the default value to "Unanswered"
|
||||
field_info.default = "Unanswered"
|
||||
# This adds "Unanswered" as a possible value to any regex patterns
|
||||
metadata = field_info.metadata
|
||||
for m in metadata:
|
||||
if hasattr(m, "pattern"):
|
||||
m.pattern += "|Unanswered"
|
||||
field_definitions = {
|
||||
name: (field_info.annotation, field_info) for name, field_info in artifact_model.model_fields.items()
|
||||
}
|
||||
artifact_model = create_model("Artifact", __base__=BaseModelLLM, **field_definitions)
|
||||
return artifact_model
|
||||
|
||||
def _is_valid_field(self, field_name: str) -> tuple[bool, ChatMessageContent]:
|
||||
"""Check if the field_name is a valid field in the artifact. Returns True if it is, False and an error message otherwise."""
|
||||
if field_name not in self.artifact.model_fields:
|
||||
error_message = f'Field "{field_name}" is not a valid field in the artifact.'
|
||||
msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content=error_message,
|
||||
metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None},
|
||||
)
|
||||
return False, msg
|
||||
return True, None
|
||||
|
||||
async def _fix_artifact_error(
|
||||
self,
|
||||
field_name: str,
|
||||
previous_attempts: str,
|
||||
conversation_repr: str,
|
||||
artifact_schema_repr: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Calls the LLM to fix an error in the artifact using Semantic Kernel kernel."""
|
||||
|
||||
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
|
||||
req_settings.max_tokens = 2000
|
||||
|
||||
self.kernel.add_function(plugin_name=self.id, function=self.update_artifact_field)
|
||||
self.kernel.add_function(plugin_name=self.id, function=self.resume_conversation)
|
||||
filter = {"included_plugins": [self.id]}
|
||||
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter)
|
||||
|
||||
arguments = KernelArguments(
|
||||
field_name=field_name,
|
||||
conversation_history=conversation_repr,
|
||||
previous_attempts=previous_attempts,
|
||||
artifact_schema=artifact_schema_repr,
|
||||
settings=req_settings,
|
||||
)
|
||||
|
||||
return await fix_error(
|
||||
kernel=self.kernel,
|
||||
prompt_template=ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE,
|
||||
req_settings=req_settings,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
def _execute_update_artifact(
|
||||
self,
|
||||
field_name: Annotated[str, "The name of the field to update in the artifact"],
|
||||
field_value: Annotated[Any, "The value to set the field to"],
|
||||
) -> None:
|
||||
"""Update a field in the artifact with a new value. This will raise an error if the field_value is invalid."""
|
||||
setattr(self.artifact, field_name, field_value)
|
||||
msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content=f"Assistant updated {field_name} to {field_value}",
|
||||
metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None},
|
||||
)
|
||||
return msg
|
||||
|
||||
async def _handle_update_error(
|
||||
self, field_name: str, field_value: Any, conversation: Conversation, error: Exception
|
||||
) -> tuple[bool, Any]:
|
||||
"""
|
||||
Handles the logic for when an error occurs while updating a field.
|
||||
Creates the appropriate context for the model and calls the LLM to fix the error.
|
||||
|
||||
Args:
|
||||
field_name (str): The name of the field to update in the artifact
|
||||
field_value (Any): The value to set the field to
|
||||
conversation (Conversation): The conversation object that contains the history of the conversation
|
||||
error (Exception): The error that occurred while updating the field
|
||||
|
||||
Returns:
|
||||
tuple[bool, Any]: A tuple containing a boolean indicating success and the new field value if successful (if not, then None)
|
||||
"""
|
||||
# Update the failed attempts for the field
|
||||
previous_attempts = self.failed_artifact_fields.get(field_name, [])
|
||||
previous_attempts, llm_formatted_attempts = update_attempts(
|
||||
error=error, attempt_id=str(field_value), previous_attempts=previous_attempts
|
||||
)
|
||||
self.failed_artifact_fields[field_name] = previous_attempts
|
||||
|
||||
# Call the LLM to fix the error
|
||||
conversation_history_repr = conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING])
|
||||
artifact_schema_repr = self.get_schema_for_prompt(filter_one_field=field_name)
|
||||
result = await self._fix_artifact_error(
|
||||
field_name, llm_formatted_attempts, conversation_history_repr, artifact_schema_repr
|
||||
)
|
||||
|
||||
# Handling the result of the LLM call
|
||||
if result["validation_result"] != ToolValidationResult.SUCCESS:
|
||||
return False, None
|
||||
# Only consider the first tool call
|
||||
tool_name = result["tool_names"][0]
|
||||
tool_args = result["tool_args_list"][0]
|
||||
if tool_name == f"{self.id}-{UPDATE_ARTIFACT_TOOL}":
|
||||
field_value = tool_args["value"]
|
||||
return True, field_value
|
||||
elif tool_name == f"{self.id}-{RESUME_CONV_TOOL}":
|
||||
return True, None
|
||||
|
||||
def to_json(self) -> dict:
|
||||
artifact_fields = self.artifact.model_dump()
|
||||
return {
|
||||
"artifact": artifact_fields,
|
||||
"failed_fields": self.failed_artifact_fields,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(
|
||||
cls,
|
||||
json_data: dict,
|
||||
kernel: Kernel,
|
||||
service_id: str,
|
||||
input_artifact: BaseModel,
|
||||
max_artifact_field_retries: int = 2,
|
||||
) -> "Artifact":
|
||||
artifact = cls(kernel, service_id, input_artifact, max_artifact_field_retries)
|
||||
|
||||
artifact.failed_artifact_fields = json_data["failed_fields"]
|
||||
|
||||
# Iterate over artifact fields and set them to the values in the json data
|
||||
# Skip any fields that are set as "Unanswered"
|
||||
for field_name, field_value in json_data["artifact"].items():
|
||||
if field_value != "Unanswered":
|
||||
setattr(artifact.artifact, field_name, field_value)
|
||||
return artifact
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.contents import AuthorRole, ChatMessageContent
|
||||
from semantic_kernel.functions import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
from guided_conversation.functions.conversation_plan import conversation_plan_function
|
||||
from guided_conversation.functions.execution import end_conversation, execution, send_message
|
||||
from guided_conversation.functions.final_update_plan import final_update_plan_function
|
||||
from guided_conversation.plugins.agenda import Agenda
|
||||
from guided_conversation.plugins.artifact import Artifact
|
||||
from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType
|
||||
from guided_conversation.utils.openai_tool_calling import (
|
||||
ToolValidationResult,
|
||||
parse_function_result,
|
||||
validate_tool_calling,
|
||||
)
|
||||
from guided_conversation.utils.plugin_helpers import PluginOutput, format_kernel_functions_as_tools
|
||||
from guided_conversation.utils.resources import GCResource, ResourceConstraint
|
||||
|
||||
MAX_DECISION_RETRIES = 2
|
||||
|
||||
|
||||
class ToolName(Enum):
|
||||
UPDATE_ARTIFACT_TOOL = "update_artifact_field"
|
||||
UPDATE_AGENDA_TOOL = "update_agenda"
|
||||
SEND_MSG_TOOL = "send_message_to_user"
|
||||
END_CONV_TOOL = "end_conversation"
|
||||
GENERATE_PLAN_TOOL = "generate_plan"
|
||||
EXECUTE_PLAN_TOOL = "execute_plan"
|
||||
FINAL_UPDATE_TOOL = "final_update"
|
||||
GUIDED_CONVERSATION_AGENT_TOOLBOX = "gc_agent"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GCOutput:
|
||||
"""The output of the GuidedConversation agent.
|
||||
|
||||
Args:
|
||||
ai_message (str): The message to send to the user.
|
||||
is_conversation_over (bool): Whether the conversation is over.
|
||||
"""
|
||||
|
||||
ai_message: str | None = field(default=None)
|
||||
is_conversation_over: bool = field(default=False)
|
||||
|
||||
|
||||
class GuidedConversation:
|
||||
def __init__(
|
||||
self,
|
||||
kernel: Kernel,
|
||||
artifact: BaseModel,
|
||||
rules: list[str],
|
||||
conversation_flow: str | None,
|
||||
context: str | None,
|
||||
resource_constraint: ResourceConstraint | None,
|
||||
service_id: str = "gc_main",
|
||||
) -> None:
|
||||
"""Initializes the GuidedConversation agent.
|
||||
|
||||
Args:
|
||||
kernel (Kernel): An instance of Kernel. Must come initialized with a AzureOpenAI or OpenAI service.
|
||||
artifact (BaseModel): The artifact to be used as the goal/working memory/output of the conversation.
|
||||
rules (list[str]): The rules to be used in the guided conversation (dos and donts).
|
||||
conversation_flow (str | None): The conversation flow to be used in the guided conversation.
|
||||
context (str | None): The scene-setting for the conversation.
|
||||
resource_constraint (ResourceConstraint | None): The limit on the conversation length (for ex: number of turns).
|
||||
service_id (str): Provide a service_id associated with the kernel's service that was provided.
|
||||
"""
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.kernel = kernel
|
||||
self.service_id = service_id
|
||||
|
||||
self.conversation = Conversation()
|
||||
self.resource = GCResource(resource_constraint)
|
||||
self.artifact = Artifact(self.kernel, self.service_id, artifact)
|
||||
self.rules = rules
|
||||
self.conversation_flow = conversation_flow
|
||||
self.context = context
|
||||
self.agenda = Agenda(self.kernel, self.service_id, self.resource.get_resource_mode(), MAX_DECISION_RETRIES)
|
||||
|
||||
# Plugins will be executed in the order of this list.
|
||||
self.plugins_order = [
|
||||
ToolName.UPDATE_ARTIFACT_TOOL.value,
|
||||
ToolName.UPDATE_AGENDA_TOOL.value,
|
||||
]
|
||||
|
||||
# Terminal plugins are plugins that are handled in a special way:
|
||||
# - Only one terminal plugin can be called in a single step of the conversation as it leads to the end of the conversation step.
|
||||
# - The order of this list determines the execution priority.
|
||||
# - For example, if the model chooses to both call send message and end conversation,
|
||||
# Send message will be executed first and since the orchestration step returns, end conversation will not be executed.
|
||||
self.terminal_plugins_order = [
|
||||
ToolName.SEND_MSG_TOOL.value,
|
||||
ToolName.END_CONV_TOOL.value,
|
||||
]
|
||||
|
||||
self.current_failed_decision_attempts = 0
|
||||
|
||||
# Set common request settings
|
||||
self.req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
|
||||
self.req_settings.max_tokens = 2000
|
||||
self.kernel.add_function(plugin_name=ToolName.SEND_MSG_TOOL.value, function=send_message)
|
||||
self.kernel.add_function(plugin_name=ToolName.END_CONV_TOOL.value, function=end_conversation)
|
||||
self.kernel.add_function(
|
||||
plugin_name=ToolName.UPDATE_ARTIFACT_TOOL.value, function=self.artifact.update_artifact_field
|
||||
)
|
||||
self.kernel.add_function(
|
||||
plugin_name=ToolName.UPDATE_AGENDA_TOOL.value, function=self.agenda.update_agenda_items
|
||||
)
|
||||
|
||||
# Set orchestrator functions for the agent
|
||||
self.kernel_function_generate_plan = self.kernel.add_function(
|
||||
plugin_name="gc_agent", function=self.generate_plan
|
||||
)
|
||||
self.kernel_function_execute_plan = self.kernel.add_function(plugin_name="gc_agent", function=self.execute_plan)
|
||||
self.kernel_function_final_update = self.kernel.add_function(plugin_name="gc_agent", function=self.final_update)
|
||||
|
||||
async def step_conversation(self, user_input: str | None = None) -> GCOutput:
|
||||
"""Given a message from a user, this will execute the guided conversation agent up until a
|
||||
terminal plugin is called or the maximum number of decision retries is reached."""
|
||||
self.logger.info(f"Starting conversation step {self.resource.turn_number}.")
|
||||
self.resource.start_resource()
|
||||
self.current_failed_decision_attempts = 0
|
||||
if user_input:
|
||||
self.conversation.add_messages(
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content=user_input,
|
||||
metadata={"turn_number": self.resource.turn_number, "type": ConversationMessageType.DEFAULT},
|
||||
)
|
||||
)
|
||||
|
||||
# Keep generating and executing plans until a terminal plugin is called
|
||||
# or the maximum number of decision retries is reached.
|
||||
while self.current_failed_decision_attempts < MAX_DECISION_RETRIES:
|
||||
plan = await self.kernel.invoke(self.kernel_function_generate_plan)
|
||||
executed_plan = await self.kernel.invoke(
|
||||
self.kernel_function_execute_plan, KernelArguments(plan=plan.value)
|
||||
)
|
||||
success, plugins, terminal_plugins = executed_plan.value
|
||||
|
||||
if success != ToolValidationResult.SUCCESS:
|
||||
self.logger.warning(
|
||||
f"Failed to parse tools in plan on retry attempt {self.current_failed_decision_attempts} out of {MAX_DECISION_RETRIES}."
|
||||
)
|
||||
self.current_failed_decision_attempts += 1
|
||||
continue
|
||||
|
||||
# Run a step of the orchestration logic based on the plugins called by the model.
|
||||
# First execute all regular plugins (if any) in the order returned by execute_plan
|
||||
for plugin_name, plugin_args in plugins:
|
||||
if plugin_name == f"{ToolName.UPDATE_ARTIFACT_TOOL.value}-{ToolName.UPDATE_ARTIFACT_TOOL.value}":
|
||||
plugin_args["conversation"] = self.conversation
|
||||
# Modify plugin_args such that field=field_name and value=field_value
|
||||
plugin_args["field_name"] = plugin_args.pop("field")
|
||||
plugin_args["field_value"] = plugin_args.pop("value")
|
||||
await self._call_plugin(self.artifact.update_artifact, plugin_args)
|
||||
elif plugin_name == f"{ToolName.UPDATE_AGENDA_TOOL.value}-{ToolName.UPDATE_AGENDA_TOOL.value}":
|
||||
plugin_args["remaining_turns"] = self.resource.get_remaining_turns()
|
||||
plugin_args["conversation"] = self.conversation
|
||||
await self._call_plugin(self.agenda.update_agenda, plugin_args)
|
||||
|
||||
# Then execute the first terminal plugin (if any)
|
||||
if terminal_plugins:
|
||||
gc_output = GCOutput()
|
||||
plugin_name, plugin_args = terminal_plugins[0]
|
||||
if plugin_name == f"{ToolName.SEND_MSG_TOOL.value}-{ToolName.SEND_MSG_TOOL.value}":
|
||||
gc_output.ai_message = plugin_args["message"]
|
||||
elif plugin_name == f"{ToolName.END_CONV_TOOL.value}-{ToolName.END_CONV_TOOL.value}":
|
||||
await self.kernel.invoke(self.kernel_function_final_update)
|
||||
gc_output.ai_message = "I will terminate this conversation now. Thank you for your time!"
|
||||
gc_output.is_conversation_over = True
|
||||
self.resource.increment_resource()
|
||||
return gc_output
|
||||
|
||||
# Handle case where the maximum number of decision retries was reached.
|
||||
self.logger.warning(f"Failed to execute plan after {MAX_DECISION_RETRIES} attempts.")
|
||||
self.resource.increment_resource()
|
||||
gc_output = GCOutput()
|
||||
gc_output.ai_message = "An error occurred and I must sadly end the conversation."
|
||||
gc_output.is_conversation_over = True
|
||||
return gc_output
|
||||
|
||||
@kernel_function(
|
||||
name=ToolName.GENERATE_PLAN_TOOL.value,
|
||||
description="Generate a plan based on a time constraint for the current state of the conversation.",
|
||||
)
|
||||
async def generate_plan(self) -> str:
|
||||
"""Generate a plan for the current state of the conversation. The idea here is to explicitly let the model plan before
|
||||
generating any plugin calls. This has been shown to increase reliability.
|
||||
|
||||
Returns:
|
||||
str: The plan generated by the plan function.
|
||||
"""
|
||||
self.logger.info("Generating plan for the current state of the conversation")
|
||||
plan = await conversation_plan_function(
|
||||
self.kernel,
|
||||
self.conversation,
|
||||
self.context,
|
||||
self.rules,
|
||||
self.conversation_flow,
|
||||
self.artifact,
|
||||
self.req_settings,
|
||||
self.resource,
|
||||
self.agenda,
|
||||
)
|
||||
plan = plan.value[0].content
|
||||
self.conversation.add_messages(
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content=plan,
|
||||
metadata={"turn_number": self.resource.turn_number, "type": ConversationMessageType.REASONING},
|
||||
)
|
||||
)
|
||||
return plan
|
||||
|
||||
@kernel_function(
|
||||
name=ToolName.EXECUTE_PLAN_TOOL.value,
|
||||
description="Given the generated plan by the model, use that plan to generate which functions to execute.",
|
||||
)
|
||||
async def execute_plan(
|
||||
self, plan: str
|
||||
) -> tuple[ToolValidationResult, list[tuple[str, dict]], list[tuple[str, dict]]]:
|
||||
"""Given the generated plan by the model, use that plan to generate which functions to execute.
|
||||
Once the tool calls are generated by the model, we sort them into two groups: regular plugins and terminal plugins
|
||||
according to the definition in __init__
|
||||
|
||||
Args:
|
||||
plan (str): The plan generated by the model.
|
||||
|
||||
Returns:
|
||||
tuple[ToolValidationResult, list[tuple[str, dict]], list[tuple[str, dict]]]: A tuple containing the validation result
|
||||
of the tool calls, the regular plugins to execute, and the terminal plugins to execute alongside their arguments.
|
||||
"""
|
||||
self.logger.info("Executing plan.")
|
||||
|
||||
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
|
||||
functions = self.plugins_order + self.terminal_plugins_order
|
||||
result = await execution(
|
||||
kernel=self.kernel,
|
||||
reasoning=plan,
|
||||
filter=functions,
|
||||
req_settings=req_settings,
|
||||
artifact_schema=self.artifact.get_schema_for_prompt(),
|
||||
)
|
||||
|
||||
parsed_result = parse_function_result(result)
|
||||
formatted_tools = format_kernel_functions_as_tools(self.kernel, functions)
|
||||
validation_result = validate_tool_calling(parsed_result, formatted_tools)
|
||||
|
||||
# Sort plugin calls into two groups in the order of the corresponding lists defined in __init__
|
||||
plugins = []
|
||||
terminal_plugins = []
|
||||
if validation_result == ToolValidationResult.SUCCESS:
|
||||
for plugin in self.plugins_order:
|
||||
for idx, called_plugin_name in enumerate(parsed_result["tool_names"]):
|
||||
plugin_name = f"{plugin}-{plugin}"
|
||||
if called_plugin_name == plugin_name:
|
||||
plugins.append((parsed_result["tool_names"][idx], parsed_result["tool_args_list"][idx]))
|
||||
|
||||
for terminal_plugin in self.terminal_plugins_order:
|
||||
for idx, called_plugin_name in enumerate(parsed_result["tool_names"]):
|
||||
terminal_plugin_name = f"{terminal_plugin}-{terminal_plugin}"
|
||||
if called_plugin_name == terminal_plugin_name:
|
||||
terminal_plugins.append(
|
||||
(parsed_result["tool_names"][idx], parsed_result["tool_args_list"][idx])
|
||||
)
|
||||
|
||||
return validation_result, plugins, terminal_plugins
|
||||
|
||||
@kernel_function(
|
||||
name=ToolName.FINAL_UPDATE_TOOL.value,
|
||||
description="After the last message of a conversation was added to the conversation history, perform a final update of the artifact",
|
||||
)
|
||||
async def final_update(self):
|
||||
"""Explicit final update of the artifact after the conversation ends."""
|
||||
self.logger.info("Final update of the artifact prior to terminating the conversation.")
|
||||
|
||||
# Get a plan from the model
|
||||
reasoning_response = await final_update_plan_function(
|
||||
kernel=self.kernel,
|
||||
req_settings=self.req_settings,
|
||||
chat_history=self.conversation,
|
||||
context=self.context,
|
||||
artifact_schema=self.artifact.get_schema_for_prompt(),
|
||||
artifact_state=self.artifact.get_artifact_for_prompt(),
|
||||
)
|
||||
|
||||
# Then generate the functions to be executed
|
||||
req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id)
|
||||
|
||||
functions = [ToolName.UPDATE_ARTIFACT_TOOL.value]
|
||||
execution_response = await execution(
|
||||
kernel=self.kernel,
|
||||
reasoning=reasoning_response.value[0].content,
|
||||
filter=functions,
|
||||
req_settings=req_settings,
|
||||
artifact_schema=self.artifact.get_schema_for_prompt(),
|
||||
)
|
||||
|
||||
parsed_result = parse_function_result(execution_response)
|
||||
formatted_tools = format_kernel_functions_as_tools(self.kernel, functions)
|
||||
validation_result = validate_tool_calling(parsed_result, formatted_tools)
|
||||
|
||||
# If the tool call was successful, update the artifact.
|
||||
if validation_result != ToolValidationResult.SUCCESS:
|
||||
self.logger.warning(f"No artifact change during final update due to: {validation_result.value}")
|
||||
pass
|
||||
else:
|
||||
for i in range(len(parsed_result["tool_names"])):
|
||||
tool_name = parsed_result["tool_names"][i]
|
||||
tool_args = parsed_result["tool_args_list"][i]
|
||||
if (
|
||||
tool_name == f"{ToolName.UPDATE_ARTIFACT_TOOL.value}-{ToolName.UPDATE_ARTIFACT_TOOL.value}"
|
||||
and "field" in tool_args
|
||||
and "value" in tool_args
|
||||
):
|
||||
# Check if tool_args contains the field and value to update
|
||||
plugin_output = await self.artifact.update_artifact(
|
||||
field_name=tool_args["field_name"],
|
||||
field_value=tool_args["field_value"],
|
||||
conversation=self.conversation,
|
||||
)
|
||||
if plugin_output.update_successful:
|
||||
self.logger.info(f"Artifact field {tool_args['field_name']} successfully updated.")
|
||||
# Set turn numbers
|
||||
for message in plugin_output.messages:
|
||||
message.turn_number = self.resource.turn_number
|
||||
self.conversation.add_messages(plugin_output.messages)
|
||||
else:
|
||||
self.logger.error(f"Final artifact field update of {tool_args['field_name']} failed.")
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"artifact": self.artifact.to_json(),
|
||||
"agenda": self.agenda.to_json(),
|
||||
"chat_history": self.conversation.to_json(),
|
||||
"resource": self.resource.to_json(),
|
||||
}
|
||||
|
||||
async def _call_plugin(self, plugin_function: Callable, plugin_args: dict):
|
||||
"""Common logic whenever any plugin is called like handling errors and appending to chat history."""
|
||||
self.logger.info(f"Calling plugin {plugin_function.__name__}.")
|
||||
output: PluginOutput = await plugin_function(**plugin_args)
|
||||
if output.update_successful:
|
||||
# Set turn numbers
|
||||
for message in output.messages:
|
||||
message.metadata["turn_number"] = self.resource.turn_number
|
||||
self.conversation.add_messages(output.messages)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Plugin {plugin_function.__name__} failed to execute on attempt {self.current_failed_decision_attempts} out of {MAX_DECISION_RETRIES}."
|
||||
)
|
||||
self.current_failed_decision_attempts += 1
|
||||
|
||||
@classmethod
|
||||
def from_json(
|
||||
cls,
|
||||
json_data: dict,
|
||||
kernel: Kernel,
|
||||
service_id: str = "gc_main",
|
||||
) -> "GuidedConversation":
|
||||
artifact = Artifact.from_json(
|
||||
json_data["artifact"],
|
||||
kernel=kernel,
|
||||
service_id=service_id,
|
||||
input_artifact=cls.artifact,
|
||||
max_artifact_field_retries=MAX_DECISION_RETRIES,
|
||||
)
|
||||
agenda = Agenda.from_json(
|
||||
json_data["agenda"],
|
||||
kernel=kernel,
|
||||
service_id=service_id,
|
||||
resource_constraint_mode=cls.resource_constraint.mode,
|
||||
)
|
||||
chat_history = Conversation.from_json(json_data["chat_history"])
|
||||
resource = GCResource.from_json(json_data["resource"])
|
||||
|
||||
gc = cls(kernel, artifact, agenda, chat_history, resource, service_id)
|
||||
|
||||
return gc
|
||||
Reference in New Issue
Block a user