chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import AGENT_TYPE_REGISTRY, AgentRegistry, DeclarativeSpecMixin, register_agent_type
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
from semantic_kernel.agents import Agent
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
|
||||
|
||||
class MockChatHistory:
|
||||
"""Minimal mock for ChatHistory to hold messages."""
|
||||
|
||||
def __init__(self, messages=None):
|
||||
self.messages = messages if messages is not None else []
|
||||
|
||||
|
||||
class MockChannel(AgentChannel):
|
||||
"""Mock channel for testing get_channel_keys and create_channel."""
|
||||
|
||||
|
||||
class MockAgent(Agent):
|
||||
"""A mock agent for testing purposes."""
|
||||
|
||||
channel_type: ClassVar[type[AgentChannel]] = MockChannel
|
||||
|
||||
def __init__(self, name: str = "Test-Agent", description: str = "A test agent", id: str = None):
|
||||
args = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
if id is not None:
|
||||
args["id"] = id
|
||||
super().__init__(**args)
|
||||
|
||||
async def create_channel(self) -> AgentChannel:
|
||||
return AsyncMock(spec=AgentChannel)
|
||||
|
||||
@override
|
||||
async def get_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
async def invoke(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
async def invoke_stream(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockAgentWithoutChannelType(MockAgent):
|
||||
channel_type = None
|
||||
|
||||
|
||||
async def test_agent_initialization():
|
||||
name = "TestAgent"
|
||||
description = "A test agent"
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent = MockAgent(name=name, description=description, id=id_value)
|
||||
|
||||
assert agent.name == name
|
||||
assert agent.description == description
|
||||
assert agent.id == id_value
|
||||
|
||||
|
||||
async def test_agent_default_id():
|
||||
agent = MockAgent()
|
||||
|
||||
assert agent.id is not None
|
||||
assert isinstance(uuid.UUID(agent.id), uuid.UUID)
|
||||
|
||||
|
||||
def test_get_channel_keys():
|
||||
agent = MockAgent()
|
||||
keys = agent.get_channel_keys()
|
||||
|
||||
assert len(list(keys)) == 1, "Should return a single key"
|
||||
|
||||
|
||||
async def test_create_channel():
|
||||
agent = MockAgent()
|
||||
channel = await agent.create_channel()
|
||||
|
||||
assert isinstance(channel, AgentChannel)
|
||||
|
||||
|
||||
async def test_agent_equality():
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent1 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
agent2 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
|
||||
assert agent1 == agent2
|
||||
|
||||
agent3 = MockAgent(name="TestAgent", description="A different description", id=id_value)
|
||||
assert agent1 != agent3
|
||||
|
||||
agent4 = MockAgent(name="AnotherAgent", description="A test agent", id=id_value)
|
||||
assert agent1 != agent4
|
||||
|
||||
|
||||
async def test_agent_equality_different_type():
|
||||
agent = MockAgent(name="TestAgent", description="A test agent", id=str(uuid.uuid4()))
|
||||
non_agent = "Not an agent"
|
||||
|
||||
assert agent != non_agent
|
||||
|
||||
|
||||
async def test_agent_hash():
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent1 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
agent2 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
|
||||
assert hash(agent1) == hash(agent2)
|
||||
|
||||
agent3 = MockAgent(name="TestAgent", description="A different description", id=id_value)
|
||||
assert hash(agent1) != hash(agent3)
|
||||
|
||||
|
||||
def test_get_channel_keys_no_channel_type():
|
||||
agent = MockAgentWithoutChannelType()
|
||||
with pytest.raises(NotImplementedError):
|
||||
list(agent.get_channel_keys())
|
||||
|
||||
|
||||
def test_merge_arguments_both_none():
|
||||
agent = MockAgent()
|
||||
merged = agent._merge_arguments(None)
|
||||
assert isinstance(merged, KernelArguments)
|
||||
assert len(merged) == 0, "If both arguments are None, should return an empty KernelArguments object"
|
||||
|
||||
|
||||
def test_merge_arguments_agent_none_override_not_none():
|
||||
agent = MockAgent()
|
||||
override = KernelArguments(settings={"key": "override"}, param1="val1")
|
||||
|
||||
merged = agent._merge_arguments(override)
|
||||
assert merged is override, "If agent.arguments is None, just return override_args"
|
||||
|
||||
|
||||
def test_merge_arguments_override_none_agent_not_none():
|
||||
agent = MockAgent()
|
||||
agent.arguments = KernelArguments(settings={"key": "base"}, param1="baseVal")
|
||||
|
||||
merged = agent._merge_arguments(None)
|
||||
assert merged is agent.arguments, "If override_args is None, should return the agent's arguments"
|
||||
|
||||
|
||||
def test_merge_arguments_both_not_none():
|
||||
agent = MockAgent()
|
||||
agent.arguments = KernelArguments(settings={"key1": "val1", "common": "base"}, param1="baseVal")
|
||||
override = KernelArguments(settings={"key2": "override_val", "common": "override"}, param2="override_param")
|
||||
|
||||
merged = agent._merge_arguments(override)
|
||||
|
||||
assert merged.execution_settings["key1"] == "val1", "Should retain original setting from agent"
|
||||
assert merged.execution_settings["key2"] == "override_val", "Should include new setting from override"
|
||||
assert merged.execution_settings["common"] == "override", "Override should take precedence"
|
||||
|
||||
assert merged["param1"] == "baseVal", "Should retain base param from agent"
|
||||
assert merged["param2"] == "override_param", "Should include param from override"
|
||||
|
||||
|
||||
# region Declarative Spec tests
|
||||
|
||||
|
||||
class DummyPlugin:
|
||||
def __init__(self):
|
||||
self.functions = {"dummy_function": lambda: "result"}
|
||||
|
||||
def get(self, name):
|
||||
return self.functions.get(name)
|
||||
|
||||
|
||||
class DummyAgentSettings:
|
||||
azure_ai_search_connection_id = "test-conn-id"
|
||||
azure_ai_search_index_name = "test-index"
|
||||
|
||||
|
||||
class DummyKernel:
|
||||
def __init__(self):
|
||||
self.plugins = {}
|
||||
|
||||
def add_plugin(self, plugin):
|
||||
name = plugin.__class__.__name__
|
||||
self.plugins[name] = plugin
|
||||
|
||||
|
||||
async def test_resolve_placeholders_with_short_and_long_keys():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def resolve_placeholders(cls, yaml_str, settings=None, extras=None):
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"\$\{([^}]+)\}")
|
||||
field_mapping = {
|
||||
"AzureAISearchConnectionId": "conn-123",
|
||||
"AzureAI:AzureAISearchConnectionId": "conn-123-override",
|
||||
}
|
||||
if extras:
|
||||
field_mapping.update(extras)
|
||||
|
||||
def replacer(match):
|
||||
full_key = match.group(1)
|
||||
section, _, key = full_key.partition(":")
|
||||
if section != "AzureAI":
|
||||
return match.group(0)
|
||||
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
|
||||
|
||||
return pattern.sub(replacer, yaml_str)
|
||||
|
||||
spec = "connection: ${AzureAI:AzureAISearchConnectionId}"
|
||||
resolved = DummyDeclarativeSpec.resolve_placeholders(spec)
|
||||
assert resolved == "connection: conn-123"
|
||||
|
||||
|
||||
async def test_validate_tools_succeeds_with_valid_plugin():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def _validate_tools(cls, tools_list, kernel):
|
||||
for tool in tools_list:
|
||||
tool_id = tool.get("id")
|
||||
if not tool_id or tool.get("type") != "function":
|
||||
continue
|
||||
plugin_name, function_name = tool_id.split(".")
|
||||
plugin = kernel.plugins.get(plugin_name)
|
||||
if not plugin:
|
||||
raise ValueError(f"Plugin {plugin_name} missing")
|
||||
if function_name not in plugin.functions:
|
||||
raise ValueError(f"Function {function_name} missing in plugin {plugin_name}")
|
||||
|
||||
kernel = DummyKernel()
|
||||
plugin = DummyPlugin()
|
||||
kernel.add_plugin(plugin)
|
||||
|
||||
DummyDeclarativeSpec._validate_tools([{"id": "DummyPlugin.dummy_function", "type": "function"}], kernel)
|
||||
|
||||
|
||||
async def test_validate_tools_raises_on_missing_plugin():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def _validate_tools(cls, tools_list, kernel):
|
||||
for tool in tools_list:
|
||||
tool_id = tool.get("id")
|
||||
if not tool_id or tool.get("type") != "function":
|
||||
continue
|
||||
plugin_name, function_name = tool_id.split(".")
|
||||
plugin = kernel.plugins.get(plugin_name)
|
||||
if not plugin:
|
||||
raise ValueError(f"Plugin {plugin_name} missing")
|
||||
if function_name not in plugin.functions:
|
||||
raise ValueError(f"Function {function_name} missing in plugin {plugin_name}")
|
||||
|
||||
kernel = DummyKernel()
|
||||
|
||||
with pytest.raises(ValueError, match="Plugin DummyPlugin missing"):
|
||||
DummyDeclarativeSpec._validate_tools([{"id": "DummyPlugin.dummy_function", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_normalize_spec_fields_creates_kernel_and_extracts_fields():
|
||||
data = {
|
||||
"name": "TestAgent",
|
||||
"description": "An agent",
|
||||
"instructions": "Use this.",
|
||||
"model": {"options": {"temperature": 0.7}},
|
||||
}
|
||||
|
||||
fields, kernel = DeclarativeSpecMixin._normalize_spec_fields(data)
|
||||
assert isinstance(kernel, Kernel)
|
||||
assert fields["name"] == "TestAgent"
|
||||
assert isinstance(fields["arguments"], KernelArguments)
|
||||
|
||||
|
||||
def test_normalize_spec_fields_adds_plugins_to_kernel():
|
||||
plugin = DummyPlugin()
|
||||
data = {"name": "PluginAgent"}
|
||||
|
||||
_, kernel = DeclarativeSpecMixin._normalize_spec_fields(data, plugins=[plugin])
|
||||
assert "DummyPlugin" in kernel.plugins
|
||||
|
||||
|
||||
def test_normalize_spec_fields_parses_prompt_template_and_overwrites_instructions():
|
||||
data = {"name": "T", "prompt_template": {"template": "new instructions", "template_format": "semantic-kernel"}}
|
||||
|
||||
fields, _ = DeclarativeSpecMixin._normalize_spec_fields(data)
|
||||
assert fields["instructions"] == "new instructions"
|
||||
|
||||
|
||||
def test_validate_tools_success(custom_plugin_class):
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(custom_plugin_class())
|
||||
tools_list = [{"id": "CustomPlugin.getLightStatus", "type": "function"}]
|
||||
|
||||
DeclarativeSpecMixin._validate_tools(tools_list, kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_invalid_format():
|
||||
kernel = Kernel()
|
||||
with pytest.raises(AgentInitializationException, match="format"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "badformat", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_missing_plugin():
|
||||
kernel = Kernel()
|
||||
with pytest.raises(AgentInitializationException, match="not found in kernel"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "MissingPlugin.foo", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_missing_function():
|
||||
plugin = DummyPlugin()
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(plugin)
|
||||
with pytest.raises(AgentInitializationException, match="not found in plugin"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "DummyPlugin.bar", "type": "function"}], kernel)
|
||||
|
||||
|
||||
@register_agent_type("test_agent")
|
||||
class TestAgent(DeclarativeSpecMixin, Agent):
|
||||
@classmethod
|
||||
def resolve_placeholders(cls, yaml_str, settings=None, extras=None):
|
||||
return yaml_str
|
||||
|
||||
@classmethod
|
||||
async def _from_dict(cls, data, kernel, **kwargs):
|
||||
return cls(
|
||||
name=data.get("name"),
|
||||
description=data.get("description"),
|
||||
instructions=data.get("instructions"),
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
async def get_response(self, messages, instructions_override=None):
|
||||
return "Test response"
|
||||
|
||||
async def invoke(self, messages, **kwargs):
|
||||
return "invoke result"
|
||||
|
||||
async def invoke_stream(self, messages, **kwargs):
|
||||
yield "streamed result"
|
||||
|
||||
|
||||
async def test_register_type_and_create_from_yaml_success():
|
||||
yaml_str = """
|
||||
type: test_agent
|
||||
name: TestAgent
|
||||
"""
|
||||
agent = await AgentRegistry.create_from_yaml(yaml_str)
|
||||
assert agent.__class__.__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_yaml_missing_type():
|
||||
yaml_str = """
|
||||
name: InvalidAgent
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_yaml(yaml_str)
|
||||
|
||||
|
||||
async def test_create_from_yaml_unregistered_type():
|
||||
yaml_str = """
|
||||
type: nonexistent_agent
|
||||
"""
|
||||
# Ensure unregistered
|
||||
AGENT_TYPE_REGISTRY.pop("nonexistent_agent", None)
|
||||
with pytest.raises(AgentInitializationException, match="not registered"):
|
||||
await AgentRegistry.create_from_yaml(yaml_str)
|
||||
|
||||
|
||||
async def test_create_from_dict_success(test_agent_cls):
|
||||
data = {"type": "test_agent", "name": "FromDictAgent"}
|
||||
agent: TestAgent = await AgentRegistry.create_from_dict(data)
|
||||
assert agent.name == "FromDictAgent"
|
||||
assert type(agent).__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_dict_missing_type():
|
||||
data = {"name": "NoType"}
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_create_from_dict_type_not_supported():
|
||||
AGENT_TYPE_REGISTRY.pop("unknown", None)
|
||||
data = {"type": "unknown"}
|
||||
with pytest.raises(AgentInitializationException, match="not supported"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_create_from_file_reads_and_creates(tmp_path, test_agent_cls):
|
||||
file_path = tmp_path / "spec.yaml"
|
||||
file_path.write_text("type: test_agent\nname: FileAgent\n", encoding="utf-8")
|
||||
|
||||
agent: TestAgent = await AgentRegistry.create_from_file(str(file_path))
|
||||
assert agent.name == "FileAgent"
|
||||
assert type(agent).__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_file_raises_on_bad_path():
|
||||
with pytest.raises(AgentInitializationException, match="Failed to read agent spec file"):
|
||||
await AgentRegistry.create_from_file("/nonexistent/path/spec.yaml")
|
||||
|
||||
|
||||
# endregion
|
||||
Reference in New Issue
Block a user