chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -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
@@ -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