chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.core_plugins.conversation_summary_plugin import (
|
||||
ConversationSummaryPlugin,
|
||||
)
|
||||
from semantic_kernel.core_plugins.http_plugin import HttpPlugin
|
||||
from semantic_kernel.core_plugins.math_plugin import MathPlugin
|
||||
from semantic_kernel.core_plugins.sessions_python_tool.sessions_python_plugin import (
|
||||
SessionsPythonTool,
|
||||
)
|
||||
from semantic_kernel.core_plugins.text_memory_plugin import TextMemoryPlugin
|
||||
from semantic_kernel.core_plugins.text_plugin import TextPlugin
|
||||
from semantic_kernel.core_plugins.time_plugin import TimePlugin
|
||||
from semantic_kernel.core_plugins.web_search_engine_plugin import WebSearchEnginePlugin
|
||||
|
||||
__all__ = [
|
||||
"ConversationSummaryPlugin",
|
||||
"HttpPlugin",
|
||||
"MathPlugin",
|
||||
"SessionsPythonTool",
|
||||
"TextMemoryPlugin",
|
||||
"TextPlugin",
|
||||
"TimePlugin",
|
||||
"WebSearchEnginePlugin",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationSummaryPlugin:
|
||||
"""Semantic plugin that enables conversations summarization."""
|
||||
|
||||
# The max tokens to process in a single semantic function call.
|
||||
_max_tokens = 1024
|
||||
|
||||
_summarize_conversation_prompt_template = (
|
||||
"BEGIN CONTENT TO SUMMARIZE:\n{{"
|
||||
"$input"
|
||||
"}}\nEND CONTENT TO SUMMARIZE.\nSummarize the conversation in 'CONTENT TO"
|
||||
" SUMMARIZE', identifying main points of discussion and any"
|
||||
" conclusions that were reached.\nDo not incorporate other general"
|
||||
" knowledge.\nSummary is in plain text, in complete sentences, with no markup"
|
||||
" or tags.\n\nBEGIN SUMMARY:\n"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, prompt_template_config: "PromptTemplateConfig", return_key: str = "summary", **kwargs: Any
|
||||
) -> None:
|
||||
"""Initializes a new instance of the ConversationSummaryPlugin.
|
||||
|
||||
The template for this plugin is built-in, and will overwrite any template passed in the prompt_template_config.
|
||||
|
||||
Args:
|
||||
prompt_template_config (PromptTemplateConfig): The prompt template configuration.
|
||||
return_key (str): The key to use for the return value.
|
||||
**kwargs: Additional keyword arguments, not used only for compatibility.
|
||||
|
||||
"""
|
||||
if "kernel" in kwargs:
|
||||
logger.warning(
|
||||
"The kernel parameter is not used in the ConversationSummaryPlugin constructor anymore."
|
||||
"Please make sure to remove and to add the created plugin to the kernel, by using:"
|
||||
"kernel.add_plugin(conversation_plugin, 'summarizer')"
|
||||
)
|
||||
|
||||
self.return_key = return_key
|
||||
prompt_template_config.template = ConversationSummaryPlugin._summarize_conversation_prompt_template
|
||||
prompt_template_config.template_format = "semantic-kernel"
|
||||
self._summarizeConversationFunction = KernelFunctionFromPrompt(
|
||||
plugin_name=ConversationSummaryPlugin.__name__,
|
||||
function_name="SummarizeConversation",
|
||||
prompt_template_config=prompt_template_config,
|
||||
)
|
||||
|
||||
@kernel_function(
|
||||
description="Given a long conversation transcript, summarize the conversation.",
|
||||
name="SummarizeConversation",
|
||||
)
|
||||
async def summarize_conversation(
|
||||
self,
|
||||
input: Annotated[str, "A long conversation transcript."],
|
||||
kernel: Annotated["Kernel", "The kernel instance."],
|
||||
arguments: Annotated["KernelArguments", "Arguments used by the kernel."],
|
||||
) -> Annotated[
|
||||
"KernelArguments", "KernelArguments with the summarized conversation result in key self.return_key."
|
||||
]:
|
||||
"""Given a long conversation transcript, summarize the conversation.
|
||||
|
||||
Args:
|
||||
input (str): A long conversation transcript.
|
||||
kernel (Kernel): The kernel for function execution.
|
||||
arguments (KernelArguments): Arguments used by the kernel.
|
||||
|
||||
Returns:
|
||||
KernelArguments with the summarized conversation result in key self.return_key.
|
||||
"""
|
||||
from semantic_kernel.text import text_chunker
|
||||
from semantic_kernel.text.function_extension import aggregate_chunked_results
|
||||
|
||||
lines = text_chunker._split_text_lines(input, ConversationSummaryPlugin._max_tokens, True)
|
||||
paragraphs = text_chunker._split_text_paragraph(lines, ConversationSummaryPlugin._max_tokens)
|
||||
|
||||
arguments[self.return_key] = await aggregate_chunked_results(
|
||||
self._summarizeConversationFunction, paragraphs, kernel, arguments
|
||||
)
|
||||
return arguments
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_enterprise import CrewAIEnterprise
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_models import (
|
||||
CrewAIStatusResponse,
|
||||
)
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_settings import (
|
||||
CrewAISettings,
|
||||
)
|
||||
|
||||
__all__ = ["CrewAIEnterprise", "CrewAISettings", "CrewAIStatusResponse"]
|
||||
@@ -0,0 +1,261 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_enterprise_client import CrewAIEnterpriseClient
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_models import CrewAIEnterpriseKickoffState, CrewAIStatusResponse
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_settings import CrewAISettings
|
||||
from semantic_kernel.exceptions.function_exceptions import (
|
||||
FunctionExecutionException,
|
||||
FunctionResultError,
|
||||
PluginInitializationError,
|
||||
)
|
||||
from semantic_kernel.functions import kernel_function
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@experimental
|
||||
class CrewAIEnterprise(KernelBaseModel):
|
||||
"""Class to interface with Crew.AI Crews from Semantic Kernel.
|
||||
|
||||
This object can be used directly or as a plugin in the Kernel.
|
||||
"""
|
||||
|
||||
client: CrewAIEnterpriseClient
|
||||
polling_interval: float = Field(default=1.0)
|
||||
polling_timeout: float = Field(default=30.0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str | None = None,
|
||||
auth_token: str | None = None,
|
||||
polling_interval: float | None = 1.0,
|
||||
polling_timeout: float | None = 30.0,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
):
|
||||
"""Initialize a new instance of the class. This object can be used directly or as a plugin in the Kernel.
|
||||
|
||||
Args:
|
||||
endpoint (str | None, optional): The API endpoint.
|
||||
auth_token (str | None, optional): The authentication token.
|
||||
polling_interval (float, optional): The polling interval in seconds. Defaults to 1.0.
|
||||
polling_timeout (float, optional): The polling timeout in seconds. Defaults to 30.0.
|
||||
session (aiohttp.ClientSession | None, optional): The HTTP client session. Defaults to None.
|
||||
env_file_path (str | None): Use the environment settings file as a
|
||||
fallback to environment variables. (Optional)
|
||||
env_file_encoding (str | None): The encoding of the environment settings file. (Optional)
|
||||
"""
|
||||
try:
|
||||
settings = CrewAISettings(
|
||||
endpoint=endpoint,
|
||||
auth_token=auth_token,
|
||||
polling_interval=polling_interval,
|
||||
polling_timeout=polling_timeout,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
except ValidationError as ex:
|
||||
raise PluginInitializationError("Failed to initialize CrewAI settings.") from ex
|
||||
|
||||
client = CrewAIEnterpriseClient(
|
||||
endpoint=settings.endpoint, auth_token=settings.auth_token.get_secret_value(), session=session
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
client=client,
|
||||
polling_interval=settings.polling_interval,
|
||||
polling_timeout=settings.polling_timeout,
|
||||
)
|
||||
|
||||
async def kickoff(
|
||||
self,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
task_webhook_url: str | None = None,
|
||||
step_webhook_url: str | None = None,
|
||||
crew_webhook_url: str | None = None,
|
||||
) -> str:
|
||||
"""Kickoff a new Crew AI task.
|
||||
|
||||
Args:
|
||||
inputs (dict[str, Any], optional): The inputs for the task. Defaults to None.
|
||||
task_webhook_url (str | None, optional): The webhook URL for task updates. Defaults to None.
|
||||
step_webhook_url (str | None, optional): The webhook URL for step updates. Defaults to None.
|
||||
crew_webhook_url (str | None, optional): The webhook URL for crew updates. Defaults to None.
|
||||
|
||||
Returns:
|
||||
str: The ID of the kickoff response.
|
||||
"""
|
||||
try:
|
||||
kickoff_response = await self.client.kickoff(inputs, task_webhook_url, step_webhook_url, crew_webhook_url)
|
||||
logger.info(f"CrewAI Crew kicked off with Id: {kickoff_response.kickoff_id}")
|
||||
return kickoff_response.kickoff_id
|
||||
except Exception as ex:
|
||||
raise FunctionExecutionException("Failed to kickoff CrewAI Crew.") from ex
|
||||
|
||||
@kernel_function(description="Get the status of a Crew AI kickoff.")
|
||||
async def get_crew_kickoff_status(self, kickoff_id: str) -> CrewAIStatusResponse:
|
||||
"""Get the status of a Crew AI task.
|
||||
|
||||
Args:
|
||||
kickoff_id (str): The ID of the kickoff response.
|
||||
|
||||
Returns:
|
||||
CrewAIStatusResponse: The status response of the task.
|
||||
"""
|
||||
try:
|
||||
status_response = await self.client.get_status(kickoff_id)
|
||||
logger.info(f"CrewAI Crew status for kickoff Id: {kickoff_id} is {status_response.state}")
|
||||
return status_response
|
||||
except Exception as ex:
|
||||
raise FunctionExecutionException(
|
||||
f"Failed to get status of CrewAI Crew with kickoff Id: {kickoff_id}."
|
||||
) from ex
|
||||
|
||||
@kernel_function(description="Wait for the completion of a Crew AI kickoff.")
|
||||
async def wait_for_crew_completion(self, kickoff_id: str) -> str:
|
||||
"""Wait for the completion of a Crew AI task.
|
||||
|
||||
Args:
|
||||
kickoff_id (str): The ID of the kickoff response.
|
||||
|
||||
Returns:
|
||||
str: The result of the task.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If the task fails or an error occurs while waiting for completion.
|
||||
"""
|
||||
status_response: CrewAIStatusResponse | None = None
|
||||
state: str = CrewAIEnterpriseKickoffState.Pending
|
||||
|
||||
async def poll_status():
|
||||
nonlocal state, status_response
|
||||
while state not in [
|
||||
CrewAIEnterpriseKickoffState.Failed,
|
||||
CrewAIEnterpriseKickoffState.Failure,
|
||||
CrewAIEnterpriseKickoffState.Success,
|
||||
CrewAIEnterpriseKickoffState.Not_Found,
|
||||
]:
|
||||
logger.debug(
|
||||
f"Waiting for CrewAI Crew with kickoff Id: {kickoff_id} to complete. Current state: {state}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(self.polling_interval)
|
||||
|
||||
try:
|
||||
status_response = await self.client.get_status(kickoff_id)
|
||||
state = status_response.state
|
||||
except Exception as ex:
|
||||
raise FunctionExecutionException(
|
||||
f"Failed to wait for completion of CrewAI Crew with kickoff Id: {kickoff_id}."
|
||||
) from ex
|
||||
|
||||
await asyncio.wait_for(poll_status(), timeout=self.polling_timeout)
|
||||
|
||||
logger.info(f"CrewAI Crew with kickoff Id: {kickoff_id} completed with status: {state}")
|
||||
result = status_response.result if status_response is not None and status_response.result is not None else ""
|
||||
|
||||
if state in ["Failed", "Failure"]:
|
||||
raise FunctionResultError(f"CrewAI Crew failed with error: {result}")
|
||||
|
||||
return result
|
||||
|
||||
def create_kernel_plugin(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: list[KernelParameterMetadata] | None = None,
|
||||
task_webhook_url: str | None = None,
|
||||
step_webhook_url: str | None = None,
|
||||
crew_webhook_url: str | None = None,
|
||||
) -> KernelPlugin:
|
||||
"""Creates a kernel plugin that can be used to invoke the CrewAI Crew.
|
||||
|
||||
Args:
|
||||
name (str): The name of the kernel plugin.
|
||||
description (str): The description of the kernel plugin.
|
||||
parameters (List[KernelParameterMetadata] | None, optional): The definitions of the Crew's
|
||||
required inputs. Defaults to None.
|
||||
task_webhook_url (Optional[str], optional): The task level webhook URL. Defaults to None.
|
||||
step_webhook_url (Optional[str], optional): The step level webhook URL. Defaults to None.
|
||||
crew_webhook_url (Optional[str], optional): The crew level webhook URL. Defaults to None.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: A dictionary representing the kernel plugin.
|
||||
"""
|
||||
|
||||
@kernel_function(description="Kickoff the CrewAI task.")
|
||||
async def kickoff(**kwargs: Any) -> str:
|
||||
args = self._build_arguments(parameters, kwargs)
|
||||
return await self.kickoff(
|
||||
inputs=args,
|
||||
task_webhook_url=task_webhook_url,
|
||||
step_webhook_url=step_webhook_url,
|
||||
crew_webhook_url=crew_webhook_url,
|
||||
)
|
||||
|
||||
@kernel_function(description="Kickoff the CrewAI task and wait for completion.")
|
||||
async def kickoff_and_wait(**kwargs: Any) -> str:
|
||||
args = self._build_arguments(parameters, kwargs)
|
||||
kickoff_id = await self.kickoff(
|
||||
inputs=args,
|
||||
task_webhook_url=task_webhook_url,
|
||||
step_webhook_url=step_webhook_url,
|
||||
crew_webhook_url=crew_webhook_url,
|
||||
)
|
||||
return await self.wait_for_crew_completion(kickoff_id)
|
||||
|
||||
return KernelPlugin(
|
||||
name,
|
||||
description,
|
||||
{
|
||||
"kickoff": KernelFunctionFromMethod(kickoff, stream_method=None, parameters=parameters),
|
||||
"kickoff_and_wait": KernelFunctionFromMethod(
|
||||
kickoff_and_wait, stream_method=None, parameters=parameters
|
||||
),
|
||||
"get_status": self.get_crew_kickoff_status,
|
||||
"wait_for_completion": self.wait_for_crew_completion,
|
||||
},
|
||||
)
|
||||
|
||||
def _build_arguments(
|
||||
self, parameters: list[KernelParameterMetadata] | None, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Builds the arguments for the CrewAI task from the provided parameters and arguments.
|
||||
|
||||
Args:
|
||||
parameters (List[KernelParameterMetadata] | None): The metadata for the inputs.
|
||||
arguments (dict[str, Any]): The provided arguments.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The built arguments.
|
||||
"""
|
||||
args = {}
|
||||
if parameters:
|
||||
for input in parameters:
|
||||
name = input.name
|
||||
if name not in arguments:
|
||||
raise PluginInitializationError(f"Missing required input '{name}' for CrewAI.")
|
||||
args[name] = arguments[name]
|
||||
return args
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Enter the session."""
|
||||
await self.client.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args, **kwargs):
|
||||
"""Close the session."""
|
||||
await self.client.__aexit__()
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from semantic_kernel.core_plugins.crew_ai.crew_ai_models import (
|
||||
CrewAIKickoffResponse,
|
||||
CrewAIRequiredInputs,
|
||||
CrewAIStatusResponse,
|
||||
)
|
||||
from semantic_kernel.utils.telemetry.user_agent import SEMANTIC_KERNEL_USER_AGENT
|
||||
|
||||
|
||||
class CrewAIEnterpriseClient:
|
||||
"""Client to interact with the Crew AI Enterprise API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
auth_token: str,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
):
|
||||
"""Initializes a new instance of the CrewAIEnterpriseClient class.
|
||||
|
||||
Args:
|
||||
endpoint (str): The API endpoint.
|
||||
auth_token (str): The authentication token.
|
||||
session (aiohttp.ClientSession | None, optional): The HTTP client session. Defaults to None.
|
||||
"""
|
||||
self.endpoint = endpoint
|
||||
self.auth_token = auth_token
|
||||
self.session = session if session is None else aiohttp.ClientSession()
|
||||
self.request_header = {
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
"user_agent": SEMANTIC_KERNEL_USER_AGENT,
|
||||
}
|
||||
|
||||
async def get_inputs(self) -> CrewAIRequiredInputs:
|
||||
"""Get the required inputs for Crew AI.
|
||||
|
||||
Returns:
|
||||
CrewAIRequiredInputs: The required inputs for Crew AI.
|
||||
"""
|
||||
async with (
|
||||
self.session.get(f"{self.endpoint}/inputs", headers=self.request_header) as response, # type: ignore
|
||||
):
|
||||
response.raise_for_status()
|
||||
return CrewAIRequiredInputs.model_validate_json(await response.text())
|
||||
|
||||
async def kickoff(
|
||||
self,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
task_webhook_url: str | None = None,
|
||||
step_webhook_url: str | None = None,
|
||||
crew_webhook_url: str | None = None,
|
||||
) -> CrewAIKickoffResponse:
|
||||
"""Kickoff a new Crew AI task.
|
||||
|
||||
Args:
|
||||
inputs (Optional[dict[str, Any]], optional): The inputs for the task. Defaults to None.
|
||||
task_webhook_url (Optional[str], optional): The webhook URL for task updates. Defaults to None.
|
||||
step_webhook_url (Optional[str], optional): The webhook URL for step updates. Defaults to None.
|
||||
crew_webhook_url (Optional[str], optional): The webhook URL for crew updates. Defaults to None.
|
||||
|
||||
Returns:
|
||||
CrewAIKickoffResponse: The response from the kickoff request.
|
||||
"""
|
||||
content = {
|
||||
"inputs": inputs,
|
||||
"taskWebhookUrl": task_webhook_url,
|
||||
"stepWebhookUrl": step_webhook_url,
|
||||
"crewWebhookUrl": crew_webhook_url,
|
||||
}
|
||||
async with (
|
||||
self.session.post(f"{self.endpoint}/kickoff", json=content, headers=self.request_header) as response, # type: ignore
|
||||
):
|
||||
response.raise_for_status()
|
||||
body = await response.text()
|
||||
return CrewAIKickoffResponse.model_validate_json(body)
|
||||
|
||||
async def get_status(self, task_id: str) -> CrewAIStatusResponse:
|
||||
"""Get the status of a Crew AI task.
|
||||
|
||||
Args:
|
||||
task_id (str): The ID of the task.
|
||||
|
||||
Returns:
|
||||
CrewAIStatusResponse: The status response of the task.
|
||||
"""
|
||||
async with (
|
||||
self.session.get(f"{self.endpoint}/status/{task_id}", headers=self.request_header) as response, # type: ignore
|
||||
):
|
||||
response.raise_for_status()
|
||||
body = await response.text()
|
||||
return CrewAIStatusResponse.model_validate_json(body)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Enter the session."""
|
||||
await self.session.__aenter__() # type: ignore
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args, **kwargs):
|
||||
"""Close the session."""
|
||||
await self.session.close() # type: ignore
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class CrewAIEnterpriseKickoffState(str, Enum):
|
||||
"""The Crew.AI Enterprise kickoff state."""
|
||||
|
||||
Pending = "PENDING"
|
||||
Started = "STARTED"
|
||||
Running = "RUNNING"
|
||||
Success = "SUCCESS"
|
||||
Failed = "FAILED"
|
||||
Failure = "FAILURE"
|
||||
Not_Found = "NOT FOUND"
|
||||
|
||||
|
||||
class CrewAIStatusResponse(KernelBaseModel):
|
||||
"""Represents the status response from Crew AI."""
|
||||
|
||||
state: CrewAIEnterpriseKickoffState
|
||||
result: str | None = None
|
||||
last_step: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CrewAIKickoffResponse(KernelBaseModel):
|
||||
"""Represents the kickoff response from Crew AI."""
|
||||
|
||||
kickoff_id: str
|
||||
|
||||
|
||||
class CrewAIRequiredInputs(KernelBaseModel):
|
||||
"""Represents the required inputs for Crew AI."""
|
||||
|
||||
inputs: dict[str, str]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
|
||||
|
||||
class CrewAISettings(KernelBaseSettings):
|
||||
"""The Crew.AI settings.
|
||||
|
||||
Required:
|
||||
- endpoint: str - The API endpoint.
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "CREW_AI_"
|
||||
|
||||
endpoint: str
|
||||
auth_token: SecretStr
|
||||
polling_interval: float = 1.0
|
||||
polling_timeout: float = 30.0
|
||||
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from typing import Annotated, Any, ClassVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class HttpPlugin(KernelBaseModel):
|
||||
"""A plugin that provides HTTP functionality.
|
||||
|
||||
Usage:
|
||||
# With allowed domains (recommended):
|
||||
kernel.add_plugin(HttpPlugin(allowed_domains=["example.com", "api.example.com"]), "http")
|
||||
|
||||
# Explicitly allow all domains (opt-in, less secure):
|
||||
kernel.add_plugin(HttpPlugin(allow_all_domains=True), "http")
|
||||
|
||||
Examples:
|
||||
{{http.getAsync $url}}
|
||||
{{http.postAsync $url}}
|
||||
{{http.putAsync $url}}
|
||||
{{http.deleteAsync $url}}
|
||||
|
||||
Security:
|
||||
- By default, all requests are blocked unless ``allowed_domains`` is provided
|
||||
or ``allow_all_domains`` is set to True.
|
||||
- When ``allowed_domains`` is set and ``allow_all_domains`` is False, HTTP
|
||||
redirects are disabled to prevent redirect-based domain bypass (SSRF).
|
||||
- When ``allow_all_domains`` is True, redirects are allowed regardless of
|
||||
whether ``allowed_domains`` is also set.
|
||||
- Only ``http`` and ``https`` URL schemes are permitted.
|
||||
- Only standard ports (80, 443) are permitted by default. Set ``allowed_ports``
|
||||
to permit additional ports. Port validation is skipped when
|
||||
``allow_all_domains`` is True.
|
||||
"""
|
||||
|
||||
allowed_domains: set[str] | None = None
|
||||
"""Set of allowed domains to send requests to."""
|
||||
|
||||
allow_all_domains: bool = False
|
||||
"""When True, requests to any domain are allowed. Must be explicitly set."""
|
||||
|
||||
allowed_ports: set[int] | None = None
|
||||
"""Set of ports permitted for outbound requests. Defaults to ``{80, 443}`` when not set.
|
||||
|
||||
Ignored when ``allow_all_domains`` is True. Set explicitly to permit non-standard ports
|
||||
(e.g. ``allowed_ports={443, 8443}``).
|
||||
"""
|
||||
|
||||
_ALLOWED_SCHEMES: ClassVar[frozenset[str]] = frozenset({"http", "https"})
|
||||
_DEFAULT_SCHEME_PORTS: ClassVar[dict[str, int]] = {"http": 80, "https": 443}
|
||||
_DEFAULT_ALLOWED_PORTS: ClassVar[frozenset[int]] = frozenset({80, 443})
|
||||
|
||||
@property
|
||||
def _allow_redirects(self) -> bool:
|
||||
"""Whether HTTP redirects should be followed.
|
||||
|
||||
Redirects are only allowed when ``allow_all_domains`` is True.
|
||||
When domain restrictions are configured, redirects are disabled
|
||||
to prevent redirect-based SSRF bypass.
|
||||
"""
|
||||
return self.allow_all_domains
|
||||
|
||||
def _is_uri_allowed(self, url: str) -> bool:
|
||||
"""Check if the URL's host and scheme are permitted.
|
||||
|
||||
Args:
|
||||
url: The URL to check.
|
||||
|
||||
Returns:
|
||||
True if the URL is allowed, False otherwise.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Validate scheme
|
||||
if parsed.scheme.lower() not in self._ALLOWED_SCHEMES:
|
||||
return False
|
||||
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
return False
|
||||
|
||||
# Validate that the port component is syntactically valid, regardless of
|
||||
# allow_all_domains. Accessing parsed.port raises ValueError for a malformed
|
||||
# or out-of-range port.
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# If allow_all_domains is set, skip the domain and port allow-list checks.
|
||||
if self.allow_all_domains:
|
||||
return True
|
||||
|
||||
# Enforce the port allow-list (deny-by-default to non-standard ports).
|
||||
if port is None:
|
||||
port = self._DEFAULT_SCHEME_PORTS.get(parsed.scheme.lower())
|
||||
allowed_ports = self.allowed_ports if self.allowed_ports is not None else self._DEFAULT_ALLOWED_PORTS
|
||||
if port not in allowed_ports:
|
||||
return False
|
||||
|
||||
# If allowed_domains is set, check against it
|
||||
if self.allowed_domains is not None:
|
||||
return host.lower() in {domain.lower() for domain in self.allowed_domains}
|
||||
|
||||
# Default: deny all
|
||||
return False
|
||||
|
||||
def _validate_url(self, url: str) -> None:
|
||||
"""Validate the URL before sending a request.
|
||||
|
||||
Always checks that the URL is non-empty, uses an allowed scheme, and has a
|
||||
syntactically valid port. When ``allow_all_domains`` is False, additionally
|
||||
enforces the port and domain allow-lists.
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If the URL is empty, uses a disallowed scheme,
|
||||
has a malformed port, or (unless ``allow_all_domains`` is True) targets
|
||||
a port or domain that is not allowed.
|
||||
"""
|
||||
if not url:
|
||||
raise FunctionExecutionException("url cannot be `None` or empty")
|
||||
|
||||
if not self._is_uri_allowed(url):
|
||||
raise FunctionExecutionException("Sending requests to the provided location is not allowed.")
|
||||
|
||||
@kernel_function(description="Makes a GET request to a url", name="getAsync")
|
||||
async def get(self, url: Annotated[str, "The URL to send the request to."]) -> str:
|
||||
"""Sends an HTTP GET request to the specified URI and returns the response body as a string.
|
||||
|
||||
Args:
|
||||
url: The URL to send the request to.
|
||||
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
):
|
||||
return await response.text()
|
||||
|
||||
@kernel_function(description="Makes a POST request to a uri", name="postAsync")
|
||||
async def post(
|
||||
self,
|
||||
url: Annotated[str, "The URI to send the request to."],
|
||||
body: Annotated[dict[str, Any] | None, "The body of the request"] = None,
|
||||
) -> str:
|
||||
"""Sends an HTTP POST request to the specified URI and returns the response body as a string.
|
||||
|
||||
Args:
|
||||
url: The URI to send the request to.
|
||||
body: Contains the body of the request
|
||||
returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
data = json.dumps(body) if body is not None else None
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.post(
|
||||
url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects
|
||||
) as response,
|
||||
):
|
||||
return await response.text()
|
||||
|
||||
@kernel_function(description="Makes a PUT request to a uri", name="putAsync")
|
||||
async def put(
|
||||
self,
|
||||
url: Annotated[str, "The URI to send the request to."],
|
||||
body: Annotated[dict[str, Any] | None, "The body of the request"] = None,
|
||||
) -> str:
|
||||
"""Sends an HTTP PUT request to the specified URI and returns the response body as a string.
|
||||
|
||||
Args:
|
||||
url: The URI to send the request to.
|
||||
body: Contains the body of the request
|
||||
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
data = json.dumps(body) if body is not None else None
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.put(
|
||||
url, headers=headers, data=data, raise_for_status=True, allow_redirects=self._allow_redirects
|
||||
) as response,
|
||||
):
|
||||
return await response.text()
|
||||
|
||||
@kernel_function(description="Makes a DELETE request to a uri", name="deleteAsync")
|
||||
async def delete(self, url: Annotated[str, "The URI to send the request to."]) -> str:
|
||||
"""Sends an HTTP DELETE request to the specified URI and returns the response body as a string.
|
||||
|
||||
Args:
|
||||
url: The URI to send the request to.
|
||||
|
||||
Returns:
|
||||
The response body as a string.
|
||||
"""
|
||||
self._validate_url(url)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.delete(url, raise_for_status=True, allow_redirects=self._allow_redirects) as response,
|
||||
):
|
||||
return await response.text()
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
|
||||
class MathPlugin:
|
||||
"""Description: MathPlugin provides a set of functions to make Math calculations.
|
||||
|
||||
Usage:
|
||||
kernel.add_plugin(MathPlugin(), plugin_name="math")
|
||||
|
||||
Examples:
|
||||
{{math.Add}} => Returns the sum of input and amount (provided in the KernelArguments)
|
||||
{{math.Subtract}} => Returns the difference of input and amount (provided in the KernelArguments)
|
||||
"""
|
||||
|
||||
def _parse_number(self, val: int | float | str) -> float:
|
||||
"""Helper to parse a value as a float (supports int, float, str)."""
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
try:
|
||||
return float(val)
|
||||
except Exception as ex:
|
||||
raise ValueError(f"Cannot convert {val!r} to float for math operation") from ex
|
||||
|
||||
@kernel_function(name="Add")
|
||||
def add(
|
||||
self,
|
||||
input: Annotated[int | float | str, "The first number to add"],
|
||||
amount: Annotated[int | float | str, "The second number to add"],
|
||||
) -> Annotated[float, "The result"]:
|
||||
"""Returns the addition result of the values provided (supports float and int)."""
|
||||
x = self._parse_number(input)
|
||||
y = self._parse_number(amount)
|
||||
return x + y
|
||||
|
||||
@kernel_function(name="Subtract")
|
||||
def subtract(
|
||||
self,
|
||||
input: Annotated[int | float | str, "The number to subtract from"],
|
||||
amount: Annotated[int | float | str, "The number to subtract"],
|
||||
) -> Annotated[float, "The result"]:
|
||||
"""Returns the difference of numbers provided (supports float and int)."""
|
||||
x = self._parse_number(input)
|
||||
y = self._parse_number(amount)
|
||||
return x - y
|
||||
@@ -0,0 +1,71 @@
|
||||
# Getting Started with the Sessions Python Plugin
|
||||
|
||||
## Setup
|
||||
|
||||
Please follow the [Azure Container Apps Documentation](https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter) to get started.
|
||||
|
||||
## Configuring the Python Plugin
|
||||
|
||||
Next, as an environment variable or in the .env file, add the `poolManagementEndpoint` value from above to the variable `ACA_POOL_MANAGEMENT_ENDPOINT`. The `poolManagementEndpoint` should look something like:
|
||||
|
||||
```html
|
||||
https://eastus.acasessions.io/subscriptions/{{subscriptionId}}/resourceGroups/{{resourceGroup}}/sessionPools/{{sessionPool}}/python/execute
|
||||
```
|
||||
|
||||
You can also provide the the `ACA_TOKEN_ENDPOINT` if you want to override the default value of `https://acasessions.io/.default`. If this token endpoint doesn't need to be overridden, then it is
|
||||
not necessary to include this as an environment variable, in the .env file, or via the plugin's constructor. Please follow the [Azure Container Apps Documentation](https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter) to review the proper role required to authenticate with the `AsyncTokenCredential`.
|
||||
|
||||
Next, let's move on to implementing the plugin in code. It is possible to add the code interpreter plugin as follows:
|
||||
|
||||
```python
|
||||
kernel = Kernel()
|
||||
|
||||
service_id = "azure_oai"
|
||||
chat_service = AzureChatCompletion(
|
||||
service_id=service_id, **azure_openai_settings_from_dot_env_as_dict(include_api_version=True)
|
||||
)
|
||||
kernel.add_service(chat_service)
|
||||
|
||||
python_code_interpreter = SessionsPythonTool(
|
||||
auth_callback=auth_callback
|
||||
)
|
||||
|
||||
sessions_tool = kernel.add_plugin(python_code_interpreter, "PythonCodeInterpreter")
|
||||
|
||||
code = "import json\n\ndef add_numbers(a, b):\n return a + b\n\nargs = '{\"a\": 1, \"b\": 1}'\nargs_dict = json.loads(args)\nprint(add_numbers(args_dict['a'], args_dict['b']))"
|
||||
result = await kernel.invoke(sessions_tool["execute_code"], code=code)
|
||||
|
||||
print(result)
|
||||
```
|
||||
|
||||
Instead of hard-coding a well-formatted Python code string, you may use automatic function calling inside of SK and allow the model to form the Python and call the plugin.
|
||||
|
||||
The authentication callback must return a valid token for the session pool. One possible way of doing this with a `AzureCliCredential` is as follows:
|
||||
|
||||
```python
|
||||
async def auth_callback() -> str:
|
||||
"""Auth callback for the SessionsPythonTool.
|
||||
This is a sample auth callback that shows how to use Azure's AzureCliCredential
|
||||
to get an access token.
|
||||
"""
|
||||
global auth_token
|
||||
current_utc_timestamp = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||
|
||||
if not auth_token or auth_token.expires_on < current_utc_timestamp:
|
||||
credential = AzureCliCredential()
|
||||
|
||||
try:
|
||||
auth_token = credential.get_token(ACA_TOKEN_ENDPOINT)
|
||||
except ClientAuthenticationError as cae:
|
||||
err_messages = getattr(cae, "messages", [])
|
||||
raise FunctionExecutionException(
|
||||
f"Failed to retrieve the client auth token with messages: {' '.join(err_messages)}"
|
||||
) from cae
|
||||
|
||||
return auth_token.token
|
||||
```
|
||||
|
||||
Currently, there are two concept examples that show this plugin in more detail:
|
||||
|
||||
- [Plugin example](../../../samples/concepts/plugins/azure_python_code_interpreter.py): shows the basic usage of calling the code execute function on the plugin.
|
||||
- [Function Calling example](../../../samples/concepts/auto_function_calling/azure_python_code_interpreter_function_calling.py): shows a simple chat application that leverages the Python code interpreter plugin for function calling.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.core_plugins.sessions_python_tool.sessions_python_plugin import (
|
||||
SessionsPythonTool,
|
||||
)
|
||||
from semantic_kernel.core_plugins.sessions_python_tool.sessions_python_settings import (
|
||||
SessionsPythonSettings,
|
||||
)
|
||||
|
||||
__all__ = ["SessionsPythonSettings", "SessionsPythonTool"]
|
||||
@@ -0,0 +1,467 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from io import BytesIO
|
||||
from typing import Annotated, Any
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from httpx import AsyncClient, HTTPStatusError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.const import USER_AGENT
|
||||
from semantic_kernel.core_plugins.sessions_python_tool.sessions_python_settings import (
|
||||
ACASessionsSettings,
|
||||
SessionsPythonSettings,
|
||||
)
|
||||
from semantic_kernel.core_plugins.sessions_python_tool.sessions_remote_file_metadata import SessionsRemoteFileMetadata
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException, FunctionInitializationError
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import HttpsUrl, KernelBaseModel
|
||||
from semantic_kernel.utils.telemetry.user_agent import HTTP_USER_AGENT, version_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SESSIONS_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info} (Language=Python)"
|
||||
|
||||
SESSIONS_API_VERSION = "2024-02-02-preview"
|
||||
|
||||
|
||||
class SessionsPythonTool(KernelBaseModel):
|
||||
"""A plugin for running Python code in an Azure Container Apps dynamic sessions code interpreter."""
|
||||
|
||||
pool_management_endpoint: HttpsUrl
|
||||
settings: SessionsPythonSettings
|
||||
auth_callback: Callable[..., Any | Awaitable[Any]]
|
||||
http_client: AsyncClient
|
||||
enable_dangerous_file_uploads: bool = False
|
||||
"""Flag to enable file upload operations. Must be True along with allowed_upload_directories to enable uploads."""
|
||||
allowed_upload_directories: set[str] | None = None
|
||||
"""Allowed local directories for file uploads. If None, upload_file is disabled (deny-by-default)."""
|
||||
allowed_download_directories: set[str] | None = None
|
||||
"""Allowed local directories for file downloads. If None, all paths are allowed (permissive-by-default)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth_callback: Callable[..., Any | Awaitable[Any]] | None = None,
|
||||
pool_management_endpoint: str | None = None,
|
||||
settings: SessionsPythonSettings | None = None,
|
||||
http_client: AsyncClient | None = None,
|
||||
env_file_path: str | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
enable_dangerous_file_uploads: bool = False,
|
||||
allowed_upload_directories: set[str] | list[str] | None = None,
|
||||
allowed_download_directories: set[str] | list[str] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initializes a new instance of the SessionsPythonTool class.
|
||||
|
||||
Args:
|
||||
auth_callback: Callback to retrieve authentication token.
|
||||
pool_management_endpoint: The ACA pool management endpoint URL.
|
||||
settings: Python session settings.
|
||||
http_client: HTTP client for making requests.
|
||||
env_file_path: Path to .env file.
|
||||
token_endpoint: Token endpoint for authentication.
|
||||
credential: Azure credential for authentication.
|
||||
enable_dangerous_file_uploads: Flag to enable file upload operations.
|
||||
Must be True along with allowed_upload_directories to enable file uploads.
|
||||
Default is False (file uploads disabled).
|
||||
allowed_upload_directories: Set or list of allowed directories for file uploads.
|
||||
If None, upload_file will be disabled (deny-by-default).
|
||||
Empty set/list means no directories are allowed (all uploads denied).
|
||||
allowed_download_directories: Set or list of allowed directories for file downloads.
|
||||
If None, all paths are allowed (permissive-by-default).
|
||||
If configured, downloads are restricted to these directories.
|
||||
kwargs: Additional keyword arguments.
|
||||
"""
|
||||
try:
|
||||
aca_settings = ACASessionsSettings(
|
||||
env_file_path=env_file_path,
|
||||
pool_management_endpoint=pool_management_endpoint,
|
||||
token_endpoint=token_endpoint,
|
||||
)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Failed to load the ACASessionsSettings with message: {e!s}")
|
||||
raise FunctionInitializationError(f"Failed to load the ACASessionsSettings with message: {e!s}") from e
|
||||
|
||||
if not settings:
|
||||
settings = SessionsPythonSettings()
|
||||
|
||||
if not http_client:
|
||||
http_client = AsyncClient(timeout=5)
|
||||
|
||||
if auth_callback is None:
|
||||
auth_callback = self._default_auth_callback(aca_settings, credential)
|
||||
|
||||
# Convert lists to sets and filter out empty strings (which resolve to CWD)
|
||||
upload_dirs = {d for d in allowed_upload_directories if d} if allowed_upload_directories is not None else None
|
||||
download_dirs = (
|
||||
{d for d in allowed_download_directories if d} if allowed_download_directories is not None else None
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
pool_management_endpoint=aca_settings.pool_management_endpoint,
|
||||
settings=settings,
|
||||
auth_callback=auth_callback,
|
||||
http_client=http_client,
|
||||
enable_dangerous_file_uploads=enable_dangerous_file_uploads,
|
||||
allowed_upload_directories=upload_dirs,
|
||||
allowed_download_directories=download_dirs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# region Helper Methods
|
||||
def _default_auth_callback(
|
||||
self, aca_settings: ACASessionsSettings, credential: TokenCredential | None
|
||||
) -> Callable[..., Any | Awaitable[Any]]:
|
||||
"""Generates a default authentication callback using the ACA settings."""
|
||||
token = aca_settings.get_sessions_auth_token(credential=credential)
|
||||
|
||||
if token is None:
|
||||
raise FunctionInitializationError("Failed to retrieve the client auth token.")
|
||||
|
||||
def auth_callback() -> str:
|
||||
"""Retrieve the client auth token."""
|
||||
return token
|
||||
|
||||
return auth_callback
|
||||
|
||||
async def _ensure_auth_token(self) -> str:
|
||||
"""Ensure the auth token is valid and handle both sync and async callbacks."""
|
||||
try:
|
||||
if inspect.iscoroutinefunction(self.auth_callback):
|
||||
auth_token = await self.auth_callback()
|
||||
else:
|
||||
auth_token = self.auth_callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve the client auth token with message: {e!s}")
|
||||
raise FunctionExecutionException(f"Failed to retrieve the client auth token with message: {e!s}") from e
|
||||
|
||||
return auth_token
|
||||
|
||||
def _sanitize_input(self, code: str) -> str:
|
||||
"""Sanitize input to the python REPL.
|
||||
|
||||
Remove whitespace, backtick & python (if llm mistakes python console as terminal).
|
||||
|
||||
Args:
|
||||
code (str): The query to sanitize
|
||||
Returns:
|
||||
str: The sanitized query
|
||||
"""
|
||||
# Removes `, whitespace & python from start
|
||||
code = re.sub(r"^(\s|`)*(?i:python)?\s*", "", code)
|
||||
# Removes whitespace & ` from end
|
||||
return re.sub(r"(\s|`)*$", "", code)
|
||||
|
||||
def _construct_remote_file_path(self, remote_file_path: str) -> str:
|
||||
"""Construct the remote file path.
|
||||
|
||||
Args:
|
||||
remote_file_path (str): The remote file path.
|
||||
|
||||
Returns:
|
||||
str: The remote file path.
|
||||
"""
|
||||
if not remote_file_path.startswith("/mnt/data/"):
|
||||
remote_file_path = f"/mnt/data/{remote_file_path}"
|
||||
return remote_file_path
|
||||
|
||||
def _build_url_with_version(self, base_url, endpoint, params):
|
||||
"""Builds a URL with the provided base URL, endpoint, and query parameters."""
|
||||
params["api-version"] = SESSIONS_API_VERSION
|
||||
query_string = "&".join([f"{key}={value}" for key, value in params.items()])
|
||||
if not base_url.endswith("/"):
|
||||
base_url += "/"
|
||||
if endpoint.endswith("/"):
|
||||
endpoint = endpoint[:-1]
|
||||
return f"{base_url}{endpoint}?{query_string}"
|
||||
|
||||
def _validate_local_path_for_upload(self, local_file_path: str) -> str:
|
||||
"""Validate local path is within allowed upload directories.
|
||||
|
||||
Args:
|
||||
local_file_path: The path to validate.
|
||||
|
||||
Returns:
|
||||
str: The canonicalized absolute path.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If file operations are disabled or path is not within allowed directories.
|
||||
"""
|
||||
if not self.enable_dangerous_file_uploads:
|
||||
raise FunctionExecutionException(
|
||||
"File upload is disabled. Set 'enable_dangerous_file_uploads=True' "
|
||||
"and configure 'allowed_upload_directories' to enable."
|
||||
)
|
||||
|
||||
if self.allowed_upload_directories is None:
|
||||
raise FunctionExecutionException("File upload requires 'allowed_upload_directories' to be configured.")
|
||||
|
||||
canonical_path = os.path.realpath(local_file_path)
|
||||
|
||||
for allowed_dir in self.allowed_upload_directories:
|
||||
allowed_canonical = os.path.realpath(allowed_dir)
|
||||
try:
|
||||
common = os.path.commonpath([allowed_canonical, canonical_path])
|
||||
if common == allowed_canonical:
|
||||
return canonical_path
|
||||
except ValueError:
|
||||
continue # Different drives on Windows
|
||||
|
||||
logger.warning(f"Upload denied for path: {local_file_path} (resolved: {canonical_path})")
|
||||
raise FunctionExecutionException(
|
||||
f"Access denied: '{local_file_path}' is not within allowed upload directories."
|
||||
)
|
||||
|
||||
def _validate_local_path_for_download(self, local_file_path: str) -> str:
|
||||
"""Validate local path is within allowed download directories (optional protection).
|
||||
|
||||
Args:
|
||||
local_file_path: The path to validate.
|
||||
|
||||
Returns:
|
||||
str: The canonicalized absolute path.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If allowed_download_directories is set and path is not within.
|
||||
"""
|
||||
# Permissive by default - if no restrictions configured, allow all paths
|
||||
if self.allowed_download_directories is None:
|
||||
return os.path.realpath(local_file_path)
|
||||
|
||||
parent_dir = os.path.dirname(local_file_path) or "."
|
||||
canonical_parent = os.path.realpath(parent_dir)
|
||||
filename = os.path.basename(local_file_path)
|
||||
canonical_path = os.path.join(canonical_parent, filename)
|
||||
|
||||
for allowed_dir in self.allowed_download_directories:
|
||||
allowed_canonical = os.path.realpath(allowed_dir)
|
||||
try:
|
||||
common = os.path.commonpath([allowed_canonical, canonical_parent])
|
||||
if common == allowed_canonical:
|
||||
return canonical_path
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.warning(f"Download denied for path: {local_file_path}")
|
||||
raise FunctionExecutionException(
|
||||
f"Access denied: '{local_file_path}' is not within allowed download directories."
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Kernel Functions
|
||||
@kernel_function(
|
||||
description="""Executes the provided Python code.
|
||||
Start and end the code snippet with double quotes to define it as a string.
|
||||
Insert \\n within the string wherever a new line should appear.
|
||||
Add spaces directly after \\n sequences to replicate indentation.
|
||||
Use \" to include double quotes within the code without ending the string.
|
||||
Keep everything in a single line; the \\n sequences will represent line breaks
|
||||
when the string is processed or displayed.
|
||||
""",
|
||||
name="execute_code",
|
||||
)
|
||||
async def execute_code(self, code: Annotated[str, "The valid Python code to execute"]) -> str:
|
||||
"""Executes the provided Python code.
|
||||
|
||||
Args:
|
||||
code (str): The valid Python code to execute
|
||||
Returns:
|
||||
str: The result of the Python code execution in the form of Result, Stdout, and Stderr
|
||||
Raises:
|
||||
FunctionExecutionException: If the provided code is empty.
|
||||
"""
|
||||
if not code:
|
||||
raise FunctionExecutionException("The provided code is empty")
|
||||
|
||||
if self.settings.sanitize_input:
|
||||
code = self._sanitize_input(code)
|
||||
|
||||
auth_token = await self._ensure_auth_token()
|
||||
|
||||
logger.info(f"Executing Python code: {code}")
|
||||
|
||||
self.http_client.headers.update({
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
"Content-Type": "application/json",
|
||||
USER_AGENT: SESSIONS_USER_AGENT,
|
||||
})
|
||||
|
||||
self.settings.python_code = code
|
||||
|
||||
request_body = {
|
||||
"properties": self.settings.model_dump(exclude_none=True, exclude={"sanitize_input"}, by_alias=True),
|
||||
}
|
||||
|
||||
url = self._build_url_with_version(
|
||||
base_url=str(self.pool_management_endpoint),
|
||||
endpoint="code/execute/",
|
||||
params={"identifier": self.settings.session_id},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.post(
|
||||
url=url,
|
||||
json=request_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()["properties"]
|
||||
return (
|
||||
f"Status:\n{result['status']}\n"
|
||||
f"Result:\n{result['result']}\n"
|
||||
f"Stdout:\n{result['stdout']}\n"
|
||||
f"Stderr:\n{result['stderr']}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
error_message = e.response.text if e.response.text else e.response.reason_phrase
|
||||
raise FunctionExecutionException(
|
||||
f"Code execution failed with status code {e.response.status_code} and error: {error_message}"
|
||||
) from e
|
||||
|
||||
@kernel_function(name="upload_file", description="Uploads a file for the current Session ID")
|
||||
async def upload_file(
|
||||
self,
|
||||
*,
|
||||
local_file_path: Annotated[str, "The path to the local file on the machine"],
|
||||
remote_file_path: Annotated[
|
||||
str | None, "The remote path to the file in the session. Defaults to /mnt/data"
|
||||
] = None,
|
||||
) -> Annotated[SessionsRemoteFileMetadata, "The metadata of the uploaded file"]:
|
||||
"""Upload a file to the session pool.
|
||||
|
||||
Args:
|
||||
remote_file_path (str): The path to the file in the session.
|
||||
local_file_path (str): The path to the file on the local machine.
|
||||
Must be within allowed_upload_directories.
|
||||
|
||||
Returns:
|
||||
RemoteFileMetadata: The metadata of the uploaded file.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If local_file_path is not provided or not in allowed directories.
|
||||
"""
|
||||
if not local_file_path:
|
||||
raise FunctionExecutionException("Please provide a local file path to upload.")
|
||||
|
||||
# Validate path is in allowed directories (deny-by-default)
|
||||
validated_path = self._validate_local_path_for_upload(local_file_path)
|
||||
|
||||
remote_file_path = self._construct_remote_file_path(remote_file_path or os.path.basename(validated_path))
|
||||
|
||||
auth_token = await self._ensure_auth_token()
|
||||
self.http_client.headers.update({
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
USER_AGENT: SESSIONS_USER_AGENT,
|
||||
})
|
||||
|
||||
url = self._build_url_with_version(
|
||||
base_url=str(self.pool_management_endpoint),
|
||||
endpoint="files/upload",
|
||||
params={"identifier": self.settings.session_id},
|
||||
)
|
||||
|
||||
try:
|
||||
with open(validated_path, "rb") as data:
|
||||
files = {"file": (remote_file_path, data, "application/octet-stream")}
|
||||
response = await self.http_client.post(url=url, files=files)
|
||||
response.raise_for_status()
|
||||
uploaded_files = await self.list_files()
|
||||
return next(
|
||||
file_metadata for file_metadata in uploaded_files if file_metadata.full_path == remote_file_path
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
error_message = e.response.text if e.response.text else e.response.reason_phrase
|
||||
raise FunctionExecutionException(
|
||||
f"Upload failed with status code {e.response.status_code} and error: {error_message}"
|
||||
) from e
|
||||
|
||||
@kernel_function(name="list_files", description="Lists all files in the provided Session ID")
|
||||
async def list_files(self) -> list[SessionsRemoteFileMetadata]:
|
||||
"""List the files in the session pool.
|
||||
|
||||
Returns:
|
||||
list[SessionsRemoteFileMetadata]: The metadata for the files in the session pool
|
||||
"""
|
||||
auth_token = await self._ensure_auth_token()
|
||||
self.http_client.headers.update({
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
USER_AGENT: SESSIONS_USER_AGENT,
|
||||
})
|
||||
|
||||
url = self._build_url_with_version(
|
||||
base_url=str(self.pool_management_endpoint),
|
||||
endpoint="files",
|
||||
params={"identifier": self.settings.session_id},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(
|
||||
url=url,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
return [SessionsRemoteFileMetadata.from_dict(entry["properties"]) for entry in response_json["value"]]
|
||||
except HTTPStatusError as e:
|
||||
error_message = e.response.text if e.response.text else e.response.reason_phrase
|
||||
raise FunctionExecutionException(
|
||||
f"List files failed with status code {e.response.status_code} and error: {error_message}"
|
||||
) from e
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
*,
|
||||
remote_file_name: Annotated[str, "The name of the file to download, relative to /mnt/data"],
|
||||
local_file_path: Annotated[str | None, "The local file path to save the file to, optional"] = None,
|
||||
) -> Annotated[BytesIO | None, "The data of the downloaded file"]:
|
||||
"""Download a file from the session pool.
|
||||
|
||||
Args:
|
||||
remote_file_name: The name of the file to download, relative to `/mnt/data`.
|
||||
local_file_path: The path to save the downloaded file to. Should include the extension.
|
||||
If not provided, the file is returned as a BytesIO object.
|
||||
|
||||
Returns:
|
||||
BytesIO | None: The file content as BytesIO if no local_file_path provided, otherwise None.
|
||||
|
||||
Raises:
|
||||
FunctionExecutionException: If local_file_path is not in allowed directories.
|
||||
"""
|
||||
auth_token = await self._ensure_auth_token()
|
||||
self.http_client.headers.update({
|
||||
"Authorization": f"Bearer {auth_token}",
|
||||
USER_AGENT: SESSIONS_USER_AGENT,
|
||||
})
|
||||
|
||||
url = self._build_url_with_version(
|
||||
base_url=str(self.pool_management_endpoint),
|
||||
endpoint=f"files/content/{remote_file_name}",
|
||||
params={"identifier": self.settings.session_id},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.http_client.get(
|
||||
url=url,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if local_file_path:
|
||||
# Validate path is in allowed directories (optional, permissive by default)
|
||||
validated_path = self._validate_local_path_for_download(local_file_path)
|
||||
with open(validated_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return None
|
||||
|
||||
return BytesIO(response.content)
|
||||
except HTTPStatusError as e:
|
||||
error_message = e.response.text if e.response.text else e.response.reason_phrase
|
||||
raise FunctionExecutionException(
|
||||
f"Download failed with status code {e.response.status_code} and error: {error_message}"
|
||||
) from e
|
||||
# endregion
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from semantic_kernel.exceptions.function_exceptions import PluginInitializationError
|
||||
from semantic_kernel.kernel_pydantic import HttpsUrl, KernelBaseModel, KernelBaseSettings
|
||||
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
|
||||
|
||||
|
||||
class CodeInputType(str, Enum):
|
||||
"""Code input type."""
|
||||
|
||||
Inline = "inline"
|
||||
|
||||
|
||||
class CodeExecutionType(str, Enum):
|
||||
"""Code execution type."""
|
||||
|
||||
Synchronous = "synchronous"
|
||||
# Asynchronous = "asynchronous" TODO: Enable when available
|
||||
|
||||
|
||||
class SessionsPythonSettings(KernelBaseModel):
|
||||
"""The Sessions Python code interpreter settings."""
|
||||
|
||||
session_id: str | None = Field(default_factory=lambda: str(uuid.uuid4()), alias="identifier", exclude=True)
|
||||
code_input_type: CodeInputType | None = Field(default=CodeInputType.Inline, alias="codeInputType")
|
||||
execution_type: CodeExecutionType | None = Field(default=CodeExecutionType.Synchronous, alias="executionType")
|
||||
python_code: str | None = Field(alias="code", default=None)
|
||||
timeout_in_sec: int | None = Field(default=100, alias="timeoutInSeconds")
|
||||
sanitize_input: bool | None = Field(default=True, alias="sanitizeInput")
|
||||
|
||||
|
||||
class ACASessionsSettings(KernelBaseSettings):
|
||||
"""Azure Container Apps sessions settings.
|
||||
|
||||
Required:
|
||||
- pool_management_endpoint: HttpsUrl - The URL of the Azure Container Apps pool management endpoint.
|
||||
(Env var ACA_POOL_MANAGEMENT_ENDPOINT)
|
||||
"""
|
||||
|
||||
env_prefix: ClassVar[str] = "ACA_"
|
||||
|
||||
pool_management_endpoint: HttpsUrl
|
||||
token_endpoint: str = "https://acasessions.io/.default"
|
||||
|
||||
@field_validator("pool_management_endpoint", mode="before")
|
||||
@classmethod
|
||||
def _validate_endpoint(cls, endpoint: str) -> str:
|
||||
"""Validates the pool management endpoint."""
|
||||
if "python/execute" in endpoint:
|
||||
endpoint_parsed = urlsplit(endpoint.replace("python/execute", ""))._asdict()
|
||||
else:
|
||||
endpoint_parsed = urlsplit(endpoint)._asdict()
|
||||
if endpoint_parsed["path"]:
|
||||
endpoint_parsed["path"] = re.sub(r"/{2,}", "/", endpoint_parsed["path"])
|
||||
else:
|
||||
endpoint_parsed["path"] = "/"
|
||||
return str(urlunsplit(endpoint_parsed.values()))
|
||||
|
||||
def get_sessions_auth_token(
|
||||
self, credential: TokenCredential | None = None, token_endpoint: str | None = None
|
||||
) -> str | None:
|
||||
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with an Azure Container App.
|
||||
|
||||
The required role for the token is `Azure ContainerApps Session Executor and Contributor`.
|
||||
The token endpoint may be specified as an environment variable, via the .env
|
||||
file or as an argument. If the token endpoint is not provided, the default is None.
|
||||
The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
|
||||
|
||||
Args:
|
||||
credential: The credential to use for authentication.
|
||||
token_endpoint: The token endpoint to use. Defaults to `https://acasessions.io/.default`.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
|
||||
Raises:
|
||||
ServiceInitializationError: If the token endpoint is not provided.
|
||||
"""
|
||||
endpoint_to_use = token_endpoint or self.token_endpoint
|
||||
if endpoint_to_use is None:
|
||||
raise PluginInitializationError("Please provide a token endpoint to retrieve the authentication token.")
|
||||
if credential is None:
|
||||
raise PluginInitializationError("Please provide a credential to retrieve the authentication token.")
|
||||
return get_entra_auth_token(credential, endpoint_to_use)
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class SessionsRemoteFileMetadata(KernelBaseModel):
|
||||
"""Metadata for a file in the session."""
|
||||
|
||||
"""The filename relative to `/mnt/data/`."""
|
||||
filename: str
|
||||
|
||||
"""The size of the file in bytes."""
|
||||
size_in_bytes: int
|
||||
|
||||
@property
|
||||
def full_path(self) -> str:
|
||||
"""Get the full path of the file."""
|
||||
if not self.filename.startswith("/mnt/data/"):
|
||||
return f"/mnt/data/{self.filename}"
|
||||
return self.filename
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "SessionsRemoteFileMetadata":
|
||||
"""Create a SessionsRemoteFileMetadata object from a dictionary of file data values.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): The file data values.
|
||||
|
||||
Returns:
|
||||
SessionsRemoteFileMetadata: The metadata for the file.
|
||||
"""
|
||||
return SessionsRemoteFileMetadata(filename=data["filename"], size_in_bytes=data["size"])
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Annotated, Any, Final
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from semantic_kernel.memory.semantic_text_memory_base import SemanticTextMemoryBase
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from warnings import deprecated
|
||||
else:
|
||||
from typing_extensions import deprecated
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_COLLECTION: Final[str] = "generic"
|
||||
COLLECTION_PARAM: Final[str] = "collection"
|
||||
DEFAULT_RELEVANCE: Final[float] = 0.75
|
||||
RELEVANCE_PARAM: Final[str] = "relevance"
|
||||
DEFAULT_LIMIT: Final[int] = 1
|
||||
|
||||
|
||||
@deprecated(
|
||||
"This class is deprecated and will be removed in a future version. Use the new `collection.as_text_search` instead."
|
||||
)
|
||||
class TextMemoryPlugin(KernelBaseModel):
|
||||
"""A plugin to interact with a Semantic Text Memory."""
|
||||
|
||||
memory: SemanticTextMemoryBase
|
||||
embeddings_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def __init__(self, memory: SemanticTextMemoryBase, embeddings_kwargs: dict[str, Any] | None = None) -> None:
|
||||
"""Initialize a new instance of the TextMemoryPlugin.
|
||||
|
||||
Args:
|
||||
memory (SemanticTextMemoryBase): the underlying Semantic Text Memory to use
|
||||
embeddings_kwargs (Optional[Dict[str, Any]]): the keyword arguments to pass to the embedding generator
|
||||
"""
|
||||
super().__init__(memory=memory, embeddings_kwargs=embeddings_kwargs if embeddings_kwargs is not None else {})
|
||||
|
||||
@kernel_function(
|
||||
description="Recall a fact from the long term memory",
|
||||
name="recall",
|
||||
)
|
||||
async def recall(
|
||||
self,
|
||||
ask: Annotated[str, "The information to retrieve"],
|
||||
collection: Annotated[str, "The collection to search for information."] = DEFAULT_COLLECTION,
|
||||
relevance: Annotated[
|
||||
float, "The relevance score, from 0.0 to 1.0; 1.0 means perfect match"
|
||||
] = DEFAULT_RELEVANCE,
|
||||
limit: Annotated[int, "The maximum number of relevant memories to recall."] = DEFAULT_LIMIT,
|
||||
) -> str:
|
||||
"""Recall a fact from the long term memory.
|
||||
|
||||
Example:
|
||||
{{memory.recall $ask}} => "Paris"
|
||||
|
||||
Args:
|
||||
ask: The question to ask the memory
|
||||
collection: The collection to search for information
|
||||
relevance: The relevance score, from 0.0 to 1.0; 1.0 means perfect match
|
||||
limit: The maximum number of relevant memories to recall
|
||||
|
||||
Returns:
|
||||
The nearest item from the memory store as a string or empty string if not found.
|
||||
"""
|
||||
results = await self.memory.search(
|
||||
collection=collection,
|
||||
query=ask,
|
||||
limit=limit,
|
||||
min_relevance_score=relevance,
|
||||
)
|
||||
if results is None or len(results) == 0:
|
||||
logger.warning(f"Memory not found in collection: {collection}")
|
||||
return ""
|
||||
|
||||
return results[0].text if limit == 1 else json.dumps([r.text for r in results]) # type: ignore
|
||||
|
||||
@kernel_function(
|
||||
description="Save information to semantic memory",
|
||||
name="save",
|
||||
)
|
||||
async def save(
|
||||
self,
|
||||
text: Annotated[str, "The information to save."],
|
||||
key: Annotated[str, "The unique key to associate with the information."],
|
||||
collection: Annotated[str, "The collection to save the information."] = DEFAULT_COLLECTION,
|
||||
) -> None:
|
||||
"""Save a fact to the long term memory.
|
||||
|
||||
Args:
|
||||
text: The text to save to the memory
|
||||
kernel: The kernel instance, that has a memory store
|
||||
collection: The collection to save the information
|
||||
key: The unique key to associate with the information
|
||||
|
||||
"""
|
||||
await self.memory.save_information(
|
||||
collection=collection, text=text, id=key, embeddings_kwargs=self.embeddings_kwargs
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class TextPlugin(KernelBaseModel):
|
||||
"""TextPlugin provides a set of functions to manipulate strings.
|
||||
|
||||
Usage:
|
||||
kernel.add_plugin(TextPlugin(), plugin_name="text")
|
||||
|
||||
Examples:
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{text.trim $input}} => "hello world"
|
||||
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{text.trimStart $input}} => "hello world "
|
||||
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{text.trimEnd $input}} => " hello world"
|
||||
|
||||
KernelArguments["input"] = "hello world"
|
||||
{{text.uppercase $input}} => "HELLO WORLD"
|
||||
|
||||
KernelArguments["input"] = "HELLO WORLD"
|
||||
{{text.lowercase $input}} => "hello world"
|
||||
"""
|
||||
|
||||
@kernel_function(description="Trim whitespace from the start and end of a string.")
|
||||
def trim(self, input: str) -> str:
|
||||
"""Trim whitespace from the start and end of a string.
|
||||
|
||||
Example:
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{text.trim $input}} => "hello world"
|
||||
"""
|
||||
return input.strip()
|
||||
|
||||
@kernel_function(description="Trim whitespace from the start of a string.")
|
||||
def trim_start(self, input: str) -> str:
|
||||
"""Trim whitespace from the start of a string.
|
||||
|
||||
Example:
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{input.trim $input}} => "hello world "
|
||||
"""
|
||||
return input.lstrip()
|
||||
|
||||
@kernel_function(description="Trim whitespace from the end of a string.")
|
||||
def trim_end(self, input: str) -> str:
|
||||
"""Trim whitespace from the end of a string.
|
||||
|
||||
Example:
|
||||
KernelArguments["input"] = " hello world "
|
||||
{{input.trim $input}} => " hello world"
|
||||
"""
|
||||
return input.rstrip()
|
||||
|
||||
@kernel_function(description="Convert a string to uppercase.")
|
||||
def uppercase(self, input: str) -> str:
|
||||
"""Convert a string to uppercase.
|
||||
|
||||
Example:
|
||||
KernelArguments["input"] = "hello world"
|
||||
{{input.uppercase $input}} => "HELLO WORLD"
|
||||
"""
|
||||
return input.upper()
|
||||
|
||||
@kernel_function(description="Convert a string to lowercase.")
|
||||
def lowercase(self, input: str) -> str:
|
||||
"""Convert a string to lowercase.
|
||||
|
||||
Example:
|
||||
KernelArguments["input"] = "HELLO WORLD"
|
||||
{{input.lowercase $input}} => "hello world"
|
||||
"""
|
||||
return input.lower()
|
||||
@@ -0,0 +1,242 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import datetime
|
||||
|
||||
from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class TimePlugin(KernelBaseModel):
|
||||
"""TimePlugin provides a set of functions to get the current time and date.
|
||||
|
||||
Usage:
|
||||
kernel.add_plugin(TimePlugin(), plugin_name="time")
|
||||
|
||||
Examples:
|
||||
{{time.date}} => Sunday, 12 January, 2031
|
||||
{{time.today}} => Sunday, 12 January, 2031
|
||||
{{time.iso_date}} => 2031-01-12
|
||||
{{time.now}} => Sunday, January 12, 2031 9:15 PM
|
||||
{{time.utcNow}} => Sunday, January 13, 2031 5:15 AM
|
||||
{{time.time}} => 09:15:07 PM
|
||||
{{time.year}} => 2031
|
||||
{{time.month}} => January
|
||||
{{time.monthNumber}} => 01
|
||||
{{time.day}} => 12
|
||||
{{time.dayOfWeek}} => Sunday
|
||||
{{time.hour}} => 9 PM
|
||||
{{time.hourNumber}} => 21
|
||||
{{time.days_ago $days}} => Sunday, 7 May, 2023
|
||||
{{time.last_matching_day $dayName}} => Sunday, 7 May, 2023
|
||||
{{time.minute}} => 15
|
||||
{{time.minutes}} => 15
|
||||
{{time.second}} => 7
|
||||
{{time.seconds}} => 7
|
||||
{{time.timeZoneOffset}} => -0800
|
||||
{{time.timeZoneName}} => PST
|
||||
"""
|
||||
|
||||
@kernel_function(description="Get the current date.")
|
||||
def date(self) -> str:
|
||||
"""Get the current date.
|
||||
|
||||
Example:
|
||||
{{time.date}} => Sunday, 12 January, 2031
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%A, %d %B, %Y")
|
||||
|
||||
@kernel_function(description="Get the current date.")
|
||||
def today(self) -> str:
|
||||
"""Get the current date.
|
||||
|
||||
Example:
|
||||
{{time.today}} => Sunday, 12 January, 2031
|
||||
"""
|
||||
return self.date()
|
||||
|
||||
@kernel_function(description="Get the current date in iso format.")
|
||||
def iso_date(self) -> str:
|
||||
"""Get the current date in iso format.
|
||||
|
||||
Example:
|
||||
{{time.iso_date}} => 2031-01-12
|
||||
"""
|
||||
today = datetime.date.today()
|
||||
return today.isoformat()
|
||||
|
||||
@kernel_function(description="Get the current date and time in the local time zone")
|
||||
def now(self) -> str:
|
||||
"""Get the current date and time in the local time zone.
|
||||
|
||||
Example:
|
||||
{{time.now}} => Sunday, January 12, 2031 9:15 PM
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%A, %B %d, %Y %I:%M %p")
|
||||
|
||||
@kernel_function(description="Get the current date and time in UTC", name="utcNow")
|
||||
def utc_now(self) -> str:
|
||||
"""Get the current date and time in UTC.
|
||||
|
||||
Example:
|
||||
{{time.utcNow}} => Sunday, January 13, 2031 5:15 AM
|
||||
"""
|
||||
now = datetime.datetime.utcnow()
|
||||
return now.strftime("%A, %B %d, %Y %I:%M %p")
|
||||
|
||||
@kernel_function(description="Get the current time in the local time zone")
|
||||
def time(self) -> str:
|
||||
"""Get the current time in the local time zone.
|
||||
|
||||
Example:
|
||||
{{time.time}} => 09:15:07 PM
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%I:%M:%S %p")
|
||||
|
||||
@kernel_function(description="Get the current year")
|
||||
def year(self) -> str:
|
||||
"""Get the current year.
|
||||
|
||||
Example:
|
||||
{{time.year}} => 2031
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%Y")
|
||||
|
||||
@kernel_function(description="Get the current month")
|
||||
def month(self) -> str:
|
||||
"""Get the current month.
|
||||
|
||||
Example:
|
||||
{{time.month}} => January
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%B")
|
||||
|
||||
@kernel_function(description="Get the current month number")
|
||||
def month_number(self) -> str:
|
||||
"""Get the current month number.
|
||||
|
||||
Example:
|
||||
{{time.monthNumber}} => 01
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%m")
|
||||
|
||||
@kernel_function(description="Get the current day")
|
||||
def day(self) -> str:
|
||||
"""Get the current day of the month.
|
||||
|
||||
Example:
|
||||
{{time.day}} => 12
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%d")
|
||||
|
||||
@kernel_function(description="Get the current day of the week", name="dayOfWeek")
|
||||
def day_of_week(self) -> str:
|
||||
"""Get the current day of the week.
|
||||
|
||||
Example:
|
||||
{{time.dayOfWeek}} => Sunday
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%A")
|
||||
|
||||
@kernel_function(description="Get the current hour")
|
||||
def hour(self) -> str:
|
||||
"""Get the current hour.
|
||||
|
||||
Example:
|
||||
{{time.hour}} => 9 PM
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%I %p")
|
||||
|
||||
@kernel_function(description="Get the current hour number", name="hourNumber")
|
||||
def hour_number(self) -> str:
|
||||
"""Get the current hour number.
|
||||
|
||||
Example:
|
||||
{{time.hourNumber}} => 21
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%H")
|
||||
|
||||
@kernel_function(description="Get the current minute")
|
||||
def minute(self) -> str:
|
||||
"""Get the current minute.
|
||||
|
||||
Example:
|
||||
{{time.minute}} => 15
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%M")
|
||||
|
||||
@kernel_function(description="Get the date of offset from today by a provided number of days")
|
||||
def days_ago(self, days: str) -> str:
|
||||
"""Get the date a provided number of days in the past.
|
||||
|
||||
Args:
|
||||
days: The number of days to offset from today
|
||||
Returns:
|
||||
The date of the offset day.
|
||||
|
||||
Example:
|
||||
{{time.days_ago $input}} => Sunday, 7 May, 2023
|
||||
"""
|
||||
d = datetime.date.today() - datetime.timedelta(days=int(days))
|
||||
return d.strftime("%A, %d %B, %Y")
|
||||
|
||||
@kernel_function(description="""Get the date of the last day matching the supplied week day name in English.""")
|
||||
def date_matching_last_day_name(self, day_name: str) -> str:
|
||||
"""Get the date of the last day matching the supplied day name.
|
||||
|
||||
Args:
|
||||
day_name: The day name to match with.
|
||||
|
||||
Returns:
|
||||
The date of the matching day.
|
||||
|
||||
Example:
|
||||
{{time.date_matching_last_day_name $input}} => Sunday, 7 May, 2023
|
||||
"""
|
||||
d = datetime.date.today()
|
||||
for i in range(1, 8):
|
||||
d = d - datetime.timedelta(days=1)
|
||||
if d.strftime("%A") == day_name:
|
||||
return d.strftime("%A, %d %B, %Y")
|
||||
raise FunctionExecutionException("day_name is not recognized")
|
||||
|
||||
@kernel_function(description="Get the seconds on the current minute")
|
||||
def second(self) -> str:
|
||||
"""Get the seconds on the current minute.
|
||||
|
||||
Example:
|
||||
{{time.second}} => 7
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%S")
|
||||
|
||||
@kernel_function(description="Get the current time zone offset", name="timeZoneOffset")
|
||||
def time_zone_offset(self) -> str:
|
||||
"""Get the current time zone offset.
|
||||
|
||||
Example:
|
||||
{{time.timeZoneOffset}} => -08:00
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%z")
|
||||
|
||||
@kernel_function(description="Get the current time zone name", name="timeZoneName")
|
||||
def time_zone_name(self) -> str:
|
||||
"""Get the current time zone name.
|
||||
|
||||
Example:
|
||||
{{time.timeZoneName}} => PST
|
||||
"""
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%Z")
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
from semantic_kernel.exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
class WaitPlugin(KernelBaseModel):
|
||||
"""WaitPlugin provides a set of functions to wait for a certain amount of time.
|
||||
|
||||
Usage:
|
||||
kernel.add_plugin(WaitPlugin(), plugin_name="wait")
|
||||
|
||||
Examples:
|
||||
{{wait.wait 5}} => Wait for 5 seconds
|
||||
"""
|
||||
|
||||
@kernel_function
|
||||
async def wait(self, input: Annotated[float | str, "The number of seconds to wait, can be str or float."]) -> None:
|
||||
"""Wait for a certain number of seconds."""
|
||||
if isinstance(input, str):
|
||||
try:
|
||||
input = float(input)
|
||||
except ValueError as exc:
|
||||
raise FunctionExecutionException("seconds text must be a number") from exc
|
||||
await asyncio.sleep(abs(input))
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.connectors.search_engine.connector import ConnectorBase
|
||||
|
||||
|
||||
class WebSearchEnginePlugin:
|
||||
"""A plugin that provides web search engine functionality.
|
||||
|
||||
Usage:
|
||||
connector = BingConnector(bing_search_api_key)
|
||||
kernel.add_plugin(WebSearchEnginePlugin(connector), plugin_name="WebSearch")
|
||||
|
||||
Examples:
|
||||
{{WebSearch.search "What is semantic kernel?"}}
|
||||
=> Returns the first `num_results` number of results for the given search query
|
||||
and ignores the first `offset` number of results.
|
||||
"""
|
||||
|
||||
_connector: "ConnectorBase"
|
||||
|
||||
def __init__(self, connector: "ConnectorBase") -> None:
|
||||
"""Initializes a new instance of the WebSearchEnginePlugin class."""
|
||||
self._connector = connector
|
||||
|
||||
@kernel_function(name="search", description="Performs a web search for a given query")
|
||||
async def search(
|
||||
self,
|
||||
query: Annotated[str, "The search query"],
|
||||
num_results: Annotated[int, "The number of search results to return"] = 1,
|
||||
offset: Annotated[int, "The number of search results to skip"] = 0,
|
||||
) -> list[str]:
|
||||
"""Returns the search results of the query provided."""
|
||||
return await self._connector.search(query, num_results, offset)
|
||||
Reference in New Issue
Block a user