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,42 @@
# Copyright (c) Microsoft. All rights reserved.
import ast
from types import NoneType
from typing import get_args
from pydantic import BaseModel, ValidationInfo, field_validator
class BaseModelLLM(BaseModel):
"""A Pydantic base class for use when an LLM is completing fields. Provides a custom field validator and Pydantic Config."""
@field_validator("*", mode="before")
def parse_literal_eval(cls, value: str, info: ValidationInfo): # noqa: N805
"""An LLM will always result in a string (e.g. '["x", "y"]'), so we need to parse it to the correct type"""
# Get the type hints for the field
annotation = cls.model_fields[info.field_name].annotation
typehints = get_args(annotation)
if len(typehints) == 0:
typehints = [annotation]
# Usually fields that are NoneType have another type hint as well, e.g. str | None
# if the LLM returns "None" and the field allows NoneType, we should return None
# without this code, the next if-block would leave the string "None" as the value
if (NoneType in typehints) and (value == "None"):
return None
# If the field allows strings, we don't parse it - otherwise a validation error might be raised
# e.g. phone_number = "1234567890" should not be converted to an int if the type hint is str
if str in typehints:
return value
try:
evaluated_value = ast.literal_eval(value)
return evaluated_value
except Exception:
return value
class Config:
# Ensure that validation happens every time a field is updated, not just when the artifact is created
validate_assignment = True
# Do not allow extra fields to be added to the artifact
extra = "forbid"
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass, field
import datetime
from enum import Enum
import logging
from typing import Union
from semantic_kernel.contents import ChatMessageContent
class ConversationMessageType(Enum):
DEFAULT = "default"
ARTIFACT_UPDATE = "artifact-update"
REASONING = "reasoning"
@dataclass
class Conversation:
"""An abstraction to represent a list of messages and common operations such as adding messages
and getting a string representation.
Args:
conversation_messages (list[ChatMessageContent]): A list of ChatMessageContent objects.
"""
logger = logging.getLogger(__name__)
conversation_messages: list[ChatMessageContent] = field(default_factory=list)
def add_messages(self, messages: Union[ChatMessageContent, list[ChatMessageContent], "Conversation", None]) -> None:
"""Add a message, list of messages to the conversation or merge another conversation into the end of this one.
Args:
messages (Union[ChatMessageContent, list[ChatMessageContent], "Conversation"]): The message(s) to add.
All messages will be added to the end of the conversation.
Returns:
None
"""
if isinstance(messages, list):
self.conversation_messages.extend(messages)
elif isinstance(messages, Conversation):
self.conversation_messages.extend(messages.conversation_messages)
elif isinstance(messages, ChatMessageContent):
# if ChatMessageContent.metadata doesn't have type, then add default
if "type" not in messages.metadata:
messages.metadata["type"] = ConversationMessageType.DEFAULT
self.conversation_messages.append(messages)
else:
self.logger.warning(f"Invalid message type: {type(messages)}")
return None
def get_repr_for_prompt(
self,
exclude_types: list[ConversationMessageType] | None = None,
) -> str:
"""Create a string representation of the conversation history for use in LLM prompts.
Args:
exclude_types (list[ConversationMessageType] | None): A list of message types to exclude from the conversation
history. If None, all message types will be included.
Returns:
str: A string representation of the conversation history.
"""
if len(self.conversation_messages) == 0:
return "None"
# Do not include the excluded messages types in the conversation history repr.
if exclude_types is not None:
conversation_messages = [
message
for message in self.conversation_messages
if "type" in message.metadata and message.metadata["type"] not in exclude_types
]
else:
conversation_messages = self.conversation_messages
to_join = []
current_turn = None
for message in conversation_messages:
participant_name = message.name
# Modify the default user to be capitalized for consistency with how assistant is written.
if participant_name == "user":
participant_name = "User"
# If the turn number is None, don't include it in the string
if "turn_number" in message.metadata and current_turn != message.metadata["turn_number"]:
current_turn = message.metadata["turn_number"]
to_join.append(f"[Turn {current_turn}]")
# Add the message content
if (message.role == "assistant") and (
"type" in message.metadata and message.metadata["type"] == ConversationMessageType.ARTIFACT_UPDATE
):
to_join.append(message.content)
elif message.role == "assistant":
to_join.append(f"Assistant: {message.content}")
else:
user_string = message.content.strip()
if user_string == "":
to_join.append(f"{participant_name}: <sent an empty message>")
else:
to_join.append(f"{participant_name}: {user_string}")
conversation_string = "\n".join(to_join)
return conversation_string
def set_turn_numbers(self, turn_number: int) -> None:
"""Set all the turn numbers in the conversation to the given turn number.
Args:
turn_number (int): The turn number to set for all messages.
Returns:
None"""
for message in self.conversation_messages:
message.metadata["turn_number"] = turn_number
@staticmethod
def message_to_json(message: ChatMessageContent) -> dict:
"""
Convert a ChatMessageContent object to a JSON serializable dictionary.
Args:
message (ChatMessageContent): The ChatMessageContent object to convert to JSON.
Returns:
dict: A JSON serializable dictionary representation of the ChatMessageContent object.
"""
return {
"role": message.role,
"content": message.content,
"name": message.name,
"metadata": {
"turn_number": message.metadata.get("turn_number", None),
"type": message.metadata.get("type", ConversationMessageType.DEFAULT),
},
}
def to_json(self) -> dict:
json_data = {}
json_data["conversation"] = {}
json_data["conversation"]["conversation_messages"] = [
self.message_to_json(message) for message in self.conversation_messages
]
return json_data
@classmethod
def from_json(
cls,
json_data: dict,
) -> "Conversation":
conversation = cls()
for message in json_data["conversation"]["conversation_messages"]:
conversation.add_messages(
ChatMessageContent(
role=message["role"],
content=message["content"],
name=message["name"],
metadata={
"turn_number": message["turn_number"],
"type": ConversationMessageType(message["type"]),
"timestamp": datetime.datetime.fromisoformat(message["timestamp"]),
},
)
)
return conversation
@@ -0,0 +1,158 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from enum import Enum
import json
import logging
from typing import Any
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.functions import FunctionResult
logger = logging.getLogger(__name__)
@dataclass
class ToolArg:
argument_name: str
required: bool
@dataclass
class Tool:
name: str
args: list[ToolArg]
class ToolValidationResult(Enum):
NO_TOOL_CALLED = "No tool was called"
INVALID_TOOL_CALLED = "A tool was called with an unexpected name"
MISSING_REQUIRED_ARGUMENT = "The tool called is missing a required argument"
INVALID_ARGUMENT_TYPE = "The value of an argument is of an unexpected type"
SUCCESS = "success"
def parse_function_result(response: FunctionResult) -> dict[str, Any]:
"""Parse the response from SK's FunctionResult object into only the relevant data for easier downstream processing.
This should only be used when you expect the response to contain tool calls.
Args:
response (FunctionResult): The response from the kernel.
Returns:
dict[str, Any]: The parsed response data with the following format if n was set greater than 1:
{
"choices": [
{
"tool_names": list[str],
"tool_args_list": list[dict[str, Any]],
"message": str,
"finish_reason": str,
"validation_result": ToolValidationResult
}, ...
]
}
Otherwise, the response will directly contain the data from the first choice (tool_names, etc keys)
"""
response_data: dict[str, Any] = {"choices": []}
for response_choice in response.value:
response_data_curr = {}
finish_reason = response_choice.finish_reason
if finish_reason == "tool_calls":
tool_names = []
tool_args_list = []
# Only look at the items that are of instance `FunctionCallContent`
tool_calls = [item for item in response_choice.items if isinstance(item, FunctionCallContent)]
for tool_call in tool_calls:
if "-" not in tool_call.name:
logger.info(f"Tool call name {tool_call.name} does not match naming convention - modifying name.")
tool_names.append(tool_call.name + "-" + tool_call.name)
else:
tool_names.append(tool_call.name)
try:
tool_args = json.loads(tool_call.arguments)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments for tool call {tool_call.name}. Using empty dict.")
tool_args = {}
tool_args_list.append(tool_args)
response_data_curr["tool_names"] = tool_names
response_data_curr["tool_args_list"] = tool_args_list
response_data_curr["message"] = response_choice.content
response_data_curr["finish_reason"] = finish_reason
response_data["choices"].append(response_data_curr)
if len(response_data["choices"]) == 1:
response_data.update(response_data["choices"][0])
del response_data["choices"]
return response_data
def construct_tool_objects(kernel_function_tools: dict) -> list[Tool]:
"""Construct a list of Tool objects from the kernel function tools definition.
Args:
kernel_function_tools (dict): The definition of tools done by the kernel function.
Returns:
list[Tool]: The list of Tool objects constructed from the kernel function tools definition.
"""
tool_objects: list[Tool] = []
for tool_definition in kernel_function_tools:
tool_name = tool_definition["function"]["name"]
tool_args = tool_definition["function"]["parameters"]["properties"]
tool_arg_objects: list[ToolArg] = []
for argument_name, _ in tool_args.items():
tool_arg = ToolArg(argument_name=argument_name, required=False)
tool_arg_objects.append(tool_arg)
required_args = tool_definition["function"]["parameters"]["required"]
for tool_arg_object in tool_arg_objects:
if tool_arg_object.argument_name in required_args:
tool_arg_object.required = True
tool_objects.append(Tool(name=tool_name, args=tool_arg_objects))
return tool_objects
def validate_tool_calling(response: dict[str, Any], request_tool_param: dict) -> ToolValidationResult:
"""Validate that the response from the LLM called tools corrected.
1. Check if any tool was called.
2. Check if the tools called were valid (names match)
3. Check if all the required arguments were passed.
Args:
response (dict[str, Any]): The response from the LLM containing the tools called (output of parse_function_result)
tools (list[Tool]): The list of tools that can be called by the model.
Returns:
ToolValidationResult: The result of the validation. ToolValidationResult.SUCCESS if the validation passed.
"""
tool_objects = construct_tool_objects(request_tool_param)
tool_names = response.get("tool_names", [])
tool_args_list = response.get("tool_args_list", [])
# Check if any tool was called.
if not tool_names:
logger.info("No tool was called.")
return ToolValidationResult.NO_TOOL_CALLED
for tool_name, tool_args in zip(tool_names, tool_args_list, strict=True):
# Check the tool names is valid.
tool: Tool | None = next((t for t in tool_objects if t.name == tool_name), None)
if not tool:
logger.warning(f"Invalid tool called: {tool_name}")
return ToolValidationResult.INVALID_TOOL_CALLED
for arg in tool.args:
# Check if the required arguments were passed.
if arg.required and arg.argument_name not in tool_args:
logger.warning(f"Missing required argument '{arg.argument_name}' for tool '{tool_name}'.")
return ToolValidationResult.MISSING_REQUIRED_ARGUMENT
return ToolValidationResult.SUCCESS
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from pydantic import ValidationError
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.functions import KernelArguments
from guided_conversation.utils.openai_tool_calling import parse_function_result, validate_tool_calling
@dataclass
class PluginOutput:
"""A wrapper for all Guided Conversation Plugins. This class is used to return the output of a generic plugin.
Args:
update_successful (bool): Whether the update was successful.
messages (list[ChatMessageContent]): A list of messages to be used at the user's digression, it
contains information about the process of calling the plugin.
"""
update_successful: bool
messages: list[ChatMessageContent]
def format_kernel_functions_as_tools(kernel: Kernel, functions: list[str]):
"""Format kernel functions as JSON schemas for custom validation."""
formatted_tools = []
for _, kernel_plugin_def in kernel.plugins.items():
for function_name, function_def in kernel_plugin_def.functions.items():
if function_name in functions:
func_metadata = function_def.metadata
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
return formatted_tools
async def fix_error(
kernel: Kernel, prompt_template: str, req_settings: AzureChatCompletion, arguments: KernelArguments
) -> dict:
"""Invokes the error correction plugin. If a plugin is called & fails during execution, this function will retry
the plugin. At a high level, we recommend the following steps when calling a plugin:
1. Call the plugin.
2. Parse the response.
3. Validate the response.
4. If the response is invalid (Validation or Value Error), retry the plugin by calling *this function*. For best
results, check out plugins/agenda.py or plugins/artifact.py for examples of prompt templates & corresponding
tools (which should be passed in the req_settings object). This function will handle the retry logic for you.
Args:
kernel (Kernel): The kernel object.
prompt_template (str): The prompt template for the plugin.
req_settings (AzureChatCompletion): The prompt execution settings.
arguments (KernelArguments): The kernel arguments.
Returns:
dict: The result of the plugin call.
"""
kernel_function_obj = kernel.add_function(
prompt=prompt_template,
function_name="error_correction",
plugin_name="error_correction",
template_format="handlebars",
prompt_execution_settings=req_settings,
)
result = await kernel.invoke(function=kernel_function_obj, arguments=arguments)
parsed_result = parse_function_result(result)
formatted_tools = []
for _, kernel_plugin_def in kernel.plugins.items():
for _, function_def in kernel_plugin_def.functions.items():
func_metadata = function_def.metadata
formatted_tools.append(kernel_function_metadata_to_function_call_format(func_metadata))
# Add any tools from req_settings
if req_settings.tools:
formatted_tools.extend(req_settings.tools)
validation_result = validate_tool_calling(parsed_result, formatted_tools)
parsed_result["validation_result"] = validation_result
return parsed_result
def update_attempts(error: Exception, attempt_id: str, previous_attempts: list) -> str:
"""
Updates the plugin class attribute list of previous attempts with the current attempt and error message
(including duplicates).
Args:
error (Exception): The error object.
attempt_id (str): The ID of the current attempt.
previous_attempts (list): The list of previous attempts.
Returns:
str: A formatted (optimized for LLM performance) string of previous attempts, with duplicates removed.
"""
if isinstance(error, ValidationError):
error_str = "; ".join([e.get("msg") for e in error.errors()])
# replace "; Input should be 'Unanswered'" with " or input should be 'Unanswered'" for clarity
error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'")
else:
error_str = str(error)
new_failed_attempt = (attempt_id, error_str)
previous_attempts.append(new_failed_attempt)
# Format previous attempts to be more friendly for the LLM
attempts_list = []
unique_attempts = set(previous_attempts)
for attempt, error in unique_attempts:
attempts_list.append(f"Attempt: {attempt}\nError: {error}")
llm_formatted_attempts = "\n".join(attempts_list)
return previous_attempts, llm_formatted_attempts
@@ -0,0 +1,251 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
import logging
import math
import time
from pydantic import BaseModel
class ResourceConstraintUnit(Enum):
"""Choose the unit of the resource constraint.
Seconds and Minutes are real-time and will be impacted by the latency of the model."""
SECONDS = "seconds"
MINUTES = "minutes"
TURNS = "turns"
class ResourceConstraintMode(Enum):
"""Choose how the agent should use the resource.
Maximum: is an upper bound, i.e. the agent can end the conversation before the resource is exhausted
Exact: the agent should aim to use exactly the given amount of the resource"""
MAXIMUM = "maximum"
EXACT = "exact"
class ResourceConstraint(BaseModel):
"""A structured representation of the resource constraint for the GuidedConversation agent.
Args:
quantity (float | int): The quantity of the resource constraint.
unit (ResourceConstraintUnit): The unit of the resource constraint.
mode (ResourceConstraintMode): The mode of the resource constraint.
"""
quantity: float | int
unit: ResourceConstraintUnit
mode: ResourceConstraintMode
class Config:
arbitrary_types_allowed = True
def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str:
"""Get formatted string for a given quantity and unit (e.g. 1 second, 20 seconds)"""
if unit != ResourceConstraintUnit.TURNS:
quantity = round(quantity, 1)
unit = unit.value
return f"{quantity} {unit[:-1] if quantity == 1 else unit}"
class GCResource:
"""Resource constraints for the GuidedConversation agent. This class is used to keep track of the resource
constraints. If resource_constraint is None, then the agent can continue indefinitely. This also means
that no agenda will be created for the conversation.
Args:
resource_constraint (ResourceConstraint | None): The resource constraint for the conversation.
initial_seconds_per_turn (int): The initial number of seconds per turn. Defaults to 120 seconds.
"""
def __init__(
self,
resource_constraint: ResourceConstraint | None,
initial_seconds_per_turn: int = 120,
):
logger = logging.getLogger(__name__)
self.logger = logger
self.resource_constraint: ResourceConstraint | None = resource_constraint
self.initial_seconds_per_turn: int = initial_seconds_per_turn
self.turn_number: int = 0
self.remaining_units: float | None = None
self.elapsed_units: float | None = None
if resource_constraint is not None:
self.elapsed_units = 0
self.remaining_units = resource_constraint.quantity
def start_resource(self) -> None:
"""To be called at the start of a conversation turn"""
if self.resource_constraint is not None and (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
self.start_time = time.time()
def increment_resource(self) -> None:
"""Increment the resource counter by one turn."""
if self.resource_constraint is not None:
if self.resource_constraint.unit == ResourceConstraintUnit.SECONDS:
self.elapsed_units += time.time() - self.start_time
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
self.elapsed_units += (time.time() - self.start_time) / 60
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
self.elapsed_units += 1
self.remaining_units -= 1
self.turn_number += 1
def get_resource_mode(self) -> ResourceConstraintMode:
"""Get the mode of the resource constraint.
Returns:
ResourceConstraintMode | None: The mode of the resource constraint, or None if there is no
resource constraint.
"""
return self.resource_constraint.mode if self.resource_constraint is not None else None
def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of elapsed turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the elapsed turns.
If false, return an integer. Defaults to False.
Returns:
str | int: The description/number of elapsed turns.
"""
if formatted_repr:
return format_resource(self.turn_number, ResourceConstraintUnit.TURNS)
else:
return self.turn_number
def get_remaining_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of remaining turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the remaining turns.
Returns:
str | int: The description/number of remaining turns.
"""
if formatted_repr:
return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS)
else:
return self.estimate_remaining_turns()
def estimate_remaining_turns(self) -> int:
"""Estimate the remaining turns based on the resource constraint, thereby translating certain
resource units (e.g. seconds, minutes) into turns.
Returns:
int: The estimated number of remaining turns.
"""
if self.resource_constraint is not None:
if (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
elapsed_turns = self.turn_number
# TODO: This can likely be simplified
if self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
time_per_turn = (
self.initial_seconds_per_turn
if elapsed_turns == 0
else (self.elapsed_units * 60) / elapsed_turns
)
time_per_turn /= 60
else:
time_per_turn = (
self.initial_seconds_per_turn if elapsed_turns == 0 else self.elapsed_units / elapsed_turns
)
remaining_turns = self.remaining_units / time_per_turn
# Round down, unless it's less than 1, in which case round up
remaining_turns = math.ceil(remaining_turns) if remaining_turns < 1 else math.floor(remaining_turns)
return remaining_turns
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
return self.resource_constraint.quantity - self.turn_number
else:
self.logger.error(
"Resource constraint is not set, so turns cannot be estimated using function estimate_remaining_turns"
)
raise ValueError(
"Resource constraint is not set. Do not try to call this method without a resource constraint."
)
def get_resource_instructions(self) -> tuple[str, str]:
"""Get the resource instructions for the conversation.
Assumes we're always using turns as the resource unit.
Returns:
str: the resource instructions
"""
if self.resource_constraint is None:
return ""
formatted_elapsed_resource = format_resource(self.elapsed_units, ResourceConstraintUnit.TURNS)
formatted_remaining_resource = format_resource(self.remaining_units, ResourceConstraintUnit.TURNS)
# if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute")
is_plural_elapsed = self.elapsed_units != 1
is_plural_remaining = self.remaining_units != 1
if self.elapsed_units > 0:
resource_instructions = f"So far, {formatted_elapsed_resource} {'have' if is_plural_elapsed else 'has'} elapsed since the conversation began. "
else:
resource_instructions = ""
if self.resource_constraint.mode == ResourceConstraintMode.EXACT:
exact_mode_instructions = f"""There {"are" if is_plural_remaining else "is"} {formatted_remaining_resource} remaining (including this one) - the conversation will automatically terminate when 0 turns are left. \
You should continue the conversation until it is automatically terminated. This means you should NOT preemptively end the conversation, \
either explicitly (by selecting the "End conversation" action) or implicitly (e.g. by telling the user that you have all required information and they should wait for the next step). \
Your goal is not to maximize efficiency (i.e. complete the artifact as quickly as possible then end the conversation), but rather to make the best use of ALL remaining turns available to you"""
if is_plural_remaining:
resource_instructions += f"""{exact_mode_instructions}. This will require you to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier, \
or where you've rushed through the conversation and all fields are completed but there are still many turns left."""
# special instruction for the final turn (i.e. 1 remaining) in exact mode
else:
resource_instructions += f"""{exact_mode_instructions}, including this one. Therefore, you should use this turn to ask for any remaining information needed to complete the artifact, \
or, if the artifact is already completed, continue to broaden/deepen the discussion in a way that's directly relevant to the artifact. Do NOT indicate to the user that the conversation is ending."""
elif self.resource_constraint.mode == ResourceConstraintMode.MAXIMUM:
resource_instructions += f"""You have a maximum of {formatted_remaining_resource} (including this one) left to complete the conversation. \
You can decide to terminate the conversation at any point (including now), otherwise the conversation will automatically terminate when 0 turns are left. \
You will need to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier."""
else:
self.logger.error("Invalid resource mode provided.")
return resource_instructions
def to_json(self) -> dict:
return {
"turn_number": self.turn_number,
"remaining_units": self.remaining_units,
"elapsed_units": self.elapsed_units,
}
@classmethod
def from_json(
cls,
json_data: dict,
) -> "GCResource":
gc_resource = cls(
resource_constraint=None,
initial_seconds_per_turn=120,
)
gc_resource.turn_number = json_data["turn_number"]
gc_resource.remaining_units = json_data["remaining_units"]
gc_resource.elapsed_units = json_data["elapsed_units"]
return gc_resource